mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc3273889 | ||
|
|
e9a4f33a7e | ||
|
|
ae820ba4ea | ||
|
|
94f1239050 | ||
|
|
acc1392a53 | ||
|
|
a6a9789091 | ||
|
|
fdb7410976 | ||
|
|
b146b8ee33 | ||
|
|
4b1afc953f | ||
|
|
4ecbe9603c | ||
|
|
847d449882 | ||
|
|
bef20cba85 | ||
|
|
ebf91ccc76 | ||
|
|
abfaff16c2 | ||
|
|
89fa895a0a | ||
|
|
334fef52e1 | ||
|
|
aa42bf13ce | ||
|
|
c486c2d3d0 | ||
|
|
0ec1cee99e | ||
|
|
ccbe4857a1 | ||
|
|
7f8b0afedb | ||
|
|
e94b7d7ad8 | ||
|
|
27baa38fd1 | ||
|
|
4af10dd3e3 | ||
|
|
269d33cab2 | ||
|
|
170e12595b | ||
|
|
b82bbaa2dd | ||
|
|
b8f3dbddbf | ||
|
|
663572fd10 | ||
|
|
5033cf7203 | ||
|
|
0fcab2f8f0 | ||
|
|
c3d790f664 | ||
|
|
0906a85d6f | ||
|
|
4da8dec4ac | ||
|
|
d6e3254a16 | ||
|
|
6dc698ff5e | ||
|
|
39e48458da | ||
|
|
c5453f83e8 | ||
|
|
510cc1b916 | ||
|
|
e7859fefb1 | ||
|
|
46a356cc40 | ||
|
|
bd56174870 | ||
|
|
965bb5f634 | ||
|
|
7937d5f802 | ||
|
|
f1373a63c3 | ||
|
|
e0d7625c39 | ||
|
|
f8505d19f7 | ||
|
|
1515a8487f | ||
|
|
7a4aa42d4b | ||
|
|
0ef1cd14c3 | ||
|
|
795e190bda | ||
|
|
d00389ab30 | ||
|
|
3071bc7525 | ||
|
|
6de6be2358 | ||
|
|
c51c222c47 | ||
|
|
e218ba21de | ||
|
|
de358c0def | ||
|
|
4b88d64b2d | ||
|
|
ee1c861eb2 | ||
|
|
6a24ac3f4e | ||
|
|
8b312c610e | ||
|
|
2e3e3a1536 | ||
|
|
5c63d81ffd | ||
|
|
c402c52a8a | ||
|
|
317fedeef9 | ||
|
|
002502a7fa | ||
|
|
1997850d55 | ||
|
|
d346eb0e88 | ||
|
|
7c5fa2180a | ||
|
|
c748c8af4e | ||
|
|
ba59a13149 | ||
|
|
3f4160b138 | ||
|
|
5f4295e399 | ||
|
|
4e730832c7 | ||
|
|
d7c00d4556 | ||
|
|
154b65afe5 | ||
|
|
fcc2a3f64b | ||
|
|
80456861ac | ||
|
|
0c4f016ba2 | ||
|
|
cc5545c333 | ||
|
|
6e28092cdc | ||
|
|
8b6910d505 | ||
|
|
d87e31efaf | ||
|
|
6618a6baf4 | ||
|
|
22b8f5454b | ||
|
|
801bd3f106 | ||
|
|
e26c8c870d | ||
|
|
f4e545e090 | ||
|
|
2006fc0d8d | ||
|
|
d456048e69 | ||
|
|
fd35b4da24 | ||
|
|
283d179b05 |
@@ -50,6 +50,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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[@]}"
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
Generated
+33
-1
@@ -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",
|
||||
@@ -2042,7 +2054,6 @@ dependencies = [
|
||||
"hex",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -4790,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"
|
||||
@@ -5818,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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -219,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.
|
||||
@@ -260,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=
|
||||
|
||||
@@ -615,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:-}
|
||||
@@ -670,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
|
||||
|
||||
+809
-223
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"))
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -494,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,11 +21,13 @@ export async function withChannelFollowLock<T>(
|
||||
cacheService: ICacheService,
|
||||
channelId: ChannelID,
|
||||
fn: () => Promise<T>,
|
||||
keepAliveTtlSeconds?: number,
|
||||
): Promise<T> {
|
||||
const ttlSeconds = keepAliveTtlSeconds ?? CHANNEL_FOLLOW_LOCK_TTL_SECONDS;
|
||||
const lockKey = `channel-follow:${channelId}`;
|
||||
let lockToken: string | null = null;
|
||||
for (let attempt = 0; attempt < CHANNEL_FOLLOW_LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
||||
lockToken = await cacheService.acquireLock(lockKey, CHANNEL_FOLLOW_LOCK_TTL_SECONDS);
|
||||
lockToken = await cacheService.acquireLock(lockKey, ttlSeconds);
|
||||
if (lockToken) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, CHANNEL_FOLLOW_LOCK_RETRY_DELAY_MS * (attempt + 1)));
|
||||
}
|
||||
@@ -36,10 +38,21 @@ export async function withChannelFollowLock<T>(
|
||||
data: {retry_after: 1},
|
||||
});
|
||||
}
|
||||
const token = lockToken;
|
||||
const keepAlive =
|
||||
keepAliveTtlSeconds === undefined
|
||||
? null
|
||||
: setInterval(
|
||||
() => {
|
||||
cacheService.extendLock(lockKey, token, keepAliveTtlSeconds).catch(() => {});
|
||||
},
|
||||
(keepAliveTtlSeconds * 1000) / 3,
|
||||
);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await cacheService.releaseLock(lockKey, lockToken).catch(() => {});
|
||||
if (keepAlive) clearInterval(keepAlive);
|
||||
await cacheService.releaseLock(lockKey, token).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {GatewayDispatchEvent} from '@app/api/constants/Gateway';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
|
||||
|
||||
interface DispatchChannelEventParams {
|
||||
gatewayService: IGatewayService;
|
||||
@@ -14,6 +15,14 @@ interface DispatchChannelEventParams {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export function withThreadContext(channel: Channel, data: unknown): unknown {
|
||||
if (!THREAD_CHANNEL_TYPES.has(channel.type) || typeof data !== 'object' || data === null) return data;
|
||||
return {
|
||||
...data,
|
||||
_fluxer_thread: {id: channel.id.toString(), parent_id: channel.parentId?.toString() ?? null, type: channel.type},
|
||||
};
|
||||
}
|
||||
|
||||
export async function dispatchChannelEvent({
|
||||
gatewayService,
|
||||
channel,
|
||||
@@ -32,7 +41,7 @@ export async function dispatchChannelEvent({
|
||||
});
|
||||
}
|
||||
if (channel.guildId) {
|
||||
return gatewayService.dispatchGuild({guildId: channel.guildId, event, data});
|
||||
return gatewayService.dispatchGuild({guildId: channel.guildId, event, data: withThreadContext(channel, data)});
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(channel.recipientIds)
|
||||
|
||||
@@ -4,38 +4,94 @@ import type {ChannelID, UserID} from '@app/api/BrandedTypes';
|
||||
import {mapChannelToResponse} from '@app/api/channel/ChannelMappers';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import type {ChannelTypeConversion} from '@app/api/channel/services/channel_data/ChannelOperationsService';
|
||||
import {applyForumTagEdit, type ForumTagEdit} from '@app/api/channel/services/thread/ForumTagService';
|
||||
import {mapThreadToResponse} from '@app/api/channel/services/thread/ThreadMappers';
|
||||
import {withThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings';
|
||||
import {loadThreadViews} from '@app/api/channel/services/thread/ThreadViews';
|
||||
import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {maskChannelResponseThreadBits} from '@app/api/guild/services/ThreadPermissionBits';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelUpdateRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {ThrottledError} from '@fluxer/errors/src/domains/core/ThrottledError';
|
||||
import type {ChannelUpdateGatedRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {ChannelResponse, ChannelSlowmodeStateResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
const FORUM_TAGS_LOCK_TTL_SECONDS = 5;
|
||||
const FORUM_TAGS_LOCK_ACQUIRE_ATTEMPTS = 6;
|
||||
const FORUM_TAGS_LOCK_RETRY_DELAY_MS = 50;
|
||||
|
||||
export class ChannelRequestService {
|
||||
constructor(
|
||||
private readonly channelService: ChannelService,
|
||||
private readonly userCacheService: UserCacheService,
|
||||
private readonly cacheService: ICacheService,
|
||||
) {}
|
||||
|
||||
async getChannelResponse(params: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.channelData.operations.getChannel({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
return mapChannelToResponse({
|
||||
return this.maskedChannelResponse(channel, params);
|
||||
}
|
||||
|
||||
private async maskedChannelResponse(
|
||||
channel: Channel,
|
||||
params: {userId: UserID; viewer: ThreadViewer; requestCache: RequestCache},
|
||||
): Promise<ChannelResponse> {
|
||||
if (channel.isThread()) return this.threadResponse(channel, params.userId);
|
||||
const response = await mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
});
|
||||
const [masked] = await maskChannelResponseThreadBits(channel.guildId, params.viewer, [
|
||||
await withThreadParentFields(
|
||||
this.channelService.channelData.threadModify.repository.threads,
|
||||
channel,
|
||||
response,
|
||||
params.viewer,
|
||||
),
|
||||
]);
|
||||
return masked;
|
||||
}
|
||||
|
||||
async getSlowmodeState(params: {user: User; channelId: ChannelID}): Promise<ChannelSlowmodeStateResponse> {
|
||||
const state = await this.channelService.getSlowmodeState({user: params.user, channelId: params.channelId});
|
||||
private async threadResponse(channel: Channel, userId: UserID): Promise<ChannelResponse> {
|
||||
const repository = this.channelService.channelData.threadModify.repository;
|
||||
const [state, member] = await Promise.all([
|
||||
repository.threads.getState(channel.id),
|
||||
repository.threads.getMember(channel.id, userId),
|
||||
]);
|
||||
if (!state) throw new UnknownChannelError();
|
||||
const [view] = await loadThreadViews(repository, [state]);
|
||||
if (!view) throw new UnknownChannelError();
|
||||
return mapThreadToResponse({...view, channel}, member);
|
||||
}
|
||||
|
||||
async getSlowmodeState(params: {
|
||||
user: User;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
}): Promise<ChannelSlowmodeStateResponse> {
|
||||
const state = await this.channelService.getSlowmodeState({
|
||||
user: params.user,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
return {
|
||||
rate_limit_per_user: state.rateLimitPerUser,
|
||||
retry_after_ms: state.retryAfterMs,
|
||||
@@ -44,9 +100,10 @@ export class ChannelRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async listRtcRegions(params: {userId: UserID; channelId: ChannelID}) {
|
||||
async listRtcRegions(params: {userId: UserID; viewer: ThreadViewer; channelId: ChannelID}) {
|
||||
const regions = await this.channelService.channelData.operations.getAvailableRtcRegions({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
return regions.map((region) => ({
|
||||
@@ -58,8 +115,25 @@ export class ChannelRequestService {
|
||||
|
||||
async updateChannel(params: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
data: ChannelUpdateRequest;
|
||||
data: ChannelUpdateGatedRequest;
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
typeConversion?: ChannelTypeConversion | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
if (!('available_tags' in params.data) || params.data.available_tags === undefined) {
|
||||
return this.applyChannelUpdate(params);
|
||||
}
|
||||
return this.withForumTagsLock(params.channelId, () => this.applyChannelUpdate(params));
|
||||
}
|
||||
|
||||
private async applyChannelUpdate(params: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
data: ChannelUpdateGatedRequest;
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
@@ -67,6 +141,7 @@ export class ChannelRequestService {
|
||||
}): Promise<ChannelResponse> {
|
||||
const channel = await this.channelService.channelData.editChannel({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
data: params.data,
|
||||
clientFeatures: params.clientFeatures,
|
||||
@@ -74,16 +149,69 @@ export class ChannelRequestService {
|
||||
auditLogReason: params.auditLogReason,
|
||||
typeConversion: params.typeConversion,
|
||||
});
|
||||
return mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: params.userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache: params.requestCache,
|
||||
return this.maskedChannelResponse(channel, params);
|
||||
}
|
||||
|
||||
async editForumTags(params: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
edit: ForumTagEdit;
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<ChannelResponse> {
|
||||
const {channel, checkPermission} = await this.channelService.channelData.auth.getChannelAuthenticated({
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
viewer: params.viewer,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (!channel.isThreadOnly() || channel.guildId === null) throw new InvalidChannelTypeError();
|
||||
await checkPermission(Permissions.MANAGE_CHANNELS);
|
||||
const guildId = channel.guildId;
|
||||
return this.withForumTagsLock(channel.id, async () => {
|
||||
const config = await this.channelService.channelData.threadModify.repository.threads.getParentConfig(
|
||||
guildId,
|
||||
channel.id,
|
||||
);
|
||||
return this.applyChannelUpdate({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
data: {type: channel.type, available_tags: applyForumTagEdit(config, params.edit)} as ChannelUpdateGatedRequest,
|
||||
clientFeatures: params.clientFeatures,
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: params.auditLogReason,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async withForumTagsLock<T>(channelId: ChannelID, fn: () => Promise<T>): Promise<T> {
|
||||
const lockKey = `channel:${channelId}:forum-tags`;
|
||||
let lockToken: string | null = null;
|
||||
for (let attempt = 0; attempt < FORUM_TAGS_LOCK_ACQUIRE_ATTEMPTS; attempt++) {
|
||||
lockToken = await this.cacheService.acquireLock(lockKey, FORUM_TAGS_LOCK_TTL_SECONDS);
|
||||
if (lockToken) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, FORUM_TAGS_LOCK_RETRY_DELAY_MS * (attempt + 1)));
|
||||
}
|
||||
if (!lockToken) {
|
||||
throw new ThrottledError({
|
||||
code: APIErrorCodes.RESOURCE_LOCKED,
|
||||
retryAfterSeconds: 1,
|
||||
data: {retry_after: 1},
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteChannel(params: {
|
||||
userId: UserID;
|
||||
viewer: ThreadViewer;
|
||||
channelId: ChannelID;
|
||||
requestCache: RequestCache;
|
||||
silent?: boolean;
|
||||
@@ -91,8 +219,19 @@ export class ChannelRequestService {
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelService.channelData.operations.getChannel({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
if (channel.isThread()) {
|
||||
await this.channelService.channelData.deleteThread({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: params.auditLogReason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
await this.channelService.groupDms.removeRecipientFromChannel({
|
||||
userId: params.userId,
|
||||
@@ -105,6 +244,7 @@ export class ChannelRequestService {
|
||||
}
|
||||
await this.channelService.channelData.operations.deleteChannel({
|
||||
userId: params.userId,
|
||||
viewer: params.viewer,
|
||||
channelId: params.channelId,
|
||||
requestCache: params.requestCache,
|
||||
auditLogReason: params.auditLogReason,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user