mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87e2fe7bf | ||
|
|
871b5116dc | ||
|
|
d7b2e67c35 | ||
|
|
7e1ca9ed6a | ||
|
|
1922d56188 | ||
|
|
cc105883a5 | ||
|
|
41c7acdd8f | ||
|
|
c35d29ea0b | ||
|
|
97c29bf1fb | ||
|
|
3ff7189566 | ||
|
|
3fa766c39c | ||
|
|
10ae4bfe1e | ||
|
|
d271f3112c | ||
|
|
5a13172b46 | ||
|
|
2269e1899e | ||
|
|
c61fd96df6 | ||
|
|
6c68202222 | ||
|
|
e0bb10d5b7 | ||
|
|
96643ab20d | ||
|
|
40da982f57 | ||
|
|
8a14281b87 | ||
|
|
be69157811 | ||
|
|
45289b5531 | ||
|
|
30a1271774 | ||
|
|
438f11adda | ||
|
|
170ca805c5 | ||
|
|
f4547d11d3 | ||
|
|
ccdd85b099 | ||
|
|
98c40c6979 | ||
|
|
e054aa96be | ||
|
|
3e12466c57 | ||
|
|
039bd1a7df | ||
|
|
7a45d5844d | ||
|
|
410fa2dba9 | ||
|
|
4cb1808959 | ||
|
|
60a62c24c3 | ||
|
|
c06e958eb5 | ||
|
|
358b7c88fc | ||
|
|
1bced6abae |
@@ -65,10 +65,19 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: read
|
||||
- name: set variables
|
||||
id: vars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
|
||||
@@ -151,9 +160,18 @@ jobs:
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: write
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-admin
|
||||
dockerfile: fluxer_admin/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-api
|
||||
dockerfile: fluxer_api/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
build:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
|
||||
@@ -187,9 +187,18 @@ jobs:
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: write
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
|
||||
@@ -67,10 +67,19 @@ jobs:
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: read
|
||||
- name: Set metadata
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
@@ -581,9 +590,18 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: write
|
||||
- name: Publish GitHub desktop release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-gateway
|
||||
dockerfile: fluxer_gateway/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-gifs
|
||||
dockerfile: fluxer_gifs/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-media-proxy
|
||||
dockerfile: fluxer_media_proxy/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-messages
|
||||
dockerfile: fluxer_messages/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-snowflakes
|
||||
dockerfile: fluxer_snowflakes/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-static
|
||||
dockerfile: fluxer_static/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-unfurl
|
||||
dockerfile: fluxer_unfurl/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-users
|
||||
dockerfile: fluxer_users/Dockerfile
|
||||
|
||||
@@ -17,6 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
name: Dispatch regeneration
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
|
||||
@@ -31,6 +31,7 @@ concurrency:
|
||||
jobs:
|
||||
metadata:
|
||||
name: resolve exact private build metadata
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -39,11 +40,20 @@ jobs:
|
||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
||||
steps:
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: read
|
||||
- name: Resolve trusted build inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_AFTER: ${{ github.event.after }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
PARENT_SHA: ${{ github.sha }}
|
||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
|
||||
@@ -5,6 +5,7 @@ permissions: {}
|
||||
jobs:
|
||||
label:
|
||||
name: Label
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
|
||||
@@ -41,7 +41,7 @@ concurrency:
|
||||
jobs:
|
||||
automatic:
|
||||
name: Lock closed conversation
|
||||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||||
if: github.repository == 'fluxerapp/fluxer' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
retroactive:
|
||||
name: Lock closed conversations retroactively
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
if: github.repository == 'fluxerapp/fluxer' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
**/.cache/
|
||||
**/.swc/
|
||||
**/__pycache__/
|
||||
**/_build/
|
||||
**/coverage/
|
||||
|
||||
Generated
+10
@@ -1624,6 +1624,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
@@ -1931,6 +1940,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"hmac 0.13.0",
|
||||
|
||||
@@ -19,8 +19,6 @@ members = [
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -12543,7 +12543,6 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"single_community_enabled": {"type": "boolean"},
|
||||
"single_community_locked": {"type": "boolean"},
|
||||
"single_community_guild_id": {"nullable": true, "type": "string"},
|
||||
"direct_messages_disabled": {"type": "boolean"},
|
||||
"direct_messages_locked": {"type": "boolean"},
|
||||
@@ -12574,18 +12573,27 @@
|
||||
"bluesky": {"type": "boolean"}
|
||||
},
|
||||
"required": ["gif", "youtube", "bluesky"]
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number"},
|
||||
"member_threshold": {"type": "number"}
|
||||
},
|
||||
"required": ["enabled", "window_hours", "member_threshold"]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"single_community_enabled",
|
||||
"single_community_locked",
|
||||
"single_community_guild_id",
|
||||
"direct_messages_disabled",
|
||||
"direct_messages_locked",
|
||||
"premium_mode",
|
||||
"services",
|
||||
"services_resolved",
|
||||
"services_available"
|
||||
"services_available",
|
||||
"deferred_phone_gate"
|
||||
]
|
||||
},
|
||||
"integrations": {
|
||||
@@ -13112,6 +13120,21 @@
|
||||
"youtube_enabled": {"nullable": true, "type": "boolean"},
|
||||
"bluesky_enabled": {"nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true},
|
||||
"member_threshold": {
|
||||
"type": "integer",
|
||||
"maximum": 1000000,
|
||||
"format": "int32",
|
||||
"minimum": 0,
|
||||
"exclusiveMinimum": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14881,6 +14904,10 @@
|
||||
"premium_grace_ends_at": {"nullable": true, "type": "string"},
|
||||
"premium_lifetime_sequence": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"suspicious_activity_flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"},
|
||||
"phone_verification_deferred": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community"
|
||||
},
|
||||
"temp_banned_until": {"nullable": true, "type": "string"},
|
||||
"pending_deletion_at": {"nullable": true, "type": "string"},
|
||||
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
|
||||
@@ -14921,6 +14948,7 @@
|
||||
"premium_grace_ends_at",
|
||||
"premium_lifetime_sequence",
|
||||
"suspicious_activity_flags",
|
||||
"phone_verification_deferred",
|
||||
"temp_banned_until",
|
||||
"pending_deletion_at",
|
||||
"pending_bulk_message_deletion_at",
|
||||
|
||||
@@ -69,6 +69,7 @@ mod tests {
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct AdminUser {
|
||||
#[serde(default)]
|
||||
pub suspicious_activity_flags: i32,
|
||||
#[serde(default)]
|
||||
pub phone_verification_deferred: bool,
|
||||
#[serde(default)]
|
||||
pub has_totp: bool,
|
||||
#[serde(default)]
|
||||
pub authenticator_types: Vec<i32>,
|
||||
|
||||
@@ -24,8 +24,6 @@ pub struct InstanceConfigResponse {
|
||||
pub struct InstancePolicyResponse {
|
||||
#[serde(default)]
|
||||
pub single_community_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub single_community_locked: bool,
|
||||
pub single_community_guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub direct_messages_disabled: bool,
|
||||
@@ -39,13 +37,34 @@ pub struct InstancePolicyResponse {
|
||||
pub services_resolved: InstanceServicesResolved,
|
||||
#[serde(default)]
|
||||
pub services_available: InstanceServicesAvailable,
|
||||
#[serde(default)]
|
||||
pub deferred_phone_gate: DeferredPhoneGateResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeferredPhoneGateResponse {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub window_hours: f64,
|
||||
#[serde(default)]
|
||||
pub member_threshold: i64,
|
||||
}
|
||||
|
||||
impl Default for DeferredPhoneGateResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
window_hours: 6.0,
|
||||
member_threshold: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InstancePolicyResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
single_community_enabled: false,
|
||||
single_community_locked: false,
|
||||
single_community_guild_id: None,
|
||||
direct_messages_disabled: false,
|
||||
direct_messages_locked: false,
|
||||
@@ -53,6 +72,7 @@ impl Default for InstancePolicyResponse {
|
||||
services: InstanceServicesOverrides::default(),
|
||||
services_resolved: InstanceServicesResolved::default(),
|
||||
services_available: InstanceServicesAvailable::default(),
|
||||
deferred_phone_gate: DeferredPhoneGateResponse::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +539,18 @@ pub struct InstancePolicyUpdateRequest {
|
||||
pub premium_mode: Option<PremiumMode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub services: Option<InstanceServicesUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DeferredPhoneGateUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_hours: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub member_threshold: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
@@ -216,7 +216,11 @@ pub async fn instance_config_post(
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"disable_single_community" => {
|
||||
let update = build_disable_single_community_update();
|
||||
let update = build_single_community_update(false);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"enable_single_community" => {
|
||||
let update = build_single_community_update(true);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"create_registration_url" => match build_create_registration_url_request(&form) {
|
||||
@@ -543,6 +547,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
_ => None,
|
||||
};
|
||||
let services = build_services_update(form);
|
||||
let deferred_phone_gate = build_deferred_phone_gate_update(form);
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
@@ -554,12 +559,37 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
direct_messages_disabled,
|
||||
premium_mode,
|
||||
services,
|
||||
deferred_phone_gate,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_deferred_phone_gate_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Option<DeferredPhoneGateUpdateRequest> {
|
||||
let enabled = form
|
||||
.first("policy_deferred_phone_gate_enabled")
|
||||
.map(|value| value == "true");
|
||||
let window_hours = form
|
||||
.first("policy_deferred_phone_gate_window_hours")
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
.filter(|value| *value > 0.0);
|
||||
let member_threshold = form
|
||||
.first("policy_deferred_phone_gate_member_threshold")
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.filter(|value| *value > 0);
|
||||
if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(DeferredPhoneGateUpdateRequest {
|
||||
enabled,
|
||||
window_hours,
|
||||
member_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
|
||||
let parse_tristate = |key: &str| match form.first(key) {
|
||||
Some("inherit") => Some(None),
|
||||
@@ -696,18 +726,19 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
|
||||
})
|
||||
}
|
||||
|
||||
fn build_disable_single_community_update() -> InstanceConfigUpdateRequest {
|
||||
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
single_community_enabled: Some(false),
|
||||
single_community_enabled: Some(enabled),
|
||||
single_community_name: None,
|
||||
direct_messages_disabled: None,
|
||||
premium_mode: None,
|
||||
services: None,
|
||||
deferred_phone_gate: None,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
|
||||
@@ -103,6 +103,7 @@ pub fn instance_config_page(
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
(sso_config_section(base, csrf_token, &instance_config.sso))
|
||||
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
|
||||
},
|
||||
))
|
||||
@if instance_config.self_hosted {
|
||||
@@ -208,18 +209,14 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Single community" }
|
||||
(badge(status.0, status.1))
|
||||
@if policy.single_community_locked {
|
||||
(badge("Locked", BadgeVariant::Warning))
|
||||
}
|
||||
}
|
||||
@if let Some(guild_id) = policy.single_community_guild_id.as_deref() {
|
||||
p class="break-all text-xs text-neutral-500" { "Community guild ID: " (guild_id) }
|
||||
}
|
||||
@if policy.single_community_enabled && !policy.single_community_locked {
|
||||
@if policy.single_community_enabled {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"This instance funnels every member into a single community. Disabling it is \
|
||||
permanent: single-community mode can only be enabled again from the \
|
||||
self-host setup wizard, never from this panel."
|
||||
"This instance funnels every member into a single community. You can turn this \
|
||||
off and on again from here. The community itself is kept either way."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=disable_single_community"} {
|
||||
(csrf_input(csrf_token))
|
||||
@@ -227,15 +224,21 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
|
||||
(danger_button("Disable single-community mode"))
|
||||
}))
|
||||
}
|
||||
} @else if policy.single_community_enabled {
|
||||
} @else if policy.single_community_guild_id.is_some() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Single-community mode is enabled and locked for this instance. It cannot be \
|
||||
changed from the admin panel."
|
||||
"Single-community mode is off. Turning it on again reuses the community above \
|
||||
if it still exists, otherwise a new one is created."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=enable_single_community"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(submit_button("Enable single-community mode"))
|
||||
}))
|
||||
}
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Single-community mode is off. It can only be turned on from the self-host \
|
||||
setup wizard, not from this panel."
|
||||
"Single-community mode is off. It can only be turned on for the first time \
|
||||
from the self-host setup wizard."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +284,57 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes
|
||||
}
|
||||
}
|
||||
|
||||
fn deferred_phone_gate_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
) -> Markup {
|
||||
let gate = &policy.deferred_phone_gate;
|
||||
let status = if gate.enabled {
|
||||
("Enabled", BadgeVariant::Success)
|
||||
} else {
|
||||
("Disabled", BadgeVariant::Default)
|
||||
};
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" }
|
||||
(badge(status.0, status.1))
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"When enabled, a phone requirement raised at registration is held back and only \
|
||||
applied if the account joins a discoverable community, or one above the member \
|
||||
threshold, within the window. Accounts that wait out the window are not challenged. \
|
||||
Inbound-SMS requirements are never deferred."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[
|
||||
("true", "Enabled"),
|
||||
("false", "Disabled"),
|
||||
], if gate.enabled { "true" } else { "false" }))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_window_hours",
|
||||
"Window (hours)",
|
||||
&gate.window_hours.to_string(),
|
||||
"6",
|
||||
))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_member_threshold",
|
||||
"Member threshold",
|
||||
&gate.member_threshold.to_string(),
|
||||
"50",
|
||||
))
|
||||
(form_actions(html! {
|
||||
(submit_button("Save deferred phone verification"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
|
||||
@@ -291,6 +291,11 @@ fn flags_card(
|
||||
can_update_suspicious,
|
||||
Some(acl::USER_UPDATE_SUSPICIOUS_ACTIVITY),
|
||||
))
|
||||
@if user.phone_verification_deferred {
|
||||
p class="text-sm text-amber-700 dark:text-amber-400" {
|
||||
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,6 +898,7 @@ fn user(id: &str, username: &str) -> Value {
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinition
|
||||
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
|
||||
const DEEP_PAGINATION_BATCH_SIZE = 1000;
|
||||
const MAX_SEARCH_LIMIT = 1000;
|
||||
const FACET_TERM_LIMIT = 200;
|
||||
|
||||
interface ElasticsearchSearchHit<TResult> {
|
||||
_source?: TResult;
|
||||
@@ -17,6 +18,10 @@ interface ElasticsearchSearchHit<TResult> {
|
||||
sort?: SortResults;
|
||||
}
|
||||
|
||||
interface ElasticsearchTermsAggregation {
|
||||
buckets?: Array<{key: string | number; doc_count: number}>;
|
||||
}
|
||||
|
||||
interface ElasticsearchSearchResponse<TResult> {
|
||||
hits: {
|
||||
total?:
|
||||
@@ -26,6 +31,7 @@ interface ElasticsearchSearchResponse<TResult> {
|
||||
};
|
||||
hits: Array<ElasticsearchSearchHit<TResult>>;
|
||||
};
|
||||
aggregations?: Record<string, ElasticsearchTermsAggregation>;
|
||||
}
|
||||
|
||||
type ElasticsearchBaseSearchRequest = Omit<SearchRequest, 'from' | 'search_after' | 'size' | 'track_total_hits'>;
|
||||
@@ -265,6 +271,7 @@ export class ElasticsearchIndexAdapter<
|
||||
},
|
||||
]
|
||||
: [{match_all: {}}];
|
||||
const facets = options?.facets;
|
||||
const searchParams: ElasticsearchBaseSearchRequest = {
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {
|
||||
@@ -273,6 +280,11 @@ export class ElasticsearchIndexAdapter<
|
||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||
},
|
||||
},
|
||||
...(facets && facets.length > 0
|
||||
? {
|
||||
aggs: Object.fromEntries(facets.map((facet) => [facet, {terms: {field: facet, size: FACET_TERM_LIMIT}}])),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const defaultSort: SortCombinations = {id: {order: 'desc'}};
|
||||
const effectiveSort: NonNullable<SearchRequest['sort']> =
|
||||
@@ -357,13 +369,30 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
|
||||
private toSearchResult(result: ElasticsearchSearchResponse<TResult>): SearchResult<TResult> {
|
||||
const facetCounts = this.toFacetCounts(result.aggregations);
|
||||
return {
|
||||
hits: this.mapHits(result.hits.hits),
|
||||
total: this.getTotalHits(result),
|
||||
cursor: result.hits.hits.at(-1)?.sort?.map((value) => String(value)),
|
||||
...(facetCounts ? {facetCounts} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private toFacetCounts(
|
||||
aggregations: Record<string, ElasticsearchTermsAggregation> | undefined,
|
||||
): Record<string, Record<string, number>> | undefined {
|
||||
if (!aggregations) {
|
||||
return undefined;
|
||||
}
|
||||
const counts: Record<string, Record<string, number>> = {};
|
||||
for (const [facet, aggregation] of Object.entries(aggregations)) {
|
||||
counts[facet] = Object.fromEntries(
|
||||
(aggregation.buckets ?? []).map((bucket) => [String(bucket.key), bucket.doc_count]),
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private mapHits(hits: Array<ElasticsearchSearchHit<TResult>>): Array<TResult> {
|
||||
return hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||
}
|
||||
|
||||
@@ -429,6 +429,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
testHarnessToken: master.dev.test_harness_token,
|
||||
},
|
||||
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
|
||||
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
|
||||
attachmentDecayEnabled: master.attachment_decay_enabled,
|
||||
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
|
||||
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
|
||||
|
||||
@@ -742,6 +742,9 @@ class BillingAdminControllerService {
|
||||
refundTarget,
|
||||
subscription,
|
||||
});
|
||||
if (refundDecision.amountCents !== null && !refundTarget) {
|
||||
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
|
||||
}
|
||||
const intentId = await this.billingRepository.actionIntents.create({
|
||||
userId: BigInt(syncedTargetUser.id),
|
||||
actorAdminId: BigInt(params.adminUserId),
|
||||
@@ -758,10 +761,7 @@ class BillingAdminControllerService {
|
||||
await this.billingRepository.actionIntents.markStage(intentId, 'sub_canceled', {
|
||||
sub_canceled_at: new Date(),
|
||||
});
|
||||
if (refundDecision.amountCents !== null) {
|
||||
if (!refundTarget) {
|
||||
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
|
||||
}
|
||||
if (refundDecision.amountCents !== null && refundTarget) {
|
||||
refund = await this.stripe.refunds.create(
|
||||
{
|
||||
...(refundTarget.paymentIntentId
|
||||
|
||||
@@ -91,7 +91,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
app_public: appPublic,
|
||||
policy: {
|
||||
single_community_enabled: policy.single_community_enabled,
|
||||
single_community_locked: policy.single_community_locked,
|
||||
single_community_guild_id: policy.single_community_guild_id,
|
||||
direct_messages_disabled: policy.direct_messages_disabled,
|
||||
direct_messages_locked: policy.direct_messages_locked,
|
||||
@@ -101,6 +100,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
youtube_enabled: policy.youtube_enabled,
|
||||
bluesky_enabled: policy.bluesky_enabled,
|
||||
},
|
||||
deferred_phone_gate: {
|
||||
enabled: policy.deferred_phone_gate_enabled,
|
||||
window_hours: policy.deferred_phone_gate_window_hours,
|
||||
member_threshold: policy.deferred_phone_gate_member_threshold,
|
||||
},
|
||||
services_resolved: resolvedServices,
|
||||
services_available: {
|
||||
gif: integrations.gif.effective_available,
|
||||
@@ -551,20 +555,19 @@ async function applyInstancePolicyUpdate(
|
||||
policy.single_community_enabled !== current.single_community_enabled
|
||||
) {
|
||||
if (policy.single_community_enabled) {
|
||||
if (appPublic.setup.configured || current.single_community_locked) {
|
||||
if (appPublic.setup.configured && current.single_community_guild_id == null) {
|
||||
throw new InstancePolicyTransitionNotAllowedError();
|
||||
}
|
||||
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
|
||||
if (!adminUser) {
|
||||
throw new InstancePolicyTransitionNotAllowedError();
|
||||
}
|
||||
await ctx.get('singleCommunityService').createStockCommunity({
|
||||
await ctx.get('singleCommunityService').ensureStockCommunity({
|
||||
owner: adminUser,
|
||||
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
|
||||
});
|
||||
} else {
|
||||
patch.single_community_enabled = false;
|
||||
patch.single_community_locked = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -593,6 +596,17 @@ async function applyInstancePolicyUpdate(
|
||||
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
|
||||
}
|
||||
}
|
||||
if (policy.deferred_phone_gate) {
|
||||
if (policy.deferred_phone_gate.enabled !== undefined) {
|
||||
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
|
||||
}
|
||||
if (policy.deferred_phone_gate.window_hours !== undefined) {
|
||||
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
|
||||
}
|
||||
if (policy.deferred_phone_gate.member_threshold !== undefined) {
|
||||
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.setInstancePolicyConfig(patch);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dns from 'node:dns';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN} from '@fluxer/constants/src/UserConstants';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -82,6 +83,7 @@ export async function mapUserToAdminResponse(
|
||||
premium_grace_ends_at: user.premiumGraceEndsAt?.toISOString() ?? null,
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
|
||||
suspicious_activity_flags: user.suspiciousActivityFlags,
|
||||
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
|
||||
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
|
||||
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
|
||||
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
|
||||
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -31,6 +39,7 @@ import * as AuthMfa from '../../auth/AuthMfa';
|
||||
import * as AuthSession from '../../auth/AuthSession';
|
||||
import * as AuthUtility from '../../auth/AuthUtility';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
@@ -406,11 +415,14 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{has_verified_phone: data.has_verified_phone},
|
||||
user.toRow(),
|
||||
);
|
||||
const phonePatch: Partial<UserRow> = {has_verified_phone: data.has_verified_phone};
|
||||
if (data.has_verified_phone) {
|
||||
const clearedFlags = (user.suspiciousActivityFlags ?? 0) & ~ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS;
|
||||
if (clearedFlags !== (user.suspiciousActivityFlags ?? 0)) {
|
||||
phonePatch.suspicious_activity_flags = clearedFlags;
|
||||
}
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, phonePatch, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
@@ -418,7 +430,15 @@ export class AdminUserSecurityService {
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_has_verified_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['has_verified_phone', String(data.has_verified_phone)]]),
|
||||
metadata: new Map(
|
||||
phonePatch.suspicious_activity_flags === undefined
|
||||
? [['has_verified_phone', String(data.has_verified_phone)]]
|
||||
: [
|
||||
['has_verified_phone', String(data.has_verified_phone)],
|
||||
['suspicious_activity_flags_before', String(user.suspiciousActivityFlags ?? 0)],
|
||||
['suspicious_activity_flags_after', String(phonePatch.suspicious_activity_flags)],
|
||||
],
|
||||
),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
@@ -438,15 +458,24 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const keepsDeferral =
|
||||
(currentFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0 &&
|
||||
(data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 &&
|
||||
(data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS);
|
||||
const newFlags = keepsDeferral ? data.flags | DEFERRED_PHONE_ON_COMMUNITY_JOIN : data.flags;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
suspicious_activity_flags: data.flags,
|
||||
suspicious_activity_flags: newFlags,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
if ((user.suspiciousActivityFlags ?? 0) !== data.flags && data.flags !== 0) {
|
||||
if (
|
||||
(currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) &&
|
||||
(newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0
|
||||
) {
|
||||
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags');
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
@@ -600,7 +629,7 @@ export class AdminUserSecurityService {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = (currentFlags | addMask) & ~removeMask;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AdminGuildMembershipService {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
@@ -83,6 +84,7 @@ export class AdminGuildMembershipService {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('Admin billing overview', () => {
|
||||
invoices: {
|
||||
[params.invoiceId]: {
|
||||
customer: params.stripeCustomerId,
|
||||
subscription: params.stripeSubscriptionId,
|
||||
subscriptionId: params.stripeSubscriptionId,
|
||||
amount_due: params.amountPaidCents,
|
||||
amount_paid: params.amountPaidCents,
|
||||
billing_reason: 'subscription_cycle',
|
||||
@@ -359,7 +359,7 @@ describe('Admin billing overview', () => {
|
||||
invoices: {
|
||||
in_local_checkout_1: {
|
||||
customer: stripeCustomerId,
|
||||
subscription: 'sub_billing_target',
|
||||
subscriptionId: 'sub_billing_target',
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_create',
|
||||
@@ -396,7 +396,7 @@ describe('Admin billing overview', () => {
|
||||
},
|
||||
in_renewal_1: {
|
||||
customer: stripeCustomerId,
|
||||
subscription: 'sub_billing_target',
|
||||
subscriptionId: 'sub_billing_target',
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_cycle',
|
||||
@@ -545,7 +545,7 @@ describe('Admin billing overview', () => {
|
||||
invoices: {
|
||||
[invoiceId]: {
|
||||
customer: stripeCustomerId,
|
||||
subscription: stripeSubscriptionId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_create',
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
normalizePolicyContactDomain,
|
||||
} from '../risk/AccountPolicyEvaluator';
|
||||
import type {IRegistrationEventsRepository} from '../risk/adapters/VelocityAdapter';
|
||||
import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate';
|
||||
import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository';
|
||||
import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {deriveLatestRiskContext} from '../risk/RiskHistoryContext';
|
||||
@@ -334,7 +335,7 @@ export async function register(
|
||||
action: riskResult.recommendedAction,
|
||||
},
|
||||
});
|
||||
const combinedFlags = policyDecision.flagBits;
|
||||
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
|
||||
const createdAt = new Date();
|
||||
const riskContext = deriveLatestRiskContext({
|
||||
userId: userId.toString(),
|
||||
|
||||
@@ -31,6 +31,16 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
|
||||
);
|
||||
}
|
||||
|
||||
export function hasNoVerifiableCredential(
|
||||
user: {passwordHash: string | null; isBot: boolean},
|
||||
hasMfa: boolean,
|
||||
): boolean {
|
||||
if (user.isBot || hasMfa) {
|
||||
return false;
|
||||
}
|
||||
return user.passwordHash === null;
|
||||
}
|
||||
|
||||
export function deriveSudoMethods(user: {
|
||||
totpSecret?: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
@@ -86,8 +96,7 @@ async function verifySudoMode(
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
const isUnclaimedAccount = user.isUnclaimedAccount();
|
||||
if (isUnclaimedAccount && !hasMfa) {
|
||||
if (hasNoVerifiableCredential(user, hasMfa)) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
if (body.password && !hasMfa) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '../../middleware/ServiceMiddleware';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {
|
||||
RecommendedAction,
|
||||
RiskConfidence,
|
||||
RiskDecisionMethod,
|
||||
RiskLevel,
|
||||
type RiskLevel as RiskLevelType,
|
||||
} from '../../risk/RiskTypes';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import type {IRegistrationRiskEvaluator} from '../services/IRegistrationRiskEvaluator';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from './AuthTestUtils';
|
||||
|
||||
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
|
||||
return {
|
||||
async evaluate() {
|
||||
return {
|
||||
level,
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore,
|
||||
reasoning: 'deferred phone gate test',
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createGuildWithInvite(harness: ApiTestHarness): Promise<{guildId: string; inviteCode: string}> {
|
||||
let owner = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${owner.userId}/acls`)
|
||||
.body({acls: ['*']})
|
||||
.expect(200)
|
||||
.execute();
|
||||
owner = await loginAccount(harness, owner);
|
||||
const guild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({name: `PhoneGate-${Date.now()}`})
|
||||
.execute();
|
||||
const invite = await createBuilder<{code: string}>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}`.concat('/invites'))
|
||||
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
|
||||
.execute();
|
||||
return {guildId: guild.id, inviteCode: invite.code};
|
||||
}
|
||||
|
||||
async function readFlags(userId: string): Promise<number> {
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const {createUserID} = await import('../../BrandedTypes');
|
||||
const user = await new UserRepository().findUnique(createUserID(BigInt(userId)));
|
||||
return user?.suspiciousActivityFlags ?? 0;
|
||||
}
|
||||
|
||||
describe('Deferred phone verification gate', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('applies the phone requirement immediately while the gate is off', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: false});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-off'),
|
||||
username: createUniqueUsername('gate_off'),
|
||||
global_name: 'Gate Off',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
});
|
||||
|
||||
it('defers the phone requirement at registration while the gate is on', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-on'),
|
||||
username: createUniqueUsername('gate_on'),
|
||||
global_name: 'Gate On',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
const me = await createBuilder<{required_actions: Array<string>}>(harness, registration.token)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a deferred account join a small guild without being challenged', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
const {guildId, inviteCode} = await createGuildWithInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-small'),
|
||||
username: createUniqueUsername('gate_small'),
|
||||
global_name: 'Gate Small',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(guildId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not defer the inbound-SMS tier, which stays enforced from registration', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator({
|
||||
async evaluate() {
|
||||
return {
|
||||
level: RiskLevel.VeryHigh,
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level: RiskLevel.VeryHigh,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore: 90,
|
||||
reasoning: 'inbound tier',
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-inbound'),
|
||||
username: createUniqueUsername('gate_inbound'),
|
||||
global_name: 'Gate Inbound',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION).not.toBe(0);
|
||||
});
|
||||
|
||||
it('promotes the requirement and refuses the join on a qualifying guild inside the window', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
});
|
||||
const {inviteCode} = await createGuildWithInvite(harness);
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-qualifying'),
|
||||
username: createUniqueUsername('gate_qualifying'),
|
||||
global_name: 'Gate Qualifying',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(403).execute();
|
||||
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {EMPTY_USER_ROW, type UserRow} from '../../database/types/UserTypes';
|
||||
import {User} from '../../models/User';
|
||||
import {hasNoVerifiableCredential, userHasMfa} from '../services/SudoVerificationService';
|
||||
|
||||
function createUser(overrides: Partial<UserRow> = {}): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(1n),
|
||||
username: 'test_user',
|
||||
discriminator: 1,
|
||||
bot: false,
|
||||
password_hash: 'hash',
|
||||
traits: new Set<string>(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('sudo verification credential capability', () => {
|
||||
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
|
||||
expect(user.isUnclaimedAccount()).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets an unclaimed account satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
});
|
||||
|
||||
it('still requires a password from accounts that have one', () => {
|
||||
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from an SSO account that enrolled a second factor', () => {
|
||||
const user = createUser({
|
||||
password_hash: null,
|
||||
traits: new Set<string>(['sso']),
|
||||
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
|
||||
});
|
||||
expect(userHasMfa(user)).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
});
|
||||
|
||||
it('never applies to bots', () => {
|
||||
const user = createUser({password_hash: null, bot: true});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe('mapStripeInvoiceToRow', () => {
|
||||
const inv = stripeFixture<Stripe.Invoice>({
|
||||
id: 'in_1',
|
||||
customer: 'cus_1',
|
||||
subscription: 'sub_1',
|
||||
parent: {type: 'subscription_details', subscription_details: {subscription: 'sub_1'}},
|
||||
status: 'paid',
|
||||
number: 'INV-001',
|
||||
currency: 'usd',
|
||||
|
||||
@@ -94,28 +94,6 @@ export interface StripeSubscriptionPayload {
|
||||
trial_start?: number | null;
|
||||
}
|
||||
|
||||
interface StripeInvoiceCompatibility extends Stripe.Invoice {
|
||||
subscription?: StripeExpandableId;
|
||||
tax?: number | null;
|
||||
application_fee_amount?: number | null;
|
||||
}
|
||||
|
||||
interface StripePaymentIntentCompatibility extends Stripe.PaymentIntent {
|
||||
invoice?: StripeExpandableId;
|
||||
}
|
||||
|
||||
interface StripeChargeCompatibility extends Stripe.Charge {
|
||||
invoice?: StripeExpandableId;
|
||||
}
|
||||
|
||||
type StripeRefundCompatibility = Stripe.Refund & {
|
||||
livemode?: boolean | null;
|
||||
};
|
||||
|
||||
interface StripeCheckoutSessionCompatibility extends Stripe.Checkout.Session {
|
||||
completed_at?: number | null;
|
||||
}
|
||||
|
||||
function createBillingSubscriptionItemValue(
|
||||
itemId: string,
|
||||
priceId: string,
|
||||
@@ -280,6 +258,14 @@ export function computeStripeUpdatedAt(
|
||||
return new Date(max * 1000);
|
||||
}
|
||||
|
||||
function sumInvoiceTotalTaxes(inv: Stripe.Invoice): bigint | null {
|
||||
const taxes = inv.total_taxes ?? null;
|
||||
if (taxes === null) {
|
||||
return null;
|
||||
}
|
||||
return BigInt(taxes.reduce((total, tax) => total + (tax.amount ?? 0), 0));
|
||||
}
|
||||
|
||||
function idOf(
|
||||
ref:
|
||||
| string
|
||||
@@ -689,9 +675,7 @@ export function mapStripeInvoiceToRow(
|
||||
throw new BillingMappingError('Invoice is missing customer', inv.id);
|
||||
}
|
||||
const now = new Date();
|
||||
const invoice = inv as StripeInvoiceCompatibility;
|
||||
const subscriptionId =
|
||||
idOf(invoice.subscription ?? null) ?? idOf(invoice.parent?.subscription_details?.subscription ?? null);
|
||||
const subscriptionId = idOf(inv.parent?.subscription_details?.subscription ?? null);
|
||||
const userIdFromMetadata = parseUserIdFromMetadata(inv.metadata);
|
||||
const userId = hints?.knownUserId ?? userIdFromMetadata;
|
||||
const transitions = inv.status_transitions ?? null;
|
||||
@@ -718,12 +702,11 @@ export function mapStripeInvoiceToRow(
|
||||
amount_paid: typeof inv.amount_paid === 'number' ? BigInt(inv.amount_paid) : null,
|
||||
amount_remaining: typeof inv.amount_remaining === 'number' ? BigInt(inv.amount_remaining) : null,
|
||||
subtotal: typeof inv.subtotal === 'number' ? BigInt(inv.subtotal) : null,
|
||||
tax: typeof invoice.tax === 'number' ? BigInt(invoice.tax) : null,
|
||||
tax: sumInvoiceTotalTaxes(inv),
|
||||
total: typeof inv.total === 'number' ? BigInt(inv.total) : null,
|
||||
starting_balance: typeof inv.starting_balance === 'number' ? BigInt(inv.starting_balance) : null,
|
||||
ending_balance: typeof inv.ending_balance === 'number' ? BigInt(inv.ending_balance) : null,
|
||||
application_fee_amount:
|
||||
typeof invoice.application_fee_amount === 'number' ? BigInt(invoice.application_fee_amount) : null,
|
||||
application_fee_amount: null,
|
||||
attempt_count: inv.attempt_count ?? null,
|
||||
attempted: inv.attempted ?? null,
|
||||
auto_advance: inv.auto_advance ?? null,
|
||||
@@ -818,7 +801,6 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
|
||||
throw new BillingMappingError('PaymentIntent is missing id');
|
||||
}
|
||||
const now = new Date();
|
||||
const paymentIntent = pi as StripePaymentIntentCompatibility;
|
||||
const customerId = idOf(
|
||||
pi.customer as
|
||||
| string
|
||||
@@ -828,7 +810,7 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
|
||||
| null
|
||||
| undefined,
|
||||
);
|
||||
const invoiceId = idOf(paymentIntent.invoice ?? null);
|
||||
const invoiceId = null;
|
||||
const paymentMethodId = idOf(
|
||||
pi.payment_method as
|
||||
| string
|
||||
@@ -892,7 +874,6 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
|
||||
throw new BillingMappingError('Charge is missing id');
|
||||
}
|
||||
const now = new Date();
|
||||
const charge = c as StripeChargeCompatibility;
|
||||
const customerId = idOf(
|
||||
c.customer as
|
||||
| string
|
||||
@@ -911,7 +892,7 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
|
||||
| null
|
||||
| undefined,
|
||||
);
|
||||
const invoiceId = idOf(charge.invoice ?? null);
|
||||
const invoiceId = null;
|
||||
const paymentMethodId = c.payment_method ?? null;
|
||||
const card = c.payment_method_details?.card ?? null;
|
||||
const billingDetails = c.billing_details ?? null;
|
||||
@@ -974,6 +955,7 @@ export function mapStripeRefundToRow(
|
||||
invoiceId?: string;
|
||||
customerId?: string;
|
||||
userId?: bigint;
|
||||
livemode?: boolean;
|
||||
},
|
||||
): {
|
||||
primary: BillingRefundRow;
|
||||
@@ -990,7 +972,6 @@ export function mapStripeRefundToRow(
|
||||
const invoiceId = hints?.invoiceId ?? null;
|
||||
const customerId = hints?.customerId ?? null;
|
||||
const userId = hints?.userId ?? null;
|
||||
const refund = r as StripeRefundCompatibility;
|
||||
const primary: BillingRefundRow = {
|
||||
provider_id: r.id,
|
||||
charge_id: chargeId,
|
||||
@@ -1005,7 +986,7 @@ export function mapStripeRefundToRow(
|
||||
receipt_number: r.receipt_number ?? null,
|
||||
failure_reason: r.failure_reason ?? null,
|
||||
description: r.description ?? null,
|
||||
livemode: refund.livemode ?? null,
|
||||
livemode: hints?.livemode ?? null,
|
||||
metadata: safeMetadata(r.metadata),
|
||||
stripe_created_at: unixToDate(r.created),
|
||||
stripe_updated_at: computeStripeUpdatedAt({created: r.created}),
|
||||
@@ -1049,16 +1030,17 @@ export function mapStripeCheckoutSessionToRow(
|
||||
cs: Stripe.Checkout.Session,
|
||||
hints?: {
|
||||
knownUserId?: bigint;
|
||||
eventCreated?: number;
|
||||
},
|
||||
): {
|
||||
primary: BillingCheckoutSessionRow;
|
||||
byCustomer: BillingCheckoutSessionByCustomerRow | null;
|
||||
} {
|
||||
const completedAt = cs.status === 'complete' ? (hints?.eventCreated ?? null) : null;
|
||||
if (!cs.id) {
|
||||
throw new BillingMappingError('Checkout session is missing id');
|
||||
}
|
||||
const now = new Date();
|
||||
const checkoutSession = cs as StripeCheckoutSessionCompatibility;
|
||||
const customerId = idOf(
|
||||
cs.customer as
|
||||
| string
|
||||
@@ -1124,14 +1106,14 @@ export function mapStripeCheckoutSessionToRow(
|
||||
amount_total: typeof cs.amount_total === 'number' ? BigInt(cs.amount_total) : null,
|
||||
currency: cs.currency ?? null,
|
||||
expires_at: unixToDate(cs.expires_at),
|
||||
completed_at: unixToDate(checkoutSession.completed_at ?? null),
|
||||
completed_at: completedAt === null ? null : unixToDate(completedAt),
|
||||
livemode: cs.livemode ?? null,
|
||||
client_reference_id: cs.client_reference_id ?? null,
|
||||
metadata: safeMetadata(cs.metadata),
|
||||
stripe_created_at: unixToDate(cs.created),
|
||||
stripe_updated_at: computeStripeUpdatedAt({
|
||||
created: cs.created,
|
||||
completed_at: checkoutSession.completed_at ?? null,
|
||||
completed_at: completedAt,
|
||||
}),
|
||||
mirrored_at: now,
|
||||
} as BillingCheckoutSessionRow;
|
||||
|
||||
@@ -49,6 +49,7 @@ export class BillingCheckoutSessionRepository {
|
||||
cs: Stripe.Checkout.Session,
|
||||
hints?: {
|
||||
knownUserId?: bigint;
|
||||
eventCreated?: number;
|
||||
},
|
||||
): Promise<{
|
||||
changed: boolean;
|
||||
|
||||
@@ -71,6 +71,7 @@ export class BillingRefundRepository {
|
||||
invoiceId?: string;
|
||||
customerId?: string;
|
||||
userId?: bigint;
|
||||
livemode?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
changed: boolean;
|
||||
|
||||
@@ -327,6 +327,7 @@ export interface APIConfig {
|
||||
testHarnessToken?: string;
|
||||
};
|
||||
presignedAttachmentUploadsEnabled: boolean;
|
||||
presignedDownloadsEnabled: boolean;
|
||||
attachmentDecayEnabled: boolean;
|
||||
deletionGracePeriodHours: number;
|
||||
inactivityDeletionThresholdDays?: number;
|
||||
|
||||
@@ -73,6 +73,8 @@ async function headArtifactResponse(
|
||||
return new Response(null, {status: 200, headers});
|
||||
}
|
||||
|
||||
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
|
||||
|
||||
async function streamArtifactResponse(
|
||||
ctx: Context<HonoEnv>,
|
||||
downloadService: DownloadService,
|
||||
@@ -83,6 +85,24 @@ async function streamArtifactResponse(
|
||||
if (ctx.req.method === 'HEAD') {
|
||||
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
|
||||
}
|
||||
if (downloadService.isPresignedDownloadEnabled()) {
|
||||
const location = await downloadService.getPresignedDownloadRedirect({
|
||||
key,
|
||||
filename: artifactFilename(key, filenameOverride),
|
||||
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
|
||||
});
|
||||
if (!location) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': 'no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
}
|
||||
const range = ctx.req.header('range') ?? undefined;
|
||||
let result: DownloadStreamResult | null;
|
||||
try {
|
||||
|
||||
@@ -533,6 +533,28 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
isPresignedDownloadEnabled(): boolean {
|
||||
return Config.presignedDownloadsEnabled;
|
||||
}
|
||||
|
||||
async getPresignedDownloadRedirect(params: {
|
||||
key: string;
|
||||
filename: string;
|
||||
expiresIn: number;
|
||||
}): Promise<string | null> {
|
||||
const metadata = await this.getDownloadMetadata({key: params.key});
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return this.storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: params.key,
|
||||
expiresIn: params.expiresIn,
|
||||
responseContentType: metadata.contentType ?? 'application/octet-stream',
|
||||
responseContentDisposition: `attachment; filename="${encodeURIComponent(params.filename)}"`,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownloadMetadata(params: {key: string}): Promise<{
|
||||
contentLength: number;
|
||||
contentType?: string | null;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const OBJECT_METADATA = {
|
||||
contentLength: 285_567_850,
|
||||
contentType: 'application/x-apple-diskimage',
|
||||
etag: '"abc123"',
|
||||
lastModified: new Date('2026-08-17T00:00:00Z'),
|
||||
};
|
||||
|
||||
interface PresignCall {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}
|
||||
|
||||
function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) {
|
||||
const presignCalls: Array<PresignCall> = [];
|
||||
const storageService = {
|
||||
getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata),
|
||||
getPresignedDownloadURL: async (params: PresignCall) => {
|
||||
presignCalls.push(params);
|
||||
return `https://storage.example.test/${params.key}?signed=1`;
|
||||
},
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), presignCalls};
|
||||
}
|
||||
|
||||
describe('presigned download redirects', () => {
|
||||
it('signs the requested object and returns its URL', async () => {
|
||||
const {service, presignCalls} = createService();
|
||||
const url = await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
|
||||
filename: 'Fluxer.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1');
|
||||
expect(presignCalls).toHaveLength(1);
|
||||
expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg');
|
||||
expect(presignCalls[0]?.expiresIn).toBe(900);
|
||||
});
|
||||
|
||||
it('preserves the download filename and content type through the redirect', async () => {
|
||||
const {service, presignCalls} = createService();
|
||||
await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
|
||||
filename: 'Fluxer Canary.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage');
|
||||
expect(presignCalls[0]?.responseContentDisposition).toBe(
|
||||
`attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a binary content type when storage reports none', async () => {
|
||||
const {service, presignCalls} = createService({
|
||||
metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA,
|
||||
});
|
||||
await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900});
|
||||
expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('returns null for a missing object so the caller can answer 404 without signing', async () => {
|
||||
const {service, presignCalls} = createService({metadata: null});
|
||||
const url = await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/missing.dmg',
|
||||
filename: 'missing.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(url).toBeNull();
|
||||
expect(presignCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
@@ -49,6 +50,9 @@ export function GuildBaseController(app: HonoApp) {
|
||||
if (policy.single_community_enabled) {
|
||||
throw new SingleCommunityCannotCreateGuildsError();
|
||||
}
|
||||
if (!user.isUnclaimedAccount()) {
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
}
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const locale = ctx.get('requestLocale') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
|
||||
|
||||
@@ -104,6 +104,7 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
app.post(
|
||||
'/discovery/guilds/:guild_id/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
|
||||
@@ -98,13 +98,20 @@ export abstract class IGuildDiscoveryService {
|
||||
}): Promise<{
|
||||
guilds: Array<DiscoveryGuildResult>;
|
||||
total: number;
|
||||
category_counts: Array<DiscoveryCategoryCount>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DiscoveryCategoryCount {
|
||||
category_type: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface DiscoveryGuildResult {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
category_type: number;
|
||||
primary_language: string | null;
|
||||
@@ -115,6 +122,25 @@ interface DiscoveryGuildResult {
|
||||
verification_level: number;
|
||||
}
|
||||
|
||||
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
|
||||
|
||||
function toDiscoveryCategoryCounts(
|
||||
counts: Readonly<Record<string, number>> | undefined,
|
||||
): Array<DiscoveryCategoryCount> {
|
||||
if (!counts) {
|
||||
return [];
|
||||
}
|
||||
const entries: Array<DiscoveryCategoryCount> = [];
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
const categoryType = Number.parseInt(key, 10);
|
||||
if (!Number.isInteger(categoryType) || count <= 0) {
|
||||
continue;
|
||||
}
|
||||
entries.push({category_type: categoryType, count});
|
||||
}
|
||||
return entries.sort((left, right) => left.category_type - right.category_type);
|
||||
}
|
||||
|
||||
export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
constructor(
|
||||
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
||||
@@ -377,6 +403,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
}): Promise<{
|
||||
guilds: Array<DiscoveryGuildResult>;
|
||||
total: number;
|
||||
category_counts: Array<DiscoveryCategoryCount>;
|
||||
}> {
|
||||
if (!this.guildSearchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
@@ -393,14 +420,23 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
sortBy,
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
const results = await this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
});
|
||||
const categoryFacetFilters: GuildSearchFilters = {...filters, discoveryCategory: undefined};
|
||||
const [results, categoryFacets] = await Promise.all([
|
||||
this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
}),
|
||||
this.guildSearchService.searchGuilds(params.query ?? '', categoryFacetFilters, {
|
||||
limit: 0,
|
||||
facets: [DISCOVERY_CATEGORY_FACET],
|
||||
}),
|
||||
]);
|
||||
const categoryCounts = toDiscoveryCategoryCounts(categoryFacets.facetCounts?.[DISCOVERY_CATEGORY_FACET]);
|
||||
const guilds: Array<DiscoveryGuildResult> = results.hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
name: hit.name,
|
||||
icon: hit.iconHash,
|
||||
banner: hit.bannerHash,
|
||||
description: hit.discoveryDescription,
|
||||
category_type: hit.discoveryCategory ?? 0,
|
||||
primary_language: hit.discoveryPrimaryLanguage ?? null,
|
||||
@@ -429,7 +465,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
);
|
||||
}
|
||||
}
|
||||
return {guilds, total};
|
||||
return {guilds, total, category_counts: categoryCounts};
|
||||
}
|
||||
|
||||
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
|
||||
|
||||
@@ -319,6 +319,7 @@ export class GuildMemberService {
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
skipRiskGate?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: JoinSourceType;
|
||||
sourceInviteCode?: InviteCode;
|
||||
|
||||
@@ -36,7 +36,6 @@ import type {
|
||||
TemplateSerializedGuild,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
|
||||
import type {ChannelID, GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '../../../BrandedTypes';
|
||||
import type {IChannelRepository} from '../../../channel/IChannelRepository';
|
||||
@@ -257,7 +256,6 @@ export class GuildOperationsService {
|
||||
if (user.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const maxGuilds = resolveLimitSafe(
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {type JoinSourceType, JoinSourceTypes, SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
GuildFeatures,
|
||||
type JoinSourceType,
|
||||
JoinSourceTypes,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
DEFAULT_GUILD_FOLDER_ICON,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
type MentionReplyPreference,
|
||||
PHONE_REQUIREMENT_FLAGS,
|
||||
UserNotificationSettings,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -17,10 +24,12 @@ import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError';
|
||||
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
|
||||
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
|
||||
import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import {ms} from 'itty-time';
|
||||
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
|
||||
@@ -38,13 +47,24 @@ import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
|
||||
import {profileSubstringBlocklistCache} from '../../../middleware/ProfileSubstringBlocklistCache';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '../../../models/Guild';
|
||||
import type {GuildMember} from '../../../models/GuildMember';
|
||||
import type {User} from '../../../models/User';
|
||||
import type {UserGuildSettings} from '../../../models/UserGuildSettings';
|
||||
import type {UserSettings} from '../../../models/UserSettings';
|
||||
import {
|
||||
DEFAULT_PHONE_GATE_MEMBER_THRESHOLD,
|
||||
evaluateDeferredPhoneGate,
|
||||
getDeferredPhoneGateConfig,
|
||||
guildTriggersPhoneGate,
|
||||
} from '../../../risk/DeferredPhoneGate';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {isProfileSubstringExempt} from '../../../user/UserHelpers';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '../../../user/UserMappers';
|
||||
import {getEffectiveSuspiciousFlags, isProfileSubstringExempt} from '../../../user/UserHelpers';
|
||||
import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
} from '../../../user/UserMappers';
|
||||
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils';
|
||||
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
||||
@@ -374,6 +394,68 @@ export class GuildMemberOperationsService {
|
||||
await this.gatewayService.leaveGuild({userId: targetId, guildId});
|
||||
}
|
||||
|
||||
private async applyDeferredPhoneGate(user: User, guild: Guild): Promise<void> {
|
||||
if (user.hasVerifiedPhone) {
|
||||
return;
|
||||
}
|
||||
const rawFlags = user.suspiciousActivityFlags ?? 0;
|
||||
if ((rawFlags & (DEFERRED_PHONE_ON_COMMUNITY_JOIN | PHONE_REQUIREMENT_FLAGS)) === 0) {
|
||||
return;
|
||||
}
|
||||
const {status, config} = await getDeferredPhoneGateConfig();
|
||||
const logContext = {
|
||||
userId: user.id.toString(),
|
||||
guildId: guild.id.toString(),
|
||||
discoverable: guild.features.has(GuildFeatures.DISCOVERABLE),
|
||||
memberCount: guild.memberCount,
|
||||
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
|
||||
};
|
||||
if (status !== 'ok') {
|
||||
const undeferredFlags = getEffectiveSuspiciousFlags({
|
||||
...user,
|
||||
suspiciousActivityFlags: rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
} as User);
|
||||
if (
|
||||
(undeferredFlags & PHONE_REQUIREMENT_FLAGS) === 0 ||
|
||||
!guildTriggersPhoneGate(guild, DEFAULT_PHONE_GATE_MEMBER_THRESHOLD)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Logger.info(logContext, `deferred_phone_gate.enforced_while_${status}`);
|
||||
throw new AccountSuspiciousActivityError(undeferredFlags);
|
||||
}
|
||||
const liveFlags = getEffectiveSuspiciousFlags(user);
|
||||
if ((liveFlags & PHONE_REQUIREMENT_FLAGS) !== 0) {
|
||||
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
|
||||
return;
|
||||
}
|
||||
Logger.info(logContext, 'deferred_phone_gate.blocked_unsatisfied_phone_requirement');
|
||||
throw new AccountSuspiciousActivityError(liveFlags);
|
||||
}
|
||||
const outcome = evaluateDeferredPhoneGate(user, guild, config, Date.now());
|
||||
if (!outcome.applies) {
|
||||
Logger.info(logContext, `deferred_phone_gate.skipped_${outcome.reason}`);
|
||||
return;
|
||||
}
|
||||
const promotedFlags = getEffectiveSuspiciousFlags({...user, suspiciousActivityFlags: outcome.flags} as User);
|
||||
if (promotedFlags === 0) {
|
||||
Logger.info(logContext, 'deferred_phone_gate.skipped_unenforceable');
|
||||
return;
|
||||
}
|
||||
const updatedUser = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
{suspicious_activity_flags: outcome.flags},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
Logger.info(logContext, 'deferred_phone_gate.applied');
|
||||
throw new AccountSuspiciousActivityError(promotedFlags);
|
||||
}
|
||||
|
||||
async addUserToGuild(
|
||||
params: {
|
||||
userId: UserID;
|
||||
@@ -381,6 +463,7 @@ export class GuildMemberOperationsService {
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
skipRiskGate?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: JoinSourceType;
|
||||
sourceInviteCode?: InviteCode;
|
||||
@@ -398,6 +481,7 @@ export class GuildMemberOperationsService {
|
||||
sendJoinMessage = true,
|
||||
skipGuildLimitCheck = false,
|
||||
skipBanCheck = false,
|
||||
skipRiskGate = false,
|
||||
isTemporary = false,
|
||||
joinSourceType = JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode = null,
|
||||
@@ -418,6 +502,9 @@ export class GuildMemberOperationsService {
|
||||
if (!skipGuildLimitCheck) {
|
||||
await this.enforceGuildLimit(user, userGuildsCount);
|
||||
}
|
||||
if (!skipRiskGate && !user.isBot) {
|
||||
await this.applyDeferredPhoneGate(user, guild);
|
||||
}
|
||||
const maxGuildMembers = resolveMaxGuildMembersLimit({
|
||||
guildFeatures: guild.features,
|
||||
snapshot: this.limitConfigService.getConfigSnapshot(),
|
||||
|
||||
@@ -120,6 +120,69 @@ describe('Discovery Search and Join', () => {
|
||||
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
|
||||
expect(found!.verification_level).toBe(GuildVerificationLevel.LOW);
|
||||
});
|
||||
test('should report category counts that ignore the selected category filter', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const gamingOwner = await createTestAccount(harness);
|
||||
const gamingGuild = await createGuild(harness, gamingOwner.token, 'Counted Gaming Guild');
|
||||
await setGuildMemberCount(harness, gamingGuild.id, 10);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
gamingOwner.token,
|
||||
admin.token,
|
||||
gamingGuild.id,
|
||||
'A gaming community for counting',
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
const musicOwner = await createTestAccount(harness);
|
||||
const musicGuild = await createGuild(harness, musicOwner.token, 'Counted Music Guild');
|
||||
await setGuildMemberCount(harness, musicGuild.id, 10);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
musicOwner.token,
|
||||
admin.token,
|
||||
musicGuild.id,
|
||||
'A music community for counting',
|
||||
DiscoveryCategories.MUSIC,
|
||||
);
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const filtered = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get(`/discovery/guilds?category=${DiscoveryCategories.GAMING}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(filtered.guilds.every((guild) => guild.category_type === DiscoveryCategories.GAMING)).toBe(true);
|
||||
const gamingCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.GAMING);
|
||||
const musicCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.MUSIC);
|
||||
expect(gamingCount?.count).toBeGreaterThanOrEqual(1);
|
||||
expect(musicCount?.count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('should expose the guild banner hash in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bannered Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
admin.token,
|
||||
guild.id,
|
||||
'A community with a banner',
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const found = results.guilds.find((entry) => entry.id === guild.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found).toHaveProperty('banner');
|
||||
});
|
||||
|
||||
test('should not return pending guilds in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Pending Guild');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {User} from '../../models/User';
|
||||
import {GuildOperationsService} from '../services/data/GuildOperationsService';
|
||||
|
||||
const REACHED_GUILD_COUNT = new Error('reached guild count lookup');
|
||||
|
||||
function createServiceStoppingAtGuildCount(): GuildOperationsService {
|
||||
const guildRepository = {
|
||||
countUserGuilds: (): never => {
|
||||
throw REACHED_GUILD_COUNT;
|
||||
},
|
||||
};
|
||||
const unused = null as never;
|
||||
return new GuildOperationsService(
|
||||
guildRepository as never,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
);
|
||||
}
|
||||
|
||||
function createUser(overrides: {emailVerified: boolean; isBot?: boolean}): User {
|
||||
return {
|
||||
id: 1n,
|
||||
isBot: overrides.isBot ?? false,
|
||||
emailVerified: overrides.emailVerified,
|
||||
isUnclaimedAccount: () => false,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
describe('guild creation email verification', () => {
|
||||
it('does not gate guild creation on email verification inside the domain operation', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({user: createUser({emailVerified: false}), data: {name: 'Stock Community'} as never}),
|
||||
).rejects.toBe(REACHED_GUILD_COUNT);
|
||||
});
|
||||
|
||||
it('reaches the same point for a verified user', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({user: createUser({emailVerified: true}), data: {name: 'Stock Community'} as never}),
|
||||
).rejects.toBe(REACHED_GUILD_COUNT);
|
||||
});
|
||||
|
||||
it('still rejects bots before any guild count lookup', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({
|
||||
user: createUser({emailVerified: true, isBot: true}),
|
||||
data: {name: 'Stock Community'} as never,
|
||||
}),
|
||||
).rejects.toMatchObject({code: APIErrorCodes.BOTS_CANNOT_CREATE_GUILDS});
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,13 @@ export interface IStorageService {
|
||||
destinationKey: string;
|
||||
newContentType?: string;
|
||||
}): Promise<void>;
|
||||
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>;
|
||||
getPresignedDownloadURL(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}): Promise<string>;
|
||||
getPresignedUploadURL(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
|
||||
@@ -241,14 +241,20 @@ export class StorageService implements IStorageService {
|
||||
bucket,
|
||||
key,
|
||||
expiresIn = seconds('5 minutes'),
|
||||
responseContentType,
|
||||
responseContentDisposition,
|
||||
}: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ResponseContentType: responseContentType,
|
||||
ResponseContentDisposition: responseContentDisposition,
|
||||
});
|
||||
return getSignedUrl(this.presignClient, command, {expiresIn});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {sanitizeLimitConfigForInstance} from '../constants/LimitConfig';
|
||||
import {fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import type {InstanceConfigurationRow} from '../database/types/InstanceConfigTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
import {InstanceConfiguration} from '../Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
|
||||
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
@@ -77,7 +78,6 @@ export type InstancePremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
export interface InstancePolicyConfig {
|
||||
single_community_enabled: boolean;
|
||||
single_community_locked: boolean;
|
||||
single_community_guild_id: string | null;
|
||||
direct_messages_disabled: boolean;
|
||||
direct_messages_locked: boolean;
|
||||
@@ -85,6 +85,9 @@ export interface InstancePolicyConfig {
|
||||
gif_enabled: boolean | null;
|
||||
youtube_enabled: boolean | null;
|
||||
bluesky_enabled: boolean | null;
|
||||
deferred_phone_gate_enabled: boolean;
|
||||
deferred_phone_gate_window_hours: number;
|
||||
deferred_phone_gate_member_threshold: number;
|
||||
}
|
||||
|
||||
interface InstanceCommunityPublicConfig {
|
||||
@@ -396,7 +399,6 @@ function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
|
||||
|
||||
const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
|
||||
single_community_enabled: false,
|
||||
single_community_locked: false,
|
||||
single_community_guild_id: null,
|
||||
direct_messages_disabled: false,
|
||||
direct_messages_locked: false,
|
||||
@@ -404,6 +406,9 @@ const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
|
||||
gif_enabled: null,
|
||||
youtube_enabled: null,
|
||||
bluesky_enabled: null,
|
||||
deferred_phone_gate_enabled: false,
|
||||
deferred_phone_gate_window_hours: 6,
|
||||
deferred_phone_gate_member_threshold: 50,
|
||||
};
|
||||
|
||||
function isPremiumMode(value: unknown): value is InstancePremiumMode {
|
||||
@@ -414,13 +419,19 @@ function normalizeNullableBoolean(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
|
||||
if (!isJsonRecord(value)) {
|
||||
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
}
|
||||
return {
|
||||
single_community_enabled: value.single_community_enabled === true,
|
||||
single_community_locked: value.single_community_locked === true,
|
||||
single_community_guild_id: normalizeNullableString(value.single_community_guild_id),
|
||||
direct_messages_disabled: value.direct_messages_disabled === true,
|
||||
direct_messages_locked: value.direct_messages_locked === true,
|
||||
@@ -428,6 +439,15 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
|
||||
gif_enabled: normalizeNullableBoolean(value.gif_enabled),
|
||||
youtube_enabled: normalizeNullableBoolean(value.youtube_enabled),
|
||||
bluesky_enabled: normalizeNullableBoolean(value.bluesky_enabled),
|
||||
deferred_phone_gate_enabled: value.deferred_phone_gate_enabled === true,
|
||||
deferred_phone_gate_window_hours: normalizePositiveNumber(
|
||||
value.deferred_phone_gate_window_hours,
|
||||
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_window_hours,
|
||||
),
|
||||
deferred_phone_gate_member_threshold: normalizePositiveNumber(
|
||||
value.deferred_phone_gate_member_threshold,
|
||||
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_member_threshold,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -916,12 +936,18 @@ export class InstanceConfigRepository {
|
||||
this.refreshRequested = false;
|
||||
this.configCache = await this.fetchAllConfigsFromDatabase();
|
||||
} while (this.refreshRequested);
|
||||
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
await this.refreshPromise;
|
||||
}
|
||||
|
||||
private syncDeferredPhoneGateCache(raw: string | null): void {
|
||||
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
}
|
||||
|
||||
private updateCachedConfigs(entries: Array<[string, string]>): void {
|
||||
if (!this.configCache) {
|
||||
return;
|
||||
@@ -1082,16 +1108,16 @@ export class InstanceConfigRepository {
|
||||
|
||||
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
|
||||
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
}
|
||||
return normalizeInstancePolicyConfig(parseJsonRecord(raw));
|
||||
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
return policy;
|
||||
}
|
||||
|
||||
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
|
||||
const current = await this.getInstancePolicyConfig();
|
||||
const next = normalizeInstancePolicyConfig({...current, ...config});
|
||||
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export class SingleCommunityService {
|
||||
}
|
||||
try {
|
||||
await this.guildMemberService.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
@@ -50,6 +51,37 @@ export class SingleCommunityService {
|
||||
}
|
||||
}
|
||||
|
||||
async ensureStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
|
||||
const policy = await this.instanceConfigRepository.getInstancePolicyConfig();
|
||||
const designatedGuildId = await this.findDesignatedGuild(policy.single_community_guild_id);
|
||||
if (designatedGuildId != null) {
|
||||
await this.instanceConfigRepository.setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: designatedGuildId.toString(),
|
||||
});
|
||||
return designatedGuildId;
|
||||
}
|
||||
return this.createStockCommunity(params);
|
||||
}
|
||||
|
||||
private async findDesignatedGuild(rawGuildId: string | null): Promise<GuildID | null> {
|
||||
if (!rawGuildId) {
|
||||
return null;
|
||||
}
|
||||
let guildId: GuildID;
|
||||
try {
|
||||
guildId = createGuildID(BigInt(rawGuildId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await this.guildDataService.getGuildSystem(guildId);
|
||||
return guildId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
|
||||
const guild = await this.guildDataService.createGuild({user: params.owner, data: {name: params.name}});
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {User} from '../../models/User';
|
||||
import type {InstancePolicyConfig} from '../InstanceConfigRepository';
|
||||
import {SingleCommunityService} from '../SingleCommunityService';
|
||||
|
||||
const EXISTING_GUILD_ID = '1234567890123456789';
|
||||
const OWNER = {id: 42n} as unknown as User;
|
||||
|
||||
interface Harness {
|
||||
service: SingleCommunityService;
|
||||
written: Array<Partial<InstancePolicyConfig>>;
|
||||
createdNames: Array<string>;
|
||||
}
|
||||
|
||||
function createHarness(params: {designatedGuildId: string | null; designatedGuildExists: boolean}): Harness {
|
||||
const written: Array<Partial<InstancePolicyConfig>> = [];
|
||||
const createdNames: Array<string> = [];
|
||||
let nextCreatedGuildId = 999n;
|
||||
const instanceConfigRepository = {
|
||||
getInstancePolicyConfig: async () => ({
|
||||
single_community_enabled: false,
|
||||
single_community_guild_id: params.designatedGuildId,
|
||||
}),
|
||||
setInstancePolicyConfig: async (patch: Partial<InstancePolicyConfig>) => {
|
||||
written.push(patch);
|
||||
},
|
||||
};
|
||||
const guildDataService = {
|
||||
getGuildSystem: async () => {
|
||||
if (!params.designatedGuildExists) {
|
||||
throw new Error('unknown guild');
|
||||
}
|
||||
return {} as never;
|
||||
},
|
||||
createGuild: async ({data}: {data: {name: string}}) => {
|
||||
createdNames.push(data.name);
|
||||
nextCreatedGuildId += 1n;
|
||||
return {id: nextCreatedGuildId.toString()} as never;
|
||||
},
|
||||
};
|
||||
const service = new SingleCommunityService(
|
||||
instanceConfigRepository as never,
|
||||
guildDataService as never,
|
||||
null as never,
|
||||
);
|
||||
return {service, written, createdNames};
|
||||
}
|
||||
|
||||
describe('SingleCommunityService.ensureStockCommunity', () => {
|
||||
it('reuses the designated community when it still exists', async () => {
|
||||
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: true});
|
||||
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(guildId.toString()).toBe(EXISTING_GUILD_ID);
|
||||
expect(harness.createdNames).toEqual([]);
|
||||
expect(harness.written).toEqual([{single_community_enabled: true, single_community_guild_id: EXISTING_GUILD_ID}]);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the designated one was deleted', async () => {
|
||||
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: false});
|
||||
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(guildId.toString()).not.toBe(EXISTING_GUILD_ID);
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the instance never designated one', async () => {
|
||||
const harness = createHarness({designatedGuildId: null, designatedGuildExists: false});
|
||||
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the stored guild id is not a snowflake', async () => {
|
||||
const harness = createHarness({designatedGuildId: 'not-a-snowflake', designatedGuildExists: true});
|
||||
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,8 @@ export class LimitConfigService {
|
||||
}
|
||||
|
||||
async refreshCache(): Promise<void> {
|
||||
this.premiumMode = (await this.repository.getInstancePolicyConfig()).premium_mode;
|
||||
const policyConfig = await this.repository.getInstancePolicyConfig();
|
||||
this.premiumMode = policyConfig.premium_mode;
|
||||
setCachedInstancePremiumMode(this.premiumMode);
|
||||
const currentHash = computeDefaultsHash();
|
||||
const lockToken = await this.cacheService.acquireLock(LIMIT_CONFIG_REFRESH_LOCK_KEY, 10);
|
||||
|
||||
@@ -273,6 +273,7 @@ export class OAuth2RequestService {
|
||||
}
|
||||
}
|
||||
await this.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId: botUserId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
|
||||
@@ -29747,6 +29747,7 @@
|
||||
"id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"},
|
||||
"name": {"type": "string", "description": "Guild name"},
|
||||
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild icon hash"},
|
||||
"banner": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild banner hash"},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Discovery description"
|
||||
@@ -29775,9 +29776,21 @@
|
||||
},
|
||||
"description": "Discovery guild results"
|
||||
},
|
||||
"total": {"type": "number", "description": "Total number of matching guilds"}
|
||||
"total": {"type": "number", "description": "Total number of matching guilds"},
|
||||
"category_counts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category_type": {"type": "number", "description": "Discovery category type"},
|
||||
"count": {"type": "number", "description": "Number of matching guilds in this category"}
|
||||
},
|
||||
"required": ["category_type", "count"]
|
||||
},
|
||||
"description": "Match counts per category for the current filters, ignoring the category filter"
|
||||
}
|
||||
},
|
||||
"required": ["guilds", "total"]
|
||||
"required": ["guilds", "total", "category_counts"]
|
||||
},
|
||||
"DonationCheckoutResponse": {
|
||||
"type": "object",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {Guild} from '../models/Guild';
|
||||
import type {User} from '../models/User';
|
||||
import {type DeferredPhoneGateConfig, evaluateDeferredPhoneGate, guildTriggersPhoneGate} from './DeferredPhoneGate';
|
||||
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
|
||||
|
||||
const CONFIG: DeferredPhoneGateConfig = {
|
||||
enabled: true,
|
||||
windowMs: 6 * ms('1 hour'),
|
||||
memberThreshold: 50,
|
||||
};
|
||||
|
||||
const USER_SNOWFLAKE = 1485046297690587136n;
|
||||
const REGISTERED_AT = snowflakeToDate(USER_SNOWFLAKE).getTime();
|
||||
|
||||
function createUser(overrides: Partial<Pick<User, 'hasVerifiedPhone' | 'suspiciousActivityFlags'>> = {}): User {
|
||||
return {
|
||||
id: USER_SNOWFLAKE,
|
||||
hasVerifiedPhone: false,
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
...overrides,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
function createGuild(overrides: {discoverable?: boolean; memberCount?: number} = {}): Guild {
|
||||
return {
|
||||
id: 1n,
|
||||
features: new Set(overrides.discoverable ? [GuildFeatures.DISCOVERABLE] : []),
|
||||
memberCount: overrides.memberCount ?? 10,
|
||||
} as unknown as Guild;
|
||||
}
|
||||
|
||||
describe('evaluateDeferredPhoneGate', () => {
|
||||
it('applies to a discoverable guild inside the window, promoting the real phone flags', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true, memberCount: 3}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
if (!outcome.applies) return;
|
||||
expect(outcome.flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(outcome.flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
|
||||
it('applies to a large non-discoverable guild inside the window', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({memberCount: 51}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
});
|
||||
|
||||
it('does not apply to a small non-discoverable guild', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({memberCount: 50}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'guild_below_threshold'});
|
||||
});
|
||||
|
||||
it('applies on the last millisecond inside the window', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + CONFIG.windowMs - 1,
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
});
|
||||
|
||||
it('does not gate once the window has elapsed, and mutates nothing', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + CONFIG.windowMs,
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'outside_window'});
|
||||
});
|
||||
it('is inert while the gate is disabled, even on a qualifying guild', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser(),
|
||||
createGuild({discoverable: true}),
|
||||
{...CONFIG, enabled: false},
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'gate_disabled'});
|
||||
});
|
||||
|
||||
it('does not apply to a user who already has a verified phone', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser({hasVerifiedPhone: true}),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome).toEqual({applies: false, reason: 'already_verified'});
|
||||
});
|
||||
|
||||
it('preserves non-phone requirements when promoting', () => {
|
||||
const outcome = evaluateDeferredPhoneGate(
|
||||
createUser({
|
||||
suspiciousActivityFlags:
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION |
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
}),
|
||||
createGuild({discoverable: true}),
|
||||
CONFIG,
|
||||
REGISTERED_AT + ms('1 hour'),
|
||||
);
|
||||
expect(outcome.applies).toBe(true);
|
||||
if (!outcome.applies) return;
|
||||
expect(outcome.flags).toBe(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDeferredPhoneGateEnabled', () => {
|
||||
it('is on only when the tunable is set and the instance is not single-community', () => {
|
||||
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: false})).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: true, single_community_enabled: true})).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resolveDeferredPhoneGateEnabled({deferred_phone_gate_enabled: false, single_community_enabled: false})).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guildTriggersPhoneGate', () => {
|
||||
it('qualifies a discoverable guild regardless of size', () => {
|
||||
expect(guildTriggersPhoneGate(createGuild({discoverable: true, memberCount: 1}), 50)).toBe(true);
|
||||
});
|
||||
it('qualifies a guild strictly above the member threshold', () => {
|
||||
expect(guildTriggersPhoneGate(createGuild({memberCount: 51}), 50)).toBe(true);
|
||||
expect(guildTriggersPhoneGate(createGuild({memberCount: 50}), 50)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
NEVER_DEFERRABLE_PHONE_FLAGS,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
import {Logger} from '../Logger';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {Guild} from '../models/Guild';
|
||||
import type {User} from '../models/User';
|
||||
import {resolveDeferredPhoneGateEnabled} from './DeferredPhoneGateCache';
|
||||
|
||||
export interface DeferredPhoneGateConfig {
|
||||
enabled: boolean;
|
||||
windowMs: number;
|
||||
memberThreshold: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_PHONE_GATE_MEMBER_THRESHOLD = 50;
|
||||
|
||||
const DISABLED_CONFIG: DeferredPhoneGateConfig = {
|
||||
enabled: false,
|
||||
windowMs: Number.POSITIVE_INFINITY,
|
||||
memberThreshold: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
|
||||
type DeferredPhoneGateConfigResult =
|
||||
| {status: 'ok'; config: DeferredPhoneGateConfig}
|
||||
| {status: 'disabled'; config: DeferredPhoneGateConfig}
|
||||
| {status: 'unreadable'; config: DeferredPhoneGateConfig};
|
||||
|
||||
export async function getDeferredPhoneGateConfig(): Promise<DeferredPhoneGateConfigResult> {
|
||||
try {
|
||||
const policy = await getInstanceConfigRepository().getInstancePolicyConfig();
|
||||
if (!resolveDeferredPhoneGateEnabled(policy)) {
|
||||
return {status: 'disabled', config: DISABLED_CONFIG};
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
config: {
|
||||
enabled: true,
|
||||
windowMs: policy.deferred_phone_gate_window_hours * ms('1 hour'),
|
||||
memberThreshold: policy.deferred_phone_gate_member_threshold,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to read deferred phone gate config');
|
||||
return {status: 'unreadable', config: DISABLED_CONFIG};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deferPhoneFlagsUntilCommunityJoin(flagBits: number): Promise<number> {
|
||||
if ((flagBits & DEFERRABLE_PHONE_FLAGS) === 0 || (flagBits & NEVER_DEFERRABLE_PHONE_FLAGS) !== 0) {
|
||||
return flagBits;
|
||||
}
|
||||
const {status} = await getDeferredPhoneGateConfig();
|
||||
if (status !== 'ok') {
|
||||
return flagBits;
|
||||
}
|
||||
return flagBits | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
}
|
||||
|
||||
export function guildTriggersPhoneGate(guild: Guild, memberThreshold: number): boolean {
|
||||
return guild.features.has(GuildFeatures.DISCOVERABLE) || guild.memberCount > memberThreshold;
|
||||
}
|
||||
|
||||
type DeferredPhoneGateOutcome =
|
||||
| {applies: false; reason: 'gate_disabled' | 'already_verified' | 'guild_below_threshold' | 'outside_window'}
|
||||
| {applies: true; flags: number};
|
||||
|
||||
export function evaluateDeferredPhoneGate(
|
||||
user: User,
|
||||
guild: Guild,
|
||||
config: DeferredPhoneGateConfig,
|
||||
now: number,
|
||||
): DeferredPhoneGateOutcome {
|
||||
if (!config.enabled) {
|
||||
return {applies: false, reason: 'gate_disabled'};
|
||||
}
|
||||
if (user.hasVerifiedPhone) {
|
||||
return {applies: false, reason: 'already_verified'};
|
||||
}
|
||||
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
|
||||
return {applies: false, reason: 'guild_below_threshold'};
|
||||
}
|
||||
if (now - snowflakeToDate(BigInt(user.id)).getTime() >= config.windowMs) {
|
||||
return {applies: false, reason: 'outside_window'};
|
||||
}
|
||||
return {applies: true, flags: (user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
let cachedEnabled = false;
|
||||
|
||||
export function resolveDeferredPhoneGateEnabled(policy: {
|
||||
deferred_phone_gate_enabled: boolean;
|
||||
single_community_enabled: boolean;
|
||||
}): boolean {
|
||||
return policy.deferred_phone_gate_enabled && !policy.single_community_enabled;
|
||||
}
|
||||
|
||||
export function getCachedDeferredPhoneGateEnabled(): boolean {
|
||||
return cachedEnabled;
|
||||
}
|
||||
|
||||
export function setCachedDeferredPhoneGateEnabled(enabled: boolean): void {
|
||||
cachedEnabled = enabled;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
PremiumFlags,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {RpcSessionTimings} from '@fluxer/schema/src/domains/rpc/RpcSchemas';
|
||||
import {Config} from '../Config';
|
||||
import type {UserRow} from '../database/types/UserTypes';
|
||||
@@ -370,12 +376,14 @@ export class RpcSessionStartService {
|
||||
timeRpcStepSync(
|
||||
timingSteps,
|
||||
'check_required_inbound_phone_flags_already_set',
|
||||
() => (user.suspiciousActivityFlags & requiredFlags) === requiredFlags,
|
||||
() =>
|
||||
(user.suspiciousActivityFlags & requiredFlags) === requiredFlags &&
|
||||
(user.suspiciousActivityFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const newFlags = user.suspiciousActivityFlags | requiredFlags;
|
||||
const newFlags = imposePhoneRequirements(user.suspiciousActivityFlags, requiredFlags);
|
||||
try {
|
||||
const updatedUser = await timeRpcStep(timingSteps, 'persist_inbound_phone_requirement', async () =>
|
||||
this.deps.userRepository.patchUpsert(user.id, {suspicious_activity_flags: newFlags}, user.toRow()),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {
|
||||
ISearchAdapter as SchemaISearchAdapter,
|
||||
SearchOptions as SchemaSearchOptions,
|
||||
SearchResult as SchemaSearchResult,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
@@ -18,9 +19,6 @@ export interface IGuildSearchService extends SchemaISearchAdapter<GuildSearchFil
|
||||
searchGuilds(
|
||||
query: string,
|
||||
filters: GuildSearchFilters,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
options?: SchemaSearchOptions,
|
||||
): Promise<SchemaSearchResult<SearchableGuild>>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {
|
||||
SearchOptions as SchemaSearchOptions,
|
||||
SearchResult as SchemaSearchResult,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import {
|
||||
ElasticsearchGuildAdapter,
|
||||
@@ -60,10 +63,7 @@ export class ElasticsearchGuildSearchService
|
||||
searchGuilds(
|
||||
query: string,
|
||||
filters: GuildSearchFilters,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
options?: SchemaSearchOptions,
|
||||
): Promise<SchemaSearchResult<SearchableGuild>> {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface MeilisearchSearchResponse<TResult> {
|
||||
totalHits?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
facetDistribution?: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export class MeilisearchIndexAdapter<
|
||||
@@ -139,6 +140,7 @@ export class MeilisearchIndexAdapter<
|
||||
const limit = Math.min(Math.max(requestedLimit, 0), MAX_SEARCH_LIMIT);
|
||||
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
|
||||
const filter = joinMeiliFilters(this.buildFilters(filters));
|
||||
const facets = options?.facets;
|
||||
const result = await this.client.request<MeilisearchSearchResponse<TResult>>(
|
||||
'POST',
|
||||
`/indexes/${encodeURIComponent(this.indexDefinition.uid)}/search`,
|
||||
@@ -150,11 +152,13 @@ export class MeilisearchIndexAdapter<
|
||||
sort: this.buildSort?.(filters),
|
||||
attributesToSearchOn: this.indexDefinition.searchableAttributes,
|
||||
showRankingScore: false,
|
||||
...(facets && facets.length > 0 ? {facets} : {}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
hits: result.hits,
|
||||
total: result.totalHits ?? result.estimatedTotalHits ?? result.hits.length,
|
||||
...(result.facetDistribution ? {facetCounts: result.facetDistribution} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {
|
||||
SearchOptions as SchemaSearchOptions,
|
||||
SearchResult as SchemaSearchResult,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {
|
||||
AuditLogSearchFilters,
|
||||
GuildMemberSearchFilters,
|
||||
@@ -171,10 +174,7 @@ export class MeilisearchGuildSearchService
|
||||
searchGuilds(
|
||||
query: string,
|
||||
filters: GuildSearchFilters,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
options?: SchemaSearchOptions,
|
||||
): Promise<SchemaSearchResult<SearchableGuild>> {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
|
||||
@@ -115,15 +115,3 @@ export function getFirstInvoicePaymentIntentId(invoice: Stripe.Invoice | null):
|
||||
}
|
||||
return paymentIntent.id ?? null;
|
||||
}
|
||||
|
||||
export function getFirstInvoicePaymentIntentLatestChargeId(invoice: Stripe.Invoice | null): string | null {
|
||||
const paymentIntent = getFirstInvoicePaymentIntent(invoice);
|
||||
if (!paymentIntent || typeof paymentIntent === 'string') {
|
||||
return null;
|
||||
}
|
||||
const latestCharge = paymentIntent.latest_charge ?? null;
|
||||
if (typeof latestCharge === 'string') {
|
||||
return latestCharge;
|
||||
}
|
||||
return latestCharge?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {mapUserToPrivateResponse} from '../../user/UserMappers';
|
||||
import type {ProductInfo, ProductRegistry} from '../ProductRegistry';
|
||||
import {
|
||||
getFirstInvoicePaymentIntentId,
|
||||
getFirstInvoicePaymentIntentLatestChargeId,
|
||||
getPrimarySubscriptionItem,
|
||||
getSubscriptionItemPeriodEnd,
|
||||
getSubscriptionPremiumPeriodEnd,
|
||||
@@ -600,11 +599,16 @@ export class StripeCheckoutWebhookHandler {
|
||||
if (!this.stripe) return null;
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['latest_invoice.payments.data.payment.payment_intent.latest_charge'],
|
||||
expand: ['latest_invoice.payments.data.payment'],
|
||||
});
|
||||
const latestInvoice =
|
||||
typeof subscription.latest_invoice === 'string' ? null : (subscription.latest_invoice ?? null);
|
||||
return getFirstInvoicePaymentIntentLatestChargeId(latestInvoice);
|
||||
const paymentIntentId = getFirstInvoicePaymentIntentId(latestInvoice);
|
||||
if (!paymentIntentId) {
|
||||
return null;
|
||||
}
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
return extractId(paymentIntent.latest_charge);
|
||||
} catch (err) {
|
||||
Logger.warn({err, subscriptionId}, 'Failed to resolve latest charge for duplicate-subscription refund');
|
||||
return null;
|
||||
@@ -739,7 +743,7 @@ export class StripeCheckoutWebhookHandler {
|
||||
};
|
||||
try {
|
||||
const subscription = (await this.stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['default_payment_method', 'latest_invoice.payments.data.payment.payment_intent'],
|
||||
expand: ['default_payment_method', 'latest_invoice.payments.data.payment'],
|
||||
})) as StripeSubscriptionWithFallbackPaymentState;
|
||||
const latestInvoice =
|
||||
typeof subscription.latest_invoice === 'string' ? null : (subscription.latest_invoice ?? null);
|
||||
|
||||
@@ -296,6 +296,7 @@ export class StripePremiumService {
|
||||
const existingMember = await this.guildRepository.getMember(visionariesGuildId, userId);
|
||||
if (!existingMember) {
|
||||
await this.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId: visionariesGuildId,
|
||||
sendJoinMessage: true,
|
||||
|
||||
@@ -39,7 +39,6 @@ interface RefundTarget {
|
||||
|
||||
type StripeInvoiceWithPayments = Stripe.Invoice & {
|
||||
customer?: string | Stripe.Customer | null;
|
||||
subscription?: string | Stripe.Subscription | null;
|
||||
payments?: {
|
||||
data?: Array<{
|
||||
payment?: {
|
||||
@@ -54,6 +53,10 @@ type StripeInvoiceWithPayments = Stripe.Invoice & {
|
||||
} | null;
|
||||
};
|
||||
|
||||
function resolveInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
|
||||
return extractId(invoice.parent?.subscription_details?.subscription ?? null);
|
||||
}
|
||||
|
||||
function getInvoicePaymentRef(invoice: Stripe.Invoice): {
|
||||
chargeId: string | null;
|
||||
paymentIntentId: string | null;
|
||||
@@ -113,7 +116,7 @@ export class StripeRefundService {
|
||||
const list = await this.stripe.invoices.list({
|
||||
customer: user.stripeCustomerId,
|
||||
limit: 5,
|
||||
expand: ['data.payments.data.payment.payment_intent'],
|
||||
expand: ['data.payments.data.payment'],
|
||||
});
|
||||
for (const invoice of list.data) {
|
||||
if (!invoice.id || invoice.status !== 'paid' || invoice.amount_paid <= 0) {
|
||||
@@ -132,7 +135,7 @@ export class StripeRefundService {
|
||||
chargeId: ref.chargeId,
|
||||
paymentIntentId: ref.paymentIntentId,
|
||||
paidAt,
|
||||
subscriptionId: extractId((invoice as StripeInvoiceWithPayments).subscription),
|
||||
subscriptionId: resolveInvoiceSubscriptionId(invoice),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -260,37 +260,13 @@ export class StripeSubscriptionReconciler {
|
||||
}
|
||||
|
||||
getPriceIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
type InvoiceLineWithPrice = Stripe.InvoiceLineItem & {
|
||||
price?: string | Stripe.Price | null;
|
||||
pricing?: {
|
||||
price_details?: {
|
||||
price?: string;
|
||||
};
|
||||
};
|
||||
parent?: {
|
||||
subscription_item_details?: {
|
||||
price?: {
|
||||
price?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
if (!invoice.lines?.data?.length) {
|
||||
return null;
|
||||
}
|
||||
for (const line of invoice.lines.data) {
|
||||
const lineWithPrice = line as InvoiceLineWithPrice;
|
||||
const directPriceId = extractId(lineWithPrice.price);
|
||||
if (directPriceId) {
|
||||
return directPriceId;
|
||||
}
|
||||
const nestedPriceId = lineWithPrice.pricing?.price_details?.price;
|
||||
if (nestedPriceId) {
|
||||
return extractId(nestedPriceId);
|
||||
}
|
||||
const parentNestedPriceId = lineWithPrice.parent?.subscription_item_details?.price?.price;
|
||||
if (parentNestedPriceId) {
|
||||
return extractId(parentNestedPriceId);
|
||||
const priceId = extractId(line.pricing?.price_details?.price ?? null);
|
||||
if (priceId) {
|
||||
return priceId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -328,39 +304,15 @@ export class StripeSubscriptionReconciler {
|
||||
}
|
||||
|
||||
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
|
||||
type InvoiceWithSubscription = Stripe.Invoice & {
|
||||
subscription?: string | Stripe.Subscription;
|
||||
};
|
||||
const invoiceWithSubscription = invoice as InvoiceWithSubscription;
|
||||
const directSubscription = invoiceWithSubscription.subscription;
|
||||
if (directSubscription) {
|
||||
return extractId(directSubscription);
|
||||
}
|
||||
type InvoiceWithParent = Stripe.Invoice & {
|
||||
parent?: {
|
||||
subscription_details?: {
|
||||
subscription?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
type InvoiceLineWithParent = Stripe.InvoiceLineItem & {
|
||||
parent?: {
|
||||
subscription_item_details?: {
|
||||
subscription?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const invoiceWithParent = invoice as InvoiceWithParent;
|
||||
const parentSubscription = invoiceWithParent.parent?.subscription_details?.subscription;
|
||||
const parentSubscription = extractId(invoice.parent?.subscription_details?.subscription ?? null);
|
||||
if (parentSubscription) {
|
||||
return extractId(parentSubscription);
|
||||
return parentSubscription;
|
||||
}
|
||||
if (invoice.lines?.data?.length) {
|
||||
for (const line of invoice.lines.data) {
|
||||
const lineWithParent = line as InvoiceLineWithParent;
|
||||
const subscriptionId = lineWithParent.parent?.subscription_item_details?.subscription;
|
||||
const subscriptionId = line.parent?.subscription_item_details?.subscription ?? null;
|
||||
if (subscriptionId) {
|
||||
return extractId(subscriptionId);
|
||||
return subscriptionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class StripeWebhookService {
|
||||
case 'checkout.session.completed': {
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session;
|
||||
await this.safeMirrorUpsert(event, () =>
|
||||
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession),
|
||||
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession, {eventCreated: event.created}),
|
||||
);
|
||||
if (checkoutSession.metadata?.verification_type === 'uk_age_verification' && this.ageVerificationService) {
|
||||
await this.ageVerificationService.completeVerification(checkoutSession);
|
||||
@@ -165,44 +165,48 @@ export class StripeWebhookService {
|
||||
}
|
||||
case 'checkout.session.async_payment_succeeded': {
|
||||
const cs = event.data.object as Stripe.Checkout.Session;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
|
||||
await this.safeMirrorUpsert(event, () =>
|
||||
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
|
||||
);
|
||||
await this.checkoutHandler.handleAsyncPaymentSucceeded(cs);
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.async_payment_failed': {
|
||||
const cs = event.data.object as Stripe.Checkout.Session;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
|
||||
await this.safeMirrorUpsert(event, () =>
|
||||
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
|
||||
);
|
||||
await this.checkoutHandler.handleAsyncPaymentFailed(cs);
|
||||
break;
|
||||
}
|
||||
case 'invoice.paid':
|
||||
case 'invoice.payment_succeeded': {
|
||||
const inv = event.data.object as Stripe.Invoice;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
|
||||
await this.mirrorInvoice(event, inv);
|
||||
await this.subscriptionHandler.handleInvoicePaymentSucceeded(event.id, inv);
|
||||
break;
|
||||
}
|
||||
case 'invoice.payment_failed': {
|
||||
const inv = event.data.object as Stripe.Invoice;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
|
||||
await this.mirrorInvoice(event, inv);
|
||||
await this.subscriptionHandler.handleInvoicePaymentFailed(inv);
|
||||
break;
|
||||
}
|
||||
case 'invoice.payment_action_required': {
|
||||
const inv = event.data.object as Stripe.Invoice;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
|
||||
await this.mirrorInvoice(event, inv);
|
||||
await this.subscriptionHandler.handleInvoicePaymentActionRequired(inv);
|
||||
break;
|
||||
}
|
||||
case 'invoice.finalization_failed': {
|
||||
const inv = event.data.object as Stripe.Invoice;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
|
||||
await this.mirrorInvoice(event, inv);
|
||||
await this.subscriptionHandler.handleInvoiceFinalizationFailed(inv);
|
||||
break;
|
||||
}
|
||||
case 'invoice.updated': {
|
||||
const inv = event.data.object as Stripe.Invoice;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
|
||||
await this.mirrorInvoice(event, inv);
|
||||
await this.subscriptionHandler.handleInvoiceUpdated(inv);
|
||||
break;
|
||||
}
|
||||
@@ -253,11 +257,12 @@ export class StripeWebhookService {
|
||||
case 'charge.refunded': {
|
||||
const c = event.data.object as Stripe.Charge;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.charges.upsertFromStripe(c));
|
||||
const refunds = c.refunds?.data ?? [];
|
||||
const refunds = await this.listChargeRefunds(c);
|
||||
for (const r of refunds) {
|
||||
await this.safeMirrorUpsert(event, () =>
|
||||
this.billingRepository.refunds.upsertFromStripe(r, {
|
||||
customerId: typeof c.customer === 'string' ? c.customer : (c.customer?.id ?? undefined),
|
||||
livemode: event.livemode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -324,7 +329,13 @@ export class StripeWebhookService {
|
||||
case 'refund.updated':
|
||||
case 'refund.failed': {
|
||||
const r = event.data.object as Stripe.Refund;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.refunds.upsertFromStripe(r));
|
||||
const customerId = await this.resolveRefundCustomerId(r);
|
||||
await this.safeMirrorUpsert(event, () =>
|
||||
this.billingRepository.refunds.upsertFromStripe(r, {
|
||||
customerId: customerId ?? undefined,
|
||||
livemode: event.livemode,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'invoice.created':
|
||||
@@ -332,12 +343,14 @@ export class StripeWebhookService {
|
||||
case 'invoice.voided':
|
||||
case 'invoice.marked_uncollectible': {
|
||||
const inv = event.data.object as Stripe.Invoice;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
|
||||
await this.mirrorInvoice(event, inv);
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.expired': {
|
||||
const cs = event.data.object as Stripe.Checkout.Session;
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
|
||||
await this.safeMirrorUpsert(event, () =>
|
||||
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'charge.dispute.updated':
|
||||
@@ -353,6 +366,54 @@ export class StripeWebhookService {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
|
||||
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
|
||||
if (chargeId !== null) {
|
||||
const chargeRow = await this.billingRepository.charges.findById(chargeId);
|
||||
if (chargeRow?.customer_id != null) {
|
||||
return chargeRow.customer_id;
|
||||
}
|
||||
}
|
||||
const paymentIntentId =
|
||||
typeof refund.payment_intent === 'string' ? refund.payment_intent : (refund.payment_intent?.id ?? null);
|
||||
if (paymentIntentId !== null) {
|
||||
const paymentIntentRow = await this.billingRepository.paymentIntents.findById(paymentIntentId);
|
||||
if (paymentIntentRow?.customer_id != null) {
|
||||
return paymentIntentRow.customer_id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listChargeRefunds(charge: Stripe.Charge): Promise<Array<Stripe.Refund>> {
|
||||
const inlined = charge.refunds?.data ?? [];
|
||||
if (inlined.length > 0 || charge.id == null || this.stripe == null) {
|
||||
return inlined;
|
||||
}
|
||||
try {
|
||||
const listed = await this.stripe.refunds.list({charge: charge.id, limit: 100});
|
||||
return listed.data;
|
||||
} catch (listErr) {
|
||||
Logger.warn({listErr, chargeId: charge.id}, 'Failed to list refunds for charge; skipping refund mirror');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async mirrorInvoice(event: Stripe.Event, inv: Stripe.Invoice): Promise<void> {
|
||||
let hydrated = inv;
|
||||
if (inv.payments === undefined && inv.id != null && this.stripe != null) {
|
||||
try {
|
||||
hydrated = await this.stripe.invoices.retrieve(inv.id, {expand: ['payments.data.payment']});
|
||||
} catch (hydrateErr) {
|
||||
Logger.warn(
|
||||
{hydrateErr, eventId: event.id, invoiceId: inv.id},
|
||||
'Failed to hydrate invoice payments; mirroring invoice without payment rows',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(hydrated));
|
||||
}
|
||||
|
||||
private async safeMirrorUpsert(event: Stripe.Event, fn: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
|
||||
@@ -22,7 +22,7 @@ interface MockStripeInvoice {
|
||||
id: string;
|
||||
object: 'invoice';
|
||||
customer: string;
|
||||
subscription: string | null;
|
||||
parent: {subscription_details: {subscription: string}} | null;
|
||||
amount_due: number;
|
||||
amount_paid: number;
|
||||
currency: string;
|
||||
@@ -66,7 +66,7 @@ function buildInvoice(opts: {
|
||||
id: opts.id,
|
||||
object: 'invoice',
|
||||
customer: customerId,
|
||||
subscription: subscriptionId,
|
||||
parent: subscriptionId == null ? null : {subscription_details: {subscription: subscriptionId}},
|
||||
amount_due: 2500,
|
||||
amount_paid: 2500,
|
||||
currency: 'usd',
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_cycle',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -217,7 +217,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_cycle',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -252,7 +252,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: invoiceId,
|
||||
billing_reason: 'subscription_cycle',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -300,7 +300,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_create',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -351,7 +351,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_cycle',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -372,7 +372,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_cycle',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -422,7 +422,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_cycle',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -460,7 +460,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_update',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
amount_paid: 0,
|
||||
amount_due: 0,
|
||||
total: 0,
|
||||
@@ -503,7 +503,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
object: {
|
||||
id: `in_test_${Date.now()}`,
|
||||
billing_reason: 'subscription_update',
|
||||
subscription: subscriptionId,
|
||||
parent: {subscription_details: {subscription: subscriptionId}},
|
||||
amount_paid: 1250,
|
||||
amount_due: 1250,
|
||||
total: 1250,
|
||||
|
||||
@@ -305,7 +305,13 @@ export class MockStorageService implements IStorageService {
|
||||
await this.deleteObject(params.sourceBucket, params.sourceKey);
|
||||
}
|
||||
|
||||
async getPresignedDownloadURL(_params: {bucket: string; key: string; expiresIn?: number}): Promise<string> {
|
||||
async getPresignedDownloadURL(_params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}): Promise<string> {
|
||||
this.getPresignedDownloadURLSpy(_params);
|
||||
return 'https://presigned.url/test';
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ interface StripeApiMockConfig {
|
||||
subscriptionsListEmpty?: boolean;
|
||||
charges?: Record<string, Partial<MockStripeCharge>>;
|
||||
customers?: Record<string, Partial<MockStripeCustomer>>;
|
||||
invoices?: Record<string, Partial<MockStripeInvoice>>;
|
||||
invoices?: Record<string, Partial<MockStripeInvoice> & {subscriptionId?: string}>;
|
||||
paymentIntents?: Record<string, Partial<MockStripePaymentIntent>>;
|
||||
paymentMethods?: Record<string, Partial<MockStripePaymentMethod>>;
|
||||
setupIntents?: Record<string, Partial<MockStripeSetupIntent>>;
|
||||
@@ -284,7 +284,10 @@ interface MockStripeInvoice {
|
||||
url: string;
|
||||
};
|
||||
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void' | null;
|
||||
subscription: string | null;
|
||||
parent: {
|
||||
type: 'subscription_details';
|
||||
subscription_details: {subscription: string};
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MockStripeValueList {
|
||||
@@ -571,13 +574,14 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
});
|
||||
}
|
||||
for (const [invoiceId, overrides] of Object.entries(config.invoices ?? {})) {
|
||||
const {subscriptionId, ...invoiceOverrides} = overrides;
|
||||
const defaultInvoice = createDefaultInvoice(invoiceId, {
|
||||
customerId: overrides.customer ?? 'cus_test_1',
|
||||
subscriptionId: overrides.subscription ?? 'sub_test_1',
|
||||
subscriptionId: subscriptionId ?? 'sub_test_1',
|
||||
});
|
||||
invoiceStore.set(invoiceId, {
|
||||
...defaultInvoice,
|
||||
...overrides,
|
||||
...invoiceOverrides,
|
||||
id: invoiceId,
|
||||
object: 'invoice',
|
||||
payments: overrides.payments ?? defaultInvoice.payments,
|
||||
@@ -739,7 +743,10 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
url: `/v1/invoices/${id}/payments`,
|
||||
},
|
||||
status: 'paid',
|
||||
subscription: subscriptionId,
|
||||
parent:
|
||||
subscriptionId === null
|
||||
? null
|
||||
: {type: 'subscription_details', subscription_details: {subscription: subscriptionId}},
|
||||
};
|
||||
}
|
||||
function getPaymentIntent(paymentIntentId: string): MockStripePaymentIntent {
|
||||
@@ -838,8 +845,6 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
object: 'subscription',
|
||||
customer: subState.customer,
|
||||
status: subState.status,
|
||||
current_period_start: subState.current_period_start,
|
||||
current_period_end: subState.current_period_end,
|
||||
latest_invoice: subState.latest_invoice ? getInvoice(subState.latest_invoice) : null,
|
||||
trial_end: subState.trial_end,
|
||||
items: {
|
||||
@@ -1070,7 +1075,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
const limit = Number.parseInt(requestUrl.searchParams.get('limit') ?? '10', 10);
|
||||
const sortedInvoices = [...invoiceStore.values()]
|
||||
.filter((invoice) => !customerId || invoice.customer === customerId)
|
||||
.filter((invoice) => !subscriptionId || invoice.subscription === subscriptionId)
|
||||
.filter((invoice) => !subscriptionId || invoice.parent?.subscription_details?.subscription === subscriptionId)
|
||||
.sort((left, right) => right.created - left.created);
|
||||
const startIndex = startingAfter ? sortedInvoices.findIndex((invoice) => invoice.id === startingAfter) + 1 : 0;
|
||||
const pagedInvoices = sortedInvoices.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + limit);
|
||||
@@ -1460,7 +1465,6 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
object: 'subscription',
|
||||
customer,
|
||||
status: 'active',
|
||||
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
|
||||
trial_end: null,
|
||||
items: {
|
||||
object: 'list',
|
||||
@@ -1482,6 +1486,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
livemode: false,
|
||||
},
|
||||
quantity: 1,
|
||||
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
|
||||
current_period_end: currentPeriodEnd,
|
||||
},
|
||||
],
|
||||
@@ -1879,11 +1884,13 @@ export function createInvoicePaidEvent(options: {
|
||||
id: options.invoiceId ?? `in_test_${Date.now()}`,
|
||||
object: 'invoice',
|
||||
customer: options.customerId ?? 'cus_test_1',
|
||||
subscription: options.subscriptionId ?? 'sub_test_1',
|
||||
parent: {
|
||||
type: 'subscription_details',
|
||||
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
|
||||
},
|
||||
amount_paid: options.amountPaid ?? 2500,
|
||||
currency: options.currency ?? 'usd',
|
||||
status: 'paid',
|
||||
paid: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1902,7 +1909,10 @@ export function createInvoicePaymentFailedEvent(options: {
|
||||
id: options.invoiceId ?? `in_test_${Date.now()}`,
|
||||
object: 'invoice',
|
||||
customer: options.customerId ?? 'cus_test_1',
|
||||
subscription: options.subscriptionId ?? 'sub_test_1',
|
||||
parent: {
|
||||
type: 'subscription_details',
|
||||
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
|
||||
},
|
||||
amount_due: options.amountDue ?? 2500,
|
||||
status: 'open',
|
||||
paid: false,
|
||||
@@ -1926,7 +1936,10 @@ export function createInvoicePaymentActionRequiredEvent(options: {
|
||||
id: options.invoiceId ?? `in_test_${Date.now()}`,
|
||||
object: 'invoice',
|
||||
customer: options.customerId ?? 'cus_test_1',
|
||||
subscription: options.subscriptionId ?? 'sub_test_1',
|
||||
parent: {
|
||||
type: 'subscription_details',
|
||||
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
|
||||
},
|
||||
amount_due: options.amountDue ?? 2500,
|
||||
status: 'open',
|
||||
paid: false,
|
||||
@@ -1950,7 +1963,10 @@ export function createInvoiceFinalizationFailedEvent(options: {
|
||||
id: options.invoiceId ?? `in_test_${Date.now()}`,
|
||||
object: 'invoice',
|
||||
customer: options.customerId ?? 'cus_test_1',
|
||||
subscription: options.subscriptionId ?? 'sub_test_1',
|
||||
parent: {
|
||||
type: 'subscription_details',
|
||||
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
|
||||
},
|
||||
status: 'draft',
|
||||
paid: false,
|
||||
last_finalization_error: {
|
||||
@@ -1980,7 +1996,10 @@ export function createInvoiceUpdatedEvent(options: {
|
||||
id: options.invoiceId ?? `in_test_${Date.now()}`,
|
||||
object: 'invoice',
|
||||
customer: options.customerId ?? 'cus_test_1',
|
||||
subscription: options.subscriptionId ?? 'sub_test_1',
|
||||
parent: {
|
||||
type: 'subscription_details',
|
||||
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
|
||||
},
|
||||
amount_due: options.amountDue ?? 2500,
|
||||
status: options.status ?? 'open',
|
||||
paid: options.paid ?? false,
|
||||
|
||||
@@ -163,13 +163,34 @@ class InMemorySearchServiceBase<TFilters, TDocument extends SearchableDocument>
|
||||
return trimmed.length === 0 || stringArrayContainsText(this.collectText(doc).filter(isString), trimmed);
|
||||
})
|
||||
.sort((left, right) => this.sortDocuments(left, right, filters, query));
|
||||
const facets = options?.facets;
|
||||
return {
|
||||
hits: paginate(hits, options),
|
||||
total: hits.length,
|
||||
...(facets && facets.length > 0 ? {facetCounts: countFacets(hits, facets)} : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function countFacets<TDocument>(docs: Array<TDocument>, facets: Array<string>): Record<string, Record<string, number>> {
|
||||
const counts: Record<string, Record<string, number>> = {};
|
||||
for (const facet of facets) {
|
||||
const facetCounts: Record<string, number> = {};
|
||||
for (const doc of docs) {
|
||||
const value = (doc as Record<string, unknown>)[facet];
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of Array.isArray(value) ? value : [value]) {
|
||||
const key = String(entry);
|
||||
facetCounts[key] = (facetCounts[key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
counts[facet] = facetCounts;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function isString(value: string | null): value is string {
|
||||
return value !== null;
|
||||
}
|
||||
@@ -452,7 +473,7 @@ class InMemoryGuildSearchService
|
||||
await this.deleteDocuments(guildIds.map((id) => id.toString()));
|
||||
}
|
||||
|
||||
searchGuilds(query: string, filters: GuildSearchFilters, options?: {limit?: number; offset?: number}) {
|
||||
searchGuilds(query: string, filters: GuildSearchFilters, options?: SearchOptions) {
|
||||
return this.search(query, filters, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
PremiumFlags,
|
||||
SuspiciousActivityFlags,
|
||||
UserPremiumTypes,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {User} from '../models/User';
|
||||
import {setInjectedAccountPolicyEvaluator} from '../risk/AccountPolicyService';
|
||||
import {setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
import {
|
||||
createCurrentBehaviorTestAccountPolicyEvaluator,
|
||||
TEST_POLICY_CONTACT_DOMAIN,
|
||||
@@ -23,6 +30,88 @@ function createUser(
|
||||
} as User;
|
||||
}
|
||||
|
||||
describe('deferred phone gate marker', () => {
|
||||
beforeEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator());
|
||||
setCachedDeferredPhoneGateEnabled(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(undefined);
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
});
|
||||
it('does not suppress anything until a policy read has proven the gate is on', () => {
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
});
|
||||
it('suppresses a deferred phone requirement so the account is not locked out', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
});
|
||||
it('never lets an inbound-SMS requirement be suppressed, since that tier is never deferred', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags:
|
||||
SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE', 'REQUIRE_INBOUND_PHONE_VERIFICATION']);
|
||||
});
|
||||
it('keeps non-phone requirements active while a phone requirement is deferred', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags:
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_EMAIL']);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
});
|
||||
it('applies the phone requirement in full once the marker is cleared', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
|
||||
});
|
||||
it('leaves an account carrying only the marker completely unrestricted', () => {
|
||||
const user = createUser({suspiciousActivityFlags: DEFERRED_PHONE_ON_COMMUNITY_JOIN});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
});
|
||||
it('re-arms stored phone requirements as soon as the gate is switched off', () => {
|
||||
const user = createUser({
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
});
|
||||
expect(getRequiredActions(user)).toEqual([]);
|
||||
setCachedDeferredPhoneGateEnabled(false);
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_VERIFIED_PHONE']);
|
||||
});
|
||||
it('stops suppressing once another subsystem imposes the phone requirement directly', () => {
|
||||
const deferred = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_REVERIFIED_PHONE);
|
||||
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
const user = createUser({suspiciousActivityFlags: imposed});
|
||||
expect(getRequiredActions(user)).toEqual(['REQUIRE_REVERIFIED_PHONE']);
|
||||
});
|
||||
it('keeps the marker when a non-phone requirement is imposed', () => {
|
||||
const deferred = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
const imposed = imposePhoneRequirements(deferred, SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
expect(imposed & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(getRequiredActions(createUser({suspiciousActivityFlags: imposed}))).toEqual(['REQUIRE_VERIFIED_EMAIL']);
|
||||
});
|
||||
it('yields no enforceable requirement for an account without an email, so the gate must not promote it', () => {
|
||||
const user = createUser({
|
||||
email: null,
|
||||
suspiciousActivityFlags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
});
|
||||
expect(getEffectiveSuspiciousFlags(user)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequiredActions', () => {
|
||||
beforeEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator());
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
PremiumFlags,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {RequiredAction} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import {Config} from '../Config';
|
||||
@@ -8,6 +14,7 @@ import type {UserRow} from '../database/types/UserTypes';
|
||||
import {getCachedInstancePremiumMode} from '../limits/InstancePremiumModeCache';
|
||||
import type {User} from '../models/User';
|
||||
import {accountPolicyContactHasCapability} from '../risk/AccountPolicyService';
|
||||
import {getCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
|
||||
type ClauseAction = Exclude<RequiredAction, 'REQUIRE_INBOUND_PHONE_VERIFICATION'>;
|
||||
type VerificationChannel = 'email' | 'phone';
|
||||
@@ -123,8 +130,18 @@ function getRequiredActionSortIndex(action: RequiredAction): number {
|
||||
return index === -1 ? REQUIRED_ACTION_ORDER.length : index;
|
||||
}
|
||||
|
||||
function suppressDeferredPhoneFlags(rawFlags: number): number {
|
||||
if ((rawFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0) {
|
||||
return rawFlags;
|
||||
}
|
||||
if (!getCachedDeferredPhoneGateEnabled()) {
|
||||
return rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN;
|
||||
}
|
||||
return rawFlags & ~DEFERRABLE_PHONE_FLAGS;
|
||||
}
|
||||
|
||||
export function getRequiredActions(user: User): ReadonlyArray<RequiredAction> {
|
||||
const flags = user.suspiciousActivityFlags ?? 0;
|
||||
const flags = suppressDeferredPhoneFlags(user.suspiciousActivityFlags ?? 0);
|
||||
if (flags === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, imposePhoneRequirements} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -299,7 +300,7 @@ export class UserAccountRequestService {
|
||||
action: emailSetRecommendedAction,
|
||||
},
|
||||
});
|
||||
nextSuspiciousFlags |= policyDecision.flagBits;
|
||||
nextSuspiciousFlags = imposePhoneRequirements(nextSuspiciousFlags, policyDecision.flagBits);
|
||||
if (nextSuspiciousFlags !== currentSuspiciousFlags) {
|
||||
user = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
@@ -550,7 +551,7 @@ export class UserAccountRequestService {
|
||||
}
|
||||
|
||||
private shouldSkipFollowupRiskChecks(user: User): boolean {
|
||||
return user.hasEverPurchased || user.suspiciousActivityFlags === 0;
|
||||
return user.hasEverPurchased || ((user.suspiciousActivityFlags ?? 0) & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN) === 0;
|
||||
}
|
||||
|
||||
private enforceUserAccess(user: User): void {
|
||||
|
||||
@@ -11,7 +11,7 @@ const client: HttpClient = createHttpClient({
|
||||
});
|
||||
const scopedClients = new Map<RequestUrlPolicy, Map<number, HttpClient>>();
|
||||
|
||||
export interface SendRequestOptions {
|
||||
interface SendRequestOptions {
|
||||
maxRedirects?: number;
|
||||
requestUrlPolicy?: RequestUrlPolicy;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
try {
|
||||
await deps.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {imposePhoneRequirements, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {AdminAuditService} from '../../../admin/services/AdminAuditService';
|
||||
@@ -58,7 +58,7 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const user = await deps.userRepository.findUnique(userId);
|
||||
if (!user) throw new Error('user_not_found');
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = (currentFlags | addMask) & ~removeMask;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await deps.userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"animejs": "4.5.0",
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"codemirror": "^6.0.2",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative, resolve} from 'node:path';
|
||||
import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract';
|
||||
|
||||
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
|
||||
|
||||
@@ -407,6 +408,11 @@ function renderValueMap(name: string, values: ReadonlyMap<string, string>): stri
|
||||
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
|
||||
}
|
||||
|
||||
function renderNameUnion(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n');
|
||||
return `export type ThemeVariableName =\n${body};`;
|
||||
}
|
||||
|
||||
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions
|
||||
.map(
|
||||
@@ -439,6 +445,8 @@ export interface ThemeVariableDefinition {
|
||||
\tsource: string;
|
||||
}
|
||||
|
||||
${renderNameUnion(definitions)}
|
||||
|
||||
${renderDefinitions(definitions)}
|
||||
|
||||
${renderStringArray(
|
||||
@@ -456,9 +464,361 @@ ${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
|
||||
`;
|
||||
}
|
||||
|
||||
interface SkeletonSurfaceInvariant {
|
||||
file: string;
|
||||
requires?: ReadonlyArray<string>;
|
||||
requiresPattern?: ReadonlyArray<string>;
|
||||
forbids?: ReadonlyArray<string>;
|
||||
forbidsPattern?: ReadonlyArray<string>;
|
||||
counts?: Readonly<Record<string, number>>;
|
||||
minimums?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)';
|
||||
|
||||
const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
|
||||
{
|
||||
file: 'src/app/globals.css',
|
||||
counts: {
|
||||
'--skeleton-chrome-border-color': 2,
|
||||
'--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1,
|
||||
'--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1,
|
||||
'--chat-horizontal-padding-default: 1rem;': 1,
|
||||
'--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1,
|
||||
'--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/ChatSkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
forbids: ['scopeBadge', 'scope-badge'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/ChatSkeleton.tsx',
|
||||
requires: [
|
||||
"import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';",
|
||||
'composerWrapperStyles.composerRoot',
|
||||
"'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),",
|
||||
"'--font-size': remFromPx(messagePresentation.fontSizePx),",
|
||||
"'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),",
|
||||
"'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),",
|
||||
],
|
||||
forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
forbids: ['.activeNowPreview'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx',
|
||||
forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'],
|
||||
requires: ['function resolveFriendsListSections('],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css',
|
||||
requires: [
|
||||
'.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}',
|
||||
'.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}',
|
||||
'.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}',
|
||||
'background-color: var(--guilds-layout-item-bg);',
|
||||
".item[data-selected='true'] .fluxerIcon",
|
||||
'clip-path: inset(0);',
|
||||
'\ttransform: translateY(0rem);',
|
||||
],
|
||||
forbids: ['--guild-list-indicator-', '::before'],
|
||||
counts: {'z-index: 0;': 3, 'z-index: 1;': 3},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx',
|
||||
requires: [
|
||||
"import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';",
|
||||
'guildStyles.guildIndicator',
|
||||
'guildStyles.guildIndicatorBar',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
'const InlineDMPlaceholder = ',
|
||||
],
|
||||
requiresPattern: ['<ChatCircleIcon[^>]*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'],
|
||||
forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'],
|
||||
counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/ui/components/Scroller.module.css',
|
||||
counts: {'clip-path: inset(0);': 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/MemberListSkeleton.tsx',
|
||||
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
|
||||
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
requires: [
|
||||
'.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}',
|
||||
'--layout-user-area-overlay-height',
|
||||
'\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);',
|
||||
'.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;',
|
||||
'.guildIndicator {',
|
||||
'.guildIndicatorBar {',
|
||||
],
|
||||
forbids: ['.fluxerSymbolIcon'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.tsx',
|
||||
forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'],
|
||||
counts: {'scrollNode.scrollTop = ': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/discovery/discovery/DiscoveryPage.tsx',
|
||||
counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/LongPressable.ts',
|
||||
forbids: ['useImperativeHandle'],
|
||||
requires: ['useMergeRefs'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts',
|
||||
requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'],
|
||||
forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'],
|
||||
counts: {
|
||||
'function resolveGuildListIndicatorHeight': 1,
|
||||
'export function resolveGuildListIndicatorBarTarget': 1,
|
||||
'export ': 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'ChatCircleIcon',
|
||||
'styles.messageBubbleIcon',
|
||||
'styles.contextMenuHover',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
],
|
||||
forbids: ['scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx',
|
||||
requires: ['resolveGuildListIndicatorBarTarget('],
|
||||
forbids: ['scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'guildStyles.contextMenuHover',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
],
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});',
|
||||
"const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});",
|
||||
"to={{opacity: 1, translateY: '0rem'}}",
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
'guildStyles.guildIndicatorBar',
|
||||
],
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css',
|
||||
forbids: ['.folderIndicator'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx',
|
||||
requires: [
|
||||
'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);',
|
||||
'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;',
|
||||
],
|
||||
forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx',
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/ChannelMembers.tsx',
|
||||
forbids: ['@tanstack/react-virtual'],
|
||||
requires: ['styles.virtualRow'],
|
||||
},
|
||||
];
|
||||
|
||||
const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray<string> = [
|
||||
'src/features/app/components/skeleton',
|
||||
'src/features/channel/components',
|
||||
'src/features/user/components/profile',
|
||||
];
|
||||
const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css';
|
||||
const BORDER_DECLARATION_PATTERN =
|
||||
/(?<![\w-])border(?:-(?:top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?\s*:\s*([^;}]+)/g;
|
||||
const RAW_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/;
|
||||
|
||||
function collectSkeletonStylesheets(appDir: string): ReadonlyArray<string> {
|
||||
const files: Array<string> = [];
|
||||
const visit = (directory: string) => {
|
||||
for (const entry of readdirSync(directory, {withFileTypes: true})) {
|
||||
const absolutePath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(SKELETON_STYLESHEET_SUFFIX)) continue;
|
||||
files.push(toPosixPath(relative(appDir, absolutePath)));
|
||||
}
|
||||
};
|
||||
for (const directory of SKELETON_STYLESHEET_DIRECTORIES) {
|
||||
visit(join(appDir, directory));
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
interface SkeletonSharedRuleSource {
|
||||
readonly file: string;
|
||||
readonly selectors: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray<SkeletonSharedRuleSource> = [
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
selectors: ['.guildIndicator', '.guildIndicatorBar'],
|
||||
},
|
||||
];
|
||||
const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
||||
|
||||
function assertSkeletonSharedRuleTokens(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
const declaredTokens = new Set<string>(SKELETON_SURFACE_TOKENS);
|
||||
for (const source of SKELETON_SHARED_RULE_SOURCES) {
|
||||
const contents = readFileSync(join(appDir, source.file), 'utf8');
|
||||
for (const selector of source.selectors) {
|
||||
const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents);
|
||||
if (rule == null) {
|
||||
violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`);
|
||||
continue;
|
||||
}
|
||||
for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) {
|
||||
const token = match[1];
|
||||
if (declaredTokens.has(token)) continue;
|
||||
violations.push(
|
||||
`${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` +
|
||||
'list it in SKELETON_SURFACE_TOKENS.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g;
|
||||
|
||||
function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array<string>): void {
|
||||
const file = 'src/app/globals.css';
|
||||
const contents = readFileSync(join(appDir, file), 'utf8');
|
||||
for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) {
|
||||
const value = match[1].trim();
|
||||
if (value.startsWith('var(--background-modifier-accent')) {
|
||||
violations.push(
|
||||
`${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` +
|
||||
'skeleton chrome must use the softened colour, never the raw accent.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSkeletonBorderTokens(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
assertSkeletonChromeBorderColourIsSoftened(appDir, violations);
|
||||
for (const file of collectSkeletonStylesheets(appDir)) {
|
||||
const contents = readFileSync(join(appDir, file), 'utf8');
|
||||
if (contents.includes('--skeleton-chrome-border-color')) {
|
||||
violations.push(
|
||||
`${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`,
|
||||
);
|
||||
}
|
||||
for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) {
|
||||
const value = match[1].trim();
|
||||
if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) {
|
||||
violations.push(
|
||||
`${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` +
|
||||
'skeleton borders must reference a shared token.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSkeletonSurfaceInvariants(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
for (const invariant of SKELETON_SURFACE_INVARIANTS) {
|
||||
const path = join(appDir, invariant.file);
|
||||
if (!existsSync(path)) {
|
||||
violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`);
|
||||
continue;
|
||||
}
|
||||
const contents = readFileSync(path, 'utf8');
|
||||
for (const required of invariant.requires ?? []) {
|
||||
if (!contents.includes(required)) {
|
||||
violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of invariant.forbids ?? []) {
|
||||
if (contents.includes(forbidden)) {
|
||||
violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`);
|
||||
}
|
||||
}
|
||||
for (const source of invariant.requiresPattern ?? []) {
|
||||
if (!new RegExp(source, 'u').test(contents)) {
|
||||
violations.push(`${invariant.file} must match /${source}/.`);
|
||||
}
|
||||
}
|
||||
for (const source of invariant.forbidsPattern ?? []) {
|
||||
if (new RegExp(source, 'u').test(contents)) {
|
||||
violations.push(`${invariant.file} must not match /${source}/.`);
|
||||
}
|
||||
}
|
||||
for (const [needle, expected] of Object.entries(invariant.counts ?? {})) {
|
||||
const actual = contents.split(needle).length - 1;
|
||||
if (actual !== expected) {
|
||||
violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`);
|
||||
}
|
||||
}
|
||||
for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) {
|
||||
const actual = contents.split(needle).length - 1;
|
||||
if (actual < minimum) {
|
||||
violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = resolve(scriptDir, '..');
|
||||
assertSkeletonSurfaceInvariants(appDir);
|
||||
assertSkeletonSharedRuleTokens(appDir);
|
||||
assertSkeletonBorderTokens(appDir);
|
||||
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
|
||||
const contents = render(appDir);
|
||||
if (process.argv.includes('--check')) {
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
import {promises as fs} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type {PrecacheEntry} from '@app/features/platform/service_worker/WorkerAppShell';
|
||||
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
interface PrecacheEntry {
|
||||
url: string;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
const PRECACHE_ROOT_FILES = ['index.html', 'manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
const PRECACHE_ROOT_FILES = ['manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
|
||||
const NEVER_PRECACHED_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
||||
|
||||
@@ -38,11 +34,7 @@ async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
for (const file of PRECACHE_ROOT_FILES) {
|
||||
const filePath = path.join(DIST_DIR, file);
|
||||
try {
|
||||
const revision = await fileRevision(filePath);
|
||||
entries.set(`/${file}`, revision);
|
||||
if (file === 'index.html') {
|
||||
entries.set('/', revision);
|
||||
}
|
||||
entries.set(`/${file}`, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -19,11 +19,8 @@ import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import {usePlatformClasses} from '@app/features/app/hooks/usePlatformClasses';
|
||||
import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBadge';
|
||||
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
@@ -35,6 +32,7 @@ import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLoca
|
||||
import {PremiumCheckoutReturnWatcher} from '@app/features/premium/components/PremiumCheckoutReturnWatcher';
|
||||
import {QUICK_SWITCHER_PORTAL_ID} from '@app/features/search/components/quick_switcher/QuickSwitcherConstants';
|
||||
import {useCustomThemeStyle} from '@app/features/theme/hooks/useCustomThemeStyle';
|
||||
import {useRemScaleTracking} from '@app/features/theme/hooks/useRemScaleTracking';
|
||||
import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import ThemeLibrary from '@app/features/theme/state/ThemeLibrary';
|
||||
@@ -127,12 +125,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, []),
|
||||
);
|
||||
const handleSkipLinkFocus = useTabKeyFocusGuard();
|
||||
const isSplashScreenActive =
|
||||
Authentication.isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
|
||||
useInertBackground(ringsContainerRef, hasBlockingModal || topPopoutRequiresBackdrop);
|
||||
useInertBackground(overlayScopeRef, isSplashScreenActive);
|
||||
useEffect(() => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
@@ -177,6 +170,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, [isNative, firstClickPassThroughWhenUnfocused]);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
usePlatformClasses(platform, isNative);
|
||||
useThemeCssVariables({
|
||||
effectiveTheme: Theme.effectiveTheme,
|
||||
|
||||
@@ -103,6 +103,25 @@ var {
|
||||
--input-wrapper-padding-bottom: 0.5rem;
|
||||
--textarea-top-bar-height: 2.5rem;
|
||||
|
||||
--chat-horizontal-padding-default: 1rem;
|
||||
--message-compact-horizontal-padding: min(
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)),
|
||||
1rem
|
||||
);
|
||||
--textarea-font-size: var(--font-size, 1rem);
|
||||
--textarea-button-height: 2rem;
|
||||
--textarea-button-icon-size: 1.375rem;
|
||||
--textarea-button-compact-height: 2rem;
|
||||
--textarea-button-compact-icon-size: 1.25rem;
|
||||
--textarea-container-padding-x: 0px;
|
||||
--textarea-line-height: calc(var(--textarea-font-size) * 1.375);
|
||||
--textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2));
|
||||
--textarea-upload-gap: var(--message-gutter, 1rem);
|
||||
--textarea-side-button-padding: max(
|
||||
0px,
|
||||
calc((var(--message-avatar-size, 2.5rem) - var(--textarea-button-height)) / 2)
|
||||
);
|
||||
|
||||
--footer-box-height: 3.625rem;
|
||||
--footer-box-inset: 0.375rem;
|
||||
--footer-box-inset-inline: var(--footer-box-inset);
|
||||
@@ -111,8 +130,38 @@ var {
|
||||
--outline-frame-border-width: 0.0625rem;
|
||||
--composer-mobile-box-height: 3rem;
|
||||
--composer-action-gap: 0.25rem;
|
||||
--composer-surface-color: var(--background-secondary-lighter);
|
||||
--composer-status-line-height: 1.125rem;
|
||||
--composer-status-safe-gap: 0.5rem;
|
||||
--composer-status-safe-area: calc(
|
||||
var(--composer-status-line-height) +
|
||||
var(--composer-status-safe-gap) +
|
||||
var(--composer-status-safe-gap)
|
||||
);
|
||||
--guild-list-item-box-size: 3rem;
|
||||
--guild-list-item-gap: 0.375rem;
|
||||
--guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
--guild-list-item-target-half-extra: calc((var(--guild-list-item-target-size) - var(--guild-list-item-box-size)) / 2);
|
||||
--guild-folder-expanded-surface-size: var(--guild-list-item-box-size);
|
||||
--folder-radius: calc(var(--guild-folder-expanded-surface-size) * 0.3);
|
||||
--mention-badge-small-size: 1.25rem;
|
||||
--mention-badge-medium-size: 1.5rem;
|
||||
--outline-frame-radius-native: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
--guild-list-divider-line-size: 0.125rem;
|
||||
--guild-list-divider-base-gap: var(--guild-list-item-gap);
|
||||
--guild-members-columns: minmax(15rem, 2.2fr) minmax(7.5rem, 1fr) minmax(7.5rem, 1fr) minmax(9.375rem, 1.15fr)
|
||||
minmax(10rem, 1.35fr) 4.5rem;
|
||||
--guild-members-min-width: calc(15rem + 7.5rem + 7.5rem + 9.375rem + 10rem + 4.5rem);
|
||||
--guild-members-select-column-width: 2.75rem;
|
||||
--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);
|
||||
--guild-members-min-width-selectable: calc(var(--guild-members-select-column-width) + var(--guild-members-min-width));
|
||||
--floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent);
|
||||
--floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent);
|
||||
--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);
|
||||
--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);
|
||||
--skeleton-opacity-strong: 0.55;
|
||||
--skeleton-opacity-default: 0.45;
|
||||
--skeleton-opacity-muted: 0.35;
|
||||
|
||||
--footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2));
|
||||
--composer-mobile-padding-y: max(
|
||||
@@ -124,10 +173,14 @@ var {
|
||||
--textarea-min-height: var(--footer-box-height);
|
||||
--textarea-padding-y: var(--footer-box-padding-y);
|
||||
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
|
||||
--composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4)));
|
||||
--messages-bottom-clearance: var(--composer-status-safe-area);
|
||||
--composer-box-inset-inline: min(
|
||||
var(--footer-box-inset),
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))
|
||||
);
|
||||
--composer-box-padding-inline: max(
|
||||
0rem,
|
||||
calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline))
|
||||
calc(var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) - var(--composer-box-inset-inline))
|
||||
);
|
||||
|
||||
--typing-indicator-height: 1rem;
|
||||
@@ -167,6 +220,7 @@ var {
|
||||
--layout-header-height: 3.5rem;
|
||||
--layout-user-area-height: var(--footer-box-height);
|
||||
--layout-user-area-reserved-height: 0px;
|
||||
--layout-voice-connection-height: 0px;
|
||||
--layout-mobile-bottom-nav-reserved-height: 0px;
|
||||
--user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width));
|
||||
--user-area-content-height: var(--textarea-button-height, 2rem);
|
||||
@@ -193,7 +247,94 @@ var {
|
||||
|
||||
--mobile-bottom-nav-height: 3.75rem;
|
||||
|
||||
--message-compact-text-indent-from-username: 2rem;
|
||||
--guilds-layout-item-bg: var(--guild-list-foreground);
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
--layout-member-list-width: 16.5rem;
|
||||
--layout-header-height-mobile: 4rem;
|
||||
|
||||
--channel-header-gap: var(--spacing-4);
|
||||
--channel-header-padding-inline: var(--spacing-4);
|
||||
--channel-header-action-size: 2rem;
|
||||
--channel-header-action-size-mobile: 2.5rem;
|
||||
--channel-header-actions-gap: var(--spacing-2);
|
||||
--channel-header-back-button-size: 2rem;
|
||||
--channel-header-back-button-gap: var(--spacing-3);
|
||||
--channel-header-icon-size: 1.5rem;
|
||||
--channel-header-back-icon-size: 1.5rem;
|
||||
--channel-header-caret-size: 1rem;
|
||||
--channel-header-action-icon-size: 1.5rem;
|
||||
--channel-header-name-gap: var(--spacing-3);
|
||||
--channel-header-name-gap-group-dm: var(--spacing-2);
|
||||
--channel-header-caret-gap: var(--spacing-1);
|
||||
--channel-header-topic-divider-gap: var(--spacing-2);
|
||||
|
||||
--chat-mobile-horizontal-padding: 0.75rem;
|
||||
--dm-sidebar-mobile-header-height: 3.5rem;
|
||||
|
||||
--message-search-bar-width: 15.25rem;
|
||||
--message-search-bar-height: 2.25rem;
|
||||
--message-search-bar-icon-size: 1rem;
|
||||
--message-search-bar-icon-gap: 0.5rem;
|
||||
|
||||
--member-list-item-content-height: 2rem;
|
||||
--member-list-item-padding-block: 0.25rem;
|
||||
--member-list-item-padding-inline: 0.5rem;
|
||||
--member-list-item-content-gap: 0.625rem;
|
||||
--member-list-item-name-line-height: 1.25rem;
|
||||
--member-list-item-status-line-height: 0.875rem;
|
||||
--member-list-row-gap: 0.125rem;
|
||||
--member-list-group-spacer-height: 0.25rem;
|
||||
--member-list-scroller-padding-block-start: 0.25rem;
|
||||
--member-list-scroller-padding-block-end: var(--spacing-4);
|
||||
--member-list-scroller-padding-inline: var(--spacing-2);
|
||||
|
||||
--friend-row-padding-block: 0.75rem;
|
||||
--friend-row-padding-inline: 1rem;
|
||||
--friend-row-separator-inset: 0 0.4rem 0 var(--spacing-2);
|
||||
--friend-row-separator-radius: 0.375rem;
|
||||
--friend-row-content-gap: 0.75rem;
|
||||
--friend-row-name-line-height: 1.25rem;
|
||||
--friend-row-subtext-line-height: 1rem;
|
||||
--friend-row-subtext-offset: -0.0625rem;
|
||||
--friend-row-avatar-size: 2.25rem;
|
||||
--friend-row-action-size: 2.25rem;
|
||||
--friend-row-action-gap: 0.5rem;
|
||||
|
||||
--avatar-stack-extra-height: 0.375rem;
|
||||
|
||||
--active-now-sidebar-width: 22rem;
|
||||
--active-now-sidebar-header-padding-inline: 1rem;
|
||||
--active-now-sidebar-header-padding-block-start: 1rem;
|
||||
--active-now-sidebar-header-padding-block-end: 0.75rem;
|
||||
--active-now-sidebar-content-gap: 0.5rem;
|
||||
--active-now-sidebar-content-padding-inline: 0.75rem;
|
||||
--active-now-sidebar-content-padding-block-end: 0.75rem;
|
||||
--active-now-card-gap: 0.75rem;
|
||||
--active-now-card-padding-block: 0.75rem;
|
||||
--active-now-card-padding-inline: 0.875rem;
|
||||
--active-now-card-radius: var(--radius-md);
|
||||
--active-now-card-shadow: 0 0.375rem 1.125rem rgba(0, 0, 0, 0.08);
|
||||
--active-now-context-gap: 0.375rem;
|
||||
--active-now-action-height: 2rem;
|
||||
--active-now-action-radius: 0.5rem;
|
||||
--active-now-action-gap: 0.375rem;
|
||||
--active-now-action-padding-block: 0.3125rem;
|
||||
--active-now-action-padding-inline: 0.625rem;
|
||||
|
||||
--discovery-card-description-min-height: 5.25rem;
|
||||
--guild-list-indicator-inset: -0.15rem;
|
||||
--guild-list-indicator-track-width: 0.5rem;
|
||||
--guild-list-indicator-bar-width: 0.35rem;
|
||||
--count-indicator-gap: 0.25rem;
|
||||
|
||||
--guild-members-table-row-min-height: 3rem;
|
||||
--guild-members-table-row-border-width: 0.0625rem;
|
||||
|
||||
--form-surface-background: var(--background-tertiary);
|
||||
--message-preview-card-background: hsla(0, 0%, 100%, 0.022);
|
||||
--message-preview-card-border: hsla(0, 0%, 100%, 0.07);
|
||||
--message-preview-card-divider: hsla(0, 0%, 100%, 0.05);
|
||||
--surface-interactive-hover-bg: var(--background-modifier-hover);
|
||||
--surface-interactive-selected-bg: var(--background-modifier-selected);
|
||||
--surface-interactive-selected-color: var(--text-primary);
|
||||
@@ -209,7 +350,11 @@ html.platform-native.platform-macos {
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);
|
||||
--form-surface-background: var(--background-primary);
|
||||
--message-preview-card-background: hsl(0, 0%, 100%);
|
||||
--message-preview-card-border: hsla(0, 0%, 0%, 0.07);
|
||||
--message-preview-card-divider: hsla(0, 0%, 0%, 0.05);
|
||||
--surface-interactive-hover-bg: var(--background-modifier-hover);
|
||||
--surface-interactive-selected-bg: var(--background-modifier-selected);
|
||||
--surface-interactive-selected-color: var(--text-primary);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user