Compare commits

...
94 Commits
Author SHA1 Message Date
HampusandGitHub dcc3273889 feat(app): ask everyone to review who can message them (#3323) 2026-10-08 20:47:44 +02:00
HampusandGitHub e9a4f33a7e feat(api): track privacy setup and open community DMs by default (#3322) 2026-10-08 20:47:22 +02:00
HampusandGitHub ae820ba4ea chore(i18n): refresh catalog references (#3320) 2026-10-08 20:34:03 +02:00
HampusandGitHub 94f1239050 fix(premium): reflect limit config flags in plan comparisons (#3319) 2026-10-08 20:33:20 +02:00
HampusandGitHub acc1392a53 fix(ui): reopen a popout clicked while it is closing (#3318) 2026-10-08 20:33:04 +02:00
HampusandGitHub a6a9789091 fix(ui): stop clicks passing through the user area footer (#3317) 2026-10-08 20:32:49 +02:00
HampusandGitHub fdb7410976 fix(threads): light up joined threads in muted communities (#3316) 2026-10-08 20:32:33 +02:00
HampusandGitHub b146b8ee33 fix(desktop): trust locally added CAs in Chromium on Linux (#3315) 2026-10-08 20:21:04 +02:00
HampusandGitHub 4b1afc953f fix(app): redraw the favicon badge when branding changes (#3314) 2026-10-08 20:20:49 +02:00
HampusandGitHub 4ecbe9603c fix(api): store branding images as media references (#3313) 2026-10-08 20:20:34 +02:00
HampusandGitHub 847d449882 chore(i18n): refresh catalog references (#3312) 2026-10-08 20:06:41 +02:00
HampusandGitHub bef20cba85 fix(links): warn on unhandled middle clicks and desktop links (#3311) 2026-10-08 20:05:54 +02:00
JiraliteandGitHub ebf91ccc76 fix(links): warn on embed link middle clicks (#3305) 2026-10-08 20:05:27 +02:00
HampusandGitHub abfaff16c2 fix(guild): fit text icon initials to the icon by measured width (#3310) 2026-10-08 20:05:09 +02:00
HampusandGitHub 89fa895a0a fix(forum): keep closed posts visible without a reload (#3309) 2026-10-08 20:04:53 +02:00
HampusandGitHub 334fef52e1 fix(desktop): keep slow connects racing instead of aborting them (#3308) 2026-10-08 20:04:39 +02:00
HampusandGitHub aa42bf13ce fix(threads): announce threads above the latest five messages (#3307) 2026-10-08 20:04:23 +02:00
HampusandGitHub c486c2d3d0 revert(admin): restore user type toggles (#3306) 2026-10-08 20:04:01 +02:00
HampusandGitHub 0ec1cee99e test(gateway): keep the recheck timer out of the eunit process (#3304) 2026-10-08 18:14:47 +02:00
HampusandGitHub ccbe4857a1 docs(auth): document the desktop handoff deep link and deny (#3303) 2026-10-08 18:00:17 +02:00
HampusandGitHub 7f8b0afedb chore(i18n): refresh catalog references (#3302) 2026-10-08 17:58:50 +02:00
JiraliteandGitHub e94b7d7ad8 fix(threads): add a debug thread button (#3276) 2026-10-08 17:57:47 +02:00
HampusandGitHub 27baa38fd1 fix(threads): open the thread menu on browser right-click (#3301) 2026-10-08 17:45:33 +02:00
HampusandGitHub 4af10dd3e3 fix(threads): keep header actions clear of the channel name (#3300) 2026-10-08 17:44:48 +02:00
HampusandGitHub 269d33cab2 fix(threads): confirm before leaving a private thread (#3299) 2026-10-08 17:44:00 +02:00
HampusandGitHub 170e12595b fix(threads): let the thread members popout scroll (#3298) 2026-10-08 17:42:56 +02:00
HampusandGitHub b82bbaa2dd fix(ui): drop the switch label tab stop with no focus ring (#3297) 2026-10-08 17:42:11 +02:00
HampusandGitHub b8f3dbddbf fix(threads): keep Tab and focus inside the thread create pane (#3296) 2026-10-08 17:41:14 +02:00
HampusandGitHub 663572fd10 fix(forum): keep attachments on forum and media posts (#3295) 2026-10-08 17:40:29 +02:00
HampusandGitHub 5033cf7203 fix(app): follow theme library changes made in other windows (#3294) 2026-10-08 17:39:34 +02:00
HampusandGitHub 0fcab2f8f0 fix(app): hold the switched view until its route commits (#3293) 2026-10-08 17:38:36 +02:00
HampusandGitHub c3d790f664 fix(app): refocus the editor before text context menu actions (#3292) 2026-10-08 17:37:40 +02:00
HampusandGitHub 0906a85d6f fix(app): keep mature content consent across account switches (#3291) 2026-10-08 17:36:55 +02:00
HampusandGitHub 4da8dec4ac fix(app): ignore leftover mentions in unseen channels (#3290) 2026-10-08 17:35:59 +02:00
HampusandGitHub d6e3254a16 feat(app): show instance branding in switchers and pickers (#3289) 2026-10-08 17:34:56 +02:00
HampusandGitHub 6dc698ff5e fix(app): follow instance branding in tab title and favicon (#3288) 2026-10-08 17:33:28 +02:00
HampusandGitHub 39e48458da fix(app): keep a global define from breaking bundled modules (#3287) 2026-10-08 17:32:40 +02:00
HampusandGitHub c5453f83e8 feat(desktop): sign in with the browser without typing a code (#3286) 2026-10-08 17:31:45 +02:00
HampusandGitHub 510cc1b916 fix(desktop): show the text context menu on password fields (#3285) 2026-10-08 17:30:11 +02:00
HampusandGitHub e7859fefb1 fix(desktop): leave full screen before hiding on macOS close (#3284) 2026-10-08 17:28:58 +02:00
HampusandGitHub 46a356cc40 fix(media): keep self-host media loading after restarts (#3283) 2026-10-08 17:28:18 +02:00
HampusandGitHub bd56174870 fix(desktop): frame streamed request bodies for every method (#3282) 2026-10-08 17:26:41 +02:00
HampusandGitHub 965bb5f634 fix(desktop): trust the system CA store in main requests (#3281) 2026-10-08 17:26:12 +02:00
HampusandGitHub 7937d5f802 fix(desktop): update from the splash only after the user clicks (#3280) 2026-10-08 17:25:34 +02:00
HampusandGitHub f1373a63c3 feat(ci): publish renderer modules without a shell build (#3279) 2026-10-08 17:23:59 +02:00
HampusandGitHub e0d7625c39 feat(api): hand desktop sign-in back with a deep link grant (#3278) 2026-10-08 17:23:28 +02:00
HampusandGitHub f8505d19f7 feat(threads): ship threads and the Plutonium page to everyone (#3277) 2026-10-08 16:30:39 +02:00
HampusandGitHub 1515a8487f fix(desktop): fall back across resolved addresses on connect (#3275) 2026-10-08 05:08:00 +02:00
HampusandGitHub 7a4aa42d4b feat(premium): native Nordic prices and subscribing during grace (#3274) 2026-10-08 04:21:54 +02:00
HampusandGitHub 0ef1cd14c3 feat(desktop): show download progress and diagnostics on splash (#3273) 2026-10-08 03:06:06 +02:00
HampusandGitHub 795e190bda fix(desktop): let the Theme Studio popout resolve its runtime (#3272) 2026-10-08 03:05:37 +02:00
HampusandGitHub d00389ab30 fix(desktop): reach split-horizon instances on their LAN IP (#3271) 2026-10-08 03:05:15 +02:00
HampusandGitHub 3071bc7525 fix(desktop): honour the system proxy in main-process requests (#3270) 2026-10-08 03:04:55 +02:00
HampusandGitHub 6de6be2358 fix(threads): open the thread menu on thread chip right-click (#3269) 2026-10-08 03:04:35 +02:00
HampusandGitHub c51c222c47 fix(voice): stop stale mute echoes from toggling mute (#3268) 2026-10-08 03:04:10 +02:00
HampusandGitHub e218ba21de fix(app): load saved account avatars from their own instance (#3267) 2026-10-08 03:03:47 +02:00
HampusandGitHub de358c0def fix(app): let action toolbar overrides win over its defaults (#3266) 2026-10-08 03:03:26 +02:00
HampusandGitHub 4b88d64b2d fix(app): show the official badge and a visible account menu (#3265) 2026-10-08 03:03:05 +02:00
HampusandGitHub ee1c861eb2 perf(desktop): cache display media and retain shown images (#3264) 2026-10-08 03:02:41 +02:00
HampusandGitHub 6a24ac3f4e fix(desktop): fetch media bytes via the local resource proxy (#3263) 2026-10-08 03:02:15 +02:00
HampusandGitHub 8b312c610e fix(app): tag only desktop proxy uploads with a local upload id (#3262) 2026-10-08 00:40:51 +02:00
HampusandGitHub 2e3e3a1536 fix(desktop): fix manual update wording and retry Velopack (#3261) 2026-10-08 00:04:58 +02:00
HampusandGitHub 5c63d81ffd fix(app): show the setup wizard on new instances, not sign-in (#3260) 2026-10-07 23:28:04 +02:00
HampusandGitHub c402c52a8a fix(app): read instance config safely in startup skeletons (#3259) 2026-10-07 23:27:59 +02:00
HampusandGitHub 317fedeef9 fix(ci): resolve a bare drive WORKDIR to the drive root (#3258) 2026-10-07 21:44:18 +02:00
HampusandGitHub 002502a7fa feat(admin): one threads toggle, hide hosted-only rollouts (#3257) 2026-10-07 21:32:45 +02:00
HampusandGitHub 1997850d55 fix(threads): let administrators grant thread permissions (#3256) 2026-10-07 21:32:39 +02:00
HampusandGitHub d346eb0e88 fix(desktop): generate build channel in tests, bump yoke-derive (#3255) 2026-10-07 19:57:20 +02:00
HampusandGitHub 7c5fa2180a feat(desktop): bundled renderer, modules and instance accounts (#3254) 2026-10-07 16:48:07 +02:00
HampusandGitHub c748c8af4e feat(channels): redesign the create channel modal (#3253) 2026-10-07 15:26:11 +02:00
HampusandGitHub ba59a13149 feat(threads): thread and forum UI on web (#3252) 2026-10-07 15:25:43 +02:00
HampusandGitHub 3f4160b138 feat(threads): thread and forum API, admin, schemas and docs (#3251) 2026-10-07 15:25:12 +02:00
HampusandGitHub 5f4295e399 feat(threads): gateway, messages and push thread support (#3250) 2026-10-07 15:24:40 +02:00
HampusandGitHub 4e730832c7 fix(installer): pull images before the first start (#3248) 2026-10-07 02:37:35 +02:00
HampusandGitHub d7c00d4556 fix(schema): keep template topics optional after trimming (#3247) 2026-10-07 01:42:37 +02:00
HampusandGitHub 154b65afe5 docs(self-hosting): fix LiveKit CSP and backup guidance (#3246) 2026-10-07 00:09:39 +02:00
HampusandGitHub fcc2a3f64b docs(github): keep vulnerability reports out of chats (#3245) 2026-10-06 23:25:53 +02:00
HampusandGitHub 80456861ac fix(api): accept long forum topics in imported templates (#3244) 2026-10-06 22:46:47 +02:00
0c4f016ba2 feat(config): read secrets from NAME_FILE variables (#1421)
Co-authored-by: Hampus <[email protected]>
2026-10-06 21:43:08 +02:00
HampusandGitHub cc5545c333 fix(api): sync stripe customer email on change (#3243) 2026-10-06 21:38:25 +02:00
HampusandGitHub 6e28092cdc fix(app): keep mention highlight when mentions are suppressed (#3242) 2026-10-06 20:58:28 +02:00
HampusandGitHub 8b6910d505 chore(github): send bug reports and ideas to feedback.fluxer.com (#3241) 2026-10-06 19:14:26 +02:00
HampusandGitHub d87e31efaf fix(app): drop the reply when its target message is deleted (#3237) 2026-10-06 02:14:00 +02:00
HampusandGitHub 6618a6baf4 fix(installer): replace a stale installer before upgrading (#3235) 2026-10-05 21:50:16 +02:00
HampusandGitHub 22b8f5454b fix(app): skip forwarded messages when editing with arrow up (#3234) 2026-10-05 21:34:05 +02:00
HampusandGitHub 801bd3f106 fix(app): cycle dms in sidebar order with the keyboard (#3233) 2026-10-05 21:17:40 +02:00
HampusandGitHub e26c8c870d feat(api): archive and schedule automated message deletion (#3231) 2026-10-05 21:03:29 +02:00
HampusandGitHub f4e545e090 fix(app): reorder dm list immediately on pin and unpin (#3230) 2026-10-05 20:45:50 +02:00
HampusandGitHub 2006fc0d8d feat(push): allow listed hosts to resolve to private addresses (#3228) 2026-10-05 20:11:08 +02:00
HampusandGitHub d456048e69 fix(svc): respect FLUXER_POSTGRES_SSL=false with a Postgres URL (#3227) 2026-10-05 20:00:50 +02:00
HampusandGitHub fd35b4da24 fix(api): stop counting one refund twice against the allowance (#3226) 2026-10-05 17:41:28 +02:00
HampusandGitHub 283d179b05 fix(app): strip youtube is= share tracking param (#3225) 2026-10-05 17:33:35 +02:00
HampusandGitHub 3093e7334b feat(api): derive stable placeholder names for hidden profiles (#3224) 2026-10-05 16:57:25 +02:00
HampusandGitHub 02c82f0038 fix(api): limit report auto-resolution on scheduled deletion (#3221) 2026-10-05 14:16:08 +02:00
2286 changed files with 360437 additions and 110759 deletions
+2
View File
@@ -50,6 +50,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
+9 -9
View File
@@ -1,24 +1,24 @@
# Contributing to Fluxer
This policy applies to all issues, discussions, commits and pull requests.
This policy applies to all commits and pull requests.
## Scope
To prevent spam, only approved contributors may submit pull requests.
To request approval, comment on an existing issue and ask to implement it. For work that extends beyond a defect fix, open a [discussion](https://github.com/orgs/fluxerapp/discussions) first.
To request approval, comment on the [feedback.fluxer.com](https://feedback.fluxer.com) post you want to implement and ask to work on it. For work that extends beyond a defect fix, post a feature request there first.
Every pull request must:
- Target the repository's default branch.
- Include a closing reference for each repository issue it resolves.
- Link each feedback.fluxer.com post it resolves.
- Receive approval from a maintainer before it is merged.
Place each closing reference on a separate line:
Place each link on a separate line:
```text
Closes #123
Closes #456
Resolves https://feedback.fluxer.com/p/123
Resolves https://feedback.fluxer.com/p/456
```
## Authorship
@@ -78,11 +78,11 @@ Complete every section of the pull request template. Clearly describe:
## Reports and other contributions
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report bugs and request features at [feedback.fluxer.com](https://feedback.fluxer.com).
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security). Never post them publicly.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Read the [operator documentation](https://fluxer.dev) for self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
-41
View File
@@ -1,41 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-discussion.json
body:
- type: markdown
attributes:
value: |
Search existing discussions before posting a feature proposal.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>.
- type: textarea
id: problem
attributes:
label: Current problem
description: State what you are trying to do and what prevents it.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: State the expected behaviour.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional information
description: Optional. Include constraints, trade-offs, related discussions, screenshots or mockups.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched existing discussions.
required: true
-83
View File
@@ -1,83 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Bug report
description: Report a reproducible defect in Fluxer.
type: Bug
body:
- type: markdown
attributes:
value: |
Search [open and closed issues](https://github.com/fluxerapp/fluxer/issues?q=is%3Aissue) before filing a report.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>. Send account and billing requests to <[email protected]>.
- type: textarea
id: summary
attributes:
label: Observed behaviour
description: State what happened and what you expected.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Give numbered steps starting from a fresh app or session.
placeholder: |
1. Go to ...
2. Select ...
3. Observe ...
validations:
required: true
- type: input
id: build
attributes:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard.
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Affected surface
multiple: true
options:
- Desktop app
- Web app
- Voice, video, or Go Live
- Self-hosted instance
- HTTP API or Gateway
- Documentation site
validations:
required: true
- type: input
id: instance
attributes:
label: Instance
description: For a self-hosted instance, include the release tag and database backend.
placeholder: fluxer.app
validations:
required: false
- type: textarea
id: evidence
attributes:
label: Evidence
description: Attach relevant logs, screenshots or recordings. Remove tokens, keys, private messages and other personal data. Configuration files may contain secrets.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
- label: I removed secrets and unrelated personal data from the report.
required: true
-18
View File
@@ -1,18 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
- name: Self-hosting support
url: https://fluxer.dev
about: Read the operator documentation, then open a discussion if the problem remains.
-44
View File
@@ -1,44 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Documentation
description: Report incorrect, missing or unclear documentation.
type: Task
labels:
- docs
body:
- type: markdown
attributes:
value: |
This form covers <https://fluxer.dev> and operator documentation.
- type: textarea
id: issue
attributes:
label: Documentation defect
description: State what the page says and what is correct. For missing content, state what information you needed.
validations:
required: true
- type: input
id: location
attributes:
label: Location
description: Provide the page URL or file path and heading.
placeholder: https://fluxer.dev/gateway/overview/
validations:
required: false
- type: textarea
id: suggestion
attributes:
label: Proposed wording
description: Optional.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
+2 -2
View File
@@ -1,7 +1,7 @@
# Security policy
Do not report a vulnerability in an issue, pull request, or discussion.
Do not report a vulnerability in a pull request, on feedback.fluxer.com, in a Fluxer community, or in a direct message to staff.
Submit a report through [GitHub private vulnerability reporting](https://github.com/fluxerapp/fluxer/security/advisories/new) or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
Submit a report through <https://fluxer.app/security> or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
The programme scope, testing rules, safe harbour, disclosure process, and reward terms are published at <https://fluxer.app/security>. That page is authoritative.
+2 -2
View File
@@ -1,6 +1,6 @@
Closes #
Resolves https://feedback.fluxer.com/p/
<!-- Repeat this line for each resolved issue, up to 20. Remove the placeholder only if no issue is resolved and the approval gate does not apply. -->
<!-- Repeat this line for each feedback.fluxer.com post this resolves, up to 20. Remove the placeholder only if no post is resolved and the approval gate does not apply. -->
## Summary
@@ -154,6 +154,7 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_SELF_HOSTED=true
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
+1
View File
@@ -200,6 +200,7 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
@@ -0,0 +1,350 @@
name: build desktop modules
on:
workflow_dispatch:
inputs:
channel:
description: Release channel to ship renderer modules on. The live shell on that channel stays as it is.
required: true
type: choice
options:
- canary
- stable
default: canary
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
default: ""
type: string
allow_shell_drift:
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
required: false
default: false
type: boolean
permissions:
contents: write
id-token: write
actions: read
concurrency:
group: desktop-modules-${{ inputs.channel }}
cancel-in-progress: false
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
jobs:
meta:
name: Resolve build metadata
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 25
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Set metadata
id: meta
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
assemble:
name: Assemble the modules-only release
if: ${{ !cancelled() && needs.shared_assets.result == 'success' }}
needs:
- meta
- shared_assets
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
fetch-depth: 0
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Check the live shell runs these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_drift
- name: Build the modules-only manifests
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_modules_only_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_modules_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
publish_release:
name: Publish GitHub modules-only release
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
needs:
- meta
- assemble
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download GitHub release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub modules-only release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: |
set -euo pipefail
release_args=(
release publish
--component "fluxer-desktop-${CHANNEL}"
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
+164
View File
@@ -103,11 +103,150 @@ jobs:
--step set_matrix
--skip-targets "${{ inputs.skip_targets }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
- shared_assets
runs-on: ${{ matrix.os }}
environment: desktop-releases
timeout-minutes: 180
@@ -130,6 +269,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
FLUXER_MODULES: "1"
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
@@ -276,6 +416,17 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Download shared renderer artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
- name: Restore shared renderer assets
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step restore_shared_assets
- name: Build Electron main process
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
@@ -505,11 +656,13 @@ jobs:
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
- shared_assets
- build
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 180
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
@@ -547,6 +700,17 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Build desktop module manifest
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_module_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
+2
View File
@@ -44,6 +44,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
Generated
+35 -1
View File
@@ -1710,6 +1710,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
@@ -1741,6 +1751,7 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"base64 0.23.1",
"brotli",
"bytes",
"chrono",
"clap",
@@ -1750,6 +1761,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tar",
"tempfile",
"tokio",
"walkdir",
@@ -1986,11 +1998,13 @@ dependencies = [
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"moka",
"rmp-serde",
"scylla",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
]
@@ -2040,7 +2054,6 @@ dependencies = [
"hex",
"rand 0.10.2",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
@@ -4788,6 +4801,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5816,6 +5840,16 @@ dependencies = [
"tls_codec",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
+3
View File
@@ -23,6 +23,9 @@
# Fluxer
> [!IMPORTANT]
> Bug reports and feature requests have moved to [feedback.fluxer.com](https://feedback.fluxer.com). Sign in with your Fluxer account to post, vote and follow updates. GitHub Issues and Discussions are closed. Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security).
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
+1
View File
@@ -89,6 +89,7 @@ FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
FLUXER_PROFILE_PSEUDONYM_SECRET=fluxer-dev-profile-pseudonym-secret
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
FLUXER_VAPID_EMAIL=dev@localhost
+5 -1
View File
@@ -184,6 +184,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
@@ -218,6 +219,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
# Push hosts on your own network, such as a ntfy server, that may resolve to
# private addresses. Comma separated.
#FLUXER_PUSH_SERVICE_PRIVATE_HOSTS=ntfy.example.com
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
# Direct mobile push through your own APNs and FCM credentials, off by default.
@@ -259,7 +263,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# only when a browser must reach an origin the defaults do not cover. Separate
# several with spaces or commas. The three values below are illustrations.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_MEDIA_SRC=
#FLUXER_CSP_EXTRA_FONT_SRC=
+3
View File
@@ -177,6 +177,7 @@ x-fluxer-env: &fluxer-env
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
FLUXER_PROFILE_PSEUDONYM_SECRET: ${FLUXER_PROFILE_PSEUDONYM_SECRET:?set FLUXER_PROFILE_PSEUDONYM_SECRET in .env}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
@@ -614,6 +615,7 @@ services:
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_PRIVATE_HOSTS: ${FLUXER_PUSH_SERVICE_PRIVATE_HOSTS:-}
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
@@ -669,6 +671,7 @@ services:
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
File diff suppressed because it is too large Load Diff
+2
View File
@@ -90,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
pub const USER_VIEW_EMAIL: &str = "user:view:email";
pub const USER_VIEW_IP: &str = "user:view:ip";
pub const USER_TEMP_BAN: &str = "user:temp_ban";
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
pub const USER_UPDATE_DOB: &str = "user:update:dob";
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
@@ -196,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
USER_VIEW_EMAIL,
USER_VIEW_IP,
USER_TEMP_BAN,
USER_UPDATE_BOT_STATUS,
USER_UPDATE_DOB,
USER_UPDATE_EMAIL,
USER_UPDATE_FLAGS,
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::ListGuildThreadsResponse;
impl AdminApiClient {
pub async fn list_guild_threads(&self, guild_id: &str) -> ApiResult<ListGuildThreadsResponse> {
let response = self
.generated()
.list_admin_guild_threads(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_thread_channel(&self, channel_id: &str) -> ApiResult<()> {
self.generated()
.delete_admin_thread_channel(&snowflake(channel_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod client;
pub mod codes;
pub mod discovery;
pub mod guild_assets;
pub mod guild_threads;
pub mod guilds;
pub mod instance_config;
pub mod jobs;
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadMetadata {
pub archived: bool,
pub locked: bool,
pub auto_archive_duration: i32,
pub archive_timestamp: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadItem {
pub id: String,
#[serde(rename = "type")]
pub channel_type: i32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(default)]
pub member_count: Option<i32>,
#[serde(default)]
pub message_count: Option<i32>,
#[serde(default)]
pub thread_metadata: Option<GuildThreadMetadata>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ListGuildThreadsResponse {
pub threads: Vec<GuildThreadItem>,
}
@@ -27,8 +27,6 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub plutonium_page: PlutoniumPageConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
@@ -507,7 +505,6 @@ impl VoiceE2eeScope {
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
@@ -579,52 +576,6 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PlutoniumPageConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for PlutoniumPageConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PlutoniumPageConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
@@ -771,8 +722,6 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
@@ -1075,24 +1024,17 @@ mod tests {
.expect("admin schema");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
.expect("default plutonium page config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let plutonium_page =
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse =
serde_json::from_value(plutonium_page.clone())
.expect("generated plutonium page config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
@@ -1102,11 +1044,6 @@ mod tests {
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_plutonium_page)
.expect("serializable generated plutonium page config"),
plutonium_page
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
@@ -1118,7 +1055,6 @@ mod tests {
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("PlutoniumPageConfigResponse", plutonium_page),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
@@ -1153,25 +1089,4 @@ mod tests {
json!({})
);
}
#[test]
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
let update = PlutoniumPageConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::PlutoniumPageConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod guild_threads;
mod instance_billing;
mod instance_config;
mod jobs;
@@ -29,6 +30,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use guild_threads::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
+22
View File
@@ -306,6 +306,28 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn change_username(
&self,
user_id: &str,
+18
View File
@@ -96,6 +96,24 @@ pub async fn render(
config, &guild, &stickers, csrf_token,
))
}
"threads" => {
if !acl::has_permission(admin_acls, acl::GUILD_LOOKUP) {
return None;
}
let threads = client
.list_guild_threads(guild_id)
.await
.map(|response| response.threads)
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads"))
.unwrap_or_default();
Some(tabs::threads::threads_tab(
config,
&guild,
&threads,
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
csrf_token,
))
}
"audit_log" | "audit-log" => {
if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) {
return None;
+10
View File
@@ -433,6 +433,16 @@ async fn dispatch_guild_action(
"Failed to delete sticker",
)
}
"delete_thread" => {
let Some(thread_id) = get("thread_id") else {
return FlashData::error("Thread ID is required");
};
action_result(
client.delete_thread_channel(&thread_id).await,
"Thread deleted",
"Failed to delete thread",
)
}
"trigger_archive" => {
let inc = form.bool_value("include_attachments");
action_result(
+2 -107
View File
@@ -18,8 +18,8 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -220,10 +220,6 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_plutonium_page" => match build_plutonium_page_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -613,41 +609,6 @@ fn build_domain_migration_update(
})
}
fn build_plutonium_page_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
enabled: Some(form.bool_value("plutonium_page_enabled")),
rollout_basis_points: parse_form_number(
form,
"plutonium_page_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
captcha: Some(CaptchaConfigUpdateRequest {
@@ -1516,72 +1477,6 @@ mod tests {
);
}
#[test]
fn build_plutonium_page_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
);
let update = build_plutonium_page_update(&form)
.expect("valid form")
.plutonium_page
.expect("plutonium page update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_plutonium_page_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"plutonium_page": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"plutonium_page_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_plutonium_page_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
+16
View File
@@ -178,6 +178,22 @@ pub async fn dispatch(
"Failed to clear user fields",
)
}
"set_bot_status" => {
let val = form.bool_value("bot");
DispatchOutcome::from_result(
client.set_bot_status(user_id, val).await,
"Bot status updated successfully",
"Failed to update bot status",
)
}
"set_system_status" => {
let val = form.bool_value("system");
DispatchOutcome::from_result(
client.set_system_status(user_id, val).await,
"System status updated successfully",
"Failed to update system status",
)
}
"change_username" => {
let Some(username) = get("username") else {
return DispatchOutcome::error("Username is required");
@@ -26,6 +26,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
("archives", "Archives"),
("emojis", "Emojis"),
("stickers", "Stickers"),
("threads", "Threads"),
("audit_logs", "Admin Audit Logs"),
("audit_log", "Guild Audit Log"),
("reports", "Reports"),
@@ -208,6 +209,7 @@ fn guild_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String])
"overview" | "members" | "settings" | "features" | "moderation" => true,
"reports" => acl::has_permission(admin_acls, acl::REPORT_VIEW),
"emojis" | "stickers" => acl::has_permission(admin_acls, acl::ASSET_PURGE),
"threads" => acl::has_permission(admin_acls, acl::GUILD_LOOKUP),
"audit_logs" => acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW),
"audit_log" => acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW),
"archives" => acl::has_any_permission(
@@ -11,6 +11,7 @@ pub mod overview;
pub mod reports;
pub mod settings;
pub mod stickers;
pub mod threads;
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
@@ -33,7 +33,11 @@ fn channel_type_label(channel_type: i32) -> &'static str {
2 => "Voice",
4 => "Category",
5 => "Announcement",
13 => "Link",
11 => "Public thread",
12 => "Private thread",
15 => "Forum",
16 => "Media",
998 => "Link",
_ => "Unknown",
}
}
@@ -179,7 +183,7 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
} @else {
div class="flex flex-col gap-2" {
@for channel in &sorted_channels {
@let is_link = channel.channel_type == 13;
@let is_link = channel.channel_type == 998;
@let parent = channel.parent_id.as_deref()
.and_then(|pid| channels_by_id.get(pid));
@let parent_nsfw_override = parent
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{GuildInfo, GuildThreadItem},
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::card_with_header,
table::{data_table, table_cell, table_row},
},
};
use maud::{Markup, html};
pub fn threads_tab(
config: &AdminConfig,
guild: &GuildInfo,
threads: &[GuildThreadItem],
can_delete: bool,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
(card_with_header("Thread search index", html! {
form method="post"
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
class="w-full" {
(csrf_input(csrf_token))
input type="hidden" name="index_type" value="threads";
(form_actions(html! {
(submit_button("Refresh threads"))
}))
}
}))
(card_with_header(
&format!("Threads ({})", threads.len()),
html! {
@if threads.is_empty() {
p class="text-sm text-neutral-500" { "No threads found for this guild." }
} @else {
(data_table(
&["Thread", "Parent", "State", "Members", "Messages", ""],
html! {
@for thread in threads {
(thread_row(base, &guild.id, thread, can_delete, csrf_token))
}
},
))
}
},
))
}
}
fn thread_kind(channel_type: i32) -> &'static str {
match channel_type {
10 => "Announcement",
12 => "Private",
_ => "Public",
}
}
fn thread_state(thread: &GuildThreadItem) -> Markup {
let metadata = thread.thread_metadata.as_ref();
let archived = metadata.is_some_and(|metadata| metadata.archived);
let locked = metadata.is_some_and(|metadata| metadata.locked);
html! {
div class="flex flex-wrap gap-1" {
(badge(thread_kind(thread.channel_type), BadgeVariant::Default))
@if archived { (badge("Archived", BadgeVariant::Default)) }
@if locked { (badge("Locked", BadgeVariant::Default)) }
}
}
}
fn thread_row(
base: &str,
guild_id: &str,
thread: &GuildThreadItem,
can_delete: bool,
csrf_token: &str,
) -> Markup {
table_row(html! {
(table_cell(false, html! {
div class="font-medium" { (thread.name.as_deref().unwrap_or("")) }
div class="text-xs text-neutral-500" { "ID: " (thread.id) }
}))
(table_cell(true, html! { (thread.parent_id.as_deref().unwrap_or("")) }))
(table_cell(false, thread_state(thread)))
(table_cell(true, html! { (thread.member_count.unwrap_or(0)) }))
(table_cell(true, html! { (thread.message_count.unwrap_or(0)) }))
(table_cell(false, html! {
@if can_delete {
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=threads&action=delete_thread"} {
(csrf_input(csrf_token))
input type="hidden" name="thread_id" value=(thread.id);
(danger_button("Delete thread"))
}
}
}))
})
}
@@ -8,9 +8,8 @@ use crate::{
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse,
PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, TagStyle,
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse, TagStyle,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -190,8 +189,9 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.",
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
@if !instance_config.self_hosted {
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
}
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1276,147 +1276,6 @@ fn domain_migration_section(
)
}
fn plutonium_page_section(
base: &str,
csrf_token: &str,
plutonium_page: &PlutoniumPageConfigResponse,
) -> Markup {
let status = if plutonium_page.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = plutonium_page.included_user_ids.join("\n");
let excluded_user_ids = plutonium_page.excluded_user_ids.join("\n");
section_card_with_description(
"Plutonium page",
"Replaces the Plutonium settings tab with a full Plutonium page, makes app pages linkable \
in chat, and uses a minimal gift purchase modal.",
html! {
form method="post" action={(base) "/instance-config?action=update_plutonium_page"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (plutonium_page.config_version)
}
}
(checkbox(
"plutonium_page_enabled",
"true",
"Serve the Plutonium page to the selected users",
plutonium_page.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
client keeps the Plutonium settings tab, so the rollout and targeting \
fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"plutonium_page_rollout_basis_points",
"Rollout (basis points)",
&plutonium_page.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the Plutonium page, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"plutonium_page_rollout_salt",
"Rollout Salt",
&plutonium_page.rollout_salt,
PLUTONIUM_PAGE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
plutonium_page.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"plutonium_page_include_premium_users",
"true",
"Include premium users",
plutonium_page.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&plutonium_page.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
plutonium_page.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"plutonium_page_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
plutonium_page.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage."
}
}
(form_actions(html! {
(submit_button("Save Plutonium Page Configuration"))
}))
}
}
},
)
}
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
}
@@ -2227,34 +2086,6 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
let plutonium_page = PlutoniumPageConfigResponse {
enabled: true,
config_version: 3,
rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..PlutoniumPageConfigResponse::default()
};
let markup = plutonium_page_section("/admin", "csrf", &plutonium_page).into_string();
assert!(markup.contains("Plutonium page"));
assert!(markup.contains("action=update_plutonium_page"));
assert!(markup.contains("name=\"plutonium_page_enabled\""));
assert!(markup.contains("name=\"plutonium_page_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"plutonium_page_include_premium_users\""));
assert!(markup.contains("name=\"plutonium_page_included_guild_ids\""));
assert!(markup.contains("Config version 3"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("anonymous_rollout_basis_points"));
assert!(!markup.contains("standalone_forwarding"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
@@ -52,6 +52,7 @@ pub fn account_tab(
(sessions_card(config, sessions))
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
(clear_fields_card(base, user, csrf_token))
(user_status_card(base, user, csrf_token))
(security_actions_card(base, user, csrf_token))
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
}
@@ -310,6 +311,19 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
}
}
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
let is_bot = user.bot;
let is_sys = user.system;
html! {
(card_with_header("User Status", html! {
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
}
}))
}
}
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
html! {
(card_with_header("Security Actions", html! {
@@ -450,3 +464,40 @@ fn action_form(
}
}
}
fn status_toggle(
base: &str,
uid: &str,
action: &str,
active: bool,
kind: &str,
csrf: &str,
) -> Markup {
let status_val = if active { "false" } else { "true" };
let label = format!(
"{} {} Status",
if active { "Remove" } else { "Set" },
capitalize(kind)
);
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
html! {
form method="post"
action=(&action_url)
hx-post=(&action_url)
hx-target="#flash-container"
hx-swap="none"
hx-push-url="false" {
(csrf_input(csrf))
input type="hidden" name=(kind) value=(status_val);
button type="submit" class=(BTN_CLS) { (label) }
}
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
}
@@ -437,6 +437,11 @@ fn acls_card(
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
}
}
@for item in &user.acls {
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
input type="hidden" name="acls[]" value=(item);
}
}
(form_actions(html! {
(submit_button("Save ACLs"))
}))
+45 -19
View File
@@ -416,17 +416,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"plutonium_page": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 500,
"rollout_salt": "plutonium-page-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": ["1500000000000000002"],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"future_plutonium_page_knob": true
},
"captcha": {
"enabled": true,
"cost": 5000,
@@ -584,14 +573,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.plutonium_page.enabled);
assert_eq!(resp.plutonium_page.config_version, 3);
assert_eq!(resp.plutonium_page.rollout_basis_points, 500);
assert_eq!(*resp.plutonium_page.rollout_salt, "plutonium-page-v1");
assert_eq!(resp.plutonium_page.included_user_ids.len(), 1);
assert_eq!(resp.plutonium_page.excluded_user_ids.len(), 1);
assert_eq!(resp.plutonium_page.included_guild_ids.len(), 1);
assert!(resp.plutonium_page.include_premium_users);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
@@ -1084,3 +1065,48 @@ fn account_identity_lock_is_unknown_when_the_api_omits_it() {
assert_eq!(identity.mode, types::AccountIdentityMode::Email);
assert_eq!(identity.locked, None);
}
#[test]
fn deserialize_guild_threads_response() {
let json = r#"{
"threads": [
{
"id": "1600000000000000010",
"type": 12,
"guild_id": "1600000000000000001",
"parent_id": "1600000000000000002",
"owner_id": "1500000000000000001",
"name": "secret plans",
"last_message_id": null,
"last_pin_timestamp": null,
"rate_limit_per_user": 0,
"flags": 0,
"thread_metadata": {
"archived": true,
"auto_archive_duration": 4320,
"archive_timestamp": "2026-09-27T12:00:00.000Z",
"locked": false,
"invitable": false,
"create_timestamp": "2026-09-26T12:00:00.000Z"
},
"message_count": 3,
"total_message_sent": 4,
"member_count": 2
}
]
}"#;
let generated: generated_types::ListGuildThreadsResponse =
serde_json::from_str(json).expect("the generated client must accept the thread list");
assert_eq!(generated.threads.len(), 1);
let resp: types::ListGuildThreadsResponse = serde_json::from_str(json).unwrap();
let thread = &resp.threads[0];
assert_eq!(thread.channel_type, 12);
assert_eq!(thread.name.as_deref(), Some("secret plans"));
assert_eq!(thread.member_count, Some(2));
assert!(
thread
.thread_metadata
.as_ref()
.is_some_and(|m| m.archived && !m.locked)
);
}
+10 -9
View File
@@ -469,7 +469,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_plutonium_page",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -485,6 +484,16 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
}
#[tokio::test]
async fn instance_config_has_no_threads_or_plutonium_page_rollout_sections() {
let app = setup().await;
let body = get(&app, "/instance-config", &[]).await;
assert_full_layout(&body);
assert!(!body.contains("Threads and forums"), "{body}");
assert!(!body.contains("update_channel_threads"), "{body}");
assert!(!body.contains("update_plutonium_page"), "{body}");
}
#[tokio::test]
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
let app = setup().await;
@@ -1193,14 +1202,6 @@ fn instance_config() -> Value {
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"plutonium_page": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "plutonium-page-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
type ElasticsearchFieldType = 'text' | 'keyword' | 'boolean' | 'long' | 'integer' | 'date' | 'float';
export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members';
export type FluxerSearchIndexName =
| 'messages'
| 'guilds'
| 'users'
| 'reports'
| 'audit_logs'
| 'guild_members'
| 'threads';
export interface ElasticsearchFieldMapping {
type: ElasticsearchFieldType;
@@ -173,6 +180,26 @@ export const ELASTICSEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, Elas
},
},
},
threads: {
indexName: 'threads',
mappings: {
properties: {
id: keyword(),
guildId: keyword(),
parentId: keyword(),
type: integer(),
name: textWithKeyword(),
ownerId: keyword(),
archived: bool(),
locked: bool(),
appliedTagIds: keyword(),
createdAt: long(),
idSequence: long(),
lastMessageAt: long(),
archivedAt: long(),
},
},
},
audit_logs: {
indexName: 'audit_logs',
mappings: {
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {
SearchableThread,
ThreadSearchCursor,
ThreadSearchFilters,
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {
compactFilters,
esAndTerms,
esRangeFilter,
esTermFilter,
esTermsFilter,
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
const SORT_FIELDS = {
last_message_time: 'lastMessageAt',
archive_time: 'archivedAt',
creation_time: 'createdAt',
} as const;
function cursorFilter(cursor: ThreadSearchCursor, op: 'gt' | 'lt'): ElasticsearchFilter {
return {
bool: {
should: [
esRangeFilter('createdAt', {[op]: cursor.createdAt}),
{
bool: {
filter: [
esTermFilter('createdAt', cursor.createdAt),
esRangeFilter('idSequence', {[op]: cursor.idSequence}),
],
},
},
],
minimum_should_match: 1,
},
};
}
function buildThreadFilters(filters: ThreadSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [
esTermFilter('guildId', filters.guildId),
esTermFilter('parentId', filters.parentId),
];
if (filters.publicOnly) {
clauses.push(
filters.privateThreadIds && filters.privateThreadIds.length > 0
? {
bool: {
should: [esTermsFilter('type', [10, 11]), esTermsFilter('id', filters.privateThreadIds)],
minimum_should_match: 1,
},
}
: esTermsFilter('type', [10, 11]),
);
}
if (filters.archived !== undefined) clauses.push(esTermFilter('archived', filters.archived));
if (filters.tagIds && filters.tagIds.length > 0) {
if (filters.tagSetting === 'match_all') clauses.push(...esAndTerms('appliedTagIds', filters.tagIds));
else clauses.push(esTermsFilter('appliedTagIds', filters.tagIds));
}
if (filters.after) clauses.push(cursorFilter(filters.after, 'gt'));
if (filters.before) clauses.push(cursorFilter(filters.before, 'lt'));
return compactFilters(clauses);
}
function buildThreadSort(filters: ThreadSearchFilters): Array<SortCombinations> | undefined {
const sortBy = filters.sortBy ?? 'last_message_time';
if (sortBy === 'relevance') return undefined;
const order = filters.sortOrder ?? 'desc';
return [...new Set([SORT_FIELDS[sortBy], 'createdAt', 'idSequence'])].map((field) => ({[field]: {order}}));
}
export interface ElasticsearchThreadAdapterOptions {
client: Client;
lock?: ElasticsearchDistributedLock;
}
export class ElasticsearchThreadAdapter extends ElasticsearchIndexAdapter<ThreadSearchFilters, SearchableThread> {
constructor(options: ElasticsearchThreadAdapterOptions) {
super({
client: options.client,
index: ELASTICSEARCH_INDEX_DEFINITIONS.threads,
searchableFields: ['name'],
buildFilters: buildThreadFilters,
buildSort: buildThreadSort,
lock: options.lock,
});
}
}
+2
View File
@@ -19,6 +19,7 @@ import {Hono} from 'hono';
interface CreateAPIAppOptions {
config: APIConfig;
logger: ILogger;
registerRoutes?: (routes: HonoApp) => void;
}
interface APIAppResult {
@@ -53,6 +54,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
routes.onError(TelemetryAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
registerControllers(routes, config);
options.registerRoutes?.(routes);
const app = new Hono<HonoEnv>({strict: true});
const {middleware: metricsMiddleware, metricsHandler} = createMetricsMiddleware('api');
app.use('*', metricsMiddleware);
+7
View File
@@ -187,6 +187,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
headersTimeoutMs: master.services.api.headers_timeout_ms,
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
automatedMessageDeletionDelayDays: master.services.api.automated_message_deletion_delay_days,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
@@ -329,6 +330,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyIsk: master.integrations.stripe.prices.monthly_isk,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
@@ -338,6 +340,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyIsk: master.integrations.stripe.prices.yearly_isk,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
@@ -350,6 +353,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
gift1MonthIsk: master.integrations.stripe.prices.gift_1_month_isk,
gift1YearIsk: master.integrations.stripe.prices.gift_1_year_isk,
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
@@ -391,6 +396,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
auth: {
sudoModeSecret: master.auth.sudo_mode_secret,
connectionInitiationSecret: master.auth.connection_initiation_secret,
profilePseudonymSecret: master.auth.profile_pseudonym_secret,
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
passkeys: {
rpName: master.auth.passkeys.rp_name,
@@ -493,6 +499,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
laneName: apiWorkerConfig?.lane,
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
metricsPort: apiWorkerConfig?.metrics_port,
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+5
View File
@@ -12,6 +12,7 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
import type {IMessageSearchService} from '@app/api/search/IMessageSearchService';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
import type {ISearchProvider} from '@app/api/search/ISearchProvider';
import type {IThreadSearchService} from '@app/api/search/IThreadSearchService';
import type {IUserSearchService} from '@app/api/search/IUserSearchService';
import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
@@ -87,6 +88,10 @@ export function getGuildMemberSearchService(): IGuildMemberSearchService | null
return searchProvider?.getGuildMemberSearchService() ?? null;
}
export function getThreadSearchService(): IThreadSearchService | null {
return searchProvider?.getThreadSearchService() ?? null;
}
export async function initializeSearch(lock?: ElasticsearchDistributedLock): Promise<void> {
if (searchProvider) {
await shutdownSearch();
+104 -6
View File
@@ -141,6 +141,8 @@ import {
type InviteRow,
PRIVATE_CHANNEL_COLUMNS,
type PrivateChannelRow,
READ_STATE_COLUMNS,
type ReadStateRow,
WEBHOOK_COLUMNS,
WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
type WebhookRow,
@@ -268,6 +270,30 @@ import {
type StorePurchaseByUserRow,
type StorePurchaseRow,
} from '@app/api/database/types/StoreBillingTypes';
import {
ACTIVE_THREADS_BY_GUILD_COLUMNS,
type ActiveThreadsByGuildRow,
ARCHIVED_THREADS_BY_PARENT_COLUMNS,
type ArchivedThreadsByParentRow,
FORUM_PINNED_THREAD_COLUMNS,
type ForumPinnedThreadRow,
GUILD_THREAD_STATE_COLUMNS,
type GuildThreadStateRow,
THREAD_MEMBER_COLUMNS,
THREAD_MEMBERS_BY_USER_COLUMNS,
THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS,
THREAD_PARENT_CONFIG_COLUMNS,
THREAD_STATE_COLUMNS,
THREAD_STATS_COLUMNS,
THREADS_BY_PARENT_COLUMNS,
type ThreadMemberRow,
type ThreadMembersByUserRow,
type ThreadOnlyChannelsByGuildRow,
type ThreadParentConfigRow,
type ThreadStateRow,
type ThreadStatsRow,
type ThreadsByParentRow,
} from '@app/api/database/types/ThreadTypes';
import {
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
@@ -579,6 +605,84 @@ export const DmStates = defineTable<DmStateRow, 'hi_user_id' | 'lo_user_id' | 'c
columns: DM_STATE_COLUMNS,
primaryKey: ['hi_user_id', 'lo_user_id', 'channel_id'],
});
export const ThreadState = defineTable<ThreadStateRow, 'thread_id'>({
name: 'thread_state',
columns: THREAD_STATE_COLUMNS,
primaryKey: ['thread_id'],
partitionKey: ['thread_id'],
});
export const ThreadStats = defineTable<ThreadStatsRow, 'thread_id'>({
name: 'thread_stats',
columns: THREAD_STATS_COLUMNS,
primaryKey: ['thread_id'],
partitionKey: ['thread_id'],
});
export const ThreadsByParent = defineTable<ThreadsByParentRow, 'parent_id' | 'thread_id', 'parent_id'>({
name: 'threads_by_parent',
columns: THREADS_BY_PARENT_COLUMNS,
primaryKey: ['parent_id', 'thread_id'],
partitionKey: ['parent_id'],
});
export const ActiveThreadsByGuild = defineTable<ActiveThreadsByGuildRow, 'guild_id' | 'thread_id', 'guild_id'>({
name: 'active_threads_by_guild',
columns: ACTIVE_THREADS_BY_GUILD_COLUMNS,
primaryKey: ['guild_id', 'thread_id'],
partitionKey: ['guild_id'],
});
export const ArchivedThreadsByParent = defineTable<
ArchivedThreadsByParentRow,
'parent_id' | 'is_private' | 'archive_timestamp' | 'thread_id',
'parent_id' | 'is_private'
>({
name: 'archived_threads_by_parent',
columns: ARCHIVED_THREADS_BY_PARENT_COLUMNS,
primaryKey: ['parent_id', 'is_private', 'archive_timestamp', 'thread_id'],
partitionKey: ['parent_id', 'is_private'],
});
export const ThreadMembers = defineTable<ThreadMemberRow, 'thread_id' | 'user_id', 'thread_id'>({
name: 'thread_members',
columns: THREAD_MEMBER_COLUMNS,
primaryKey: ['thread_id', 'user_id'],
partitionKey: ['thread_id'],
});
export const ThreadMembersByUser = defineTable<
ThreadMembersByUserRow,
'user_id' | 'guild_id' | 'parent_id' | 'is_private' | 'thread_id',
'user_id'
>({
name: 'thread_members_by_user',
columns: THREAD_MEMBERS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'guild_id', 'parent_id', 'is_private', 'thread_id'],
partitionKey: ['user_id'],
});
export const ThreadParentConfig = defineTable<ThreadParentConfigRow, 'guild_id' | 'channel_id', 'guild_id'>({
name: 'thread_parent_config',
columns: THREAD_PARENT_CONFIG_COLUMNS,
primaryKey: ['guild_id', 'channel_id'],
partitionKey: ['guild_id'],
});
export const ForumPinnedThread = defineTable<ForumPinnedThreadRow, 'parent_id'>({
name: 'forum_pinned_thread',
columns: FORUM_PINNED_THREAD_COLUMNS,
primaryKey: ['parent_id'],
partitionKey: ['parent_id'],
});
export const ThreadOnlyChannelsByGuild = defineTable<
ThreadOnlyChannelsByGuildRow,
'guild_id' | 'channel_id',
'guild_id'
>({
name: 'thread_only_channels_by_guild',
columns: THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS,
primaryKey: ['guild_id', 'channel_id'],
partitionKey: ['guild_id'],
});
export const GuildThreadState = defineTable<GuildThreadStateRow, 'guild_id'>({
name: 'guild_thread_state',
columns: GUILD_THREAD_STATE_COLUMNS,
primaryKey: ['guild_id'],
partitionKey: ['guild_id'],
});
interface PinnedDmRow {
user_id: bigint;
@@ -593,12 +697,6 @@ export const PinnedDms = defineTable<PinnedDmRow, 'user_id' | 'channel_id'>({
primaryKey: ['user_id', 'channel_id'],
});
interface ReadStateRow {
user_id: bigint;
channel_id: bigint;
}
const READ_STATE_COLUMNS = ['user_id', 'channel_id'] as const satisfies ReadonlyArray<keyof ReadStateRow>;
export const ReadStates = defineTable<ReadStateRow, 'user_id' | 'channel_id'>({
name: 'read_states',
columns: READ_STATE_COLUMNS,
@@ -38,6 +38,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_MEMBERS: 'list_guild_members',
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
LIST_GUILD_STICKERS: 'list_guild_stickers',
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
@@ -37,7 +37,6 @@ import {
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -67,7 +66,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
pushRelay,
domainMigration,
plutoniumPage,
captcha,
experimentDelivery,
registrationConfig,
@@ -78,7 +76,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
@@ -116,7 +113,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
push_relay: pushRelay,
domain_migration: domainMigration,
plutonium_page: plutoniumPage,
captcha,
experiment_delivery: experimentDelivery,
registration: {
@@ -400,18 +396,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.plutonium_page) {
const patch = omitUndefinedFields(data.plutonium_page);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updatePlutoniumPageConfig((current) =>
PlutoniumPageConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
@@ -26,7 +26,7 @@ export function SearchAdminController(app: HonoApp) {
operationId: 'create_admin_search_index_refresh',
summary: 'Refresh a search index',
description:
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages, guild_members and threads indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
responseSchema: RefreshSearchIndexResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {AdminRateLimitConfigs} from '@app/api/rate_limit_configs/AdminRateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {ListGuildThreadsResponse} from '@fluxer/schema/src/domains/admin/AdminThreadSchemas';
import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
export function ThreadAdminController(app: HonoApp) {
app.get(
'/admin/guilds/:guild_id/threads',
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GUILD_LOOKUP),
Validator('param', GuildIdParam),
OpenAPI({
operationId: 'list_admin_guild_threads',
summary: 'List guild threads',
description:
'Lists every thread of a guild, active and archived, whether or not the channel threads experiment is active for it. Requires GUILD_LOOKUP permission.',
responseSchema: ListGuildThreadsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
experiment: 'channel_threads',
}),
async (ctx) => {
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const threads = await ctx.get('threadService').lists.listGuildThreadsForAdmin(guildId);
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guildId,
action: AdminAuditReadActions.LIST_GUILD_THREADS,
metadata: {result_count: threads.length},
});
return ctx.json({threads});
},
);
app.delete(
'/admin/channels/:channel_id',
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_MESSAGE_OPERATION),
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'delete_admin_thread_channel',
summary: 'Delete a thread',
description:
'Deletes a thread channel with its messages and memberships. Only public and private threads can be deleted here. Requires MESSAGE_DELETE_ALL permission.',
responseSchema: null,
statusCode: 204,
security: 'adminApiKey',
tags: 'Admin',
experiment: 'channel_threads',
}),
async (ctx) => {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const thread = await ctx.get('channelRepository').findUnique(channelId);
if (!thread) throw new UnknownChannelError();
if (!thread.isThread()) throw new InvalidChannelTypeError();
const adminUserId = ctx.get('adminUserId');
await ctx.get('threadService').deletion.deleteThread({
thread,
actorId: adminUserId,
auditLogReason: ctx.get('auditLogReason'),
recordGuildAudit: false,
});
await recordAdminWrite(ctx, {
targetType: 'channel',
targetId: channelId,
action: 'delete_thread',
metadata: {guild_id: thread.guildId?.toString(), parent_id: thread.parentId?.toString(), type: thread.type},
});
return ctx.body(null, 204);
},
);
}
@@ -24,6 +24,7 @@ import {
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
AdminUserBotStatusRequest,
AdminUserChangeLogQuery,
AdminUserClearFieldsRequest,
AdminUserDeletionCancelRequest,
@@ -38,6 +39,7 @@ import {
AdminUserPremiumFlagsUpdateRequest,
AdminUserRelationshipCategoryQuery,
AdminUserRelationshipParam,
AdminUserSystemStatusRequest,
AdminUsersMeResponse,
AdminUserTraitsRequest,
AdminUserUnbanRequest,
@@ -601,6 +603,70 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.put(
'/admin/users/:user_id/bot-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserBotStatusRequest),
OpenAPI({
operationId: 'set_admin_user_bot_status',
summary: 'Set user bot status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserBotStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.put(
'/admin/users/:user_id/system-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserSystemStatusRequest),
OpenAPI({
operationId: 'set_admin_user_system_status',
summary: 'Set user system status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserSystemStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.patch(
'/admin/users/:user_id/username',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -19,6 +19,7 @@ import {ReportAdminController} from '@app/api/admin/controllers/ReportAdminContr
import {SearchAdminController} from '@app/api/admin/controllers/SearchAdminController';
import {StoreBillingAdminController} from '@app/api/admin/controllers/StoreBillingAdminController';
import {SystemDmAdminController} from '@app/api/admin/controllers/SystemDmAdminController';
import {ThreadAdminController} from '@app/api/admin/controllers/ThreadAdminController';
import {UserAdminController} from '@app/api/admin/controllers/UserAdminController';
import {VoiceAdminController} from '@app/api/admin/controllers/VoiceAdminController';
import type {HonoApp} from '@app/api/types/HonoEnv';
@@ -30,6 +31,7 @@ export function registerAdminControllers(app: HonoApp) {
StoreBillingAdminController(app);
CodesAdminController(app);
GuildAdminController(app);
ThreadAdminController(app);
AssetAdminController(app);
BanAdminController(app);
InstanceConfigAdminController(app);
@@ -13,17 +13,19 @@ import {
type UserID,
} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {withThreadContext} from '@app/api/channel/services/ChannelGatewayDispatch';
import {
enqueueCrosspostFamilyPurgeFromCopies,
enqueueCrosspostSourceRemoval,
} from '@app/api/channel/services/message/CrosspostPropagation';
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
import {decrementThreadMessageCount, purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
import {
createMessageResponseDataService,
type MessageResponseAccessContext,
messageResponseAccessForChannel,
messageResponseAccessForGuild,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {getPurgeQueue, getStorageService} from '@app/api/middleware/ServiceSingletons';
@@ -144,15 +146,16 @@ export class AdminMessageService {
message.authorId || createUserID(0n),
message.pinnedTimestamp || undefined,
);
await decrementThreadMessageCount(channelRepository, channel, [messageId]);
if (channel) {
if (channel.guildId) {
await gatewayService.dispatchGuild({
guildId: channel.guildId,
event: 'MESSAGE_DELETE',
data: {
data: withThreadContext(channel, {
channel_id: channelId.toString(),
id: messageId.toString(),
},
}),
});
} else {
for (const recipientId of channel.recipientIds) {
@@ -349,9 +352,12 @@ export class AdminMessageService {
guildName: null,
};
}
const guild = await guildRepository.findUnique(channel.guildId);
const [guild, scope] = await Promise.all([
guildRepository.findUnique(channel.guildId),
resolveNsfwScopeChannel(channel, (id) => channelRepository.findUnique(id)),
]);
return {
channelNsfw: channel.isNsfw,
channelNsfw: scope.isNsfw,
guildNsfwLevel: guild?.nsfwLevel ?? null,
channelName: channel.name ?? null,
guildId: channel.guildId.toString(),
@@ -21,9 +21,11 @@ import {
messageResponseAccessForChannel,
messageResponseAccessForGuild,
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
@@ -205,6 +207,7 @@ export class AdminReportService {
});
await this.deps.channelService.messages.send.sendMessage({
user: systemUser,
viewer: SYSTEM_THREAD_VIEWER,
channelId: dmChannel.id,
data: {
content: template.value.body,
@@ -523,7 +526,10 @@ export class AdminReportService {
return reportNsfwLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
}
const channel = await this.deps.channelRepository.findUnique(channelId);
const channelNsfw = channel?.isNsfw ?? null;
const scope = channel
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id))
: null;
const channelNsfw = scope?.isNsfw ?? null;
reportNsfwLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
return channelNsfw;
}
@@ -6,9 +6,11 @@ import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
import {isSyntheticUserId} from '@app/api/constants/Core';
import {channelThreadsEnabled} from '@app/api/experiment/ChannelThreadsGate';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {Logger} from '@app/api/Logger';
import {getGuildSearchService, getUserSearchService} from '@app/api/SearchFactory';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
@@ -23,7 +25,8 @@ interface RefreshSearchIndexJobPayload extends WorkerJobPayload {
| 'channel_messages'
| 'favorite_memes'
| 'guild_members'
| 'discovery';
| 'discovery'
| 'threads';
admin_user_id: string;
audit_log_reason: string | null;
job_id: string;
@@ -175,7 +178,8 @@ export class AdminSearchService {
| 'channel_messages'
| 'guild_members'
| 'favorite_memes'
| 'discovery';
| 'discovery'
| 'threads';
guild_id?: bigint;
user_id?: bigint;
},
@@ -197,6 +201,15 @@ export class AdminSearchService {
}
payload.guild_id = data.guild_id.toString();
}
if (data.index_type === 'threads') {
if (!channelThreadsEnabled()) {
throw InputValidationError.fromCode('index_name', ValidationErrorCodes.INVALID_FORMAT);
}
if (!data.guild_id) {
throw InputValidationError.create('guild_id', 'guild_id is required for the threads index type');
}
payload.guild_id = data.guild_id.toString();
}
if (data.index_type === 'guild_members') {
if (!data.guild_id) {
throw InputValidationError.create('guild_id', 'guild_id is required for the guild_members index type');
@@ -9,6 +9,7 @@ import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserU
import * as AuthSession from '@app/api/auth/AuthSession';
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
import {Logger} from '@app/api/Logger';
@@ -45,11 +46,28 @@ interface AdminUserDeletionServiceDeps {
billingRepository: BillingRepository;
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
storeEntitlementService: StoreEntitlementService;
ncmecRepository: Pick<NcmecRepository, 'getUserWorkflow'>;
}
const minUserRequestedDeletionDays = 14;
const minStandardDeletionDays = 60;
const reportResolvingDeletionReasons: ReadonlySet<number> = new Set([
DeletionReasons.SPAM,
DeletionReasons.CHEATING_OR_EXPLOITATION,
DeletionReasons.COORDINATED_RAIDING,
DeletionReasons.AUTOMATION_OR_SELFBOT,
DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING,
DeletionReasons.HARASSMENT_OR_BULLYING,
DeletionReasons.BAN_EVASION,
DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM,
DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
DeletionReasons.MALICIOUS_LINKS_OR_MALWARE,
DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY,
]);
const manuallyResolvedReportCategories: ReadonlySet<string> = new Set(['child_safety', 'underage_user', 'self_harm']);
function describePendingDeletion(user: User, prefix: string): Array<[string, string]> {
if (!user.pendingDeletionAt) return [];
return [
@@ -247,6 +265,8 @@ export class AdminUserDeletionService {
let knownIps: ReadonlySet<string> = new Set();
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
}
if (reportResolvingDeletionReasons.has(data.reason_code)) {
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
}
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
@@ -389,7 +409,10 @@ export class AdminUserDeletionService {
}): Promise<void> {
const {user, adminUserId, reasonCode} = params;
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
const {reportService, auditService} = this.deps;
const {reportService, auditService, ncmecRepository} = this.deps;
if (await ncmecRepository.getUserWorkflow(user.id)) {
return;
}
const reportSearchService = getReportSearchService();
if (!reportSearchService) {
Logger.warn(
@@ -415,6 +438,7 @@ export class AdminUserDeletionService {
);
if (hits.length === 0) break;
for (const hit of hits) {
if (manuallyResolvedReportCategories.has(hit.category)) continue;
pendingReportIds.add(hit.id);
}
offset += hits.length;
@@ -12,8 +12,12 @@ import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastruct
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -21,6 +25,8 @@ import type {
ChangeEmailRequest,
ChangeUsernameRequest,
ClearUserFieldsRequest,
SetUserBotStatusRequest,
SetUserSystemStatusRequest,
VerifyUserEmailRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {types} from 'cassandra-driver';
@@ -103,6 +109,75 @@ export class AdminUserProfileService {
};
}
async setUserBotStatus(
data: SetUserBotStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.bot && user.acls.size > 0) {
throw new AccessDeniedError();
}
const updates: Record<string, boolean> = {bot: data.bot};
if (!data.bot) {
updates['system'] = false;
}
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_bot_status',
auditLogReason,
metadata: new Map([['bot', data.bot.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async setUserSystemStatus(
data: SetUserSystemStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.system && !user.isBot) {
throw InputValidationError.fromCode(
'system',
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
);
}
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_system_status',
auditLogReason,
metadata: new Map([['system', data.system.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async verifyUserEmail(
data: VerifyUserEmailRequest,
adminUserId: UserID,
@@ -218,6 +293,7 @@ export class AdminUserProfileService {
users: userRepository,
cache: cacheService,
contactChangeLog: contactChangeLogService,
worker: workerService,
} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
@@ -240,6 +316,7 @@ export class AdminUserProfileService {
reason: 'admin_action',
actorUserId: adminUserId,
});
await enqueueStripeCustomerEmailSync(workerService, user, updatedUser);
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -12,6 +12,7 @@ import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurit
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
@@ -112,6 +113,7 @@ export class AdminUserService {
billingRepository: getBillingRepository(),
oauth2Tokens: new OAuth2TokenRepository(),
storeEntitlementService: deps.storeEntitlementService,
ncmecRepository: new NcmecRepository(),
});
this.contactChangeLogService = contactChangeLog;
}
@@ -39,7 +39,7 @@ export class AdminGuildLookupService {
return {guild: null};
}
const [channels, roles, ownerUser] = await Promise.all([
channelRepository.listGuildChannels(guildId),
channelRepository.listGuildChannels(guildId, 'maintenance'),
guildRepository.listRoles(guildId),
userRepository.findUnique(guild.ownerId),
]);
@@ -78,6 +78,7 @@ const adminEndpoints: Array<AdminEndpointCase> = [
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
@@ -20,6 +20,8 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
];
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {
ALL_THREADS_ACTIVE,
resetChannelThreadsConfig,
setChannelThreadsConfig,
threadsRequest,
} from '@app/api/channel/tests/ThreadTestUtils';
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {ServerMessageFlags} from '@fluxer/constants/src/ThreadConstants';
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface BrowseResponse {
messages: Array<{id: string; channel_id: string}>;
message_responses?: Array<MessageResponse>;
}
describe('admin thread browse', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
resetChannelThreadsConfig();
});
afterAll(async () => {
resetChannelThreadsConfig();
await harness.shutdown();
});
it('returns thread messages and thread artifacts unmasked', async () => {
await setChannelThreadsConfig(ALL_THREADS_ACTIVE);
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, 'browse');
const channel = await createChannel(harness, owner.token, guild.id, 'general');
const source = await sendMessage(harness, owner.token, channel.id, 'source');
await threadsRequest(harness, owner.token)
.post(`/channels/${channel.id}/messages/${source.id}/threads`)
.body({name: 'from source'})
.expect(201)
.execute();
const standalone = await threadsRequest<ThreadChannelResponse>(harness, owner.token)
.post(`/channels/${channel.id}/threads`)
.body({name: 'standalone', type: ChannelTypes.PUBLIC_THREAD})
.expect(201)
.execute();
const inThread = await threadsRequest<MessageResponse>(harness, owner.token)
.post(`/channels/${standalone.id}/messages`)
.body({content: 'inside'})
.expect(200)
.execute();
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.MESSAGE_LOOKUP,
]);
const parent = await createBuilder<BrowseResponse>(harness, admin.token)
.get(`/admin/channels/${channel.id}/messages?limit=50`)
.expect(200)
.execute();
const responses = parent.message_responses ?? [];
const sourceResponse = responses.find((message) => message.id === source.id);
expect((sourceResponse?.flags ?? 0) & ServerMessageFlags.HAS_THREAD).toBe(ServerMessageFlags.HAS_THREAD);
expect(responses.some((message) => message.type === MessageTypes.THREAD_CREATED)).toBe(true);
const thread = await createBuilder<BrowseResponse>(harness, admin.token)
.get(`/admin/channels/${standalone.id}/messages?limit=50`)
.expect(200)
.execute();
expect(thread.messages.map((message) => message.id)).toContain(inThread.id);
});
});
@@ -2,11 +2,12 @@
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {HTTP_STATUS, TEST_CREDENTIALS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
interface ChangeLogResponse {
entries: Array<{
@@ -44,6 +45,9 @@ describe('Admin User Change Log and Flags', () => {
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
@@ -176,6 +180,37 @@ describe('Admin User Change Log and Flags', () => {
.execute();
});
});
describe('PATCH /admin/users/{user_id}/email', () => {
test('queues a Stripe customer email sync for users with a Stripe customer', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${target.userId}/premium`)
.body({stripe_customer_id: 'cus_admin_email_sync'})
.expect(HTTP_STATUS.OK)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await createBuilder(harness, `${admin.token}`)
.patch(`/admin/users/${target.userId}/email`)
.body({email: `admin-changed-${Date.now()}@example.com`})
.expect(HTTP_STATUS.OK)
.execute();
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: target.userId});
});
test('does not queue a Stripe customer email sync for users without a Stripe customer', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
await createBuilder(harness, `${admin.token}`)
.patch(`/admin/users/${target.userId}/email`)
.body({email: `admin-changed-${Date.now()}@example.com`})
.expect(HTTP_STATUS.OK)
.execute();
expect(addJob).not.toHaveBeenCalledWith('syncStripeCustomerEmail', expect.anything());
});
});
describe('PUT /admin/users/{user_id}/email-verification', () => {
test('verifying email clears email_bounced', async () => {
const admin = await createTestAccount(harness);
@@ -21,6 +21,7 @@ import {ReportAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ReportA
import {SearchAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SearchAdminAuditCases';
import {StoreBillingAdminAuditCases} from '@app/api/admin/tests/audit_coverage/StoreBillingAdminAuditCases';
import {SystemDmAdminAuditCases} from '@app/api/admin/tests/audit_coverage/SystemDmAdminAuditCases';
import {ThreadAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ThreadAdminAuditCases';
import {UserAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserAdminAuditCases';
import {UserWriteAdminAuditCases} from '@app/api/admin/tests/audit_coverage/UserWriteAdminAuditCases';
import {VoiceAdminAuditCases} from '@app/api/admin/tests/audit_coverage/VoiceAdminAuditCases';
@@ -48,6 +49,7 @@ const ALL_CASES = [
...SearchAdminAuditCases,
...StoreBillingAdminAuditCases,
...SystemDmAdminAuditCases,
...ThreadAdminAuditCases,
...UserAdminAuditCases,
...UserWriteAdminAuditCases,
...VoiceAdminAuditCases,
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditCoverageCase} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {
ALL_THREADS_ACTIVE,
resetChannelThreadsConfig,
setChannelThreadsConfig,
threadsRequest,
} from '@app/api/channel/tests/ThreadTestUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
export const ThreadAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
{
method: 'GET',
route: '/admin/guilds/:guild_id/threads',
async prepare({harness, admin}) {
resetChannelThreadsConfig();
const guild = await createGuild(harness, admin.token, 'Audit Thread List Guild');
return {
request: {path: `/admin/guilds/${guild.id}/threads`},
expected: {
action: 'list_guild_threads',
targetType: 'guild',
targetId: guild.id,
metadata: {result_count: '0'},
},
};
},
},
{
method: 'DELETE',
route: '/admin/channels/:channel_id',
async prepare({harness, admin}) {
resetChannelThreadsConfig();
await setChannelThreadsConfig(ALL_THREADS_ACTIVE);
const guild = await createGuild(harness, admin.token, 'Audit Thread Delete Guild');
const channel = await createChannel(harness, admin.token, guild.id, 'general');
const thread = await threadsRequest<ThreadChannelResponse>(harness, admin.token)
.post(`/channels/${channel.id}/threads`)
.body({name: 'doomed', type: ChannelTypes.PUBLIC_THREAD})
.expect(201)
.execute();
return {
request: {path: `/admin/channels/${thread.id}`, expectStatus: 204},
expected: {
action: 'delete_thread',
targetType: 'channel',
targetId: thread.id,
metadata: {guild_id: guild.id, parent_id: channel.id, type: String(ChannelTypes.PUBLIC_THREAD)},
},
};
},
},
];
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describeAdminAuditCoverage} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
import {ThreadAdminAuditCases} from '@app/api/admin/tests/audit_coverage/ThreadAdminAuditCases';
describeAdminAuditCoverage('ThreadAdminController', ThreadAdminAuditCases);
@@ -191,6 +191,39 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/bot-status',
async prepare({harness}) {
const target = await createTestAccount(harness);
return {
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
expected: {
action: 'set_bot_status',
targetType: 'user',
targetId: target.userId,
metadata: {bot: 'true'},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/system-status',
async prepare(context) {
const target = await createTestAccount(context.harness);
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
return {
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
expected: {
action: 'set_system_status',
targetType: 'user',
targetId: target.userId,
metadata: {system: 'true'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/users/:user_id/username',
+11 -2
View File
@@ -3,6 +3,7 @@
import type {ILogger} from '@app/api/ILogger';
import {ActivityContextMiddleware} from '@app/api/infrastructure/activity/ActivityMeta';
import {AuditLogMiddleware} from '@app/api/middleware/AuditLogMiddleware';
import {ClientFeaturesMiddleware} from '@app/api/middleware/ClientFeaturesMiddleware';
import {ConcurrencyLimitMiddleware} from '@app/api/middleware/ConcurrencyLimitMiddleware';
import ContentFilterMiddleware from '@app/api/middleware/ContentFilterMiddleware';
import {GuildAvailabilityMiddleware} from '@app/api/middleware/GuildAvailabilityMiddleware';
@@ -57,10 +58,17 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
allowedHeaders: [
HttpHeaders.CONTENT_TYPE,
HttpHeaders.AUTHORIZATION,
'X-Requested-With',
'Accept-Language',
HttpHeaders.X_REQUESTED_WITH,
HttpHeaders.ACCEPT_LANGUAGE,
HttpHeaders.X_REQUEST_ID,
HttpHeaders.IF_NONE_MATCH,
HttpHeaders.X_AUDIT_LOG_REASON,
HttpHeaders.X_CAPTCHA_ID,
HttpHeaders.X_CAPTCHA_TOKEN,
HttpHeaders.X_FLUXER_CLIENT_INSTALLATION_ID,
HttpHeaders.X_FLUXER_FEATURES,
HttpHeaders.X_FLUXER_PLATFORM,
HttpHeaders.X_FLUXER_SUDO_MODE_JWT,
],
exposedHeaders: [HttpHeaders.X_FLUXER_VERSION, HttpHeaders.ETAG],
},
@@ -78,6 +86,7 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
);
routes.use(RequestErrorTelemetry);
routes.use(RequestCacheMiddleware);
routes.use(ClientFeaturesMiddleware);
if (nodeEnv === 'production') {
routes.use('*', async (ctx, next) => {
const host = ctx.req.header('host');
+39 -6
View File
@@ -29,7 +29,9 @@ import {
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateRequest,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
@@ -609,6 +611,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
Validator('json', HandoffInitiateRequest),
OpenAPI({
operationId: 'initiate_handoff',
summary: 'Initiate handoff',
@@ -617,10 +620,15 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
request: ctx.req.raw,
returnUri: ctx.req.valid('json')?.return_uri ?? null,
}),
);
},
);
app.get(
@@ -656,23 +664,47 @@ export function AuthController(app: HonoApp) {
OpenAPI({
operationId: 'complete_handoff',
summary: 'Complete handoff',
responseSchema: null,
statusCode: 204,
responseSchema: HandoffCompleteResponse,
statusCode: [200, 204],
security: [],
tags: ['Auth'],
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
description:
'Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').completeHandoff({
const response = await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
clientIp,
authToken: ctx.get('authToken') ?? undefined,
approverOrigin: ctx.req.header('origin'),
});
return response ? ctx.json(response) : ctx.body(null, 204);
},
);
app.post(
'/auth/handoff/:code/deny',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_DENY),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'deny_handoff',
summary: 'Deny handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').denyHandoff({code: ctx.req.valid('param').code, clientIp});
return ctx.body(null, 204);
},
);
@@ -726,6 +758,7 @@ export function AuthController(app: HonoApp) {
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
grant: ctx.req.valid('json').grant,
});
return ctx.json(response);
},
+3 -1
View File
@@ -6,6 +6,7 @@ import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {createEmailRevertToken} from '@app/api/BrandedTypes';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -44,7 +45,7 @@ export async function revertEmailChange(
user_id: string;
token: string;
}> {
const {users, gateway, contactChangeLog, config} = ctx.services;
const {users, gateway, contactChangeLog, config, worker} = ctx.services;
const {token, password, request} = params;
const tokenData = await users.getEmailRevertToken(token);
if (!tokenData) {
@@ -101,5 +102,6 @@ export async function revertEmailChange(
reason: 'user_requested',
actorUserId: user.id,
});
await enqueueStripeCustomerEmailSync(worker, user, updatedUser);
return {user_id: updatedUser.id.toString(), token: authToken};
}
+27 -5
View File
@@ -41,6 +41,7 @@ import type {
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusResponse,
@@ -141,6 +142,7 @@ interface AuthLogoutAuthSessionsRequest {
interface AuthHandoffInitiateRequest {
request: Request;
returnUri?: string | null;
}
interface AuthHandoffInfoRequest {
@@ -152,6 +154,7 @@ interface AuthHandoffStatusRequest {
code: string;
clientIp: string;
pollSecret?: string;
grant?: string;
}
interface AuthHandoffCancelRequest {
@@ -339,16 +342,18 @@ export class AuthRequestService {
return {available: !(await isUsernameTaken(this.apiContext.services.users, username))};
}
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
async initiateHandoff({request, returnUri}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({
origin,
initiatorOrigin: request.headers.get('origin'),
returnUri,
});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
poll_secret: result.pollSecret,
return_method: result.returnMethod,
};
}
@@ -376,16 +381,26 @@ export class AuthRequestService {
country: geo.countryName,
},
},
return_method: info.returnMethod,
};
}
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
async denyHandoff({code, clientIp}: AuthHandoffInfoRequest): Promise<void> {
await this.desktopHandoffService.denyHandoff(code, clientIp);
}
async completeHandoff({
data,
clientIp,
authToken,
approverOrigin,
}: AuthHandoffCompleteRequest): Promise<HandoffCompleteResponse | null> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
let createdToken: string | null = null;
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
const {initiatorOrigin, returnUrl} = await this.desktopHandoffService.completeHandoff(
data.code,
async (origin) => {
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
@@ -397,10 +412,12 @@ export class AuthRequestService {
return created;
},
clientIp,
data.return_method,
);
if (createdToken !== null) {
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
}
return returnUrl ? {return_url: returnUrl} : null;
}
private async recordPushSessionPredecessor(
@@ -428,8 +445,13 @@ export class AuthRequestService {
}
}
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
async getHandoffStatus({
code,
clientIp,
pollSecret,
grant,
}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret, grant);
return {
status: result.status,
token: result.token,
@@ -21,6 +21,7 @@ const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_TTL_SECONDS = 900;
const MAX_INFO_LOOKUPS = 3;
const POLL_SECRET_BYTES = 32;
const GRANT_BYTES = 32;
interface HandoffData {
createdAt: number;
@@ -28,14 +29,19 @@ interface HandoffData {
initiatorOrigin?: string | null;
infoLookupCount: number;
pollSecretHash: string;
returnUri?: string | null;
denied?: boolean;
}
interface HandoffTokenData {
token: string;
userId: string;
pollSecretHash: string;
grantHash?: string;
}
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
interface HandoffApproverData {
approvedAt: number;
}
@@ -62,15 +68,19 @@ function requireNormalizedHandoffCode(code: string): string {
return normalized;
}
function generatePollSecret(): string {
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
function generateSecret(byteLength: number): string {
return randomBytes(byteLength).toString('base64url');
}
function hashPollSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
function resolveReturnMethod(handoffData: HandoffData): DesktopHandoffReturnMethod {
return handoffData.returnUri ? 'deep_link' : 'code';
}
function secretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
if (!presented || !storedHash) {
return false;
}
@@ -85,32 +95,44 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
async initiateHandoff(args: {
origin: SessionOrigin;
initiatorOrigin?: string | null;
returnUri?: string | null;
}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
returnMethod: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = generateNormalizedHandoffCode();
const pollSecret = generatePollSecret();
const pollSecret = generateSecret(POLL_SECRET_BYTES);
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
initiatorOrigin: args.initiatorOrigin ?? null,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
returnUri: args.returnUri ?? null,
};
const expirySeconds = seconds('5 minutes');
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
const expiresAt = new Date(Date.now() + ms('5 minutes'));
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
return {
code: formatDesktopHandoffCode(normalizedCode),
expiresAt,
pollSecret,
returnMethod: resolveReturnMethod(handoffData),
};
}
async completeHandoff(
code: string,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<{initiatorOrigin: string | null}> {
returnMethod: DesktopHandoffReturnMethod = 'code',
): Promise<{initiatorOrigin: string | null; returnUrl: string | null}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
@@ -120,10 +142,13 @@ export class DesktopHandoffService {
throw new InvalidHandoffCodeError();
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
if (returnMethod === 'deep_link' && !handoffData.returnUri) {
throw new InvalidHandoffCodeError();
}
const remainingSeconds = Math.max(
0,
seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000),
@@ -132,15 +157,22 @@ export class DesktopHandoffService {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData(handoffData.origin);
const grant = returnMethod === 'deep_link' ? generateSecret(GRANT_BYTES) : null;
const tokenData: HandoffTokenData = {
token,
userId,
pollSecretHash: handoffData.pollSecretHash,
...(grant ? {grantHash: hashPollSecret(grant)} : {}),
};
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
let returnUrl: string | null = null;
if (grant && handoffData.returnUri) {
const params = new URLSearchParams({code: formatDesktopHandoffCode(normalizedCode), grant});
returnUrl = `${handoffData.returnUri}?${params.toString()}`;
}
return {initiatorOrigin: handoffData.initiatorOrigin ?? null, returnUrl};
}
async getHandoffInfo(
@@ -149,13 +181,14 @@ export class DesktopHandoffService {
): Promise<{
status: 'pending' | 'expired';
origin?: SessionOrigin;
returnMethod?: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
return {status: 'expired'};
}
@@ -172,15 +205,35 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {status: 'pending', origin: handoffData.origin};
return {status: 'pending', origin: handoffData.origin, returnMethod: resolveReturnMethod(handoffData)};
}
async denyHandoff(code: string, approverIp: string): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const approverKey = `${HANDOFF_APPROVER_PREFIX}${normalizedCode}`;
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const storedApprover = await cache.get<HandoffApproverData>(approverKey);
const handoffData = await cache.get<HandoffData>(codeKey);
if (!storedApprover || !handoffData) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
const remainingTtl = await cache.ttl(codeKey);
if (remainingTtl > 0) {
await cache.set(codeKey, {...handoffData, denied: true}, remainingTtl);
}
await cache.delete(approverKey);
}
async getHandoffStatus(
code: string,
pollerIp: string,
pollSecret: string | undefined,
grant?: string,
): Promise<{
status: 'pending' | 'completed' | 'expired';
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string;
userId?: string;
}> {
@@ -190,10 +243,19 @@ export class DesktopHandoffService {
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
if (tokenData) {
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
if (!secretMatches(pollSecret, tokenData.pollSecretHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
if (tokenData.grantHash) {
if (!grant) {
return {status: 'pending'};
}
if (!secretMatches(grant, tokenData.grantHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
}
await cache.delete(tokenKey);
return {
status: 'completed',
@@ -203,7 +265,7 @@ export class DesktopHandoffService {
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (handoffData) {
return {status: 'pending'};
return {status: handoffData.denied ? 'denied' : 'pending'};
}
return {status: 'expired'};
}
@@ -216,7 +278,7 @@ export class DesktopHandoffService {
const handoffData = await cache.get<HandoffData>(codeKey);
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
if (!pollSecretMatches(pollSecret, storedHash)) {
if (!secretMatches(pollSecret, storedHash)) {
throw new InvalidHandoffCodeError();
}
await cache.delete(codeKey);
@@ -9,8 +9,9 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface BouncedEmailRequestNewResponse {
ticket: string;
@@ -51,12 +52,21 @@ describe('Bounced email recovery flow', () => {
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows bounced users to replace email without original-email verification', async () => {
const account = await createTestAccount(harness);
await markEmailAsBounced(harness, account);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_bounced_email_sync'})
.expect(200)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const initialMe = await createBuilder<UserPrivateResponse>(harness, account.token)
.get('/users/@me')
.expect(200)
@@ -94,6 +104,7 @@ describe('Bounced email recovery flow', () => {
const finalMe = await createBuilder<UserPrivateResponse>(harness, account.token).get('/users/@me').execute();
expect(finalMe.email).toBe(replacementEmail);
expect(finalMe.email_bounced).toBe(false);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
});
it('rejects bounced-email recovery for accounts that are not marked as bounced', async () => {
const account = await createTestAccount(harness);
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
poll_secret: string;
return_method: 'deep_link' | 'code';
}
interface HandoffInfoResponse {
status: 'pending' | 'expired';
return_method?: 'deep_link' | 'code';
}
interface HandoffCompleteResponse {
return_url: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string | null;
user_id?: string | null;
}
const RETURN_URI = 'fluxer-canary://handoff';
describe('Auth desktop handoff deep link return', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function initiate(body: Record<string, unknown> | null): Promise<HandoffInitiateResponse> {
return createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(body)
.execute();
}
async function readStatus(code: string, body: Record<string, unknown>): Promise<HandoffStatusResponse> {
return createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.post(`/auth/handoff/${code}/status`)
.body(body)
.execute();
}
it('releases the token only to the app that presents both the poll secret and the grant', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
expect(initResp.return_method).toBe('deep_link');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('deep_link');
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(200)
.execute();
const returnUrl = new URL(completed.return_url);
expect(`${returnUrl.protocol}//${returnUrl.host}`).toBe(RETURN_URI);
expect(returnUrl.searchParams.get('code')).toBe(initResp.code);
const grant = returnUrl.searchParams.get('grant');
expect(grant).toBeTruthy();
const withoutGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(withoutGrant.status).toBe('pending');
expect(withoutGrant.token ?? null).toBeNull();
const wrongGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: 'not-the-grant'});
expect(wrongGrant.status).toBe('pending');
const grantWithoutSecret = await readStatus(initResp.code, {poll_secret: 'not-the-secret', grant: grant!});
expect(grantWithoutSecret.status).toBe('pending');
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: grant!});
expect(released.status).toBe('completed');
expect(released.token).toBeTruthy();
expect(released.user_id).toBe(login.userId);
});
it('keeps polling without a grant from counting as a failed attempt', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.execute();
for (let i = 0; i < 8; i++) {
const pending = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(pending.status).toBe('pending');
}
const grant = new URL(completed.return_url).searchParams.get('grant')!;
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant});
expect(released.status).toBe('completed');
});
it('still completes through the typed code when the app registered a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'code'})
.expect(204)
.execute();
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(released.status).toBe('completed');
});
it('reports the code return method when the app did not register a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate(null);
expect(initResp.return_method).toBe('code');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('code');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
it('rejects a return deep link outside the desktop app schemes', async () => {
for (const returnUri of ['https://evil.example/handoff', 'fluxer://handoff/extra', 'javascript://handoff']) {
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/initiate')
.body({return_uri: returnUri})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
it('tells the app when the browser declines the request and blocks later approval', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness)
.post(`/auth/handoff/${initResp.code}/deny`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness).post(`/auth/handoff/${initResp.code}/deny`).expect(204).execute();
const denied = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(denied.status).toBe('denied');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.status).toBe('expired');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
});
@@ -12,8 +12,9 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
@@ -129,6 +130,9 @@ describe('Email change flow', () => {
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
@@ -341,13 +345,14 @@ describe('Email change flow', () => {
.execute();
expect(updated.email).toBe(newEmail);
});
it('applies email changes for users who have ever purchased', async () => {
it('applies email changes for users who have ever purchased and syncs their Stripe customer', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/premium`)
.body({has_ever_purchased: true})
.body({has_ever_purchased: true, stripe_customer_id: 'cus_email_change_sync'})
.expect(200)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
@@ -387,9 +392,11 @@ describe('Email change flow', () => {
.execute();
expect(updated.email).toBe(newEmail);
expect(updated.has_ever_purchased).toBe(true);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
});
it('applies ordinary claimed email changes', async () => {
const account = await createTestAccount(harness);
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const startResp = await startEmailChange(harness, account, account.password);
const emails = await listTestEmails(harness, {recipient: account.email});
const originalEmail = findLastTestEmail(emails, 'email_change_original');
@@ -425,6 +432,7 @@ describe('Email change flow', () => {
.execute();
expect(updated.email).toBe(newEmail);
expect(updated.verified).toBe(true);
expect(addJob).not.toHaveBeenCalledWith('syncStripeCustomerEmail', expect.anything());
});
it('requires MFA (not password) for email_token apply when user has TOTP enabled', async () => {
const account = await createTestAccount(harness);
@@ -10,8 +10,9 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
interface EmailChangeStartResponse {
ticket: string;
@@ -130,11 +131,20 @@ describe('Email revert flow', () => {
await harness.reset();
await clearTestEmails(harness);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness?.shutdown();
});
it('restores original email and clears mfa', async () => {
const account = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post(`/test/users/${account.userId}/premium`)
.body({stripe_customer_id: 'cus_email_revert_sync'})
.expect(200)
.execute();
const addJob = vi.spyOn(NoopWorkerService.prototype, 'addJob');
const startResp = await startEmailChange(harness, account, account.password);
let originalProof: string;
if (startResp.require_original) {
@@ -178,6 +188,7 @@ describe('Email revert flow', () => {
expect(revertEmail?.metadata?.token).toBeDefined();
const revertToken = revertEmail!.metadata!.token!;
const newPassword = uniquePassword();
addJob.mockClear();
const revertResp = await createBuilderWithoutAuth<EmailRevertResponse>(harness)
.post('/auth/email-revert')
.body({
@@ -186,6 +197,7 @@ describe('Email revert flow', () => {
})
.execute();
expect(revertResp.token.length).toBeGreaterThan(0);
expect(addJob).toHaveBeenCalledWith('syncStripeCustomerEmail', {userId: account.userId});
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
const user = await createBuilder<UserPrivateResponse>(harness, revertResp.token).get('/users/@me').execute();
expect(user.email).toBe(account.email);
@@ -99,6 +99,17 @@ function serializeGuildTextChannel(channel: Channel, ctx: ContentWarningCtx): Ch
};
}
function serializeThreadOnlyChannel(channel: Channel, ctx: ContentWarningCtx): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
...serializeMessageableFields(channel),
...serializePositionableGuildChannelFields(channel),
topic: channel.topic,
...serializeContentWarningFields(channel, ctx),
rate_limit_per_user: channel.rateLimitPerUser,
};
}
function serializeGuildVoiceChannel(channel: Channel, ctx: ContentWarningCtx): ChannelResponse {
return {
...serializeBaseChannelFields(channel),
@@ -202,6 +213,10 @@ export async function mapChannelToResponse(params: MapChannelToResponseParams):
case ChannelTypes.GUILD_VOICE:
response = serializeGuildVoiceChannel(channel, ctx);
break;
case ChannelTypes.GUILD_FORUM:
case ChannelTypes.GUILD_MEDIA:
response = serializeThreadOnlyChannel(channel, ctx);
break;
case ChannelTypes.GUILD_CATEGORY:
response = serializeGuildCategoryChannel(channel, ctx);
break;
@@ -3,6 +3,8 @@
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {ChannelRepository as NewChannelRepository} from '@app/api/channel/repositories/ChannelRepository';
import type {GuildChannelListMode} from '@app/api/channel/repositories/IChannelDataRepository';
import type {UpsertMessageOptions} from '@app/api/channel/repositories/IMessageRepository';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {MessageRow} from '@app/api/database/types/MessageTypes';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
@@ -30,6 +32,10 @@ export class ChannelRepository extends IChannelRepository {
return this.repository.messageInteractions;
}
get threads() {
return this.repository.threads;
}
get crossposts() {
return this.repository.crossposts;
}
@@ -42,12 +48,12 @@ export class ChannelRepository extends IChannelRepository {
return this.repository.channelData.upsert(data);
}
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
return this.repository.channelData.updateLastMessageId(channelId, messageId);
async updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise<void> {
return this.repository.channelData.updateLastMessageId(channelId, messageId, opts);
}
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
return this.repository.channelData.delete(channelId, guildId);
async delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void> {
return this.repository.channelData.delete(channelId, guildId, type);
}
async listMessages(
@@ -63,8 +69,8 @@ export class ChannelRepository extends IChannelRepository {
return this.repository.messages.getMessage(channelId, messageId);
}
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
return this.repository.messages.upsertMessage(data, oldData);
async upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message> {
return this.repository.messages.upsertMessage(data, oldData, opts);
}
async deleteMessage(
@@ -176,8 +182,8 @@ export class ChannelRepository extends IChannelRepository {
);
}
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
return this.repository.channelData.listGuildChannels(guildId);
async listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>> {
return this.repository.channelData.listGuildChannels(guildId, mode);
}
async listChannels(channelIds: Array<ChannelID>): Promise<Array<Channel>> {
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {GuildChannelListMode} from '@app/api/channel/repositories/IChannelDataRepository';
import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {UpsertMessageOptions} from '@app/api/channel/repositories/IMessageRepository';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {MessageRow} from '@app/api/database/types/MessageTypes';
import type {Channel} from '@app/api/models/Channel';
@@ -13,11 +15,11 @@ export abstract class IChannelRepository extends IChannelRepositoryAggregate {
abstract upsert(data: ChannelRow): Promise<Channel>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void>;
abstract listGuildChannels(guildId: GuildID): Promise<Array<Channel>>;
abstract listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>>;
abstract listChannels(channelIds: Array<ChannelID>): Promise<Array<Channel>>;
@@ -32,7 +34,7 @@ export abstract class IChannelRepository extends IChannelRepositoryAggregate {
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message>;
abstract deleteMessage(
channelId: ChannelID,
@@ -2,6 +2,8 @@
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {createChannelID, createUserID} from '@app/api/BrandedTypes';
import {GatedJsonValidator} from '@app/api/channel/threads/GatedJsonValidator';
import {viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {GroupDmRecipientAddProtectionMiddleware} from '@app/api/middleware/GroupDmProtectionMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -9,13 +11,14 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {CLIENT_FEATURES_HEADER, parseClientFeaturesHeader} from '@app/api/utils/featureUtils';
import {Validator} from '@app/api/Validator';
import {ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {TEXT_THREAD_PARENT_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {
ChannelUpdateGatedRequest,
ChannelUpdateRequest,
ChannelUpdateRequestBody,
DeleteChannelQuery,
@@ -33,6 +36,8 @@ import {
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {Context} from 'hono';
const THREAD_PARENT_DEFAULT_KEYS = ['default_auto_archive_duration', 'default_thread_rate_limit_per_user'];
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -59,11 +64,7 @@ export function ChannelController(app: HonoApp) {
const requestCache = ctx.get('requestCache');
const channelRequestService = ctx.get('channelRequestService');
return ctx.json(
await channelRequestService.getChannelResponse({
userId,
channelId,
requestCache,
}),
await channelRequestService.getChannelResponse({viewer: viewerFromCtx(ctx), userId, channelId, requestCache}),
);
},
);
@@ -86,7 +87,7 @@ export function ChannelController(app: HonoApp) {
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const channelRequestService = ctx.get('channelRequestService');
return ctx.json(await channelRequestService.getSlowmodeState({user, channelId}));
return ctx.json(await channelRequestService.getSlowmodeState({viewer: viewerFromCtx(ctx), user, channelId}));
},
);
app.get(
@@ -109,7 +110,7 @@ export function ChannelController(app: HonoApp) {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const channelRequestService = ctx.get('channelRequestService');
return ctx.json(await channelRequestService.listRtcRegions({userId, channelId}));
return ctx.json(await channelRequestService.listRtcRegions({viewer: viewerFromCtx(ctx), userId, channelId}));
},
);
app.patch(
@@ -123,15 +124,17 @@ export function ChannelController(app: HonoApp) {
}
const channelId = createChannelID(result.data.channel_id);
const existing = await ctx.get('channelService').channelData.operations.getChannel({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId,
skipNsfwValidation: true,
});
ctx.set('channelUpdateType', existing.type);
ctx.set('channelUpdateGuildId', existing.guildId?.toString());
return undefined;
},
}),
Validator('json', ChannelUpdateRequest, {
GatedJsonValidator(ChannelUpdateRequest, ChannelUpdateGatedRequest, {
pre: async (raw: unknown, ctx: Context<HonoEnv>) => {
const channelType = ctx.get('channelUpdateType');
if (channelType === undefined) {
@@ -152,6 +155,20 @@ export function ChannelController(app: HonoApp) {
}
return {...body, type: requestedType};
},
touchesGate: (body, ctx) => {
const channelType = ctx.get('channelUpdateType');
if (channelType === undefined) return false;
if (THREAD_ONLY_CHANNEL_TYPES.has(channelType)) return true;
return (
TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channelType) &&
isPlainObject(body) &&
THREAD_PARENT_DEFAULT_KEYS.some((key) => body[key] !== undefined)
);
},
active: (ctx) => {
const guildId = ctx.get('channelUpdateGuildId');
return guildId !== undefined && viewerActive(viewerFromCtx(ctx), guildId);
},
}),
OpenAPI({
operationId: 'update_channel',
@@ -171,12 +188,13 @@ export function ChannelController(app: HonoApp) {
const existingType = ctx.get('channelUpdateType');
const typeConversion =
existingType !== undefined && data.type !== existingType ? {from: existingType, to: data.type} : null;
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
const clientFeatures = ctx.get('clientFeatures');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
return ctx.json(
await channelRequestService.updateChannel({
viewer: viewerFromCtx(ctx),
userId,
channelId,
data,
@@ -217,14 +235,23 @@ export function ChannelController(app: HonoApp) {
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
await ctx.get('channelService').channelData.operations.getChannel({userId, channelId});
await ctx
.get('channelService')
.channelData.operations.getChannel({viewer: viewerFromCtx(ctx), userId, channelId});
if (delete_messages) {
await requireSudoMode(ctx, user, body);
await ctx.get('channelService').userMessageDeletion.deleteUserMessagesInScope(userId, {
channelIds: [channelId],
});
}
await channelRequestService.deleteChannel({userId, channelId, requestCache, silent, auditLogReason});
await channelRequestService.deleteChannel({
viewer: viewerFromCtx(ctx),
userId,
channelId,
requestCache,
silent,
auditLogReason,
});
return ctx.body(null, 204);
},
);
@@ -286,7 +313,9 @@ export function ChannelController(app: HonoApp) {
const body = ctx.req.valid('json');
const requestCache = ctx.get('requestCache');
if (delete_messages && recipientId === userId) {
await ctx.get('channelService').channelData.operations.getChannel({userId, channelId});
await ctx
.get('channelService')
.channelData.operations.getChannel({viewer: viewerFromCtx(ctx), userId, channelId});
await requireSudoMode(ctx, ctx.get('user'), body);
await ctx.get('channelService').userMessageDeletion.deleteUserMessagesInScope(userId, {
channelIds: [channelId],
@@ -319,7 +348,7 @@ export function ChannelController(app: HonoApp) {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const overwriteId = ctx.req.valid('param').overwrite_id;
const data = ctx.req.valid('json');
const clientFeatures = parseClientFeaturesHeader(ctx.req.header(CLIENT_FEATURES_HEADER));
const clientFeatures = ctx.get('clientFeatures');
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').channelData.operations.setChannelPermissionOverwrite({
@@ -332,6 +361,7 @@ export function ChannelController(app: HonoApp) {
deny_: data.deny ? data.deny : 0n,
},
clientFeatures,
viewer: viewerFromCtx(ctx),
requestCache,
auditLogReason,
});
@@ -363,6 +393,8 @@ export function ChannelController(app: HonoApp) {
userId,
channelId,
overwriteId,
clientFeatures: ctx.get('clientFeatures'),
viewer: viewerFromCtx(ctx),
requestCache,
auditLogReason,
});
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID} from '@app/api/BrandedTypes';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -37,6 +38,7 @@ export function ChannelFollowController(app: HonoApp) {
assertAccountNotLimited(ctx.get('user'));
const followed = await ctx.get('channelFollowService').followChannel({
userId: ctx.get('user').id,
viewer: viewerFromCtx(ctx),
channelId: createChannelID(ctx.req.valid('param').channel_id),
webhookChannelId: createChannelID(ctx.req.valid('json').webhook_channel_id),
requestCache: ctx.get('requestCache'),
@@ -66,6 +68,7 @@ export function ChannelFollowController(app: HonoApp) {
async (ctx) => {
const stats = await ctx.get('channelFollowService').getFollowerStats({
userId: ctx.get('user').id,
viewer: viewerFromCtx(ctx),
channelId: createChannelID(ctx.req.valid('param').channel_id),
});
return ctx.json(stats);
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID} from '@app/api/BrandedTypes';
import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {
ChannelIdTagIdParam,
ForumTagRequest,
SearchIndexNotReadyResponse,
ThreadPostDataRequest,
ThreadPostDataResponse,
ThreadSearchQuery,
ThreadSearchResult,
} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas';
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {Context} from 'hono';
const EXPERIMENT = 'channel_threads';
const TAGS = 'Channels';
function tagEditParams(ctx: Context<HonoEnv>) {
return {
userId: ctx.get('user').id,
viewer: viewerFromCtx(ctx),
clientFeatures: ctx.get('clientFeatures'),
requestCache: ctx.get('requestCache'),
auditLogReason: ctx.get('auditLogReason') ?? null,
};
}
export function ForumController(app: HonoApp) {
app.get(
'/channels/:channel_id/threads/search',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_SEARCH),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadSearchQuery),
OpenAPI({
operationId: 'search_threads',
summary: 'Search threads',
description:
'Returns threads of the channel that match the search. Requires the read message history permission. While the search index of the guild is being built, responds with 202 and a body that says when to retry.',
responseSchema: ThreadSearchResult,
acceptedResponseSchema: SearchIndexNotReadyResponse,
statusCode: [200, 202],
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
return ctx.json(
await ctx.get('threadService').forum.search({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
query: ctx.req.valid('query'),
}),
);
},
);
app.post(
'/channels/:channel_id/post-data',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_POST_DATA),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('json', ThreadPostDataRequest),
OpenAPI({
operationId: 'get_channel_post_data',
summary: 'Get forum post data',
description:
'Returns the owner and first message of each requested post in a forum or media channel. Requires the read message history permission.',
responseSchema: ThreadPostDataResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
return ctx.json(
await ctx.get('threadService').forum.postData({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
threadIds: ctx.req.valid('json').thread_ids.map((id) => createChannelID(id)),
requestCache: ctx.get('requestCache'),
}),
);
},
);
app.post(
'/channels/:channel_id/tags',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_FORUM_TAGS),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('json', ForumTagRequest),
OpenAPI({
operationId: 'create_forum_tag',
summary: 'Create a forum tag',
description:
'Adds a tag to a forum or media channel. Requires the manage channels permission. Returns the updated channel.',
responseSchema: ChannelResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
return ctx.json(
await ctx.get('channelRequestService').editForumTags({
...tagEditParams(ctx),
channelId: createChannelID(ctx.req.valid('param').channel_id),
edit: {kind: 'create', tag: ctx.req.valid('json')},
}),
);
},
);
app.put(
'/channels/:channel_id/tags/:tag_id',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_FORUM_TAGS),
LoginRequired,
Validator('param', ChannelIdTagIdParam),
Validator('json', ForumTagRequest),
OpenAPI({
operationId: 'update_forum_tag',
summary: 'Update a forum tag',
description:
'Replaces a tag of a forum or media channel. Requires the manage channels permission. Returns the updated channel.',
responseSchema: ChannelResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {channel_id, tag_id} = ctx.req.valid('param');
return ctx.json(
await ctx.get('channelRequestService').editForumTags({
...tagEditParams(ctx),
channelId: createChannelID(channel_id),
edit: {kind: 'update', tagId: tag_id, tag: ctx.req.valid('json')},
}),
);
},
);
app.delete(
'/channels/:channel_id/tags/:tag_id',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_FORUM_TAGS),
LoginRequired,
Validator('param', ChannelIdTagIdParam),
OpenAPI({
operationId: 'delete_forum_tag',
summary: 'Delete a forum tag',
description:
'Removes a tag from a forum or media channel. Requires the manage channels permission. Returns the updated channel.',
responseSchema: ChannelResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {channel_id, tag_id} = ctx.req.valid('param');
return ctx.json(
await ctx.get('channelRequestService').editForumTags({
...tagEditParams(ctx),
channelId: createChannelID(channel_id),
edit: {kind: 'delete', tagId: tag_id},
}),
);
},
);
}
@@ -6,11 +6,13 @@ import {Config} from '@app/api/Config';
import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes';
import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility';
import {parseMultipartMessageData} from '@app/api/channel/services/message/MessageRequestParser';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
import {parseJsonPreservingLargeIntegers} from '@app/api/utils/LosslessJsonParser';
@@ -71,6 +73,7 @@ export function MessageController(app: HonoApp) {
const messageRequestService = ctx.get('messageRequestService');
return ctx.json(
await messageRequestService.listMessages({
viewer: viewerFromCtx(ctx),
userId,
channelId,
query: {
@@ -108,6 +111,7 @@ export function MessageController(app: HonoApp) {
const messageRequestService = ctx.get('messageRequestService');
return ctx.json(
await messageRequestService.listMessagesBulk({
viewer: viewerFromCtx(ctx),
userId,
requests: requests.map((request) => ({
channelId: createChannelID(request.channel_id),
@@ -148,6 +152,7 @@ export function MessageController(app: HonoApp) {
const messageRequestService = ctx.get('messageRequestService');
return ctx.json(
await messageRequestService.getMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
@@ -196,6 +201,7 @@ export function MessageController(app: HonoApp) {
return validationResult.data;
})();
const response = await messageRequestService.sendMessage({
viewer: viewerFromCtx(ctx),
user,
channelId,
data: validatedData as MessageRequest,
@@ -229,6 +235,7 @@ export function MessageController(app: HonoApp) {
const {attachments} = ctx.req.valid('json');
return ctx.json({
attachments: await ctx.get('channelService').attachments.requestPresignedAttachmentUploadUrls({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId,
clientIp,
@@ -262,6 +269,7 @@ export function MessageController(app: HonoApp) {
const {uploads} = ctx.req.valid('json');
return ctx.json({
uploads: await ctx.get('channelService').attachments.completeMultipartAttachmentUploads({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId,
clientIp,
@@ -314,6 +322,7 @@ export function MessageController(app: HonoApp) {
})();
return ctx.json(
await messageRequestService.editMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
@@ -364,9 +373,14 @@ export function MessageController(app: HonoApp) {
const messageId = createMessageID(message_id);
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx
.get('channelService')
.messages.deletion.deleteMessage({userId, channelId, messageId, requestCache, auditLogReason});
await ctx.get('channelService').messages.deletion.deleteMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
requestCache,
auditLogReason,
});
return ctx.body(null, 204);
},
);
@@ -393,6 +407,7 @@ export function MessageController(app: HonoApp) {
const attachmentId = createAttachmentID(attachment_id);
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').attachments.deleteAttachment({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId: messageId,
@@ -423,9 +438,13 @@ export function MessageController(app: HonoApp) {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const messageIds = ctx.req.valid('json').message_ids.map(createMessageID);
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx
.get('channelService')
.messages.deletion.bulkDeleteMessages({userId, channelId, messageIds, auditLogReason});
await ctx.get('channelService').messages.deletion.bulkDeleteMessages({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageIds,
auditLogReason,
});
return ctx.body(null, 204);
},
);
@@ -449,7 +468,7 @@ export function MessageController(app: HonoApp) {
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const {deletedCount} = await ctx
.get('channelService')
.messages.deletion.purgePersonalNotesMessages({userId, channelId});
.messages.deletion.purgePersonalNotesMessages({viewer: viewerFromCtx(ctx), userId, channelId});
return ctx.json({deleted_count: deletedCount});
},
);
@@ -475,7 +494,9 @@ export function MessageController(app: HonoApp) {
const userId = user.id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const body = ctx.req.valid('json');
await ctx.get('channelService').channelData.operations.getChannel({userId, channelId});
await ctx
.get('channelService')
.channelData.operations.getChannel({viewer: viewerFromCtx(ctx), userId, channelId});
await requireSudoMode(ctx, user, body);
await ctx.get('channelService').userMessageDeletion.deleteUserMessagesInScope(userId, {
channelIds: [channelId],
@@ -492,17 +513,20 @@ export function MessageController(app: HonoApp) {
operationId: 'indicate_typing',
summary: 'Indicate typing activity',
responseSchema: null,
statusCode: 204,
statusCode: [200, 204],
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Channels', 'Messages'],
description:
'Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content. Commonly called repeatedly while the user is composing a message.',
'Notifies other users in the channel that you are actively typing. Typing indicators typically expire after a short period (usually 10 seconds). Returns 204 No Content, or 200 with a JSON body holding the remaining slowmode cooldowns in message_send_cooldown_ms and thread_create_cooldown_ms when the user is rate limited. Commonly called repeatedly while the user is composing a message.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
const user = ctx.get('user');
const viewer = viewerFromCtx(ctx);
const channelId = createChannelID(ctx.req.valid('param').channel_id);
await ctx.get('channelService').interactions.startTyping({userId, channelId});
return ctx.body(null, 204);
const channelService = ctx.get('channelService');
const auth = await channelService.interactions.startTyping({viewer, userId: user.id, channelId});
const cooldown = await channelService.getTypingCooldown({user, viewer, auth});
return cooldown ? ctx.json(cooldown, 200) : ctx.body(null, 204);
},
);
app.post(
@@ -526,6 +550,7 @@ export function MessageController(app: HonoApp) {
return ctx.json(
await ctx.get('messageRequestService').crosspostMessage({
userId: ctx.get('user').id,
viewer: viewerFromCtx(ctx),
channelId: createChannelID(channel_id),
messageId: createMessageID(message_id),
requestCache: ctx.get('requestCache'),
@@ -553,6 +578,7 @@ export function MessageController(app: HonoApp) {
return ctx.json(
await ctx.get('messageRequestService').getCrosspostSource({
userId: ctx.get('user').id,
viewer: viewerFromCtx(ctx),
channelId: createChannelID(channel_id),
messageId: createMessageID(message_id),
requestCache: ctx.get('requestCache'),
@@ -589,6 +615,7 @@ export function MessageController(app: HonoApp) {
messageId,
mentionCount: mentionCount ?? 0,
manual,
capable: readStateCapable(ctx),
});
return ctx.body(null, 204);
},
@@ -3,14 +3,17 @@
import {createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
import {isPersonalNotesChannel} from '@app/api/channel/services/message/MessageHelpers';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import {THREAD_FEATURE_CHANNEL_TYPES, viewerActive, viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import {readStateCapable} from '@app/api/read_state/ReadStateChannelMeta';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {UnclaimedAccountCannotAddReactionsError} from '@fluxer/errors/src/domains/channel/UnclaimedAccountCannotAddReactionsError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {
@@ -53,9 +56,14 @@ export function MessageInteractionController(app: HonoApp) {
const requestCache = ctx.get('requestCache');
const {limit, before} = ctx.req.valid('query');
return ctx.json(
await ctx
.get('channelService')
.interactions.getChannelPins({userId, channelId, requestCache, limit, beforeTimestamp: before}),
await ctx.get('channelService').interactions.getChannelPins({
viewer: viewerFromCtx(ctx),
userId,
channelId,
requestCache,
limit,
beforeTimestamp: before,
}),
);
},
);
@@ -77,10 +85,32 @@ export function MessageInteractionController(app: HonoApp) {
async (ctx) => {
const userId = ctx.get('user').id;
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const channel = await ctx.get('channelService').channelData.operations.getChannelSystem(channelId);
const channelService = ctx.get('channelService');
const channel = await channelService.channelData.operations.getChannelSystem(channelId);
if (channel && THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) {
const viewer = viewerFromCtx(ctx);
if (channel.guildId === null || !viewerActive(viewer, channel.guildId)) {
return ctx.body(null, 204);
}
if (channel.isThreadOnly()) {
throw new InvalidChannelTypeError();
}
await channelService.channelData.auth.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
}
const timestamp = channel?.lastPinTimestamp;
if (timestamp != null) {
await ctx.get('readStateService').ackPins({userId, channelId, timestamp});
await ctx.get('readStateService').ackPins({
userId,
channelId,
timestamp,
capable: readStateCapable(ctx),
channel,
});
}
return ctx.body(null, 204);
},
@@ -108,6 +138,7 @@ export function MessageInteractionController(app: HonoApp) {
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.pinMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
@@ -140,6 +171,7 @@ export function MessageInteractionController(app: HonoApp) {
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
await ctx.get('channelService').interactions.unpinMessage({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
@@ -172,9 +204,15 @@ export function MessageInteractionController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const afterUserId = after ? createUserID(after) : undefined;
const result = await ctx
.get('channelService')
.interactions.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId});
const result = await ctx.get('channelService').interactions.getUsersForReaction({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
emoji,
limit,
after: afterUserId,
});
ctx.header('X-Has-More', result.has_more ? 'true' : 'false');
if (result.next_after !== null) {
ctx.header('X-Next-After', result.next_after);
@@ -205,9 +243,15 @@ export function MessageInteractionController(app: HonoApp) {
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
const afterUserId = after ? createUserID(after) : undefined;
const result = await ctx
.get('channelService')
.interactions.getUsersForReaction({userId, channelId, messageId, emoji, limit, after: afterUserId});
const result = await ctx.get('channelService').interactions.getUsersForReaction({
viewer: viewerFromCtx(ctx),
userId,
channelId,
messageId,
emoji,
limit,
after: afterUserId,
});
return ctx.json(
{
items: result.users,
@@ -248,6 +292,7 @@ export function MessageInteractionController(app: HonoApp) {
throw InputValidationError.fromCode('emoji', ValidationErrorCodes.MUST_START_SESSION_BEFORE_SENDING);
}
await ctx.get('channelService').interactions.addReaction({
viewer: viewerFromCtx(ctx),
userId: user.id,
sessionId,
channelId,
@@ -282,6 +327,7 @@ export function MessageInteractionController(app: HonoApp) {
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').interactions.removeOwnReaction({
viewer: viewerFromCtx(ctx),
userId,
sessionId,
channelId,
@@ -317,6 +363,7 @@ export function MessageInteractionController(app: HonoApp) {
const sessionId = ctx.req.valid('query').session_id;
const requestCache = ctx.get('requestCache');
await ctx.get('channelService').interactions.removeReaction({
viewer: viewerFromCtx(ctx),
userId,
sessionId,
channelId,
@@ -348,7 +395,9 @@ export function MessageInteractionController(app: HonoApp) {
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
await ctx.get('channelService').interactions.removeAllReactionsForEmoji({userId, channelId, messageId, emoji});
await ctx
.get('channelService')
.interactions.removeAllReactionsForEmoji({viewer: viewerFromCtx(ctx), userId, channelId, messageId, emoji});
return ctx.body(null, 204);
},
);
@@ -372,7 +421,9 @@ export function MessageInteractionController(app: HonoApp) {
const userId = ctx.get('user').id;
const channelId = createChannelID(channel_id);
const messageId = createMessageID(message_id);
await ctx.get('channelService').interactions.removeAllReactions({userId, channelId, messageId});
await ctx
.get('channelService')
.interactions.removeAllReactions({viewer: viewerFromCtx(ctx), userId, channelId, messageId});
return ctx.body(null, 204);
},
);
@@ -2,6 +2,7 @@
import {createChannelID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -40,7 +41,9 @@ export function StreamController(app: HonoApp) {
const user = ctx.get('user');
const {region} = ctx.req.valid('json');
const streamKey = ctx.req.valid('param').stream_key;
await ctx.get('streamService').updateStreamRegion({streamKey, region, userId: user.id});
await ctx
.get('streamService')
.updateStreamRegion({viewer: viewerFromCtx(ctx), streamKey, region, userId: user.id});
return ctx.body(null, 204);
},
);
@@ -64,7 +67,9 @@ export function StreamController(app: HonoApp) {
async (ctx) => {
const user = ctx.get('user');
const streamKey = ctx.req.valid('param').stream_key;
const preview = await ctx.get('streamService').getPreview({streamKey, userId: user.id});
const preview = await ctx
.get('streamService')
.getPreview({viewer: viewerFromCtx(ctx), streamKey, userId: user.id});
if (!preview) {
return ctx.body(null, 404);
}
@@ -102,6 +107,7 @@ export function StreamController(app: HonoApp) {
clientIpHeaderName: Config.proxy.client_ip_header,
});
const response = await ctx.get('streamService').createPreviewUploadUrl({
viewer: viewerFromCtx(ctx),
streamKey,
channelId: createChannelID(channel_id),
userId: user.id,
@@ -133,6 +139,7 @@ export function StreamController(app: HonoApp) {
const {thumbnail, channel_id, content_type} = ctx.req.valid('json');
const streamKey = ctx.req.valid('param').stream_key;
await ctx.get('streamService').uploadPreview({
viewer: viewerFromCtx(ctx),
streamKey,
channelId: createChannelID(channel_id),
userId: user.id,
@@ -161,7 +168,7 @@ export function StreamController(app: HonoApp) {
async (ctx) => {
const user = ctx.get('user');
const streamKey = ctx.req.valid('param').stream_key;
await ctx.get('streamService').deletePreview({streamKey, userId: user.id});
await ctx.get('streamService').deletePreview({viewer: viewerFromCtx(ctx), streamKey, userId: user.id});
return ctx.body(null, 204);
},
);
@@ -0,0 +1,530 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type ChannelID, createChannelID, createGuildID, createMessageID, createUserID} from '@app/api/BrandedTypes';
import type {MessageRequest} from '@app/api/channel/MessageTypes';
import {normalizeMessageRequestPayload} from '@app/api/channel/services/message/MessageRequestCompatibility';
import {parseMultipartMessageData} from '@app/api/channel/services/message/MessageRequestParser';
import type {ForumPostInput} from '@app/api/channel/services/thread/ThreadCreationService';
import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {BotOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {parseJsonPreservingLargeIntegers} from '@app/api/utils/LosslessJsonParser';
import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator';
import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {
ForumThreadMessageRequest,
StartForumThreadRequest,
StartForumThreadResponse,
StartThreadMultipartRequest,
StartThreadRequestBody,
} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas';
import {
ActiveThreadsResponse,
ArchivedThreadsQuery,
ArchivedThreadsResponse,
JoinedArchivedThreadsQuery,
StartThreadFromMessageRequest,
StartThreadRequest,
ThreadChannelResponse,
ThreadLocationQuery,
ThreadMemberGetQuery,
ThreadMemberListResponse,
ThreadMembersListQuery,
} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas';
import {
ChannelIdMessageIdParam,
ChannelIdParam,
ChannelIdUserIdParam,
GuildIdParam,
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {Context, MiddlewareHandler} from 'hono';
import {z} from 'zod';
const EXPERIMENT = 'channel_threads';
const TAGS = 'Channels';
function isMultipart(ctx: Context<HonoEnv>): boolean {
return (ctx.req.header('content-type') ?? '').includes('multipart/form-data');
}
async function readPayloadJson(ctx: Context<HonoEnv>): Promise<unknown> {
let payloadJson: unknown;
try {
payloadJson = (await ctx.req.parseBody())['payload_json'];
} catch {
throw InputValidationError.fromCode('multipart_form', ValidationErrorCodes.FAILED_TO_PARSE_MULTIPART_FORM_DATA);
}
if (payloadJson === undefined) return {};
if (typeof payloadJson === 'string') {
try {
return parseJsonPreservingLargeIntegers(payloadJson);
} catch {}
}
throw InputValidationError.fromCode('payload_json', ValidationErrorCodes.INVALID_JSON_IN_PAYLOAD_JSON);
}
async function readJsonBody(ctx: Context<HonoEnv>): Promise<unknown> {
if (isMultipart(ctx)) return readPayloadJson(ctx);
try {
const raw = await ctx.req.text();
return raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
} catch {
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
}
}
function parseWithSchema<T extends z.ZodType>(schema: T, value: unknown): z.output<T> {
const result = schema.safeParse(value);
if (!result.success) throw inputValidationErrorFromZodIssues(result.error.issues);
return result.data;
}
function SelfThreadMemberAlias(action: 'join' | 'leave'): MiddlewareHandler<HonoEnv> {
return async (ctx, next) => {
if (ctx.req.param('user_id') !== '@me') return next();
const {channel_id} = parseWithSchema(ChannelIdParam, {channel_id: ctx.req.param('channel_id')});
await ctx.get('threadService').members[action]({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(channel_id),
});
return ctx.body(null, 204);
};
}
function normalizeForumEnvelope(value: unknown): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const envelope = value as Record<string, unknown>;
return {...envelope, message: normalizeMessageRequestPayload(envelope.message)};
}
const ForumPostMultipartMessage = z.preprocess(
(value) =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? ((value as Record<string, unknown>).message ?? {})
: {},
ForumThreadMessageRequest,
);
async function parseForumPostBody(ctx: Context<HonoEnv>, channelId: ChannelID): Promise<ForumPostInput> {
if (!isMultipart(ctx)) {
return parseWithSchema(StartForumThreadRequest, normalizeForumEnvelope(await readJsonBody(ctx))) as ForumPostInput;
}
let envelope: unknown = null;
const message = (await parseMultipartMessageData(
ctx,
ctx.get('user'),
channelId,
ForumPostMultipartMessage as unknown as z.ZodType<MessageRequest>,
{
onPayloadParsed(payload) {
envelope = payload;
},
},
)) as MessageRequest;
const fields = parseWithSchema(StartForumThreadRequest.omit({message: true}), envelope ?? {});
return {...fields, message};
}
export function ThreadController(app: HonoApp) {
app.post(
'/channels/:channel_id/messages/:message_id/threads',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_CREATE),
LoginRequired,
Validator('param', ChannelIdMessageIdParam),
Validator('json', StartThreadFromMessageRequest),
OpenAPI({
operationId: 'start_thread_from_message',
summary: 'Start a thread from a message',
description:
'Creates a public thread from an existing message in a text channel. The thread shares the ID of the message, so a message can start one thread.',
responseSchema: ThreadChannelResponse,
statusCode: 201,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {channel_id, message_id} = ctx.req.valid('param');
const body = ctx.req.valid('json');
const thread = await ctx.get('threadService').creation.createFromMessage({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(channel_id),
messageId: createMessageID(message_id),
input: {
name: body.name,
autoArchiveDuration: body.auto_archive_duration,
rateLimitPerUser: body.rate_limit_per_user,
},
requestCache: ctx.get('requestCache'),
auditLogReason: ctx.get('auditLogReason') ?? null,
});
return ctx.json(thread, 201);
},
);
app.post(
'/channels/:channel_id/threads',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_CREATE),
LoginRequired,
Validator('param', ChannelIdParam),
OpenAPI({
operationId: 'start_thread',
summary: 'Start a thread',
description:
'Creates a thread that is not attached to an existing message. In a text channel the thread type is required. In a forum or media channel this creates a post, and the body carries the first message. The body can also be sent as multipart form data with the JSON in a payload_json field, and a post can attach files as files[n] parts.',
requestSchema: StartThreadRequestBody,
requestFormSchema: StartThreadMultipartRequest,
responseSchema: StartForumThreadResponse,
statusCode: 201,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const viewer = viewerFromCtx(ctx);
const user = ctx.get('user');
const channelId = createChannelID(ctx.req.valid('param').channel_id);
const creation = ctx.get('threadService').creation;
const parentAuth = await creation.authenticateParent(viewer, user.id, channelId);
const auditLogReason = ctx.get('auditLogReason') ?? null;
if (parentAuth.channel.isThreadOnly()) {
const body = await parseForumPostBody(ctx, channelId);
return ctx.json(
await creation.createForumPost({
viewer,
user,
parentAuth,
body,
requestCache: ctx.get('requestCache'),
auditLogReason,
}),
201,
);
}
if (!TEXT_THREAD_PARENT_CHANNEL_TYPES.has(parentAuth.channel.type)) throw new InvalidChannelTypeError();
const body = parseWithSchema(StartThreadRequest, await readJsonBody(ctx));
const thread = await creation.createTextThread({
user,
parentAuth,
type: body.type,
invitable: body.invitable,
input: {
name: body.name,
autoArchiveDuration: body.auto_archive_duration,
rateLimitPerUser: body.rate_limit_per_user,
},
auditLogReason,
});
return ctx.json(thread, 201);
},
);
app.get(
'/guilds/:guild_id/threads/active',
ChannelThreadsRouteGuard({botOnly: true}),
RateLimitMiddleware(RateLimitConfigs.GUILD_THREADS_ACTIVE),
LoginRequired,
BotOnly,
Validator('param', GuildIdParam),
OpenAPI({
operationId: 'list_guild_active_threads',
summary: 'List active guild threads',
description:
'Returns every active thread in the guild that the bot can view, newest first, with a thread member object for each thread the bot joined.',
responseSchema: ActiveThreadsResponse,
statusCode: 200,
security: ['botToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
return ctx.json(
await ctx.get('threadService').lists.listGuildActive({
userId: ctx.get('user').id,
guildId: createGuildID(ctx.req.valid('param').guild_id),
}),
);
},
);
app.get(
'/channels/:channel_id/threads/archived/public',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_ARCHIVED_LIST),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ArchivedThreadsQuery),
OpenAPI({
operationId: 'list_public_archived_threads',
summary: 'List public archived threads',
description:
'Returns archived public threads of the channel, most recently archived first. Requires the read message history permission.',
responseSchema: ArchivedThreadsResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {before, limit} = ctx.req.valid('query');
return ctx.json(
await ctx.get('threadService').lists.listPublicArchived({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
before: before ? new Date(before) : undefined,
limit,
}),
);
},
);
app.get(
'/channels/:channel_id/threads/archived/private',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_ARCHIVED_LIST),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ArchivedThreadsQuery),
OpenAPI({
operationId: 'list_private_archived_threads',
summary: 'List private archived threads',
description:
'Returns archived private threads of the text channel, most recently archived first. Requires the read message history and manage threads permissions.',
responseSchema: ArchivedThreadsResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {before, limit} = ctx.req.valid('query');
return ctx.json(
await ctx.get('threadService').lists.listPrivateArchived({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
before: before ? new Date(before) : undefined,
limit,
}),
);
},
);
app.get(
'/channels/:channel_id/users/@me/threads/archived/private',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREADS_ARCHIVED_LIST),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', JoinedArchivedThreadsQuery),
OpenAPI({
operationId: 'list_joined_private_archived_threads',
summary: 'List joined private archived threads',
description:
'Returns archived private threads of the text channel that the current user joined, newest first. Requires the read message history permission.',
responseSchema: ArchivedThreadsResponse,
statusCode: 200,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {before, limit} = ctx.req.valid('query');
return ctx.json(
await ctx.get('threadService').lists.listJoinedPrivateArchived({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
before: before !== undefined ? createChannelID(before) : undefined,
limit,
}),
);
},
);
app.get(
'/channels/:channel_id/thread-members',
ChannelThreadsRouteGuard({botOnly: true}),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBERS_LIST),
LoginRequired,
BotOnly,
Validator('param', ChannelIdParam),
Validator('query', ThreadMembersListQuery),
OpenAPI({
operationId: 'list_thread_members',
summary: 'List thread members',
description:
'Returns thread members ordered by user ID. Paginate with after and limit. Set with_member to include the guild member object of each thread member.',
responseSchema: ThreadMemberListResponse,
statusCode: 200,
security: ['botToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {with_member, after, limit} = ctx.req.valid('query');
return ctx.json(
await ctx.get('threadService').members.list({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(ctx.req.valid('param').channel_id),
after: after !== undefined ? createUserID(after) : undefined,
limit,
withMember: with_member,
requestCache: ctx.get('requestCache'),
}),
);
},
);
app.put(
'/channels/:channel_id/thread-members/@me',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'join_thread',
summary: 'Join a thread',
description: 'Adds the current user to the thread. The thread must not be archived.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
await ctx.get('threadService').members.join({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(ctx.req.valid('param').channel_id),
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/thread-members/@me',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'leave_thread',
summary: 'Leave a thread',
description: 'Removes the current user from the thread. The thread must not be archived.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
await ctx.get('threadService').members.leave({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(ctx.req.valid('param').channel_id),
});
return ctx.body(null, 204);
},
);
app.get(
'/channels/:channel_id/thread-members/:user_id',
ChannelThreadsRouteGuard({botOnly: true}),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_GET),
LoginRequired,
BotOnly,
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadMemberGetQuery),
OpenAPI({
operationId: 'get_thread_member',
summary: 'Get a thread member',
description: 'Returns the thread member object of the user when the user is a member of the thread.',
responseSchema: ThreadMemberResponse,
statusCode: 200,
security: ['botToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {channel_id, user_id} = ctx.req.valid('param');
return ctx.json(
await ctx.get('threadService').members.get({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(channel_id),
targetId: createUserID(user_id),
withMember: ctx.req.valid('query').with_member,
requestCache: ctx.get('requestCache'),
}),
);
},
);
app.put(
'/channels/:channel_id/thread-members/:user_id',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT),
LoginRequired,
SelfThreadMemberAlias('join'),
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'add_thread_member',
summary: 'Add a thread member',
description:
'Adds another guild member to the thread. Requires permission to send messages in threads, and the thread must not be archived.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {channel_id, user_id} = ctx.req.valid('param');
await ctx.get('threadService').members.add({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(channel_id),
targetId: createUserID(user_id),
});
return ctx.body(null, 204);
},
);
app.delete(
'/channels/:channel_id/thread-members/:user_id',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE),
LoginRequired,
SelfThreadMemberAlias('leave'),
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'remove_thread_member',
summary: 'Remove a thread member',
description:
'Removes a member from the thread. Requires the manage threads permission, or being the creator of a private thread. The thread must not be archived.',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'sessionToken'],
tags: TAGS,
experiment: EXPERIMENT,
}),
async (ctx) => {
const {channel_id, user_id} = ctx.req.valid('param');
await ctx.get('threadService').members.remove({
viewer: viewerFromCtx(ctx),
user: ctx.get('user'),
channelId: createChannelID(channel_id),
targetId: createUserID(user_id),
});
return ctx.body(null, 204);
},
);
}
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChannelID} from '@app/api/BrandedTypes';
import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard';
import {viewerFromCtx} from '@app/api/experiment/ChannelThreadsGate';
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {ThreadMemberResponse} from '@fluxer/schema/src/domains/channel/ThreadSchemas';
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ThreadMemberSettingsRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
export function ThreadMemberSettingsController(app: HonoApp) {
app.patch(
'/channels/:channel_id/thread-members/@me/settings',
ChannelThreadsRouteGuard(),
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_SETTINGS),
LoginRequired,
DefaultUserOnly,
Validator('param', ChannelIdParam),
Validator('json', ThreadMemberSettingsRequest),
OpenAPI({
operationId: 'update_thread_member_settings',
summary: 'Update thread settings',
description:
"Updates the current user's notification settings for a thread they are a member of. Returns the thread member, or 204 when nothing changed.",
responseSchema: ThreadMemberResponse,
statusCode: [200, 204],
bodylessStatusCodes: [204],
security: ['sessionToken'],
tags: 'Channels',
experiment: 'channel_threads',
}),
async (ctx) => {
const member = await ctx.get('threadService').memberSettings.update({
viewer: viewerFromCtx(ctx),
userId: ctx.get('user').id,
channelId: createChannelID(ctx.req.valid('param').channel_id),
data: ctx.req.valid('json'),
});
return member ? ctx.json(member, 200) : ctx.body(null, 204);
},
);
}
@@ -3,9 +3,12 @@
import {CallController} from '@app/api/channel/controllers/CallController';
import {ChannelController} from '@app/api/channel/controllers/ChannelController';
import {ChannelFollowController} from '@app/api/channel/controllers/ChannelFollowController';
import {ForumController} from '@app/api/channel/controllers/ForumController';
import {MessageController} from '@app/api/channel/controllers/MessageController';
import {MessageInteractionController} from '@app/api/channel/controllers/MessageInteractionController';
import {StreamController} from '@app/api/channel/controllers/StreamController';
import {ThreadController} from '@app/api/channel/controllers/ThreadController';
import {ThreadMemberSettingsController} from '@app/api/channel/controllers/ThreadMemberSettingsController';
import type {HonoApp} from '@app/api/types/HonoEnv';
export function registerChannelControllers(app: HonoApp) {
@@ -15,4 +18,7 @@ export function registerChannelControllers(app: HonoApp) {
MessageController(app);
CallController(app);
StreamController(app);
ThreadController(app);
ForumController(app);
ThreadMemberSettingsController(app);
}
@@ -1,14 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import {type ChannelID, channelIdToMessageId, type GuildID, type MessageID, type UserID} from '@app/api/BrandedTypes';
import {
privateChannelFanOutTargets,
privateChannelLastMessageIdPatch,
privateChannelMetadataPatch,
} from '@app/api/channel/PrivateChannelSnapshot';
import {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import {type GuildChannelListMode, IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
import {
BatchBuilder,
executeConditional,
fetchMany,
fetchManyInChunks,
fetchOne,
@@ -18,10 +19,13 @@ import {Db} from '@app/api/database/CassandraTypes';
import {buildPatchFromData, executeVersionedUpdate} from '@app/api/database/CassandraVersionedUpdate';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import {CHANNEL_COLUMNS} from '@app/api/database/types/ChannelTypes';
import type {ThreadStatsRow} from '@app/api/database/types/ThreadTypes';
import {guildActive, isTainted} from '@app/api/experiment/ChannelThreadsGate';
import {Logger} from '@app/api/Logger';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {Channel} from '@app/api/models/Channel';
import {Channels, ChannelsByGuild, PrivateChannels} from '@app/api/Tables';
import {Channels, ChannelsByGuild, PrivateChannels, ThreadOnlyChannelsByGuild, ThreadStats} from '@app/api/Tables';
import {THREAD_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
const FETCH_CHANNEL_BY_ID = Channels.select({
where: [Channels.where.eq('channel_id'), Channels.where.eq('soft_deleted')],
@@ -33,6 +37,11 @@ const FETCH_CHANNELS_BY_IDS = Channels.select({
const FETCH_GUILD_CHANNELS_BY_GUILD_ID = ChannelsByGuild.select({
where: ChannelsByGuild.where.eq('guild_id'),
});
const FETCH_THREAD_ONLY_CHANNELS_BY_GUILD_ID = ThreadOnlyChannelsByGuild.select({
where: ThreadOnlyChannelsByGuild.where.eq('guild_id'),
});
const THREAD_STATS_CAS_ATTEMPTS = 16;
const FETCH_THREAD_STATS = ThreadStats.select({where: ThreadStats.where.eq('thread_id'), limit: 1});
const FETCH_OPEN_PRIVATE_CHANNEL_TARGET = PrivateChannels.selectCql({
columns: ['user_id'],
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')],
@@ -70,12 +79,14 @@ export class ChannelDataRepository extends IChannelDataRepository {
Channels,
{initialData: oldData},
);
if (data.guild_id) {
if (data.guild_id && !THREAD_CHANNEL_TYPES.has(data.type)) {
await upsertOne(
ChannelsByGuild.upsertAll({
guild_id: data.guild_id,
channel_id: channelId,
}),
THREAD_ONLY_CHANNEL_TYPES.has(data.type)
? ThreadOnlyChannelsByGuild.upsertAll({guild_id: data.guild_id, channel_id: channelId})
: ChannelsByGuild.upsertAll({
guild_id: data.guild_id,
channel_id: channelId,
}),
);
}
const finalRow: ChannelRow = {...data, version: result.finalVersion ?? 0};
@@ -83,7 +94,7 @@ export class ChannelDataRepository extends IChannelDataRepository {
return new Channel(finalRow);
}
async updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void> {
async updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise<void> {
this.requestCache?.channels.delete(channelId);
const existing = await fetchOne<ChannelRow>(
FETCH_CHANNEL_BY_ID.bind({
@@ -92,6 +103,9 @@ export class ChannelDataRepository extends IChannelDataRepository {
}),
);
if (!existing) return;
if (opts?.isInsert && THREAD_CHANNEL_TYPES.has(existing.type) && messageId !== channelIdToMessageId(channelId)) {
await this.adjustThreadStats(channelId, 1, 1);
}
const prev = existing.last_message_id ?? null;
if (prev !== null && messageId <= prev) return;
await upsertOne(
@@ -100,6 +114,34 @@ export class ChannelDataRepository extends IChannelDataRepository {
void this.fanOutPrivateChannelLastMessageId(existing, messageId);
}
async adjustThreadStats(threadId: ChannelID, messageDelta: number, sentDelta: number): Promise<void> {
for (let attempt = 0; attempt < THREAD_STATS_CAS_ATTEMPTS; attempt++) {
const stats = await fetchOne<ThreadStatsRow>(FETCH_THREAD_STATS.bind({thread_id: threadId}));
const messageCount = Math.max(0, (stats?.message_count ?? 0) + messageDelta);
const totalMessageSent = Math.max(0, (stats?.total_message_sent ?? 0) + sentDelta);
const applied = await executeConditional(
stats
? ThreadStats.conditionalPatchByPk(
{thread_id: threadId},
{message_count: Db.set(messageCount), total_message_sent: Db.set(totalMessageSent)},
{message_count: stats.message_count ?? null, total_message_sent: stats.total_message_sent ?? null},
)
: ThreadStats.insertIfNotExists({
thread_id: threadId,
message_count: messageCount,
total_message_sent: totalMessageSent,
}),
);
if (applied) return;
}
Logger.warn({threadId: threadId.toString()}, 'Gave up adjusting thread stats under contention');
}
async patchIndexedAt(channelId: ChannelID, indexedAt: Date): Promise<void> {
this.requestCache?.channels.delete(channelId);
await upsertOne(Channels.patchByPk({channel_id: channelId, soft_deleted: false}, {indexed_at: Db.set(indexedAt)}));
}
private async writeThroughPrivateChannelMetadata(row: ChannelRow): Promise<void> {
try {
const targets = await this.listOpenPrivateChannelTargets(row);
@@ -175,7 +217,7 @@ export class ChannelDataRepository extends IChannelDataRepository {
);
}
async delete(channelId: ChannelID, guildId?: GuildID): Promise<void> {
async delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void> {
this.requestCache?.channels.delete(channelId);
const batch = new BatchBuilder();
batch.addPrepared(
@@ -184,23 +226,30 @@ export class ChannelDataRepository extends IChannelDataRepository {
soft_deleted: false,
}),
);
if (guildId) {
if (guildId && (type === undefined || !THREAD_CHANNEL_TYPES.has(type))) {
batch.addPrepared(
ChannelsByGuild.deleteByPk({
guild_id: guildId,
channel_id: channelId,
}),
type !== undefined && THREAD_ONLY_CHANNEL_TYPES.has(type)
? ThreadOnlyChannelsByGuild.deleteByPk({guild_id: guildId, channel_id: channelId})
: ChannelsByGuild.deleteByPk({
guild_id: guildId,
channel_id: channelId,
}),
);
}
await batch.execute();
}
async listGuildChannels(guildId: GuildID): Promise<Array<Channel>> {
const guildChannels = await fetchMany<{
channel_id: bigint;
}>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}));
if (guildChannels.length === 0) return [];
const channelIds = guildChannels.map((c) => c.channel_id);
async listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>> {
const includeThreadOnly =
mode === 'enrolled' ? guildActive(guildId) : await isTainted(guildId, {fresh: mode === 'complete'});
const [guildChannels, threadOnlyChannels] = await Promise.all([
fetchMany<{channel_id: bigint}>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})),
includeThreadOnly
? fetchMany<{channel_id: bigint}>(FETCH_THREAD_ONLY_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}))
: Promise.resolve([]),
]);
if (guildChannels.length === 0 && threadOnlyChannels.length === 0) return [];
const channelIds = [...guildChannels, ...threadOnlyChannels].map((c) => c.channel_id);
const channels = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_BY_IDS, channelIds, (chunk) => ({
channel_ids: chunk,
soft_deleted: false,
@@ -218,9 +267,12 @@ export class ChannelDataRepository extends IChannelDataRepository {
}
async countGuildChannels(guildId: GuildID): Promise<number> {
const guildChannels = await fetchMany<{
channel_id: bigint;
}>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}));
return guildChannels.length;
const [guildChannels, threadOnlyChannels] = await Promise.all([
fetchMany<{channel_id: bigint}>(FETCH_GUILD_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId})),
guildActive(guildId)
? fetchMany<{channel_id: bigint}>(FETCH_THREAD_ONLY_CHANNELS_BY_GUILD_ID.bind({guild_id: guildId}))
: Promise.resolve([]),
]);
return guildChannels.length + threadOnlyChannels.length;
}
}
@@ -5,12 +5,15 @@ import {CrosspostedMessageRepository} from '@app/api/channel/repositories/Crossp
import {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import {MessageInteractionRepository} from '@app/api/channel/repositories/MessageInteractionRepository';
import {MessageRepository} from '@app/api/channel/repositories/MessageRepository';
import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository';
import {enqueueRepairThreadIndexes} from '@app/api/channel/threads/ThreadJobs';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
export class ChannelRepository extends IChannelRepositoryAggregate {
readonly channelData: ChannelDataRepository;
readonly messages: MessageRepository;
readonly messageInteractions: MessageInteractionRepository;
readonly threads: ThreadRepository;
readonly crossposts: CrosspostedMessageRepository;
constructor(requestCache?: RequestCache) {
@@ -18,6 +21,7 @@ export class ChannelRepository extends IChannelRepositoryAggregate {
this.channelData = new ChannelDataRepository(requestCache);
this.messages = new MessageRepository(this.channelData);
this.messageInteractions = new MessageInteractionRepository(this.messages);
this.threads = new ThreadRepository(this.channelData, this.messages, enqueueRepairThreadIndexes);
this.crossposts = new CrosspostedMessageRepository();
}
}
@@ -4,18 +4,22 @@ import type {ChannelID, GuildID, MessageID} from '@app/api/BrandedTypes';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {Channel} from '@app/api/models/Channel';
export type GuildChannelListMode = 'enrolled' | 'maintenance' | 'complete';
export abstract class IChannelDataRepository {
abstract findUnique(channelId: ChannelID): Promise<Channel | null>;
abstract upsert(data: ChannelRow): Promise<Channel>;
abstract upsert(data: ChannelRow, oldData?: ChannelRow | null): Promise<Channel>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID): Promise<void>;
abstract updateLastMessageId(channelId: ChannelID, messageId: MessageID, opts?: {isInsert?: boolean}): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID): Promise<void>;
abstract delete(channelId: ChannelID, guildId?: GuildID, type?: number): Promise<void>;
abstract listGuildChannels(guildId: GuildID): Promise<Array<Channel>>;
abstract listGuildChannels(guildId: GuildID, mode: GuildChannelListMode): Promise<Array<Channel>>;
abstract listChannels(channelIds: Array<ChannelID>): Promise<Array<Channel>>;
abstract countGuildChannels(guildId: GuildID): Promise<number>;
abstract patchIndexedAt(channelId: ChannelID, indexedAt: Date): Promise<void>;
}
@@ -4,10 +4,12 @@ import type {IChannelDataRepository} from '@app/api/channel/repositories/IChanne
import type {ICrosspostedMessageRepository} from '@app/api/channel/repositories/ICrosspostedMessageRepository';
import type {IMessageInteractionRepository} from '@app/api/channel/repositories/IMessageInteractionRepository';
import type {IMessageRepository} from '@app/api/channel/repositories/IMessageRepository';
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
export abstract class IChannelRepositoryAggregate {
abstract readonly channelData: IChannelDataRepository;
abstract readonly messages: IMessageRepository;
abstract readonly messageInteractions: IMessageInteractionRepository;
abstract readonly threads: IThreadRepository;
abstract readonly crossposts: ICrosspostedMessageRepository;
}
@@ -9,6 +9,11 @@ export interface ListMessagesOptions {
immediateAfter?: boolean;
}
export interface UpsertMessageOptions {
isInsert?: boolean;
skipParentLastMessageId?: boolean;
}
export abstract class IMessageRepository {
abstract listMessages(
channelId: ChannelID,
@@ -20,7 +25,7 @@ export abstract class IMessageRepository {
abstract getMessage(channelId: ChannelID, messageId: MessageID): Promise<Message | null>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message>;
abstract upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message>;
abstract updateEmbeds(message: Message): Promise<void>;
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {ChannelRow} from '@app/api/database/types/ChannelTypes';
import type {GuildThreadStateRow, ThreadParentConfigRow, ThreadStateRow} from '@app/api/database/types/ThreadTypes';
import type {MuteConfig} from '@app/api/database/types/UserTypes';
import type {ThreadMember} from '@app/api/models/ThreadMember';
import type {ThreadParentConfig} from '@app/api/models/ThreadParentConfig';
import type {ThreadState} from '@app/api/models/ThreadState';
import type {ThreadStats} from '@app/api/models/ThreadStats';
export interface CreateThreadMember {
userId: UserID;
flags: number;
}
export interface CreateThreadParams {
channel: ChannelRow;
parentType: number;
autoArchiveDuration: number;
invitable: boolean | null;
flags: number;
appliedTags: Array<bigint>;
hasStarter: boolean;
createdAt: Date;
members: Array<CreateThreadMember>;
}
export type ThreadStatePatch = Partial<
Pick<
ThreadStateRow,
'archived' | 'locked' | 'invitable' | 'auto_archive_duration' | 'archive_timestamp' | 'flags' | 'applied_tags'
>
>;
export interface ThreadStateTransition {
previous: ThreadState;
state: ThreadState;
}
export interface ArchivedThreadPage {
threads: Array<ThreadState>;
hasMore: boolean;
}
export interface ThreadMemberSettingsPatch {
flags?: number;
muted?: boolean;
muteConfig?: MuteConfig | null;
}
export interface ThreadMemberAddResult {
added: Array<ThreadMember>;
state: ThreadState;
}
export interface ThreadMemberRemoveResult {
removed: Array<ThreadMember>;
state: ThreadState | null;
}
export type ThreadParentConfigPatch = Partial<Omit<ThreadParentConfigRow, 'guild_id' | 'channel_id'>>;
export abstract class IThreadRepository {
abstract getState(threadId: ChannelID): Promise<ThreadState | null>;
abstract getStates(threadIds: Array<ChannelID>): Promise<Array<ThreadState>>;
abstract getStats(threadId: ChannelID): Promise<ThreadStats>;
abstract getStatsMany(threadIds: Array<ChannelID>): Promise<Map<ChannelID, ThreadStats>>;
abstract adjustMessageCount(threadId: ChannelID, delta: number): Promise<void>;
abstract create(params: CreateThreadParams): Promise<ThreadState>;
abstract updateState(
threadId: ChannelID,
mutate: (current: ThreadState) => ThreadStatePatch | null,
): Promise<ThreadStateTransition | null>;
abstract claimForumPin(parentId: ChannelID, threadId: ChannelID): Promise<boolean>;
abstract releaseForumPin(parentId: ChannelID, threadId: ChannelID): Promise<void>;
abstract getForumPin(parentId: ChannelID): Promise<ChannelID | null>;
abstract countActiveThreads(guildId: GuildID): Promise<number>;
abstract listActiveThreads(guildId: GuildID): Promise<Array<ThreadState>>;
abstract listArchivedThreads(
parentId: ChannelID,
isPrivate: boolean,
opts: {before?: Date; limit: number},
): Promise<ArchivedThreadPage>;
abstract listJoinedPrivateArchivedThreads(
userId: UserID,
guildId: GuildID,
parentId: ChannelID,
opts: {before?: ChannelID; limit: number},
): Promise<ArchivedThreadPage>;
abstract listJoinedThreadIds(userId: UserID, guildId: GuildID): Promise<Array<ChannelID>>;
abstract listJoinedPrivateThreadIds(
userId: UserID,
guildId: GuildID,
parentId: ChannelID,
limit: number,
): Promise<Array<ChannelID>>;
abstract repairThreadIndexes(threadIds: Array<ChannelID>): Promise<void>;
abstract getMember(threadId: ChannelID, userId: UserID): Promise<ThreadMember | null>;
abstract getMembers(threadId: ChannelID, userIds: Array<UserID>): Promise<Array<ThreadMember>>;
abstract listMembers(threadId: ChannelID, opts: {after?: UserID; limit: number}): Promise<Array<ThreadMember>>;
abstract addMembers(
threadId: ChannelID,
members: Array<CreateThreadMember>,
opts?: {joinTimestamp?: Date},
): Promise<ThreadMemberAddResult | null>;
abstract removeMembers(threadId: ChannelID, userIds: Array<UserID>): Promise<ThreadMemberRemoveResult>;
abstract updateMemberSettings(expected: ThreadMember, patch: ThreadMemberSettingsPatch): Promise<ThreadMember | null>;
abstract listThreadIdsByParent(
parentId: ChannelID,
opts: {after?: ChannelID; limit: number},
): Promise<Array<ChannelID>>;
abstract listParentThreads(parentId: ChannelID): Promise<Array<{threadId: ChannelID; type: number}>>;
abstract setThreadType(threadId: ChannelID, type: number): Promise<ThreadState | null>;
abstract listGuildThreadIds(
guildId: GuildID,
opts?: {activeSince?: Date; parents?: ReadonlyArray<{id: ChannelID; type: number}>},
): Promise<Array<ChannelID>>;
abstract purgeThread(threadId: ChannelID): Promise<void>;
abstract revertParentLastMessageId(
parentId: ChannelID,
threadId: ChannelID,
previous: MessageID | null,
): Promise<void>;
abstract purgeGuild(guildId: GuildID): Promise<void>;
abstract getGuildMarker(guildId: GuildID): Promise<GuildThreadStateRow | null>;
abstract ensureGuildMarker(guildId: GuildID, opts?: {permsSeededAt?: Date}): Promise<void>;
abstract markGuildPermsSeeded(guildId: GuildID, at: Date): Promise<void>;
abstract markGuildSearchBackfilled(guildId: GuildID, at: Date): Promise<void>;
abstract clearGuildSearchBackfilled(guildId: GuildID): Promise<void>;
abstract getParentConfig(guildId: GuildID, channelId: ChannelID): Promise<ThreadParentConfig | null>;
abstract listParentConfigs(guildId: GuildID): Promise<Array<ThreadParentConfig>>;
abstract patchParentConfig(guildId: GuildID, channelId: ChannelID, patch: ThreadParentConfigPatch): Promise<void>;
abstract deleteParentConfig(guildId: GuildID, channelId: ChannelID): Promise<void>;
}
@@ -2,7 +2,11 @@
import type {AttachmentID, ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {ChannelDataRepository} from '@app/api/channel/repositories/ChannelDataRepository';
import {IMessageRepository, type ListMessagesOptions} from '@app/api/channel/repositories/IMessageRepository';
import {
IMessageRepository,
type ListMessagesOptions,
type UpsertMessageOptions,
} from '@app/api/channel/repositories/IMessageRepository';
import {MessageAttachmentRepository} from '@app/api/channel/repositories/message/MessageAttachmentRepository';
import {MessageAuthorRepository} from '@app/api/channel/repositories/message/MessageAuthorRepository';
import {MessageDataRepository} from '@app/api/channel/repositories/message/MessageDataRepository';
@@ -40,10 +44,14 @@ export class MessageRepository extends IMessageRepository {
return this.dataRepo.getMessage(channelId, messageId);
}
async upsertMessage(data: MessageRow, oldData?: MessageRow | null): Promise<Message> {
async upsertMessage(data: MessageRow, oldData?: MessageRow | null, opts?: UpsertMessageOptions): Promise<Message> {
const message = await this.dataRepo.upsertMessage(data, oldData);
if (!oldData) {
await this.channelDataRepo.updateLastMessageId(data.channel_id, data.message_id);
if (!oldData && !opts?.skipParentLastMessageId) {
await this.channelDataRepo.updateLastMessageId(
data.channel_id,
data.message_id,
opts?.isInsert ? {isInsert: true} : undefined,
);
}
return message;
}
@@ -0,0 +1,992 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {spawnSync} from 'node:child_process';
import {readFileSync} from 'node:fs';
import {createServer} from 'node:net';
import {fileURLToPath} from 'node:url';
import {
type ChannelID,
channelIdToMessageId,
createChannelID,
createGuildID,
createMessageID,
createUserID,
type GuildID,
} from '@app/api/BrandedTypes';
import {ChannelRepository} from '@app/api/channel/repositories/ChannelRepository';
import type {CreateThreadParams} from '@app/api/channel/repositories/IThreadRepository';
import {ThreadRepository} from '@app/api/channel/repositories/ThreadRepository';
import {
type CassandraQueryExecutorForTesting,
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
setCassandraQueryExecutorForTesting,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {CHANNEL_COLUMNS, type ChannelRow} from '@app/api/database/types/ChannelTypes';
import {MESSAGE_COLUMNS} from '@app/api/database/types/MessageTypes';
import {
clearChannelThreadsTaintCacheForTesting,
syncChannelThreadsConfig,
} from '@app/api/experiment/ChannelThreadsGate';
import {
ActiveThreadsByGuild,
ArchivedThreadsByParent,
Channels,
ChannelsByGuild,
ForumPinnedThread,
GuildThreadState,
ThreadMembers,
ThreadMembersByUser,
ThreadOnlyChannelsByGuild,
ThreadParentConfig,
ThreadState,
ThreadStats,
ThreadsByParent,
} from '@app/api/Tables';
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {ChannelFlags, MAX_THREAD_MEMBERS} from '@fluxer/constants/src/ThreadConstants';
import {MaxThreadMembersError} from '@fluxer/errors/src/domains/channel/MaxThreadMembersError';
import {ThreadAlreadyCreatedForMessageError} from '@fluxer/errors/src/domains/channel/ThreadAlreadyCreatedForMessageError';
import {
type ChannelThreadsConfig,
ChannelThreadsConfigSchema,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
import {createSnowflakeFromTimestamp} from '@fluxer/snowflake/src/Snowflake';
import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/postgres/src/Client';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const GUILD_ID = createGuildID(1_900_000_000_000_000_000n);
const PARENT_ID = createChannelID(1_900_000_000_000_000_100n);
const FORUM_ID = createChannelID(1_900_000_000_000_000_200n);
const OWNER_ID = createUserID(1_900_000_000_000_001_000n);
class RecordingExecutor implements CassandraQueryExecutorForTesting {
readonly statements: Array<string> = [];
constructor(private readonly inner: CassandraQueryExecutorForTesting) {}
async executeQuery<T = Record<string, unknown>, P extends CassandraParams = CassandraParams>(
query: PreparedQuery<P>,
): Promise<Array<T>> {
const meta = query.kvMeta;
this.record(meta, meta?.conditions || meta?.ifNotExists || meta?.batchEntries ? 'cas' : undefined);
return this.inner.executeQuery<T, P>(query);
}
async executeBatch(
queries: Array<{query: string; params: object; meta?: KvQueryMeta}>,
atomic?: boolean,
): Promise<void> {
for (const entry of queries) this.record(entry.meta);
await this.inner.executeBatch(queries, atomic);
}
count(statement: string): number {
return this.statements.filter((entry) => entry === statement).length;
}
private record(meta: KvQueryMeta | undefined, prefix?: string): void {
if (!meta) return;
this.statements.push(`${prefix ?? meta.action}:${meta.table.name}`);
}
}
function parseConfig(raw: string | null): ChannelThreadsConfig {
return ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {});
}
function setThreadsConfig(patch: Partial<ChannelThreadsConfig> | null): void {
clearChannelThreadsTaintCacheForTesting();
syncChannelThreadsConfig(patch === null ? null : JSON.stringify(patch), parseConfig);
}
function channelRow(channelId: ChannelID, type: number, overrides: Partial<ChannelRow> = {}): ChannelRow {
return {
channel_id: channelId,
guild_id: GUILD_ID,
type,
name: `channel-${channelId}`,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: 0,
owner_id: null,
recipient_ids: null,
nsfw: false,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: 0,
bitrate: null,
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
...overrides,
};
}
let lastId = 0n;
function freshThreadId(offsetMs = 0): ChannelID {
const base = createSnowflakeFromTimestamp(Date.now() + offsetMs);
if (offsetMs !== 0) return createChannelID(base);
lastId = base > lastId ? base : lastId + 1n;
return createChannelID(lastId);
}
function createParams(threadId: ChannelID, overrides: Partial<CreateThreadParams> = {}): CreateThreadParams {
const type = overrides.channel?.type ?? ChannelTypes.PUBLIC_THREAD;
return {
channel: channelRow(threadId, type, {parent_id: PARENT_ID, owner_id: OWNER_ID, indexed_at: new Date()}),
parentType: ChannelTypes.GUILD_TEXT,
autoArchiveDuration: 4320,
invitable: null,
flags: 0,
appliedTags: [],
hasStarter: false,
createdAt: new Date(),
members: [{userId: OWNER_ID, flags: 1}],
...overrides,
};
}
function describeThreadRepository(backend: string, makeExecutor: () => Promise<CassandraQueryExecutorForTesting>) {
describe(`ThreadRepository (${backend})`, () => {
let executor: RecordingExecutor;
let repositories: ChannelRepository;
beforeEach(async () => {
executor = new RecordingExecutor(await makeExecutor());
setCassandraQueryExecutorForTesting(executor);
setThreadsConfig(null);
repositories = new ChannelRepository();
await upsertOne(Channels.upsertAll(channelRow(PARENT_ID, ChannelTypes.GUILD_TEXT)));
await upsertOne(ChannelsByGuild.upsertAll({guild_id: GUILD_ID, channel_id: PARENT_ID}));
executor.statements.length = 0;
});
afterEach(() => {
setThreadsConfig(null);
});
async function taint(guildId: GuildID = GUILD_ID): Promise<void> {
setThreadsConfig({enabled: true, ever_enabled: true, enabled_guild_ids: [guildId.toString()]});
await upsertOne(
GuildThreadState.upsertAll({
guild_id: guildId,
first_active_at: new Date(),
perms_seeded_at: null,
search_backfilled_at: null,
}),
);
}
it('writes a thread into its side tables and never into the guild channel index', async () => {
const threadId = freshThreadId();
const state = await repositories.threads.create(createParams(threadId));
expect(state.stateVersion).toBe(1);
expect(state.memberCount).toBe(1);
expect(state.memberIdsPreview).toEqual([OWNER_ID]);
const channel = await repositories.channelData.findUnique(threadId);
expect(channel?.isThread()).toBe(true);
expect(await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).toHaveLength(1);
expect(
await fetchOne(
ChannelsByGuild.selectCql({
where: [ChannelsByGuild.where.eq('guild_id'), ChannelsByGuild.where.eq('channel_id')],
}),
{guild_id: GUILD_ID, channel_id: threadId},
),
).toBeNull();
expect((await repositories.threads.listActiveThreads(GUILD_ID)).map((t) => t.threadId)).toEqual([threadId]);
expect(await repositories.threads.listThreadIdsByParent(PARENT_ID, {limit: 10})).toEqual([threadId]);
expect((await repositories.threads.getParentConfig(GUILD_ID, PARENT_ID))?.hasThreads).toBe(true);
expect((await repositories.threads.getMember(threadId, OWNER_ID))?.flags).toBe(1);
});
it('lets exactly one of two concurrent creates win', async () => {
const threadId = freshThreadId();
const results = await Promise.allSettled([
repositories.threads.create(createParams(threadId)),
repositories.threads.create(createParams(threadId)),
]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
const rejected = results.find((result) => result.status === 'rejected') as PromiseRejectedResult;
expect(rejected.reason).toBeInstanceOf(ThreadAlreadyCreatedForMessageError);
});
it('refuses a create over a fresh dangling state and repairs one older than 30 seconds', async () => {
const threadId = freshThreadId();
const dangling = {
thread_id: threadId,
guild_id: GUILD_ID,
parent_id: PARENT_ID,
type: ChannelTypes.PUBLIC_THREAD,
archived: false,
locked: false,
invitable: null,
auto_archive_duration: 4320,
archive_timestamp: new Date(),
created_at: new Date(),
flags: 0,
applied_tags: null,
member_count: 0,
member_ids_preview: null,
has_starter: false,
state_version: 1,
};
expect(await executeConditional(ThreadState.insertIfNotExists(dangling))).toBe(true);
await expect(repositories.threads.create(createParams(threadId))).rejects.toBeInstanceOf(
ThreadAlreadyCreatedForMessageError,
);
const staleId = freshThreadId();
expect(
await executeConditional(
ThreadState.insertIfNotExists({...dangling, thread_id: staleId, created_at: new Date(Date.now() - 31_000)}),
),
).toBe(true);
const repaired = await repositories.threads.create(createParams(staleId));
expect(repaired.memberCount).toBe(1);
expect(await repositories.channelData.findUnique(staleId)).not.toBeNull();
});
it('clears the crashed creator memberships when repairing a dangling create', async () => {
const threadId = freshThreadId();
const crashed = createUserID(1_900_000_000_000_003_000n);
const createdAt = new Date(Date.now() - 31_000);
expect(
await executeConditional(
ThreadState.insertIfNotExists({
thread_id: threadId,
guild_id: GUILD_ID,
parent_id: PARENT_ID,
type: ChannelTypes.PRIVATE_THREAD,
archived: false,
locked: false,
invitable: true,
auto_archive_duration: 4320,
archive_timestamp: createdAt,
created_at: createdAt,
flags: 0,
applied_tags: null,
member_count: 1,
member_ids_preview: [crashed],
has_starter: false,
state_version: 1,
}),
),
).toBe(true);
const memberRow = {
thread_id: threadId,
guild_id: GUILD_ID,
parent_id: PARENT_ID,
join_timestamp: createdAt,
flags: 1,
muted: false,
mute_config: null,
};
await upsertOne(ThreadMembers.upsertAll({...memberRow, user_id: crashed}));
await upsertOne(ThreadMembers.upsertAll({...memberRow, user_id: OWNER_ID}));
await upsertOne(
ThreadMembersByUser.upsertAll({
user_id: crashed,
guild_id: GUILD_ID,
parent_id: PARENT_ID,
is_private: true,
thread_id: threadId,
}),
);
const repaired = await repositories.threads.create(createParams(threadId));
expect(repaired.memberCount).toBe(1);
const members = await repositories.threads.listMembers(threadId, {limit: 10});
expect(members.map((member) => member.userId)).toEqual([OWNER_ID]);
expect(members[0]!.joinTimestamp.getTime()).not.toBe(createdAt.getTime());
expect(await repositories.threads.listJoinedThreadIds(crashed, GUILD_ID)).toEqual([]);
});
it('removes a private membership index row when the thread state is gone', async () => {
const threadId = freshThreadId();
const params = createParams(threadId);
params.channel.type = ChannelTypes.PRIVATE_THREAD;
await repositories.threads.create(params);
await deleteOneOrMany(ThreadState.deleteByPk({thread_id: threadId}));
const result = await repositories.threads.removeMembers(threadId, [OWNER_ID]);
expect(result.removed.map((member) => member.userId)).toEqual([OWNER_ID]);
expect(await repositories.threads.listJoinedThreadIds(OWNER_ID, GUILD_ID)).toEqual([]);
});
it('ignores a dangling threads_by_parent row in lists but enumerates it for maintenance', async () => {
await taint();
const threadId = freshThreadId();
await upsertOne(
ThreadsByParent.upsertAll({
parent_id: PARENT_ID,
thread_id: threadId,
guild_id: GUILD_ID,
type: ChannelTypes.PUBLIC_THREAD,
}),
);
expect(await repositories.threads.listActiveThreads(GUILD_ID)).toEqual([]);
expect(await repositories.threads.listGuildThreadIds(GUILD_ID)).toEqual([threadId]);
});
it('moves index rows on archive and unarchive and clears the pin', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId, {flags: ChannelFlags.PINNED}));
expect(await repositories.threads.claimForumPin(FORUM_ID, threadId)).toBe(true);
await upsertOne(ThreadState.patchByPk({thread_id: threadId}, {parent_id: {kind: 'set', value: FORUM_ID}}));
const archived = await repositories.threads.updateState(threadId, () => ({archived: true}));
expect(archived?.state.archived).toBe(true);
expect(archived?.state.isPinned).toBe(false);
expect(archived?.state.stateVersion).toBe(2);
expect(await repositories.threads.getForumPin(FORUM_ID)).toBeNull();
expect(await repositories.threads.listActiveThreads(GUILD_ID)).toEqual([]);
const page = await repositories.threads.listArchivedThreads(FORUM_ID, false, {limit: 10});
expect(page.threads.map((t) => t.threadId)).toEqual([threadId]);
const unarchived = await repositories.threads.updateState(threadId, () => ({archived: false}));
expect(unarchived?.state.archived).toBe(false);
expect((await repositories.threads.listActiveThreads(GUILD_ID)).map((t) => t.threadId)).toEqual([threadId]);
expect((await repositories.threads.listArchivedThreads(FORUM_ID, false, {limit: 10})).threads).toEqual([]);
});
it('serialises racing transitions through the state version', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId));
const [archive, lock] = await Promise.all([
repositories.threads.updateState(threadId, () => ({archived: true})),
repositories.threads.updateState(threadId, () => ({locked: true})),
]);
expect(archive).not.toBeNull();
expect(lock).not.toBeNull();
const state = await repositories.threads.getState(threadId);
expect(state?.archived).toBe(true);
expect(state?.locked).toBe(true);
expect(state?.stateVersion).toBe(3);
expect(await repositories.threads.listActiveThreads(GUILD_ID)).toEqual([]);
expect((await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toHaveLength(1);
});
it('drops and reports stale index rows, and repairs missing ones', async () => {
const drift = vi.fn();
const threads = new ThreadRepository(repositories.channelData, repositories.messages, drift);
const threadId = freshThreadId();
await threads.create(createParams(threadId));
await threads.updateState(threadId, () => ({archived: true}));
await upsertOne(
ActiveThreadsByGuild.upsertAll({
guild_id: GUILD_ID,
thread_id: threadId,
parent_id: PARENT_ID,
type: ChannelTypes.PUBLIC_THREAD,
}),
);
expect(await threads.listActiveThreads(GUILD_ID)).toEqual([]);
expect(drift).toHaveBeenCalledWith([threadId]);
expect(
await fetchMany(ActiveThreadsByGuild.selectCql({where: ActiveThreadsByGuild.where.eq('guild_id')}), {
guild_id: GUILD_ID,
}),
).toEqual([]);
const state = await threads.getState(threadId);
await executeConditional(
ArchivedThreadsByParent.conditionalDeleteByPk(
{parent_id: PARENT_ID, is_private: false, archive_timestamp: state!.archiveTimestamp!, thread_id: threadId},
{guild_id: GUILD_ID},
),
);
expect((await threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toEqual([]);
await threads.repairThreadIndexes([threadId]);
expect((await threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toHaveLength(1);
});
it('claims one pinned post per forum and recovers a stale claim', async () => {
const first = freshThreadId();
const second = freshThreadId();
await repositories.threads.create(createParams(first, {flags: ChannelFlags.PINNED}));
await repositories.threads.create(createParams(second));
expect(await repositories.threads.claimForumPin(FORUM_ID, first)).toBe(true);
expect(await repositories.threads.claimForumPin(FORUM_ID, first)).toBe(true);
expect(await repositories.threads.claimForumPin(FORUM_ID, second)).toBe(false);
await upsertOne(ThreadState.patchByPk({thread_id: first}, {flags: {kind: 'set', value: 0}}));
expect(await repositories.threads.claimForumPin(FORUM_ID, second)).toBe(true);
expect(await repositories.threads.getForumPin(FORUM_ID)).toBe(second);
});
it('pages archived threads newest first with before cursors and limits 2 and 100', async () => {
const ids: Array<ChannelID> = [];
for (let i = 0; i < 5; i++) {
const threadId = freshThreadId();
ids.push(threadId);
await repositories.threads.create(createParams(threadId));
await repositories.threads.updateState(threadId, () => ({
archived: true,
archive_timestamp: new Date(1_700_000_000_000 + i * 1000),
}));
}
const newestFirst = [...ids].reverse();
const first = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 2});
expect(first.threads.map((t) => t.threadId)).toEqual(newestFirst.slice(0, 2));
expect(first.hasMore).toBe(true);
const second = await repositories.threads.listArchivedThreads(PARENT_ID, false, {
limit: 2,
before: first.threads[1]!.archiveTimestamp!,
});
expect(second.threads.map((t) => t.threadId)).toEqual(newestFirst.slice(2, 4));
expect(second.hasMore).toBe(true);
const all = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 100});
expect(all.threads.map((t) => t.threadId)).toEqual(newestFirst);
expect(all.hasMore).toBe(false);
expect((await repositories.threads.listArchivedThreads(PARENT_ID, true, {limit: 100})).threads).toEqual([]);
});
it('pages past stale rows when several archived threads share one timestamp', async () => {
const tied = new Date(1_700_000_000_000);
const ids: Array<ChannelID> = [];
for (let i = 0; i < 4; i++) {
const threadId = freshThreadId();
ids.push(threadId);
await repositories.threads.create(createParams(threadId));
await repositories.threads.updateState(threadId, () => ({archived: true, archive_timestamp: tied}));
}
const older = freshThreadId();
await repositories.threads.create(createParams(older));
await repositories.threads.updateState(older, () => ({
archived: true,
archive_timestamp: new Date(tied.getTime() - 1000),
}));
for (const offset of [1n, 2n]) {
await upsertOne(
ArchivedThreadsByParent.upsertAll({
parent_id: PARENT_ID,
is_private: false,
archive_timestamp: tied,
thread_id: createChannelID(ids[3]! + 1000n * offset),
guild_id: GUILD_ID,
}),
);
}
const newestFirst = [...ids].reverse();
const first = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 2});
expect(first.threads.map((t) => t.threadId)).toEqual(newestFirst.slice(0, 2));
expect(first.hasMore).toBe(true);
const all = await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 4});
expect(all.threads.map((t) => t.threadId)).toEqual(newestFirst);
expect(all.hasMore).toBe(true);
});
it('lists only joined private archived threads, newest id first', async () => {
const member = createUserID(1_900_000_000_000_002_000n);
const privateIds: Array<ChannelID> = [];
for (let i = 0; i < 3; i++) {
const threadId = freshThreadId();
privateIds.push(threadId);
const params = createParams(threadId, {members: [{userId: member, flags: 1}]});
params.channel.type = ChannelTypes.PRIVATE_THREAD;
await repositories.threads.create(params);
await repositories.threads.updateState(threadId, () => ({archived: true}));
}
const publicId = freshThreadId();
await repositories.threads.create(createParams(publicId, {members: [{userId: member, flags: 1}]}));
await repositories.threads.updateState(publicId, () => ({archived: true}));
executor.statements.length = 0;
const page = await repositories.threads.listJoinedPrivateArchivedThreads(member, GUILD_ID, PARENT_ID, {limit: 2});
expect(page.threads.map((t) => t.threadId)).toEqual([privateIds[2], privateIds[1]]);
expect(page.hasMore).toBe(true);
const next = await repositories.threads.listJoinedPrivateArchivedThreads(member, GUILD_ID, PARENT_ID, {
limit: 2,
before: privateIds[1],
});
expect(next.threads.map((t) => t.threadId)).toEqual([privateIds[0]]);
expect(next.hasMore).toBe(false);
expect(page.threads.every((t) => t.isPrivate)).toBe(true);
});
it('adds a 250 member batch with one state compare-and-set', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId));
executor.statements.length = 0;
const members = Array.from({length: 250}, (_, index) => ({
userId: createUserID(1_900_000_000_010_000_000n + BigInt(index)),
flags: 0,
}));
const result = await repositories.threads.addMembers(threadId, members);
expect(result?.added).toHaveLength(250);
expect(result?.state.memberCount).toBe(251);
expect(result?.state.memberIdsPreview).toHaveLength(8);
expect(result?.state.memberIdsPreview[0]).toBe(members[249]!.userId);
expect(executor.count('cas:thread_state')).toBe(1);
expect(executor.count('cas:thread_members')).toBe(1);
const again = await repositories.threads.addMembers(threadId, members.slice(0, 3));
expect(again?.added).toEqual([]);
expect((await repositories.threads.listMembers(threadId, {limit: 1000})).length).toBe(251);
});
it('writes creator memberships and the seeded stamp only under compare-and-set', async () => {
const threadId = freshThreadId();
await repositories.threads.create(
createParams(threadId, {
members: [
{userId: OWNER_ID, flags: 1},
{userId: OWNER_ID, flags: 1},
],
}),
);
expect(executor.count('cas:thread_members')).toBe(1);
expect(executor.statements.some((entry) => entry.endsWith(':thread_members') && !entry.startsWith('cas:'))).toBe(
false,
);
expect((await repositories.threads.listMembers(threadId, {limit: 10})).map((m) => m.userId)).toEqual([OWNER_ID]);
const guildId = createGuildID(1_900_000_000_000_000_777n);
await repositories.threads.ensureGuildMarker(guildId);
expect((await repositories.threads.getGuildMarker(guildId))?.perms_seeded_at ?? null).toBeNull();
executor.statements.length = 0;
const seededAt = new Date(1_800_000_000_000);
await repositories.threads.markGuildPermsSeeded(guildId, seededAt);
await repositories.threads.markGuildPermsSeeded(guildId, new Date());
expect((await repositories.threads.getGuildMarker(guildId))?.perms_seeded_at).toEqual(seededAt);
expect(
executor.statements.filter((entry) => entry.endsWith(':guild_thread_state') && !entry.startsWith('select:')),
).toEqual(['cas:guild_thread_state']);
const fresh = createGuildID(1_900_000_000_000_000_778n);
await repositories.threads.markGuildPermsSeeded(fresh, seededAt);
expect((await repositories.threads.getGuildMarker(fresh))?.perms_seeded_at).toEqual(seededAt);
});
it('marks search backfill on a full marker row and clears it for a reindex', async () => {
const guildId = createGuildID(1_900_000_000_000_000_779n);
const at = new Date(1_800_000_000_000);
await repositories.threads.markGuildSearchBackfilled(guildId, at);
const marker = await repositories.threads.getGuildMarker(guildId);
expect(marker?.first_active_at).toBeInstanceOf(Date);
expect(marker?.search_backfilled_at).toEqual(at);
await repositories.threads.clearGuildSearchBackfilled(guildId);
const cleared = await repositories.threads.getGuildMarker(guildId);
expect(cleared?.search_backfilled_at ?? null).toBeNull();
expect(cleared?.first_active_at).toEqual(marker?.first_active_at);
});
it('refuses joins past the member cap', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId));
await upsertOne(
ThreadState.patchByPk({thread_id: threadId}, {member_count: {kind: 'set', value: MAX_THREAD_MEMBERS - 1}}),
);
await expect(
repositories.threads.addMembers(threadId, [
{userId: createUserID(1_900_000_000_020_000_001n), flags: 0},
{userId: createUserID(1_900_000_000_020_000_002n), flags: 0},
]),
).rejects.toBeInstanceOf(MaxThreadMembersError);
const single = await repositories.threads.addMembers(threadId, [
{userId: createUserID(1_900_000_000_020_000_003n), flags: 0},
]);
expect(single?.state.memberCount).toBe(MAX_THREAD_MEMBERS);
});
it('lets only one of two concurrent joins take the last member slot', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId));
await upsertOne(
ThreadState.patchByPk({thread_id: threadId}, {member_count: {kind: 'set', value: MAX_THREAD_MEMBERS - 1}}),
);
const joiners = [createUserID(1_900_000_000_025_000_001n), createUserID(1_900_000_000_025_000_002n)];
const results = await Promise.allSettled(
joiners.map((userId) => repositories.threads.addMembers(threadId, [{userId, flags: 0}])),
);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
const rejected = results.find((result) => result.status === 'rejected') as PromiseRejectedResult;
expect(rejected.reason).toBeInstanceOf(MaxThreadMembersError);
expect((await repositories.threads.getState(threadId))?.memberCount).toBe(MAX_THREAD_MEMBERS);
expect(await repositories.threads.listMembers(threadId, {limit: 100})).toHaveLength(2);
});
it('keeps the member count consistent under concurrent adds and removes', async () => {
const threadId = freshThreadId();
const leaving = createUserID(1_900_000_000_030_000_001n);
await repositories.threads.create(
createParams(threadId, {
members: [
{userId: OWNER_ID, flags: 1},
{userId: leaving, flags: 0},
],
}),
);
const joiners = [createUserID(1_900_000_000_030_000_002n), createUserID(1_900_000_000_030_000_003n)];
await Promise.all([
repositories.threads.addMembers(
threadId,
joiners.map((userId) => ({userId, flags: 0})),
),
repositories.threads.removeMembers(threadId, [leaving]),
repositories.threads.removeMembers(threadId, [leaving]),
]);
const state = await repositories.threads.getState(threadId);
const rows = await repositories.threads.listMembers(threadId, {limit: 100});
expect(rows.map((row) => row.userId).sort()).toEqual([OWNER_ID, ...joiners].sort());
expect(state?.memberCount).toBe(3);
expect(state?.memberIdsPreview).not.toContain(leaving);
expect(await repositories.threads.listJoinedThreadIds(leaving, GUILD_ID)).toEqual([]);
});
it('rolls back member rows when the member count compare-and-set gives up', async () => {
const threadId = freshThreadId();
const staying = createUserID(1_900_000_000_040_000_001n);
const joining = createUserID(1_900_000_000_040_000_002n);
await repositories.threads.create(
createParams(threadId, {
members: [
{userId: OWNER_ID, flags: 1},
{userId: staying, flags: 0},
],
}),
);
const executeQuery = executor.executeQuery.bind(executor);
const spy = vi.spyOn(executor, 'executeQuery').mockImplementation(async (query) => {
const meta = query.kvMeta;
if (meta?.table.name === 'thread_state' && meta.conditions) {
return [{'[applied]': false}] as never;
}
return executeQuery(query);
});
await expect(repositories.threads.addMembers(threadId, [{userId: joining, flags: 0}])).rejects.toThrow();
await expect(repositories.threads.removeMembers(threadId, [staying])).rejects.toThrow();
spy.mockRestore();
const rows = await repositories.threads.listMembers(threadId, {limit: 100});
expect(rows.map((row) => row.userId).sort()).toEqual([OWNER_ID, staying].sort());
expect((await repositories.threads.getState(threadId))?.memberCount).toBe(2);
const joined = await repositories.threads.addMembers(threadId, [{userId: joining, flags: 0}]);
expect(joined?.added.map((member) => member.userId)).toEqual([joining]);
expect(joined?.state.memberCount).toBe(3);
expect(await repositories.threads.listJoinedThreadIds(joining, GUILD_ID)).toEqual([threadId]);
const left = await repositories.threads.removeMembers(threadId, [staying]);
expect(left.removed.map((member) => member.userId)).toEqual([staying]);
expect(left.state?.memberCount).toBe(2);
});
it('counts inserted thread messages only, excludes the starter id and floors at zero', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId));
await repositories.channelData.updateLastMessageId(threadId, channelIdToMessageId(threadId), {isInsert: true});
expect((await repositories.threads.getStats(threadId)).messageCount).toBe(0);
await repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 10n));
expect((await repositories.threads.getStats(threadId)).messageCount).toBe(0);
await repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 20n), {isInsert: true});
await repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 30n), {isInsert: true});
const stats = await repositories.threads.getStats(threadId);
expect(stats.messageCount).toBe(2);
expect(stats.totalMessageSent).toBe(2);
await repositories.threads.adjustMessageCount(threadId, -5);
const floored = await repositories.threads.getStats(threadId);
expect(floored.messageCount).toBe(0);
expect(floored.totalMessageSent).toBe(2);
executor.statements.length = 0;
await repositories.channelData.updateLastMessageId(PARENT_ID, createMessageID(threadId + 40n), {isInsert: true});
expect(executor.count('select:thread_stats')).toBe(0);
});
it('keeps thread message counters exact under concurrent sends and deletes', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId));
await Promise.all(
Array.from({length: 8}, (_, index) =>
repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + BigInt(index + 1)), {
isInsert: true,
}),
),
);
const sent = await repositories.threads.getStats(threadId);
expect(sent.messageCount).toBe(8);
expect(sent.totalMessageSent).toBe(8);
await Promise.all([
repositories.threads.adjustMessageCount(threadId, -1),
repositories.threads.adjustMessageCount(threadId, -1),
repositories.channelData.updateLastMessageId(threadId, createMessageID(threadId + 100n), {isInsert: true}),
]);
const mixed = await repositories.threads.getStats(threadId);
expect(mixed.messageCount).toBe(7);
expect(mixed.totalMessageSent).toBe(9);
});
it('enumerates guild threads with and without an activity cutoff', async () => {
await taint();
const oldThread = freshThreadId(-86_400_000);
const newThread = freshThreadId();
await repositories.threads.create(createParams(oldThread));
await repositories.threads.create(createParams(newThread));
await repositories.channelData.updateLastMessageId(
newThread,
createMessageID(createSnowflakeFromTimestamp(Date.now())),
);
expect((await repositories.threads.listGuildThreadIds(GUILD_ID)).sort()).toEqual([oldThread, newThread].sort());
expect(
await repositories.threads.listGuildThreadIds(GUILD_ID, {activeSince: new Date(Date.now() - 3_600_000)}),
).toEqual([newThread]);
});
it('does no thread IO for never-enabled guilds', async () => {
const threadId = freshThreadId();
await upsertOne(
ThreadsByParent.upsertAll({parent_id: PARENT_ID, thread_id: threadId, guild_id: GUILD_ID, type: 11}),
);
executor.statements.length = 0;
expect(await repositories.threads.listGuildThreadIds(GUILD_ID)).toEqual([]);
expect(executor.statements).toEqual([]);
await repositories.channelData.listGuildChannels(GUILD_ID, 'maintenance');
await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled');
expect(executor.statements.every((entry) => /:(channels|channels_by_guild_id)$/.test(entry))).toBe(true);
expect(executor.count('select:guild_thread_state')).toBe(0);
});
it('indexes forums separately and merges them only for active or tainted guilds', async () => {
await repositories.channelData.upsert(channelRow(FORUM_ID, ChannelTypes.GUILD_FORUM));
expect(
await fetchMany(ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}), {
guild_id: GUILD_ID,
}),
).toHaveLength(1);
expect((await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).map((c) => c.id)).toEqual([
PARENT_ID,
]);
expect((await repositories.channelData.listGuildChannels(GUILD_ID, 'maintenance')).map((c) => c.id)).toEqual([
PARENT_ID,
]);
await taint();
expect(
new Set((await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).map((c) => c.id)),
).toEqual(new Set([PARENT_ID, FORUM_ID]));
setThreadsConfig({enabled: false, ever_enabled: true});
expect((await repositories.channelData.listGuildChannels(GUILD_ID, 'enrolled')).map((c) => c.id)).toEqual([
PARENT_ID,
]);
expect(
new Set((await repositories.channelData.listGuildChannels(GUILD_ID, 'maintenance')).map((c) => c.id)),
).toEqual(new Set([PARENT_ID, FORUM_ID]));
await repositories.channelData.delete(FORUM_ID, GUILD_ID, ChannelTypes.GUILD_FORUM);
expect(
await fetchMany(ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}), {
guild_id: GUILD_ID,
}),
).toEqual([]);
});
it('purges every thread row with threads_by_parent last', async () => {
const threadId = freshThreadId();
await repositories.threads.create(createParams(threadId, {members: [{userId: OWNER_ID, flags: 1}]}));
await repositories.threads.updateState(threadId, () => ({archived: true}));
executor.statements.length = 0;
await repositories.threads.purgeThread(threadId);
expect(executor.statements.at(-1)).toBe('delete:threads_by_parent');
expect(await repositories.threads.getState(threadId)).toBeNull();
expect(await repositories.channelData.findUnique(threadId)).toBeNull();
expect(await repositories.threads.listMembers(threadId, {limit: 10})).toEqual([]);
expect(await repositories.threads.listJoinedThreadIds(OWNER_ID, GUILD_ID)).toEqual([]);
expect((await repositories.threads.listArchivedThreads(PARENT_ID, false, {limit: 10})).threads).toEqual([]);
expect(await repositories.threads.listThreadIdsByParent(PARENT_ID, {limit: 10})).toEqual([]);
});
it('stores parent config through plain patches', async () => {
await repositories.threads.patchParentConfig(GUILD_ID, FORUM_ID, {
flags: ChannelFlags.REQUIRE_TAG,
available_tags: [{id: 5n, name: 'bug', moderated: false, emoji_id: null, emoji_name: '🐛'}],
default_sort_order: 1,
});
const config = await repositories.threads.getParentConfig(GUILD_ID, FORUM_ID);
expect(config?.flags).toBe(ChannelFlags.REQUIRE_TAG);
expect(config?.availableTags.map((tag) => tag.toUdt())).toEqual([
{id: 5n, name: 'bug', moderated: false, emoji_id: null, emoji_name: '🐛'},
]);
await repositories.threads.patchParentConfig(GUILD_ID, FORUM_ID, {available_tags: []});
expect((await repositories.threads.getParentConfig(GUILD_ID, FORUM_ID))?.availableTags).toEqual([]);
expect(await repositories.threads.listParentConfigs(GUILD_ID)).toHaveLength(1);
await repositories.threads.deleteParentConfig(GUILD_ID, FORUM_ID);
expect(
await fetchOne(
ThreadParentConfig.selectCql({
where: [ThreadParentConfig.where.eq('guild_id'), ThreadParentConfig.where.eq('channel_id')],
}),
{guild_id: GUILD_ID, channel_id: FORUM_ID},
),
).toBeNull();
});
});
}
const THREAD_TABLES = [
ThreadState,
ThreadStats,
ThreadsByParent,
ActiveThreadsByGuild,
ArchivedThreadsByParent,
ThreadMembers,
ThreadMembersByUser,
ThreadParentConfig,
ForumPinnedThread,
ThreadOnlyChannelsByGuild,
GuildThreadState,
];
describe('thread storage leaves control storage untouched', () => {
it('keeps the channel and message column lists byte-identical', () => {
expect([...CHANNEL_COLUMNS]).toEqual([
'channel_id',
'guild_id',
'type',
'name',
'topic',
'icon_hash',
'url',
'parent_id',
'position',
'owner_id',
'recipient_ids',
'nsfw',
'content_warning_level',
'content_warning_text',
'rate_limit_per_user',
'bitrate',
'user_limit',
'voice_connection_limit',
'rtc_region',
'last_message_id',
'last_pin_timestamp',
'permission_overwrites',
'nicks',
'soft_deleted',
'indexed_at',
'version',
]);
expect([...MESSAGE_COLUMNS]).toEqual([
'channel_id',
'bucket',
'message_id',
'author_id',
'type',
'webhook_id',
'webhook_name',
'webhook_avatar_hash',
'content',
'edited_timestamp',
'pinned_timestamp',
'flags',
'mention_everyone',
'mention_users',
'mention_roles',
'mention_channels',
'attachments',
'embeds',
'sticker_items',
'message_reference',
'message_snapshots',
'call',
'has_reaction',
'version',
]);
});
it('declares every thread table in the target schema with the same columns and no TTL', () => {
const schema = JSON.parse(
readFileSync(
fileURLToPath(new URL('../../../../../tools/dev/cassandra_target_schema.json', import.meta.url)),
'utf8',
),
) as {tables: Array<{name: string; columns: Array<{name: string}>; options: string}>};
for (const table of THREAD_TABLES) {
const declared = schema.tables.find((entry) => entry.name === table.name);
expect(declared, table.name).toBeDefined();
expect(new Set(declared!.columns.map((column) => column.name))).toEqual(new Set(table.columns));
expect(declared!.options).not.toContain('default_time_to_live');
expect(table.defaultTtlSeconds).toBeUndefined();
}
});
});
describeThreadRepository('in-memory cassandra', async () => new InMemoryCassandraQueryExecutor());
const dockerAvailable = spawnSync('docker', ['version'], {stdio: 'ignore'}).status === 0;
const KV_TABLE = 'kv_thread_repository';
const CONTAINER = `fluxer-threads-${process.pid.toString(36)}-${Date.now().toString(36)}`;
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address === 'string' || address === null) {
reject(new Error('no port'));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}
describe.skipIf(!dockerAvailable)('ThreadRepository against postgres kv', () => {
beforeAll(async () => {
const port = await freePort();
startDockerContainer([
'run',
'-d',
'--name',
CONTAINER,
'-e',
'POSTGRES_USER=fluxer',
'-e',
'POSTGRES_PASSWORD=fluxer',
'-e',
'POSTGRES_DB=fluxer',
'-p',
`127.0.0.1:${port}:5432`,
'postgres:16-alpine',
'-c',
'fsync=off',
]);
let ready = false;
for (let attempt = 0; attempt < 180 && !ready; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 500));
const probe = spawnSync('docker', ['exec', CONTAINER, 'pg_isready', '-U', 'fluxer', '-d', 'fluxer'], {
stdio: 'ignore',
});
if (probe.status !== 0) continue;
try {
await initPostgres({
url: `postgres://fluxer:[email protected]:${port}/fluxer`,
maxConnections: 8,
kvTable: KV_TABLE,
});
await getDefaultPostgresClient().query('SELECT 1');
ready = true;
} catch {
await shutdownPostgres().catch(() => {});
}
}
if (!ready) throw new Error('postgres never came up');
await ensurePostgresKvSchema(getDefaultPostgresClient());
}, 900_000);
afterAll(async () => {
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
await shutdownPostgres().catch(() => {});
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
});
describeThreadRepository('postgres kv', async () => {
const client = getDefaultPostgresClient();
await client.query(`DELETE FROM ${KV_TABLE}`);
return new PostgresKvQueryExecutor(client);
});
});
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@ import {
} from '@app/api/channel/services/message/MessageHelpers';
import {applyUploadRelayDecision, resolveUploadRelayDecision} from '@app/api/channel/services/UploadRelay';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
@@ -44,6 +45,7 @@ import {
ATTACHMENT_UPLOAD_MAX_CHUNKS,
resolveAttachmentUploadPartSize,
} from '@fluxer/constants/src/LimitConstants';
import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
@@ -64,6 +66,7 @@ import type {
interface DeleteAttachmentParams {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
attachmentId: AttachmentID;
@@ -74,6 +77,7 @@ type UploadActor = 'member' | 'webhook';
interface UploadFormDataAttachmentsParams {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
clientIp: string;
files: Array<{
@@ -89,6 +93,7 @@ interface UploadFormDataAttachmentsParams {
interface RequestPresignedAttachmentUploadUrlsParams {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
clientIp: string;
attachments: Array<PresignedAttachmentUploadRequestItem>;
@@ -96,6 +101,7 @@ interface RequestPresignedAttachmentUploadUrlsParams {
interface CompleteMultipartAttachmentUploadsParams {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
clientIp: string;
uploads: Array<CompleteMultipartAttachmentUploadItem>;
@@ -118,13 +124,14 @@ export class AttachmentUploadService {
async uploadFormDataAttachments({
userId,
viewer,
channelId,
clientIp,
files,
attachmentMetadata,
actor = 'member',
}: UploadFormDataAttachmentsParams): Promise<Array<UploadedAttachment>> {
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor});
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, viewer, channelId, actor});
assertAttachmentFileSizesWithinLimit(
files.map(({file}) => file.size),
maxFileSize,
@@ -172,6 +179,7 @@ export class AttachmentUploadService {
async requestPresignedAttachmentUploadUrls({
userId,
viewer,
channelId,
clientIp,
attachments,
@@ -179,7 +187,7 @@ export class AttachmentUploadService {
if (!Config.presignedAttachmentUploadsEnabled) {
throw new FeatureTemporarilyDisabledError();
}
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'});
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, viewer, channelId, actor: 'member'});
assertAttachmentFileSizesWithinLimit(
attachments.map(({file_size}) => file_size),
maxFileSize,
@@ -279,6 +287,7 @@ export class AttachmentUploadService {
async completeMultipartAttachmentUploads({
userId,
viewer,
channelId,
clientIp,
uploads,
@@ -286,7 +295,7 @@ export class AttachmentUploadService {
if (!Config.presignedAttachmentUploadsEnabled) {
throw new FeatureTemporarilyDisabledError();
}
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'});
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, viewer, channelId, actor: 'member'});
const bucket = Config.s3.buckets.uploads;
return Promise.all(
uploads.map(async ({upload_filename, upload_id}, index) => {
@@ -349,6 +358,7 @@ export class AttachmentUploadService {
async deleteAttachment({
userId,
viewer,
channelId,
messageId,
attachmentId,
@@ -357,6 +367,7 @@ export class AttachmentUploadService {
const {channel, guild} = await this.messageInteractionService.authService.getChannelAuthenticated({
userId,
channelId,
viewer,
});
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
throw new FeatureTemporarilyDisabledError();
@@ -380,6 +391,7 @@ export class AttachmentUploadService {
if (willBeEmpty) {
await this.messageService.deletion.deleteMessage({
userId,
viewer,
channelId,
messageId,
requestCache,
@@ -411,6 +423,7 @@ export class AttachmentUploadService {
if (!updatedMessage) {
await this.messageService.deletion.deleteMessage({
userId,
viewer,
channelId,
messageId,
requestCache,
@@ -449,10 +462,12 @@ export class AttachmentUploadService {
private async getUploadPermissionAndLimit({
userId,
viewer,
channelId,
actor,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
actor: UploadActor;
}): Promise<{
@@ -461,8 +476,8 @@ export class AttachmentUploadService {
const {channel, guild} =
actor === 'webhook'
? await this.getWebhookUploadChannel(channelId)
: await this.getMemberUploadChannel({userId, channelId});
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
: await this.getMemberUploadChannel({userId, viewer, channelId});
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type) && !THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) {
throw new CannotSendMessageToNonTextChannelError();
}
const user = await this.userRepository.findUnique(userId);
@@ -478,7 +493,15 @@ export class AttachmentUploadService {
return {maxFileSize};
}
private async getMemberUploadChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{
private async getMemberUploadChannel({
userId,
viewer,
channelId,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
}): Promise<{
channel: Channel;
guild: GuildResponse | null;
}> {
@@ -486,6 +509,7 @@ export class AttachmentUploadService {
await this.messageInteractionService.authService.getChannelAuthenticated({
userId,
channelId,
viewer,
});
if (guild) {
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
@@ -1,13 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/api/models/Channel';
import type {ThreadMember} from '@app/api/models/ThreadMember';
import type {ThreadState} from '@app/api/models/ThreadState';
import type {ThreadActorContext} from '@fluxer/constants/src/ThreadPermissionUtils';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
export interface AuthenticatedThread {
state: ThreadState;
parent: Channel;
member: ThreadMember | null;
parentPermissions: bigint;
actor: ThreadActorContext;
isModerator: boolean;
enforceMfa: (permission: bigint) => void;
}
export interface AuthenticatedChannel {
channel: Channel;
guild: GuildResponse | null;
member: GuildMemberResponse | null;
hasPermission: (permission: bigint) => Promise<boolean>;
checkPermission: (permission: bigint) => Promise<void>;
thread?: AuthenticatedThread;
}
@@ -2,12 +2,13 @@
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IChannelRepositoryAggregate';
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
import type {AuthenticatedChannel, AuthenticatedThread} from '@app/api/channel/services/AuthenticatedChannel';
import {DMPermissionValidator} from '@app/api/channel/services/DMPermissionValidator';
import {
ensurePersonalNotesChannelExists,
isPersonalNotesChannelId,
} from '@app/api/channel/services/PersonalNotesChannelRepair';
import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials';
import {
type ContentWarningChannelLike,
channelResponseToContentWarningView,
@@ -16,6 +17,15 @@ import {
guildResponseToContentWarningView,
} from '@app/api/channel/utils/EffectiveContentWarning';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import {
THREAD_CHANNEL_TYPES,
THREAD_FEATURE_CHANNEL_TYPES,
THREAD_ONLY_CHANNEL_TYPES,
THREAD_PARENT_CHANNEL_TYPES,
type ThreadViewer,
viewerActive,
} from '@app/api/experiment/ChannelThreadsGate';
import {isGuildMemberTimedOut} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import type {GuildChannelAuthContext, IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -25,6 +35,12 @@ import type {User} from '@app/api/models/User';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {
canViewThread,
isThreadModerator,
threadViewPermissions,
withImplicitThreadBits,
} from '@fluxer/constants/src/ThreadPermissionUtils';
import {CannotSendMessagesToUserError} from '@fluxer/errors/src/domains/channel/CannotSendMessagesToUserError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
@@ -33,6 +49,7 @@ import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildEr
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
export interface ChannelAuthOptions {
errorOnMissingGuild: 'unknown_channel' | 'missing_permissions';
@@ -51,6 +68,13 @@ interface DMSendPermissionsByChannelIdParams {
type DMSendPermissionsParams = DMSendPermissionsByChannelParams | DMSendPermissionsByChannelIdParams;
export interface ThreadPermissionContext {
guild: GuildResponse;
member: GuildMemberResponse;
parentCategory: GuildChannelAuthContext['parentChannel'];
thread: AuthenticatedThread;
}
export abstract class BaseChannelAuthService {
protected abstract readonly options: ChannelAuthOptions;
protected dmPermissionValidator: DMPermissionValidator;
@@ -70,10 +94,12 @@ export abstract class BaseChannelAuthService {
async getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation,
}: {
userId: UserID;
channelId: ChannelID;
viewer: ThreadViewer;
skipNsfwValidation?: boolean;
}): Promise<AuthenticatedChannel> {
if (this.isPersonalNotesChannel({userId, channelId})) {
@@ -85,6 +111,12 @@ export abstract class BaseChannelAuthService {
}
const channel = await this.channelRepository.channelData.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
if (
THREAD_FEATURE_CHANNEL_TYPES.has(channel.type) &&
(channel.guildId === null || !viewerActive(viewer, channel.guildId))
) {
throw new UnknownChannelError();
}
if (!channel.guildId) {
const recipients = await this.userRepository.listUsers(Array.from(channel.recipientIds));
return this.getDMChannelAuth({channel, recipients, userId});
@@ -174,6 +206,9 @@ export abstract class BaseChannelAuthService {
userId: UserID;
skipNsfwValidation?: boolean;
}): Promise<AuthenticatedChannel> {
if (THREAD_CHANNEL_TYPES.has(channel.type)) {
return this.getThreadChannelAuth({channel, userId, skipNsfwValidation});
}
const guildId = channel.guildId!;
const [authContextResult, guildMemberResult] = await Promise.all([
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}),
@@ -226,7 +261,8 @@ export abstract class BaseChannelAuthService {
(channel.type === ChannelTypes.GUILD_TEXT ||
channel.type === ChannelTypes.GUILD_ANNOUNCEMENT ||
channel.type === ChannelTypes.GUILD_VOICE ||
channel.type === ChannelTypes.GUILD_LINK) &&
channel.type === ChannelTypes.GUILD_LINK ||
THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) &&
requiresAgeVerification
) {
const user = await this.userRepository.findUnique(userId);
@@ -244,6 +280,111 @@ export abstract class BaseChannelAuthService {
};
}
async resolveThreadPermissionContext({
thread,
userId,
}: {
thread: Channel;
userId: UserID;
}): Promise<ThreadPermissionContext> {
const guildId = thread.guildId;
const parentId = thread.parentId;
if (guildId === null || parentId === null) throw new UnknownChannelError();
const [parent, state, threadMember, guildMemberResult] = await Promise.all([
this.channelRepository.channelData.findUnique(parentId),
this.channelRepository.threads.getState(thread.id),
this.channelRepository.threads.getMember(thread.id, userId),
this.fetchGuildMemberOrThrow({guildId, userId}),
]);
if (!parent || !state || parent.guildId !== guildId || !THREAD_PARENT_CHANNEL_TYPES.has(parent.type)) {
throw new UnknownChannelError();
}
if (!guildMemberResult.success || !guildMemberResult.memberData) {
this.throwGuildAccessError();
}
const [authContextResult, parentPermissions] = await Promise.all([
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(parent)}),
this.gatewayService.getUserPermissions({guildId, userId, channelId: parent.id}),
]);
if (!authContextResult) {
this.throwGuildAccessError();
}
const member = await this.fillMissingMemberTimeout({guildId, userId, memberData: guildMemberResult.memberData});
const guild = authContextResult.guild;
const enforceMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData: guild, userId});
const isOwner = guild.owner_id === userId.toString();
const timedOut = isGuildMemberTimedOut(member);
const actor = {
permissions: parentPermissions,
isOwner,
timedOut,
thread: {
type: state.type,
archived: state.archived,
locked: state.locked,
invitable: state.invitable ?? true,
},
isThreadOwner: thread.ownerId === userId,
isMember: threadMember !== null,
};
return {
guild,
member,
parentCategory: authContextResult.parentChannel,
thread: {
state,
parent,
member: threadMember,
parentPermissions,
actor,
isModerator: isThreadModerator(withImplicitThreadBits(parentPermissions), {isOwner, timedOut}),
enforceMfa,
},
};
}
protected async getThreadChannelAuth({
channel,
userId,
skipNsfwValidation,
}: {
channel: Channel;
userId: UserID;
skipNsfwValidation?: boolean;
}): Promise<AuthenticatedChannel> {
const context = await this.resolveThreadPermissionContext({thread: channel, userId});
const {guild, member, thread} = context;
assertThreadAllowed(canViewThread(thread.actor));
const permissions = threadViewPermissions(thread.parentPermissions);
const hasPermission = async (permission: bigint): Promise<boolean> => {
const allowed = (permissions & permission) === permission;
if (allowed) thread.enforceMfa(permission);
return allowed;
};
const checkPermission = async (permission: bigint): Promise<void> => {
if (!(await hasPermission(permission))) throw new MissingPermissionsError();
};
if (this.options.validateNsfw && !skipNsfwValidation && THREAD_PARENT_CHANNEL_TYPES.has(thread.parent.type)) {
const parentCategory = await this.getParentCategoryContentWarningView({
channel: thread.parent,
parentChannel: context.parentCategory,
});
const requiresAgeVerification = computeEffectiveChannelNsfw(
channelToContentWarningView(thread.parent),
parentCategory,
guildResponseToContentWarningView(guild),
);
if (requiresAgeVerification) {
const user = await this.userRepository.findUnique(userId);
if (!user) throw new UnknownUserError();
if (!canUserAccessNsfwContent(user)) {
throw new NsfwContentRequiresAgeVerificationError();
}
}
}
return {channel, guild, member, hasPermission, checkPermission, thread};
}
private parentLookupChannelId(channel: Channel): ChannelID | undefined {
if (!channel.parentId || channel.type === ChannelTypes.GUILD_CATEGORY) {
return undefined;
@@ -12,6 +12,9 @@ import {ChannelOperationsService} from '@app/api/channel/services/channel_data/C
import {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
import {GroupDmUpdateService} from '@app/api/channel/services/channel_data/GroupDmUpdateService';
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
import {ThreadModifyService} from '@app/api/channel/services/thread/ThreadModifyService';
import {pickThreadParentSettings} from '@app/api/channel/services/thread/ThreadParentSettings';
import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
@@ -30,12 +33,16 @@ import type {IUserRepository} from '@app/api/user/IUserRepository';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import type {
ChannelUpdateGatedRequest,
ChannelUpdateNonThreadRequest,
ChannelUpdateThreadRequest,
} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
type GuildChannelUpdateRequest = Exclude<
ChannelUpdateRequest,
ChannelUpdateNonThreadRequest,
{
type: typeof ChannelTypes.GROUP_DM;
}
@@ -47,6 +54,7 @@ export class ChannelDataService {
public readonly operations: ChannelOperationsService;
public readonly groupDmUpdate: GroupDmUpdateService;
public readonly utils: ChannelUtilsService;
public readonly threadModify: ThreadModifyService;
constructor(
channelRepository: IChannelRepositoryAggregate,
@@ -93,7 +101,18 @@ export class ChannelDataService {
limitConfigService,
rateLimitService,
cacheService,
snowflakeService,
);
this.threadModify = new ThreadModifyService({
channelRepository,
gatewayService,
guildAuditLogService,
rateLimitService,
cacheService,
snowflakeService,
messagePersistence: messagePersistenceService,
utils: this.utils,
});
this.groupDmUpdate = new GroupDmUpdateService(
channelRepository,
userRepository,
@@ -104,8 +123,26 @@ export class ChannelDataService {
);
}
async deleteThread({
userId,
viewer,
channelId,
requestCache,
auditLogReason,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true});
await this.threadModify.deleteThread({authChannel, userId, requestCache, auditLogReason});
}
async editChannel({
userId,
viewer,
channelId,
data,
clientFeatures,
@@ -114,22 +151,34 @@ export class ChannelDataService {
typeConversion,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
data: Omit<ChannelUpdateRequest, 'type'>;
data: Omit<ChannelUpdateGatedRequest, 'type'>;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
}): Promise<Channel> {
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true});
const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true});
const {channel} = authChannel;
if (authChannel.thread) {
return this.threadModify.updateThread({
authChannel,
userId,
data: data as Omit<ChannelUpdateThreadRequest, 'type'>,
requestCache,
auditLogReason,
});
}
if (channel.type === ChannelTypes.GROUP_DM) {
const groupDmData = data as Omit<Extract<ChannelUpdateNonThreadRequest, {type: 3}>, 'type'>;
return await this.groupDmUpdate.updateGroupDmChannel({
userId,
channelId,
name: data.name !== undefined ? data.name : undefined,
icon: data.icon !== undefined ? data.icon : undefined,
ownerId: data.owner_id ? createUserID(data.owner_id) : undefined,
nicks: data.nicks,
name: groupDmData.name !== undefined ? groupDmData.name : undefined,
icon: groupDmData.icon !== undefined ? groupDmData.icon : undefined,
ownerId: groupDmData.owner_id ? createUserID(groupDmData.owner_id) : undefined,
nicks: groupDmData.nicks,
requestCache,
});
}
@@ -188,8 +237,10 @@ export class ChannelDataService {
}
return this.operations.editChannel({
userId,
viewer,
channelId,
data: channelUpdateData,
threadParent: pickThreadParentSettings(channel.type, guildChannelData),
clientFeatures,
requestCache,
auditLogReason,

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