Compare commits

...
Author SHA1 Message Date
HampusandGitHub 528777926c fix(api): stop one unreachable community from breaking bookmarks (#1779) 2026-08-20 15:50:51 +02:00
HampusandGitHub 47b5c3d4f0 fix(app): expose the guild id on guild list items again (#1778) 2026-08-20 14:39:24 +02:00
HampusandGitHub d9bfca66d6 fix(app): keep search results in server order instead of per channel (#1777) 2026-08-20 13:10:47 +02:00
HampusandGitHub ded51b65d3 fix(app): restore the mobile voice message recorder (#1776) 2026-08-20 04:33:24 +02:00
HampusandGitHub ddc8837d4f fix(app): scale embed and attachment width caps with zoom (#1775) 2026-08-20 04:33:17 +02:00
HampusandGitHub df16957c95 fix(app): scale tooltip max-width with app zoom (#1774) 2026-08-20 04:33:10 +02:00
HampusandGitHub f38b19d4bd fix(app): let the caret move past emoji and mentions in the composer (#1768) 2026-08-19 23:41:05 +02:00
HampusandGitHub f7cd4f2c74 docs(operator): explain how to reclaim space after upgrades (#1767) 2026-08-19 23:31:15 +02:00
a078392888 chore(i18n): update public marketing catalogs (#1719)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-19 22:46:33 +02:00
HampusandGitHub 3060f23f8c chore(marketing): bump the marketing submodule pointer (#1766) 2026-08-19 22:29:10 +02:00
HampusandGitHub 6a5f0d4d29 fix(i18n): simplify the marketing translation and saved media strings (#1764) 2026-08-19 22:25:01 +02:00
HampusandGitHub 91d989dc7c fix(app): point the language notice at Weblate instead of email (#1763) 2026-08-19 22:05:39 +02:00
HampusandGitHub 7c82804df6 fix(app): stop sensitive content options overlapping in long locales (#1762) 2026-08-19 19:53:46 +02:00
HampusandGitHub b7de83f7fc fix(app): stop the Firefox microphone failure and self-mute storm (#1761) 2026-08-19 19:32:41 +02:00
HampusandGitHub 97bd022bee fix(app): render the discovery language icon at bold weight (#1760) 2026-08-19 19:08:48 +02:00
HampusandGitHub 62324df039 feat(app): add a language filter button to the discovery navbar (#1759) 2026-08-19 18:46:39 +02:00
HampusandGitHub 9f78b3d9a6 feat(app): retry failed image loads with escalating delay and connectivity waits (#1758) 2026-08-19 18:25:34 +02:00
HampusandGitHub 362a89f2b2 fix(app): keep embed icons mounted so they reserve space while loading (#1757) 2026-08-19 18:00:08 +02:00
HampusandGitHub ce41960fcd perf(app): load embed author and footer icons through the image cache (#1756) 2026-08-19 17:38:00 +02:00
HampusandGitHub 2083eaddd6 feat(app): wrap pasted links in every composer that allows masked links (#1755) 2026-08-19 17:19:39 +02:00
HampusandGitHub d398ebc44b test: drop desktop test files that no runner executes (#1753) 2026-08-19 14:58:59 +02:00
HampusandGitHub 59887ad404 fix(app): wrap pasted links from any source, not just Fluxer's own clipboard (#1752) 2026-08-19 14:57:13 +02:00
HampusandGitHub 5aa283e8e0 fix(desktop): repair a stale Linux .desktop entry instead of preserving it (#1751) 2026-08-19 14:56:29 +02:00
HampusandGitHub d9ed256a4b fix(desktop): pin Electron to 43.4.0 to restore KDE and XFCE tray icons (#1750) 2026-08-19 14:45:58 +02:00
HampusandGitHub a297f89b83 fix(markdown): parse a table that follows a text line without a blank line (#1749) 2026-08-19 14:24:33 +02:00
HampusandGitHub 4a16921242 fix(app): stop macOS window chrome from swallowing clicks below the titlebar (#1748) 2026-08-19 13:05:42 +02:00
HampusandGitHub a6f83c4fb1 feat(app): wrap the selection in a link when pasting a URL (#1747) 2026-08-19 12:55:15 +02:00
HampusandGitHub 7b5c82c6cf fix(app): keep quick switcher focus when results are recomputed (#1746) 2026-08-19 12:55:04 +02:00
HampusandGitHub 30a61ce90f chore(i18n): refresh catalogs and translate new strings (#1745) 2026-08-19 12:46:51 +02:00
HampusandGitHub 22bc2cab74 fix(app): offset toasts below the window chrome on macOS (#1744) 2026-08-19 12:42:08 +02:00
HampusandGitHub 53df9a0d6d fix(app): include remainder seconds in slowmode durations (#1743) 2026-08-19 12:41:56 +02:00
HampusandGitHub f9b7ec4d0b fix(app): stop the bio editor placeholder from overflowing (#1742) 2026-08-19 12:41:44 +02:00
HampusandGitHub 569abf57b7 fix(app): keep the edited marker on attachment-only messages (#1741) 2026-08-19 12:41:31 +02:00
HampusandGitHub ae8e32d809 fix(app): render modals above a fullscreen voice call (#1740) 2026-08-19 12:41:19 +02:00
HampusandGitHub a81b1abec7 fix(api): reject alt text edits on forwarded messages (#1739) 2026-08-19 12:41:07 +02:00
HampusandGitHub 8836565c32 fix(app): keep the guild verification timer stable across switches (#1738) 2026-08-19 12:40:55 +02:00
HampusandGitHub a1b595d52f fix(app): apply the current system theme when relaunching (#1737) 2026-08-19 12:40:43 +02:00
HampusandGitHub abb71ed558 fix(app): prompt for a restart when the system titlebar toggle changes (#1736) 2026-08-19 12:40:32 +02:00
HampusandGitHub c006d413ac fix(desktop): bump Electron to 43.4.1 to restore the Linux tray icon (#1735) 2026-08-19 12:40:20 +02:00
HampusandGitHub fa11acae15 fix(desktop): use the masked icon for AppImage desktop integration (#1734) 2026-08-19 12:40:09 +02:00
HampusandGitHub 300f467ad0 fix(desktop): stop the AppImage adding a duplicate menu entry each launch (#1733) 2026-08-19 12:39:57 +02:00
HampusandGitHub 698469fa96 perf(app): defer media capture routing and skip offscreen skeleton layout (#1732) 2026-08-19 02:58:01 +02:00
HampusandGitHub 591e9fe2ba fix(api): only finalize self-serve refunds once the provider confirms (#1731) 2026-08-19 02:57:58 +02:00
HampusandGitHub d148b4e5b7 feat(app): show 25 unread messages per channel in the inbox (#1729) 2026-08-19 01:06:00 +02:00
HampusandGitHub 324f333bb5 test: drop stale hosted instance config and voice engine boundary tests (#1728) 2026-08-19 00:48:06 +02:00
HampusandGitHub 9b0b703c9d fix(api): identify session clients correctly and attribute handoff sessions (#1727) 2026-08-19 00:41:29 +02:00
HampusandGitHub 5660972c71 perf(app): cut idle renderer CPU and unbounded memory growth (#1724) 2026-08-18 23:13:42 +02:00
HampusandGitHub b4a5eb77a8 docs: fix community health document links broken by the .github move (#1723) 2026-08-18 20:23:40 +02:00
HampusandGitHub 4bbfee4cec fix(app): make click-through the default and move animation pausing to Accessibility (#1722) 2026-08-18 19:40:35 +02:00
HampusandGitHub 9e89539f0a perf(app): stop unbounded WASM heap growth and idle-CPU churn (#1721) 2026-08-18 18:31:28 +02:00
fa71eb8682 chore(marketing): advance pointer 9720a17 → a31164c (#1717)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-18 16:50:27 +02:00
HampusandGitHub 49b7127bc7 chore(static): regenerate marketing screenshots and README showcase (#1718) 2026-08-18 16:50:23 +02:00
HampusandGitHub 9cb8812be0 fix(app-proxy): collapse the discovery cold-start retry condition (#1716) 2026-08-18 16:12:33 +02:00
HampusandGitHub 7d82d188a0 fix(gateway): register the session process with presence on resume (#1715) 2026-08-18 16:06:26 +02:00
HampusandGitHub 5ce5669f17 fix(app): allow the inbox shortcut while the composer is empty (#1714) 2026-08-18 14:31:23 +02:00
HampusandGitHub 9ea750152e fix(app-proxy): serve the SPA from cached discovery instead of blocking on refresh (#1713) 2026-08-18 06:07:53 +02:00
HampusandGitHub e87e2fe7bf fix(admin): show the deferred phone gate controls on hosted instances (#1712) 2026-08-18 05:49:33 +02:00
HampusandGitHub 871b5116dc fix(media-proxy): stop sending X-Robots-Tag in static mode (#1710) 2026-08-18 05:21:10 +02:00
HampusandGitHub d7b2e67c35 feat(api): defer registration phone verification to community joins (#1709) 2026-08-18 05:13:41 +02:00
301 changed files with 12695 additions and 10178 deletions
+4 -4
View File
@@ -25,7 +25,7 @@ Closes #456
You must understand every line you submit and be able to explain why the change is correct.
The [LLM usage policy](LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
The [LLM usage policy](https://github.com/fluxerapp/fluxer/blob/main/.github/LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
Each contribution must contain one coherent change. Do not include unrelated fixes, refactoring or formatting changes.
@@ -80,15 +80,15 @@ Complete every section of the pull request template. Clearly describe:
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report security vulnerabilities privately through the channels specified in the [security policy](SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
## Private marketing project
+2 -2
View File
@@ -8,11 +8,11 @@ External contributions do not grant voting rights, commit access, employment or
## Licence and contributor rights
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). The licence permits its use, modification and redistribution subject to its terms.
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). The licence permits its use, modification and redistribution subject to its terms.
Fluxer Platform AB does not require contributors to sign a contributor licence agreement or assign their copyright. Contributors retain the copyright in their work.
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](https://github.com/fluxerapp/fluxer/blob/main/.github/CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
## Name and marks
+1 -1
View File
@@ -129,7 +129,7 @@ Deliberately submitting a fabricated security report MAY result in an immediate
Maintainers are not required to investigate possible LLM use proactively. Writing style alone is not evidence of a violation.
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](CODE_OF_CONDUCT.md).
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
## 11. Normative References
+1 -1
View File
@@ -31,5 +31,5 @@
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
</p>
+31 -1
View File
@@ -12573,6 +12573,15 @@
"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": [
@@ -12583,7 +12592,8 @@
"premium_mode",
"services",
"services_resolved",
"services_available"
"services_available",
"deferred_phone_gate"
]
},
"integrations": {
@@ -13110,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
}
}
}
}
}
@@ -14879,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"},
@@ -14919,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",
+1
View File
@@ -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,
+2
View File
@@ -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>,
@@ -37,6 +37,28 @@ 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 {
@@ -50,6 +72,7 @@ impl Default for InstancePolicyResponse {
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -516,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)]
+28 -1
View File
@@ -7,7 +7,7 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
@@ -547,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,
@@ -558,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),
@@ -712,6 +738,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
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 {
@@ -283,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."
}
}
}
}
}
+1 -35
View File
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
}
}
#[tokio::test]
async fn hosted_instance_config_hides_self_host_setup_controls() {
let app = setup().await;
let body = get(&app, "/instance-config", &[]).await;
assert_full_layout(&body);
assert!(body.contains("Registration Controls"), "{body}");
assert!(body.contains("Runtime Integrations"), "{body}");
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
assert!(!body.contains("Public App Identity"), "{body}");
assert!(!body.contains("Setup complete"), "{body}");
assert!(!body.contains("Community & Policy"), "{body}");
assert!(!body.contains("Single community"), "{body}");
assert!(!body.contains("Direct messages &amp; friends"), "{body}");
assert!(!body.contains("Premium model"), "{body}");
assert!(!body.contains("Optional services"), "{body}");
assert!(!body.contains("Registration Fields"), "{body}");
assert!(
!body.contains("Collect date of birth during registration"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_app_public"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_app_registration"),
"{body}"
);
assert!(
!body.contains("/instance-config?action=update_policy"),
"{body}"
);
}
#[tokio::test]
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
let app = setup().await;
@@ -898,6 +863,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,
@@ -100,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,
@@ -591,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,11 +39,13 @@ 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 {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {mapUserToAdminResponse} from '../models/UserTypes';
import type {AdminAuditService} from './AdminAuditService';
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
@@ -406,11 +416,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 +431,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 +459,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 +630,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},
@@ -720,7 +750,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: Date;
clientIp: string;
clientUserAgent: string | null;
clientIsDesktop: boolean | null;
clientOs: string | null;
deletedAt: Date | null;
}> = [
...activeSessions.map((s) => ({
@@ -729,7 +759,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: s.approximateLastUsedAt,
clientIp: s.clientIp,
clientUserAgent: s.clientUserAgent,
clientIsDesktop: s.clientIsDesktop,
clientOs: s.clientOs ?? null,
deletedAt: null as Date | null,
})),
...tombstones.map((t) => ({
@@ -738,7 +768,7 @@ export class AdminUserSecurityService {
approximateLastUsedAt: t.approximateLastUsedAt,
clientIp: t.clientIp,
clientUserAgent: t.clientUserAgent,
clientIsDesktop: t.clientIsDesktop,
clientOs: t.clientOs ?? null,
deletedAt: t.deletedAt,
})),
];
@@ -747,6 +777,8 @@ export class AdminUserSecurityService {
if (a.deletedAt !== null && b.deletedAt === null) return 1;
return b.createdAt.getTime() - a.createdAt.getTime();
});
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
const productName = branding.product_name;
const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD);
if (!canViewIp) {
await auditService.createAuditLog({
@@ -759,9 +791,10 @@ export class AdminUserSecurityService {
});
return {
sessions: entries.map((entry) => {
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: entry.clientUserAgent,
isDesktopClient: entry.clientIsDesktop,
reportedOs: entry.clientOs,
productName,
});
return {
session_id_hash: entry.sessionIdHash.toString('base64url'),
@@ -769,8 +802,8 @@ export class AdminUserSecurityService {
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
client_ip: '[redacted]',
client_ip_reverse: null,
client_os: clientOs,
client_platform: clientPlatform,
client_os: clientInfo.os,
client_platform: clientInfo.platform,
client_location: null,
deleted_at: entry.deletedAt?.toISOString() ?? null,
};
@@ -807,9 +840,10 @@ export class AdminUserSecurityService {
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
const reverseDnsResult = reverseDnsResults[index];
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: entry.clientUserAgent,
isDesktopClient: entry.clientIsDesktop,
reportedOs: entry.clientOs,
productName,
});
return {
session_id_hash: entry.sessionIdHash.toString('base64url'),
@@ -817,8 +851,8 @@ export class AdminUserSecurityService {
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
client_ip: entry.clientIp,
client_ip_reverse: clientIpReverse,
client_os: clientOs,
client_platform: clientPlatform,
client_os: clientInfo.os,
client_platform: clientInfo.platform,
client_location: clientLocation,
deleted_at: entry.deletedAt?.toISOString() ?? null,
};
@@ -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,
+1 -13
View File
@@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) {
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined;
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
userAgent: ctx.req.header('User-Agent'),
clientIp,
clientPlatform,
}),
);
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
},
);
app.get(
@@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) {
});
await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
request: ctx.req.raw,
clientIp,
authToken: ctx.get('authToken') ?? undefined,
});
+4 -1
View File
@@ -100,7 +100,10 @@ export async function revertEmailChange(
event: 'USER_UPDATE',
data: mapUserToPrivateResponse(updatedUser),
});
const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
const [authToken] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
await contactChangeLog.recordDiff({
oldUser: user,
newUser: updatedUser,
+21 -29
View File
@@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache {
userId: string;
email: string;
username: string;
clientIp: string;
userAgent: string;
platform: string | null;
origin: AuthSession.SessionOrigin;
authToken: string;
clientLocation: string;
inviteCode?: string | null;
@@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache {
createdAt: number;
}
function getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket:${ticket}`;
export function getTicketCacheKey(ticket: string): string {
return `ip-auth-ticket-v2:${ticket}`;
}
function getTokenCacheKey(token: string): string {
@@ -148,7 +146,7 @@ export async function resendIpAuthorization(
payload.email,
payload.username,
payload.authToken,
payload.clientIp,
payload.origin.ip,
payload.clientLocation,
null,
);
@@ -172,7 +170,7 @@ export async function completeIpAuthorization(
user_id: string;
ticket: string;
}> {
const {users, cache, config} = ctx.services;
const {users, cache} = ctx.services;
const tokenMapping = await cache.get<{
ticket: string;
}>(getTokenCacheKey(token));
@@ -193,19 +191,8 @@ export async function completeIpAuthorization(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await users.createAuthorizedIp(user.id, payload.clientIp);
const headers: Record<string, string> = {
[config.proxy.client_ip_header]: payload.clientIp,
'user-agent': payload.userAgent,
};
if (payload.platform) {
headers['x-fluxer-platform'] = payload.platform;
}
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
headers,
method: 'POST',
});
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, request: syntheticRequest});
await users.createAuthorizedIp(user.id, payload.origin.ip);
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
await cache.delete(cacheKey);
await cache.delete(getTokenCacheKey(token));
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
@@ -313,15 +300,11 @@ export async function login(
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
const geoipResult = await lookupGeoip(clientIp);
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
const userAgent = request.headers.get('user-agent') || '';
const platform = request.headers.get('x-fluxer-platform');
const cachePayload: IpAuthorizationTicketCache = {
userId: currentUser.id.toString(),
email: currentUser.email!,
username: currentUser.username,
clientIp,
userAgent,
platform: platform ?? null,
origin: AuthSession.resolveSessionOrigin(ctx, request),
authToken,
clientLocation,
inviteCode: data.invite_code ?? null,
@@ -329,7 +312,7 @@ export async function login(
createdAt: Date.now(),
};
const ttlSeconds = seconds('15 minutes');
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
await cache.set<IpAuthorizationTicketCache>(getTicketCacheKey(ticket), cachePayload, ttlSeconds);
await cache.set<{
ticket: string;
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
@@ -364,7 +347,10 @@ export async function login(
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
}
}
const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user: currentUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {
user_id: currentUser.id.toString(),
token,
@@ -418,7 +404,10 @@ export async function loginMfaTotp(
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: user.id.toString(), token};
}
@@ -438,7 +427,10 @@ export async function loginMfaWebAuthn(
AuthUtility.assertNonBotUser(ctx, user);
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: user.id.toString(), token};
}
+12 -17
View File
@@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche
import {uint8ArrayToBase64} from 'uint8array-extras';
import {Config} from '../Config';
import {Logger} from '../Logger';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {AuthSession} from '../models/AuthSession';
import {getLocationLabelFromIp} from '../utils/IpUtils';
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden';
@@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({
const locationResults = await Promise.allSettled(
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
);
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
return sortedSessions.map((authSession, index): AuthSessionResponse => {
const locationResult = locationResults[index];
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
let clientOs: string;
let clientPlatform: string;
if (authSession.clientUserAgent) {
const parsed = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
isDesktopClient: authSession.clientIsDesktop,
});
clientOs = parsed.clientOs;
clientPlatform = parsed.clientPlatform;
} else {
clientOs = authSession.clientOs || 'Unknown';
clientPlatform = authSession.clientPlatform || 'Unknown';
}
const clientInfo = resolveSessionClientInfo({
userAgent: authSession.clientUserAgent,
reportedOs: authSession.clientOs ?? null,
productName: branding.product_name,
});
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
return {
id_hash: idHash,
client_info: {
platform: clientPlatform,
os: clientOs,
browser: undefined,
platform: clientInfo.platform,
os: clientInfo.os,
browser: clientInfo.browser,
device: clientInfo.device,
location: clientLocation
? {
city: clientLocation.split(',').at(0)?.trim() || null,
+4 -1
View File
@@ -275,7 +275,10 @@ export async function resetPassword(
if (hasMfa) {
return await createMfaTicketResponse(ctx, updatedUser);
}
const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user: updatedUser,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
return {user_id: updatedUser.id.toString(), token};
}
+6 -2
View File
@@ -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(),
@@ -443,7 +444,10 @@ export async function register(
);
}
await singleCommunityService.joinStockCommunity(userId, requestCache);
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
});
if (grantBootstrapAdmin) {
await instanceConfigRepository.markAdminBootstrapped();
}
+24 -23
View File
@@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {User} from '../models/User';
import {mapUserToPartialResponse} from '../user/UserMappers';
import {lookupGeoip} from '../utils/IpUtils';
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils';
import * as AuthEmail from './AuthEmail';
import * as AuthEmailRevert from './AuthEmailRevert';
@@ -89,7 +90,6 @@ interface AuthLogoutRequest {
interface AuthHandoffCompleteRequest {
data: HandoffCompleteRequest;
request: Request;
clientIp: string;
authToken?: string;
}
@@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest {
}
interface AuthHandoffInitiateRequest {
userAgent?: string;
clientIp: string;
clientPlatform?: string;
request: Request;
}
interface AuthHandoffInfoRequest {
@@ -263,7 +261,7 @@ export class AuthRequestService {
user: await this.getUserPartial(parsed.user_id),
};
}
const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`);
const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket));
if (!ticketPayload) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
}
@@ -276,7 +274,10 @@ export class AuthRequestService {
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
}
@@ -298,12 +299,9 @@ export class AuthRequestService {
return {suggestions: generateUsernameSuggestions(globalName)};
}
async initiateHandoff({
userAgent,
clientIp,
clientPlatform,
}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({origin});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
@@ -312,19 +310,22 @@ export class AuthRequestService {
async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise<HandoffInfoResponse> {
const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp);
if (info.status === 'expired' || !info.clientIp) {
if (info.status === 'expired' || !info.origin) {
return {status: info.status, client_info: null};
}
const geo = await lookupGeoip(info.clientIp);
const {clientOs, clientPlatform} = resolveSessionClientInfo({
userAgent: info.userAgent ?? null,
isDesktopClient: info.clientPlatform === 'desktop',
const geo = await lookupGeoip(info.origin.ip);
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
const resolved = resolveSessionClientInfo({
userAgent: info.origin.userAgent,
reportedOs: info.origin.clientOs,
productName: branding.product_name,
});
return {
status: 'pending',
client_info: {
platform: clientPlatform,
os: clientOs,
platform: resolved.platform,
os: resolved.os,
device: resolved.device,
location: {
city: geo.city,
region: geo.region,
@@ -334,18 +335,18 @@ export class AuthRequestService {
};
}
async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
await this.desktopHandoffService.completeHandoff(
data.code,
() =>
(origin) =>
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
token: sessionToken,
expectedUserId: data.user_id,
request,
origin,
}),
clientIp,
);
+31 -20
View File
@@ -15,12 +15,19 @@ import {Logger} from '../Logger';
import type {AuthSession} from '../models/AuthSession';
import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
import {mapAuthSessionsToResponse} from './AuthModel';
import * as AuthUtility from './AuthUtility';
export interface SessionOrigin {
ip: string;
userAgent: string | null;
clientOs: string | null;
}
interface CreateAuthSessionParams {
user: User;
request: Request;
origin: SessionOrigin;
}
interface LogoutAuthSessionsParams {
@@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult {
interface CreateAdditionalAuthSessionFromTokenParams {
token: string;
expectedUserId?: string;
request: Request;
origin: SessionOrigin;
}
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
const {config} = ctx.services;
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const userAgent = request.headers.get('user-agent')?.trim() || null;
const clientOs = isFluxerNativeUserAgent(userAgent)
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
: null;
return {ip, userAgent, clientOs};
}
export async function createAuthSession(
ctx: ApiContext,
{user, request}: CreateAuthSessionParams,
{user, origin}: CreateAuthSessionParams,
): Promise<[token: string, AuthSession]> {
const {users, config} = ctx.services;
const {users} = ctx.services;
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
const ip = requireClientIp(request, {
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
});
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
const uaRaw = request.headers.get('user-agent') ?? '';
const isDesktopClient = platformHeader === 'desktop';
let clientCountry: string | null = null;
try {
const geoip = await lookupGeoip(ip);
const geoip = await lookupGeoip(origin.ip);
clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null;
} catch (error) {
Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation');
@@ -92,11 +105,9 @@ export async function createAuthSession(
session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, token)),
created_at: now,
approx_last_used_at: now,
client_ip: ip,
client_user_agent: uaRaw || null,
client_is_desktop: isDesktopClient,
client_os: null,
client_platform: null,
client_ip: origin.ip,
client_user_agent: origin.userAgent,
client_os: origin.clientOs,
client_country: clientCountry,
version: 1,
});
@@ -105,7 +116,7 @@ export async function createAuthSession(
export async function createAdditionalAuthSessionFromToken(
ctx: ApiContext,
{token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams,
{token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams,
): Promise<{
token: string;
userId: string;
@@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken(
if (expectedUserId && user.id.toString() !== expectedUserId) {
throw new SessionTokenMismatchError();
}
const [newToken] = await createAuthSession(ctx, {user, request});
const [newToken] = await createAuthSession(ctx, {user, origin});
return {token: newToken, userId: user.id.toString()};
}
@@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession(
(authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash),
);
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request});
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
await dispatchAuthSessionChange(ctx, {
userId: user.id,
@@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import type {SessionOrigin} from '../AuthSession';
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 12;
@@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3;
interface HandoffData {
createdAt: number;
userAgent?: string;
clientIp: string;
clientPlatform?: string;
origin: SessionOrigin;
infoLookupCount: number;
}
@@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void {
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
code: string;
expiresAt: Date;
}> {
@@ -70,9 +69,7 @@ export class DesktopHandoffService {
const normalizedCode = normalizeHandoffCode(code);
const handoffData: HandoffData = {
createdAt: Date.now(),
userAgent: args.userAgent,
clientIp: args.clientIp,
clientPlatform: args.clientPlatform,
origin: args.origin,
infoLookupCount: 0,
};
const expirySeconds = seconds('5 minutes');
@@ -83,7 +80,7 @@ export class DesktopHandoffService {
async completeHandoff(
code: string,
createTokenData: () => Promise<{token: string; userId: string}>,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<void> {
const {cache} = this.apiContext.services;
@@ -107,7 +104,7 @@ export class DesktopHandoffService {
if (remainingSeconds <= 0) {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData();
const {token, userId} = await createTokenData(handoffData.origin);
const tokenData: HandoffTokenData = {
token,
userId,
@@ -122,9 +119,7 @@ export class DesktopHandoffService {
approverIp: string,
): Promise<{
status: 'pending' | 'expired';
userAgent?: string;
clientIp?: string;
clientPlatform?: string;
origin?: SessionOrigin;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
@@ -149,12 +144,7 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {
status: 'pending',
userAgent: handoffData.userAgent,
clientIp: handoffData.clientIp,
clientPlatform: handoffData.clientPlatform,
};
return {status: 'pending', origin: handoffData.origin};
}
async getHandoffStatus(
@@ -332,7 +332,10 @@ export class SsoService {
});
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
const user = await this.resolveUserFromClaims(claims, config);
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
const [token] = await AuthSession.createAuthSession(this.apiContext, {
user,
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
});
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
}
@@ -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);
});
});
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils';
interface HandoffInitiateResponse {
@@ -17,6 +19,7 @@ interface HandoffInfoResponse {
client_info?: {
platform?: string | null;
os?: string | null;
device?: 'mobile' | 'desktop';
location?: {
city?: string | null;
region?: string | null;
@@ -25,6 +28,16 @@ interface HandoffInfoResponse {
} | null;
}
interface AuthSessionsResponseItem {
masked_ip?: string | null;
client_info?: {
platform?: string | null;
os?: string | null;
browser?: string | null;
device?: 'mobile' | 'desktop';
} | null;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
token?: string;
@@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => {
afterAll(async () => {
await harness?.shutdown();
});
it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => {
const DESKTOP_IP = '203.0.113.77';
const desktopUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36';
const browserUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.header('User-Agent', desktopUserAgent)
.header('x-forwarded-for', DESKTOP_IP)
.body(null)
.execute();
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.header('User-Agent', browserUserAgent)
.execute();
expect(info.client_info?.platform).toBe('Fluxer macOS');
expect(info.client_info?.device).toBe('desktop');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.header('User-Agent', browserUserAgent)
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(204)
.execute();
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.get(`/auth/handoff/${initResp.code}/status`)
.execute();
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
.get('/auth/sessions')
.execute();
const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS');
expect(handedOff).toHaveLength(1);
expect(handedOff[0]?.client_info?.os).toBe('macOS');
expect(handedOff[0]?.client_info?.browser).toBeNull();
expect(handedOff[0]?.client_info?.device).toBe('desktop');
expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false);
expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP));
});
it('completes full handoff flow: initiate → info → complete → status', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
@@ -93,19 +93,7 @@ export class MessageEditService {
assertGuildMemberCanCommunicate(member);
}
if (data.message_snapshots !== undefined) {
const isAuthor = message.authorId === userId;
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
if (!isAuthor && !canManage) {
throw new MissingPermissionsError();
}
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
this.deps.persistenceService.updateSnapshotAttachments({
message,
snapshotEdits: data.message_snapshots ?? [],
}),
);
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
return updatedMessage;
throw new MissingPermissionsError();
}
const user = await this.deps.userRepository.findUnique(userId);
this.deps.validationService.validateMessageEditable(message);
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os?: Nullish<string>;
client_platform?: Nullish<string>;
client_os: Nullish<string>;
client_country: Nullish<string>;
version: number;
}
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
approx_last_used_at: Date;
client_ip: string;
client_user_agent: Nullish<string>;
client_is_desktop: Nullish<boolean>;
client_os: Nullish<string>;
client_platform: Nullish<string>;
client_country: Nullish<string>;
deleted_at: Date;
version: number;
@@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [
'approx_last_used_at',
'client_ip',
'client_user_agent',
'client_is_desktop',
'client_os',
'client_platform',
'client_country',
'version',
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
@@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [
'approx_last_used_at',
'client_ip',
'client_user_agent',
'client_is_desktop',
'client_os',
'client_platform',
'client_country',
'deleted_at',
'version',
@@ -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({
@@ -319,6 +319,7 @@ export class GuildMemberService {
sendJoinMessage?: boolean;
skipGuildLimitCheck?: boolean;
skipBanCheck?: boolean;
skipRiskGate?: boolean;
isTemporary?: boolean;
joinSourceType?: JoinSourceType;
sourceInviteCode?: InviteCode;
@@ -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(),
@@ -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';
@@ -84,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 {
@@ -402,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 {
@@ -412,6 +419,13 @@ 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};
@@ -425,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,
),
};
}
@@ -913,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;
@@ -1079,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,
@@ -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);
+2 -12
View File
@@ -10,9 +10,7 @@ export class AuthSession {
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly clientOs?: string | null;
readonly clientPlatform?: string | null;
readonly clientOs: string | null;
readonly clientCountry: string | null;
readonly version: number;
@@ -23,9 +21,7 @@ export class AuthSession {
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.clientOs = row.client_os ?? null;
this.clientPlatform = row.client_platform ?? null;
this.clientCountry = row.client_country ?? null;
this.version = row.version;
}
@@ -38,9 +34,7 @@ export class AuthSession {
approx_last_used_at: this.approximateLastUsedAt,
client_ip: this.clientIp,
client_user_agent: this.clientUserAgent,
client_is_desktop: this.clientIsDesktop,
client_os: this.clientOs,
client_platform: this.clientPlatform,
client_country: this.clientCountry,
version: this.version,
};
@@ -54,9 +48,7 @@ export class AuthSessionTombstone {
readonly approximateLastUsedAt: Date;
readonly clientIp: string;
readonly clientUserAgent: string | null;
readonly clientIsDesktop: boolean | null;
readonly clientOs?: string | null;
readonly clientPlatform?: string | null;
readonly clientOs: string | null;
readonly clientCountry: string | null;
readonly deletedAt: Date;
readonly version: number;
@@ -68,9 +60,7 @@ export class AuthSessionTombstone {
this.approximateLastUsedAt = row.approx_last_used_at;
this.clientIp = row.client_ip;
this.clientUserAgent = row.client_user_agent ?? null;
this.clientIsDesktop = row.client_is_desktop ?? null;
this.clientOs = row.client_os ?? null;
this.clientPlatform = row.client_platform ?? null;
this.clientCountry = row.client_country ?? null;
this.deletedAt = row.deleted_at;
this.version = row.version;
@@ -273,6 +273,7 @@ export class OAuth2RequestService {
}
}
await this.guildService.members.addUserToGuild({
skipRiskGate: true,
userId: botUserId,
guildId,
skipGuildLimitCheck: true,
+29 -6
View File
@@ -26040,11 +26040,18 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "The operating system of the requesting device"
},
"device": {
"enum": ["mobile", "desktop"],
"type": "string",
"x-enumNames": ["mobile", "desktop"],
"description": "Device class of the requesting device, decided by the server"
},
"location": {
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
"description": "The approximate location of the requesting device"
}
}
},
"required": ["device"]
},
{"type": "null"}
],
@@ -26288,11 +26295,18 @@
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "The browser reported by the client"
},
"device": {
"enum": ["mobile", "desktop"],
"type": "string",
"x-enumNames": ["mobile", "desktop"],
"description": "Device class of the session, decided by the server"
},
"location": {
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
"description": "The geolocation data sent by the client"
}
}
},
"required": ["device"]
},
{"type": "null"}
],
@@ -27388,9 +27402,9 @@
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 25,
"maximum": 50,
"format": "int32",
"description": "Number of messages to return for this channel (1-25)"
"description": "Number of messages to return for this channel (1-50)"
},
"before": {"$ref": "#/components/schemas/SnowflakeType"},
"after": {"$ref": "#/components/schemas/SnowflakeType"},
@@ -33239,12 +33253,20 @@
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"refunded_amount_cents": {"type": "integer", "format": "int53"},
"refunded_amount_cents": {
"type": "integer",
"format": "int53",
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
},
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
"currency": {"type": "string"},
"subscription_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Subscription that was cancelled along with the refund, when applicable"
},
"status": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
}
},
"required": [
@@ -33255,7 +33277,8 @@
"refunded_amount_cents",
"invoice_amount_paid_cents",
"currency",
"subscription_id"
"subscription_id",
"status"
]
},
"ReadStateAckResponse": {
@@ -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()),
@@ -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,
@@ -13,7 +13,7 @@ import type {
SelfServeRefundResponse,
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import type Stripe from 'stripe';
import type {UserID} from '../../BrandedTypes';
import {createUserID, type UserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {Logger} from '../../Logger';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
@@ -206,6 +206,51 @@ export class StripeRefundService {
};
}
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
}
private async finalizeIfSucceeded(refund: Stripe.Refund): Promise<void> {
if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') {
return;
}
const userIdRaw = refund.metadata.user_id;
if (!userIdRaw) {
return;
}
let userId: UserID;
try {
userId = createUserID(BigInt(userIdRaw));
} catch {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
if (subscriptionId) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
Logger.warn(
{error, userId: user.id.toString(), subscriptionId},
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
);
}
}
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
Logger.info(
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
);
}
async handleRefundWebhookEvent(refund: Stripe.Refund): Promise<void> {
await this.finalizeIfSucceeded(refund);
}
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
const stripe = this.ensureStripe();
const user = await this.getRequiredUser(userId);
@@ -220,6 +265,14 @@ export class StripeRefundService {
if (this.cooldownExpiresAt(user)) {
throw new StripeRefundCooldownActiveError();
}
const priorFailures = await this.countPriorTerminalFailures(target.invoiceId);
const idempotencyKey = [
'self-serve-refund',
user.id.toString(),
target.invoiceId,
target.paymentIntentId ?? target.chargeId,
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
].join(':');
let refund: Stripe.Response<Stripe.Refund>;
try {
refund = await stripe.refunds.create(
@@ -232,11 +285,10 @@ export class StripeRefundService {
invoice_id: target.invoiceId,
refund_kind: 'self_serve',
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
},
},
{
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
},
{idempotencyKey},
);
} catch (error) {
Logger.warn(
@@ -254,36 +306,29 @@ export class StripeRefundService {
} catch (mirrorErr) {
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
}
if (target.subscriptionId) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
Logger.warn(
{error, userId: user.id.toString(), subscriptionId: target.subscriptionId},
'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook',
);
}
}
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
await this.finalizeIfSucceeded(refund);
const succeeded = refund.status === 'succeeded';
Logger.info(
{
userId: user.id.toString(),
invoiceId: target.invoiceId,
refundId: refund.id,
status: refund.status,
amountCents: refund.amount,
subscriptionId: target.subscriptionId,
},
'Self-serve refund issued',
succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
);
return {
invoice_id: target.invoiceId,
payment_intent_id: target.paymentIntentId,
charge_id: target.chargeId,
refund_id: refund.id,
refunded_amount_cents: refund.amount,
refunded_amount_cents: succeeded ? refund.amount : 0,
invoice_amount_paid_cents: target.amountPaidCents,
currency: target.currency,
subscription_id: target.subscriptionId,
subscription_id: succeeded ? target.subscriptionId : null,
status: refund.status ?? 'pending',
};
}
}
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
import type {StripeGiftService} from './StripeGiftService';
import {StripePaymentFraudService} from './StripePaymentFraudService';
import type {StripePremiumService} from './StripePremiumService';
import type {StripeRefundService} from './StripeRefundService';
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
@@ -61,6 +62,7 @@ export class StripeWebhookService {
adminRepository: AdminRepository,
snowflakeService: ISnowflakeService,
private billingRepository: BillingRepository,
private refundService: StripeRefundService,
) {
this.checkoutHandler = new StripeCheckoutWebhookHandler(
stripe,
@@ -336,6 +338,7 @@ export class StripeWebhookService {
livemode: event.livemode,
}),
);
await this.refundService.handleRefundWebhookEvent(r);
break;
}
case 'invoice.created':
@@ -8,6 +8,7 @@ import type {
import {HttpResponse, http} from 'msw';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
});
}
function refundCreateHandler() {
function refundCreateHandler(opts?: {
status?: 'succeeded' | 'pending' | 'failed';
failureReason?: string;
onRequest?: (idempotencyKey: string | null) => void;
}) {
return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
opts?.onRequest?.(request.headers.get('idempotency-key'));
const formData = await request.formData();
const params = Object.fromEntries(formData.entries());
const metadata: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
const match = key.match(/^metadata\[(.+)\]$/);
if (match) metadata[match[1]] = value as string;
}
return HttpResponse.json({
id: 're_test_self_serve',
object: 'refund',
amount: Number.parseInt((params.amount as string) ?? '0', 10),
currency: 'usd',
status: 'succeeded',
status: opts?.status ?? 'succeeded',
failure_reason: opts?.failureReason ?? null,
payment_intent: params.payment_intent ?? null,
charge: params.charge ?? null,
metadata,
});
});
}
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
.execute();
});
test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
refundCreateHandler({status: 'pending'}),
);
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {
stripe_customer_id: MOCK_CUSTOMER_ID,
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
});
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
.post('/premium/refund-latest')
.execute();
expect(response.status).toBe('pending');
expect(response.refunded_amount_cents).toBe(0);
expect(response.subscription_id).toBeNull();
const {UserRepository} = await import('../../user/repositories/UserRepository');
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(updatedUser!.firstRefundAt).toBeNull();
});
test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
refundCreateHandler({status: 'failed', failureReason: 'unknown'}),
);
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {
stripe_customer_id: MOCK_CUSTOMER_ID,
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
});
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
.post('/premium/refund-latest')
.execute();
expect(response.status).toBe('failed');
expect(response.refunded_amount_cents).toBe(0);
expect(response.subscription_id).toBeNull();
const {UserRepository} = await import('../../user/repositories/UserRepository');
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
expect(updatedUser!.firstRefundAt).toBeNull();
});
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
server.use(...createStripeApiHandlers().handlers);
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
const account = await createTestAccount(harness);
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
const idempotencyKeys: Array<string | null> = [];
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
expect(idempotencyKeys).toHaveLength(2);
expect(idempotencyKeys[0]).not.toBeNull();
expect(idempotencyKeys[1]).not.toBeNull();
expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]);
expect(idempotencyKeys[1]).toContain('retry-1');
});
});
});
@@ -2,12 +2,14 @@
import crypto from 'node:crypto';
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {HttpResponse, http} from 'msw';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder';
import {UserRepository} from '../../user/repositories/UserRepository';
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME);
});
});
describe('refund.updated', () => {
test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const userRepository = new UserRepository();
const subscriptionId = 'sub_test_webhook_finalize';
await userRepository.patchUpsert(
userId,
{stripe_subscription_id: subscriptionId},
(await userRepository.findUnique(userId))!.toRow(),
);
server.use(
http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) =>
HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}),
),
);
await sendWebhook({
type: 'refund.updated',
data: {
object: {
id: 'pyr_test_webhook_finalize',
status: 'succeeded',
amount: 2499,
currency: 'brl',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId.toString(),
invoice_id: 'in_test_webhook_finalize',
subscription_id: subscriptionId,
},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).not.toBeNull();
});
test('does not finalize cooldown while the refund is still pending', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const userRepository = new UserRepository();
await sendWebhook({
type: 'refund.updated',
data: {
object: {
id: 'pyr_test_webhook_pending',
status: 'pending',
amount: 2499,
currency: 'brl',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId.toString(),
invoice_id: 'in_test_webhook_pending',
},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).toBeNull();
});
});
describe('refund.failed', () => {
test('does not finalize cooldown when the refund ultimately fails', async () => {
const account = await createTestAccount(harness);
const userId = createUserID(BigInt(account.userId));
const userRepository = new UserRepository();
await sendWebhook({
type: 'refund.failed',
data: {
object: {
id: 'pyr_test_webhook_failed',
status: 'failed',
failure_reason: 'unknown',
amount: 2499,
currency: 'brl',
metadata: {
refund_kind: 'self_serve',
user_id: account.userId.toString(),
invoice_id: 'in_test_webhook_failed',
},
},
},
});
const updatedUser = await userRepository.findUnique(userId);
expect(updatedUser!.firstRefundAt).toBeNull();
});
});
});
@@ -35,6 +35,7 @@ import type {Context} from 'hono';
import {seconds} from 'itty-time';
import {AttachmentDecayRepository} from '../attachment/AttachmentDecayRepository';
import type {IpAuthorizationTicketCache} from '../auth/AuthLogin';
import {getTicketCacheKey} from '../auth/AuthLogin';
import {
type ChannelID,
createApplicationID,
@@ -86,6 +87,7 @@ import {UserRepository} from '../user/repositories/UserRepository';
import {processUserDeletion} from '../user/services/UserDeletionService';
import {UserHarvestRepository} from '../user/UserHarvestRepository';
import {getExpiryBucket} from '../utils/AttachmentDecay';
import {parseReportedClientOs} from '../utils/SessionClientIdentity';
import {ScheduledMessageExecutor} from '../worker/executors/ScheduledMessageExecutor';
import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments';
import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions';
@@ -627,7 +629,7 @@ export function TestHarnessController(app: HonoApp) {
client_ip: clientIp,
user_agent: userAgent,
client_location: clientLocation,
platform,
client_properties: clientProperties,
resend_used: resendUsed,
invite_code: inviteCode,
created_at: createdAtInput,
@@ -649,9 +651,11 @@ export function TestHarnessController(app: HonoApp) {
userId: String(userId),
email: String(email),
username: String(username),
clientIp: String(clientIp),
userAgent: String(userAgent),
platform: platform ? String(platform) : null,
origin: {
ip: String(clientIp),
userAgent: userAgent ? String(userAgent) : null,
clientOs: parseReportedClientOs(clientProperties ? String(clientProperties) : null),
},
authToken: String(token),
clientLocation: String(clientLocation),
inviteCode: inviteCode ? String(inviteCode) : null,
@@ -659,7 +663,7 @@ export function TestHarnessController(app: HonoApp) {
createdAt,
};
const ttl = typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : seconds('15 minutes');
await cacheService.set(`ip-auth-ticket:${ticket}`, payload, ttl);
await cacheService.set(getTicketCacheKey(String(ticket)), payload, ttl);
await cacheService.set(`ip-auth-token:${token}`, {ticket: String(ticket)}, ttl);
return ctx.json(
{
@@ -721,7 +725,7 @@ export function TestHarnessController(app: HonoApp) {
return ctx.json({error: 'ticket or token is required'}, 400);
}
if (ticket) {
await cacheService.delete(`ip-auth-ticket:${ticket}`);
await cacheService.delete(getTicketCacheKey(String(ticket)));
await cacheService.delete(`ip-auth:${ticket}`);
}
if (token) {
+90 -1
View File
@@ -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());
+19 -2
View File
@@ -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 [];
}
@@ -211,9 +211,7 @@ function toTombstoneRow(row: AuthSessionRow, deletedAt: Date): AuthSessionTombst
approx_last_used_at: row.approx_last_used_at,
client_ip: row.client_ip,
client_user_agent: row.client_user_agent,
client_is_desktop: row.client_is_desktop,
client_os: row.client_os ?? null,
client_platform: row.client_platform ?? null,
client_country: row.client_country ?? null,
deleted_at: deletedAt,
version: row.version,
@@ -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 {
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {describe, expect, it} from 'vitest';
import {UserContentServiceTestHooks} from './UserContentService';
const {isUnreachableEntityError} = UserContentServiceTestHooks;
describe('isUnreachableEntityError', () => {
it('treats a deleted or left community as unreachable rather than fatal', () => {
expect(isUnreachableEntityError(new UnknownGuildError())).toBe(true);
});
it('treats a gone channel and a lost permission as unreachable', () => {
expect(isUnreachableEntityError(new UnknownChannelError())).toBe(true);
expect(isUnreachableEntityError(new MissingPermissionsError())).toBe(true);
});
it('leaves a deleted message to the delete path instead of marking it unavailable', () => {
expect(isUnreachableEntityError(new UnknownMessageError())).toBe(false);
});
it('still lets unexpected failures surface', () => {
expect(isUnreachableEntityError(new Error('database is on fire'))).toBe(false);
expect(isUnreachableEntityError(null)).toBe(false);
});
});
@@ -6,6 +6,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {MaxBookmarksError} from '@fluxer/errors/src/domains/core/MaxBookmarksError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
import {HarvestNotReadyError} from '@fluxer/errors/src/domains/moderation/HarvestNotReadyError';
@@ -94,6 +95,13 @@ function normalizeProviderEnvironment(
return platform === 'ios_apns' ? DEFAULT_APNS_PROVIDER_ENVIRONMENT : null;
}
const isUnreachableEntityError = (error: unknown): boolean =>
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError ||
error instanceof UnknownGuildError;
export const UserContentServiceTestHooks = {isUnreachableEntityError};
export class UserContentService {
private readonly updatePropagator: BaseUserUpdatePropagator;
private readonly userRepository: UserContentRepository;
@@ -138,11 +146,7 @@ export class UserContentService {
messageId: mention.messageId,
});
} catch (error) {
if (
error instanceof UnknownMessageError ||
error instanceof MissingPermissionsError ||
error instanceof UnknownChannelError
) {
if (error instanceof UnknownMessageError || isUnreachableEntityError(error)) {
return null;
}
throw error;
@@ -188,7 +192,7 @@ export class UserContentService {
await this.userRepository.deleteSavedMessage(userId, savedMessage.messageId);
return null;
}
if (error instanceof MissingPermissionsError || error instanceof UnknownChannelError) {
if (isUnreachableEntityError(error)) {
status = 'missing_permissions';
} else {
throw error;
@@ -0,0 +1,134 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Bowser from 'bowser';
import {Logger} from '../Logger';
import {parseJsonRecord} from './JsonBoundaryUtils';
export type SessionDeviceClass = 'mobile' | 'desktop';
interface SessionClientSignals {
userAgent: string | null;
reportedOs: string | null;
productName: string;
}
export interface SessionClientInfo {
platform: string | null;
os: string | null;
browser: string | null;
device: SessionDeviceClass;
}
type OsToken = 'android' | 'ios' | 'macos' | 'windows' | 'linux';
const OS_DISPLAY: Record<OsToken, string> = {
android: 'Android',
ios: 'iOS',
macos: 'macOS',
windows: 'Windows',
linux: 'Linux',
};
const BOWSER_OS_TO_TOKEN: Record<string, OsToken> = {
macOS: 'macos',
Windows: 'windows',
Linux: 'linux',
iOS: 'ios',
Android: 'android',
};
const NATIVE_UA_REGEX = /^Fluxer (Android|iOS|Linux|Desktop|Client)(?=[/ ]|$)/;
const ELECTRON_UA_REGEX = /\bElectron\/\d+(?:\.\d+)*/;
const PRODUCT_TOKEN_OS: Record<string, OsToken | null> = {
Android: 'android',
iOS: 'ios',
Linux: 'linux',
Desktop: null,
Client: null,
};
const CLIENT_PROPERTIES_HEADER_MAX_LENGTH = 4096;
const MOBILE_PLATFORM_TYPES = new Set(['mobile', 'tablet']);
function narrowOsToken(value: string | null): OsToken | null {
if (value === null) return null;
return Object.hasOwn(OS_DISPLAY, value) ? (value as OsToken) : null;
}
function parseUserAgent(userAgent: string): Bowser.Parser.Parser | null {
try {
return Bowser.getParser(userAgent);
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return null;
}
}
function bowserOsToken(userAgent: string): OsToken | null {
if (!userAgent) return null;
return narrowOsToken(BOWSER_OS_TO_TOKEN[parseUserAgent(userAgent)?.getOSName() ?? ''] ?? null);
}
export function isFluxerNativeUserAgent(userAgent: string | null): boolean {
return NATIVE_UA_REGEX.test(userAgent?.trim() ?? '');
}
export function parseReportedClientOs(headerValue: string | null): OsToken | null {
if (!headerValue) return null;
const trimmed = headerValue.trim();
if (!trimmed || trimmed.length > CLIENT_PROPERTIES_HEADER_MAX_LENGTH) return null;
let decoded: string;
try {
decoded = Buffer.from(trimmed, 'base64').toString('utf8');
} catch {
return null;
}
const record = parseJsonRecord(decoded);
if (!record) return null;
const os = record.os;
return typeof os === 'string' ? narrowOsToken(os) : null;
}
export function resolveSessionClientInfo({
userAgent,
reportedOs,
productName,
}: SessionClientSignals): SessionClientInfo {
const ua = userAgent?.trim() ?? '';
const nativeMatch = NATIVE_UA_REGEX.exec(ua);
if (nativeMatch) {
const productToken = nativeMatch[1] as keyof typeof PRODUCT_TOKEN_OS;
const osToken = narrowOsToken(reportedOs) ?? PRODUCT_TOKEN_OS[productToken] ?? bowserOsToken(ua);
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
const mobile = osToken === 'ios' || osToken === 'android';
const platform = osDisplay
? mobile
? `${productName} ${osDisplay}`
: `${productName} Lite ${osDisplay}`
: `${productName} Lite`;
return {platform, os: osDisplay, browser: null, device: mobile ? 'mobile' : 'desktop'};
}
if (ELECTRON_UA_REGEX.test(ua)) {
const osToken = bowserOsToken(ua);
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
const mobile = osToken === 'ios' || osToken === 'android';
return {
platform: osDisplay ? `${productName} ${osDisplay}` : productName,
os: osDisplay,
browser: null,
device: mobile ? 'mobile' : 'desktop',
};
}
const parser = ua ? parseUserAgent(ua) : null;
const browser = parser?.getBrowserName() || null;
const os = parser?.getOSName() || null;
const platformType = parser?.getPlatformType(true) ?? '';
return {
platform: browser ?? os,
os,
browser,
device: MOBILE_PLATFORM_TYPES.has(platformType) ? 'mobile' : 'desktop',
};
}
@@ -1,43 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Bowser from 'bowser';
import {Logger} from '../Logger';
interface UserAgentInfo {
clientOs: string;
detectedPlatform: string;
}
const UNKNOWN_LABEL = 'Unknown';
function formatName(name?: string | null): string {
const normalized = name?.trim();
return normalized || UNKNOWN_LABEL;
}
function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
const ua = userAgentRaw.trim();
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
try {
const parser = Bowser.getParser(ua);
return {
clientOs: formatName(parser.getOSName()),
detectedPlatform: formatName(parser.getBrowserName()),
};
} catch (error) {
Logger.warn({error}, 'Failed to parse user agent');
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
}
}
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
clientOs: string;
clientPlatform: string;
} {
const parsed = parseUserAgentSafe(args.userAgent ?? '');
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
return {
clientOs: parsed.clientOs,
clientPlatform,
};
}
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {
isFluxerNativeUserAgent,
parseReportedClientOs,
resolveSessionClientInfo,
type SessionClientInfo,
} from '../SessionClientIdentity';
const ELECTRON_MAC_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
const ELECTRON_WINDOWS_UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FluxerCanary/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
const CHROME_MAC_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
const SAFARI_IPHONE_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const resolve = (userAgent: string | null, reportedOs: string | null, productName = 'Fluxer'): SessionClientInfo =>
resolveSessionClientInfo({userAgent, reportedOs, productName});
describe('resolveSessionClientInfo', () => {
it('labels the Flutter mobile clients by their operating system', () => {
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios')).toEqual({
platform: 'Fluxer iOS',
os: 'iOS',
browser: null,
device: 'mobile',
});
expect(resolve('Fluxer Android/1.4.2 (stable)', 'android')).toEqual({
platform: 'Fluxer Android',
os: 'Android',
browser: null,
device: 'mobile',
});
});
it('accepts the versionless user agent emitted before package info loads', () => {
expect(resolve('Fluxer iOS (stable)', 'ios')).toEqual({
platform: 'Fluxer iOS',
os: 'iOS',
browser: null,
device: 'mobile',
});
});
it('labels Flutter desktop builds as Lite and distinguishes them by reported operating system', () => {
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'macos')).toEqual({
platform: 'Fluxer Lite macOS',
os: 'macOS',
browser: null,
device: 'desktop',
});
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows')).toEqual({
platform: 'Fluxer Lite Windows',
os: 'Windows',
browser: null,
device: 'desktop',
});
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'linux')).toEqual({
platform: 'Fluxer Lite Linux',
os: 'Linux',
browser: null,
device: 'desktop',
});
});
it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => {
expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({
platform: 'Fluxer Lite Linux',
os: 'Linux',
browser: null,
device: 'desktop',
});
});
it('resolves legacy rows that predate the reported operating system', () => {
expect(resolve('Fluxer iOS/1.4.2 (stable)', null)).toEqual({
platform: 'Fluxer iOS',
os: 'iOS',
browser: null,
device: 'mobile',
});
expect(resolve('Fluxer Desktop/1.4.2 (stable)', null)).toEqual({
platform: 'Fluxer Lite',
os: null,
browser: null,
device: 'desktop',
});
});
it('ignores a corrupt reported operating system instead of rendering it', () => {
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'Windows 11')).toEqual({
platform: 'Fluxer Lite',
os: null,
browser: null,
device: 'desktop',
});
});
it('labels the desktop application by operating system without naming a browser', () => {
expect(resolve(ELECTRON_MAC_UA, null)).toEqual({
platform: 'Fluxer macOS',
os: 'macOS',
browser: null,
device: 'desktop',
});
expect(resolve(ELECTRON_WINDOWS_UA, null)).toEqual({
platform: 'Fluxer Windows',
os: 'Windows',
browser: null,
device: 'desktop',
});
});
it('keeps browser sessions reporting their browser', () => {
expect(resolve(CHROME_MAC_UA, null)).toEqual({
platform: 'Chrome',
os: 'macOS',
browser: 'Chrome',
device: 'desktop',
});
expect(resolve(SAFARI_IPHONE_UA, null)).toEqual({
platform: 'Safari',
os: 'iOS',
browser: 'Safari',
device: 'mobile',
});
});
it('keeps the operating system when the browser cannot be named', () => {
expect(resolve('SomeBot (Windows NT 10.0; Win64; x64)', null)).toEqual({
platform: 'Windows',
os: 'Windows',
browser: null,
device: 'desktop',
});
});
it('rejects a product token that only prefixes a real one', () => {
const resolved = resolve('Fluxer iOS-not-really/6.6.6', 'ios');
expect(resolved.platform).not.toBe('Fluxer iOS');
expect(resolved.browser).toBe(resolved.platform);
});
it('never emits an Unknown literal when the user agent is absent', () => {
expect(resolve(null, null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
expect(resolve('', null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
});
it('uses instance branding for the product word', () => {
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios', 'Acme').platform).toBe('Acme iOS');
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows', 'Acme').platform).toBe('Acme Lite Windows');
expect(resolve(ELECTRON_MAC_UA, null, 'Acme').platform).toBe('Acme macOS');
});
});
describe('isFluxerNativeUserAgent', () => {
it('matches only the Fluxer native product tokens', () => {
expect(isFluxerNativeUserAgent('Fluxer iOS/1.4.2 (stable)')).toBe(true);
expect(isFluxerNativeUserAgent('Fluxer Desktop (canary)')).toBe(true);
expect(isFluxerNativeUserAgent(ELECTRON_MAC_UA)).toBe(false);
expect(isFluxerNativeUserAgent(CHROME_MAC_UA)).toBe(false);
expect(isFluxerNativeUserAgent(null)).toBe(false);
});
});
describe('parseReportedClientOs', () => {
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value), 'utf8').toString('base64');
it('reads the operating system from the client properties header', () => {
expect(parseReportedClientOs(encode({os: 'macos', device: 'mobile'}))).toBe('macos');
expect(parseReportedClientOs(encode({os: 'ios'}))).toBe('ios');
});
it('rejects anything outside the known operating systems', () => {
expect(parseReportedClientOs(null)).toBeNull();
expect(parseReportedClientOs('')).toBeNull();
expect(parseReportedClientOs('not base64 $$$')).toBeNull();
expect(parseReportedClientOs(encode([1, 2]))).toBeNull();
expect(parseReportedClientOs(encode({os: 'solaris'}))).toBeNull();
expect(parseReportedClientOs(encode({os: 42}))).toBeNull();
expect(parseReportedClientOs(encode({}))).toBeNull();
});
it('rejects an oversized header without decoding it', () => {
expect(parseReportedClientOs('a'.repeat(4097))).toBeNull();
});
});
@@ -50,7 +50,7 @@ import type {User} from '../../models/User';
import type {UserGuildSettings} from '../../models/UserGuildSettings';
import type {UserSettings} from '../../models/UserSettings';
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
import {ContentAddressedAttachmentCollector} from '../utils/ContentAddressedAttachmentCollector';
@@ -114,6 +114,7 @@ interface HarvestMessageResult {
interface UserDataJsonParams {
user: User;
userId: UserID;
productName: string;
authSessions: Array<AuthSession>;
relationships: Array<Relationship>;
userNotes: Map<UserID, string>;
@@ -354,6 +355,7 @@ function buildUserDataJson(params: UserDataJsonParams) {
const {
user,
userId,
productName,
authSessions,
relationships,
userNotes,
@@ -412,17 +414,18 @@ function buildUserDataJson(params: UserDataJsonParams) {
authenticator_types: Array.from(user.authenticatorTypes),
},
auth_sessions: authSessions.map((session) => {
const {clientOs, clientPlatform} = resolveSessionClientInfo({
const clientInfo = resolveSessionClientInfo({
userAgent: session.clientUserAgent,
isDesktopClient: session.clientIsDesktop,
reportedOs: session.clientOs ?? null,
productName,
});
return {
created_at: session.createdAt.toISOString(),
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
client_ip: session.clientIp,
client_os: clientOs,
client_os: clientInfo.os,
client_user_agent: session.clientUserAgent,
client_platform: clientPlatform,
client_platform: clientInfo.platform,
};
}),
relationships: relationships.map((rel) => ({
@@ -872,9 +875,11 @@ const harvestUserData: WorkerTaskHandler = async (payload, helpers) => {
const guildSettings = await Promise.all(
guildIds.map((guildId: GuildID) => userRepository.findGuildSettings(userId, guildId)),
);
const {branding} = await instanceConfigRepository.getAppPublicConfig();
const userData = buildUserDataJson({
user,
userId,
productName: branding.product_name,
authSessions,
relationships,
userNotes,
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
import {StripeGiftService} from '../../stripe/services/StripeGiftService';
import {StripePremiumService} from '../../stripe/services/StripePremiumService';
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
const ageVerificationService = deps.stripe
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
: null;
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
const webhookService = new StripeWebhookService(
deps.stripe,
checkoutService,
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
deps.adminRepository,
deps.snowflakeService,
deps.billingRepository,
refundService,
);
await webhookService.handleWebhook({body, signature});
};
@@ -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},
+3 -50
View File
@@ -53,12 +53,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -111,7 +105,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
@@ -123,7 +117,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
@@ -203,7 +197,6 @@ version = "0.1.0"
dependencies = [
"proptest",
"wasm-bindgen",
"wee_alloc",
"zstd",
]
@@ -225,12 +218,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memory_units"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -530,7 +517,7 @@ version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
@@ -603,40 +590,6 @@ dependencies = [
"semver",
]
[[package]]
name = "wee_alloc"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
dependencies = [
"cfg-if 0.1.10",
"libc",
"memory_units",
"winapi",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
-1
View File
@@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "=0.2.123"
wee_alloc = {version = "0.4.5", default-features = false}
zstd = {version = "0.13.3", default-features = false, features = ["no_asm", "thin", "wasm"]}
[dev-dependencies]
-4
View File
@@ -9,10 +9,6 @@ use formats::is_animated_image_bytes;
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
use wasm_bindgen::prelude::*;
#[cfg(target_arch = "wasm32")]
#[global_allocator]
static WASM_ALLOCATOR: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn crop_rotate_rgba_raw(
-11
View File
@@ -16,11 +16,6 @@
background: transparent;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
:local(.appContainer) {
pointer-events: none;
}
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
padding-top: env(safe-area-inset-top);
}
@@ -86,12 +81,6 @@
--skip-link-titlebar-offset: var(--native-titlebar-height);
--skip-link-traffic-lights-offset: 4.5rem;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
:local(.overlayScope)
> :not([data-overlay-pass-through]) {
pointer-events: none;
}
.quickSwitcherPortal {
position: fixed;
inset: 0;
+2 -14
View File
@@ -45,15 +45,9 @@ import {getActivePortalHost, setActivePortalHost} from '@app/features/ui/overlay
import MobileLayout from '@app/features/ui/state/MobileLayout';
import Modal from '@app/features/ui/state/Modal';
import Popout from '@app/features/ui/state/Popout';
import {
getDesktopWindowBehaviorSettings,
setDesktopWindowBehaviorSettings,
} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {
FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS,
UNFOCUSED_FULLY_INTERACTIVE_CLASS,
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot';
@@ -86,7 +80,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
const {i18n} = useLingui();
const reducedMotion = Accessibility.useReducedMotion;
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
const {platform, isNative} = useNativePlatform();
const useSystemTitleBar = useNativeTitleBar();
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
@@ -163,11 +156,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
useDocumentClassToggle('reduced-motion', reducedMotion);
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
useDocumentClassToggle(FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS, firstClickPassThroughWhenUnfocused);
useEffect(() => {
if (!isNative) return;
void setDesktopWindowBehaviorSettings({firstClickPassThroughWhenUnfocused});
}, [isNative, firstClickPassThroughWhenUnfocused]);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useRemScaleTracking();
@@ -600,7 +600,6 @@ export interface AccessibilitySettings {
hdrDisplayMode: HdrDisplayMode;
preserveEditDraft: boolean;
stayInteractiveWhenUnfocused: boolean;
firstClickPassThroughWhenUnfocused: boolean;
scrollToBottomOnMessageSend: boolean;
sequentialFileSend: boolean;
showNeko: boolean;
@@ -718,7 +717,6 @@ class Accessibility {
hdrDisplayMode = HdrDisplayMode.FULL;
preserveEditDraft = false;
stayInteractiveWhenUnfocused = false;
firstClickPassThroughWhenUnfocused = false;
scrollToBottomOnMessageSend = true;
sequentialFileSend = false;
showNeko = false;
@@ -829,7 +827,6 @@ class Accessibility {
'hdrDisplayMode',
'preserveEditDraft',
'stayInteractiveWhenUnfocused',
'firstClickPassThroughWhenUnfocused',
'scrollToBottomOnMessageSend',
'sequentialFileSend',
],
@@ -890,7 +887,6 @@ class Accessibility {
hdrDisplayMode: HDR_TO_PROTO[s.hdrDisplayMode],
preserveEditDraft: s.preserveEditDraft,
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused: s.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
sequentialFileSend: s.sequentialFileSend,
}),
@@ -980,8 +976,6 @@ class Accessibility {
if (m.preserveEditDraft !== undefined) s.preserveEditDraft = m.preserveEditDraft;
if (m.stayInteractiveWhenUnfocused !== undefined)
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
if (m.firstClickPassThroughWhenUnfocused !== undefined)
s.firstClickPassThroughWhenUnfocused = m.firstClickPassThroughWhenUnfocused;
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
},
@@ -1316,8 +1310,6 @@ class Accessibility {
if (validated.preserveEditDraft !== undefined) this.preserveEditDraft = validated.preserveEditDraft;
if (validated.stayInteractiveWhenUnfocused !== undefined)
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
if (validated.firstClickPassThroughWhenUnfocused !== undefined)
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
if (validated.scrollToBottomOnMessageSend !== undefined)
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
@@ -1418,8 +1410,6 @@ class Accessibility {
hdrDisplayMode: data.hdrDisplayMode ?? this.hdrDisplayMode,
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused:
data.firstClickPassThroughWhenUnfocused ?? this.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
showNeko: data.showNeko ?? this.showNeko,
@@ -3,6 +3,10 @@
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
UNREAD_PREVIEW_MESSAGE_LIMIT,
} from '@app/features/app/components/floating/UnreadPreviewBudget';
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
@@ -131,8 +135,6 @@ interface ChannelPreviewData {
const INITIAL_VISIBLE_CHANNELS = 10;
const LOAD_MORE_CHUNK = 10;
const UNREAD_PREVIEW_MESSAGE_LIMIT = 5;
const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 10;
interface CacheEntry {
cacheKey: string;
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
UNREAD_PREVIEW_MESSAGE_LIMIT,
} from '@app/features/app/components/floating/UnreadPreviewBudget';
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {describe, expect, it} from 'vitest';
const buildBatch = (limit: number) => ({
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
channel_id: String(1000000000000000000n + BigInt(index)),
limit,
})),
});
describe('unread preview fetch budget', () => {
it('keeps the anchored window inside the bulk fetch schema', () => {
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
});
it('keeps the unanchored window inside the bulk fetch schema', () => {
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
});
it('rejects a batch one channel wider than the client sends', () => {
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
});
});
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const UNREAD_PREVIEW_MESSAGE_LIMIT = 25;
export const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 5;
@@ -3,7 +3,3 @@
.nativeDragRegion {
-webkit-app-region: none;
}
:global(html.platform-native.platform-macos) .nativeDragRegion {
-webkit-app-region: drag;
}
@@ -332,6 +332,7 @@ export const GuildListItemPresentation = forwardRef<
role="button"
tabIndex={0}
data-guild-list-focus-item="true"
data-guild-id={guild.id}
onLongPress={onLongPress}
disabled={false}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-list-item.click"
@@ -5,7 +5,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import type {CSSProperties, FC, ReactNode} from 'react';
import type {FC, ReactNode} from 'react';
const REVEAL_SPOILER_DESCRIPTOR = msg({
message: 'Reveal spoiler',
@@ -23,24 +23,14 @@ interface SpoilerOverlayProps {
label?: string;
inline?: boolean;
className?: string;
style?: CSSProperties;
}
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({
hidden,
onReveal,
children,
label,
inline,
className,
style,
}) => {
export const SpoilerOverlay: FC<SpoilerOverlayProps> = ({hidden, onReveal, children, label, inline, className}) => {
const {i18n} = useLingui();
const ariaLabel = label ?? i18n._(REVEAL_SPOILER_DESCRIPTOR);
return (
<div
className={clsx(styles.container, inline && styles.inline, hidden && styles.hidden, className)}
style={style}
data-flx="app.spoiler-overlay.container"
>
<div className={styles.content} aria-hidden={hidden} data-flx="app.spoiler-overlay.content">
@@ -19,10 +19,12 @@ export interface PlaceholderMessageGroup {
readonly usernameWidth: number;
readonly timestampWidth: number;
readonly attachment: PlaceholderAttachmentSize | null;
readonly height: number;
}
interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup {
attachment: PlaceholderAttachmentSize | null;
height: number;
}
export interface PlaceholderSpecs {
@@ -100,20 +102,20 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
for (let line = 0; line < lineCount; line++) {
lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE);
}
const groupHeight = compact
? MESSAGE_HEIGHT_COMPACT * lineCount
: COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
groups.push({
lineWidths,
usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE,
timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE,
attachment: null,
height: groupHeight,
});
if (index > 0) {
totalHeight += groupSpacing;
}
if (compact) {
totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount;
} else {
totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
}
totalHeight += groupHeight;
}
const availableGroupIndices = Array.from(Array(groups.length).keys());
for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) {
@@ -123,6 +125,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX),
};
groups[groupIndex].attachment = attachment;
groups[groupIndex].height += attachment.height + ATTACHMENT_MARGIN;
totalHeight += attachment.height + ATTACHMENT_MARGIN;
}
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
@@ -44,6 +44,7 @@
.messageGroup {
position: relative;
z-index: 0;
content-visibility: auto;
}
.group {
@@ -52,7 +52,10 @@ function CozyScrollFillerSkeletonGroup({
return (
<flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
>
<flx-message-list-skeleton-group-row
@@ -170,7 +173,10 @@ function CompactScrollFillerSkeletonGroup({
return (
<flx-message-list-skeleton-group
className={flxElementClassName(styles.messageGroup)}
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
style={{
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
}}
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
>
<flx-message-list-skeleton-compact-messages
@@ -6,7 +6,6 @@ import {THE_OTHER_PLATFORM} from '@fluxer/constants/src/ExternalPlatformConstant
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from './ProductConstants';
export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
export const DESKTOP_PRODUCT_NAME = `${PRODUCT_NAME} Desktop`;
export const PRODUCT_API_NAME = `${PRODUCT_NAME} API`;
export const PRODUCT_HQ_COMMUNITY_NAME = `${PRODUCT_NAME} HQ`;
export const CANARY_RELEASE_CHANNEL_NAME = `${PRODUCT_NAME} Canary`;
@@ -29,6 +28,8 @@ export const SUPPORT_EMAIL = '[email protected]';
export const SUPPORT_EMAIL_MAILTO = `mailto:${SUPPORT_EMAIL}`;
export const I18N_EMAIL = '[email protected]';
export const I18N_EMAIL_MAILTO = `mailto:${I18N_EMAIL}`;
export const I18N_WEBLATE_DOMAIN = 'weblate.fluxer.tools';
export const I18N_WEBLATE_URL = `https://${I18N_WEBLATE_DOMAIN}`;
export const EXAMPLE_DOMAIN = 'example.com';
export const EXAMPLE_URL = `https://${EXAMPLE_DOMAIN}`;
export const EXAMPLE_CALLBACK_URL = `${EXAMPLE_URL}/callback`;
@@ -1,177 +0,0 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
function RovingFocusProbe() {
const listRef = useRovingFocusList<HTMLDivElement>({
autoFocusFirst: true,
focusableSelector: '[data-roving-focus="true"]',
manageTabIndex: true,
});
return (
<div
ref={listRef}
role="menu"
aria-orientation="vertical"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menu"
>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button"
>
First
</button>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button--2"
>
Second
</button>
<div
role="menuitemcheckbox"
aria-checked="false"
tabIndex={0}
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitemcheckbox"
>
Third
</div>
</div>
);
}
function ManualEntryRovingFocusProbe() {
const listRef = useRovingFocusList<HTMLDivElement>({
focusableSelector: '[data-roving-focus="true"]',
manageTabIndex: true,
});
return (
<div
ref={listRef}
role="menu"
aria-orientation="vertical"
tabIndex={-1}
data-autofocus
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menu"
>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button"
>
First
</button>
<button
type="button"
role="menuitem"
data-roving-focus="true"
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button--2"
>
Second
</button>
</div>
);
}
const press = (target: Element, key: string): KeyboardEvent => {
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true});
act(() => {
target.dispatchEvent(event);
});
return event;
};
const getItems = (container: HTMLElement): Array<HTMLElement> =>
Array.from(container.querySelectorAll<HTMLElement>('[data-roving-focus="true"]'));
describe('useRovingFocusList', () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
beforeEach(() => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root?.unmount();
});
root = null;
container?.remove();
container = null;
});
it('moves focus with vertical arrow keys while keeping one tabbable item', () => {
act(() => {
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe" />);
});
const items = getItems(container!);
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
const down = press(items[0], 'ArrowDown');
expect(down.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(items[1]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
press(items[1], 'ArrowDown');
expect(document.activeElement).toBe(items[2]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
press(items[2], 'ArrowUp');
expect(document.activeElement).toBe(items[1]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
});
it('supports Home and End in a managed roving list', () => {
act(() => {
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--2" />);
});
const items = getItems(container!);
press(items[0], 'End');
expect(document.activeElement).toBe(items[2]);
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
press(items[2], 'Home');
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
});
it('can start on the container with no focused item before ArrowDown enters the list', () => {
act(() => {
root?.render(
<ManualEntryRovingFocusProbe data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe" />,
);
});
const menu = container?.querySelector<HTMLElement>('[role="menu"]');
const items = getItems(container!);
expect(menu).toBeInstanceOf(HTMLElement);
expect(document.activeElement).not.toBe(items[0]);
act(() => {
menu?.focus();
});
expect(document.activeElement).toBe(menu);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
const down = press(menu!, 'ArrowDown');
expect(down.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
});
});
@@ -72,7 +72,9 @@ const applyTabIndices = (focusable: Array<HTMLElement>, activeIndex: number): vo
for (let i = 0; i < focusable.length; i++) {
const element = focusable[i];
const desired = i === clamped ? '0' : '-1';
element.setAttribute(ROVING_MANAGED_ATTR, '');
if (!element.hasAttribute(ROVING_MANAGED_ATTR)) {
element.setAttribute(ROVING_MANAGED_ATTR, '');
}
if (element.getAttribute('tabindex') !== desired) {
element.setAttribute('tabindex', desired);
}
@@ -16,7 +16,6 @@ class Initialization {
state: InitializationState = InitializationState.LOADING;
hasCompletedInitialLoad = false;
error: string | null = null;
readyPayload: unknown = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -46,29 +45,25 @@ class Initialization {
setLoading(): void {
this.state = InitializationState.LOADING;
this.error = null;
this.readyPayload = null;
}
@action
setConnecting(): void {
this.state = InitializationState.CONNECTING;
this.error = null;
this.readyPayload = null;
}
@action
setReady(payload: unknown): void {
setReady(): void {
this.state = InitializationState.READY;
this.hasCompletedInitialLoad = true;
this.error = null;
this.readyPayload = payload;
}
@action
setError(error: string): void {
this.state = InitializationState.ERROR;
this.error = error;
this.readyPayload = null;
}
@action
@@ -76,7 +71,6 @@ class Initialization {
this.state = InitializationState.LOADING;
this.hasCompletedInitialLoad = false;
this.error = null;
this.readyPayload = null;
}
}
@@ -9,7 +9,6 @@ import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import UserSettings from '@app/features/user/state/UserSettings';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
@@ -24,9 +23,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
const logger = new Logger('AuthService');
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
'X-Fluxer-Platform': getPlatformHeaderValue(),
const withAuthLocaleHeader = (headers?: Record<string, string>): Record<string, string> => ({
'Accept-Language': UserSettings.getLocale(),
...(headers ?? {}),
});
@@ -247,7 +244,7 @@ export async function login({
try {
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
body: loginBody({email, password, inviteCode}),
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Login successful', {mfa: response.body?.mfa});
return response.body;
@@ -268,7 +265,7 @@ export async function loginMfaTotp(code: string, ticket: string, inviteCode?: st
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_TOTP, {
body: mfaTotpBody(code, ticket, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('MFA TOTP authentication successful');
@@ -288,7 +285,7 @@ export async function loginMfaWebAuthn(
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN, {
body: mfaWebAuthnBody(response, challenge, ticket, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('MFA WebAuthn authentication successful');
@@ -303,7 +300,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS, {
body: ticketBody(ticket),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn MFA options retrieved');
@@ -317,7 +314,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
try {
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_WEBAUTHN_OPTIONS, {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('WebAuthn authentication options retrieved');
@@ -336,7 +333,7 @@ export async function authenticateWithWebAuthn(
try {
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_WEBAUTHN_AUTHENTICATE, {
body: webAuthnBody(response, challenge, inviteCode),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = httpResponse.body;
logger.debug('WebAuthn authentication successful');
@@ -351,7 +348,7 @@ export async function register(data: RegisterData): Promise<RegisterResponse> {
try {
const response = await http.post<RegisterResponse>(Endpoints.AUTH_REGISTER, {
body: registerBody(data),
headers: withPlatformHeader(captchaHeaders(data)),
headers: withAuthLocaleHeader(captchaHeaders(data)),
});
const responseBody = response.body;
logger.info('Registration successful');
@@ -370,7 +367,7 @@ export async function getUsernameSuggestions(globalName: string): Promise<Array<
try {
const response = await http.post<UsernameSuggestionsResponse>(Endpoints.AUTH_USERNAME_SUGGESTIONS, {
body: {global_name: globalName},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
@@ -389,7 +386,7 @@ export async function forgotPassword(
try {
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
body: {email},
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
});
logger.debug('Password reset email sent');
} catch (error) {
@@ -402,7 +399,7 @@ export async function validateResetPasswordToken(token: string): Promise<boolean
const response = await http.get<{
valid: boolean;
}>(Endpoints.AUTH_VALIDATE_RESET_PASSWORD_TOKEN(token), {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body.valid;
} catch (error) {
@@ -415,7 +412,7 @@ export async function resetPassword(token: string, password: string): Promise<Re
try {
const response = await http.post<ResetPasswordResponse>(Endpoints.AUTH_RESET_PASSWORD, {
body: {token, password},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Password reset successful');
@@ -430,7 +427,7 @@ export async function revertEmailChange(token: string, password: string): Promis
try {
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
body: {token, password},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
const responseBody = response.body;
logger.info('Email revert successful');
@@ -445,7 +442,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_VERIFY_EMAIL, {
body: tokenBody(token),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('Email verification successful');
return VerificationResult.SUCCESS;
@@ -463,7 +460,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
export async function resendVerificationEmail(): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_RESEND_VERIFICATION, {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('Verification email resent');
return VerificationResult.SUCCESS;
@@ -486,7 +483,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
try {
await http.post(Endpoints.AUTH_AUTHORIZE_IP, {
body: tokenBody(token),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
logger.info('IP authorization successful');
return VerificationResult.SUCCESS;
@@ -504,7 +501,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
export async function resendIpAuthorization(ticket: string): Promise<void> {
await http.post(Endpoints.AUTH_IP_AUTHORIZATION_RESEND, {
body: ticketBody(ticket),
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
}
@@ -517,7 +514,7 @@ export interface IpAuthorizationPollResult {
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
const response = await http.get<IpAuthorizationPollResult>(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), {
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -547,7 +544,7 @@ export async function completeDesktopHandoff({
}): Promise<void> {
await http.post(Endpoints.AUTH_HANDOFF_COMPLETE, {
body: {code, user_id: userId},
headers: withPlatformHeader({Authorization: token}),
headers: withAuthLocaleHeader({Authorization: token}),
auth: 'none',
});
}
@@ -643,7 +640,7 @@ export async function startSso({
};
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
body,
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -651,7 +648,7 @@ export async function startSso({
export async function completeSso({code, state}: {code: string; state: string}): Promise<SsoCompleteResponse> {
const response = await http.post<SsoCompleteResponse>(Endpoints.AUTH_SSO_COMPLETE, {
body: {code, state},
headers: withPlatformHeader(),
headers: withAuthLocaleHeader(),
});
return response.body;
}
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
@@ -18,11 +17,6 @@ const SIGN_IN_CODE_DESCRIPTOR = msg({
message: 'Sign-in code',
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
});
const PRODUCT_DESKTOP_DESCRIPTOR = msg({
message: '{productName} Desktop',
comment:
'Display name for the product desktop client in the authentication handoff approval flow when the raw client name is Electron. Preserve {productName}; it is inserted by code.',
});
const CODE_LENGTH = 12;
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{12}$/;
@@ -53,10 +47,6 @@ function formatLocation(location: {
return parts.length > 0 ? parts.join(', ') : null;
}
function isElectronClientLabel(label: string): boolean {
return label.trim().toLowerCase() === 'electron';
}
interface HandoffApprovalFlowProps {
mode: DesktopHandoffMode;
error: string | null;
@@ -164,11 +154,6 @@ export function HandoffApprovalFlow({
const os = clientInfo?.os ?? null;
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
const hasAnyDeviceInfo = Boolean(platform || os || location);
const platformLabel = platform
? isElectronClientLabel(platform)
? i18n._(PRODUCT_DESKTOP_DESCRIPTOR, {productName: PRODUCT_NAME})
: platform
: null;
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
@@ -183,13 +168,13 @@ export function HandoffApprovalFlow({
</p>
{hasAnyDeviceInfo ? (
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
{platformLabel ? (
{platform ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
<Trans>Platform</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
{platformLabel}
{platform}
</span>
</div>
) : null}
@@ -7,6 +7,8 @@ export class AuthSession {
readonly approxLastUsedAt: Date | null;
readonly clientOs: string | null;
readonly clientPlatform: string | null;
readonly clientBrowser: string | null;
readonly clientDevice: 'mobile' | 'desktop';
readonly clientLocation: string | null;
readonly maskedIp: string | null;
readonly isCurrent: boolean;
@@ -18,6 +20,8 @@ export class AuthSession {
this.clientInfo = data.client_info ?? null;
this.clientOs = this.clientInfo?.os ?? null;
this.clientPlatform = this.clientInfo?.platform ?? null;
this.clientBrowser = this.clientInfo?.browser ?? null;
this.clientDevice = this.clientInfo?.device ?? 'desktop';
this.clientLocation = getLocationLabel(this.clientInfo?.location ?? null);
this.maskedIp = data.masked_ip ?? null;
this.isCurrent = data.current;
@@ -28,6 +28,10 @@ import {
SearchIndexingState,
} from '@app/features/channel/components/channel_search_results/SearchResultsStateViews';
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
} from '@app/features/channel/components/SearchResultGrouping';
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
import {areSegmentsEqual} from '@app/features/channel/components/SearchResultsUtils';
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from '@app/features/channel/components/SearchScopeOptions';
@@ -134,35 +138,27 @@ export const ChannelSearchResults = observer(
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
[successChannels],
);
const messagesByChannel = useMemo(() => {
const grouped = new Map<string, Array<Message>>();
for (const message of successResults) {
if (!grouped.has(message.channelId)) {
grouped.set(message.channelId, []);
}
grouped.get(message.channelId)!.push(message);
}
return grouped;
}, [successResults]);
const resultGroups = useMemo(() => buildSearchResultGroups(successResults), [successResults]);
const resultGroupsByMessageId = useMemo(() => buildSearchResultGroupsByMessageId(resultGroups), [resultGroups]);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(successResults);
const spammerOverrideVersion = LocalUserSpamOverride.version;
const collapsedMessageVisibility = useMemo(
() => ({
isMessageRevealed: (message: Message) => {
const channelMessages = messagesByChannel.get(message.channelId);
if (!channelMessages) return false;
const resultGroup = resultGroupsByMessageId.get(message.id);
if (!resultGroup) return false;
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
if (!messageChannel) return false;
const groupKey = getCollapsedMessageGroupKey({
channel: messageChannel,
messages: channelMessages,
messages: resultGroup.messages,
messageId: message.id,
treatSpam: true,
});
return groupKey != null && revealedGroupKeys.has(groupKey);
},
}),
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
[resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
);
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
setRevealedGroupKeys((current) => {
@@ -606,9 +602,9 @@ export const ChannelSearchResults = observer(
data-message-selection-root="true"
data-flx="channel.channel-search-results.render-content.results-scroller"
>
{Array.from(messagesByChannel.entries()).map(([resultChannelId, messages]) => {
{resultGroups.map((resultGroup) => {
const renderData = getSearchResultChannelRenderData(
resultChannelId,
resultGroup.channelId,
searchChannelsById,
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
);
@@ -649,7 +645,7 @@ export const ChannelSearchResults = observer(
</div>
);
return (
<React.Fragment key={resultChannelId}>
<React.Fragment key={resultGroup.key}>
<MessageContextPrefix
channel={messageChannel}
showGuildMeta={showGuildMeta}
@@ -660,7 +656,7 @@ export const ChannelSearchResults = observer(
/>
<SearchResultMessageList
channel={messageChannel}
messages={messages}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
@@ -725,7 +721,7 @@ export const ChannelSearchResults = observer(
handlePaginationJump,
onCopySelectedMessages,
collapsedMessageVisibility,
messagesByChannel,
resultGroups,
searchChannelsById,
revealedGroupKeys,
handleCollapsedGroupRevealChange,
@@ -16,9 +16,8 @@ import {
EDIT_MESSAGE_DESCRIPTOR,
EMOJIS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Keybind from '@app/features/input/state/InputKeybind';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import {applyMarkdownSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
@@ -155,16 +154,6 @@ export const EditingMessageInput = observer(
if (event.defaultPrevented) {
return;
}
const composer = composerRef.current;
const selection = composer == null ? null : composer.getSelection();
const hasSelectionRange = selection != null && selection.start !== selection.end;
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && actualContent.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
if (event.key === 'Escape' && !event.shiftKey && !event.defaultPrevented && !event.nativeEvent.isComposing) {
event.preventDefault();
event.stopPropagation();
@@ -52,8 +52,7 @@ import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import GuildGuilds from '@app/features/guild/state/Guilds';
import {CANCEL_DESCRIPTOR, CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Keybind from '@app/features/input/state/InputKeybind';
import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {insertComposerEmoji} from '@app/features/lexical/composer/ComposerInsertion';
import {LexicalComposerInput} from '@app/features/lexical/composer/LexicalComposerInput';
import {
@@ -69,7 +68,7 @@ import {showAttachmentPermissionDeniedModal} from '@app/features/messaging/compo
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {TooManyAttachmentsModal} from '@app/features/messaging/components/alerts/TooManyAttachmentsModal';
import {useTextareaAttachments} from '@app/features/messaging/hooks/useCloudUpload';
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission';
import {useTextareaDraftAndTyping} from '@app/features/messaging/hooks/useTextareaDraftAndTyping';
import {useTextareaEditing} from '@app/features/messaging/hooks/useTextareaEditing';
@@ -848,22 +847,9 @@ export const LexicalChannelTextareaContent = observer(
}, [channel.guildId]);
const handleEditorKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
const handle = handleRef.current;
let selection: ComposerSelectionRange | null = null;
if (handle !== null) {
selection = handle.getSelection();
}
const hasSelectionRange = selection ? selection.start !== selection.end : false;
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && value.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
handleEscapeKey(event);
},
[handleEscapeKey, value],
[handleEscapeKey],
);
const handleSubmit = useCallback(() => {
if (!canSubmit) {
@@ -1389,7 +1375,10 @@ export const LexicalChannelTextareaContent = observer(
showStickersButton={showStickersButton}
showEmojiButton={showEmojiButton}
showMessageSendButton={showMessageSendButton}
showVoiceMessageButton={false}
canRecordVoice={canAttachFilesInChannel(channel)}
isEditingMessage={isEditingMessageInComposer || editingMessageId != null}
hasPendingSticker={hasPendingSticker}
voiceTooltipAnchorRef={contentAreaRef}
expressionPickerOpen={expressionPickerOpen}
selectedTab={selectedTab}
isMobile={mobileLayout.enabled}
@@ -67,7 +67,6 @@ import {Popout} from '@app/features/ui/popover/PopoverPopout';
import ContextMenu from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {canUseWindowFocusedActivationClick} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {MessageStates} from '@fluxer/constants/src/ChannelConstants';
@@ -187,14 +186,6 @@ const useShiftKey = (enabled: boolean) => {
}, [enabled]);
return useSyncExternalStore(subscribe, getSnapshot, shiftKeyManager.getServerSnapshot);
};
const suppressBlockedActivationClick = (event: React.SyntheticEvent): boolean => {
if (canUseWindowFocusedActivationClick()) {
return false;
}
event.preventDefault();
event.stopPropagation();
return true;
};
interface MessageActionBarButtonProps {
label: string;
@@ -211,9 +202,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, dataAction}, ref) => {
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick?.(event);
@@ -222,9 +210,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
);
const handlePointerDownCapture = useCallback(
(event: React.PointerEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
onPointerDownCapture?.(event);
},
[onPointerDownCapture],
@@ -275,9 +260,6 @@ const QuickReactionButton = observer(
const {url: displayUrl} = getEmojiDisplayData(emoji);
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
EmojiPickerCommands.trackEmojiUsage(emoji);
@@ -700,7 +682,6 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
uniqueId={`emoji_picker-actionbar-${message.id}`}
shouldAutoUpdate={false}
animationType="none"
shouldOpenOnClick={() => canUseWindowFocusedActivationClick()}
onOpen={handleEmojiPickerOpen}
onClose={handleEmojiPickerClose}
data-flx="channel.message-action-bar.message-action-bar-core.popout"
@@ -2,6 +2,8 @@
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
export const ATTACHMENT_CARD_WIDTH = 400;
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', 'image/avif'];
const VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime'];
@@ -48,7 +48,7 @@
margin-top: var(--message-container-gap, 0.25rem);
}
.urlEmbedSpoiler {
div.urlEmbedSpoiler {
width: 100%;
max-width: 22.5rem;
}
@@ -109,7 +109,6 @@ const SpoileredUrlEmbed = observer(function SpoileredUrlEmbed({
hidden={hidden}
onReveal={reveal}
className={styles.urlEmbedSpoiler}
style={{width: '100%', maxWidth: 360}}
data-flx="channel.message-attachments.spoilered-url-embed"
>
{children}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ATTACHMENT_CARD_WIDTH} from '@app/features/channel/components/MessageAttachmentUtils';
import styles from '@app/features/channel/components/MessageUploadProgress.module.css';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {useMessageUpload} from '@app/features/messaging/hooks/useCloudUpload';
@@ -77,8 +78,8 @@ export const MessageUploadProgress = observer(({attachment, message}: MessageUpl
}
: {
display: 'grid',
width: '400px',
maxWidth: '400px',
width: remFromPx(ATTACHMENT_CARD_WIDTH),
maxWidth: remFromPx(ATTACHMENT_CARD_WIDTH),
};
return (
<div style={containerStyles} data-flx="channel.message-upload-progress.div">
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {describe, expect, it} from 'vitest';
import {
buildSearchResultGroups,
buildSearchResultGroupsByMessageId,
countSearchResultChannels,
} from './SearchResultGrouping';
const message = (id: string, channelId: string): Message => ({id, channelId}) as Message;
describe('SearchResultGrouping', () => {
it('keeps the order the server returned instead of bucketing per channel', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(groups.map((group) => group.messages.map((result) => result.id))).toEqual([['500'], ['400'], ['300']]);
expect(groups.map((group) => group.channelId)).toEqual(['general', 'random', 'general']);
});
it('merges consecutive results from the same channel into one group', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('490', 'general'),
message('400', 'random'),
]);
expect(groups).toHaveLength(2);
expect(groups[0].messages.map((result) => result.id)).toEqual(['500', '490']);
expect(groups[1].messages.map((result) => result.id)).toEqual(['400']);
});
it('gives every group a unique key even when a channel appears more than once', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(new Set(groups.map((group) => group.key)).size).toBe(3);
});
it('indexes each message to the group it renders in', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
const groupsByMessageId = buildSearchResultGroupsByMessageId(groups);
expect(groupsByMessageId.get('500')).toBe(groups[0]);
expect(groupsByMessageId.get('400')).toBe(groups[1]);
expect(groupsByMessageId.get('300')).toBe(groups[2]);
});
it('counts distinct channels rather than groups', () => {
const groups = buildSearchResultGroups([
message('500', 'general'),
message('400', 'random'),
message('300', 'general'),
]);
expect(countSearchResultChannels(groups)).toBe(2);
expect(countSearchResultChannels(buildSearchResultGroups([]))).toBe(0);
});
});
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Message} from '@app/features/messaging/models/MessagingMessage';
export interface SearchResultGroup {
key: string;
channelId: string;
messages: Array<Message>;
}
export const buildSearchResultGroups = (messages: Array<Message>): Array<SearchResultGroup> => {
const groups: Array<SearchResultGroup> = [];
let currentGroup: SearchResultGroup | null = null;
for (const message of messages) {
if (!currentGroup || currentGroup.channelId !== message.channelId) {
currentGroup = {key: `${message.channelId}-${message.id}`, channelId: message.channelId, messages: []};
groups.push(currentGroup);
}
currentGroup.messages.push(message);
}
return groups;
};
export const buildSearchResultGroupsByMessageId = (
groups: Array<SearchResultGroup>,
): Map<string, SearchResultGroup> => {
const groupsByMessageId = new Map<string, SearchResultGroup>();
for (const group of groups) {
for (const message of group.messages) {
groupsByMessageId.set(message.id, group);
}
}
return groupsByMessageId;
};
export const countSearchResultChannels = (groups: Array<SearchResultGroup>): number => {
return new Set(groups.map((group) => group.channelId)).size;
};
@@ -74,7 +74,11 @@ export function formatSlowmodeDuration(ms: number, locale: string): string {
return formatDurationPart(totalSeconds, 'second', locale);
}
if (totalSeconds < SECONDS_PER_HOUR) {
return formatDurationPart(Math.round(totalSeconds / SECONDS_PER_MINUTE), 'minute', locale);
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
const remainingSeconds = totalSeconds % SECONDS_PER_MINUTE;
const minutePart = formatDurationPart(minutes, 'minute', locale);
if (remainingSeconds === 0) return minutePart;
return `${minutePart} ${formatDurationPart(remainingSeconds, 'second', locale)}`;
}
if (totalSeconds < SECONDS_PER_DAY) {
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
@@ -770,8 +770,46 @@ export const UserMessage = observer(() => {
/>
</Tooltip>
)}
{(message.editedTimestamp || message.isEditing) &&
(message.isEditing ? (
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--3">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
) : (
<TimestampWithTooltip
date={message.editedTimestamp!}
className={styles.editedTimestamp}
data-flx="channel.user-message.edited-timestamp--2"
>
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--4">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
</TimestampWithTooltip>
))}
</AuthorHeading>
)}
{((!message.content && !isEditing) || (shouldHideContent && !isEditing)) &&
shouldGroup &&
(message.editedTimestamp || message.isEditing) &&
(message.isEditing ? (
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--5">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
) : (
<TimestampWithTooltip
date={message.editedTimestamp!}
className={styles.editedTimestamp}
data-flx="channel.user-message.edited-timestamp--3"
>
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--6">
{' '}
{i18n._(EDITED_DESCRIPTOR)}
</span>
</TimestampWithTooltip>
))}
<MessageAttachments data-flx="channel.user-message.message-attachments--3" />
{renderFailedFooter()}
</div>
@@ -32,14 +32,18 @@ export const VerificationBarrier = observer(({channel}: Props) => {
case VerificationFailureReason.ACCOUNT_TOO_NEW:
return (
<AccountTooNewBarrier
initialTimeRemaining={verificationStatus.timeRemaining || 0}
initialTimeRemaining={
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
}
data-flx="channel.verification-barrier.account-too-new-barrier"
/>
);
case VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH:
return (
<NotMemberLongEnoughBarrier
initialTimeRemaining={verificationStatus.timeRemaining || 0}
initialTimeRemaining={
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
}
data-flx="channel.verification-barrier.not-member-long-enough-barrier"
/>
);
@@ -50,7 +54,9 @@ export const VerificationBarrier = observer(({channel}: Props) => {
case VerificationFailureReason.TIMED_OUT:
return (
<TimeoutBarrier
initialTimeRemaining={verificationStatus.timeRemaining || 0}
initialTimeRemaining={
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
}
data-flx="channel.verification-barrier.timeout-barrier"
/>
);

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