mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c777b244 | ||
|
|
1544e58e76 | ||
|
|
5d0c9c7cbe | ||
|
|
8f58fcc4c4 | ||
|
|
5799ef705d | ||
|
|
0de7dde1ce | ||
|
|
7b39e5a79d | ||
|
|
583c791016 | ||
|
|
bc5dcdfe21 | ||
|
|
973aaced96 | ||
|
|
3e9ee908f8 | ||
|
|
e7347b582c | ||
|
|
71b7cffabc | ||
|
|
da9e9ff0be | ||
|
|
c6941d5905 | ||
|
|
a3cf960660 | ||
|
|
7eebfca20b | ||
|
|
e9167d96ec | ||
|
|
1664050ef7 |
@@ -16,4 +16,10 @@ Every commit made by a contributor must include the [Developer Certificate of Or
|
||||
|
||||
## Name and marks
|
||||
|
||||
The AGPL does not grant permission to use the Fluxer name, logo or other branding. Forks must use a distinct name and branding unless Fluxer Platform AB grants permission otherwise.
|
||||
Fluxer and the Fluxer logo are trademarks of Fluxer Platform AB. Neither the AGPL nor the CC BY-SA 4.0 licence on Fluxer artwork grants trademark rights. Fluxer Platform AB grants everyone the following permissions.
|
||||
|
||||
- You may distribute unmodified builds of Fluxer, or builds with light patches, under the Fluxer name and logo. Light patches are changes for packaging, portability, security and bug fixes, configuration defaults and translations. Linux distributions, nixpkgs, Flathub and container images are all covered.
|
||||
- A self-hosted instance running such a build may show the Fluxer name and logo under the instance's own name and domain, as long as it does not imply affiliation with or endorsement by Fluxer Platform AB.
|
||||
- You may refer to Fluxer by name to describe compatibility, for example "works with Fluxer".
|
||||
|
||||
Forks with substantive functional changes must use their own name and logo. Any other use needs permission from Fluxer Platform AB. Contact support@fluxer.com.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
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-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
<img src="https://fluxer.app/static/img/screenshots-desktop-readme-1920w.70cb6ce340007e0a.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
@@ -143,14 +143,13 @@ Full setup notes, including canary, are in the [Linux repositories documentation
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||
terms, listed in
|
||||
Fluxer artwork, such as the logo, icons, badges and default avatars, is
|
||||
licensed under [CC BY-SA 4.0](./fluxer_static/LICENSE). Third-party material
|
||||
keeps its own terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
Use of the Fluxer name and logo is covered by the
|
||||
[name and marks policy](./.github/GOVERNANCE.md#name-and-marks).
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
|
||||
@@ -357,9 +357,8 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_GIFT_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
|
||||
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
|
||||
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
|
||||
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
|
||||
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
|
||||
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
|
||||
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080
|
||||
}
|
||||
|
||||
@@ -845,8 +845,6 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -1451,7 +1451,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.",
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -12181,8 +12181,15 @@
|
||||
},
|
||||
"BanCheckResponseSchema": {
|
||||
"type": "object",
|
||||
"properties": {"banned": {"type": "boolean"}},
|
||||
"required": ["banned"],
|
||||
"properties": {
|
||||
"banned": {"type": "boolean"},
|
||||
"expires_at": {
|
||||
"nullable": true,
|
||||
"description": "ISO 8601 timestamp when the matching ban expires. Null when the ban is permanent, when nothing matches, and on every blocklist other than ip.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["banned", "expires_at"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminBlocklistBulkDeleteRequest": {
|
||||
@@ -12961,10 +12968,13 @@
|
||||
"BanUrlDomainRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {"description": "Domain to ban (e.g. example.com)", "type": "string"},
|
||||
"domain": {
|
||||
"description": "Domain to ban (e.g. example.com), or a pattern whose leftmost label contains * under a registrable domain (e.g. *shop*.example.com). Internationalized names are stored in ASCII form.",
|
||||
"type": "string"
|
||||
},
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
@@ -13017,7 +13027,15 @@
|
||||
},
|
||||
"BanIpRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
|
||||
"properties": {
|
||||
"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"},
|
||||
"duration_hours": {
|
||||
"description": "Hours until the ban expires and its entry is removed. Omit it or use 0 for a permanent ban.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 8760
|
||||
}
|
||||
},
|
||||
"required": ["ip"]
|
||||
},
|
||||
"EmailBlocklistEntryType": {"type": "string"},
|
||||
@@ -13060,7 +13078,7 @@
|
||||
"properties": {
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -28,11 +28,23 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
pub async fn ban_ip(
|
||||
&self,
|
||||
ip: &str,
|
||||
duration_hours: u32,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() },
|
||||
generated_types::BanIpRequest {
|
||||
duration_hours: Some(
|
||||
i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
),
|
||||
ip: ip.to_owned(),
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
@@ -136,6 +148,19 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("url-domain", domain, None).await
|
||||
}
|
||||
|
||||
pub async fn list_url_domain_entries(
|
||||
&self,
|
||||
after: Option<&str>,
|
||||
) -> ApiResult<BlocklistEntryPage> {
|
||||
let list_type = blocklist_list_type("url-domain")?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_blocklist_entries(list_type, after, Some(BLOCKLIST_PAGE_SIZE), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_file_sha(
|
||||
&self,
|
||||
sha256_hex: &str,
|
||||
@@ -323,6 +348,8 @@ impl AdminApiClient {
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
const BLOCKLIST_PAGE_SIZE: &str = "200";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
|
||||
@@ -255,9 +255,30 @@ pub enum FlashLevel {
|
||||
pub struct BanCheckResult {
|
||||
pub banned: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub match_subdomains: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntryPage {
|
||||
pub items: Vec<BlocklistEntry>,
|
||||
pub has_more: bool,
|
||||
#[serde(default)]
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BulkBanResult {
|
||||
pub job_id: String,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::AdminApiClient,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError},
|
||||
types::FlashMessage,
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf, htmx},
|
||||
state::AppState,
|
||||
templates,
|
||||
@@ -13,10 +16,12 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ActionQuery;
|
||||
use super::bans_actions::{
|
||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
|
||||
to_flash,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -90,16 +95,7 @@ async fn generic_ban_post(
|
||||
};
|
||||
let value = extract_value(form, ban_cfg.input_name);
|
||||
let is_htmx = htmx::is_htmx_request(headers);
|
||||
let (level, msg) = execute_ban(
|
||||
&client,
|
||||
ban_key,
|
||||
action,
|
||||
&value,
|
||||
form.hashes.as_deref(),
|
||||
form.sha256_list.as_deref(),
|
||||
form.audit_log_reason.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
|
||||
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||
}
|
||||
|
||||
@@ -141,16 +137,56 @@ ban_post!(url_bans_post, "url-bans");
|
||||
ban_post!(file_sha_bans_post, "file-sha-bans");
|
||||
ban_post!(avatar_hash_bans_post, "avatar-hash-bans");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UrlDomainListQuery {
|
||||
after: Option<String>,
|
||||
}
|
||||
|
||||
async fn render_url_domain_page(
|
||||
state: &AppState,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&FlashMessage>,
|
||||
csrf_token: &str,
|
||||
after: Option<&str>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
|
||||
let entries = match client.list_url_domain_entries(after).await {
|
||||
Ok(page) => Some(page),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: list URL domain blocklist");
|
||||
None
|
||||
}
|
||||
};
|
||||
let markup = templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
flash,
|
||||
csrf_token,
|
||||
entries.as_ref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn ban_url_domain_error(domain: &str, error: &ApiError) -> String {
|
||||
match error {
|
||||
ApiError::Http { status: 400, .. } => {
|
||||
format!("Failed to ban {domain}: not a valid domain, or the pattern is too broad")
|
||||
}
|
||||
_ => format!("Failed to ban {domain}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn url_domain_bans(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let csrf_token = csrf::get_csrf_token(&request);
|
||||
let markup =
|
||||
templates::pages::url_domain_bans::url_domain_bans_page(config, &auth.0, None, &csrf_token);
|
||||
Html(markup.into_string()).into_response()
|
||||
let Query(query): Query<UrlDomainListQuery> =
|
||||
Query::try_from_uri(request.uri()).unwrap_or(Query(UrlDomainListQuery { after: None }));
|
||||
let after = query.after.as_deref().filter(|value| !value.is_empty());
|
||||
render_url_domain_page(&state, &auth.0, None, &csrf_token, after).await
|
||||
}
|
||||
|
||||
async fn url_domain_bans_post(
|
||||
@@ -181,10 +217,10 @@ async fn url_domain_bans_post(
|
||||
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
||||
Ok(()) => ("success", format!("{domain} banned successfully")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
||||
("error", format!("Failed to ban domain {domain}"))
|
||||
("error", ban_url_domain_error(&domain, &error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,15 +228,15 @@ async fn url_domain_bans_post(
|
||||
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
||||
Ok(()) => ("success", format!("{domain} unbanned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
||||
("error", format!("Failed to unban domain {domain}"))
|
||||
("error", format!("Failed to unban {domain}"))
|
||||
}
|
||||
},
|
||||
"check" => match client.check_url_domain_ban(&domain).await {
|
||||
Ok(r) if r.banned => ("info", format!("Domain {domain} is banned")),
|
||||
Ok(_) => ("info", format!("Domain {domain} is NOT banned")),
|
||||
Ok(r) if r.banned => ("info", format!("{domain} is blocked")),
|
||||
Ok(_) => ("info", format!("{domain} is NOT blocked")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: check URL domain ban");
|
||||
("error", "Error checking ban status".into())
|
||||
@@ -208,15 +244,11 @@ async fn url_domain_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"url-domain",
|
||||
)
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, &msg);
|
||||
}
|
||||
let flash = to_flash(level, &msg);
|
||||
render_url_domain_page(&state, &auth.0, Some(&flash), &csrf_token, None).await
|
||||
}
|
||||
|
||||
async fn profile_substring_bans(
|
||||
@@ -288,13 +320,5 @@ async fn profile_substring_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"profile-substring",
|
||||
)
|
||||
custom_flash(config, &auth.0, is_htmx, level, &msg, &csrf_token)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct BanFormData {
|
||||
#[serde(default)]
|
||||
pub substring: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration_hours: Option<String>,
|
||||
#[serde(default)]
|
||||
pub audit_log_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub _csrf: Option<String>,
|
||||
@@ -58,10 +60,12 @@ pub async fn execute_ban(
|
||||
ban_type: &str,
|
||||
action: &str,
|
||||
value: &str,
|
||||
bulk_hashes: Option<&str>,
|
||||
bulk_sha256_list: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
form: &BanFormData,
|
||||
) -> (&'static str, String) {
|
||||
let bulk_hashes = form.hashes.as_deref();
|
||||
let bulk_sha256_list = form.sha256_list.as_deref();
|
||||
let duration_hours = form.duration_hours.as_deref();
|
||||
let audit_log_reason = form.audit_log_reason.as_deref();
|
||||
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
||||
let raw_hashes = if action == "bulk-ban-files" {
|
||||
bulk_sha256_list
|
||||
@@ -74,6 +78,9 @@ pub async fn execute_ban(
|
||||
return ("error", "Value is required".into());
|
||||
}
|
||||
match action {
|
||||
"ban" if ban_type == "ip-bans" => {
|
||||
execute_ip_ban(client, value, duration_hours, audit_log_reason).await
|
||||
}
|
||||
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
||||
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
||||
"check" => execute_check(client, ban_type, value).await,
|
||||
@@ -107,6 +114,34 @@ async fn execute_bulk_ban(
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_ip_ban(
|
||||
client: &AdminApiClient,
|
||||
value: &str,
|
||||
duration_hours: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let duration_hours = match duration_hours.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
None => 0,
|
||||
Some(raw) => match raw.parse::<u32>() {
|
||||
Ok(hours) => hours,
|
||||
Err(_) => return ("error", "Invalid ban duration".into()),
|
||||
},
|
||||
};
|
||||
let success_message = if duration_hours == 0 {
|
||||
format!("{value} banned permanently")
|
||||
} else {
|
||||
format!(
|
||||
"{value} banned for {}",
|
||||
crate::templates::pages::bans::ip_ban_duration_label(duration_hours)
|
||||
)
|
||||
};
|
||||
ban_action_result(
|
||||
client.ban_ip(value, duration_hours, audit_log_reason).await,
|
||||
success_message,
|
||||
format!("Failed to ban {value}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_single_ban(
|
||||
client: &AdminApiClient,
|
||||
ban_type: &str,
|
||||
@@ -114,7 +149,6 @@ async fn execute_single_ban(
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
||||
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
||||
@@ -166,7 +200,10 @@ async fn execute_check(
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => ("info", format!("{value} is banned")),
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
|
||||
None => ("info", format!("{value} is banned")),
|
||||
},
|
||||
Ok(_) => ("info", format!("{value} is NOT banned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
||||
@@ -243,25 +280,16 @@ pub fn custom_flash(
|
||||
level: &str,
|
||||
message: &str,
|
||||
csrf_token: &str,
|
||||
page_type: &str,
|
||||
) -> Response {
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, message);
|
||||
}
|
||||
let flash = to_flash(level, message);
|
||||
let markup = match page_type {
|
||||
"url-domain" => templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
_ => templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
};
|
||||
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -243,8 +243,11 @@ pub async fn dispatch(
|
||||
let Some(ip) = get("ip") else {
|
||||
return DispatchOutcome::error("IP address is required");
|
||||
};
|
||||
let Ok(duration) = form.parse_value::<u32>("duration_hours") else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client.ban_ip(&ip, None).await,
|
||||
client.ban_ip(&ip, duration.unwrap_or(0), None).await,
|
||||
"IP banned successfully",
|
||||
"Failed to ban IP",
|
||||
)
|
||||
|
||||
@@ -20,6 +20,24 @@ pub struct BanConfig {
|
||||
pub entity_name: &'static str,
|
||||
pub active_page: &'static str,
|
||||
pub show_bulk_tools: bool,
|
||||
pub show_duration: bool,
|
||||
}
|
||||
|
||||
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
|
||||
(24, "1 day"),
|
||||
(168, "7 days"),
|
||||
(720, "30 days"),
|
||||
(0, "Permanent"),
|
||||
];
|
||||
|
||||
pub fn ip_ban_duration_label(hours: u32) -> String {
|
||||
IP_BAN_DURATIONS
|
||||
.iter()
|
||||
.find(|(value, _)| *value == hours)
|
||||
.map_or_else(
|
||||
|| format!("{hours} hours"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
@@ -33,6 +51,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "IP/CIDR",
|
||||
active_page: "ip-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: true,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
@@ -44,6 +63,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Email",
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
@@ -55,6 +75,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Phrase",
|
||||
active_page: "phrase-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Blocklist",
|
||||
@@ -66,6 +87,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "URL",
|
||||
active_page: "url-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "File SHA Blocklist",
|
||||
@@ -77,6 +99,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "SHA-256",
|
||||
active_page: "file-sha-bans",
|
||||
show_bulk_tools: true,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Avatar Hash Blocklist",
|
||||
@@ -88,6 +111,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Avatar Hash",
|
||||
active_page: "avatar-hash-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Domain Blocklist",
|
||||
@@ -99,6 +123,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Domain",
|
||||
active_page: "url-domain-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Profile Substring Blocklist",
|
||||
@@ -110,6 +135,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Substring",
|
||||
active_page: "profile-substring-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -163,6 +189,9 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(form_field(cfg.input_name, cfg.input_label, cfg.input_type, cfg.placeholder, true))
|
||||
@if cfg.show_duration {
|
||||
(duration_field())
|
||||
}
|
||||
(form_field("audit_log_reason", "Private reason (audit log, optional)", "text", "Why is this ban being applied?", false))
|
||||
(submit_btn("Ban", cfg.entity_name, false))
|
||||
}
|
||||
@@ -394,6 +423,24 @@ fn form_field(
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_field() -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
label for="duration_hours" class="block text-sm font-medium text-neutral-700" {
|
||||
"Duration"
|
||||
}
|
||||
select id="duration_hours" name="duration_hours"
|
||||
class="block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
|
||||
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
|
||||
focus:ring-brand-primary" {
|
||||
@for &(value, label) in IP_BAN_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn textarea_field(name: &str, label: &str, required: bool) -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{BlocklistEntry, BlocklistEntryPage},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{form::checkbox, page_container::page_header},
|
||||
components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{checkbox, csrf_input},
|
||||
page_container::page_header,
|
||||
table::{data_table, empty_state, table_cell, table_row},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::blocklist_helpers::{
|
||||
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
|
||||
@@ -13,15 +19,21 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const PAGE_DESCRIPTION: &str = "A domain entry blocks that host and, when it matches subdomains, every host under it. \
|
||||
A pattern such as *shop*.example.com matches the one label left of a registrable domain, so it blocks \
|
||||
shop.example.com and my-shop-2.example.com but never example.com itself. Patterns are matched against the \
|
||||
ASCII form of a host.";
|
||||
|
||||
pub fn url_domain_bans_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&crate::api::types::FlashMessage>,
|
||||
csrf_token: &str,
|
||||
entries: Option<&BlocklistEntryPage>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header("URL Domain Blocklist", None))
|
||||
(page_header("URL Domain Blocklist", Some(PAGE_DESCRIPTION)))
|
||||
div class="grid gap-6 lg:grid-cols-2" {
|
||||
(ban_card(base, csrf_token))
|
||||
(check_card(base, csrf_token))
|
||||
@@ -29,6 +41,9 @@ pub fn url_domain_bans_page(
|
||||
div class="mt-6" {
|
||||
(unban_card(base, csrf_token))
|
||||
}
|
||||
div class="mt-6" {
|
||||
(entries_card(base, csrf_token, entries))
|
||||
}
|
||||
};
|
||||
admin_layout(
|
||||
config,
|
||||
@@ -43,15 +58,15 @@ pub fn url_domain_bans_page(
|
||||
fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=ban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Ban URL Domain",
|
||||
"Ban URL Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(checkbox("match_subdomains", "true", "Match subdomains (e.g. sub.example.com)", true, true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being applied?", false))
|
||||
},
|
||||
"Ban Domain",
|
||||
"Ban",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -59,13 +74,13 @@ fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=check&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Check Domain Ban Status",
|
||||
"Test a Host or URL",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Host or URL", "shop-2.example.com or https://shop.example.com/x", true))
|
||||
},
|
||||
"Check Status",
|
||||
"Test",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -73,14 +88,131 @@ fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn unban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Remove Domain Ban",
|
||||
"Remove Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being removed?", false))
|
||||
},
|
||||
"Unban Domain",
|
||||
"Unban",
|
||||
BlocklistActionVariant::Danger,
|
||||
)
|
||||
}
|
||||
|
||||
fn entries_card(base: &str, csrf_token: &str, entries: Option<&BlocklistEntryPage>) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
div class="mb-4 flex items-center justify-between gap-4" {
|
||||
h3 class="text-base font-medium text-neutral-900" { "Blocked Domains and Patterns" }
|
||||
a href={(base) "/url-domain-bans"} class="text-sm text-brand-primary hover:underline" { "Refresh" }
|
||||
}
|
||||
@match entries {
|
||||
None => {
|
||||
p class="text-sm text-red-700" { "Failed to load the blocklist entries" }
|
||||
}
|
||||
Some(page) => {
|
||||
(entries_table(base, csrf_token, page))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_table(base: &str, csrf_token: &str, page: &BlocklistEntryPage) -> Markup {
|
||||
if page.items.is_empty() {
|
||||
return empty_state("No domains or patterns are blocked");
|
||||
}
|
||||
let next_after = page.next_after.as_deref().filter(|_| page.has_more);
|
||||
html! {
|
||||
(data_table(
|
||||
&["Value", "Kind", "Subdomains", "Category", "Added", ""],
|
||||
html! {
|
||||
@for entry in &page.items {
|
||||
(entry_row(base, csrf_token, entry))
|
||||
}
|
||||
},
|
||||
))
|
||||
@if let Some(next) = next_after {
|
||||
div class="mt-4" {
|
||||
a href={(base) "/url-domain-bans?after=" (urlencoding::encode(next))}
|
||||
class="text-sm text-brand-primary hover:underline" {
|
||||
"Next page"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
let is_pattern = entry.value.contains('*');
|
||||
table_row(html! {
|
||||
(table_cell(false, html! { code class="break-all" { (entry.value) } }))
|
||||
(table_cell(false, html! {
|
||||
@if is_pattern {
|
||||
(badge("Pattern", BadgeVariant::Info))
|
||||
} @else {
|
||||
(badge("Domain", BadgeVariant::Default))
|
||||
}
|
||||
}))
|
||||
(table_cell(true, html! {
|
||||
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
|
||||
}))
|
||||
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
|
||||
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
|
||||
(table_cell(false, html! {
|
||||
form method="post" action=(action_url) {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="domain" value=(entry.value);
|
||||
button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700" {
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
|
||||
BlocklistEntry {
|
||||
value: value.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
category: Some("manual".to_owned()),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_lists_patterns_with_remove_forms() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: vec![
|
||||
entry("*shop*.example.com", false),
|
||||
entry("store.example.com", true),
|
||||
],
|
||||
has_more: true,
|
||||
next_after: Some("store.example.com".to_owned()),
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("*shop*.example.com"));
|
||||
assert!(markup.contains(">Pattern</span>"));
|
||||
assert!(markup.contains(">Domain</span>"));
|
||||
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
|
||||
assert!(markup.contains("/admin/url-domain-bans?action=unban&_csrf=token"));
|
||||
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_reports_an_empty_list() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: Vec::new(),
|
||||
has_more: false,
|
||||
next_after: None,
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("No domains or patterns are blocked"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,6 +859,14 @@ fn deserialize_ban_check_response() {
|
||||
assert!(resp.banned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ban_check_response_with_expiry() {
|
||||
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
|
||||
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.banned);
|
||||
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_codes_response() {
|
||||
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"sharp": "catalog:",
|
||||
"stripe": "catalog:",
|
||||
"tempy": "catalog:",
|
||||
"tldts": "catalog:",
|
||||
"transliteration": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"uint8array-extras": "catalog:",
|
||||
|
||||
@@ -161,38 +161,43 @@ export class AdminRepository implements IAdminRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
async banIp(ip: string): Promise<void> {
|
||||
if (isIpBanExempt(ip)) {
|
||||
return;
|
||||
}
|
||||
const canonicalIp = canonicalizeBannedIpEntry(ip);
|
||||
await upsertOne(
|
||||
BannedIps.insert({
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'permanent',
|
||||
reason: 'platform_admin_enforcement',
|
||||
expires_at: null,
|
||||
created_at: new Date(),
|
||||
}),
|
||||
);
|
||||
async banIp(ip: string, ttlSeconds: number | null = null): Promise<void> {
|
||||
await this.writeIpBan(ip, 'platform_admin_enforcement', ttlSeconds);
|
||||
}
|
||||
|
||||
async banIpTemp(ip: string, ttlSeconds: number): Promise<void> {
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||
await this.writeIpBan(ip, 'abusive_api_access_patterns', ttlSeconds);
|
||||
}
|
||||
|
||||
private async writeIpBan(ip: string, reason: string, ttlSeconds: number | null): Promise<void> {
|
||||
if (ttlSeconds !== null && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
|
||||
throw new RangeError('Temporary IP ban TTL must be a positive integer');
|
||||
}
|
||||
if (isIpBanExempt(ip)) {
|
||||
return;
|
||||
}
|
||||
const canonicalIp = canonicalizeBannedIpEntry(ip);
|
||||
const createdAt = new Date();
|
||||
if (ttlSeconds === null) {
|
||||
await upsertOne(
|
||||
BannedIps.insert({
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'permanent',
|
||||
reason,
|
||||
expires_at: null,
|
||||
created_at: createdAt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await upsertOne(
|
||||
BannedIps.insertWithTtl(
|
||||
{
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'temporary_24h',
|
||||
reason: 'abusive_api_access_patterns',
|
||||
expires_at: new Date(Date.now() + ttlSeconds * 1000),
|
||||
created_at: new Date(),
|
||||
reason,
|
||||
expires_at: new Date(createdAt.getTime() + ttlSeconds * 1000),
|
||||
created_at: createdAt,
|
||||
},
|
||||
ttlSeconds,
|
||||
),
|
||||
@@ -228,13 +233,16 @@ export class AdminRepository implements IAdminRepository {
|
||||
expires_at?: Date | null;
|
||||
created_at?: Date | null;
|
||||
}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
|
||||
return rows.map((row) => ({
|
||||
ip: row.ip,
|
||||
kind: parseBannedIpKind(row.ban_kind),
|
||||
reason: row.reason ?? null,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
}));
|
||||
const now = Date.now();
|
||||
return rows
|
||||
.filter((row) => !row.expires_at || row.expires_at.getTime() > now)
|
||||
.map((row) => ({
|
||||
ip: row.ip,
|
||||
kind: parseBannedIpKind(row.ban_kind),
|
||||
reason: row.reason ?? null,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async isEmailBanned(email: string): Promise<boolean> {
|
||||
|
||||
@@ -43,7 +43,7 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract isIpBanned(ip: string): Promise<boolean>;
|
||||
|
||||
abstract banIp(ip: string): Promise<void>;
|
||||
abstract banIp(ip: string, ttlSeconds?: number | null): Promise<void>;
|
||||
|
||||
abstract banIpTemp(ip: string, ttlSeconds: number): Promise<void>;
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ const BLOCKLIST_CATALOG = [
|
||||
{
|
||||
list_type: 'ip' as const,
|
||||
description:
|
||||
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively.',
|
||||
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively. An entry can carry an expiry, after which it stops applying and is removed.',
|
||||
value_field: 'ip',
|
||||
fields: [],
|
||||
fields: ['duration_hours'],
|
||||
scoped: false,
|
||||
supports_bulk_create: false,
|
||||
supports_bulk_delete: false,
|
||||
@@ -99,7 +99,8 @@ const BLOCKLIST_CATALOG = [
|
||||
},
|
||||
{
|
||||
list_type: 'url-domain' as const,
|
||||
description: 'Domains blocked from being linked, optionally covering every subdomain rooted at the domain.',
|
||||
description:
|
||||
'Domains blocked from being linked, optionally covering every subdomain rooted at the domain. A value whose leftmost label contains * is a pattern that matches that one label under a registrable domain.',
|
||||
value_field: 'domain',
|
||||
fields: ['match_subdomains', 'category', 'severity', 'source_url', 'notes'],
|
||||
scoped: false,
|
||||
@@ -232,7 +233,7 @@ async function checkBlocklistEntry(
|
||||
listType: AdminBlocklistListType,
|
||||
entryValue: string,
|
||||
scope: ProfileSubstringScope | undefined,
|
||||
): Promise<{banned: boolean}> {
|
||||
): Promise<{banned: boolean; expires_at?: string | null}> {
|
||||
switch (listType) {
|
||||
case 'ip':
|
||||
return bans.checkIpBan({ip: entryValue});
|
||||
@@ -488,7 +489,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.',
|
||||
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
@@ -507,7 +508,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
banned: result.banned,
|
||||
},
|
||||
});
|
||||
return ctx.json(result);
|
||||
return ctx.json({banned: result.banned, expires_at: result.expires_at ?? null});
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
|
||||
@@ -24,6 +24,7 @@ import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
|
||||
import {parseUrlDomainEntry} from '@app/api/utils/UrlHostRules';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -108,6 +109,16 @@ function normalizeAvatarHashes(hashes: Array<string>): Array<string> {
|
||||
return Array.from(new Set(hashes.map((hash) => stripAvatarAnimationPrefix(hash.toLowerCase()))));
|
||||
}
|
||||
|
||||
function hostFromUrlOrHostname(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
try {
|
||||
return new URL(trimmed).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withReasonMetadata(entries: Array<[string, string]>, reason: string | undefined): Map<string, string> {
|
||||
if (!reason) {
|
||||
return new Map(entries);
|
||||
@@ -122,6 +133,7 @@ export class AdminBanManagementService {
|
||||
async banIp(
|
||||
data: {
|
||||
ip: string;
|
||||
duration_hours?: number;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
@@ -142,15 +154,24 @@ export class AdminBanManagementService {
|
||||
message: 'This IP address is on the instance exemption list',
|
||||
});
|
||||
}
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
const durationHours = data.duration_hours ?? 0;
|
||||
const metadata = new Map([['ip', data.ip]]);
|
||||
if (durationHours > 0) {
|
||||
const ttlSeconds = durationHours * 3600;
|
||||
await adminRepository.banIp(data.ip, ttlSeconds);
|
||||
metadata.set('duration_hours', durationHours.toString());
|
||||
metadata.set('expires_at', new Date(Date.now() + ttlSeconds * 1000).toISOString());
|
||||
} else {
|
||||
await adminRepository.banIp(data.ip);
|
||||
}
|
||||
await ipBanCache.refresh();
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
action: 'ban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,9 +198,10 @@ export class AdminBanManagementService {
|
||||
|
||||
async checkIpBan(data: {ip: string}): Promise<{
|
||||
banned: boolean;
|
||||
expires_at: string | null;
|
||||
}> {
|
||||
const banned = ipBanCache.isBanned(data.ip);
|
||||
return {banned};
|
||||
const match = ipBanCache.getMatch(data.ip);
|
||||
return {banned: match !== null, expires_at: toIsoString(match?.expiresAt)};
|
||||
}
|
||||
|
||||
async banEmail(
|
||||
@@ -355,7 +377,9 @@ export class AdminBanManagementService {
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const d = data.domain.toLowerCase();
|
||||
const entry = parseUrlDomainEntry(data.domain);
|
||||
if (!entry.ok) throw InputValidationError.create('domain', entry.message);
|
||||
const d = entry.value;
|
||||
const matchSubs = data.match_subdomains ?? true;
|
||||
await adminRepository.banUrlDomain({
|
||||
domain: d,
|
||||
@@ -367,7 +391,7 @@ export class AdminBanManagementService {
|
||||
added_by: adminUserId,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
urlBlocklistCache.addDomain(d);
|
||||
urlBlocklistCache.addDomain(d, matchSubs);
|
||||
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
@@ -377,6 +401,7 @@ export class AdminBanManagementService {
|
||||
metadata: new Map([
|
||||
['domain', d],
|
||||
['match_subdomains', String(matchSubs)],
|
||||
['pattern', String(entry.pattern)],
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -390,7 +415,8 @@ export class AdminBanManagementService {
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const d = data.domain.toLowerCase();
|
||||
const entry = parseUrlDomainEntry(data.domain);
|
||||
const d = entry.ok ? entry.value : data.domain.trim().toLowerCase();
|
||||
await adminRepository.unbanUrlDomain(d);
|
||||
urlBlocklistCache.removeDomain(d);
|
||||
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
|
||||
@@ -406,7 +432,8 @@ export class AdminBanManagementService {
|
||||
async checkUrlDomainBan(data: {domain: string}): Promise<{
|
||||
banned: boolean;
|
||||
}> {
|
||||
return {banned: urlBlocklistCache.isHostnameBanned(data.domain)};
|
||||
const host = hostFromUrlOrHostname(data.domain);
|
||||
return {banned: host != null && urlBlocklistCache.isHostnameBanned(host)};
|
||||
}
|
||||
|
||||
async banFileSha(
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
code: string;
|
||||
errors?: Array<{path: string; message: string}>;
|
||||
}
|
||||
|
||||
interface EntryPage {
|
||||
items: Array<{value: string; match_subdomains: boolean | null}>;
|
||||
}
|
||||
|
||||
describe('Admin url-domain blocklist patterns', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'ban:url_domain:add',
|
||||
'ban:url_domain:check',
|
||||
'ban:url_domain:remove',
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function add(domain: string, matchSubdomains?: boolean): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/blocklists/url-domain/entries')
|
||||
.body(matchSubdomains === undefined ? {domain} : {domain, match_subdomains: matchSubdomains})
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function check(value: string): Promise<boolean> {
|
||||
const json = await createBuilder<{banned: boolean}>(harness, admin.token)
|
||||
.get(`/admin/blocklists/url-domain/entries/${encodeURIComponent(value)}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
return json.banned;
|
||||
}
|
||||
|
||||
async function list(): Promise<EntryPage['items']> {
|
||||
const json = await createBuilder<EntryPage>(harness, admin.token)
|
||||
.get('/admin/blocklists/url-domain/entries?limit=200')
|
||||
.expect(200)
|
||||
.execute();
|
||||
return json.items;
|
||||
}
|
||||
|
||||
it('stores a canonical pattern and reports the hosts it covers', async () => {
|
||||
await add('**Shop**.OnRender.com.');
|
||||
expect(await list()).toMatchObject([{value: '*shop*.onrender.com', match_subdomains: true}]);
|
||||
expect(await check('shop-2.onrender.com')).toBe(true);
|
||||
expect(await check('https://www.myshop.onrender.com/checkout')).toBe(true);
|
||||
expect(await check('onrender.com')).toBe(false);
|
||||
expect(await check('docs.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('records whether the entry is a pattern in the audit log', async () => {
|
||||
await add('*shop*.onrender.com', false);
|
||||
await add('shop.example.com');
|
||||
const logs = (await getAdminRepository().listAllAuditLogsPaginated(1000)).filter(
|
||||
(log) => log.action === 'ban_url_domain',
|
||||
);
|
||||
const metadata = logs.map((log) => Object.fromEntries(log.metadata));
|
||||
expect(metadata).toEqual(
|
||||
expect.arrayContaining([
|
||||
{domain: '*shop*.onrender.com', match_subdomains: 'false', pattern: 'true'},
|
||||
{domain: 'shop.example.com', match_subdomains: 'true', pattern: 'false'},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects patterns that are too broad or malformed', async () => {
|
||||
for (const domain of ['*', '*.com', '*shop*.co.uk', '*.onrender.com', '*ab*.onrender.com', 'shop.*.example.com']) {
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
|
||||
.post('/admin/blocklists/url-domain/entries')
|
||||
.body({domain})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(json.errors?.[0]?.path, domain).toBe('domain');
|
||||
}
|
||||
expect(await list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('validates the value on update', async () => {
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
|
||||
.patch(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*.com')}`)
|
||||
.body({})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(json.errors?.[0]?.path).toBe('domain');
|
||||
});
|
||||
|
||||
it('stores internationalized domains in ASCII form', async () => {
|
||||
await add('Bücher.Example.');
|
||||
expect((await list()).map((entry) => entry.value)).toEqual(['xn--bcher-kva.example']);
|
||||
expect(await check('www.bücher.example')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an add for a domain that is already blocked', async () => {
|
||||
await add('shop.example.com');
|
||||
await add('shop.example.com', false);
|
||||
expect(await list()).toMatchObject([{value: 'shop.example.com', match_subdomains: false}]);
|
||||
});
|
||||
|
||||
it('removes a pattern through any spelling that canonicalizes to it', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*SHOP**.onrender.com')}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await list()).toEqual([]);
|
||||
expect(await check('shop.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks messages whose masked links or autolinks point at a covered host', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, member.token, 'Links');
|
||||
const channel = await createChannel(harness, member.token, guild.id, 'general');
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
for (const content of [
|
||||
'[open the store](https://shop-2.onrender.com)',
|
||||
'<https://[email protected]:8443/x>',
|
||||
'https://SHOP.onrender.com./',
|
||||
]) {
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content})
|
||||
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
}
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: '[docs](https://docs.onrender.com) and https://onrender.com'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('blocks rich embeds that link to a covered host', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, member.token, 'Embeds');
|
||||
const channel = await createChannel(harness, member.token, guild.id, 'general');
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({embeds: [{title: 'Store', url: 'https://shop.onrender.com/'}]})
|
||||
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BlocklistEntryPage {
|
||||
items: Array<{value: string; reason: string | null; expires_at: string | null; created_at: string | null}>;
|
||||
}
|
||||
|
||||
interface BlocklistCheck {
|
||||
banned: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
describe('Admin IP bans with an expiry', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.BAN_IP_ADD,
|
||||
AdminACLs.BAN_IP_CHECK,
|
||||
AdminACLs.BAN_IP_REMOVE,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
async function addIpBan(body: Record<string, unknown>, status: number = HTTP_STATUS.NO_CONTENT): Promise<void> {
|
||||
await createBuilder(harness, admin.token).post('/admin/blocklists/ip/entries').body(body).expect(status).execute();
|
||||
}
|
||||
|
||||
async function listIpBans(): Promise<BlocklistEntryPage['items']> {
|
||||
const page = await createBuilder<BlocklistEntryPage>(harness, admin.token)
|
||||
.get('/admin/blocklists/ip/entries')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return page.items;
|
||||
}
|
||||
|
||||
async function checkIpBan(ip: string): Promise<BlocklistCheck> {
|
||||
return createBuilder<BlocklistCheck>(harness, admin.token)
|
||||
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(ip)}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function banIpAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
|
||||
return logs.filter((log) => log.action === 'ban_ip');
|
||||
}
|
||||
|
||||
it('stores an expiring ban that the listing and the check both report', async () => {
|
||||
const before = Date.now();
|
||||
await addIpBan({ip: '198.51.100.7', duration_hours: 24});
|
||||
|
||||
const [entry] = await listIpBans();
|
||||
expect(entry?.value).toBe('198.51.100.7');
|
||||
expect(entry?.reason).toBe('platform_admin_enforcement');
|
||||
const expiresAt = Date.parse(entry?.expires_at ?? '');
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 24 * HOUR_MS);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 24 * HOUR_MS);
|
||||
|
||||
const check = await checkIpBan('198.51.100.7');
|
||||
expect(check.banned).toBe(true);
|
||||
expect(Date.parse(check.expires_at ?? '')).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('records the duration and expiry in the audit log', async () => {
|
||||
await addIpBan({ip: '198.51.100.8', duration_hours: 168});
|
||||
|
||||
const [log] = await banIpAuditLogs();
|
||||
const metadata = Object.fromEntries(log!.metadata);
|
||||
expect(metadata['ip']).toBe('198.51.100.8');
|
||||
expect(metadata['duration_hours']).toBe('168');
|
||||
expect(Date.parse(metadata['expires_at'] ?? '')).toBeGreaterThan(Date.now() + 167 * HOUR_MS);
|
||||
});
|
||||
|
||||
it('keeps a ban permanent when no duration is given', async () => {
|
||||
await addIpBan({ip: '198.51.100.9'});
|
||||
await addIpBan({ip: '198.51.100.10', duration_hours: 0});
|
||||
|
||||
const entries = await listIpBans();
|
||||
expect(entries.map((entry) => entry.expires_at)).toEqual([null, null]);
|
||||
expect(await checkIpBan('198.51.100.9')).toEqual({banned: true, expires_at: null});
|
||||
const [log] = await banIpAuditLogs();
|
||||
expect(log!.metadata.has('duration_hours')).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces a permanent ban with an expiring one when the address is banned again', async () => {
|
||||
await addIpBan({ip: '198.51.100.11'});
|
||||
await addIpBan({ip: '198.51.100.11', duration_hours: 24});
|
||||
|
||||
const [entry] = await listIpBans();
|
||||
expect(entry?.expires_at).not.toBeNull();
|
||||
const check = await checkIpBan('198.51.100.11');
|
||||
expect(check.banned).toBe(true);
|
||||
expect(check.expires_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a duration beyond one year', async () => {
|
||||
await addIpBan({ip: '198.51.100.12', duration_hours: 8761}, HTTP_STATUS.BAD_REQUEST);
|
||||
await addIpBan({ip: '198.51.100.12', duration_hours: 1.5}, HTTP_STATUS.BAD_REQUEST);
|
||||
|
||||
expect(await listIpBans()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a check with no match as not banned with no expiry', async () => {
|
||||
expect(await checkIpBan('198.51.100.13')).toEqual({banned: false, expires_at: null});
|
||||
});
|
||||
|
||||
it('stops applying an expiring ban once its expiry has passed', async () => {
|
||||
await new AdminRepository().banIp('198.51.100.14', 1);
|
||||
await ipBanCache.refresh();
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
|
||||
await ipBanCache.refresh();
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
|
||||
expect(await listIpBans()).toEqual([]);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -56,8 +56,8 @@ async function verifySudoMode(
|
||||
const apiContext = ctx.get('apiContext');
|
||||
const credentials = await apiContext.services.users.listWebAuthnCredentials(user.id);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const hasMfa = userHasMfa(user);
|
||||
const hasSudoCapability = userHasSudoCapability(user, hasPasskeyCredentials);
|
||||
const hasUsableMfa = userHasMfa(user) && hasSudoCapability;
|
||||
const issueSudoToken = options.issueSudoToken ?? hasSudoCapability;
|
||||
if (hasSudoCapability && ctx.get('sudoModeValid')) {
|
||||
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
|
||||
@@ -82,10 +82,10 @@ async function verifySudoMode(
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
if (hasNoVerifiableCredential(user, hasMfa, hasPasskeyCredentials)) {
|
||||
if (hasNoVerifiableCredential(user, hasUsableMfa, hasPasskeyCredentials)) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
if (body.password && !hasMfa) {
|
||||
if (body.password && !hasUsableMfa) {
|
||||
if (!user.passwordHash) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setWebAuthnTwoFactor} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface PrivateUserResponse {
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
}
|
||||
|
||||
interface SudoModeRequiredResponse {
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
}
|
||||
|
||||
async function setAuthenticatorTypes(account: TestAccount, types: Array<number>): Promise<void> {
|
||||
const users = getUserRepository();
|
||||
const user = (await users.findUnique(createUserID(BigInt(account.userId))))!;
|
||||
await users.patchUpsert(user.id, {authenticator_types: new Set<number>(types)}, user.toRow());
|
||||
}
|
||||
|
||||
describe('Sudo mode for accounts whose authenticator types list no usable factor', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('accepts the password and lets the account turn passkey two-factor off when no passkey remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setAuthenticatorTypes(account, [UserAuthenticatorTypes.WEBAUTHN]);
|
||||
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
expect(challenge.has_mfa).toBe(false);
|
||||
const disabled = await setWebAuthnTwoFactor(harness, account.token, false, {password: account.password});
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the password when the TOTP type is listed without a stored secret', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setAuthenticatorTypes(account, [UserAuthenticatorTypes.TOTP]);
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: 'wrong-password'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
await setWebAuthnTwoFactor(harness, account.token, false, {password: account.password});
|
||||
});
|
||||
|
||||
it('still refuses the password from an account with TOTP enrolled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: account.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
expect(challenge.has_mfa).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ function attachment(id: bigint, hash: string | null): MessageAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function message(attachments: Array<MessageAttachment>): Message {
|
||||
function message(attachments: Array<MessageAttachment>, content = ''): Message {
|
||||
return new Message({
|
||||
channel_id: createChannelID(10n),
|
||||
bucket: 0,
|
||||
@@ -43,7 +43,7 @@ function message(attachments: Array<MessageAttachment>): Message {
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: '',
|
||||
content,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
@@ -75,10 +75,10 @@ describe('message activity', () => {
|
||||
resetActivityEventsForTests();
|
||||
});
|
||||
|
||||
function params(attachments: Array<MessageAttachment>) {
|
||||
function params(attachments: Array<MessageAttachment>, content = '') {
|
||||
return {
|
||||
user: {id: createUserID(3n), isBot: false} as unknown as User,
|
||||
message: message(attachments),
|
||||
message: message(attachments, content),
|
||||
channel: {id: createChannelID(10n), type: ChannelTypes.DM} as unknown as Channel,
|
||||
guildId: null,
|
||||
guildOwnerId: null,
|
||||
@@ -117,4 +117,18 @@ describe('message activity', () => {
|
||||
expect(updated.data).toMatchObject({message_id: '100', attachments: [{hash: HASH.toLowerCase()}]});
|
||||
expect(updated.id).not.toBe(created.id);
|
||||
});
|
||||
|
||||
it('records the link domain of a masked markdown link without its brackets', async () => {
|
||||
const publisher = new CapturingPublisher();
|
||||
await startActivityEvents({publisher, kv: new MockKVProvider()});
|
||||
emitMessageCreated(
|
||||
params(
|
||||
[],
|
||||
'[OPEN](https://Shop.Example.com) [docs](<https://www.docs.example.org/a>) https://[email protected]:8443/x',
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(publisher.payloads).toHaveLength(1));
|
||||
const event = JSON.parse(publisher.payloads[0]!);
|
||||
expect(event.data.link_domains).toEqual(['shop.example.com', 'docs.example.org', 'cdn.example.net']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,22 +10,20 @@ import type {Message} from '@app/api/models/Message';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {findInvites} from '@app/api/utils/InviteUtils';
|
||||
import {extractLinkHosts} from '@app/api/utils/UrlNormalizer';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
const LIST_MAX = 10;
|
||||
const MENTIONS_MAX = 20;
|
||||
const LINK_PATTERN = /https?:\/\/([^\s/?#<>"']+)/giu;
|
||||
const WWW_PREFIX_RE = /^www\./u;
|
||||
|
||||
function linkDomains(content: string): Array<string> {
|
||||
const domains = new Set<string>();
|
||||
for (const match of content.matchAll(LINK_PATTERN)) {
|
||||
const host = match[1]
|
||||
?.toLowerCase()
|
||||
.replace(/:\d+$/u, '')
|
||||
.replace(/^www\./u, '');
|
||||
if (host) domains.add(host);
|
||||
for (const host of extractLinkHosts(content)) {
|
||||
const domain = host.replace(WWW_PREFIX_RE, '');
|
||||
if (domain) domains.add(domain);
|
||||
if (domains.size >= LIST_MAX) break;
|
||||
}
|
||||
return [...domains];
|
||||
|
||||
@@ -94,10 +94,14 @@ export class MessageValidationService {
|
||||
contentModerationService.scanText(data.content, modCtx);
|
||||
if (data.embeds) {
|
||||
for (const embed of data.embeds) {
|
||||
if (embed.url) contentModerationService.scanUrl(embed.url, modCtx);
|
||||
contentModerationService.scanText(embed.title ?? null, modCtx);
|
||||
contentModerationService.scanText(embed.description ?? null, modCtx);
|
||||
if (embed.footer) contentModerationService.scanText(embed.footer.text ?? null, modCtx);
|
||||
if (embed.author) contentModerationService.scanText(embed.author.name ?? null, modCtx);
|
||||
if (embed.author) {
|
||||
contentModerationService.scanText(embed.author.name ?? null, modCtx);
|
||||
if (embed.author.url) contentModerationService.scanUrl(embed.author.url, modCtx);
|
||||
}
|
||||
if (embed.fields) {
|
||||
for (const field of embed.fields) {
|
||||
contentModerationService.scanText(field.name ?? null, modCtx);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('Attachment Decay', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
@@ -258,7 +258,7 @@ describe('Attachment Decay', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const smallFile = loadFixture('yeah.png');
|
||||
const largeFile = loadFixture('thisisfine.gif');
|
||||
const largeFile = loadFixture('animated.gif');
|
||||
const smallResult = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
|
||||
@@ -327,17 +327,17 @@ describe('Attachment Upload Validation', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Ordered files test',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png', description: 'First file', title: 'First'},
|
||||
{id: 1, filename: 'thisisfine.gif', description: 'Second file', title: 'Second'},
|
||||
{id: 1, filename: 'animated.gif', description: 'Second file', title: 'Second'},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
@@ -346,7 +346,7 @@ describe('Attachment Upload Validation', () => {
|
||||
expect(json.attachments![0].filename).toBe('yeah.png');
|
||||
expect(json.attachments![0].description).toBe('First file');
|
||||
expect(json.attachments![0].title).toBe('First');
|
||||
expect(json.attachments![1].filename).toBe('thisisfine.gif');
|
||||
expect(json.attachments![1].filename).toBe('animated.gif');
|
||||
expect(json.attachments![1].description).toBe('Second file');
|
||||
expect(json.attachments![1].title).toBe('Second');
|
||||
});
|
||||
@@ -356,17 +356,17 @@ describe('Attachment Upload Validation', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Sparse IDs test',
|
||||
attachments: [
|
||||
{id: 2, filename: 'yeah.png', description: 'ID is 2', title: 'Two'},
|
||||
{id: 5, filename: 'thisisfine.gif', description: 'ID is 5', title: 'Five'},
|
||||
{id: 5, filename: 'animated.gif', description: 'ID is 5', title: 'Five'},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 2, filename: 'yeah.png', data: file1Data},
|
||||
{index: 5, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 5, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
@@ -472,7 +472,7 @@ describe('Attachment Upload Validation', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Mixed metadata test',
|
||||
attachments: [
|
||||
@@ -485,13 +485,13 @@ describe('Attachment Upload Validation', () => {
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
filename: 'thisisfine.gif',
|
||||
filename: 'animated.gif',
|
||||
},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function sendWithImage(
|
||||
filenames.map((filename, index) => ({
|
||||
index,
|
||||
filename,
|
||||
data: loadFixture(filename.endsWith('.gif') ? 'thisisfine.gif' : 'yeah.png'),
|
||||
data: loadFixture(filename.endsWith('.gif') ? 'animated.gif' : 'yeah.png'),
|
||||
})),
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
|
||||
@@ -86,25 +86,25 @@ describe('Embed Attachment URL Resolution', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Both image and thumbnail',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png'},
|
||||
{id: 1, filename: 'thisisfine.gif'},
|
||||
{id: 1, filename: 'animated.gif'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Complete Embed',
|
||||
description: 'This embed uses both image and thumbnail',
|
||||
image: {url: 'attachment://thisisfine.gif'},
|
||||
image: {url: 'attachment://animated.gif'},
|
||||
thumbnail: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
@@ -576,12 +576,12 @@ describe('Embed Attachment URL Resolution', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Multiple embeds with different attachments',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png'},
|
||||
{id: 1, filename: 'thisisfine.gif'},
|
||||
{id: 1, filename: 'animated.gif'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
@@ -592,19 +592,19 @@ describe('Embed Attachment URL Resolution', () => {
|
||||
{
|
||||
title: 'Second Embed',
|
||||
description: 'Uses GIF',
|
||||
image: {url: 'attachment://thisisfine.gif'},
|
||||
image: {url: 'attachment://animated.gif'},
|
||||
},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
expect(json.embeds![0].image?.url).toContain('yeah.png');
|
||||
expect(json.embeds![1].image?.url).toContain('thisisfine.gif');
|
||||
expect(json.embeds![1].image?.url).toContain('animated.gif');
|
||||
});
|
||||
it('should resolve multiple files referenced by embeds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Logger} from '@app/api/Logger';
|
||||
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
|
||||
export interface ModerationContext {
|
||||
@@ -40,16 +39,12 @@ class ContentModerationService {
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
const urls = extractUrlCandidates(text);
|
||||
if (urls.length === 0) return;
|
||||
for (const url of urls) {
|
||||
if (urlBlocklistCache.isUrlOrDomainBanned(url)) {
|
||||
Logger.warn(
|
||||
{surface: ctx.surface, userId: ctx.userId?.toString(), guildId: ctx.guildId?.toString()},
|
||||
'content_moderation.block url match in text',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
if (urlBlocklistCache.containsBannedLink(text)) {
|
||||
Logger.warn(
|
||||
{surface: ctx.surface, userId: ctx.userId?.toString(), guildId: ctx.guildId?.toString()},
|
||||
'content_moderation.block url match in text',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isTestSsoProvider, normalizeAndValidateSsoConfig} from '@app/api/instance/SsoConfigValidation';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function ssoConfig(overrides: {authorizationUrl?: string | null; tokenUrl?: string | null} = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
enforced: false,
|
||||
issuer: null,
|
||||
authorizationUrl: 'test',
|
||||
tokenUrl: 'test',
|
||||
userInfoUrl: null,
|
||||
jwksUrl: null,
|
||||
clientId: 'client',
|
||||
allowedEmailDomains: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isTestSsoProvider', () => {
|
||||
it('recognises the placeholder provider only when test mode is enabled', () => {
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: null}, true)).toBe(true);
|
||||
expect(isTestSsoProvider({authorizationUrl: null, tokenUrl: 'test'}, true)).toBe(true);
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test-provider', tokenUrl: null}, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('never recognises the placeholder provider outside test mode', () => {
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: null}, false)).toBe(false);
|
||||
expect(isTestSsoProvider({authorizationUrl: null, tokenUrl: 'test'}, false)).toBe(false);
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: 'test'}, false)).toBe(false);
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test-provider', tokenUrl: null}, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAndValidateSsoConfig placeholder endpoints', () => {
|
||||
it('accepts placeholder endpoints in test mode', async () => {
|
||||
const result = await normalizeAndValidateSsoConfig(ssoConfig(), {testModeEnabled: true});
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.authorizationUrl).toBe('test');
|
||||
});
|
||||
|
||||
it('rejects a placeholder authorization endpoint outside test mode', async () => {
|
||||
await expect(
|
||||
normalizeAndValidateSsoConfig(ssoConfig({tokenUrl: null}), {testModeEnabled: false}),
|
||||
).rejects.toBeInstanceOf(InputValidationError);
|
||||
});
|
||||
|
||||
it('rejects a placeholder token endpoint outside test mode', async () => {
|
||||
await expect(
|
||||
normalizeAndValidateSsoConfig(ssoConfig({authorizationUrl: null}), {testModeEnabled: false}),
|
||||
).rejects.toBeInstanceOf(InputValidationError);
|
||||
});
|
||||
});
|
||||
@@ -64,10 +64,13 @@ export function isTestSsoProvider(
|
||||
},
|
||||
testModeEnabled: boolean,
|
||||
): boolean {
|
||||
if (!testModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
config.authorizationUrl === 'test' ||
|
||||
config.tokenUrl === 'test' ||
|
||||
(testModeEnabled && (config.authorizationUrl?.startsWith('test-') ?? false))
|
||||
(config.authorizationUrl?.startsWith('test-') ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {Logger} from '@app/api/Logger';
|
||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {readRequestJsonBody} from '@app/api/utils/RequestJsonBody';
|
||||
import {extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
@@ -73,6 +72,8 @@ const SKIP_FIELD_SUFFIXES = [
|
||||
] as const;
|
||||
const SKIP_CONTENT_FILTER_PATH_PARTS = [
|
||||
'/admin/blocklists/phrase/',
|
||||
'/admin/blocklists/url-domain/',
|
||||
'/admin/blocklists/url/',
|
||||
'/auth/',
|
||||
'/oauth2/',
|
||||
'/premium/store/',
|
||||
@@ -149,15 +150,12 @@ const ContentFilterMiddleware = createMiddleware(async (ctx, next) => {
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
const urls = extractUrlCandidates(text);
|
||||
for (const url of urls) {
|
||||
if (urlBlocklistCache.isUrlOrDomainBanned(url)) {
|
||||
Logger.warn(
|
||||
{surface: 'global_filter', userId: userId?.toString(), path},
|
||||
'content_moderation.block url match in request body',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
if (urlBlocklistCache.containsBannedLink(text)) {
|
||||
Logger.warn(
|
||||
{surface: 'global_filter', userId: userId?.toString(), path},
|
||||
'content_moderation.block url match in request body',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
}
|
||||
return next();
|
||||
|
||||
@@ -127,7 +127,7 @@ class IpBanCache {
|
||||
const sameIpDecisionKey = getSameIpDecisionKey(parsed.canonical);
|
||||
if (sameIpDecisionKey) {
|
||||
const decisionCount = this.sameIpDecisionBans.get(sameIpDecisionKey);
|
||||
if (decisionCount) {
|
||||
if (decisionCount && this.isActive(decisionCount)) {
|
||||
return {
|
||||
ipAddress: parsed.canonical,
|
||||
matchedEntry: sameIpDecisionKey,
|
||||
@@ -137,7 +137,7 @@ class IpBanCache {
|
||||
}
|
||||
const singleMap = this.singleIpBans[parsed.family];
|
||||
const single = singleMap.get(parsed.canonical);
|
||||
if (single) {
|
||||
if (single && this.isActive(single.count)) {
|
||||
return {
|
||||
ipAddress: parsed.canonical,
|
||||
matchedEntry: parsed.canonical,
|
||||
@@ -146,7 +146,7 @@ class IpBanCache {
|
||||
}
|
||||
const rangeMap = this.rangeIpBans[parsed.family];
|
||||
for (const [canonical, range] of rangeMap.entries()) {
|
||||
if (parsed.value >= range.start && parsed.value <= range.end) {
|
||||
if (parsed.value >= range.start && parsed.value <= range.end && this.isActive(range.count)) {
|
||||
return {
|
||||
ipAddress: parsed.canonical,
|
||||
matchedEntry: canonical,
|
||||
@@ -232,6 +232,13 @@ class IpBanCache {
|
||||
return count.permanent <= 0 && count.temporary <= 0;
|
||||
}
|
||||
|
||||
private isActive(count: IpBanCount): boolean {
|
||||
if (count.permanent > 0 || !count.temporaryExpiresAt) {
|
||||
return true;
|
||||
}
|
||||
return count.temporaryExpiresAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
private resolveCount(count: IpBanCount): {
|
||||
kind: BannedIpKind;
|
||||
expiresAt: Date | null;
|
||||
|
||||
@@ -7,12 +7,13 @@ import {BANNED_URL_DOMAINS_REFRESH_CHANNEL, BANNED_URLS_REFRESH_CHANNEL} from '@
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {RefreshSubscription} from '@app/api/utils/RefreshSubscription';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import {UrlHostRuleSet} from '@app/api/utils/UrlHostRules';
|
||||
import {canonicalizeUrl, extractLinkHosts, extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
|
||||
class UrlBlocklistCache {
|
||||
private exactUrls: Set<string> = new Set();
|
||||
private blockedDomains: Set<string> = new Set();
|
||||
private hostRules = new UrlHostRuleSet();
|
||||
private adminRepository = new AdminRepository();
|
||||
private kvClient: IKVProvider | null = null;
|
||||
private storageService: IStorageService | null = null;
|
||||
@@ -55,15 +56,15 @@ class UrlBlocklistCache {
|
||||
for (const row of manualUrls) {
|
||||
if (row.url_canonical) nextUrls.add(row.url_canonical.toLowerCase());
|
||||
}
|
||||
const nextDomains = new Set<string>();
|
||||
const nextHostRules = new UrlHostRuleSet();
|
||||
for (const row of domains) {
|
||||
nextDomains.add(row.domain.toLowerCase());
|
||||
nextHostRules.add(row.domain, row.match_subdomains ?? true);
|
||||
}
|
||||
this.exactUrls = nextUrls;
|
||||
this.blockedDomains = nextDomains;
|
||||
this.hostRules = nextHostRules;
|
||||
this.consecutiveFailures = 0;
|
||||
Logger.debug(
|
||||
{urls: nextUrls.size, domains: nextDomains.size, feedUrls: feedUrls.size},
|
||||
{urls: nextUrls.size, ...nextHostRules.size, feedUrls: feedUrls.size},
|
||||
'URL blocklist cache refreshed',
|
||||
);
|
||||
}
|
||||
@@ -94,7 +95,17 @@ class UrlBlocklistCache {
|
||||
}
|
||||
|
||||
isHostnameBanned(host: string): boolean {
|
||||
return this.blockedDomains.has(host.toLowerCase());
|
||||
return this.hostRules.matches(host);
|
||||
}
|
||||
|
||||
containsBannedLink(text: string): boolean {
|
||||
for (const url of extractUrlCandidates(text)) {
|
||||
if (this.isUrlOrDomainBanned(url)) return true;
|
||||
}
|
||||
for (const host of extractLinkHosts(text)) {
|
||||
if (this.isHostnameBanned(host)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
addExactUrl(canonical: string): void {
|
||||
@@ -105,21 +116,22 @@ class UrlBlocklistCache {
|
||||
this.exactUrls.delete(canonical.toLowerCase());
|
||||
}
|
||||
|
||||
addDomain(domain: string): void {
|
||||
this.blockedDomains.add(domain.toLowerCase());
|
||||
addDomain(domain: string, matchSubdomains = true): void {
|
||||
this.hostRules.add(domain, matchSubdomains);
|
||||
}
|
||||
|
||||
removeDomain(domain: string): void {
|
||||
this.blockedDomains.delete(domain.toLowerCase());
|
||||
this.hostRules.remove(domain);
|
||||
}
|
||||
|
||||
get size(): {
|
||||
urls: number;
|
||||
domains: number;
|
||||
patterns: number;
|
||||
} {
|
||||
return {
|
||||
urls: this.exactUrls.size,
|
||||
domains: this.blockedDomains.size,
|
||||
...this.hostRules.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +140,7 @@ class UrlBlocklistCache {
|
||||
Logger.error({error}, 'Failed to shut down URL blocklist cache');
|
||||
});
|
||||
this.exactUrls = new Set();
|
||||
this.blockedDomains = new Set();
|
||||
this.hostRules = new UrlHostRuleSet();
|
||||
this.kvClient = null;
|
||||
this.storageService = null;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
@@ -81,4 +81,14 @@ describe('shouldSkipContentFilterPath', () => {
|
||||
const result = paths.map((path) => shouldSkipContentFilterPath(path));
|
||||
expect(result).toEqual([false, false, false]);
|
||||
});
|
||||
test('skips blocklist writes whose values are the blocked content', () => {
|
||||
const paths = [
|
||||
'/admin/blocklists/phrase/entries',
|
||||
'/admin/blocklists/url/entries',
|
||||
'/admin/blocklists/url-domain/entries',
|
||||
'/admin/blocklists/profile-substring/entries',
|
||||
];
|
||||
const result = paths.map((path) => shouldSkipContentFilterPath(path));
|
||||
expect(result).toEqual([true, true, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
ipBanCache.resetCaches();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('IpBanCache', () => {
|
||||
it('blocks IPv4-mapped IPv6 when a single IPv4 address is banned', () => {
|
||||
ipBanCache.ban('127.0.0.1');
|
||||
@@ -44,4 +48,21 @@ describe('IpBanCache', () => {
|
||||
expect(match?.kind).toBe('permanent');
|
||||
expect(match?.expiresAt).toBe(null);
|
||||
});
|
||||
it('stops matching a temporary ban once it has expired', () => {
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
ipBanCache.banTemp('203.0.113.52', 3600);
|
||||
ipBanCache.banTemp('203.0.113.0/24', 3600);
|
||||
expect(ipBanCache.isBanned('203.0.113.52')).toBe(true);
|
||||
expect(ipBanCache.isBanned('203.0.113.53')).toBe(true);
|
||||
vi.advanceTimersByTime(3_600_001);
|
||||
expect(ipBanCache.getMatch('203.0.113.52')).toBe(null);
|
||||
expect(ipBanCache.getMatch('203.0.113.53')).toBe(null);
|
||||
});
|
||||
it('keeps matching a permanent ban that shares an address with an expired temporary one', () => {
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
ipBanCache.banTemp('203.0.113.54', 3600);
|
||||
ipBanCache.ban('203.0.113.54');
|
||||
vi.advanceTimersByTime(3_600_001);
|
||||
expect(ipBanCache.getMatch('203.0.113.54')?.kind).toBe('permanent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('urlBlocklistCache link matching', () => {
|
||||
afterEach(() => {
|
||||
urlBlocklistCache.resetForTesting();
|
||||
});
|
||||
|
||||
it('blocks a masked markdown link whose target a pattern covers', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', true);
|
||||
expect(urlBlocklistCache.containsBannedLink('[open the store](https://shop-2.onrender.com)')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('[open the store](<https://www.shop.onrender.com/x>)')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('[https://docs.onrender.com](https://shop.onrender.com)')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks autolinks and bare links', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', false);
|
||||
expect(urlBlocklistCache.containsBannedLink('<https://myshop.onrender.com/path>')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('visit myshop.onrender.com today')).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes the link target before matching', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', false);
|
||||
const variants = [
|
||||
'https://user:[email protected]:8443/x',
|
||||
'https://[email protected]',
|
||||
'https://shop.onrender.com./',
|
||||
'https://shop%2Eonrender%2Ecom/',
|
||||
'https://shop。onrender。com/',
|
||||
'https://shop-ü.onrender.com/',
|
||||
];
|
||||
for (const text of variants) {
|
||||
expect(urlBlocklistCache.containsBannedLink(text), text).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the bare suffix and unrelated hosts alone', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', true);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://onrender.com/docs')).toBe(false);
|
||||
expect(urlBlocklistCache.containsBannedLink('[docs](https://docs.onrender.com)')).toBe(false);
|
||||
expect(urlBlocklistCache.containsBannedLink('the shop is closed')).toBe(false);
|
||||
});
|
||||
|
||||
it('covers subdomains of a domain entry only when it is flagged to', () => {
|
||||
urlBlocklistCache.addDomain('shop.example.com', true);
|
||||
urlBlocklistCache.addDomain('store.example.com', false);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://www.shop.example.com')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://store.example.com')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://www.store.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops matching after removal', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', true);
|
||||
urlBlocklistCache.removeDomain('*shop*.onrender.com');
|
||||
expect(urlBlocklistCache.containsBannedLink('https://shop.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies domain rules to a single URL', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', false);
|
||||
expect(urlBlocklistCache.isUrlOrDomainBanned('https://shop.onrender.com/checkout')).toBe(true);
|
||||
expect(urlBlocklistCache.isUrlOrDomainBanned('https://docs.onrender.com/')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,8 @@ const DSA_CODE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const DSA_CODE_SEGMENT_LENGTH = 4;
|
||||
const DSA_CODE_SEPARATOR = '-';
|
||||
const DSA_TICKET_BYTES = 32;
|
||||
const DSA_EMAIL_SEND_RECIPIENT_MAX = 3;
|
||||
const DSA_EMAIL_SEND_RECIPIENT_WINDOW = ms('1 hour');
|
||||
|
||||
async function emitReportFiled(row: IARSubmissionRow, target: ReportTarget): Promise<void> {
|
||||
const key = row.reported_user_id ?? row.reporter_id;
|
||||
@@ -373,6 +375,20 @@ export class ReportService {
|
||||
|
||||
async sendDsaReportVerificationCode(email: string, locale: string | null = null): Promise<void> {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const recipientLimit = await this.rateLimitService.checkLimit({
|
||||
identifier: `dsa:report:email:send:recipient:${normalizedEmail}`,
|
||||
maxAttempts: DSA_EMAIL_SEND_RECIPIENT_MAX,
|
||||
windowMs: DSA_EMAIL_SEND_RECIPIENT_WINDOW,
|
||||
});
|
||||
if (!recipientLimit.allowed) {
|
||||
throw new RateLimitError({
|
||||
retryAfter: recipientLimit.retryAfter,
|
||||
retryAfterDecimal: recipientLimit.retryAfterDecimal,
|
||||
limit: recipientLimit.limit,
|
||||
resetTime: recipientLimit.resetTime,
|
||||
resetAfterDecimal: recipientLimit.resetAfterDecimal,
|
||||
});
|
||||
}
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {clearTestEmails, createUniqueEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function countVerificationEmailsTo(harness: ApiTestHarness, email: string): Promise<number> {
|
||||
const emails = await listTestEmails(harness);
|
||||
return emails.filter((sent) => sent.type === 'dsa_report_verification' && sent.to === email.toLowerCase()).length;
|
||||
}
|
||||
|
||||
describe('DSA report verification email recipient limit', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('limits verification emails per address regardless of letter case', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const email = createUniqueEmail('dsa-recipient');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa/email/send')
|
||||
.body({email})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa/email/send')
|
||||
.body({email: email.toUpperCase()})
|
||||
.expect(429)
|
||||
.execute();
|
||||
expect(await countVerificationEmailsTo(harness, email)).toBe(3);
|
||||
});
|
||||
|
||||
test('keeps sending to other addresses after one address reaches its limit', async () => {
|
||||
await clearTestEmails(harness);
|
||||
const limited = createUniqueEmail('dsa-recipient');
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email: limited}).execute();
|
||||
}
|
||||
const other = createUniqueEmail('dsa-recipient');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa/email/send')
|
||||
.body({email: other})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(await countVerificationEmailsTo(harness, other)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const QUOTE_CHARS = new Set(['"', '“', '”', '„', '‟', '″', '«', '»', '"']);
|
||||
|
||||
function extractQuotedPhrases(input: string): {rest: string; phrases: Array<string>} {
|
||||
const phrases: Array<string> = [];
|
||||
let rest = '';
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
if (!QUOTE_CHARS.has(input[i])) {
|
||||
rest += input[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
let phrase = '';
|
||||
while (i < input.length && !QUOTE_CHARS.has(input[i])) {
|
||||
phrase += input[i];
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
const trimmed = phrase.trim();
|
||||
if (trimmed) phrases.push(trimmed);
|
||||
rest += ' ';
|
||||
}
|
||||
return {rest: rest.replace(/\s+/g, ' ').trim(), phrases};
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(phrase: string): string {
|
||||
let start = 0;
|
||||
let end = phrase.length;
|
||||
while (start < end && QUOTE_CHARS.has(phrase[start])) start++;
|
||||
while (end > start && QUOTE_CHARS.has(phrase[end - 1])) end--;
|
||||
return phrase.slice(start, end).trim();
|
||||
}
|
||||
|
||||
export function normalizeQuotedPhrases<T extends {content?: string; exact_phrases?: Array<string>}>(params: T): T {
|
||||
const exactPhrases = (params.exact_phrases ?? []).map(stripWrappingQuotes).filter(Boolean);
|
||||
let content = params.content;
|
||||
if (content) {
|
||||
const extracted = extractQuotedPhrases(content);
|
||||
exactPhrases.push(...extracted.phrases);
|
||||
content = extracted.rest || undefined;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
content,
|
||||
exact_phrases: exactPhrases.length > 0 ? [...new Set(exactPhrases)] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {GuildService} from '@app/api/guild/services/GuildService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {GlobalSearchService} from '@app/api/search/GlobalSearchService';
|
||||
import {normalizeQuotedPhrases} from '@app/api/search/SearchQuotedPhrases';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -58,7 +59,8 @@ export class SearchService {
|
||||
data: GlobalSearchMessagesRequest;
|
||||
}): Promise<MessageSearchResponse> {
|
||||
const {userId, requestCache, data} = params;
|
||||
const {channel_id, channel_ids, context_channel_id, context_guild_id, ...searchParams} = data;
|
||||
const {channel_id, channel_ids, context_channel_id, context_guild_id, ...rawSearchParams} = data;
|
||||
const searchParams = normalizeQuotedPhrases(rawSearchParams);
|
||||
const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null;
|
||||
const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null;
|
||||
const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? [];
|
||||
|
||||
@@ -17,6 +17,16 @@ export interface MeilisearchTask {
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchTaskError extends Error {
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(message: string, code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'MeilisearchTaskError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MeilisearchClient {
|
||||
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||
waitForTask(taskUid: number): Promise<void>;
|
||||
@@ -84,7 +94,10 @@ export class MeilisearchHttpClient implements MeilisearchClient {
|
||||
return;
|
||||
}
|
||||
if (task.status === 'failed' || task.status === 'canceled') {
|
||||
throw new Error(task.error?.message ?? `Meilisearch task ${taskUid} ${task.status}`);
|
||||
throw new MeilisearchTaskError(
|
||||
task.error?.message ?? `Meilisearch task ${taskUid} ${task.status}`,
|
||||
task.error?.code,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MeilisearchClient, MeilisearchTask} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import {MeilisearchTaskError} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import {MeilisearchMessageAdapter} from '@app/api/search/meilisearch/MeilisearchDomainAdapters';
|
||||
import {MEILISEARCH_MAX_TRACKED_BULK_TASKS} from '@app/api/search/meilisearch/MeilisearchIndexAdapter';
|
||||
import type {SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
@@ -15,6 +16,7 @@ interface RecordedMeilisearchRequest {
|
||||
class FakeMeilisearchClient implements MeilisearchClient {
|
||||
readonly requests: Array<RecordedMeilisearchRequest> = [];
|
||||
readonly waitedTaskUids: Array<number> = [];
|
||||
readonly failedTasks = new Map<number, MeilisearchTaskError>();
|
||||
private nextTaskUid = 1;
|
||||
indexExists = false;
|
||||
|
||||
@@ -50,6 +52,10 @@ class FakeMeilisearchClient implements MeilisearchClient {
|
||||
|
||||
async waitForTask(taskUid: number): Promise<void> {
|
||||
this.waitedTaskUids.push(taskUid);
|
||||
const failure = this.failedTasks.get(taskUid);
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -87,6 +93,26 @@ describe('MeilisearchMessageAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an index created concurrently by another process as created', async () => {
|
||||
const client = new FakeMeilisearchClient();
|
||||
client.failedTasks.set(1, new MeilisearchTaskError('Index `messages` already exists.', 'index_already_exists'));
|
||||
const adapter = new MeilisearchMessageAdapter({client});
|
||||
|
||||
await adapter.initialize();
|
||||
|
||||
expect(adapter.isAvailable()).toBe(true);
|
||||
expect(client.waitedTaskUids).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('still fails when creating the index fails for another reason', async () => {
|
||||
const client = new FakeMeilisearchClient();
|
||||
client.failedTasks.set(1, new MeilisearchTaskError('Index uid is invalid.', 'invalid_index_uid'));
|
||||
const adapter = new MeilisearchMessageAdapter({client});
|
||||
|
||||
await expect(adapter.initialize()).rejects.toThrow('Index uid is invalid.');
|
||||
expect(adapter.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('builds Meilisearch search requests from message filters', async () => {
|
||||
const client = new FakeMeilisearchClient();
|
||||
client.indexExists = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MeilisearchClient, MeilisearchTask} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import {MeilisearchTaskError} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import type {MeilisearchFilter} from '@app/api/search/meilisearch/MeilisearchFilterUtils';
|
||||
import {joinMeiliFilters} from '@app/api/search/meilisearch/MeilisearchFilterUtils';
|
||||
import type {MeilisearchIndexDefinition} from '@app/api/search/meilisearch/MeilisearchIndexDefinitions';
|
||||
@@ -58,7 +59,13 @@ export class MeilisearchIndexAdapter<
|
||||
uid,
|
||||
primaryKey: this.indexDefinition.primaryKey,
|
||||
});
|
||||
await this.client.waitForTask(task.taskUid);
|
||||
try {
|
||||
await this.client.waitForTask(task.taskUid);
|
||||
} catch (error) {
|
||||
if (!(error instanceof MeilisearchTaskError && error.code === 'index_already_exists')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
this.applySetting('PUT', 'searchable-attributes', this.indexDefinition.searchableAttributes),
|
||||
|
||||
@@ -145,6 +145,28 @@ describe('Message Search Exact Phrases', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
test('typographic quotes in content are treated as exact phrases', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Smart Quote Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const timestamp = Date.now();
|
||||
const tag = `sq-${timestamp}`;
|
||||
await sendMessage(harness, owner.token, channelId, `${tag} hello world`);
|
||||
await sendMessage(harness, owner.token, channelId, `${tag} world hello`);
|
||||
await markChannelAsIndexed(harness, channelId);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: `${tag} \u201chello world\u201d`,
|
||||
context_channel_id: channelId,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(isSearchResult(result)).toBe(true);
|
||||
if (isSearchResult(result)) {
|
||||
expect(result.messages.map((m) => m.content)).toEqual([`${tag} hello world`]);
|
||||
}
|
||||
});
|
||||
test('exact_phrases combined with author_id filter', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild');
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
-3154
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
@@ -113,13 +113,13 @@ describe('Favorite Meme Limits', () => {
|
||||
attachment_id: message1.attachments[0].id,
|
||||
name: 'PNG Meme',
|
||||
});
|
||||
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
|
||||
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'animated.gif');
|
||||
const meme2 = await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
|
||||
attachment_id: message2.attachments[0].id,
|
||||
name: 'GIF Meme',
|
||||
});
|
||||
expect(meme2.id).toBeTruthy();
|
||||
expect(meme2.filename).toBe('thisisfine.gif');
|
||||
expect(meme2.filename).toBe('animated.gif');
|
||||
});
|
||||
test('should return error for invalid attachment id', async () => {
|
||||
const account = await createTestAccountForAttachmentTests(harness);
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('Favorite Meme Operations', () => {
|
||||
attachment_id: message1.attachments[0].id,
|
||||
name: 'First Meme',
|
||||
});
|
||||
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'thisisfine.gif');
|
||||
const message2 = await createMessageWithImageAttachment(harness, account.token, channel.id, 'animated.gif');
|
||||
await createFavoriteMemeFromMessage(harness, account.token, channel.id, message2.id, {
|
||||
attachment_id: message2.attachments[0].id,
|
||||
name: 'Second Meme',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {normalizeHostname} from '@app/api/utils/UrlNormalizer';
|
||||
import {getDomain} from 'tldts';
|
||||
|
||||
const URL_HOST_PATTERN_MAX_WILDCARDS = 3;
|
||||
const URL_HOST_PATTERN_MIN_LITERAL_CHARS = 3;
|
||||
|
||||
const MAX_HOSTNAME_LENGTH = 253;
|
||||
const HOSTNAME_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const GLOB_LABEL_RE = /^[a-z0-9*-]{1,63}$/;
|
||||
const REPEATED_WILDCARDS_RE = /\*+/g;
|
||||
|
||||
export type UrlDomainEntry =
|
||||
| {ok: true; value: string; pattern: boolean}
|
||||
| {
|
||||
ok: false;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function isValidHostname(host: string): boolean {
|
||||
if (host.length > MAX_HOSTNAME_LENGTH) return false;
|
||||
return host.split('.').every((label) => HOSTNAME_LABEL_RE.test(label));
|
||||
}
|
||||
|
||||
function invalid(message: string): UrlDomainEntry {
|
||||
return {ok: false, message};
|
||||
}
|
||||
|
||||
export function parseUrlDomainEntry(raw: string): UrlDomainEntry {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value.includes('*')) {
|
||||
const host = normalizeHostname(value);
|
||||
if (!host?.includes('.') || !isValidHostname(host)) {
|
||||
return invalid('Must be a valid domain');
|
||||
}
|
||||
return {ok: true, value: host, pattern: false};
|
||||
}
|
||||
const separator = value.indexOf('.');
|
||||
if (separator === -1) {
|
||||
return invalid('A pattern must name a domain after its wildcard label');
|
||||
}
|
||||
const glob = value.slice(0, separator).replace(REPEATED_WILDCARDS_RE, '*');
|
||||
const suffixValue = value.slice(separator + 1);
|
||||
if (suffixValue.includes('*')) {
|
||||
return invalid('Only the leftmost label of a pattern can contain *');
|
||||
}
|
||||
if (!GLOB_LABEL_RE.test(glob)) {
|
||||
return invalid('The wildcard label can contain only a-z, 0-9, hyphens, and *');
|
||||
}
|
||||
const wildcards = glob.length - glob.replaceAll('*', '').length;
|
||||
if (wildcards > URL_HOST_PATTERN_MAX_WILDCARDS) {
|
||||
return invalid(`The wildcard label can contain at most ${URL_HOST_PATTERN_MAX_WILDCARDS} *`);
|
||||
}
|
||||
if (glob.length - wildcards < URL_HOST_PATTERN_MIN_LITERAL_CHARS) {
|
||||
return invalid(
|
||||
`The wildcard label needs at least ${URL_HOST_PATTERN_MIN_LITERAL_CHARS} characters besides *, so the pattern is too broad`,
|
||||
);
|
||||
}
|
||||
const suffix = normalizeHostname(suffixValue);
|
||||
if (!suffix || !isValidHostname(suffix)) {
|
||||
return invalid('The part after the wildcard label must be a valid domain');
|
||||
}
|
||||
if (getDomain(suffix, {allowPrivateDomains: false}) === null) {
|
||||
return invalid('The part after the wildcard label is a public suffix, so the pattern is too broad');
|
||||
}
|
||||
const entry = `${glob}.${suffix}`;
|
||||
if (entry.length > MAX_HOSTNAME_LENGTH) {
|
||||
return invalid('Must be a valid domain');
|
||||
}
|
||||
return {ok: true, value: entry, pattern: true};
|
||||
}
|
||||
|
||||
interface HostPatternRule {
|
||||
value: string;
|
||||
segments: ReadonlyArray<string>;
|
||||
matchSubdomains: boolean;
|
||||
}
|
||||
|
||||
function globMatches(segments: ReadonlyArray<string>, label: string): boolean {
|
||||
const first = segments[0] ?? '';
|
||||
const last = segments[segments.length - 1] ?? '';
|
||||
if (!label.startsWith(first)) return false;
|
||||
let position = first.length;
|
||||
for (let index = 1; index < segments.length - 1; index++) {
|
||||
const segment = segments[index] ?? '';
|
||||
const found = label.indexOf(segment, position);
|
||||
if (found === -1) return false;
|
||||
position = found + segment.length;
|
||||
}
|
||||
return label.length - last.length >= position && label.endsWith(last);
|
||||
}
|
||||
|
||||
export class UrlHostRuleSet {
|
||||
private readonly domains = new Map<string, boolean>();
|
||||
private readonly patterns = new Map<string, Array<HostPatternRule>>();
|
||||
private patternCount = 0;
|
||||
|
||||
add(rawValue: string, matchSubdomains: boolean): void {
|
||||
if (rawValue.includes('*')) {
|
||||
const entry = parseUrlDomainEntry(rawValue);
|
||||
if (!entry.ok || !entry.pattern) return;
|
||||
this.remove(entry.value);
|
||||
const separator = entry.value.indexOf('.');
|
||||
const suffix = entry.value.slice(separator + 1);
|
||||
const rules = this.patterns.get(suffix) ?? [];
|
||||
rules.push({
|
||||
value: entry.value,
|
||||
segments: entry.value.slice(0, separator).split('*'),
|
||||
matchSubdomains,
|
||||
});
|
||||
this.patterns.set(suffix, rules);
|
||||
this.patternCount++;
|
||||
return;
|
||||
}
|
||||
const host = normalizeHostname(rawValue);
|
||||
if (host) this.domains.set(host, matchSubdomains);
|
||||
}
|
||||
|
||||
remove(rawValue: string): void {
|
||||
if (!rawValue.includes('*')) {
|
||||
const host = normalizeHostname(rawValue);
|
||||
if (host) this.domains.delete(host);
|
||||
return;
|
||||
}
|
||||
const entry = parseUrlDomainEntry(rawValue);
|
||||
if (!entry.ok) return;
|
||||
const suffix = entry.value.slice(entry.value.indexOf('.') + 1);
|
||||
const rules = this.patterns.get(suffix);
|
||||
if (!rules) return;
|
||||
const remaining = rules.filter((rule) => rule.value !== entry.value);
|
||||
this.patternCount -= rules.length - remaining.length;
|
||||
if (remaining.length === 0) {
|
||||
this.patterns.delete(suffix);
|
||||
} else {
|
||||
this.patterns.set(suffix, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
matches(rawHost: string): boolean {
|
||||
const host = normalizeHostname(rawHost);
|
||||
if (!host) return false;
|
||||
if (this.domains.has(host)) return true;
|
||||
let labelStart = 0;
|
||||
let isFirstLabel = true;
|
||||
while (labelStart < host.length) {
|
||||
const separator = host.indexOf('.', labelStart);
|
||||
if (separator === -1) return false;
|
||||
const suffix = host.slice(separator + 1);
|
||||
if (this.domains.get(suffix) === true) return true;
|
||||
const rules = this.patterns.get(suffix);
|
||||
if (rules) {
|
||||
const label = host.slice(labelStart, separator);
|
||||
for (const rule of rules) {
|
||||
if ((isFirstLabel || rule.matchSubdomains) && globMatches(rule.segments, label)) return true;
|
||||
}
|
||||
}
|
||||
labelStart = separator + 1;
|
||||
isFirstLabel = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get size(): {domains: number; patterns: number} {
|
||||
return {domains: this.domains.size, patterns: this.patternCount};
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,17 @@ export function canonicalizeUrl(raw: string): string | null {
|
||||
return parsed.toString().toLowerCase();
|
||||
}
|
||||
|
||||
const TRAILING_DOTS_RE = /\.+$/;
|
||||
|
||||
export function normalizeHostname(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
const ascii = domainToASCII(trimmed);
|
||||
if (!ascii) return null;
|
||||
const host = ascii.toLowerCase().replace(TRAILING_DOTS_RE, '');
|
||||
return host || null;
|
||||
}
|
||||
|
||||
const URL_CANDIDATE_RE =
|
||||
/(?<![a-z0-9._+-]@)((?:https?:\/\/)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?:[/:?#][^\s<>\u201D']*)?)/gi;
|
||||
const TRAILING_PUNCT_RE = /[.,;:!?)\]}\x22'\u00bb\u201C\u201D]+$/;
|
||||
@@ -78,3 +89,29 @@ export function extractUrlCandidates(text: string | null | undefined): Array<str
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LINK_AUTHORITY_RE = /https?:\/\/([^\s/\\?#<>"'`()[\]{}|^]+)/giu;
|
||||
|
||||
function hostFromAuthority(authority: string): string | null {
|
||||
const cleaned = authority.replace(TRAILING_PUNCT_RE, '');
|
||||
if (!cleaned) return null;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(`http://${cleaned}/`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return normalizeHostname(parsed.hostname);
|
||||
}
|
||||
|
||||
export function extractLinkHosts(text: string | null | undefined): Array<string> {
|
||||
if (!text) return [];
|
||||
const hosts = new Set<string>();
|
||||
for (const match of text.matchAll(LINK_AUTHORITY_RE)) {
|
||||
const authority = match[1];
|
||||
if (!authority) continue;
|
||||
const host = hostFromAuthority(authority);
|
||||
if (host) hosts.add(host);
|
||||
}
|
||||
return [...hosts];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {parseUrlDomainEntry, UrlHostRuleSet} from '@app/api/utils/UrlHostRules';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function rules(entries: Array<[string, boolean]>): UrlHostRuleSet {
|
||||
const set = new UrlHostRuleSet();
|
||||
for (const [value, matchSubdomains] of entries) {
|
||||
set.add(value, matchSubdomains);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
describe('parseUrlDomainEntry', () => {
|
||||
it('canonicalizes a plain domain', () => {
|
||||
expect(parseUrlDomainEntry(' Shop.Example.COM. ')).toEqual({ok: true, value: 'shop.example.com', pattern: false});
|
||||
});
|
||||
|
||||
it('stores an internationalized domain in punycode', () => {
|
||||
expect(parseUrlDomainEntry('bücher.example')).toEqual({ok: true, value: 'xn--bcher-kva.example', pattern: false});
|
||||
});
|
||||
|
||||
it('keeps an exact entry for a domain under a shared hosting suffix', () => {
|
||||
expect(parseUrlDomainEntry('onrender.com')).toEqual({ok: true, value: 'onrender.com', pattern: false});
|
||||
});
|
||||
|
||||
it('rejects malformed plain domains', () => {
|
||||
for (const value of ['localhost', 'bad_label.example.com', '-lead.example.com', 'a..example.com', 'a b.com']) {
|
||||
expect(parseUrlDomainEntry(value).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('canonicalizes a pattern and collapses repeated wildcards', () => {
|
||||
expect(parseUrlDomainEntry('**Shop**.OnRender.com.')).toEqual({
|
||||
ok: true,
|
||||
value: '*shop*.onrender.com',
|
||||
pattern: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts patterns under a private shared hosting suffix', () => {
|
||||
expect(parseUrlDomainEntry('*shop*.github.io').ok).toBe(true);
|
||||
expect(parseUrlDomainEntry('shop-*.example.co.uk').ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects patterns that are too broad', () => {
|
||||
for (const value of [
|
||||
'*',
|
||||
'*.com',
|
||||
'*shop*.com',
|
||||
'shop*.co.uk',
|
||||
'*.example.com',
|
||||
'*ab*.example.com',
|
||||
'a*b.example.com',
|
||||
]) {
|
||||
expect(parseUrlDomainEntry(value).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects wildcards outside the leftmost label', () => {
|
||||
expect(parseUrlDomainEntry('shop.*.example.com').ok).toBe(false);
|
||||
expect(parseUrlDomainEntry('*shop*.example*.com').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects wildcard labels with unsupported characters or too many wildcards', () => {
|
||||
expect(parseUrlDomainEntry('*sh?p*.example.com').ok).toBe(false);
|
||||
expect(parseUrlDomainEntry('*sh_p*.example.com').ok).toBe(false);
|
||||
expect(parseUrlDomainEntry('*a*b*c*d*.example.com').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a pattern without a domain', () => {
|
||||
expect(parseUrlDomainEntry('*shop*').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UrlHostRuleSet', () => {
|
||||
it('matches an exact domain', () => {
|
||||
const set = rules([['shop.example.com', false]]);
|
||||
expect(set.matches('shop.example.com')).toBe(true);
|
||||
expect(set.matches('www.shop.example.com')).toBe(false);
|
||||
expect(set.matches('example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches subdomains only when the entry covers them', () => {
|
||||
const set = rules([['shop.example.com', true]]);
|
||||
expect(set.matches('shop.example.com')).toBe(true);
|
||||
expect(set.matches('a.b.shop.example.com')).toBe(true);
|
||||
expect(set.matches('myshop.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes the checked host', () => {
|
||||
const set = rules([['shop.example.com', true]]);
|
||||
expect(set.matches('SHOP.Example.com.')).toBe(true);
|
||||
expect(set.matches('www.shop。example。com')).toBe(true);
|
||||
expect(rules([['xn--bcher-kva.example', false]]).matches('Bücher.example')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches a pattern against the label left of its suffix', () => {
|
||||
const set = rules([['*shop*.onrender.com', false]]);
|
||||
expect(set.matches('shop.onrender.com')).toBe(true);
|
||||
expect(set.matches('best-shop-2.onrender.com')).toBe(true);
|
||||
expect(set.matches('SHOPPING.onrender.com')).toBe(true);
|
||||
expect(set.matches('store.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('never matches the bare suffix of a pattern', () => {
|
||||
const set = rules([['*shop*.onrender.com', true]]);
|
||||
expect(set.matches('onrender.com')).toBe(false);
|
||||
expect(set.matches('com')).toBe(false);
|
||||
expect(set.matches('shop.com')).toBe(false);
|
||||
expect(set.matches('shop.onrender.com.evil.example')).toBe(false);
|
||||
expect(set.matches('shoponrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies anchored pattern segments', () => {
|
||||
const set = rules([
|
||||
['shop-*.example.com', false],
|
||||
['*-store.example.org', false],
|
||||
['a*b*c.example.net', false],
|
||||
]);
|
||||
expect(set.matches('shop-1.example.com')).toBe(true);
|
||||
expect(set.matches('myshop-1.example.com')).toBe(false);
|
||||
expect(set.matches('big-store.example.org')).toBe(true);
|
||||
expect(set.matches('big-store2.example.org')).toBe(false);
|
||||
expect(set.matches('axxbyyc.example.net')).toBe(true);
|
||||
expect(set.matches('abc.example.net')).toBe(true);
|
||||
expect(set.matches('acb.example.net')).toBe(false);
|
||||
});
|
||||
|
||||
it('extends a pattern to deeper subdomains only when the entry covers them', () => {
|
||||
const exact = rules([['*shop*.onrender.com', false]]);
|
||||
const covering = rules([['*shop*.onrender.com', true]]);
|
||||
expect(exact.matches('www.shop.onrender.com')).toBe(false);
|
||||
expect(covering.matches('www.shop.onrender.com')).toBe(true);
|
||||
expect(covering.matches('shop.www.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('matches internationalized labels through their punycode form', () => {
|
||||
const set = rules([['*shop*.onrender.com', false]]);
|
||||
expect(set.matches('shop-ü.onrender.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('removes domains and patterns', () => {
|
||||
const set = rules([
|
||||
['shop.example.com', true],
|
||||
['*shop*.onrender.com', true],
|
||||
['*store*.onrender.com', true],
|
||||
]);
|
||||
set.remove('SHOP.example.com');
|
||||
set.remove('**shop*.onrender.com');
|
||||
expect(set.matches('shop.example.com')).toBe(false);
|
||||
expect(set.matches('shop.onrender.com')).toBe(false);
|
||||
expect(set.matches('store.onrender.com')).toBe(true);
|
||||
expect(set.size).toEqual({domains: 0, patterns: 1});
|
||||
});
|
||||
|
||||
it('replaces a pattern when it is added again', () => {
|
||||
const set = rules([
|
||||
['*shop*.onrender.com', false],
|
||||
['*shop*.onrender.com', true],
|
||||
]);
|
||||
expect(set.size).toEqual({domains: 0, patterns: 1});
|
||||
expect(set.matches('www.shop.onrender.com')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores invalid stored patterns', () => {
|
||||
const set = rules([['*.com', true]]);
|
||||
expect(set.size).toEqual({domains: 0, patterns: 0});
|
||||
expect(set.matches('anything.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {canonicalizeUrl, extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import {canonicalizeUrl, extractLinkHosts, extractUrlCandidates, normalizeHostname} from '@app/api/utils/UrlNormalizer';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('canonicalizeUrl', () => {
|
||||
@@ -176,3 +176,61 @@ describe('extractUrlCandidates', () => {
|
||||
expect(extractUrlCandidates('hello world no links here')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeHostname', () => {
|
||||
it('lowercases and strips trailing dots', () => {
|
||||
expect(normalizeHostname('Shop.Example.COM..')).toBe('shop.example.com');
|
||||
});
|
||||
it('converts internationalized names to punycode', () => {
|
||||
expect(normalizeHostname('bücher.example')).toBe('xn--bcher-kva.example');
|
||||
});
|
||||
it('maps ideographic full stops to dots', () => {
|
||||
expect(normalizeHostname('shop\u3002example\u3002com')).toBe('shop.example.com');
|
||||
});
|
||||
it('rejects empty and invalid input', () => {
|
||||
expect(normalizeHostname(' ')).toBeNull();
|
||||
expect(normalizeHostname('.')).toBeNull();
|
||||
expect(normalizeHostname('a b.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractLinkHosts', () => {
|
||||
it('drops the closing parenthesis of a masked markdown link', () => {
|
||||
expect(extractLinkHosts('[open the shop](https://shop.example.com)')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('drops brackets and parentheses around nested links', () => {
|
||||
expect(extractLinkHosts('[[x]](https://shop.example.com)(more)')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('reads angle-bracket autolinks', () => {
|
||||
expect(extractLinkHosts('<https://Shop.Example.com/path>')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('ignores userinfo and ports', () => {
|
||||
expect(extractLinkHosts('https://user:[email protected]:8443/x')).toEqual(['shop.example.com']);
|
||||
expect(extractLinkHosts('[x](https://[email protected])')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('strips trailing dots and punctuation', () => {
|
||||
expect(extractLinkHosts('see https://shop.example.com./ and https://other.example.org, ok')).toEqual([
|
||||
'shop.example.com',
|
||||
'other.example.org',
|
||||
]);
|
||||
});
|
||||
it('decodes percent-encoded hosts the way a browser does', () => {
|
||||
expect(extractLinkHosts('https://shop%2Eexample%2Ecom/')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('returns internationalized hosts in punycode', () => {
|
||||
expect(extractLinkHosts('https://bücher.example/')).toEqual(['xn--bcher-kva.example']);
|
||||
});
|
||||
it('matches the scheme case-insensitively', () => {
|
||||
expect(extractLinkHosts('HTTPS://SHOP.EXAMPLE.COM')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('stops the host at a backslash', () => {
|
||||
expect(extractLinkHosts('https://shop.example.com\\path')).toEqual(['shop.example.com']);
|
||||
});
|
||||
it('deduplicates hosts', () => {
|
||||
expect(extractLinkHosts('https://a.example.com https://A.example.com/x')).toEqual(['a.example.com']);
|
||||
});
|
||||
it('returns an empty array without links', () => {
|
||||
expect(extractLinkHosts('shop.example.com')).toEqual([]);
|
||||
expect(extractLinkHosts(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -467,6 +467,7 @@ export function WebhookController(app: HonoApp) {
|
||||
app.post(
|
||||
'/webhooks/:webhook_id/:token/github',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_GITHUB),
|
||||
BlockAppOriginMiddleware,
|
||||
OpenAPI({
|
||||
operationId: 'execute_github_webhook',
|
||||
summary: 'Execute GitHub webhook',
|
||||
@@ -494,6 +495,7 @@ export function WebhookController(app: HonoApp) {
|
||||
app.post(
|
||||
'/webhooks/:webhook_id/:token/slack',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_EXECUTE),
|
||||
BlockAppOriginMiddleware,
|
||||
OpenAPI({
|
||||
operationId: 'execute_slack_webhook',
|
||||
summary: 'Execute Slack webhook',
|
||||
@@ -520,6 +522,7 @@ export function WebhookController(app: HonoApp) {
|
||||
app.post(
|
||||
'/webhooks/:webhook_id/:token/instatus',
|
||||
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_INSTATUS),
|
||||
BlockAppOriginMiddleware,
|
||||
OpenAPI({
|
||||
operationId: 'execute_instatus_webhook',
|
||||
summary: 'Execute Instatus webhook',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import {stripOwnAttachmentSignature} from '@app/api/attachment/AttachmentUrls';
|
||||
import type {ChannelID, GuildID, MessageID, UserID, WebhookID, WebhookToken} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createWebhookID, createWebhookToken} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '@app/api/channel/MessageTypes';
|
||||
import {withChannelFollowLock} from '@app/api/channel/services/ChannelFollowers';
|
||||
@@ -29,7 +29,6 @@ import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '@app/api/models/Channel';
|
||||
import type {Message} from '@app/api/models/Message';
|
||||
import type {Webhook} from '@app/api/models/Webhook';
|
||||
import {resolveAssetPath} from '@app/api/utils/AssetPaths';
|
||||
import * as RandomUtils from '@app/api/utils/RandomUtils';
|
||||
import type {IWebhookRepository} from '@app/api/webhook/IWebhookRepository';
|
||||
import {transform as GitHubTransform} from '@app/api/webhook/transformers/GitHubTransformer';
|
||||
@@ -117,6 +116,10 @@ interface WebhookExecuteInstatusParams extends WebhookTokenParams {
|
||||
}
|
||||
|
||||
const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__';
|
||||
const HOSTED_WEBHOOK_AVATAR_URLS = {
|
||||
github: 'https://fluxer.app/static/img/app-webhook-github.2d0319169a3aee33.webp',
|
||||
instatus: 'https://fluxer.app/static/img/app-webhook-instatus.22ad5aee16da872c.webp',
|
||||
} as const;
|
||||
|
||||
export class WebhookService {
|
||||
private static readonly NO_ALLOWED_MENTIONS: AllowedMentionsRequest = {parse: []};
|
||||
@@ -744,32 +747,22 @@ export class WebhookService {
|
||||
}
|
||||
|
||||
private async getGitHubWebhookAvatar(webhookId: WebhookID): Promise<string | null> {
|
||||
return this.getStaticWebhookAvatar({webhookId, provider: 'github'});
|
||||
return this.getHostedWebhookAvatar({webhookId, provider: 'github'});
|
||||
}
|
||||
|
||||
private async getInstatusWebhookAvatar(webhookId: WebhookID): Promise<string | null> {
|
||||
return this.getStaticWebhookAvatar({webhookId, provider: 'instatus'});
|
||||
return this.getHostedWebhookAvatar({webhookId, provider: 'instatus'});
|
||||
}
|
||||
|
||||
private async getStaticWebhookAvatar({
|
||||
private async getHostedWebhookAvatar({
|
||||
webhookId,
|
||||
provider,
|
||||
}: {
|
||||
webhookId: WebhookID;
|
||||
provider: 'github' | 'instatus';
|
||||
provider: keyof typeof HOSTED_WEBHOOK_AVATAR_URLS;
|
||||
}): Promise<string | null> {
|
||||
const cacheKey = `webhook:${webhookId}:avatar:${provider}`;
|
||||
const avatarCache = await this.cacheService.get<string | null>(cacheKey);
|
||||
if (avatarCache) return avatarCache;
|
||||
const avatarFile = await fs.readFile(resolveAssetPath('assets', `${provider}.webp`));
|
||||
const avatar = await this.avatarService.uploadAvatar({
|
||||
prefix: 'avatars',
|
||||
entityId: webhookId,
|
||||
errorPath: 'avatar',
|
||||
base64Image: avatarFile.toString('base64'),
|
||||
});
|
||||
await this.cacheService.set(cacheKey, avatar, seconds('1 day'));
|
||||
return avatar;
|
||||
if (Config.instance.selfHosted) return null;
|
||||
return this.getWebhookAvatar({webhookId, avatarUrl: HOSTED_WEBHOOK_AVATAR_URLS[provider]});
|
||||
}
|
||||
|
||||
private getWebhookMetadata(webhook: Webhook): Record<string, string> | undefined {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterEach, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
const TOKEN_ROUTE_SUFFIXES = ['', '/github', '/slack', '/instatus'];
|
||||
|
||||
describe('Webhook token routes and the web app origin', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
for (const suffix of TOKEN_ROUTE_SUFFIXES) {
|
||||
it(`refuses POST /webhooks/:webhook_id/:token${suffix} from the web app origin`, async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'App Origin Guild');
|
||||
const webhook = await createWebhook(harness, guild.system_channel_id!, owner.token, 'App Origin Webhook');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/webhooks/${webhook.id}/${webhook.token}${suffix}`)
|
||||
.header('origin', getConfig().endpoints.webAppOrigins[0]!)
|
||||
.body({content: 'hello'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVALID_API_ORIGIN)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -35,7 +35,7 @@ describe('Webhook emoji bypass', () => {
|
||||
user.token,
|
||||
guildId,
|
||||
'animated',
|
||||
'thisisfine.gif',
|
||||
'animated.gif',
|
||||
'image/gif',
|
||||
);
|
||||
const webhook = await createWebhook(harness, channelId, user.token, 'Emoji Test Webhook');
|
||||
|
||||
@@ -414,7 +414,8 @@ export class JetStreamWorkerQueue {
|
||||
this.requireConsumerConfiguration(existing, lane.consumerName);
|
||||
const updated = await jsm.consumers.update(STREAM_NAME, lane.consumerName, config);
|
||||
this.requireConsumerConfiguration(updated, lane.consumerName);
|
||||
if (updated.created !== existing.created) {
|
||||
const current = await this.readConsumer(jsm, lane.consumerName);
|
||||
if (current?.created !== existing.created) {
|
||||
throw new Error(`Worker consumer ${lane.consumerName} was replaced during startup`);
|
||||
}
|
||||
Logger.info({lane: lane.name, consumer: lane.consumerName}, 'Consumer updated without resetting delivery state');
|
||||
|
||||
@@ -330,11 +330,11 @@ async function scanEmbedsForBannedContent(
|
||||
continue;
|
||||
}
|
||||
for (const embed of embeds) {
|
||||
const imageUrls = [embed.thumbnail?.url, embed.image?.url, embed.video?.url, embed.audio?.url].filter(
|
||||
const embedUrls = [embed.url, embed.thumbnail?.url, embed.image?.url, embed.video?.url, embed.audio?.url].filter(
|
||||
(u): u is string => u != null,
|
||||
);
|
||||
for (const imageUrl of imageUrls) {
|
||||
contentModerationService.scanUrl(imageUrl, ctx);
|
||||
for (const embedUrl of embedUrls) {
|
||||
contentModerationService.scanUrl(embedUrl, ctx);
|
||||
}
|
||||
const children = embed.children ?? [];
|
||||
for (const child of children) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
|
||||
// SPDX-FileCopyrightText: 2022 adryd
|
||||
|
||||
import styles from '@app/features/accessibility/components/NekoSprite.module.css';
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
|
||||
@@ -10,6 +10,10 @@ import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErro
|
||||
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {
|
||||
ACCOUNT_LIMITED_NOTICE_DESCRIPTOR,
|
||||
ACCOUNT_LIMITED_TITLE_DESCRIPTOR,
|
||||
} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -72,11 +76,15 @@ export function resolveDmActionErrorContent(
|
||||
): {title: string; message: string} {
|
||||
switch (code) {
|
||||
case APIErrorCodes.NEW_CONVERSATIONS_LIMITED:
|
||||
case APIErrorCodes.ACCOUNT_LIMITED:
|
||||
return {
|
||||
title: i18nGlobal._(GENERIC_TITLE_DESCRIPTOR),
|
||||
message: apiMessage || i18nGlobal._(GENERIC_ERROR_BODY_DESCRIPTOR),
|
||||
};
|
||||
case APIErrorCodes.ACCOUNT_LIMITED:
|
||||
return {
|
||||
title: i18nGlobal._(ACCOUNT_LIMITED_TITLE_DESCRIPTOR),
|
||||
message: apiMessage || i18nGlobal._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR),
|
||||
};
|
||||
case APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER:
|
||||
return {
|
||||
title: i18nGlobal._(CANNOT_MESSAGE_USER_TITLE_DESCRIPTOR),
|
||||
|
||||
@@ -1271,6 +1271,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.SCHEDULED_MAINTENANCE]: {tone: SkeletonNagbarTone.MAINTENANCE_SCHEDULED, hasActions: true},
|
||||
[NagbarType.UNCLAIMED_ACCOUNT]: {tone: SkeletonNagbarTone.ALERT, hasActions: true},
|
||||
[NagbarType.EMAIL_VERIFICATION]: {tone: SkeletonNagbarTone.ALERT, hasActions: true},
|
||||
[NagbarType.ACCOUNT_LIMITED]: {tone: SkeletonNagbarTone.NEUTRAL, hasActions: false},
|
||||
[NagbarType.DESKTOP_NOTIFICATION]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.PREMIUM_GRACE_PERIOD]: {tone: SkeletonNagbarTone.PREMIUM, hasActions: true},
|
||||
[NagbarType.PREMIUM_EXPIRED]: {tone: SkeletonNagbarTone.DANGER, hasActions: true},
|
||||
@@ -1284,7 +1285,6 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true},
|
||||
[NagbarType.TERMS_ACCEPTANCE]: {tone: SkeletonNagbarTone.LEGAL, hasActions: true},
|
||||
[NagbarType.LINUX_INPUT_ACCESS]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true},
|
||||
[NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true},
|
||||
[NagbarType.DOMAIN_MOVED]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo
|
||||
import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {formatKeyCombo} from '@app/features/input/utils/KeybindUtils';
|
||||
import {getPushToTalkHoldLabel} from '@app/features/input/utils/PushToTalkHint';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import {SettingsContextMenu} from '@app/features/ui/action_menu/SettingsContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
@@ -205,8 +205,6 @@ const UserAreaInner = observer(
|
||||
};
|
||||
}, [hasVoiceConnection]);
|
||||
const wrapperClassName = styles.userAreaInnerWrapper;
|
||||
const pushToTalkCombo = Keybind.getByAction('voice_push_to_talk').combo;
|
||||
const pushToTalkHint = formatKeyCombo(i18n, pushToTalkCombo);
|
||||
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
|
||||
const microphoneState = selectUserAreaMicrophoneState({
|
||||
effectiveAudioMuted: isMuted,
|
||||
@@ -226,7 +224,7 @@ const UserAreaInner = observer(
|
||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
||||
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
||||
if (isPushToTalkEffective) return i18n._(PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR, {pushToTalkHint});
|
||||
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
||||
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||
})();
|
||||
@@ -234,7 +232,7 @@ const UserAreaInner = observer(
|
||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
||||
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
||||
if (isPushToTalkEffective) return i18n._(PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR, {pushToTalkHint});
|
||||
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
||||
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||
})();
|
||||
|
||||
@@ -18,7 +18,6 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
|
||||
@@ -257,7 +256,6 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
startupVoiceSessionRestoreSnapshotKey && startupVoiceSessionRestoreSnapshotKey === voiceSessionRestoreSnapshotKey,
|
||||
);
|
||||
})();
|
||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||
@@ -307,6 +305,11 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
: nagbarState.forceEmailVerification
|
||||
? true
|
||||
: Boolean(RuntimeConfig.emailsEnabled && user?.isClaimed() && !user.verified),
|
||||
canShowAccountLimited: nagbarState.forceHideAccountLimited
|
||||
? false
|
||||
: nagbarState.forceAccountLimited
|
||||
? true
|
||||
: user?.accountLimited === true,
|
||||
canShowDesktopNotification: nagbarState.forceHideDesktopNotification
|
||||
? false
|
||||
: nagbarState.forceDesktopNotification
|
||||
@@ -323,7 +326,6 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowVisionaryMfa,
|
||||
canShowVoiceSessionRestore,
|
||||
needsTermsAcceptance,
|
||||
canShowLinuxInputAccess,
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
@@ -375,6 +377,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.userNeedsVerification,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.ACCOUNT_LIMITED,
|
||||
priority: -3.75,
|
||||
visible: conditions.canShowAccountLimited,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PREMIUM_EXPIRED,
|
||||
priority: 0,
|
||||
@@ -435,12 +443,6 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowDesktopNotification,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.LINUX_INPUT_ACCESS,
|
||||
priority: 8.5,
|
||||
visible: conditions.canShowLinuxInputAccess,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DESKTOP_DOWNLOAD,
|
||||
priority: 9,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NagbarType = {
|
||||
SCHEDULED_MAINTENANCE: 'scheduled-maintenance',
|
||||
UNCLAIMED_ACCOUNT: 'unclaimed-account',
|
||||
EMAIL_VERIFICATION: 'email-verification',
|
||||
ACCOUNT_LIMITED: 'account-limited',
|
||||
DESKTOP_NOTIFICATION: 'desktop-notification',
|
||||
PREMIUM_GRACE_PERIOD: 'premium-grace-period',
|
||||
PREMIUM_EXPIRED: 'premium-expired',
|
||||
@@ -22,7 +23,6 @@ export const NagbarType = {
|
||||
VISIONARY_MFA: 'visionary-mfa',
|
||||
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
||||
TERMS_ACCEPTANCE: 'terms-acceptance',
|
||||
LINUX_INPUT_ACCESS: 'linux-input-access',
|
||||
SOFTWARE_ENCODER: 'software-encoder',
|
||||
STREAMER_MODE: 'streamer-mode',
|
||||
DOMAIN_MOVED: 'domain-moved',
|
||||
@@ -48,6 +48,7 @@ export interface NagbarConditions {
|
||||
canShowScheduledMaintenance: boolean;
|
||||
userIsUnclaimed: boolean;
|
||||
userNeedsVerification: boolean;
|
||||
canShowAccountLimited: boolean;
|
||||
canShowDesktopNotification: boolean;
|
||||
canShowPremiumGracePeriod: boolean;
|
||||
canShowPremiumExpired: boolean;
|
||||
@@ -61,7 +62,6 @@ export interface NagbarConditions {
|
||||
canShowVisionaryMfa: boolean;
|
||||
canShowVoiceSessionRestore: boolean;
|
||||
needsTermsAcceptance: boolean;
|
||||
canShowLinuxInputAccess: boolean;
|
||||
canShowSoftwareEncoder: boolean;
|
||||
canShowStreamerMode: boolean;
|
||||
canShowDomainMoved: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {type NagbarState, NagbarType} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import styles from '@app/features/app/components/layout/app_layout/NagbarContainer.module.css';
|
||||
import {AccountLimitedNagbar} from '@app/features/app/components/layout/app_layout/nagbars/AccountLimitedNagbar';
|
||||
import {BuildEnvironmentNagbar} from '@app/features/app/components/layout/app_layout/nagbars/BuildEnvironmentNagbar';
|
||||
import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar';
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
@@ -13,7 +14,6 @@ import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_l
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
||||
import {LegacyPriceOptInNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LegacyPriceOptInNagbar';
|
||||
import {LinuxInputAccessNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LinuxInputAccessNagbar';
|
||||
import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumExpiredNagbar';
|
||||
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
|
||||
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
|
||||
@@ -105,6 +105,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.email-verification-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.ACCOUNT_LIMITED:
|
||||
return (
|
||||
<AccountLimitedNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.account-limited-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.DESKTOP_NOTIFICATION:
|
||||
return (
|
||||
<DesktopNotificationNagbar
|
||||
@@ -208,14 +216,6 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.voice-session-restore-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.LINUX_INPUT_ACCESS:
|
||||
return (
|
||||
<LinuxInputAccessNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.linux-input-access-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.SOFTWARE_ENCODER:
|
||||
return (
|
||||
<SoftwareEncoderNagbar
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {ACCOUNT_LIMITED_NOTICE_DESCRIPTOR} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const AccountLimitedNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.NEUTRAL].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.NEUTRAL].textColor}
|
||||
data-flx="app.app-layout.nagbars.account-limited-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
message={
|
||||
<span data-flx="app.app-layout.nagbars.account-limited-nagbar.message">
|
||||
{i18n._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR)}
|
||||
</span>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.account-limited-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import KeybindManager from '@app/features/app/keybindings/KeybindManager';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {getUserSettingsTabLabel} from '@app/features/user/components/settings_utils/SettingsConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useState} from 'react';
|
||||
|
||||
const LINUX_INPUT_ACCESS_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Enable input access to use system-wide shortcuts on Wayland. After changing input access, fully quit and restart {productName} so shortcuts can use it.',
|
||||
comment: 'Description for a Linux Wayland input-access nagbar. {productName} is the app name.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_RELOGIN_DESCRIPTOR = msg({
|
||||
message: 'Input access was changed. Fully quit and restart {productName} so system-wide shortcuts can use it.',
|
||||
comment:
|
||||
'Description for a Linux Wayland input-access nagbar after access was changed. {productName} is the app name.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_ERROR_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Input access could not be enabled. Try again from {settingsMenuName} settings, then fully quit and restart {productName}.',
|
||||
comment:
|
||||
'Description for a Linux Wayland input-access nagbar after automatic permission setup failed. {settingsMenuName} is the shared user settings tab label for shortcuts and {productName} is the app name.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_ENABLE_DESCRIPTOR = msg({
|
||||
message: 'Enable',
|
||||
comment: 'Button label in the Linux Wayland input-access nagbar.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_RECHECK_DESCRIPTOR = msg({
|
||||
message: 'Recheck',
|
||||
comment: 'Button label in the Linux Wayland input-access nagbar after the user changes OS permissions.',
|
||||
});
|
||||
export const LinuxInputAccessNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const needsRelogin = NativePermission.linuxInputAccessGrantNeedsRelogin;
|
||||
const hasError = NativePermission.linuxInputAccessGrantError !== null;
|
||||
const shortcutsSettingsMenuName = getUserSettingsTabLabel(i18n, 'keybinds');
|
||||
const handleEnable = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await NativePermission.grantLinuxInputAccess();
|
||||
await KeybindManager.reapplyGlobalShortcuts();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const handleRecheck = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await NativePermission.recheckLinuxInputAccess();
|
||||
await KeybindManager.reapplyGlobalShortcuts();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const message = needsRelogin
|
||||
? i18n._(LINUX_INPUT_ACCESS_RELOGIN_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: hasError
|
||||
? i18n._(LINUX_INPUT_ACCESS_ERROR_DESCRIPTOR, {
|
||||
settingsMenuName: shortcutsSettingsMenuName,
|
||||
productName: PRODUCT_NAME,
|
||||
})
|
||||
: i18n._(LINUX_INPUT_ACCESS_DESCRIPTION_DESCRIPTOR, {productName: PRODUCT_NAME});
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={NativePermission.dismissLinuxInputAccessNagbar}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={NativePermission.dismissLinuxInputAccessNagbar}
|
||||
message={message}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={needsRelogin ? handleRecheck : handleEnable}
|
||||
submitting={submitting}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar-button.enable"
|
||||
>
|
||||
{needsRelogin
|
||||
? i18n._(LINUX_INPUT_ACCESS_RECHECK_DESCRIPTOR)
|
||||
: i18n._(LINUX_INPUT_ACCESS_ENABLE_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export function hostedImageUrl(name: string): string {
|
||||
return `https://fluxer.app/static/img/${name}`;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
|
||||
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`;
|
||||
export const DESKTOP_ENTRY_NAME = 'Fluxer';
|
||||
export const CANARY_DESKTOP_ENTRY_NAME = 'Fluxer Canary';
|
||||
export const VOICE_PROVIDER_NAME = 'LiveKit';
|
||||
export const PAYMENT_PROVIDER_NAME = 'Stripe';
|
||||
export const BLUESKY_PROVIDER_NAME = 'Bluesky';
|
||||
|
||||
@@ -33,9 +33,11 @@ import {
|
||||
shouldSuppressLocalShortcutForModalFocus,
|
||||
shouldSuppressShortcutForFullscreenMedia,
|
||||
} from '@app/features/app/keybindings/utils/ModalSuppression';
|
||||
import {reactToPushToTalkModeChanges} from '@app/features/app/keybindings/utils/PushToTalkModeReconcile';
|
||||
import {
|
||||
buildCustomRuntimeKeybinds,
|
||||
buildDefaultRuntimeKeybinds,
|
||||
gamepadSourceIdForKeybind,
|
||||
HOLD_ACTIONS,
|
||||
HOLD_ACTIONS_FOR_PTT_MODE,
|
||||
HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
hookShortcutIdForAction,
|
||||
hookShortcutIdForKeybind,
|
||||
type RuntimeKeybind,
|
||||
sourceIdForKeybind,
|
||||
} from '@app/features/app/keybindings/utils/RuntimeKeybinds';
|
||||
import {LOCAL_SHORTCUT_ACTION_PRIORITY} from '@app/features/app/keybindings/utils/ShortcutPriority';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -51,8 +54,10 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import GuildList from '@app/features/guild/state/GuildList';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import GlobalShortcuts, {getGlobalShortcutsApi} from '@app/features/input/state/GlobalShortcuts';
|
||||
import Keybind, {
|
||||
type CustomKeybindEntry,
|
||||
isKeybindCommand,
|
||||
type KeybindCommand,
|
||||
type KeybindConfig,
|
||||
type KeyCombo,
|
||||
@@ -68,9 +73,7 @@ import * as NavigationCommands from '@app/features/navigation/commands/Navigatio
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
import NativePermission, {
|
||||
type LinuxInputAccessNagbarReason,
|
||||
} from '@app/features/permissions/system/state/NativePermission';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {ensureMacPermission} from '@app/features/permissions/system/utils/MacPermissionGate';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
@@ -87,11 +90,19 @@ import CompactVoiceCallHeight, {
|
||||
} from '@app/features/voice/state/CompactVoiceCallHeight';
|
||||
import MockIncomingCall from '@app/features/voice/state/MockIncomingCall';
|
||||
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
|
||||
import type {
|
||||
GlobalShortcutActionDefinition,
|
||||
GlobalShortcutBinding,
|
||||
GlobalShortcutCombo,
|
||||
GlobalShortcutEvent,
|
||||
GlobalShortcutsApi,
|
||||
GlobalShortcutsSyncPayload,
|
||||
} from '@app/types/electron.d';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import CombokeysImport from 'combokeys';
|
||||
import {autorun, reaction} from 'mobx';
|
||||
import {autorun, compareStructural, reaction} from 'mobx';
|
||||
|
||||
const normalizeKeyboardShortcutKey = (key: string): string => {
|
||||
if (key === ' ') return 'space';
|
||||
@@ -108,6 +119,86 @@ export {
|
||||
|
||||
const ROUTE_ALLOWED_ACTIONS = new Set<KeybindCommand>(['system_open_theme_studio_popout']);
|
||||
const GAMEPAD_POLL_INTERVAL_MS = 50;
|
||||
const PORTAL_SOURCE_ID_PREFIX = 'portal:';
|
||||
|
||||
interface HoldBindingEntry {
|
||||
action: HoldAction;
|
||||
sourceId: string;
|
||||
gamepadSourceId: string;
|
||||
combo: KeyCombo;
|
||||
}
|
||||
|
||||
interface GlobalHoldRouting {
|
||||
hooksActive: boolean;
|
||||
supportsMouseButtons: boolean;
|
||||
supportsModifierOnly: boolean;
|
||||
}
|
||||
|
||||
function isHoldAction(action: string): action is HoldAction {
|
||||
return HOLD_ACTIONS.includes(action as HoldAction);
|
||||
}
|
||||
|
||||
function comboHasModifiers(combo: GlobalShortcutCombo): boolean {
|
||||
return combo.ctrl || combo.alt || combo.shift || combo.meta;
|
||||
}
|
||||
|
||||
function toGlobalShortcutCombo(combo: KeyCombo): GlobalShortcutCombo | null {
|
||||
if (combo.gamepadButton != null) return null;
|
||||
const hasKey = (combo.key ?? '') !== '' || (combo.code ?? '') !== '';
|
||||
if (!hasKey && combo.mouseButton == null) return null;
|
||||
const isMacOS = isNativeMacOS();
|
||||
const result: GlobalShortcutCombo = {
|
||||
key: combo.key ?? '',
|
||||
ctrl: Boolean(combo.ctrl) || (!isMacOS && Boolean(combo.ctrlOrMeta)),
|
||||
alt: Boolean(combo.alt),
|
||||
shift: Boolean(combo.shift),
|
||||
meta: Boolean(combo.meta) || (isMacOS && Boolean(combo.ctrlOrMeta)),
|
||||
};
|
||||
if (combo.code) result.code = combo.code;
|
||||
if (combo.mouseButton != null) result.mouseButton = combo.mouseButton;
|
||||
if (combo.modifierOnly) result.modifierOnly = true;
|
||||
if (combo.modifierOnly && combo.bothSides) result.bothSides = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
function createHoldBindingRuntime(entry: HoldBindingEntry): HoldBindingRuntime {
|
||||
const {combo} = entry;
|
||||
return {
|
||||
action: entry.action,
|
||||
sourceId: entry.sourceId,
|
||||
gamepadSourceId: entry.gamepadSourceId,
|
||||
combo,
|
||||
keycode: null,
|
||||
keyName: null,
|
||||
physicalKeyName: null,
|
||||
mouseButton: combo.mouseButton ?? null,
|
||||
gamepadButton: combo.gamepadButton ?? null,
|
||||
isModifierOnly: Boolean(combo.modifierOnly),
|
||||
ctrlOrMeta: Boolean(combo.ctrlOrMeta),
|
||||
requireBothSides: Boolean(combo.modifierOnly && combo.bothSides),
|
||||
modifiers: {
|
||||
ctrl: Boolean(combo.ctrl),
|
||||
alt: Boolean(combo.alt),
|
||||
shift: Boolean(combo.shift),
|
||||
meta: Boolean(combo.meta),
|
||||
},
|
||||
routing: null,
|
||||
pressedKeycodes: new Set<number>(),
|
||||
localPressedCodes: new Set<string>(),
|
||||
localActiveCode: null,
|
||||
localMouseActive: false,
|
||||
globalMouseActive: false,
|
||||
localKeyDown: null,
|
||||
localKeyUp: null,
|
||||
localMouseDown: null,
|
||||
localMouseUp: null,
|
||||
gamepadHeld: false,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyHoldSourceId(binding: HoldBindingRuntime): string {
|
||||
return `legacy:${hookShortcutIdForAction(binding.action, binding.combo)}`;
|
||||
}
|
||||
|
||||
class KeybindManager {
|
||||
private handlers = new Map<KeybindCommand, KeybindHandler>();
|
||||
@@ -117,8 +208,11 @@ class KeybindManager {
|
||||
private disposers: Array<() => void> = [];
|
||||
private combokeys: CombokeysInstance | null = null;
|
||||
private inputMonitoringHookStatus: 'unknown' | 'granted' | 'denied' = 'unknown';
|
||||
pttReleaseTimer: NodeJS.Timeout | null = null;
|
||||
private registeredGlobalHookShortcutIds = new Set<string>();
|
||||
private globalShortcutsEventUnsubscribe: (() => void) | null = null;
|
||||
private syncedGlobalSourceIds = new Set<string>();
|
||||
private syncedSourceIdByHookId = new Map<string, string>();
|
||||
private syncedGlobalActions: Array<GlobalShortcutActionDefinition> = [];
|
||||
private globalKeyHookUnsubscribes: Array<() => void> = [];
|
||||
private globalKeybindTriggeredUnsubscribe: (() => void) | null = null;
|
||||
private globalKeyHookStarted = false;
|
||||
@@ -420,26 +514,29 @@ class KeybindManager {
|
||||
() => this.refreshLocalShortcuts(),
|
||||
),
|
||||
);
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const desired = this.computeDesiredGlobalHookShortcuts();
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(desired));
|
||||
}),
|
||||
);
|
||||
GlobalShortcuts.attach();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
this.attachGlobalShortcuts(globalShortcutsApi);
|
||||
} else {
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const desired = this.computeDesiredGlobalHookShortcuts();
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(desired));
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => Keybind.transmitMode,
|
||||
() => {
|
||||
MediaEngine.handlePushToTalkModeChange();
|
||||
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
|
||||
({entries, routing}) => {
|
||||
const bindings = entries.map(createHoldBindingRuntime);
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(bindings, routing));
|
||||
},
|
||||
{equals: compareStructural, fireImmediately: true},
|
||||
),
|
||||
);
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const bindings = this.buildHoldBindings();
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(bindings));
|
||||
}),
|
||||
);
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const pathname = Navigation.pathname;
|
||||
@@ -461,79 +558,74 @@ class KeybindManager {
|
||||
return this.inputSyncQueue;
|
||||
}
|
||||
|
||||
private buildHoldBindings(): Array<HoldBindingRuntime> {
|
||||
const bindings: Array<HoldBindingRuntime> = [];
|
||||
private buildHoldBindingEntries(): Array<HoldBindingEntry> {
|
||||
const entries: Array<HoldBindingEntry> = [];
|
||||
const inPttMode = Keybind.isPushToTalkEffective();
|
||||
const eligibleActions = inPttMode ? HOLD_ACTIONS_FOR_PTT_MODE : HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE;
|
||||
const customs: ReadonlyArray<CustomKeybindEntry> = Keybind.getCustomKeybinds();
|
||||
const pushBinding = (action: HoldAction, combo: KeyCombo): void => {
|
||||
if (!this.isActionAllowedForCurrentView(action)) return;
|
||||
const hasBinding = !!(combo.key || combo.code || combo.gamepadButton != null || combo.mouseButton != null);
|
||||
if (!hasBinding) return;
|
||||
bindings.push({
|
||||
action,
|
||||
combo,
|
||||
keycode: null,
|
||||
keyName: null,
|
||||
physicalKeyName: null,
|
||||
mouseButton: combo.mouseButton ?? null,
|
||||
gamepadButton: combo.gamepadButton ?? null,
|
||||
isModifierOnly: Boolean(combo.modifierOnly),
|
||||
ctrlOrMeta: Boolean(combo.ctrlOrMeta),
|
||||
requireBothSides: Boolean(combo.modifierOnly && combo.bothSides),
|
||||
modifiers: {
|
||||
ctrl: Boolean(combo.ctrl),
|
||||
alt: Boolean(combo.alt),
|
||||
shift: Boolean(combo.shift),
|
||||
meta: Boolean(combo.meta),
|
||||
},
|
||||
routing: null,
|
||||
pressedKeycodes: new Set<number>(),
|
||||
localPressedCodes: new Set<string>(),
|
||||
localActiveCode: null,
|
||||
localMouseActive: false,
|
||||
globalMouseActive: false,
|
||||
localKeyDown: null,
|
||||
localKeyUp: null,
|
||||
localMouseDown: null,
|
||||
localMouseUp: null,
|
||||
gamepadHeld: false,
|
||||
});
|
||||
};
|
||||
for (const action of eligibleActions) {
|
||||
for (const entry of customs) {
|
||||
if (!entry.enabled) continue;
|
||||
if (entry.action !== action) continue;
|
||||
pushBinding(action, entry.combo);
|
||||
const combo = entry.combo;
|
||||
const hasBinding = !!(combo.key || combo.code || combo.gamepadButton != null || combo.mouseButton != null);
|
||||
if (!hasBinding) continue;
|
||||
const keybind = {id: entry.id, action};
|
||||
entries.push({
|
||||
action,
|
||||
sourceId: sourceIdForKeybind(keybind),
|
||||
gamepadSourceId: gamepadSourceIdForKeybind(keybind),
|
||||
combo: {...combo},
|
||||
});
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async applyHoldBindings(bindings: Array<HoldBindingRuntime>): Promise<void> {
|
||||
private getGlobalHoldRouting(): GlobalHoldRouting | null {
|
||||
if (!getGlobalShortcutsApi()) return null;
|
||||
const status = GlobalShortcuts.status;
|
||||
return {
|
||||
hooksActive: GlobalShortcuts.hooksActive && GlobalShortcuts.backend !== 'portal',
|
||||
supportsMouseButtons: status?.supportsMouseButtons === true,
|
||||
supportsModifierOnly: status?.supportsModifierOnly === true,
|
||||
};
|
||||
}
|
||||
|
||||
private canRouteHoldBindingGlobally(binding: HoldBindingRuntime, routing: GlobalHoldRouting): boolean {
|
||||
if (!routing.hooksActive) return false;
|
||||
if (binding.mouseButton !== null) return routing.supportsMouseButtons;
|
||||
if (binding.isModifierOnly) return routing.supportsModifierOnly;
|
||||
return true;
|
||||
}
|
||||
|
||||
private rebuildHoldBindings(): void {
|
||||
const bindings = this.buildHoldBindingEntries().map(createHoldBindingRuntime);
|
||||
const routing = this.getGlobalHoldRouting();
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(bindings, routing));
|
||||
}
|
||||
|
||||
private async applyHoldBindings(
|
||||
bindings: Array<HoldBindingRuntime>,
|
||||
routing: GlobalHoldRouting | null,
|
||||
): Promise<void> {
|
||||
this.detachLocalHoldListener();
|
||||
this.releaseGlobalHoldBindings();
|
||||
this.releaseGamepadHoldBindings();
|
||||
this.holdBindings = bindings;
|
||||
if (bindings.length === 0 || this.suspended || !this.initialized) {
|
||||
this.maybeStopGlobalKeyHook();
|
||||
if (routing === null) this.maybeStopGlobalKeyHook();
|
||||
this.refreshGamepadPolling();
|
||||
return;
|
||||
}
|
||||
const electronApi = getElectronAPI();
|
||||
const globalHookAvailable = !!electronApi?.globalKeyHookStart;
|
||||
const wantsGlobal = bindings.some((b) => {
|
||||
const hasGlobalRoutable = !!(b.combo.key || b.combo.code || b.mouseButton != null);
|
||||
return hasGlobalRoutable && (b.combo.global ?? false) && globalHookAvailable;
|
||||
});
|
||||
let globalReady = false;
|
||||
if (wantsGlobal) {
|
||||
globalReady = await this.startGlobalKeyHook('push-to-talk');
|
||||
}
|
||||
const legacyGlobalReady = routing === null && (await this.startLegacyGlobalHoldHook(bindings));
|
||||
let needsLocal = false;
|
||||
for (const binding of bindings) {
|
||||
const hasGlobalRoutable = !!(binding.combo.key || binding.combo.code || binding.mouseButton != null);
|
||||
if (globalReady && hasGlobalRoutable && (binding.combo.global ?? false)) {
|
||||
const wantsGlobal = hasGlobalRoutable && (binding.combo.global ?? false);
|
||||
if (wantsGlobal && routing !== null && this.canRouteHoldBindingGlobally(binding, routing)) {
|
||||
binding.routing = 'global';
|
||||
} else if (wantsGlobal && legacyGlobalReady) {
|
||||
binding.keycode = jsKeyToUiohookKeycode(binding.combo.code ?? binding.combo.key);
|
||||
binding.keyName = keyNameForGlobalHook(binding.combo);
|
||||
binding.physicalKeyName = physicalKeyNameForGlobalHook(binding.combo);
|
||||
@@ -545,13 +637,158 @@ class KeybindManager {
|
||||
binding.routing = null;
|
||||
}
|
||||
}
|
||||
this.maybeStopGlobalKeyHook();
|
||||
if (routing === null) this.maybeStopGlobalKeyHook();
|
||||
if (needsLocal) {
|
||||
this.attachLocalHoldListener();
|
||||
}
|
||||
this.refreshGamepadPolling();
|
||||
}
|
||||
|
||||
private async startLegacyGlobalHoldHook(bindings: ReadonlyArray<HoldBindingRuntime>): Promise<boolean> {
|
||||
if (!getElectronAPI()?.globalKeyHookStart) return false;
|
||||
const wantsGlobal = bindings.some((binding) => {
|
||||
const hasGlobalRoutable = !!(binding.combo.key || binding.combo.code || binding.mouseButton != null);
|
||||
return hasGlobalRoutable && (binding.combo.global ?? false);
|
||||
});
|
||||
if (!wantsGlobal) return false;
|
||||
return this.startGlobalKeyHook();
|
||||
}
|
||||
|
||||
private attachGlobalShortcuts(api: GlobalShortcutsApi): void {
|
||||
this.globalShortcutsEventUnsubscribe = api.onEvent((event) => this.handleGlobalShortcutEvent(event));
|
||||
void this.enqueueInputSync(() => api.setPaused(this.suspended));
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => this.buildGlobalShortcutsSyncPayload(),
|
||||
(payload) => {
|
||||
void this.enqueueInputSync(() => this.syncGlobalShortcuts(api, payload));
|
||||
},
|
||||
{equals: compareStructural, fireImmediately: true},
|
||||
),
|
||||
reaction(
|
||||
() => this.canDedupeGlobalPresses(),
|
||||
() => this.activeGlobalShortcutPressIds.clear(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private detachGlobalShortcuts(api: GlobalShortcutsApi): void {
|
||||
this.globalShortcutsEventUnsubscribe?.();
|
||||
this.globalShortcutsEventUnsubscribe = null;
|
||||
const payload: GlobalShortcutsSyncPayload = {bindings: [], actions: this.syncedGlobalActions};
|
||||
this.syncedGlobalSourceIds = new Set();
|
||||
this.syncedSourceIdByHookId = new Map();
|
||||
void this.enqueueInputSync(() => api.sync(payload));
|
||||
const pushToTalkEngaged = Keybind.pushToTalkHeld;
|
||||
const pushToMuteEngaged = Keybind.pushToMuteHeld;
|
||||
Keybind.resetPushToTalkState();
|
||||
Keybind.resetPushToMuteState();
|
||||
if (pushToTalkEngaged) MediaEngine.applyPushToTalkHold(false);
|
||||
if (pushToMuteEngaged) MediaEngine.applyPushToMuteHold(false);
|
||||
}
|
||||
|
||||
private buildGlobalShortcutsSyncPayload(): GlobalShortcutsSyncPayload {
|
||||
const bindings: Array<GlobalShortcutBinding> = [];
|
||||
const seenHookIds = new Set<string>();
|
||||
for (const entry of this.resolvedKeybinds) {
|
||||
if (!entry.allowGlobal || !(entry.combo.global ?? false)) continue;
|
||||
const combo = toGlobalShortcutCombo(entry.combo);
|
||||
if (!combo) continue;
|
||||
const hookId = hookShortcutIdForKeybind(entry);
|
||||
if (hookId === null || seenHookIds.has(hookId)) continue;
|
||||
seenHookIds.add(hookId);
|
||||
bindings.push({
|
||||
sourceId: sourceIdForKeybind(entry),
|
||||
action: entry.action,
|
||||
combo,
|
||||
});
|
||||
}
|
||||
const actions: Array<GlobalShortcutActionDefinition> = [];
|
||||
for (const config of Keybind.getDefaults()) {
|
||||
if (!config.allowGlobal) continue;
|
||||
const hold = isHoldAction(config.action);
|
||||
const preferred = bindings.find(
|
||||
(binding) =>
|
||||
binding.action === config.action &&
|
||||
binding.combo.mouseButton === undefined &&
|
||||
!(hold && comboHasModifiers(binding.combo)),
|
||||
);
|
||||
actions.push({
|
||||
action: config.action,
|
||||
description: config.label,
|
||||
preferredCombo: preferred ? preferred.combo : null,
|
||||
});
|
||||
}
|
||||
return {bindings, actions};
|
||||
}
|
||||
|
||||
private async syncGlobalShortcuts(api: GlobalShortcutsApi, payload: GlobalShortcutsSyncPayload): Promise<void> {
|
||||
if (!this.initialized) return;
|
||||
if (payload.bindings.length > 0) {
|
||||
await this.checkInputMonitoringPermission();
|
||||
}
|
||||
const sourceIds = new Set(payload.bindings.map((binding) => binding.sourceId));
|
||||
const sourceIdByHookId = new Map<string, string>();
|
||||
for (const entry of this.resolvedKeybinds) {
|
||||
const sourceId = sourceIdForKeybind(entry);
|
||||
if (!sourceIds.has(sourceId)) continue;
|
||||
const hookId = hookShortcutIdForKeybind(entry);
|
||||
if (hookId !== null) sourceIdByHookId.set(hookId, sourceId);
|
||||
}
|
||||
this.syncedGlobalSourceIds = sourceIds;
|
||||
this.syncedSourceIdByHookId = sourceIdByHookId;
|
||||
this.syncedGlobalActions = payload.actions;
|
||||
await api.sync(payload);
|
||||
}
|
||||
|
||||
private isSyncedGlobalShortcutSource(event: GlobalShortcutEvent): boolean {
|
||||
if (event.sourceId.startsWith(PORTAL_SOURCE_ID_PREFIX)) {
|
||||
return this.syncedGlobalActions.some((definition) => definition.action === event.action);
|
||||
}
|
||||
return this.syncedGlobalSourceIds.has(event.sourceId);
|
||||
}
|
||||
|
||||
private handleGlobalShortcutEvent(event: GlobalShortcutEvent): void {
|
||||
if (!isKeybindCommand(event.action)) return;
|
||||
const action = event.action;
|
||||
const handler = this.handlers.get(action);
|
||||
if (!handler) return;
|
||||
const payload = {type: event.phase, source: 'global', sourceId: event.sourceId} as const;
|
||||
if (isHoldAction(action)) {
|
||||
if (event.phase === 'press' && (this.suspended || !this.isSyncedGlobalShortcutSource(event))) return;
|
||||
handler(payload);
|
||||
return;
|
||||
}
|
||||
if (event.phase === 'release') {
|
||||
if (!this.activeGlobalShortcutPressIds.delete(event.sourceId)) return;
|
||||
handler(payload);
|
||||
return;
|
||||
}
|
||||
if (!this.isSyncedGlobalShortcutSource(event)) return;
|
||||
if (this.activeGlobalShortcutPressIds.has(event.sourceId)) return;
|
||||
if (this.suspended) return;
|
||||
if (!this.isActionAllowedForCurrentView(action)) return;
|
||||
if (shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(action)) return;
|
||||
this.activeGlobalShortcutPressIds.add(event.sourceId);
|
||||
handler(payload);
|
||||
}
|
||||
|
||||
private canDedupeGlobalPresses(): boolean {
|
||||
const backend = GlobalShortcuts.backend;
|
||||
return GlobalShortcuts.hooksActive && backend !== 'portal' && backend !== 'none';
|
||||
}
|
||||
|
||||
private globalPressDedupeId(entry: RuntimeKeybind): string | null {
|
||||
const hookId = hookShortcutIdForKeybind(entry);
|
||||
if (getGlobalShortcutsApi()) {
|
||||
if (!this.canDedupeGlobalPresses() || hookId === null) return null;
|
||||
return this.syncedSourceIdByHookId.get(hookId) ?? null;
|
||||
}
|
||||
if (!(entry.combo.global ?? false)) return null;
|
||||
return hookId !== null && this.registeredGlobalHookShortcutIds.has(hookId) ? hookId : null;
|
||||
}
|
||||
|
||||
private attachLocalHoldListener(): void {
|
||||
if (this.localHoldListenerAttached) {
|
||||
this.detachLocalHoldListener();
|
||||
@@ -566,14 +803,14 @@ class KeybindManager {
|
||||
binding.localPressedCodes.add(event.code);
|
||||
const required = this.requiredModifierKeyCount(binding);
|
||||
if (binding.localPressedCodes.size === required) {
|
||||
this.fireHoldHandler(binding, 'press', 'local');
|
||||
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!this.localKeyEventMatchesBinding(binding, event)) continue;
|
||||
if (binding.localActiveCode === event.code) continue;
|
||||
binding.localActiveCode = event.code;
|
||||
this.fireHoldHandler(binding, 'press', 'local');
|
||||
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent): void => {
|
||||
@@ -585,13 +822,13 @@ class KeybindManager {
|
||||
const wasAtThreshold = binding.localPressedCodes.size === required;
|
||||
binding.localPressedCodes.delete(event.code);
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (binding.localActiveCode !== event.code) continue;
|
||||
binding.localActiveCode = null;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
@@ -602,7 +839,7 @@ class KeybindManager {
|
||||
if (!this.matchesModifiers(binding, event)) continue;
|
||||
if (binding.localMouseActive) continue;
|
||||
binding.localMouseActive = true;
|
||||
this.fireHoldHandler(binding, 'press', 'local');
|
||||
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
const onMouseUp = (event: MouseEvent): void => {
|
||||
@@ -612,7 +849,7 @@ class KeybindManager {
|
||||
if (event.button !== binding.mouseButton) continue;
|
||||
if (!binding.localMouseActive) continue;
|
||||
binding.localMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
for (const binding of this.holdBindings) {
|
||||
@@ -626,19 +863,19 @@ class KeybindManager {
|
||||
if (binding.routing !== 'local') continue;
|
||||
if (binding.localActiveCode !== null) {
|
||||
binding.localActiveCode = null;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
if (binding.localPressedCodes.size > 0) {
|
||||
const required = this.requiredModifierKeyCount(binding);
|
||||
const wasAtThreshold = binding.localPressedCodes.size >= required;
|
||||
binding.localPressedCodes.clear();
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
}
|
||||
if (binding.localMouseActive) {
|
||||
binding.localMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -682,19 +919,19 @@ class KeybindManager {
|
||||
for (const binding of this.holdBindings) {
|
||||
if (binding.localActiveCode !== null) {
|
||||
binding.localActiveCode = null;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
if (binding.localPressedCodes.size > 0) {
|
||||
const required = this.requiredModifierKeyCount(binding);
|
||||
const wasAtThreshold = binding.localPressedCodes.size >= required;
|
||||
binding.localPressedCodes.clear();
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
}
|
||||
if (binding.localMouseActive) {
|
||||
binding.localMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
binding.localKeyDown = null;
|
||||
binding.localKeyUp = null;
|
||||
@@ -810,23 +1047,24 @@ class KeybindManager {
|
||||
if (type === 'press' && shouldSuppressLocalShortcutForModalFocus(binding, event.target ?? null)) continue;
|
||||
const id = hookShortcutIdForKeybind(binding);
|
||||
if (!id) continue;
|
||||
const isRegisteredGlobalShortcut = this.isHookShortcutRegistered(binding);
|
||||
const globalPressId = this.globalPressDedupeId(binding);
|
||||
const sourceId = sourceIdForKeybind(binding);
|
||||
if (type === 'release') {
|
||||
if (!this.activeLocalShortcutPressIds.delete(id)) continue;
|
||||
if (isRegisteredGlobalShortcut) {
|
||||
this.activeGlobalShortcutPressIds.delete(id);
|
||||
if (globalPressId !== null) {
|
||||
this.activeGlobalShortcutPressIds.delete(globalPressId);
|
||||
}
|
||||
this.fireShortcutHandler(binding, type, 'local', {shiftKey: event.shiftKey});
|
||||
this.fireShortcutHandler(binding, type, 'local', sourceId, {shiftKey: event.shiftKey});
|
||||
continue;
|
||||
}
|
||||
if (!this.comboModifiersMatch(combo, event)) continue;
|
||||
if (this.activeLocalShortcutPressIds.has(id)) continue;
|
||||
if (isRegisteredGlobalShortcut) {
|
||||
if (this.activeGlobalShortcutPressIds.has(id)) continue;
|
||||
this.activeGlobalShortcutPressIds.add(id);
|
||||
if (globalPressId !== null) {
|
||||
if (this.activeGlobalShortcutPressIds.has(globalPressId)) continue;
|
||||
this.activeGlobalShortcutPressIds.add(globalPressId);
|
||||
}
|
||||
this.activeLocalShortcutPressIds.add(id);
|
||||
this.fireShortcutHandler(binding, type, 'local', {shiftKey: event.shiftKey});
|
||||
this.fireShortcutHandler(binding, type, 'local', sourceId, {shiftKey: event.shiftKey});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,13 +1086,9 @@ class KeybindManager {
|
||||
for (const shortcut of comboToCombokeysStrings(entry.combo)) {
|
||||
this.activeLocalShortcutPressIds.delete(shortcut);
|
||||
}
|
||||
const registeredHookShortcutId = hookShortcutIdForKeybind(entry);
|
||||
if (
|
||||
(entry.combo.global ?? false) &&
|
||||
registeredHookShortcutId &&
|
||||
this.registeredGlobalHookShortcutIds.has(registeredHookShortcutId)
|
||||
) {
|
||||
this.activeGlobalShortcutPressIds.delete(registeredHookShortcutId);
|
||||
const globalPressId = this.globalPressDedupeId(entry);
|
||||
if (globalPressId !== null) {
|
||||
this.activeGlobalShortcutPressIds.delete(globalPressId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,7 +1118,7 @@ class KeybindManager {
|
||||
if (!keyboardEventStartsComboPress(entry.combo, event, {isMacOS})) continue;
|
||||
if (this.activeLocalShortcutPressIds.has(id)) {
|
||||
if (
|
||||
this.isHookShortcutRegistered(entry) ||
|
||||
this.globalPressDedupeId(entry) !== null ||
|
||||
!keyboardEventCanRecoverStaleMacMetaPress(entry.combo, event, {isMacOS})
|
||||
) {
|
||||
continue;
|
||||
@@ -1013,7 +1247,7 @@ class KeybindManager {
|
||||
for (const binding of this.holdBindings) {
|
||||
if (binding.gamepadHeld) {
|
||||
binding.gamepadHeld = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.gamepadSourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1021,7 +1255,7 @@ class KeybindManager {
|
||||
private releaseGamepadShortcutStates(): void {
|
||||
for (const {binding, pressed} of this.gamepadShortcutStates.values()) {
|
||||
if (pressed) {
|
||||
this.fireShortcutHandler(binding, 'release', 'local');
|
||||
this.fireShortcutHandler(binding, 'release', 'local', gamepadSourceIdForKeybind(binding));
|
||||
}
|
||||
}
|
||||
this.gamepadShortcutStates.clear();
|
||||
@@ -1058,7 +1292,7 @@ class KeybindManager {
|
||||
const pressed = this.isGamepadButtonPressed(pads, target);
|
||||
if (pressed === binding.gamepadHeld) continue;
|
||||
binding.gamepadHeld = pressed;
|
||||
this.fireHoldHandler(binding, pressed ? 'press' : 'release', 'local');
|
||||
this.fireHoldHandler(binding, pressed ? 'press' : 'release', 'local', binding.gamepadSourceId);
|
||||
}
|
||||
for (const binding of this.localGamepadShortcutKeybinds) {
|
||||
if (!this.isActionAllowedForCurrentView(binding.action)) continue;
|
||||
@@ -1069,7 +1303,7 @@ class KeybindManager {
|
||||
const previous = this.gamepadShortcutStates.get(id)?.pressed ?? false;
|
||||
if (pressed === previous) continue;
|
||||
this.gamepadShortcutStates.set(id, {binding, pressed});
|
||||
this.fireShortcutHandler(binding, pressed ? 'press' : 'release', 'local');
|
||||
this.fireShortcutHandler(binding, pressed ? 'press' : 'release', 'local', gamepadSourceIdForKeybind(binding));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,22 +1316,30 @@ class KeybindManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private fireHoldHandler(binding: HoldBindingRuntime, type: 'press' | 'release', source: ShortcutSource): void {
|
||||
private fireHoldHandler(
|
||||
binding: HoldBindingRuntime,
|
||||
type: 'press' | 'release',
|
||||
source: ShortcutSource,
|
||||
sourceId: string,
|
||||
): void {
|
||||
const handler = this.handlers.get(binding.action);
|
||||
if (!handler) return;
|
||||
if (type === 'press') {
|
||||
if (this.suspended) return;
|
||||
if (!this.isActionAllowedForCurrentView(binding.action)) return;
|
||||
if (shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(binding.action)) return;
|
||||
if (source === 'local') {
|
||||
if (!this.isActionAllowedForCurrentView(binding.action)) return;
|
||||
if (shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(binding.action)) return;
|
||||
}
|
||||
}
|
||||
handler({type, source});
|
||||
handler({type, source, sourceId});
|
||||
}
|
||||
|
||||
private fireShortcutHandler(
|
||||
binding: RuntimeKeybind,
|
||||
type: 'press' | 'release',
|
||||
source: ShortcutSource,
|
||||
sourceId: string,
|
||||
options: {shiftKey?: boolean} = {},
|
||||
): void {
|
||||
if (this.suspended) return;
|
||||
@@ -1106,13 +1348,20 @@ class KeybindManager {
|
||||
if (Keybind.isActionMuted(binding.action)) return;
|
||||
const handler = this.handlers.get(binding.action);
|
||||
if (!handler) return;
|
||||
handler({type, source, shiftKey: options.shiftKey});
|
||||
handler({type, source, sourceId, shiftKey: options.shiftKey});
|
||||
}
|
||||
|
||||
async reapplyGlobalShortcuts() {
|
||||
if (!this.initialized) return;
|
||||
await this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
await this.enqueueInputSync(() => this.applyHoldBindings(this.buildHoldBindings()));
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
const payload = this.buildGlobalShortcutsSyncPayload();
|
||||
await this.enqueueInputSync(() => this.syncGlobalShortcuts(globalShortcutsApi, payload));
|
||||
} else {
|
||||
await this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
}
|
||||
this.rebuildHoldBindings();
|
||||
await this.inputSyncQueue;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -1120,6 +1369,11 @@ class KeybindManager {
|
||||
this.initialized = false;
|
||||
this.disposers.forEach((dispose) => dispose());
|
||||
this.disposers = [];
|
||||
GlobalShortcuts.detach();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
this.detachGlobalShortcuts(globalShortcutsApi);
|
||||
}
|
||||
if (this.globalKeybindTriggeredUnsubscribe) {
|
||||
this.globalKeybindTriggeredUnsubscribe();
|
||||
this.globalKeybindTriggeredUnsubscribe = null;
|
||||
@@ -1145,7 +1399,7 @@ class KeybindManager {
|
||||
this.combokeys = null;
|
||||
}
|
||||
|
||||
async startGlobalKeyHook(reason: LinuxInputAccessNagbarReason = 'global-hotkeys'): Promise<boolean> {
|
||||
private async startGlobalKeyHook(): Promise<boolean> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.globalKeyHookStart) return false;
|
||||
if (this.globalKeyHookStarted) return true;
|
||||
@@ -1153,19 +1407,8 @@ class KeybindManager {
|
||||
return false;
|
||||
}
|
||||
const started = await electronApi.globalKeyHookStart();
|
||||
if (!started) {
|
||||
if (NativePermission.isLinuxWaylandDesktop) {
|
||||
void NativePermission.recheckLinuxInputAccess();
|
||||
NativePermission.requestLinuxInputAccessNagbar(reason);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!started) return false;
|
||||
this.globalKeyHookStarted = true;
|
||||
if (NativePermission.isLinuxWaylandDesktop) {
|
||||
void NativePermission.recheckLinuxInputAccess().then((status) => {
|
||||
if (status === 'blocked') NativePermission.requestLinuxInputAccessNagbar(reason);
|
||||
});
|
||||
}
|
||||
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
|
||||
this.handleGlobalKeyEvent(
|
||||
event as {
|
||||
@@ -1222,11 +1465,15 @@ class KeybindManager {
|
||||
if (!this.isActionAllowedForCurrentView(keybind.action)) return;
|
||||
if (event.type === 'keydown' && shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(keybind.action)) return;
|
||||
handler({type: event.type === 'keydown' ? 'press' : 'release', source: 'global'});
|
||||
handler({
|
||||
type: event.type === 'keydown' ? 'press' : 'release',
|
||||
source: 'global',
|
||||
sourceId: `legacy:${event.id}`,
|
||||
});
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
stopGlobalKeyHook(): void {
|
||||
private stopGlobalKeyHook(): void {
|
||||
const electronApi = getElectronAPI();
|
||||
this.globalKeyHookUnsubscribes.forEach((unsub) => unsub());
|
||||
this.globalKeyHookUnsubscribes = [];
|
||||
@@ -1251,7 +1498,7 @@ class KeybindManager {
|
||||
if (!keybind || !HOLD_ACTIONS.includes(keybind.action as HoldAction)) continue;
|
||||
const handler = this.handlers.get(keybind.action);
|
||||
if (handler) {
|
||||
handler({type: 'release', source: 'global'});
|
||||
handler({type: 'release', source: 'global', sourceId: `legacy:${id}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1265,11 +1512,11 @@ class KeybindManager {
|
||||
if (binding.routing !== 'global') continue;
|
||||
if (binding.pressedKeycodes.size > 0) {
|
||||
binding.pressedKeycodes.clear();
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
if (binding.globalMouseActive) {
|
||||
binding.globalMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1316,14 +1563,14 @@ class KeybindManager {
|
||||
if (binding.pressedKeycodes.has(event.keycode)) return;
|
||||
binding.pressedKeycodes.add(event.keycode);
|
||||
if (binding.pressedKeycodes.size === requiredCount) {
|
||||
this.fireHoldHandler(binding, 'press', 'global');
|
||||
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
} else {
|
||||
if (!binding.pressedKeycodes.has(event.keycode)) return;
|
||||
const wasAtThreshold = binding.pressedKeycodes.size === requiredCount;
|
||||
binding.pressedKeycodes.delete(event.keycode);
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1332,13 +1579,13 @@ class KeybindManager {
|
||||
if (!this.globalKeyEventMatchesHoldBinding(binding, event)) return;
|
||||
if (event.type === 'keyup') {
|
||||
if (!binding.pressedKeycodes.delete(event.keycode)) return;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
return;
|
||||
}
|
||||
if (!this.globalHoldModifiersMatch(binding, event)) return;
|
||||
if (binding.pressedKeycodes.has(event.keycode)) return;
|
||||
binding.pressedKeycodes.add(event.keycode);
|
||||
this.fireHoldHandler(binding, 'press', 'global');
|
||||
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
|
||||
private globalKeyEventMatchesHoldBinding(
|
||||
@@ -1422,33 +1669,48 @@ class KeybindManager {
|
||||
if (event.type === 'mouseup') {
|
||||
if (!binding.globalMouseActive) continue;
|
||||
binding.globalMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
continue;
|
||||
}
|
||||
if (!this.globalHoldModifiersMatch(binding, event)) continue;
|
||||
if (binding.globalMouseActive) continue;
|
||||
binding.globalMouseActive = true;
|
||||
this.fireHoldHandler(binding, 'press', 'global');
|
||||
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
|
||||
suspend(): void {
|
||||
const wasSuspended = this.suspended;
|
||||
this.manualSuspendCount += 1;
|
||||
this.combokeys?.reset();
|
||||
this.releaseHoldBindingsForSuspension();
|
||||
this.detachLocalEditableShortcutCaptureListener();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
if (!wasSuspended && this.initialized) {
|
||||
void this.enqueueInputSync(() => globalShortcutsApi.setPaused(true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
void this.enqueueInputSync(() => {
|
||||
this.stopGlobalKeyHook();
|
||||
});
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
const wasSuspended = this.suspended;
|
||||
this.manualSuspendCount = Math.max(0, this.manualSuspendCount - 1);
|
||||
if (!this.suspended) {
|
||||
this.refreshLocalShortcuts();
|
||||
if (this.suspended) return;
|
||||
this.refreshLocalShortcuts();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
if (wasSuspended && this.initialized) {
|
||||
void this.enqueueInputSync(() => globalShortcutsApi.setPaused(false));
|
||||
}
|
||||
} else {
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(this.buildHoldBindings()));
|
||||
}
|
||||
this.rebuildHoldBindings();
|
||||
}
|
||||
|
||||
isSuspended(): boolean {
|
||||
@@ -1458,18 +1720,21 @@ class KeybindManager {
|
||||
private setRouteSuspended(value: boolean): void {
|
||||
if (this.routeSuspended === value) return;
|
||||
this.routeSuspended = value;
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (value) {
|
||||
this.combokeys?.reset();
|
||||
this.releaseHoldBindingsForSuspension();
|
||||
this.detachLocalHoldListener();
|
||||
this.releaseGamepadHoldBindings();
|
||||
this.detachLocalEditableShortcutCaptureListener();
|
||||
void this.enqueueInputSync(() => {
|
||||
this.stopGlobalKeyHook();
|
||||
});
|
||||
}
|
||||
if (!this.suspended) {
|
||||
this.refreshLocalShortcuts();
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(this.buildHoldBindings()));
|
||||
if (!globalShortcutsApi) {
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
}
|
||||
if (!this.localHoldListenerAttached && this.holdBindings.some((binding) => binding.routing === 'local')) {
|
||||
this.attachLocalHoldListener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1536,15 +1801,6 @@ class KeybindManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
private isHookShortcutRegistered(binding: RuntimeKeybind): boolean {
|
||||
return this.isHookShortcutIdRegistered(binding.action, binding.combo);
|
||||
}
|
||||
|
||||
private isHookShortcutIdRegistered(action: KeybindCommand, combo: KeyCombo): boolean {
|
||||
const id = hookShortcutIdForAction(action, combo);
|
||||
return id !== null && this.registeredGlobalHookShortcutIds.has(id);
|
||||
}
|
||||
|
||||
private computeDesiredGlobalHookShortcuts(): Map<string, KeyCombo> {
|
||||
const desiredCombos = new Map<string, KeyCombo>();
|
||||
for (const k of this.activeGlobalKeybinds) {
|
||||
@@ -1611,11 +1867,11 @@ class KeybindManager {
|
||||
if (hookShortcutIdForAction(binding.action, binding.combo) !== shortcutId) continue;
|
||||
if (binding.pressedKeycodes.size > 0) {
|
||||
binding.pressedKeycodes.clear();
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
if (binding.globalMouseActive) {
|
||||
binding.globalMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1737,7 +1993,6 @@ class KeybindManager {
|
||||
const {combo, action} = entry;
|
||||
const requiresKeyboardMode = entry.requiresKeyboardMode ?? false;
|
||||
const requiresMessageFocus = entry.requiresMessageFocus ?? false;
|
||||
const registeredHookShortcutId = hookShortcutIdForKeybind(entry);
|
||||
const handler = this.handlers.get(action);
|
||||
if (!handler) return false;
|
||||
if (type === 'press' && event.repeat) return false;
|
||||
@@ -1748,11 +2003,6 @@ class KeybindManager {
|
||||
}
|
||||
if (this.shouldIgnoreLocalShortcutEvent(entry, event)) return false;
|
||||
if (Keybind.isActionMuted(action)) return false;
|
||||
const isRegisteredGlobalShortcut = Boolean(
|
||||
(combo.global ?? false) &&
|
||||
registeredHookShortcutId &&
|
||||
this.registeredGlobalHookShortcutIds.has(registeredHookShortcutId),
|
||||
);
|
||||
if (requiresKeyboardMode && !KeyboardMode.keyboardModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
@@ -1771,17 +2021,19 @@ class KeybindManager {
|
||||
}
|
||||
focusedChannel = MessageFocus.getFocusedChannel();
|
||||
}
|
||||
if (isRegisteredGlobalShortcut && registeredHookShortcutId) {
|
||||
const globalPressId = this.globalPressDedupeId(entry);
|
||||
if (globalPressId !== null) {
|
||||
if (type === 'press') {
|
||||
if (this.activeGlobalShortcutPressIds.has(registeredHookShortcutId)) return false;
|
||||
this.activeGlobalShortcutPressIds.add(registeredHookShortcutId);
|
||||
if (this.activeGlobalShortcutPressIds.has(globalPressId)) return false;
|
||||
this.activeGlobalShortcutPressIds.add(globalPressId);
|
||||
} else {
|
||||
this.activeGlobalShortcutPressIds.delete(registeredHookShortcutId);
|
||||
this.activeGlobalShortcutPressIds.delete(globalPressId);
|
||||
}
|
||||
}
|
||||
handler({
|
||||
type,
|
||||
source: 'local',
|
||||
sourceId: sourceIdForKeybind(entry),
|
||||
context: focusedMessage ? {focusedMessage, focusedChannel} : undefined,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
|
||||
+33
-45
@@ -201,57 +201,45 @@ export function registerDefaultKeybindHandlers(host: HandlerHost, i18n: I18n): v
|
||||
if (type !== 'press') return;
|
||||
ThemeStudioCommands.openThemeStudioPopout();
|
||||
});
|
||||
host.register('voice_push_to_talk', ({type}) => {
|
||||
if (!Keybind.isPushToTalkEffective()) return;
|
||||
if (type === 'press') {
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (host.pttReleaseTimer) {
|
||||
clearTimeout(host.pttReleaseTimer);
|
||||
host.pttReleaseTimer = null;
|
||||
}
|
||||
const shouldUnmute = Keybind.handlePushToTalkPress();
|
||||
if (shouldUnmute) {
|
||||
MediaEngine.applyPushToTalkHold(true);
|
||||
}
|
||||
} else {
|
||||
const shouldMute = Keybind.handlePushToTalkRelease();
|
||||
if (shouldMute) {
|
||||
const delay = Keybind.pushToTalkReleaseDelay;
|
||||
host.pttReleaseTimer = setTimeout(() => {
|
||||
host.pttReleaseTimer = null;
|
||||
MediaEngine.applyPushToTalkHold(false);
|
||||
}, delay);
|
||||
}
|
||||
host.register('voice_push_to_talk', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_push_to_talk', sourceId, () => MediaEngine.applyPushToTalkHold(false));
|
||||
return;
|
||||
}
|
||||
});
|
||||
host.register('voice_push_to_mute', ({type}) => {
|
||||
if (Keybind.isPushToTalkEffective()) return;
|
||||
MediaEngine.applyPushToMuteHold(type === 'press');
|
||||
});
|
||||
host.register('voice_push_to_talk_priority', ({type}) => {
|
||||
if (!Keybind.isPushToTalkEffective()) return;
|
||||
if (type === 'press') {
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (host.pttReleaseTimer) {
|
||||
clearTimeout(host.pttReleaseTimer);
|
||||
host.pttReleaseTimer = null;
|
||||
}
|
||||
Keybind.handlePushToTalkPress();
|
||||
Keybind.setPrioritySpeakerHeld(true);
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (Keybind.pressHoldSource('voice_push_to_talk', sourceId)) {
|
||||
MediaEngine.applyPushToTalkHold(true);
|
||||
} else {
|
||||
Keybind.handlePushToTalkRelease();
|
||||
Keybind.setPrioritySpeakerHeld(false);
|
||||
const delay = Keybind.pushToTalkReleaseDelay;
|
||||
host.pttReleaseTimer = setTimeout(() => {
|
||||
host.pttReleaseTimer = null;
|
||||
MediaEngine.applyPushToTalkHold(false);
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
host.register('voice_priority_vad', ({type}) => {
|
||||
host.register('voice_push_to_mute', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_push_to_mute', sourceId, () => MediaEngine.applyPushToMuteHold(false));
|
||||
return;
|
||||
}
|
||||
if (Keybind.isPushToTalkEffective()) return;
|
||||
Keybind.setPrioritySpeakerHeld(type === 'press');
|
||||
if (Keybind.pressHoldSource('voice_push_to_mute', sourceId)) {
|
||||
MediaEngine.applyPushToMuteHold(true);
|
||||
}
|
||||
});
|
||||
host.register('voice_push_to_talk_priority', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_push_to_talk_priority', sourceId, () => MediaEngine.applyPushToTalkHold(false));
|
||||
return;
|
||||
}
|
||||
if (!Keybind.isPushToTalkEffective()) return;
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (Keybind.pressHoldSource('voice_push_to_talk_priority', sourceId)) {
|
||||
MediaEngine.applyPushToTalkHold(true);
|
||||
}
|
||||
});
|
||||
host.register('voice_priority_vad', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_priority_vad', sourceId);
|
||||
return;
|
||||
}
|
||||
if (Keybind.isPushToTalkEffective()) return;
|
||||
Keybind.pressHoldSource('voice_priority_vad', sourceId);
|
||||
});
|
||||
host.register('voice_toggle_vad', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface HandlerHost {
|
||||
readonly logger: Logger;
|
||||
readonly currentChannelId: string | null;
|
||||
readonly currentGuildId: string | null;
|
||||
pttReleaseTimer: NodeJS.Timeout | null;
|
||||
navigateToChannel(guildId: string | null, channelId: string): void;
|
||||
navigateToDirectMessages(): void;
|
||||
navigateToLastCommunityChannel(): boolean;
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ShortcutSource = 'local' | 'global';
|
||||
export type KeybindHandler = (payload: {
|
||||
type: 'press' | 'release';
|
||||
source: ShortcutSource;
|
||||
sourceId: string;
|
||||
context?: {
|
||||
focusedMessage?: Message;
|
||||
focusedChannel?: Channel | null;
|
||||
@@ -44,6 +45,8 @@ export type KeybindHandler = (payload: {
|
||||
|
||||
export interface HoldBindingRuntime {
|
||||
action: HoldAction;
|
||||
sourceId: string;
|
||||
gamepadSourceId: string;
|
||||
combo: KeyCombo;
|
||||
keycode: number | null;
|
||||
keyName: string | null;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
GlobalShortcutsApi,
|
||||
GlobalShortcutsBackend,
|
||||
GlobalShortcutsPortalState,
|
||||
GlobalShortcutsPortalStatus,
|
||||
GlobalShortcutsStatus,
|
||||
} from '@app/types/electron.d';
|
||||
import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
msg: (descriptor: unknown) => descriptor,
|
||||
t: (descriptor: unknown) => descriptor,
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: () => null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
|
||||
|
||||
(globalThis.window as unknown as Record<string, unknown>).__FLUXER_BOOTSTRAP__ = {
|
||||
config: {
|
||||
releaseChannel: 'stable',
|
||||
bootstrapApiEndpoint: BOOTSTRAP_ENDPOINT,
|
||||
bootstrapApiPublicEndpoint: BOOTSTRAP_ENDPOINT,
|
||||
},
|
||||
instance: {
|
||||
api_code_version: Number.MAX_SAFE_INTEGER,
|
||||
endpoints: {
|
||||
api: BOOTSTRAP_ENDPOINT,
|
||||
api_client: BOOTSTRAP_ENDPOINT,
|
||||
api_public: BOOTSTRAP_ENDPOINT,
|
||||
gateway: 'wss://gateway.primary.test',
|
||||
media: 'https://media.primary.test',
|
||||
static_cdn: 'https://cdn.primary.test',
|
||||
marketing: 'https://primary.test',
|
||||
admin: 'https://admin.primary.test',
|
||||
invite: 'https://primary.test/invite',
|
||||
gift: 'https://primary.test/gift',
|
||||
webapp: 'https://app.primary.test',
|
||||
upload_relay: 'https://upload.primary.test',
|
||||
},
|
||||
captcha: {provider: 'none'},
|
||||
features: {
|
||||
voice_enabled: false,
|
||||
stripe_enabled: false,
|
||||
self_hosted: false,
|
||||
presigned_attachment_uploads: false,
|
||||
emails_enabled: false,
|
||||
phone_verification_enabled: false,
|
||||
},
|
||||
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
|
||||
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''},
|
||||
registration: {mode: 'open', admin_registration_urls_enabled: true},
|
||||
community: {single_community: false, single_community_guild_id: null, direct_messages_disabled: false},
|
||||
services: {gif_enabled: true, youtube_enabled: false, bluesky_enabled: false},
|
||||
limits: undefined,
|
||||
push: {public_vapid_key: null},
|
||||
app_public: {
|
||||
branding: {
|
||||
product_name: 'Fluxer',
|
||||
icon_url: null,
|
||||
symbol_url: null,
|
||||
logo_url: null,
|
||||
wordmark_url: null,
|
||||
favicon_url: null,
|
||||
theme_color: null,
|
||||
},
|
||||
setup: {configured: true, admin_url: null},
|
||||
legal: {terms_url: null, privacy_url: null},
|
||||
registration: {collect_date_of_birth: true},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let statusListener: ((status: GlobalShortcutsStatus) => void) | null = null;
|
||||
|
||||
const globalShortcutsApi: Partial<GlobalShortcutsApi> = {
|
||||
sync: async () => {},
|
||||
getStatus: () => new Promise<GlobalShortcutsStatus>(() => {}),
|
||||
onStatus: (callback) => {
|
||||
statusListener = callback;
|
||||
return () => {
|
||||
statusListener = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@app/features/ui/utils/NativeUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@app/features/ui/utils/NativeUtils')>()),
|
||||
getElectronAPI: () => ({globalShortcuts: globalShortcutsApi}),
|
||||
}));
|
||||
|
||||
const PORTAL_ASSIGNED_ACTIONS_KEY = 'GlobalShortcuts:portalAssignedActions:v1';
|
||||
|
||||
interface PortalStatusOptions {
|
||||
recovering?: boolean;
|
||||
trigger?: string | null;
|
||||
error?: string | null;
|
||||
backend?: GlobalShortcutsBackend;
|
||||
directInputEnabled?: boolean;
|
||||
}
|
||||
|
||||
function portalStatus(state: GlobalShortcutsPortalState, options: PortalStatusOptions = {}): GlobalShortcutsStatus {
|
||||
const trigger = options.trigger === undefined ? 'F13' : options.trigger;
|
||||
const portal: GlobalShortcutsPortalStatus = {
|
||||
state,
|
||||
version: 2,
|
||||
canConfigure: true,
|
||||
canRecheck: state === 'unsupported' || state === 'error',
|
||||
portalAppId: 'app.fluxer.FluxerDesktop',
|
||||
shortcuts: [
|
||||
{action: 'voice_push_to_talk', triggerDescription: state === 'bound' ? trigger : null},
|
||||
{action: 'voice_push_to_mute', triggerDescription: null},
|
||||
],
|
||||
error: options.error ?? (state === 'error' ? 'portal-unavailable' : null),
|
||||
recovering: options.recovering ?? false,
|
||||
};
|
||||
return {
|
||||
backend: options.backend ?? (state === 'unsupported' ? 'none' : 'portal'),
|
||||
platform: 'linux',
|
||||
linux: {
|
||||
session: 'wayland',
|
||||
sandbox: 'none',
|
||||
desktop: 'gnome',
|
||||
portal,
|
||||
directInput: {available: true, enabled: options.directInputEnabled ?? false, locked: false},
|
||||
},
|
||||
hooksActive: false,
|
||||
hookError: null,
|
||||
supportsMouseButtons: false,
|
||||
supportsModifierOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
type Modules = {
|
||||
GlobalShortcuts: typeof import('@app/features/input/state/GlobalShortcuts').default;
|
||||
Keybind: typeof import('@app/features/input/state/InputKeybind').default;
|
||||
AppStorage: typeof import('@app/features/platform/state/PersistentStorage').default;
|
||||
reactToPushToTalkModeChanges: typeof import('@app/features/app/keybindings/utils/PushToTalkModeReconcile').reactToPushToTalkModeChanges;
|
||||
};
|
||||
|
||||
let modules: Modules;
|
||||
|
||||
function emit(status: GlobalShortcutsStatus): void {
|
||||
expect(statusListener).not.toBeNull();
|
||||
statusListener?.(status);
|
||||
}
|
||||
|
||||
describe('push-to-talk mode reconcile', () => {
|
||||
beforeAll(async () => {
|
||||
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
|
||||
AppStorage.setJSON(PORTAL_ASSIGNED_ACTIONS_KEY, ['voice_push_to_talk']);
|
||||
const {default: GlobalShortcuts} = await import('@app/features/input/state/GlobalShortcuts');
|
||||
const {default: Keybind} = await import('@app/features/input/state/InputKeybind');
|
||||
const {reactToPushToTalkModeChanges} = await import('@app/features/app/keybindings/utils/PushToTalkModeReconcile');
|
||||
modules = {GlobalShortcuts, Keybind, AppStorage, reactToPushToTalkModeChanges};
|
||||
Keybind.setTransmitMode('voice_push_to_talk');
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
modules.GlobalShortcuts.detach();
|
||||
const {default: Idle} = await import('@app/features/ui/state/Idle');
|
||||
Idle.destroy();
|
||||
});
|
||||
|
||||
it('restores the last settled portal assignment at startup before any status arrives', () => {
|
||||
const {GlobalShortcuts, Keybind} = modules;
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(false);
|
||||
GlobalShortcuts.attach();
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
emit(portalStatus('unknown'));
|
||||
emit(portalStatus('probing'));
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps push-to-talk effective through transient and error portal states', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
emit(portalStatus('bound'));
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
for (const status of [
|
||||
portalStatus('bound', {recovering: true}),
|
||||
portalStatus('bound', {recovering: true, trigger: null}),
|
||||
portalStatus('declined', {recovering: true}),
|
||||
portalStatus('unknown', {recovering: true, backend: 'none'}),
|
||||
portalStatus('probing', {recovering: true}),
|
||||
portalStatus('binding'),
|
||||
portalStatus('error', {recovering: true}),
|
||||
portalStatus('error', {error: 'portal-unavailable'}),
|
||||
portalStatus('error', {error: 'timeout'}),
|
||||
portalStatus('error', {error: 'session-closed'}),
|
||||
portalStatus('error', {error: 'identity'}),
|
||||
portalStatus('error', {error: 'bind-failed'}),
|
||||
portalStatus('unsupported', {recovering: true}),
|
||||
portalStatus('not-set-up'),
|
||||
portalStatus('unknown'),
|
||||
portalStatus('bound'),
|
||||
]) {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
}
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps push-to-talk effective while a recovering session reopens with no listed triggers', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
emit(portalStatus('bound'));
|
||||
for (const status of [
|
||||
portalStatus('bound', {recovering: true}),
|
||||
portalStatus('bound', {recovering: true, trigger: null}),
|
||||
portalStatus('binding', {recovering: true}),
|
||||
portalStatus('bound'),
|
||||
]) {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
}
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('flips push-to-talk only on settled states and preserves self-mute when it does', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
const expectFlip = (status: GlobalShortcutsStatus, effective: boolean): void => {
|
||||
reconcile.mockClear();
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(effective);
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
|
||||
};
|
||||
expectFlip(portalStatus('declined'), false);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('unsupported'), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('unknown', {backend: 'evdev', directInputEnabled: true}), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('bound', {trigger: null}), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
reconcile.mockClear();
|
||||
Keybind.setTransmitMode('voice_activity');
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false});
|
||||
Keybind.setTransmitMode('voice_push_to_talk');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {compareStructural, reaction} from 'mobx';
|
||||
|
||||
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void {
|
||||
return reaction(
|
||||
() => ({
|
||||
transmitMode: Keybind.transmitMode,
|
||||
pushToTalk: Keybind.isPushToTalkEffective(),
|
||||
pushToMute: Keybind.isPushToMuteEffective(),
|
||||
}),
|
||||
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}),
|
||||
{equals: compareStructural},
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
} from '@app/features/app/keybindings/utils/HookShortcutIds';
|
||||
|
||||
export type RuntimeKeybind = KeybindConfig & {
|
||||
id: string | null;
|
||||
combo: KeyCombo;
|
||||
};
|
||||
export type RuntimeKeybindBaseResolver = (action: KeybindCommand) => KeybindConfig | null;
|
||||
@@ -34,6 +35,16 @@ export const HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE: ReadonlyArray<HoldAction> = [
|
||||
'voice_priority_vad',
|
||||
];
|
||||
|
||||
export function sourceIdForKeybind(keybind: {id: string | null; action: KeybindCommand}): string {
|
||||
if (keybind.id === null) return `default:${keybind.action}`;
|
||||
return `custom:${keybind.id}`;
|
||||
}
|
||||
|
||||
export function gamepadSourceIdForKeybind(keybind: {id: string | null; action: KeybindCommand}): string {
|
||||
if (keybind.id === null) return `gamepad:default:${keybind.action}`;
|
||||
return `gamepad:${keybind.id}`;
|
||||
}
|
||||
|
||||
export function hasTriggerKey(combo: KeyCombo): boolean {
|
||||
return (combo.key ?? '') !== '' || (combo.code ?? '') !== '';
|
||||
}
|
||||
@@ -55,7 +66,7 @@ export function buildDefaultRuntimeKeybinds(
|
||||
if (overriddenActions.has(entry.action)) continue;
|
||||
const combo = entry.combo;
|
||||
if (!isEnabledDefaultCombo(combo)) continue;
|
||||
result.push({...entry, combo});
|
||||
result.push({...entry, id: null, combo});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -69,7 +80,7 @@ export function buildCustomRuntimeKeybinds(
|
||||
if (!custom.action || !isActiveCustomKeybind(custom)) continue;
|
||||
const base = getBaseByAction(custom.action);
|
||||
if (!base) continue;
|
||||
result.push({...base, combo: custom.combo});
|
||||
result.push({...base, id: custom.id, combo: custom.combo});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
|
||||
const WEB_CHECK_TIMEOUT_MS = 15 * 1000;
|
||||
const NATIVE_CHECK_TIMEOUT_MS = 30 * 1000;
|
||||
const VERSION_ENDPOINT = '/version.json';
|
||||
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
|
||||
const ALLOWED_WEB_UPDATE_HOSTS = new Set([
|
||||
@@ -157,6 +158,7 @@ class Updater {
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
private pendingManualDownloadRefreshes = 0;
|
||||
private checkInProgress = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -195,10 +197,6 @@ class Updater {
|
||||
return this.snapshot.context.nativeManualDownloadOptions;
|
||||
}
|
||||
|
||||
private get checkInProgress(): boolean {
|
||||
return this.snapshot.context.checkInProgress;
|
||||
}
|
||||
|
||||
private get nativeCheckFailed(): boolean {
|
||||
return this.snapshot.context.nativeCheckFailed;
|
||||
}
|
||||
@@ -495,6 +493,7 @@ class Updater {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkInProgress = true;
|
||||
this.transition({type: 'check.started'});
|
||||
|
||||
const checkContext: 'user' | 'background' = userInitiated ? 'user' : 'background';
|
||||
@@ -518,6 +517,7 @@ class Updater {
|
||||
pushUpdateCheckFailedModal();
|
||||
}
|
||||
} finally {
|
||||
this.checkInProgress = false;
|
||||
this.transition({type: failed ? 'check.failed' : 'check.finished', now: Date.now()});
|
||||
}
|
||||
}
|
||||
@@ -525,12 +525,17 @@ class Updater {
|
||||
private async checkNativeUpdate(context: 'user' | 'background'): Promise<boolean> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return false;
|
||||
let timeoutId: number | undefined;
|
||||
const timedOut = new Promise<false>((resolve) => {
|
||||
timeoutId = window.setTimeout(() => resolve(false), NATIVE_CHECK_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
await electronApi.updaterCheck(context);
|
||||
return true;
|
||||
return await Promise.race([electronApi.updaterCheck(context).then(() => true), timedOut]);
|
||||
} catch (error) {
|
||||
logger.debug('Native update check failed silently:', error);
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,6 +697,7 @@ class Updater {
|
||||
if (this.checkInProgress) {
|
||||
return option;
|
||||
}
|
||||
this.checkInProgress = true;
|
||||
this.transition({type: 'check.started'});
|
||||
let timeoutId: number | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
@@ -706,6 +712,7 @@ class Updater {
|
||||
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
this.checkInProgress = false;
|
||||
this.transition({type: 'check.finished', now: Date.now()});
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -39,6 +39,11 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
|
||||
const resp = await http.get<PublicAppData>(Endpoints.OAUTH_PUBLIC_APPLICATION(clientId));
|
||||
if (cancelled) return;
|
||||
const currentUser = resp.body.current_user;
|
||||
if (currentUserId && !currentUser) {
|
||||
logger.warn('OAuth public app fetch returned no current user for a signed-in account');
|
||||
setState({status: 'session_expired', data: null, error: null});
|
||||
return;
|
||||
}
|
||||
if (currentUser && currentUser.id === currentUserId) {
|
||||
const userData = authResponseUserToUserData(currentUser);
|
||||
if (userData) {
|
||||
|
||||
+7
@@ -29,6 +29,7 @@ import {
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import type {BotPermissionOption} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -388,6 +389,12 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
setSubmitting(null);
|
||||
setSubmitError(i18n._(AUTHORIZATION_FAILED_DESCRIPTOR));
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 401) {
|
||||
logger.warn('OAuth consent returned 401', err);
|
||||
setSubmitting(null);
|
||||
dispatch({type: 'INIT_SESSION_EXPIRED'});
|
||||
return;
|
||||
}
|
||||
logger.error('Authorization failed', err);
|
||||
setSubmitting(null);
|
||||
setSubmitError(failureMessage(err) ?? i18n._(AUTHORIZATION_FAILED_DESCRIPTOR));
|
||||
|
||||
@@ -14,6 +14,7 @@ import {useMessageViewContext} from '@app/features/channel/components/MessageVie
|
||||
import {ThemeEmbed} from '@app/features/channel/components/ThemeEmbed';
|
||||
import {TimestampWithTooltip} from '@app/features/channel/components/TimestampWithTooltip';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {useStickerAnimation} from '@app/features/emoji/hooks/useStickerAnimation';
|
||||
import Sticker from '@app/features/emoji/state/EmojiSticker';
|
||||
import {ExpressionInfoBottomSheet} from '@app/features/expressions/components/bottomsheets/ExpressionInfoBottomSheet';
|
||||
@@ -55,14 +56,7 @@ import type {
|
||||
MessageStickerItem,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
CaretRightIcon,
|
||||
HashIcon,
|
||||
MegaphoneSimpleIcon,
|
||||
NotePencilIcon,
|
||||
SpeakerHighIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {ArrowBendUpRightIcon, CaretRightIcon, NotePencilIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
@@ -179,34 +173,11 @@ const ForwardedFromSource = observer(({message}: {message: Message}) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sourceChannel.type === ChannelTypes.GUILD_VOICE) {
|
||||
return (
|
||||
<SpeakerHighIcon
|
||||
className={styles.forwardedSourceIcon}
|
||||
weight="fill"
|
||||
size={iconSize}
|
||||
data-flx="channel.message-attachments.render-channel-icon.forwarded-source-icon--2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sourceChannel.type === ChannelTypes.GUILD_ANNOUNCEMENT) {
|
||||
return (
|
||||
<MegaphoneSimpleIcon
|
||||
className={styles.forwardedSourceIcon}
|
||||
weight="bold"
|
||||
size={iconSize}
|
||||
data-flx="channel.message-attachments.render-channel-icon.forwarded-source-icon--4"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HashIcon
|
||||
className={styles.forwardedSourceIcon}
|
||||
weight="bold"
|
||||
size={iconSize}
|
||||
data-flx="channel.message-attachments.render-channel-icon.forwarded-source-icon--3"
|
||||
/>
|
||||
);
|
||||
return ChannelUtils.getIcon(sourceChannel, {
|
||||
className: styles.forwardedSourceIcon,
|
||||
weight: 'bold',
|
||||
size: iconSize,
|
||||
});
|
||||
}, [sourceChannel, sourceUser]);
|
||||
if (!hasAccessToSource || !sourceChannel || !displayName || !message.messageReference) {
|
||||
return null;
|
||||
|
||||
+5
-6
@@ -2,10 +2,10 @@
|
||||
|
||||
import i18n from '@app/app/I18n';
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {ACCOUNT_LIMITED_NOTICE_DESCRIPTOR} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -36,6 +36,7 @@ const INVITE_LINK_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
});
|
||||
|
||||
export function showGroupRecipientAddFailedModal(error: unknown): void {
|
||||
if (handleAccountLimitedError(error)) return;
|
||||
const code = failureCode(error);
|
||||
ModalCommands.push(
|
||||
modal(() => {
|
||||
@@ -47,9 +48,6 @@ export function showGroupRecipientAddFailedModal(error: unknown): void {
|
||||
case APIErrorCodes.RATE_LIMITED:
|
||||
message = i18n._(RATE_LIMITED_DESCRIPTOR);
|
||||
break;
|
||||
case APIErrorCodes.ACCOUNT_LIMITED:
|
||||
message = failureMessage(error) || i18n._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR);
|
||||
break;
|
||||
default:
|
||||
message = i18n._(ADD_TO_GROUP_FAILED_MESSAGE_DESCRIPTOR);
|
||||
break;
|
||||
@@ -65,7 +63,8 @@ export function showGroupRecipientAddFailedModal(error: unknown): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function showGroupInviteCreateFailedModal(): void {
|
||||
export function showGroupInviteCreateFailedModal(error: unknown): void {
|
||||
if (handleAccountLimitedError(error)) return;
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
|
||||
@@ -14,6 +14,7 @@ import {TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonM
|
||||
import * as InviteCommands from '@app/features/invite/commands/InviteCommands';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
@@ -74,6 +75,7 @@ export function useAddFriendsToGroupModalLogic(channelId: string): State & Handl
|
||||
[remainingSlotsCount],
|
||||
);
|
||||
const handleAddFriends = useCallback(async () => {
|
||||
if (blockIfAccountLimited()) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const promises = selectedUserIds.map((userId) =>
|
||||
@@ -109,7 +111,7 @@ export function useAddFriendsToGroupModalLogic(channelId: string): State & Handl
|
||||
return fullUrl;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate invite:', error);
|
||||
showGroupInviteCreateFailedModal();
|
||||
showGroupInviteCreateFailedModal(error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsGeneratingInvite(false);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
@@ -220,6 +221,7 @@ export function useCreateDMModalLogic(
|
||||
const createChannel = useCallback(
|
||||
async (userIds: Array<string>) => {
|
||||
if (restriction) return;
|
||||
if (userIds.length !== 1 && blockIfAccountLimited()) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const channel =
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as FailureInspect from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import type {
|
||||
ConnectionListResponse,
|
||||
@@ -86,6 +87,7 @@ function successToast(i18n: I18n, message: MessageDescriptor): void {
|
||||
}
|
||||
|
||||
function showErrorModal(i18n: I18n, error: unknown, fallbackMessage: MessageDescriptor): void {
|
||||
if (handleAccountLimitedError(error)) return;
|
||||
const errorMessage = FailureInspect.failureMessage(error);
|
||||
showGenericErrorModal({
|
||||
title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ACCOUNT_LIMITED_DESCRIPTOR} from '@app/features/channel/components/channel_header_components/developer_tools/OptionPresets';
|
||||
import type {Nagbar, NagbarToggleKey} from '@app/features/ui/state/Nagbar';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -164,6 +165,22 @@ export const getNagbarControls = (): Array<NagbarControlDefinition> => [
|
||||
forceShowDisabled: (state) => state.forceEmailVerification,
|
||||
forceHideDisabled: (state) => state.forceHideEmailVerification,
|
||||
},
|
||||
{
|
||||
key: 'forceAccountLimited',
|
||||
label: ACCOUNT_LIMITED_DESCRIPTOR,
|
||||
forceKey: 'forceAccountLimited',
|
||||
forceHideKey: 'forceHideAccountLimited',
|
||||
resetKeys: ['forceAccountLimited'],
|
||||
status: (state) =>
|
||||
state.forceAccountLimited
|
||||
? FORCE_ENABLED
|
||||
: state.forceHideAccountLimited
|
||||
? FORCE_DISABLED
|
||||
: USING_ACTUAL_ACCOUNT_STATE,
|
||||
useActualDisabled: (state) => !state.forceAccountLimited && !state.forceHideAccountLimited,
|
||||
forceShowDisabled: (state) => state.forceAccountLimited,
|
||||
forceHideDisabled: (state) => state.forceHideAccountLimited,
|
||||
},
|
||||
{
|
||||
key: 'forceDesktopNotification',
|
||||
label: DESKTOP_NOTIFICATION_NAGBAR_DESCRIPTOR,
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as NavigationCommands from '@app/features/navigation/commands/Navigatio
|
||||
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {showAccountLimitedModal} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {blockIfAccountLimited, showAccountLimitedModal} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -116,6 +116,7 @@ export async function joinDiscoveryGuild(
|
||||
guildId: string,
|
||||
target?: {channelId: string; messageId?: string},
|
||||
): Promise<boolean> {
|
||||
if (blockIfAccountLimited()) return false;
|
||||
try {
|
||||
await DiscoveryCommands.joinGuild(guildId);
|
||||
if (target) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as MessageCommands from '@app/features/messaging/commands/MessageComman
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {useCopyLinkHandler} from '@app/lib/copy-link';
|
||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -51,6 +52,7 @@ export const GiftSendToFriendModal = observer(function GiftSendToFriendModal({co
|
||||
const handleCopy = useCopyLinkHandler(giftUrl, true);
|
||||
const handleSendGift = useCallback(
|
||||
async (item: RecipientItem) => {
|
||||
if (blockIfAccountLimited()) return;
|
||||
const userId = item.type === 'group_dm' ? item.id : item.user.id;
|
||||
setSendingTo((previous) => new Set(previous).add(userId));
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {TemplateSerializedGuild} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
@@ -80,6 +81,9 @@ export function parseTemplateCode(input: string): string | null {
|
||||
}
|
||||
|
||||
export function handleGuildCreationError(error: unknown): never {
|
||||
if (handleAccountLimitedError(error)) {
|
||||
throw new DOMException('Guild create skipped', 'AbortError');
|
||||
}
|
||||
if (failureCode(error) === APIErrorCodes.UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS) {
|
||||
openClaimAccountModal({force: true});
|
||||
}
|
||||
|
||||
+1
-2
@@ -40,7 +40,6 @@ import {useRoleHierarchy} from '@app/features/permissions/hooks/useRoleHierarchy
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as RelationshipCommands from '@app/features/relationship/commands/RelationshipCommands';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
import * as RelationshipActionUtils from '@app/features/relationship/utils/RelationshipActionUtils';
|
||||
import {
|
||||
@@ -204,7 +203,7 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
|
||||
);
|
||||
};
|
||||
const handleSendFriendRequest = () => {
|
||||
RelationshipCommands.sendFriendRequest(user.id);
|
||||
void RelationshipActionUtils.sendFriendRequest(i18n, user.id);
|
||||
onClose();
|
||||
};
|
||||
const handleAcceptFriendRequest = () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1632,6 +1632,9 @@
|
||||
{
|
||||
"msgid": "Capture entire system audio"
|
||||
},
|
||||
{
|
||||
"msgid": "Change in system settings"
|
||||
},
|
||||
{
|
||||
"msgid": "Change stream"
|
||||
},
|
||||
@@ -1755,6 +1758,9 @@
|
||||
{
|
||||
"msgid": "Characters in a single message"
|
||||
},
|
||||
{
|
||||
"msgid": "Check again"
|
||||
},
|
||||
{
|
||||
"msgid": "Checkout is blocked for this account."
|
||||
},
|
||||
@@ -1863,6 +1869,15 @@
|
||||
{
|
||||
"msgid": "Copy emoji"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy lines"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy lines for hyprland.conf"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy lines for hyprland.lua"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy sticker"
|
||||
},
|
||||
@@ -2133,6 +2148,18 @@
|
||||
{
|
||||
"msgid": "Footnote about custom tags"
|
||||
},
|
||||
{
|
||||
"msgid": "For push-to-mute, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the microphone can stay muted."
|
||||
},
|
||||
{
|
||||
"msgid": "For push-to-talk, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the microphone can stay on."
|
||||
},
|
||||
{
|
||||
"msgid": "For this shortcut, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the shortcut can stay active."
|
||||
},
|
||||
{
|
||||
"msgid": "For voice activity priority, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or priority can stay on."
|
||||
},
|
||||
{
|
||||
"msgid": "From {sourceGuildName}"
|
||||
},
|
||||
@@ -2217,9 +2244,18 @@
|
||||
{
|
||||
"msgid": "Hid the community owner crown."
|
||||
},
|
||||
{
|
||||
"msgid": "Holding Shift, Ctrl or Alt blocks this shortcut. Add alternates in your system settings."
|
||||
},
|
||||
{
|
||||
"msgid": "How much of the member's recent message history to delete."
|
||||
},
|
||||
{
|
||||
"msgid": "Hyprland has no shortcut dialog. Add the lines for your Hyprland version to your Hyprland config and replace KEY with the key you want."
|
||||
},
|
||||
{
|
||||
"msgid": "Hyprland has no shortcut dialog. Bind the keys in your Hyprland config."
|
||||
},
|
||||
{
|
||||
"msgid": "Ignore this channel's slowmode cooldown."
|
||||
},
|
||||
@@ -2271,6 +2307,9 @@
|
||||
{
|
||||
"msgid": "Keep attachments when clearing an edit"
|
||||
},
|
||||
{
|
||||
"msgid": "Keyboard access couldn't start. Shortcuts only work while {productName} is focused."
|
||||
},
|
||||
{
|
||||
"msgid": "Kicked {targetUserTag} from the community"
|
||||
},
|
||||
@@ -2298,6 +2337,9 @@
|
||||
{
|
||||
"msgid": "Lets other communities follow this channel and get copies of what you publish."
|
||||
},
|
||||
{
|
||||
"msgid": "Lets {productName} read your keyboard directly. Your system already allows this."
|
||||
},
|
||||
{
|
||||
"msgid": "Lightweight classic filter, works on any device."
|
||||
},
|
||||
@@ -2472,6 +2514,9 @@
|
||||
{
|
||||
"msgid": "Not accepted"
|
||||
},
|
||||
{
|
||||
"msgid": "Not assigned"
|
||||
},
|
||||
{
|
||||
"msgid": "Not sent to followers yet."
|
||||
},
|
||||
@@ -2661,6 +2706,33 @@
|
||||
{
|
||||
"msgid": "Push relay notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk has no system-wide key yet. To set one, go to Settings > Apps > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk has no system-wide key yet. To set one, go to System Settings > Keyboard > Shortcuts > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk has no system-wide key yet. To set one, open your desktop's keyboard shortcut settings."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk is on. Hold your push-to-talk key to speak."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk only works while {productName} is focused because your desktop didn't set up system-wide shortcuts."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk only works while {productName} is focused until you set up system-wide shortcuts."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk will use the key you set in {productName}. Set one first, or your microphone will use voice activity."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk works outside {productName} only after you bind it in your Hyprland config."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk works outside {productName} only after you bind it in your Hyprland config. Add the line for your Hyprland version and replace KEY with the key you want."
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
@@ -2673,6 +2745,9 @@
|
||||
{
|
||||
"msgid": "Reason (optional)."
|
||||
},
|
||||
{
|
||||
"msgid": "Reconnecting to your desktop…"
|
||||
},
|
||||
{
|
||||
"msgid": "Redeem a gift code"
|
||||
},
|
||||
@@ -3087,6 +3162,9 @@
|
||||
{
|
||||
"msgid": "Set to send {resolution} at {frameRate} FPS with {codec}"
|
||||
},
|
||||
{
|
||||
"msgid": "Set up"
|
||||
},
|
||||
{
|
||||
"msgid": "Set up {productName}"
|
||||
},
|
||||
@@ -3312,6 +3390,15 @@
|
||||
{
|
||||
"msgid": "Syntax highlighting"
|
||||
},
|
||||
{
|
||||
"msgid": "System-wide shortcuts could not be set up."
|
||||
},
|
||||
{
|
||||
"msgid": "System-wide: Not assigned"
|
||||
},
|
||||
{
|
||||
"msgid": "System-wide: {trigger}"
|
||||
},
|
||||
{
|
||||
"msgid": "Talk in calls and test your input."
|
||||
},
|
||||
@@ -3438,6 +3525,9 @@
|
||||
{
|
||||
"msgid": "This community has published a lot of messages recently. Try again in {duration}."
|
||||
},
|
||||
{
|
||||
"msgid": "This desktop doesn't offer system-wide shortcuts. Shortcuts still work while {productName} is focused."
|
||||
},
|
||||
{
|
||||
"msgid": "This device could not keep up with {targetResolution} at {targetFrameRate} FPS last time, so your stream started at {resolution} at {frameRate} FPS."
|
||||
},
|
||||
@@ -3480,6 +3570,9 @@
|
||||
{
|
||||
"msgid": "This is your last passkey. Once it's gone you won't be able to use a passkey as your second factor."
|
||||
},
|
||||
{
|
||||
"msgid": "This key works while {productName} is focused. Your desktop sets the system-wide key, under {settingsTabName} > {sectionName}."
|
||||
},
|
||||
{
|
||||
"msgid": "This message can only be forwarded to age-restricted channels"
|
||||
},
|
||||
@@ -3507,6 +3600,15 @@
|
||||
{
|
||||
"msgid": "Timed out {targetUserTag}"
|
||||
},
|
||||
{
|
||||
"msgid": "To change the keys, go to Settings > Apps > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "To change the keys, go to System Settings > Keyboard > Shortcuts > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "To change the keys, open your desktop's keyboard shortcut settings."
|
||||
},
|
||||
{
|
||||
"msgid": "Transferred community ownership to {user}."
|
||||
},
|
||||
@@ -3576,6 +3678,9 @@
|
||||
{
|
||||
"msgid": "Update your passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Update {productName} to use system-wide shortcuts on Wayland."
|
||||
},
|
||||
{
|
||||
"msgid": "Updates are on their way!"
|
||||
},
|
||||
@@ -3594,6 +3699,9 @@
|
||||
{
|
||||
"msgid": "Use custom emojis and stickers in any community"
|
||||
},
|
||||
{
|
||||
"msgid": "Use direct input device access"
|
||||
},
|
||||
{
|
||||
"msgid": "Use emoji from other communities in this channel."
|
||||
},
|
||||
@@ -3651,6 +3759,9 @@
|
||||
{
|
||||
"msgid": "Volume sliders"
|
||||
},
|
||||
{
|
||||
"msgid": "Waiting for your desktop…"
|
||||
},
|
||||
{
|
||||
"msgid": "We could not renew your subscription. Update your payment method to keep your perks."
|
||||
},
|
||||
@@ -3693,6 +3804,9 @@
|
||||
{
|
||||
"msgid": "Wordmark"
|
||||
},
|
||||
{
|
||||
"msgid": "Works while {productName} is focused"
|
||||
},
|
||||
{
|
||||
"msgid": "You asked for {resolution} at {frameRate} FPS"
|
||||
},
|
||||
@@ -3729,6 +3843,12 @@
|
||||
{
|
||||
"msgid": "Your connection cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your desktop didn't set up system-wide shortcuts."
|
||||
},
|
||||
{
|
||||
"msgid": "Your desktop runs these shortcuts even while {productName} is not focused."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
|
||||
},
|
||||
@@ -3795,6 +3915,12 @@
|
||||
{
|
||||
"msgid": "greeting"
|
||||
},
|
||||
{
|
||||
"msgid": "hyprland.conf (before Hyprland 0.55)"
|
||||
},
|
||||
{
|
||||
"msgid": "hyprland.lua (Hyprland 0.55 and later)"
|
||||
},
|
||||
{
|
||||
"msgid": "macOS"
|
||||
},
|
||||
@@ -4371,6 +4497,21 @@
|
||||
{
|
||||
"msgid": "{productName} application logo"
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can ask your desktop to run its shortcuts while {productName} is not focused. Your desktop asks you to confirm and lets you pick the keys."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can no longer read your keyboard, so it listens for shortcuts through X11 instead."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can no longer read your keyboard, so your desktop's shortcuts are used instead."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can no longer read your keyboard. Shortcuts only work while {productName} is focused."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can register its shortcuts with Hyprland so they run while {productName} is not focused. You then bind the keys in your Hyprland config."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} cannot start a safe desktop audio route while excluding {productName}'s call audio."
|
||||
},
|
||||
@@ -4398,6 +4539,9 @@
|
||||
{
|
||||
"msgid": "{productName} now lives at {host}. Install it from there."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} registered its shortcuts with Hyprland. Bind them in your Hyprland config to use them while {productName} is not focused."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} wordmark"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user