Compare commits

...
15 Commits
Author SHA1 Message Date
HampusandGitHub d346eb0e88 fix(desktop): generate build channel in tests, bump yoke-derive (#3255) 2026-10-07 19:57:20 +02:00
HampusandGitHub 7c5fa2180a feat(desktop): bundled renderer, modules and instance accounts (#3254) 2026-10-07 16:48:07 +02:00
HampusandGitHub c748c8af4e feat(channels): redesign the create channel modal (#3253) 2026-10-07 15:26:11 +02:00
HampusandGitHub ba59a13149 feat(threads): thread and forum UI on web (#3252) 2026-10-07 15:25:43 +02:00
HampusandGitHub 3f4160b138 feat(threads): thread and forum API, admin, schemas and docs (#3251) 2026-10-07 15:25:12 +02:00
HampusandGitHub 5f4295e399 feat(threads): gateway, messages and push thread support (#3250) 2026-10-07 15:24:40 +02:00
HampusandGitHub 4e730832c7 fix(installer): pull images before the first start (#3248) 2026-10-07 02:37:35 +02:00
HampusandGitHub d7c00d4556 fix(schema): keep template topics optional after trimming (#3247) 2026-10-07 01:42:37 +02:00
HampusandGitHub 154b65afe5 docs(self-hosting): fix LiveKit CSP and backup guidance (#3246) 2026-10-07 00:09:39 +02:00
HampusandGitHub fcc2a3f64b docs(github): keep vulnerability reports out of chats (#3245) 2026-10-06 23:25:53 +02:00
HampusandGitHub 80456861ac fix(api): accept long forum topics in imported templates (#3244) 2026-10-06 22:46:47 +02:00
0c4f016ba2 feat(config): read secrets from NAME_FILE variables (#1421)
Co-authored-by: Hampus <[email protected]>
2026-10-06 21:43:08 +02:00
HampusandGitHub cc5545c333 fix(api): sync stripe customer email on change (#3243) 2026-10-06 21:38:25 +02:00
HampusandGitHub 6e28092cdc fix(app): keep mention highlight when mentions are suppressed (#3242) 2026-10-06 20:58:28 +02:00
HampusandGitHub 8b6910d505 chore(github): send bug reports and ideas to feedback.fluxer.com (#3241) 2026-10-06 19:14:26 +02:00
2062 changed files with 332230 additions and 94291 deletions
+2
View File
@@ -50,6 +50,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
+9 -9
View File
@@ -1,24 +1,24 @@
# Contributing to Fluxer
This policy applies to all issues, discussions, commits and pull requests.
This policy applies to all commits and pull requests.
## Scope
To prevent spam, only approved contributors may submit pull requests.
To request approval, comment on an existing issue and ask to implement it. For work that extends beyond a defect fix, open a [discussion](https://github.com/orgs/fluxerapp/discussions) first.
To request approval, comment on the [feedback.fluxer.com](https://feedback.fluxer.com) post you want to implement and ask to work on it. For work that extends beyond a defect fix, post a feature request there first.
Every pull request must:
- Target the repository's default branch.
- Include a closing reference for each repository issue it resolves.
- Link each feedback.fluxer.com post it resolves.
- Receive approval from a maintainer before it is merged.
Place each closing reference on a separate line:
Place each link on a separate line:
```text
Closes #123
Closes #456
Resolves https://feedback.fluxer.com/p/123
Resolves https://feedback.fluxer.com/p/456
```
## Authorship
@@ -78,11 +78,11 @@ Complete every section of the pull request template. Clearly describe:
## Reports and other contributions
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report bugs and request features at [feedback.fluxer.com](https://feedback.fluxer.com).
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security). Never post them publicly.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Read the [operator documentation](https://fluxer.dev) for self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
-41
View File
@@ -1,41 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-discussion.json
body:
- type: markdown
attributes:
value: |
Search existing discussions before posting a feature proposal.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>.
- type: textarea
id: problem
attributes:
label: Current problem
description: State what you are trying to do and what prevents it.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: State the expected behaviour.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional information
description: Optional. Include constraints, trade-offs, related discussions, screenshots or mockups.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched existing discussions.
required: true
-83
View File
@@ -1,83 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Bug report
description: Report a reproducible defect in Fluxer.
type: Bug
body:
- type: markdown
attributes:
value: |
Search [open and closed issues](https://github.com/fluxerapp/fluxer/issues?q=is%3Aissue) before filing a report.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>. Send account and billing requests to <[email protected]>.
- type: textarea
id: summary
attributes:
label: Observed behaviour
description: State what happened and what you expected.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Give numbered steps starting from a fresh app or session.
placeholder: |
1. Go to ...
2. Select ...
3. Observe ...
validations:
required: true
- type: input
id: build
attributes:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard.
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Affected surface
multiple: true
options:
- Desktop app
- Web app
- Voice, video, or Go Live
- Self-hosted instance
- HTTP API or Gateway
- Documentation site
validations:
required: true
- type: input
id: instance
attributes:
label: Instance
description: For a self-hosted instance, include the release tag and database backend.
placeholder: fluxer.app
validations:
required: false
- type: textarea
id: evidence
attributes:
label: Evidence
description: Attach relevant logs, screenshots or recordings. Remove tokens, keys, private messages and other personal data. Configuration files may contain secrets.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
- label: I removed secrets and unrelated personal data from the report.
required: true
-18
View File
@@ -1,18 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
- name: Self-hosting support
url: https://fluxer.dev
about: Read the operator documentation, then open a discussion if the problem remains.
-44
View File
@@ -1,44 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Documentation
description: Report incorrect, missing or unclear documentation.
type: Task
labels:
- docs
body:
- type: markdown
attributes:
value: |
This form covers <https://fluxer.dev> and operator documentation.
- type: textarea
id: issue
attributes:
label: Documentation defect
description: State what the page says and what is correct. For missing content, state what information you needed.
validations:
required: true
- type: input
id: location
attributes:
label: Location
description: Provide the page URL or file path and heading.
placeholder: https://fluxer.dev/gateway/overview/
validations:
required: false
- type: textarea
id: suggestion
attributes:
label: Proposed wording
description: Optional.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
+2 -2
View File
@@ -1,7 +1,7 @@
# Security policy
Do not report a vulnerability in an issue, pull request, or discussion.
Do not report a vulnerability in a pull request, on feedback.fluxer.com, in a Fluxer community, or in a direct message to staff.
Submit a report through [GitHub private vulnerability reporting](https://github.com/fluxerapp/fluxer/security/advisories/new) or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
Submit a report through <https://fluxer.app/security> or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
The programme scope, testing rules, safe harbour, disclosure process, and reward terms are published at <https://fluxer.app/security>. That page is authoritative.
+2 -2
View File
@@ -1,6 +1,6 @@
Closes #
Resolves https://feedback.fluxer.com/p/
<!-- Repeat this line for each resolved issue, up to 20. Remove the placeholder only if no issue is resolved and the approval gate does not apply. -->
<!-- Repeat this line for each feedback.fluxer.com post this resolves, up to 20. Remove the placeholder only if no post is resolved and the approval gate does not apply. -->
## Summary
@@ -154,6 +154,7 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_SELF_HOSTED=true
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
+1
View File
@@ -200,6 +200,7 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
+164
View File
@@ -103,11 +103,150 @@ jobs:
--step set_matrix
--skip-targets "${{ inputs.skip_targets }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
- shared_assets
runs-on: ${{ matrix.os }}
environment: desktop-releases
timeout-minutes: 180
@@ -130,6 +269,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
FLUXER_MODULES: "1"
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
@@ -276,6 +416,17 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Download shared renderer artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
- name: Restore shared renderer assets
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step restore_shared_assets
- name: Build Electron main process
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
@@ -505,11 +656,13 @@ jobs:
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
- shared_assets
- build
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 180
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
@@ -547,6 +700,17 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Build desktop module manifest
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_module_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
+2
View File
@@ -44,6 +44,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
Generated
+33 -1
View File
@@ -1710,6 +1710,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
@@ -1741,6 +1751,7 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"base64 0.23.1",
"brotli",
"bytes",
"chrono",
"clap",
@@ -1750,6 +1761,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tar",
"tempfile",
"tokio",
"walkdir",
@@ -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"
+3
View File
@@ -23,6 +23,9 @@
# Fluxer
> [!IMPORTANT]
> Bug reports and feature requests have moved to [feedback.fluxer.com](https://feedback.fluxer.com). Sign in with your Fluxer account to post, vote and follow updates. GitHub Issues and Discussions are closed. Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security).
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
+1 -1
View File
@@ -263,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=
+1
View File
@@ -671,6 +671,7 @@ services:
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::ListGuildThreadsResponse;
impl AdminApiClient {
pub async fn list_guild_threads(&self, guild_id: &str) -> ApiResult<ListGuildThreadsResponse> {
let response = self
.generated()
.list_admin_guild_threads(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_thread_channel(&self, channel_id: &str) -> ApiResult<()> {
self.generated()
.delete_admin_thread_channel(&snowflake(channel_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod client;
pub mod codes;
pub mod discovery;
pub mod guild_assets;
pub mod guild_threads;
pub mod guilds;
pub mod instance_config;
pub mod jobs;
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadMetadata {
pub archived: bool,
pub locked: bool,
pub auto_archive_duration: i32,
pub archive_timestamp: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadItem {
pub id: String,
#[serde(rename = "type")]
pub channel_type: i32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(default)]
pub member_count: Option<i32>,
#[serde(default)]
pub message_count: Option<i32>,
#[serde(default)]
pub thread_metadata: Option<GuildThreadMetadata>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ListGuildThreadsResponse {
pub threads: Vec<GuildThreadItem>,
}
@@ -31,6 +31,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub channel_threads: ChannelThreadsConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
@@ -510,6 +512,8 @@ 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;
pub const CHANNEL_THREADS_DEFAULT_GUILD_SALT: &str = "channel-threads-guild-v1";
pub const CHANNEL_THREADS_DEFAULT_USER_SALT: &str = "channel-threads-user-v1";
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
@@ -653,6 +657,62 @@ pub struct CaptchaConfigUpdateRequest {
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ChannelThreadsConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub ever_enabled: bool,
pub guild_basis_points: u32,
pub guild_salt: String,
pub enabled_guild_ids: Vec<String>,
pub disabled_guild_ids: Vec<String>,
pub user_basis_points: u32,
pub user_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for ChannelThreadsConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
ever_enabled: false,
guild_basis_points: 0,
guild_salt: CHANNEL_THREADS_DEFAULT_GUILD_SALT.to_owned(),
enabled_guild_ids: Vec::new(),
disabled_guild_ids: Vec::new(),
user_basis_points: 0,
user_salt: CHANNEL_THREADS_DEFAULT_USER_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ChannelThreadsConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_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 excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -775,6 +835,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_threads: Option<ChannelThreadsConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod guild_threads;
mod instance_billing;
mod instance_config;
mod jobs;
@@ -29,6 +30,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use guild_threads::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
+27
View File
@@ -96,6 +96,33 @@ 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();
let threads_enabled = client
.get_instance_config()
.await
.map(|instance| instance.channel_threads.enabled)
.map_err(
|error| tracing::warn!(%error, "admin API request failed: get instance config"),
)
.unwrap_or(false);
Some(tabs::threads::threads_tab(
config,
&guild,
&threads,
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
threads_enabled,
csrf_token,
))
}
"audit_log" | "audit-log" => {
if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) {
return None;
+10
View File
@@ -433,6 +433,16 @@ async fn dispatch_guild_action(
"Failed to delete sticker",
)
}
"delete_thread" => {
let Some(thread_id) = get("thread_id") else {
return FlashData::error("Thread ID is required");
};
action_result(
client.delete_thread_channel(&thread_id).await,
"Thread deleted",
"Failed to delete thread",
)
}
"trigger_archive" => {
let inc = form.bool_value("include_attachments");
action_result(
+170 -13
View File
@@ -7,19 +7,20 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
CaptchaConfigUpdateRequest, ChannelThreadsConfigUpdateRequest,
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
PlutoniumPageConfigUpdateRequest, PremiumMode, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -228,6 +229,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_channel_threads" => match build_channel_threads_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -671,6 +676,53 @@ fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateReq
})
}
fn build_channel_threads_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
channel_threads: Some(ChannelThreadsConfigUpdateRequest {
enabled: Some(form.bool_value("channel_threads_enabled")),
guild_basis_points: parse_form_number(
form,
"channel_threads_guild_basis_points",
"Guild rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
guild_salt: parse_experiment_rollout_salt(form, "channel_threads_guild_salt")?,
enabled_guild_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_enabled_guild_ids")
.unwrap_or_default(),
"Enabled guild IDs",
)?),
disabled_guild_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_disabled_guild_ids")
.unwrap_or_default(),
"Disabled guild IDs",
)?),
user_basis_points: parse_form_number(
form,
"channel_threads_user_basis_points",
"User rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
user_salt: parse_experiment_rollout_salt(form, "channel_threads_user_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("channel_threads_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -1436,6 +1488,111 @@ mod tests {
}
}
#[test]
fn build_channel_threads_update_reads_both_rollout_dimensions() {
let form = MultiValueForm::parse(
b"channel_threads_enabled=true&channel_threads_guild_basis_points=%2010%20&channel_threads_guild_salt=%20channel-threads-guild-v2%20&channel_threads_enabled_guild_ids=1600000000000000001%0A1600000000000000002%0A1600000000000000001&channel_threads_disabled_guild_ids=1600000000000000003&channel_threads_user_basis_points=10000&channel_threads_user_salt=channel-threads-user-v2&channel_threads_included_user_ids=1500000000000000001&channel_threads_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_channel_threads_update(&form)
.expect("valid form")
.channel_threads
.expect("channel threads update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.guild_basis_points, Some(10));
assert_eq!(
update.guild_salt,
Some("channel-threads-guild-v2".to_owned())
);
assert_eq!(
update.enabled_guild_ids,
Some(vec![
"1600000000000000001".to_owned(),
"1600000000000000002".to_owned()
])
);
assert_eq!(
update.disabled_guild_ids,
Some(vec!["1600000000000000003".to_owned()])
);
assert_eq!(update.user_basis_points, Some(10000));
assert_eq!(update.user_salt, Some("channel-threads-user-v2".to_owned()));
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn build_channel_threads_update_leaves_the_experiment_off_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_channel_threads_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"channel_threads": {
"enabled": false,
"enabled_guild_ids": [],
"disabled_guild_ids": [],
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_channel_threads_update_rejects_invalid_targeting() {
let too_many_guilds = (0..=EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("%2C");
for (form, message) in [
(
"channel_threads_guild_basis_points=10001".to_owned(),
"Guild rollout basis points must be a whole number between 0 and 10000",
),
(
"channel_threads_user_basis_points=-1".to_owned(),
"User rollout basis points must be a whole number between 0 and 10000",
),
(
"channel_threads_guild_salt=%20%20".to_owned(),
"Rollout salt must be between 1 and 64 characters",
),
(
"channel_threads_user_salt=caf%C3%A9".to_owned(),
"Rollout salt must use printable ASCII",
),
(
"channel_threads_enabled_guild_ids=123%2Cinvalid".to_owned(),
"Enabled guild IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"channel_threads_disabled_guild_ids=123456789012345678901".to_owned(),
"Disabled guild IDs entry 1 must contain 1 to 20 decimal digits",
),
(
"channel_threads_excluded_user_ids=abc".to_owned(),
"Excluded user IDs entry 1 must contain 1 to 20 decimal digits",
),
(
format!("channel_threads_enabled_guild_ids={too_many_guilds}"),
"Enabled guild IDs must contain at most 1000 unique IDs",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_channel_threads_update(&form).expect_err("invalid targeting"),
message
);
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
@@ -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,106 @@
// 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,
can_reindex: bool,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
@if can_reindex {
(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"))
}
}
}))
})
}
@@ -3,7 +3,8 @@
use crate::{
api::types::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CHANNEL_THREADS_DEFAULT_GUILD_SALT,
CHANNEL_THREADS_DEFAULT_USER_SALT, CaptchaConfigResponse, ChannelThreadsConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
@@ -16,6 +17,7 @@ use crate::{
middleware::auth::AuthContext,
templates::{
components::{
alert::alert_warning,
badge::{BadgeVariant, badge},
form::{
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
@@ -191,6 +193,7 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(channel_threads_section(base, csrf_token, &instance_config.channel_threads))
(plutonium_page_section(base, csrf_token, &instance_config.plutonium_page))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
@@ -1484,6 +1487,189 @@ fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse
)
}
fn channel_threads_section(
base: &str,
csrf_token: &str,
channel_threads: &ChannelThreadsConfigResponse,
) -> Markup {
let status = if channel_threads.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let enabled_guild_ids = channel_threads.enabled_guild_ids.join("\n");
let disabled_guild_ids = channel_threads.disabled_guild_ids.join("\n");
let included_user_ids = channel_threads.included_user_ids.join("\n");
let excluded_user_ids = channel_threads.excluded_user_ids.join("\n");
section_card_with_description(
"Channel threads",
"Threads, forum channels and media channels. A guild gets the feature only when the guild \
is selected, and a member sees it only when they are also selected and use a client \
that supports threads. Bots follow the guild selection.",
html! {
form method="post" action={(base) "/instance-config?action=update_channel_threads"} {
(csrf_input(csrf_token))
div class="space-y-6" {
(alert_warning("Before enabling", html! {
p class="text-sm" {
"Enable only after every gateway role, the api, the workers and the \
messages service run the gate build and the bit 34-38 overwrite audit \
is clean."
}
}))
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))
@if channel_threads.ever_enabled {
(badge("Ever enabled", BadgeVariant::Warning))
}
span class="text-xs text-neutral-500" {
"Config version " (channel_threads.config_version)
}
}
(checkbox(
"channel_threads_enabled",
"true",
"Turn on threads for the selected guilds and users",
channel_threads.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked no guild \
has threads, and existing threads and forums stay stored but hidden \
until it is turned back on."
}
h3 class="text-sm font-semibold text-neutral-900" { "Guilds" }
(number_field(
"channel_threads_guild_basis_points",
"Guild rollout (basis points)",
&channel_threads.guild_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of guilds bucketed into the experiment, in basis points: 0 is nobody, 100 is 1%, 10000 is every guild."),
))
div class="flex flex-col gap-2" {
(text_input(
"channel_threads_guild_salt",
"Guild rollout salt",
&channel_threads.guild_salt,
CHANNEL_THREADS_DEFAULT_GUILD_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the guild bucketing hash. Printable ASCII only. Changing it \
reshuffles which guilds fall inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_enabled_guild_ids",
"Always-on Guild IDs",
"1600000000000000001\n1600000000000000002",
&enabled_guild_ids,
4,
false,
))
(entry_count_hint(
channel_threads.enabled_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These guilds 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" {
(textarea_input(
"channel_threads_disabled_guild_ids",
"Never-on Guild IDs",
"1600000000000000003\n1600000000000000004",
&disabled_guild_ids,
4,
false,
))
(entry_count_hint(
channel_threads.disabled_guild_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. This is the per-guild kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Users" }
(number_field(
"channel_threads_user_basis_points",
"User rollout (basis points)",
&channel_threads.user_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the experiment, in basis points. Set 10000 before enrolling any guild outside staff, so every member of that guild, moderators included, sees its threads."),
))
div class="flex flex-col gap-2" {
(text_input(
"channel_threads_user_salt",
"User rollout salt",
&channel_threads.user_salt,
CHANNEL_THREADS_DEFAULT_USER_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the user bucketing hash. Printable ASCII only. Changing it \
reshuffles which users fall inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
channel_threads.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format as the guild lists. These users are targeted regardless \
of the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"channel_threads_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
channel_threads.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, and it also applies to bots."
}
}
(alert_warning("Excluded bots", html! {
p class="text-sm" {
"Excluded bots are blind to threads, including moderation bots."
}
}))
(form_actions(html! {
(submit_button("Save channel threads configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -2227,6 +2413,52 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn channel_threads_section_shows_both_dimensions_and_the_warnings() {
let channel_threads = ChannelThreadsConfigResponse {
enabled: true,
config_version: 7,
ever_enabled: true,
guild_basis_points: 25,
enabled_guild_ids: vec!["1600000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..ChannelThreadsConfigResponse::default()
};
let markup = channel_threads_section("/admin", "csrf", &channel_threads).into_string();
assert!(markup.contains("action=update_channel_threads"));
for name in [
"channel_threads_enabled",
"channel_threads_guild_basis_points",
"channel_threads_guild_salt",
"channel_threads_enabled_guild_ids",
"channel_threads_disabled_guild_ids",
"channel_threads_user_basis_points",
"channel_threads_user_salt",
"channel_threads_included_user_ids",
"channel_threads_excluded_user_ids",
] {
assert!(markup.contains(&format!("name=\"{name}\"")), "{name}");
}
assert!(markup.contains("value=\"25\""));
assert!(markup.contains("Config version 7"));
assert!(markup.contains("Ever enabled"));
assert!(markup.contains("bit 34-38 overwrite audit is clean"));
assert!(markup.contains("Excluded bots are blind to threads, including moderation bots."));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
let off =
channel_threads_section("/admin", "csrf", &ChannelThreadsConfigResponse::default())
.into_string();
assert!(off.contains("Inert"));
assert!(!off.contains("Ever enabled"));
assert!(off.contains(CHANNEL_THREADS_DEFAULT_GUILD_SALT));
assert!(off.contains(CHANNEL_THREADS_DEFAULT_USER_SALT));
}
#[test]
fn plutonium_page_section_shows_the_rollout_and_list_counts() {
let plutonium_page = PlutoniumPageConfigResponse {
+118
View File
@@ -433,6 +433,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"max_counter": 1000,
"future_captcha_knob": 1
},
"channel_threads": {
"enabled": true,
"config_version": 3,
"ever_enabled": true,
"guild_basis_points": 0,
"guild_salt": "channel-threads-guild-v1",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": [],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -595,6 +608,9 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
assert!(resp.channel_threads.enabled);
assert_eq!(resp.channel_threads.config_version, 3);
assert_eq!(resp.channel_threads.enabled_guild_ids.len(), 1);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
@@ -662,6 +678,63 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
}
#[test]
fn deserialize_channel_threads_config() {
let config: types::ChannelThreadsConfigResponse = serde_json::from_str(
r#"{
"enabled": true,
"config_version": 12,
"ever_enabled": true,
"guild_basis_points": 50,
"guild_salt": "channel-threads-guild-v2",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": ["1600000000000000002", "1600000000000000003"],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000001"],
"future_threads_knob": 1
}"#,
)
.expect("a channel threads config must deserialize");
assert!(config.enabled);
assert!(config.ever_enabled);
assert_eq!(config.config_version, 12);
assert_eq!(config.guild_basis_points, 50);
assert_eq!(config.guild_salt, "channel-threads-guild-v2");
assert_eq!(config.enabled_guild_ids, vec!["1600000000000000001"]);
assert_eq!(config.disabled_guild_ids.len(), 2);
assert_eq!(config.user_basis_points, 10000);
assert_eq!(config.excluded_user_ids, vec!["1500000000000000001"]);
let absent: types::ChannelThreadsConfigResponse =
serde_json::from_str("{}").expect("an api without the experiment still deserializes");
assert!(!absent.enabled);
assert!(!absent.ever_enabled);
assert_eq!(absent.guild_salt, types::CHANNEL_THREADS_DEFAULT_GUILD_SALT);
assert_eq!(absent.user_salt, types::CHANNEL_THREADS_DEFAULT_USER_SALT);
}
#[test]
fn serialize_channel_threads_update_never_sends_server_owned_fields() {
let update = types::InstanceConfigUpdateRequest {
channel_threads: Some(types::ChannelThreadsConfigUpdateRequest {
enabled: Some(true),
enabled_guild_ids: Some(vec!["1600000000000000001".to_owned()]),
..Default::default()
}),
..Default::default()
};
assert_eq!(
serde_json::to_value(&update).unwrap(),
serde_json::json!({"channel_threads": {
"enabled": true,
"enabled_guild_ids": ["1600000000000000001"],
}})
);
}
#[test]
fn deserialize_push_relay_config() {
let accepted: types::PushRelayConfigResponse = serde_json::from_str(
@@ -1084,3 +1157,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)
);
}
+65
View File
@@ -469,6 +469,7 @@ 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_channel_threads",
"/instance-config?action=update_plutonium_page",
"/instance-config?action=update_experiment_delivery",
][..],
@@ -485,6 +486,57 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
}
#[tokio::test]
async fn channel_threads_section_renders_and_saves_through_htmx_toasts() {
let app = setup().await;
let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await;
assert_full_layout(&body);
assert!(body.contains("Channel threads"), "{body}");
assert!(body.contains("Config version 3"), "{body}");
assert!(body.contains("Ever enabled"), "{body}");
assert!(body.contains("1600000000000000001"), "{body}");
assert!(body.contains("1500000000000000009"), "{body}");
assert!(
body.contains("Excluded bots are blind to threads, including moderation bots."),
"{body}"
);
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{body}"));
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let htmx_headers = [
("HX-Request", "true"),
("HX-Target", "flash-container"),
("Cookie", cookie.as_str()),
];
for (form, expected) in [
(
format!(
"_csrf={csrf_token}&channel_threads_enabled=true&channel_threads_guild_basis_points=0&channel_threads_enabled_guild_ids=1600000000000000001&channel_threads_user_basis_points=10000"
),
"Instance config updated",
),
(
format!("_csrf={csrf_token}&channel_threads_enabled_guild_ids=not-a-guild"),
"Enabled guild IDs entry 1 must contain 1 to 20 decimal digits",
),
] {
let (status, response_headers, response_body) = post_form_with_headers(
&app,
"/instance-config?action=update_channel_threads",
&htmx_headers,
&form,
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "{response_body}");
let toast = response_headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_else(|| panic!("missing toast header\n{response_body}"));
assert!(toast.contains(expected), "{toast}");
}
}
#[tokio::test]
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
let app = setup().await;
@@ -1193,6 +1245,19 @@ fn instance_config() -> Value {
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"channel_threads": {
"enabled": false,
"config_version": 3,
"ever_enabled": true,
"guild_basis_points": 0,
"guild_salt": "channel-threads-guild-v1",
"enabled_guild_ids": ["1600000000000000001"],
"disabled_guild_ids": [],
"user_basis_points": 10000,
"user_salt": "channel-threads-user-v1",
"included_user_ids": [],
"excluded_user_ids": ["1500000000000000009"]
},
"plutonium_page": {
"enabled": false,
"config_version": 0,
@@ -1,7 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
type ElasticsearchFieldType = 'text' | 'keyword' | 'boolean' | 'long' | 'integer' | 'date' | 'float';
export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members';
export type FluxerSearchIndexName =
| 'messages'
| 'guilds'
| 'users'
| 'reports'
| 'audit_logs'
| 'guild_members'
| 'threads';
export interface ElasticsearchFieldMapping {
type: ElasticsearchFieldType;
@@ -173,6 +180,26 @@ export const ELASTICSEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, Elas
},
},
},
threads: {
indexName: 'threads',
mappings: {
properties: {
id: keyword(),
guildId: keyword(),
parentId: keyword(),
type: integer(),
name: textWithKeyword(),
ownerId: keyword(),
archived: bool(),
locked: bool(),
appliedTagIds: keyword(),
createdAt: long(),
idSequence: long(),
lastMessageAt: long(),
archivedAt: long(),
},
},
},
audit_logs: {
indexName: 'audit_logs',
mappings: {
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Client} from '@elastic/elasticsearch';
import type {SortCombinations} from '@elastic/elasticsearch/lib/api/types';
import type {
SearchableThread,
ThreadSearchCursor,
ThreadSearchFilters,
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {
compactFilters,
esAndTerms,
esRangeFilter,
esTermFilter,
esTermsFilter,
} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
const SORT_FIELDS = {
last_message_time: 'lastMessageAt',
archive_time: 'archivedAt',
creation_time: 'createdAt',
} as const;
function cursorFilter(cursor: ThreadSearchCursor, op: 'gt' | 'lt'): ElasticsearchFilter {
return {
bool: {
should: [
esRangeFilter('createdAt', {[op]: cursor.createdAt}),
{
bool: {
filter: [
esTermFilter('createdAt', cursor.createdAt),
esRangeFilter('idSequence', {[op]: cursor.idSequence}),
],
},
},
],
minimum_should_match: 1,
},
};
}
function buildThreadFilters(filters: ThreadSearchFilters): Array<ElasticsearchFilter | undefined> {
const clauses: Array<ElasticsearchFilter | undefined> = [
esTermFilter('guildId', filters.guildId),
esTermFilter('parentId', filters.parentId),
];
if (filters.publicOnly) {
clauses.push(
filters.privateThreadIds && filters.privateThreadIds.length > 0
? {
bool: {
should: [esTermsFilter('type', [10, 11]), esTermsFilter('id', filters.privateThreadIds)],
minimum_should_match: 1,
},
}
: esTermsFilter('type', [10, 11]),
);
}
if (filters.archived !== undefined) clauses.push(esTermFilter('archived', filters.archived));
if (filters.tagIds && filters.tagIds.length > 0) {
if (filters.tagSetting === 'match_all') clauses.push(...esAndTerms('appliedTagIds', filters.tagIds));
else clauses.push(esTermsFilter('appliedTagIds', filters.tagIds));
}
if (filters.after) clauses.push(cursorFilter(filters.after, 'gt'));
if (filters.before) clauses.push(cursorFilter(filters.before, 'lt'));
return compactFilters(clauses);
}
function buildThreadSort(filters: ThreadSearchFilters): Array<SortCombinations> | undefined {
const sortBy = filters.sortBy ?? 'last_message_time';
if (sortBy === 'relevance') return undefined;
const order = filters.sortOrder ?? 'desc';
return [...new Set([SORT_FIELDS[sortBy], 'createdAt', 'idSequence'])].map((field) => ({[field]: {order}}));
}
export interface ElasticsearchThreadAdapterOptions {
client: Client;
lock?: ElasticsearchDistributedLock;
}
export class ElasticsearchThreadAdapter extends ElasticsearchIndexAdapter<ThreadSearchFilters, SearchableThread> {
constructor(options: ElasticsearchThreadAdapterOptions) {
super({
client: options.client,
index: ELASTICSEARCH_INDEX_DEFINITIONS.threads,
searchableFields: ['name'],
buildFilters: buildThreadFilters,
buildSort: buildThreadSort,
lock: options.lock,
});
}
}
+2
View File
@@ -19,6 +19,7 @@ import {Hono} from 'hono';
interface CreateAPIAppOptions {
config: APIConfig;
logger: ILogger;
registerRoutes?: (routes: HonoApp) => void;
}
interface APIAppResult {
@@ -53,6 +54,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
routes.onError(TelemetryAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
registerControllers(routes, config);
options.registerRoutes?.(routes);
const app = new Hono<HonoEnv>({strict: true});
const {middleware: metricsMiddleware, metricsHandler} = createMetricsMiddleware('api');
app.use('*', metricsMiddleware);
+1
View File
@@ -495,6 +495,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
laneName: apiWorkerConfig?.lane,
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
metricsPort: apiWorkerConfig?.metrics_port,
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+5
View File
@@ -12,6 +12,7 @@ import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
import type {IMessageSearchService} from '@app/api/search/IMessageSearchService';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
import type {ISearchProvider} from '@app/api/search/ISearchProvider';
import type {IThreadSearchService} from '@app/api/search/IThreadSearchService';
import type {IUserSearchService} from '@app/api/search/IUserSearchService';
import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
@@ -87,6 +88,10 @@ export function getGuildMemberSearchService(): IGuildMemberSearchService | null
return searchProvider?.getGuildMemberSearchService() ?? null;
}
export function getThreadSearchService(): IThreadSearchService | null {
return searchProvider?.getThreadSearchService() ?? null;
}
export async function initializeSearch(lock?: ElasticsearchDistributedLock): Promise<void> {
if (searchProvider) {
await shutdownSearch();
+104 -6
View File
@@ -141,6 +141,8 @@ import {
type InviteRow,
PRIVATE_CHANNEL_COLUMNS,
type PrivateChannelRow,
READ_STATE_COLUMNS,
type ReadStateRow,
WEBHOOK_COLUMNS,
WEBHOOKS_BY_SOURCE_CHANNEL_COLUMNS,
type WebhookRow,
@@ -268,6 +270,30 @@ import {
type StorePurchaseByUserRow,
type StorePurchaseRow,
} from '@app/api/database/types/StoreBillingTypes';
import {
ACTIVE_THREADS_BY_GUILD_COLUMNS,
type ActiveThreadsByGuildRow,
ARCHIVED_THREADS_BY_PARENT_COLUMNS,
type ArchivedThreadsByParentRow,
FORUM_PINNED_THREAD_COLUMNS,
type ForumPinnedThreadRow,
GUILD_THREAD_STATE_COLUMNS,
type GuildThreadStateRow,
THREAD_MEMBER_COLUMNS,
THREAD_MEMBERS_BY_USER_COLUMNS,
THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS,
THREAD_PARENT_CONFIG_COLUMNS,
THREAD_STATE_COLUMNS,
THREAD_STATS_COLUMNS,
THREADS_BY_PARENT_COLUMNS,
type ThreadMemberRow,
type ThreadMembersByUserRow,
type ThreadOnlyChannelsByGuildRow,
type ThreadParentConfigRow,
type ThreadStateRow,
type ThreadStatsRow,
type ThreadsByParentRow,
} from '@app/api/database/types/ThreadTypes';
import {
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
@@ -579,6 +605,84 @@ export const DmStates = defineTable<DmStateRow, 'hi_user_id' | 'lo_user_id' | 'c
columns: DM_STATE_COLUMNS,
primaryKey: ['hi_user_id', 'lo_user_id', 'channel_id'],
});
export const ThreadState = defineTable<ThreadStateRow, 'thread_id'>({
name: 'thread_state',
columns: THREAD_STATE_COLUMNS,
primaryKey: ['thread_id'],
partitionKey: ['thread_id'],
});
export const ThreadStats = defineTable<ThreadStatsRow, 'thread_id'>({
name: 'thread_stats',
columns: THREAD_STATS_COLUMNS,
primaryKey: ['thread_id'],
partitionKey: ['thread_id'],
});
export const ThreadsByParent = defineTable<ThreadsByParentRow, 'parent_id' | 'thread_id', 'parent_id'>({
name: 'threads_by_parent',
columns: THREADS_BY_PARENT_COLUMNS,
primaryKey: ['parent_id', 'thread_id'],
partitionKey: ['parent_id'],
});
export const ActiveThreadsByGuild = defineTable<ActiveThreadsByGuildRow, 'guild_id' | 'thread_id', 'guild_id'>({
name: 'active_threads_by_guild',
columns: ACTIVE_THREADS_BY_GUILD_COLUMNS,
primaryKey: ['guild_id', 'thread_id'],
partitionKey: ['guild_id'],
});
export const ArchivedThreadsByParent = defineTable<
ArchivedThreadsByParentRow,
'parent_id' | 'is_private' | 'archive_timestamp' | 'thread_id',
'parent_id' | 'is_private'
>({
name: 'archived_threads_by_parent',
columns: ARCHIVED_THREADS_BY_PARENT_COLUMNS,
primaryKey: ['parent_id', 'is_private', 'archive_timestamp', 'thread_id'],
partitionKey: ['parent_id', 'is_private'],
});
export const ThreadMembers = defineTable<ThreadMemberRow, 'thread_id' | 'user_id', 'thread_id'>({
name: 'thread_members',
columns: THREAD_MEMBER_COLUMNS,
primaryKey: ['thread_id', 'user_id'],
partitionKey: ['thread_id'],
});
export const ThreadMembersByUser = defineTable<
ThreadMembersByUserRow,
'user_id' | 'guild_id' | 'parent_id' | 'is_private' | 'thread_id',
'user_id'
>({
name: 'thread_members_by_user',
columns: THREAD_MEMBERS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'guild_id', 'parent_id', 'is_private', 'thread_id'],
partitionKey: ['user_id'],
});
export const ThreadParentConfig = defineTable<ThreadParentConfigRow, 'guild_id' | 'channel_id', 'guild_id'>({
name: 'thread_parent_config',
columns: THREAD_PARENT_CONFIG_COLUMNS,
primaryKey: ['guild_id', 'channel_id'],
partitionKey: ['guild_id'],
});
export const ForumPinnedThread = defineTable<ForumPinnedThreadRow, 'parent_id'>({
name: 'forum_pinned_thread',
columns: FORUM_PINNED_THREAD_COLUMNS,
primaryKey: ['parent_id'],
partitionKey: ['parent_id'],
});
export const ThreadOnlyChannelsByGuild = defineTable<
ThreadOnlyChannelsByGuildRow,
'guild_id' | 'channel_id',
'guild_id'
>({
name: 'thread_only_channels_by_guild',
columns: THREAD_ONLY_CHANNELS_BY_GUILD_COLUMNS,
primaryKey: ['guild_id', 'channel_id'],
partitionKey: ['guild_id'],
});
export const GuildThreadState = defineTable<GuildThreadStateRow, 'guild_id'>({
name: 'guild_thread_state',
columns: GUILD_THREAD_STATE_COLUMNS,
primaryKey: ['guild_id'],
partitionKey: ['guild_id'],
});
interface PinnedDmRow {
user_id: bigint;
@@ -593,12 +697,6 @@ export const PinnedDms = defineTable<PinnedDmRow, 'user_id' | 'channel_id'>({
primaryKey: ['user_id', 'channel_id'],
});
interface ReadStateRow {
user_id: bigint;
channel_id: bigint;
}
const READ_STATE_COLUMNS = ['user_id', 'channel_id'] as const satisfies ReadonlyArray<keyof ReadStateRow>;
export const ReadStates = defineTable<ReadStateRow, 'user_id' | 'channel_id'>({
name: 'read_states',
columns: READ_STATE_COLUMNS,
@@ -38,6 +38,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_MEMBERS: 'list_guild_members',
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
LIST_GUILD_STICKERS: 'list_guild_stickers',
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
@@ -4,6 +4,7 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs';
import {
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
@@ -14,6 +15,7 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {
getChannelThreadsConfigPublisher,
getGatewayRolloutConfigPublisher,
getInstanceConfigRepository,
getPushRelayConfigPublisher,
@@ -21,6 +23,11 @@ import {
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {
enabledThreadGuildIds,
enqueueThreadPermissionSeeds,
newlyEnabledThreadGuildIds,
} from '@app/api/worker/tasks/SeedThreadPermissions';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
@@ -35,6 +42,10 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
applyChannelThreadsConfigUpdate,
type ChannelThreadsConfig,
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
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';
@@ -69,6 +80,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
domainMigration,
plutoniumPage,
captcha,
channelThreads,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -80,6 +92,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getPlutoniumPageConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getChannelThreadsConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -118,6 +131,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
domain_migration: domainMigration,
plutonium_page: plutoniumPage,
captcha,
channel_threads: channelThreads,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -418,6 +432,25 @@ export function InstanceConfigAdminController(app: HonoApp) {
await instanceConfigRepository.updateCaptchaConfig(patch);
}
}
let channelThreadsConfigVersion: number | undefined;
if (data.channel_threads) {
const patch = omitUndefinedFields(data.channel_threads);
if (Object.keys(patch).length > 0) {
let previous: ChannelThreadsConfig | undefined;
const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => {
previous = current;
return applyChannelThreadsConfigUpdate(current, patch);
});
channelThreadsConfigVersion = landed.config_version;
await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed);
const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed);
for (const guildId of newlyEnabled) {
await enqueueRebuildThreadAutoArchiveQueue(guildId);
await enqueueThreadSearchBackfill(guildId);
}
await getChannelThreadsConfigPublisher().publish(landed);
}
}
if (data.experiment_delivery) {
const patch = data.experiment_delivery;
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
@@ -613,6 +646,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
action: 'update_instance_config',
metadata: {
sections: listSuppliedSections(data),
channel_threads_config_version: channelThreadsConfigVersion?.toString(),
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
},
});
@@ -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);
},
);
}
@@ -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,6 +12,7 @@ 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 {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
@@ -218,6 +219,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 +242,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),
]);
@@ -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,
@@ -8,11 +8,13 @@ import {
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {vi} from 'vitest';
async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise<string> {
const created = await createBuilder<CreateRegistrationUrlResponse>(harness, admin.token)
@@ -80,6 +82,26 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'PATCH',
route: '/admin/instance/config',
name: 'channel threads',
async prepare() {
vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
return {
request: {
path: '/admin/instance/config',
body: {channel_threads: {enabled: true, enabled_guild_ids: ['1']}},
},
expected: {
action: 'update_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {sections: 'channel_threads', channel_threads_config_version: '1'},
},
};
},
},
{
method: 'POST',
route: '/admin/instance/config/branding-assets',
@@ -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);
+11 -2
View File
@@ -3,6 +3,7 @@
import type {ILogger} from '@app/api/ILogger';
import {ActivityContextMiddleware} from '@app/api/infrastructure/activity/ActivityMeta';
import {AuditLogMiddleware} from '@app/api/middleware/AuditLogMiddleware';
import {ClientFeaturesMiddleware} from '@app/api/middleware/ClientFeaturesMiddleware';
import {ConcurrencyLimitMiddleware} from '@app/api/middleware/ConcurrencyLimitMiddleware';
import ContentFilterMiddleware from '@app/api/middleware/ContentFilterMiddleware';
import {GuildAvailabilityMiddleware} from '@app/api/middleware/GuildAvailabilityMiddleware';
@@ -57,10 +58,17 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
allowedHeaders: [
HttpHeaders.CONTENT_TYPE,
HttpHeaders.AUTHORIZATION,
'X-Requested-With',
'Accept-Language',
HttpHeaders.X_REQUESTED_WITH,
HttpHeaders.ACCEPT_LANGUAGE,
HttpHeaders.X_REQUEST_ID,
HttpHeaders.IF_NONE_MATCH,
HttpHeaders.X_AUDIT_LOG_REASON,
HttpHeaders.X_CAPTCHA_ID,
HttpHeaders.X_CAPTCHA_TOKEN,
HttpHeaders.X_FLUXER_CLIENT_INSTALLATION_ID,
HttpHeaders.X_FLUXER_FEATURES,
HttpHeaders.X_FLUXER_PLATFORM,
HttpHeaders.X_FLUXER_SUDO_MODE_JWT,
],
exposedHeaders: [HttpHeaders.X_FLUXER_VERSION, HttpHeaders.ETAG],
},
@@ -78,6 +86,7 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
);
routes.use(RequestErrorTelemetry);
routes.use(RequestCacheMiddleware);
routes.use(ClientFeaturesMiddleware);
if (nodeEnv === 'production') {
routes.use('*', async (ctx, next) => {
const host = ctx.req.header('host');
+3 -1
View File
@@ -6,6 +6,7 @@ import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {createEmailRevertToken} from '@app/api/BrandedTypes';
import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -44,7 +45,7 @@ export async function revertEmailChange(
user_id: string;
token: string;
}> {
const {users, gateway, contactChangeLog, config} = ctx.services;
const {users, gateway, contactChangeLog, config, worker} = ctx.services;
const {token, password, request} = params;
const tokenData = await users.getEmailRevertToken(token);
if (!tokenData) {
@@ -101,5 +102,6 @@ export async function revertEmailChange(
reason: 'user_requested',
actorUserId: user.id,
});
await enqueueStripeCustomerEmailSync(worker, user, updatedUser);
return {user_id: updatedUser.id.toString(), token: authToken};
}
@@ -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);
@@ -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,
@@ -5,6 +5,7 @@ import type {ChannelID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
import {AttachmentUploadService} from '@app/api/channel/services/AttachmentUploadService';
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
import {CallService} from '@app/api/channel/services/CallService';
import {ChannelDataService} from '@app/api/channel/services/ChannelDataService';
import {GroupDmOperationsService} from '@app/api/channel/services/group_dm/GroupDmOperationsService';
@@ -12,6 +13,7 @@ import {MessageInteractionService} from '@app/api/channel/services/MessageIntera
import {MessageService} from '@app/api/channel/services/MessageService';
import {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
import {type ThreadViewer, viewerActive} from '@app/api/experiment/ChannelThreadsGate';
import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
@@ -30,9 +32,15 @@ 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 {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
interface TypingCooldown {
message_send_cooldown_ms?: number;
thread_create_cooldown_ms?: number;
}
interface SlowmodeState {
rateLimitPerUser: number;
retryAfterMs: number;
@@ -188,8 +196,51 @@ export class ChannelService {
);
}
async getSlowmodeState({user, channelId}: {user: User; channelId: ChannelID}): Promise<SlowmodeState> {
const auth = await this.channelData.auth.getChannelAuthenticated({userId: user.id, channelId});
async getTypingCooldown({
user,
viewer,
auth,
}: {
user: User;
viewer: ThreadViewer;
auth: AuthenticatedChannel;
}): Promise<TypingCooldown | null> {
const {channel, guild} = auth;
if (!guild || user.isBot || !viewerActive(viewer, guild.id)) return null;
const createsThreads = TEXT_THREAD_PARENT_CHANNEL_TYPES.has(channel.type);
if (channel.rateLimitPerUser <= 0 || (await auth.hasPermission(Permissions.BYPASS_SLOWMODE))) return null;
const windowMs = channel.rateLimitPerUser * 1000;
const [messageSendCooldownMs, threadCreateCooldownMs] = await Promise.all([
this.peekSlowmode(`slowmode:${channel.id}:${user.id}`, windowMs),
createsThreads ? this.peekSlowmode(`slowmode-thread:${channel.id}:${user.id}`, windowMs) : Promise.resolve(0),
]);
if (messageSendCooldownMs <= 0 && threadCreateCooldownMs <= 0) return null;
return {
...(messageSendCooldownMs > 0 ? {message_send_cooldown_ms: messageSendCooldownMs} : {}),
...(threadCreateCooldownMs > 0 ? {thread_create_cooldown_ms: threadCreateCooldownMs} : {}),
};
}
private async peekSlowmode(identifier: string, windowMs: number): Promise<number> {
const peek = await this.rateLimitService.peekLimit({
identifier,
maxAttempts: 1,
windowMs,
algorithm: 'leaky_bucket',
});
return Math.max(0, peek.resetTime.getTime() - Date.now());
}
async getSlowmodeState({
user,
channelId,
viewer,
}: {
user: User;
viewer: ThreadViewer;
channelId: ChannelID;
}): Promise<SlowmodeState> {
const auth = await this.channelData.auth.getChannelAuthenticated({userId: user.id, channelId, viewer});
const rateLimitPerUser = auth.channel.rateLimitPerUser ?? 0;
if (!auth.guild || rateLimitPerUser <= 0 || user.isBot) {
return {rateLimitPerUser, retryAfterMs: 0, nextSendAllowedAt: null, canBypass: false};
@@ -198,8 +249,9 @@ export class ChannelService {
if (canBypass) {
return {rateLimitPerUser, retryAfterMs: 0, nextSendAllowedAt: null, canBypass: true};
}
const bucket = auth.channel.isThreadOnly() ? 'slowmode-thread' : 'slowmode';
const peek = await this.rateLimitService.peekLimit({
identifier: `slowmode:${channelId}:${user.id}`,
identifier: `${bucket}:${channelId}:${user.id}`,
maxAttempts: 1,
windowMs: rateLimitPerUser * 1000,
algorithm: 'leaky_bucket',
@@ -2,6 +2,7 @@
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
import {MessageInteractionAuthService} from '@app/api/channel/services/interaction/MessageInteractionAuthService';
import {MessagePinAuthService} from '@app/api/channel/services/interaction/MessagePinAuthService';
import {MessagePinService} from '@app/api/channel/services/interaction/MessagePinService';
@@ -9,6 +10,9 @@ import {MessageReactionService} from '@app/api/channel/services/interaction/Mess
import {MessageReadStateService} from '@app/api/channel/services/interaction/MessageReadStateService';
import {dispatchMessageUpdateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch';
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
import {maskThreadArtifactsFor} from '@app/api/channel/services/message/ThreadMessageResponses';
import {assertThreadAllowed} from '@app/api/channel/services/thread/ThreadDenials';
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 {IGatewayService} from '@app/api/infrastructure/IGatewayService';
@@ -26,8 +30,10 @@ import {
} from '@app/api/user/NewConversationLimit';
import {assertGuildMemberCanCommunicate} from '@app/api/utils/GuildCommunicationUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {NewConversationsLimitedError} from '@fluxer/errors/src/domains/user/NewConversationsLimitedError';
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {ChannelPinResponse, MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
export class MessageInteractionService {
@@ -71,22 +77,37 @@ export class MessageInteractionService {
);
}
async startTyping({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
async startTyping({
userId,
channelId,
viewer,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
}): Promise<AuthenticatedChannel> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
if (authChannel.channel.isThreadOnly()) throw new InvalidChannelTypeError();
await authChannel.checkPermission(Permissions.SEND_MESSAGES);
assertGuildMemberCanCommunicate(authChannel.member);
if (!authChannel.guild && (await this.startsNewConversation(authChannel.channel, userId))) return;
if (authChannel.thread) {
assertThreadAllowed(threadWriteBlock('send', authChannel.thread.actor));
}
if (!authChannel.guild && (await this.startsNewConversation(authChannel.channel, userId))) return authChannel;
await this.readStateService.startTyping({authChannel, userId});
return authChannel;
}
async getChannelPins({
userId,
viewer,
channelId,
requestCache,
beforeTimestamp,
limit,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
requestCache: RequestCache;
beforeTimestamp?: Date;
@@ -95,24 +116,37 @@ export class MessageInteractionService {
items: Array<ChannelPinResponse>;
has_more: boolean;
}> {
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId, viewer});
const pins = await this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
const original = pins.items.map((item) => item.message as MessageResponse);
const messages = maskThreadArtifactsFor(viewer, authChannel.channel.guildId, original);
if (messages === original) return pins;
const byId = new Map(messages.map((message) => [message.id, message]));
return {
...pins,
items: pins.items.flatMap((item) => {
const message = byId.get(item.message.id);
return message ? [{...item, message}] : [];
}),
};
}
async pinMessage({
userId,
viewer,
channelId,
messageId,
requestCache,
auditLogReason,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
await this.assertConversationAllowed(authChannel.channel, userId);
@@ -122,18 +156,20 @@ export class MessageInteractionService {
async unpinMessage({
userId,
viewer,
channelId,
messageId,
requestCache,
auditLogReason,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
requestCache: RequestCache;
auditLogReason?: string | null;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
if (!authChannel.guild && authChannel.channel.type !== ChannelTypes.DM_PERSONAL_NOTES) {
await this.authService.validateDMSendPermissions({channel: authChannel.channel, userId});
}
@@ -142,6 +178,7 @@ export class MessageInteractionService {
async getUsersForReaction({
userId,
viewer,
channelId,
messageId,
emoji,
@@ -149,6 +186,7 @@ export class MessageInteractionService {
after,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
@@ -159,25 +197,27 @@ export class MessageInteractionService {
has_more: boolean;
next_after: string | null;
}> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
return this.reactionService.getUsersForReaction({authChannel, messageId, emoji, limit, after, userId});
}
async addReaction({
userId,
viewer,
sessionId,
channelId,
messageId,
emoji,
}: {
userId: UserID;
viewer: ThreadViewer;
sessionId?: string;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
if (!authChannel.guild) {
await this.assertConversationAllowed(authChannel.channel, userId);
}
@@ -210,6 +250,7 @@ export class MessageInteractionService {
async removeReaction({
userId,
viewer,
sessionId,
channelId,
messageId,
@@ -217,6 +258,7 @@ export class MessageInteractionService {
targetId,
}: {
userId: UserID;
viewer: ThreadViewer;
sessionId?: string;
channelId: ChannelID;
messageId: MessageID;
@@ -224,12 +266,13 @@ export class MessageInteractionService {
targetId: UserID;
requestCache: RequestCache;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
await this.reactionService.removeReaction({authChannel, messageId, emoji, targetId, sessionId, actorId: userId});
}
async removeOwnReaction({
userId,
viewer,
sessionId,
channelId,
messageId,
@@ -237,53 +280,60 @@ export class MessageInteractionService {
requestCache,
}: {
userId: UserID;
viewer: ThreadViewer;
sessionId?: string;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
requestCache: RequestCache;
}): Promise<void> {
await this.removeReaction({userId, sessionId, channelId, messageId, emoji, targetId: userId, requestCache});
await this.removeReaction({userId, viewer, sessionId, channelId, messageId, emoji, targetId: userId, requestCache});
}
async removeAllReactionsForEmoji({
userId,
viewer,
channelId,
messageId,
emoji,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
emoji: string;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
await this.reactionService.removeAllReactionsForEmoji({authChannel, messageId, emoji});
}
async removeAllReactions({
userId,
viewer,
channelId,
messageId,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
}): Promise<void> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
await this.reactionService.removeAllReactions({authChannel, messageId});
}
async getMessageReactions({
userId,
viewer,
channelId,
messageId,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
messageId: MessageID;
}): Promise<Array<MessageReaction>> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId, viewer});
return this.reactionService.getMessageReactions({authChannel, messageId});
}
@@ -20,6 +20,7 @@ import {MessageSendService} from '@app/api/channel/services/message/MessageSendS
import {MessageSystemService} from '@app/api/channel/services/message/MessageSystemService';
import {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService';
import {MessageWriteLock} from '@app/api/channel/services/message/MessageWriteLock';
import {ThreadMessageActivity} from '@app/api/channel/services/message/ThreadMessageActivity';
import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
@@ -54,6 +55,7 @@ export class MessageService {
public readonly writeLock: MessageWriteLock;
public readonly crosspostPropagation: CrosspostPropagation;
public readonly crosspost: MessageCrosspostService;
public readonly threadActivity: ThreadMessageActivity;
constructor(
channelRepository: IChannelRepositoryAggregate,
@@ -117,7 +119,9 @@ export class MessageService {
snowflakeService,
favoriteMemeRepository,
});
this.threadActivity = new ThreadMessageActivity(channelRepository, gatewayService, userRepository);
this.send = new MessageSendService({
threadActivity: this.threadActivity,
channelRepository,
userRepository,
storageService,
@@ -4,6 +4,7 @@ import {createChannelID, createUserID} from '@app/api/BrandedTypes';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import type {StreamPreviewService} from '@app/api/channel/services/StreamPreviewService';
import {StreamService} from '@app/api/channel/services/StreamService';
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {InvalidStreamThumbnailPayloadError} from '@fluxer/errors/src/domains/channel/InvalidStreamThumbnailPayloadError';
@@ -55,6 +56,7 @@ describe('StreamService.uploadPreview', () => {
const upload = (thumbnail: string) =>
streamService.uploadPreview({
viewer: SYSTEM_THREAD_VIEWER,
userId: USER_ID,
streamKey: STREAM_KEY,
channelId: CHANNEL_ID,
@@ -3,6 +3,7 @@
import {type ChannelID, createChannelID, createGuildID, type GuildID, type UserID} from '@app/api/BrandedTypes';
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import type {StreamPreviewService} from '@app/api/channel/services/StreamPreviewService';
import type {ThreadViewer} from '@app/api/experiment/ChannelThreadsGate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
@@ -77,11 +78,13 @@ export class StreamService {
private async assertStreamChannelAccess(params: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
parsedKey: ParsedStreamKey;
}): Promise<void> {
const channel = await this.channelService.channelData.operations.getChannel({
userId: params.userId,
viewer: params.viewer,
channelId: params.channelId,
});
if (channel.guildId) {
@@ -118,6 +121,7 @@ export class StreamService {
private async assertStreamMutationAccess(params: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
parsedKey: ParsedStreamKey;
}): Promise<void> {
@@ -149,11 +153,17 @@ export class StreamService {
}
}
async updateStreamRegion(params: {userId: UserID; streamKey: string; region?: string}): Promise<void> {
async updateStreamRegion(params: {
userId: UserID;
viewer: ThreadViewer;
streamKey: string;
region?: string;
}): Promise<void> {
const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey);
const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey);
await this.assertStreamMutationAccess({
userId: params.userId,
viewer: params.viewer,
channelId,
parsedKey,
});
@@ -164,7 +174,7 @@ export class StreamService {
);
}
async getPreview(params: {userId: UserID; streamKey: string}): Promise<{
async getPreview(params: {userId: UserID; viewer: ThreadViewer; streamKey: string}): Promise<{
buffer: Uint8Array;
contentType: string;
} | null> {
@@ -172,6 +182,7 @@ export class StreamService {
const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey);
await this.assertStreamChannelAccess({
userId: params.userId,
viewer: params.viewer,
channelId,
parsedKey,
});
@@ -180,6 +191,7 @@ export class StreamService {
async uploadPreview(params: {
userId: UserID;
viewer: ThreadViewer;
streamKey: string;
channelId: ChannelID;
thumbnail: string;
@@ -188,6 +200,7 @@ export class StreamService {
const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey);
await this.assertStreamMutationAccess({
userId: params.userId,
viewer: params.viewer,
channelId: params.channelId,
parsedKey,
});
@@ -206,6 +219,7 @@ export class StreamService {
async createPreviewUploadUrl(params: {
userId: UserID;
viewer: ThreadViewer;
streamKey: string;
channelId: ChannelID;
contentType?: string;
@@ -214,6 +228,7 @@ export class StreamService {
const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey);
await this.assertStreamMutationAccess({
userId: params.userId,
viewer: params.viewer,
channelId: params.channelId,
parsedKey,
});
@@ -226,11 +241,12 @@ export class StreamService {
});
}
async deletePreview(params: {userId: UserID; streamKey: string}): Promise<void> {
async deletePreview(params: {userId: UserID; viewer: ThreadViewer; streamKey: string}): Promise<void> {
const parsedKey = this.getParsedStreamKeyOrThrow(params.streamKey);
const channelId = this.getChannelIdFromParsedKeyOrThrow(parsedKey);
await this.assertStreamMutationAccess({
userId: params.userId,
viewer: params.viewer,
channelId,
parsedKey,
});
@@ -10,14 +10,38 @@ import {
} from '@app/api/channel/services/ChannelFollowers';
import type {ChannelAuthService} from '@app/api/channel/services/channel_data/ChannelAuthService';
import type {ChannelUtilsService} from '@app/api/channel/services/channel_data/ChannelUtilsService';
import {dispatchThreadEvents} from '@app/api/channel/services/thread/ThreadDispatch';
import {
loadConvertibleParentThreads,
PARENT_CONVERSION_LOCK_TTL_SECONDS,
retypedThreadEvents,
retypeParentThreads,
} from '@app/api/channel/services/thread/ThreadParentConversion';
import {
buildThreadParentPatch,
loadThreadParentConfig,
serializeThreadParentForAudit,
type ThreadParentSettingsInput,
} from '@app/api/channel/services/thread/ThreadParentSettings';
import {enqueueDeleteChannelThreads} from '@app/api/channel/threads/ThreadJobs';
import {
everEnabled,
guildActive,
isTainted,
THREAD_FEATURE_CHANNEL_TYPES,
type ThreadViewer,
viewerActive,
} from '@app/api/experiment/ChannelThreadsGate';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers';
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
import {hasThreadPermissionBits, resolveProtectedBitActor} from '@app/api/guild/services/ThreadPermissionBits';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
import type {IInviteRepository} from '@app/api/invite/IInviteRepository';
import {Logger} from '@app/api/Logger';
@@ -26,17 +50,17 @@ import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder'
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
import type {ThreadState} from '@app/api/models/ThreadState';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {serializeChannelForAudit} from '@app/api/utils/AuditSerializationUtils';
import {applyProtectedOverwriteBits} from '@app/api/utils/featureUtils';
import {applyProtectedOverwriteBits, permissionWriteMask, protectedThreadBits} from '@app/api/utils/featureUtils';
import {overwriteGrantedBits} from '@app/api/utils/PermissionUtils';
import type {VoiceAvailabilityService} from '@app/api/voice/VoiceAvailabilityService';
import type {VoiceRegionAvailability} from '@app/api/voice/VoiceModel';
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {
ALL_PERMISSIONS,
ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES,
ChannelTypes,
GUILD_TEXT_BASED_CHANNEL_TYPES,
@@ -45,6 +69,8 @@ import {
} from '@fluxer/constants/src/ChannelConstants';
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {withImplicitThreadBits} from '@fluxer/constants/src/ThreadPermissionUtils';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {ChannelHasFollowedChannelsError} from '@fluxer/errors/src/domains/channel/ChannelHasFollowedChannelsError';
import {ChannelTypeConversionNotSupportedError} from '@fluxer/errors/src/domains/channel/ChannelTypeConversionNotSupportedError';
@@ -84,6 +110,13 @@ export interface ChannelUpdateData {
nicks?: Record<string, string | null> | null;
}
function assertOverwriteTarget(channel: Channel, viewer: ThreadViewer | undefined): void {
if (!THREAD_FEATURE_CHANNEL_TYPES.has(channel.type)) return;
const active = viewer !== undefined && channel.guildId !== null && viewerActive(viewer, channel.guildId);
if (!active) throw new UnknownChannelError();
if (channel.isThread()) throw new InvalidChannelTypeError();
}
export class ChannelOperationsService {
constructor(
private channelRepository: IChannelRepositoryAggregate,
@@ -101,20 +134,24 @@ export class ChannelOperationsService {
private limitConfigService: LimitConfigService,
private rateLimitService: IRateLimitService,
private cacheService: ICacheService,
private snowflakeService: ISnowflakeService,
) {}
async getChannel({
userId,
viewer,
channelId,
skipNsfwValidation,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
skipNsfwValidation?: boolean;
}): Promise<Channel> {
const {channel} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation,
});
return channel;
@@ -138,24 +175,29 @@ export class ChannelOperationsService {
async editChannel({
userId,
viewer,
channelId,
data,
clientFeatures,
requestCache,
auditLogReason,
typeConversion,
threadParent,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
data: ChannelUpdateData;
clientFeatures: ReadonlySet<string>;
requestCache: RequestCache;
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
threadParent?: ThreadParentSettingsInput | null;
}): Promise<Channel> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
if (channel.type === ChannelTypes.GROUP_DM) {
@@ -165,6 +207,18 @@ export class ChannelOperationsService {
await checkPermission(Permissions.MANAGE_CHANNELS);
const nextType = resolveNextChannelType(channel, typeConversion ?? null);
const guildIdValue = createGuildID(BigInt(guild.id));
const parentConfig = await loadThreadParentConfig(this.channelRepository.threads, channel);
const parentPatch =
threadParent && guildActive(guildIdValue)
? await buildThreadParentPatch({
channelType: channel.type,
guildId: guildIdValue,
input: threadParent,
current: parentConfig,
guildRepository: this.guildRepository,
generateId: () => this.snowflakeService.generate(),
})
: null;
contentModerationService.scanText(data.name ?? null, {
userId,
guildId: guildIdValue,
@@ -180,7 +234,7 @@ export class ChannelOperationsService {
surface: 'profile_field',
});
let channelName = data.name ?? channel.name;
if (data.name !== undefined && isTextNamedChannelType(channel.type)) {
if (data.name !== undefined && (isTextNamedChannelType(channel.type) || channel.isThreadOnly())) {
const hasFlexibleNamesEnabled = guild.features?.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES) ?? false;
if (!hasFlexibleNamesEnabled) {
channelName = ChannelNameType.parse(data.name);
@@ -219,25 +273,34 @@ export class ChannelOperationsService {
const guildId = createGuildID(BigInt(guild.id));
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
const gatewayPermissions = await this.gatewayService.getUserPermissions({
guildId,
userId,
channelId: channel.id,
});
const actor = await resolveProtectedBitActor({
guildId,
userId,
clientFeatures,
viewer,
isBot: async () => viewer.kind === 'user' && viewer.bot,
});
const writeMask = permissionWriteMask(actor);
const channelPermissions = actor.threadBits ? withImplicitThreadBits(gatewayPermissions) : gatewayPermissions;
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
const targetId = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
const existing = previousPermissionOverwrites?.get(targetId);
const protectedBits = applyProtectedOverwriteBits(
{
allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & ALL_PERMISSIONS,
deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS,
allow: (overwrite.allow ? BigInt(overwrite.allow) : 0n) & writeMask,
deny: (overwrite.deny ? BigInt(overwrite.deny) : 0n) & writeMask,
},
{
allow: existing?.allow ?? 0n,
deny: existing?.deny ?? 0n,
},
clientFeatures,
actor,
);
permissionOverwrites.set(
targetId,
@@ -248,6 +311,19 @@ export class ChannelOperationsService {
}),
);
}
const keptBits = protectedThreadBits(actor);
if (keptBits !== 0n) {
for (const [targetId, previous] of previousPermissionOverwrites ?? []) {
if (permissionOverwrites.has(targetId)) continue;
const allow = previous.allow & keptBits;
const deny = previous.deny & keptBits;
if (allow === 0n && deny === 0n) continue;
permissionOverwrites.set(
targetId,
new ChannelPermissionOverwrite({type: previous.type, allow_: allow, deny_: deny}),
);
}
}
if (!isOwner) {
const targetIds = new Set([...(previousPermissionOverwrites?.keys() ?? []), ...permissionOverwrites.keys()]);
for (const targetId of targetIds) {
@@ -255,7 +331,7 @@ export class ChannelOperationsService {
previousPermissionOverwrites?.get(targetId),
permissionOverwrites.get(targetId),
);
if ((grantedBits & ~channelPermissions) !== 0n) {
if ((grantedBits & ~keptBits & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
@@ -292,7 +368,7 @@ export class ChannelOperationsService {
? data.voice_connection_limit
: channel.voiceConnectionLimit,
rate_limit_per_user:
data.rate_limit_per_user !== undefined && GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type)
data.rate_limit_per_user !== undefined && acceptsRateLimit(channel.type)
? data.rate_limit_per_user
: channel.rateLimitPerUser,
nsfw: resolveNsfwOverrideWrite(channel, data),
@@ -309,26 +385,54 @@ export class ChannelOperationsService {
]),
),
};
let retypedThreads: Array<ThreadState> = [];
const toAnnouncement =
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT;
const updatedChannel =
nextType === ChannelTypes.GUILD_ANNOUNCEMENT && channel.type !== ChannelTypes.GUILD_ANNOUNCEMENT
? await withChannelFollowLock(this.cacheService, channelId, async () => {
const webhooks = await this.webhookRepository.listByChannel(channelId);
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
throw new ChannelHasFollowedChannelsError();
}
return await this.channelRepository.channelData.upsert(updatedChannelData);
})
toAnnouncement || (nextType !== channel.type && everEnabled())
? await withChannelFollowLock(
this.cacheService,
channelId,
async () => {
if (toAnnouncement) {
const webhooks = await this.webhookRepository.listByChannel(channelId);
if (webhooks.some((webhook) => webhook.type === WebhookTypes.CHANNEL_FOLLOWER)) {
throw new ChannelHasFollowedChannelsError();
}
}
const threads = await loadConvertibleParentThreads(
this.channelRepository,
guildIdValue,
channel,
nextType,
);
if (threads.length === 0) return this.channelRepository.channelData.upsert(updatedChannelData);
const {parent, active} = await retypeParentThreads(this.channelRepository, threads, nextType, () =>
this.channelRepository.channelData.upsert(updatedChannelData),
);
retypedThreads = active;
return parent;
},
everEnabled() ? PARENT_CONVERSION_LOCK_TTL_SECONDS : undefined,
)
: await this.channelRepository.channelData.upsert(updatedChannelData);
if (channel.type === ChannelTypes.GUILD_ANNOUNCEMENT && nextType !== ChannelTypes.GUILD_ANNOUNCEMENT) {
await enqueueChannelFollowerRemoval({sourceChannelId: channelId, reason: 'converted'});
}
if (parentPatch) await this.channelRepository.threads.patchParentConfig(guildIdValue, channel.id, parentPatch);
const nextParentConfig = parentPatch
? await loadThreadParentConfig(this.channelRepository.threads, updatedChannel)
: parentConfig;
if (
data.rate_limit_per_user !== undefined &&
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
acceptsRateLimit(channel.type) &&
data.rate_limit_per_user !== channel.rateLimitPerUser
) {
try {
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode:${channelId}:`);
if (everEnabled() && (await isTainted(guildIdValue))) {
await this.rateLimitService.clearLimitsByIdentifierPrefix(`slowmode-thread:${channelId}:`);
}
} catch (error) {
Logger.error(
{error, channelId: channelId.toString()},
@@ -337,6 +441,11 @@ export class ChannelOperationsService {
}
}
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChannel, requestCache});
await dispatchThreadEvents(
this.gatewayService,
guildIdValue,
await retypedThreadEvents(this.channelRepository, updatedChannel, retypedThreads),
);
if (channel.type === ChannelTypes.GUILD_CATEGORY && data.permission_overwrites !== undefined && guild) {
await this.propagatePermissionsToSyncedChildren({
categoryChannel: updatedChannel,
@@ -356,8 +465,14 @@ export class ChannelOperationsService {
channelId,
});
}
const beforeSnapshot = serializeChannelForAudit(channel);
const afterSnapshot = serializeChannelForAudit(updatedChannel);
const beforeSnapshot = {
...serializeChannelForAudit(channel),
...serializeThreadParentForAudit(channel.type, parentConfig),
};
const afterSnapshot = {
...serializeChannelForAudit(updatedChannel),
...serializeThreadParentForAudit(updatedChannel.type, nextParentConfig),
};
const changes = this.guildAuditLogService.computeChanges(beforeSnapshot, afterSnapshot);
if (changes.length > 0) {
const builder = this.guildAuditLogService
@@ -398,11 +513,13 @@ export class ChannelOperationsService {
async deleteChannel({
userId,
viewer,
channelId,
requestCache,
auditLogReason,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
requestCache: RequestCache;
auditLogReason: string | null;
@@ -410,6 +527,7 @@ export class ChannelOperationsService {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
if (this.channelAuthService.isPersonalNotesChannel({userId, channelId})) {
@@ -419,13 +537,14 @@ export class ChannelOperationsService {
await checkPermission(Permissions.MANAGE_CHANNELS);
const guildId = createGuildID(BigInt(guild.id));
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance');
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === channelId);
for (const childChannel of childChannels) {
const updatedChild = await this.channelRepository.channelData.upsert({
...childChannel.toRow(),
parent_id: null,
});
if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) continue;
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
}
}
@@ -470,7 +589,11 @@ export class ChannelOperationsService {
'Failed to record guild audit log',
);
}
await this.channelRepository.channelData.delete(channelId, guildId);
await this.channelRepository.channelData.delete(channelId, guildId, channel.type);
if (channel.isThreadParent() && everEnabled() && (await isTainted(guildId, {fresh: true}))) {
await enqueueDeleteChannelThreads(guildId, channelId);
await this.channelRepository.threads.deleteParentConfig(guildId, channelId);
}
const guildModel = await this.guildRepository.findUnique(guildId);
if (guildModel) {
const guildRow = guildModel.toRow();
@@ -495,9 +618,11 @@ export class ChannelOperationsService {
async getAvailableRtcRegions({
userId,
viewer,
channelId,
}: {
userId: UserID;
viewer: ThreadViewer;
channelId: ChannelID;
}): Promise<Array<VoiceRegionAvailability>> {
if (this.voiceAvailabilityService === null) {
@@ -506,6 +631,7 @@ export class ChannelOperationsService {
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({
userId,
channelId,
viewer,
skipNsfwValidation: true,
});
if (channel.type !== ChannelTypes.GUILD_VOICE) {
@@ -535,7 +661,7 @@ export class ChannelOperationsService {
guildId: GuildID;
requestCache: RequestCache;
}): Promise<void> {
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId);
const guildChannels = await this.channelRepository.channelData.listGuildChannels(guildId, 'maintenance');
const childChannels = guildChannels.filter((ch: Channel) => ch.parentId === categoryChannel.id);
const syncedChannels: Array<Channel> = [];
for (const child of childChannels) {
@@ -555,6 +681,7 @@ export class ChannelOperationsService {
]),
),
});
if (THREAD_ONLY_CHANNEL_TYPES.has(updatedChild.type) && !guildActive(guildId)) return;
await this.channelUtilsService.dispatchChannelUpdate({channel: updatedChild, requestCache});
}),
);
@@ -617,7 +744,7 @@ export class ChannelOperationsService {
if (params.channel.type === ChannelTypes.GUILD_CATEGORY) {
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.CATEGORIES_CANNOT_HAVE_PARENTS);
}
const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId);
const guildChannels = await this.channelRepository.channelData.listGuildChannels(params.guildId, 'enrolled');
const parentChannel = guildChannels.find((channel) => channel.id === params.parentId);
if (!parentChannel) {
throw InputValidationError.fromCode('parent_id', ValidationErrorCodes.INVALID_PARENT_CHANNEL);
@@ -661,11 +788,13 @@ export class ChannelOperationsService {
deny_: bigint;
};
clientFeatures: ReadonlySet<string>;
viewer?: ThreadViewer;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel?.guildId) throw new UnknownChannelError();
assertOverwriteTarget(channel, params.viewer);
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
@@ -674,22 +803,31 @@ export class ChannelOperationsService {
});
const targetId = params.overwrite.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
const existing = channel.permissionOverwrites?.get(targetId);
const actor = await resolveProtectedBitActor({
guildId: channel.guildId,
userId: params.userId,
clientFeatures: params.clientFeatures,
viewer: params.viewer,
isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false,
});
const writeMask = permissionWriteMask(actor);
const protectedBits = applyProtectedOverwriteBits(
{
allow: params.overwrite.allow_ & ALL_PERMISSIONS,
deny: params.overwrite.deny_ & ALL_PERMISSIONS,
allow: params.overwrite.allow_ & writeMask,
deny: params.overwrite.deny_ & writeMask,
},
{
allow: existing?.allow ?? 0n,
deny: existing?.deny ?? 0n,
},
params.clientFeatures,
actor,
);
const sanitizedAllow = protectedBits.allow;
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
const grantedBits = overwriteGrantedBits(existing, {allow: sanitizedAllow, deny: sanitizedDeny});
if (!hasAdministrator && (grantedBits & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const effectivePermissions = actor.threadBits ? withImplicitThreadBits(userPermissions) : userPermissions;
if (!hasAdministrator && (grantedBits & ~effectivePermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const nextOverwrite = new ChannelPermissionOverwrite({
type: params.overwrite.type,
@@ -698,12 +836,15 @@ export class ChannelOperationsService {
});
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(targetId, nextOverwrite);
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
});
const updated = await this.channelRepository.channelData.upsert(
{
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
},
channel.toRow(),
);
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
await this.propagatePermissionsToSyncedChildren({
@@ -727,17 +868,32 @@ export class ChannelOperationsService {
userId: UserID;
channelId: ChannelID;
overwriteId: bigint;
clientFeatures?: ReadonlySet<string>;
viewer?: ThreadViewer;
requestCache: RequestCache;
auditLogReason: string | null;
}): Promise<void> {
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
if (!channel?.guildId) throw new UnknownChannelError();
assertOverwriteTarget(channel, params.viewer);
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
const kept =
removed && (hasThreadPermissionBits(removed.allow) || hasThreadPermissionBits(removed.deny))
? protectedThreadBits(
await resolveProtectedBitActor({
guildId: channel.guildId,
userId: params.userId,
clientFeatures: params.clientFeatures ?? new Set(),
viewer: params.viewer,
isBot: async () => (await this.userRepository.findUnique(params.userId))?.isBot ?? false,
}),
)
: 0n;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
@@ -745,16 +901,30 @@ export class ChannelOperationsService {
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
if (!hasAdministrator && (removed.deny & ~kept & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
});
if (removed && kept !== 0n) {
const removedTargetId = removed.type === 0 ? createRoleID(params.overwriteId) : createUserID(params.overwriteId);
overwrites.set(
removedTargetId,
new ChannelPermissionOverwrite({
type: removed.type,
allow_: removed.allow & kept,
deny_: removed.deny & kept,
}),
);
}
const updated = await this.channelRepository.channelData.upsert(
{
...channel.toRow(),
permission_overwrites: new Map(
Array.from(overwrites.entries()).map(([id, ow]) => [id, ow.toPermissionOverwrite()]),
),
},
channel.toRow(),
);
await this.channelUtilsService.dispatchChannelUpdate({channel: updated, requestCache: params.requestCache});
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
await this.propagatePermissionsToSyncedChildren({
@@ -807,10 +977,15 @@ function isWritableGuildChannel(type: number): boolean {
type === ChannelTypes.GUILD_ANNOUNCEMENT ||
type === ChannelTypes.GUILD_VOICE ||
type === ChannelTypes.GUILD_LINK ||
type === ChannelTypes.GUILD_CATEGORY
type === ChannelTypes.GUILD_CATEGORY ||
THREAD_ONLY_CHANNEL_TYPES.has(type)
);
}
function acceptsRateLimit(type: number): boolean {
return GUILD_TEXT_BASED_CHANNEL_TYPES.has(type) || THREAD_ONLY_CHANNEL_TYPES.has(type);
}
function resolveNsfwOverrideWrite(channel: Channel, data: ChannelUpdateData): boolean | null {
if (!isWritableGuildChannel(channel.type)) {
return channel.nsfwOverride;
@@ -6,6 +6,7 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch';
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
import {withThreadParentFields} from '@app/api/channel/services/thread/ThreadParentSettings';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
@@ -48,12 +49,16 @@ export class ChannelUtilsService {
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
if (channel.guildId) {
const channelResponse = await mapChannelToResponse({
const channelResponse = await withThreadParentFields(
this.channelRepository.threads,
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
});
await mapChannelToResponse({
channel,
currentUserId: null,
userCacheService: this.userCacheService,
requestCache,
}),
);
await dispatchChannelEvent({
gatewayService: this.gatewayService,
channel,
@@ -3,6 +3,7 @@
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {Channel} from '@app/api/models/Channel';
import {TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {THREAD_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -21,7 +22,7 @@ export abstract class MessageInteractionBase {
}
protected ensureTextChannel(channel: Channel): void {
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type) && !THREAD_CHANNEL_TYPES.has(channel.type)) {
throw new CannotSendMessageToNonTextChannelError();
}
}
@@ -12,6 +12,7 @@ import {
} from '@app/api/channel/services/message/MessageGatewayDispatch';
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
import {createMessageResponseDataService} from '@app/api/channel/services/message/MessageResponseDataService';
import {assertThreadInteractionAllowed} from '@app/api/channel/services/thread/ThreadInteractionGuards';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
@@ -21,7 +22,9 @@ import type {Message} from '@app/api/models/Message';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {CannotEditSystemMessageError} from '@fluxer/errors/src/domains/channel/CannotEditSystemMessageError';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import type {ChannelPinResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
@@ -75,6 +78,7 @@ export class MessagePinService extends MessageInteractionBase {
has_more: boolean;
}> {
const {channel} = authChannel;
if (THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) throw new InvalidChannelTypeError();
this.ensureTextChannel(channel);
const hasReadHistory = !authChannel.guild || (await authChannel.hasPermission(Permissions.READ_MESSAGE_HISTORY));
if (!hasReadHistory) {
@@ -158,6 +162,7 @@ export class MessagePinService extends MessageInteractionBase {
}
}
this.ensureTextChannel(channel);
assertThreadInteractionAllowed(authChannel, 'pin');
await this.assertMessageHistoryAccess({authChannel, messageId});
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) throw new UnknownMessageError();
@@ -168,7 +173,7 @@ export class MessagePinService extends MessageInteractionBase {
const updatedMessage = await this.channelRepository.messages.upsertMessage(updatedMessageData, message.toRow());
await this.channelRepository.messageInteractions.addChannelPin(channel.id, messageId, now);
const updatedChannelData = {...channel.toRow(), last_pin_timestamp: now};
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData);
const updatedChannel = await this.channelRepository.channelData.upsert(updatedChannelData, channel.toRow());
await this.dispatchChannelPinsUpdate(updatedChannel);
await this.sendPinSystemMessage({channel, message, userId});
await dispatchMessageUpdateBroadcast({
@@ -184,6 +189,7 @@ export class MessagePinService extends MessageInteractionBase {
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withThreadScope(channel.isThread())
.withReason(auditLogReason ?? null)
.commit();
}
@@ -209,6 +215,7 @@ export class MessagePinService extends MessageInteractionBase {
}
}
this.ensureTextChannel(channel);
assertThreadInteractionAllowed(authChannel, 'pin');
await this.assertMessageHistoryAccess({authChannel, messageId});
const message = await this.channelRepository.messages.getMessage(channel.id, messageId);
if (!message) throw new UnknownMessageError();
@@ -231,6 +238,7 @@ export class MessagePinService extends MessageInteractionBase {
channel_id: channel.id.toString(),
message_id: messageId.toString(),
})
.withThreadScope(channel.isThread())
.withReason(auditLogReason ?? null)
.commit();
}
@@ -6,6 +6,7 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC
import type {AuthenticatedChannel} from '@app/api/channel/services/AuthenticatedChannel';
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import {MessageInteractionBase, type ParsedEmoji} from '@app/api/channel/services/interaction/MessageInteractionBase';
import {assertThreadInteractionAllowed} from '@app/api/channel/services/thread/ThreadInteractionGuards';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -162,6 +163,7 @@ export class MessageReactionService extends MessageInteractionBase {
const channel = authChannel.channel;
const {guild, hasPermission, checkPermission} = authChannel;
this.ensureTextChannel(channel);
assertThreadInteractionAllowed(authChannel, 'react');
assertGuildMemberCanCommunicate(authChannel.member);
await this.assertMessageHistoryAccess({authChannel, messageId});
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
@@ -266,6 +268,7 @@ export class MessageReactionService extends MessageInteractionBase {
const channel = authChannel.channel;
const {guild, hasPermission} = authChannel;
this.ensureTextChannel(channel);
assertThreadInteractionAllowed(authChannel, 'react', {ignoreTimeout: true});
await this.assertMessageHistoryAccess({authChannel, messageId});
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
@@ -306,6 +309,7 @@ export class MessageReactionService extends MessageInteractionBase {
const channel = authChannel.channel;
const {guild, hasPermission} = authChannel;
this.ensureTextChannel(channel);
assertThreadInteractionAllowed(authChannel, 'react', {ignoreTimeout: true});
await this.assertMessageHistoryAccess({authChannel, messageId});
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
@@ -338,6 +342,7 @@ export class MessageReactionService extends MessageInteractionBase {
const channel = authChannel.channel;
const {guild, hasPermission} = authChannel;
this.ensureTextChannel(channel);
assertThreadInteractionAllowed(authChannel, 'react', {ignoreTimeout: true});
await this.assertMessageHistoryAccess({authChannel, messageId});
if (this.isOperationDisabled(guild, GuildOperations.REACTIONS)) {
throw new FeatureTemporarilyDisabledError();
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import fs from 'node:fs';
import {createAttachmentID, type UserID} from '@app/api/BrandedTypes';
import {type ChannelID, createAttachmentID, type UserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {AttachmentToProcess} from '@app/api/channel/AttachmentDTOs';
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
@@ -45,6 +45,7 @@ interface ProcessAttachmentParams {
attachment: AttachmentToProcess;
index: number;
uploadUserId: UserID;
uploadChannelId?: ChannelID;
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
@@ -81,6 +82,7 @@ export class AttachmentProcessingService {
message: Message;
attachments: Array<AttachmentToProcess>;
uploadUserId: UserID;
uploadChannelId?: ChannelID;
channel?: Channel;
guild?: GuildResponse | null;
member?: GuildMemberResponse | null;
@@ -104,6 +106,7 @@ export class AttachmentProcessingService {
attachment,
index,
uploadUserId: params.uploadUserId,
uploadChannelId: params.uploadChannelId,
channel: params.channel,
guild: params.guild,
member: params.member,
@@ -181,7 +184,7 @@ export class AttachmentProcessingService {
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
uploadKey: attachment.upload_filename,
userId: params.uploadUserId,
channelId: message.channelId,
channelId: params.uploadChannelId ?? message.channelId,
});
if (!pendingUpload) {
throw InputValidationError.fromCode(
@@ -66,6 +66,7 @@ import {
WebhookTypes,
} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {THREAD_MESSAGE_FLAG_MASK} from '@fluxer/constants/src/ThreadConstants';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -644,7 +645,10 @@ export class CrosspostDeliveryService {
attachments: null,
embeds: null,
sticker_items: null,
flags: MessageFlags.IS_CROSSPOST | MessageFlags.SOURCE_MESSAGE_DELETED,
flags:
MessageFlags.IS_CROSSPOST |
MessageFlags.SOURCE_MESSAGE_DELETED |
(fresh.flags & THREAD_MESSAGE_FLAG_MASK),
edited_timestamp: new Date(),
},
fresh.toRow(),
@@ -8,6 +8,7 @@ import type {IUserRepository} from '@app/api/user/IUserRepository';
import * as EmojiUtils from '@app/api/utils/EmojiUtils';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {GuildExplicitContentFilterTypes, GuildFeatures, GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -50,7 +51,11 @@ export class MessageContentService {
if (isBot) {
return true;
}
if (channel && GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) && channel.isNsfw) {
if (
channel &&
(GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) || THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) &&
channel.isNsfw
) {
return true;
}
if (channel?.type === ChannelTypes.DM_PERSONAL_NOTES) {

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