Merge branch 'main' into rei/violations_are_tagged

This commit is contained in:
Eric Eastwood
2026-03-24 16:42:47 -05:00
79 changed files with 6056 additions and 3232 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ runs:
uses: open-policy-agent/setup-opa@v2.2.0
with:
# Keep in sync with the Dockerfile and policies/Makefile
version: 1.8.0
version: 1.13.1
- name: Build the policies
run: make
+17 -17
View File
@@ -84,7 +84,7 @@ jobs:
chmod -R u=rwX,go=rX assets-dist/
- name: Upload assets
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@v7.0.0
with:
name: assets
path: assets-dist
@@ -143,7 +143,7 @@ jobs:
-p mas-cli
- name: Upload binary artifact
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@v7.0.0
with:
name: binary-${{ matrix.target }}
path: target/${{ matrix.target }}/release/mas-cli
@@ -162,19 +162,19 @@ jobs:
steps:
- name: Download assets
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: assets
path: assets-dist
- name: Download binary x86_64
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: binary-x86_64-unknown-linux-gnu
path: binary-x86_64
- name: Download binary aarch64
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: binary-aarch64-unknown-linux-gnu
path: binary-aarch64
@@ -192,13 +192,13 @@ jobs:
done
- name: Upload aarch64 archive
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@v7.0.0
with:
name: mas-cli-aarch64-linux
path: mas-cli-aarch64-linux.tar.gz
- name: Upload x86_64 archive
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@v7.0.0
with:
name: mas-cli-x86_64-linux
path: mas-cli-x86_64-linux.tar.gz
@@ -226,7 +226,7 @@ jobs:
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5.10.0
uses: docker/metadata-action@v6.0.0
with:
images: "${{ env.IMAGE }}"
bake-target: docker-metadata-action
@@ -242,7 +242,7 @@ jobs:
- name: Docker meta (debug variant)
id: meta-debug
uses: docker/metadata-action@v5.10.0
uses: docker/metadata-action@v6.0.0
with:
images: "${{ env.IMAGE }}"
bake-target: docker-metadata-action-debug
@@ -258,17 +258,17 @@ jobs:
type=sha
- name: Setup Cosign
uses: sigstore/cosign-installer@v4.0.0
uses: sigstore/cosign-installer@v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.12.0
uses: docker/setup-buildx-action@v4.0.0
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.6.0
uses: docker/login-action@v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -276,7 +276,7 @@ jobs:
- name: Build and push
id: bake
uses: docker/bake-action@v6.10.0
uses: docker/bake-action@v7.0.0
with:
files: |
./docker-bake.hcl
@@ -320,14 +320,14 @@ jobs:
- build-image
steps:
- name: Download the artifacts from the previous job
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: mas-cli-*
path: artifacts
merge-multiple: true
- name: Prepare a release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.6.1
with:
generate_release_notes: true
body: |
@@ -382,7 +382,7 @@ jobs:
.github/scripts
- name: Download the artifacts from the previous job
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
pattern: mas-cli-*
path: artifacts
@@ -396,7 +396,7 @@ jobs:
await script({ core, github, context });
- name: Update unstable release
uses: softprops/action-gh-release@v2.5.0
uses: softprops/action-gh-release@v2.6.1
with:
name: "Unstable build"
tag_name: unstable
+7 -7
View File
@@ -42,7 +42,7 @@ jobs:
uses: StyraInc/setup-regal@v1
with:
# Keep in sync with policies/Makefile
version: 0.36.1
version: 0.38.1
- name: Lint policies
working-directory: ./policies
@@ -64,7 +64,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
@@ -88,7 +88,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
@@ -112,7 +112,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
@@ -216,7 +216,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@1.89.0
uses: dtolnay/rust-toolchain@1.93.0
with:
components: clippy
@@ -257,7 +257,7 @@ jobs:
SQLX_OFFLINE: "1"
- name: Upload archive to workflow
uses: actions/upload-artifact@v6.0.0
uses: actions/upload-artifact@v7.0.0
with:
name: nextest-archive
path: nextest-archive.tar.zst
@@ -305,7 +305,7 @@ jobs:
- uses: ./.github/actions/build-policies
- name: Download archive
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
name: nextest-archive
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
tool: mdbook
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
@@ -42,7 +42,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v8.0.0
uses: peter-evans/create-pull-request@v8.1.0
with:
sign-commits: true
token: ${{ secrets.BOT_GITHUB_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
- name: Install Node
uses: actions/setup-node@v6.1.0
uses: actions/setup-node@v6.3.0
with:
node-version: 24
Generated
+203 -231
View File
File diff suppressed because it is too large Load Diff
+39 -38
View File
@@ -9,7 +9,7 @@ members = ["crates/*"]
resolver = "2"
# Updated in the CI with a `sed` command
package.version = "1.10.0"
package.version = "1.14.0"
package.license = "AGPL-3.0-only OR LicenseRef-Element-Commercial"
package.authors = ["Element Backend Team"]
package.edition = "2024"
@@ -34,35 +34,35 @@ broken_intra_doc_links = "deny"
[workspace.dependencies]
# Workspace crates
mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.10.0" }
mas-cli = { path = "./crates/cli/", version = "=1.10.0" }
mas-config = { path = "./crates/config/", version = "=1.10.0" }
mas-context = { path = "./crates/context/", version = "=1.10.0" }
mas-data-model = { path = "./crates/data-model/", version = "=1.10.0" }
mas-email = { path = "./crates/email/", version = "=1.10.0" }
mas-graphql = { path = "./crates/graphql/", version = "=1.10.0" }
mas-handlers = { path = "./crates/handlers/", version = "=1.10.0" }
mas-http = { path = "./crates/http/", version = "=1.10.0" }
mas-i18n = { path = "./crates/i18n/", version = "=1.10.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.10.0" }
mas-iana = { path = "./crates/iana/", version = "=1.10.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.10.0" }
mas-jose = { path = "./crates/jose/", version = "=1.10.0" }
mas-keystore = { path = "./crates/keystore/", version = "=1.10.0" }
mas-listener = { path = "./crates/listener/", version = "=1.10.0" }
mas-matrix = { path = "./crates/matrix/", version = "=1.10.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.10.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.10.0" }
mas-policy = { path = "./crates/policy/", version = "=1.10.0" }
mas-router = { path = "./crates/router/", version = "=1.10.0" }
mas-spa = { path = "./crates/spa/", version = "=1.10.0" }
mas-storage = { path = "./crates/storage/", version = "=1.10.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.10.0" }
mas-tasks = { path = "./crates/tasks/", version = "=1.10.0" }
mas-templates = { path = "./crates/templates/", version = "=1.10.0" }
mas-tower = { path = "./crates/tower/", version = "=1.10.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=1.10.0" }
syn2mas = { path = "./crates/syn2mas", version = "=1.10.0" }
mas-axum-utils = { path = "./crates/axum-utils/", version = "=1.14.0" }
mas-cli = { path = "./crates/cli/", version = "=1.14.0" }
mas-config = { path = "./crates/config/", version = "=1.14.0" }
mas-context = { path = "./crates/context/", version = "=1.14.0" }
mas-data-model = { path = "./crates/data-model/", version = "=1.14.0" }
mas-email = { path = "./crates/email/", version = "=1.14.0" }
mas-graphql = { path = "./crates/graphql/", version = "=1.14.0" }
mas-handlers = { path = "./crates/handlers/", version = "=1.14.0" }
mas-http = { path = "./crates/http/", version = "=1.14.0" }
mas-i18n = { path = "./crates/i18n/", version = "=1.14.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=1.14.0" }
mas-iana = { path = "./crates/iana/", version = "=1.14.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=1.14.0" }
mas-jose = { path = "./crates/jose/", version = "=1.14.0" }
mas-keystore = { path = "./crates/keystore/", version = "=1.14.0" }
mas-listener = { path = "./crates/listener/", version = "=1.14.0" }
mas-matrix = { path = "./crates/matrix/", version = "=1.14.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=1.14.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=1.14.0" }
mas-policy = { path = "./crates/policy/", version = "=1.14.0" }
mas-router = { path = "./crates/router/", version = "=1.14.0" }
mas-spa = { path = "./crates/spa/", version = "=1.14.0" }
mas-storage = { path = "./crates/storage/", version = "=1.14.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=1.14.0" }
mas-tasks = { path = "./crates/tasks/", version = "=1.14.0" }
mas-templates = { path = "./crates/templates/", version = "=1.14.0" }
mas-tower = { path = "./crates/tower/", version = "=1.14.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=1.14.0" }
syn2mas = { path = "./crates/syn2mas", version = "=1.14.0" }
# OpenAPI schema generation and validation
[workspace.dependencies.aide]
@@ -71,7 +71,7 @@ features = ["axum", "axum-extra", "axum-extra-query", "axum-json", "macros"]
# An `Arc` that can be atomically updated
[workspace.dependencies.arc-swap]
version = "1.7.1"
version = "1.8.1"
# GraphQL server
[workspace.dependencies.async-graphql]
@@ -88,7 +88,7 @@ version = "0.1.89"
# High-level error handling
[workspace.dependencies.anyhow]
version = "1.0.100"
version = "1.0.102"
# Assert that a value matches a pattern
[workspace.dependencies.assert_matches]
@@ -124,7 +124,7 @@ features = ["std"]
# Bcrypt password hashing
[workspace.dependencies.bcrypt]
version = "0.17.1"
version = "0.18.0"
default-features = true
# Packed bitfields
@@ -330,7 +330,7 @@ version = "2.0.6"
# Snapshot testing
[workspace.dependencies.insta]
version = "1.43.2"
version = "1.46.3"
features = ["yaml", "json"]
# IP network address types
@@ -378,7 +378,7 @@ version = "0.3.17"
# Templates
[workspace.dependencies.minijinja]
version = "2.12.0"
version = "2.15.1"
features = ["urlencode", "loader", "json", "speedups", "unstable_machinery"]
# Additional filters for minijinja
@@ -508,6 +508,7 @@ features = [
"charset",
"json",
"socks",
"system-proxy",
]
# RSA cryptography
@@ -567,7 +568,7 @@ features = [
# Sentry error tracking
[workspace.dependencies.sentry]
version = "0.46.0"
version = "0.46.2"
default-features = false
features = ["backtrace", "contexts", "panic", "tower", "reqwest"]
@@ -614,7 +615,7 @@ version = "2.2.0"
# Low-level socket manipulation
[workspace.dependencies.socket2]
version = "0.6.1"
version = "0.6.2"
# Subject Public Key Info
[workspace.dependencies.spki]
@@ -685,7 +686,7 @@ features = ["cors", "fs", "add-extension", "set-header"]
[workspace.dependencies.tracing]
version = "0.1.41"
[workspace.dependencies.tracing-subscriber]
version = "0.3.20"
version = "0.3.22"
features = ["env-filter"]
[workspace.dependencies.tracing-appender]
version = "0.2.4"
+9 -7
View File
@@ -1,4 +1,5 @@
# syntax = docker/dockerfile:1.7.1
# syntax = docker/dockerfile:1.21.0
# Copyright 2025, 2026 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -10,13 +11,14 @@
# being the platform being built.
# The Debian version and version name must be in sync
ARG DEBIAN_VERSION=12
ARG DEBIAN_VERSION_NAME=bookworm
ARG RUSTC_VERSION=1.89.0
ARG NODEJS_VERSION=24.11.0
ARG DEBIAN_VERSION=13
ARG DEBIAN_VERSION_NAME=trixie
# Keep in sync with .github/workflows/ci.yaml
ARG RUSTC_VERSION=1.93.0
ARG NODEJS_VERSION=24.13.0
# Keep in sync with .github/actions/build-policies/action.yml and policies/Makefile
ARG OPA_VERSION=1.8.0
ARG CARGO_AUDITABLE_VERSION=0.7.0
ARG OPA_VERSION=1.13.1
ARG CARGO_AUDITABLE_VERSION=0.7.2
##########################################
## Build stage that builds the frontend ##
+12
View File
@@ -638,6 +638,12 @@ impl Options {
// synchronously yet.
let user = repo.user().lock(&clock, user).await?;
// Schedule a job to provision the user so that the lock flag is propagated
// to Synapse
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
if deactivate {
warn!(%user.id, "Scheduling user deactivation");
repo.queue_job()
@@ -668,6 +674,12 @@ impl Options {
.await?
.context("User not found")?;
// Schedule a job to provision the user so that the lock flag is propagated
// to Synapse
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
if reactivate {
warn!(%user.id, "Scheduling user reactivation");
repo.queue_job()
-13
View File
@@ -16,19 +16,6 @@ use url::Url;
use super::{ClientSecret, ClientSecretRaw, ConfigurationSection};
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum JwksOrJwksUri {
Jwks(PublicJsonWebKeySet),
JwksUri(Url),
}
impl From<PublicJsonWebKeySet> for JwksOrJwksUri {
fn from(jwks: PublicJsonWebKeySet) -> Self {
Self::Jwks(jwks)
}
}
/// Authentication method used by clients
#[derive(JsonSchema, Serialize, Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "snake_case")]
-9
View File
@@ -14,15 +14,6 @@ use serde::{Deserialize, Serialize, de::Error};
use super::ConfigurationSection;
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct Credentials {
/// Username for use to authenticate when connecting to the SMTP server
pub username: String,
/// Password for use to authenticate when connecting to the SMTP server
pub password: String,
}
/// Encryption mode to use
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
-2
View File
@@ -29,8 +29,6 @@ rand.workspace = true
rand_chacha.workspace = true
regex.workspace = true
woothee.workspace = true
ruma-common.workspace = true
lettre.workspace = true
mas-iana.workspace = true
mas-jose.workspace = true
@@ -4,8 +4,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::str::FromStr as _;
use chrono::{DateTime, Utc};
use mas_iana::oauth::PkceCodeChallengeMethod;
use oauth2_types::{
@@ -17,7 +15,6 @@ use rand::{
RngCore,
distributions::{Alphanumeric, DistString},
};
use ruma_common::UserId;
use serde::Serialize;
use ulid::Ulid;
use url::Url;
@@ -142,12 +139,6 @@ impl AuthorizationGrantStage {
}
}
pub enum LoginHint<'a> {
MXID(&'a UserId),
Email(lettre::Address),
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AuthorizationGrant {
pub id: Ulid,
@@ -175,31 +166,6 @@ impl std::ops::Deref for AuthorizationGrant {
}
impl AuthorizationGrant {
/// Parse a `login_hint`
///
/// Returns `LoginHint::MXID` for valid mxid 'mxid:@john.doe:example.com'
///
/// Returns `LoginHint::Email` for valid email 'john.doe@example.com'
///
/// Otherwise returns `LoginHint::None`
#[must_use]
pub fn parse_login_hint(&self, homeserver: &str) -> LoginHint<'_> {
let Some(login_hint) = &self.login_hint else {
return LoginHint::None;
};
if let Some(value) = login_hint.strip_prefix("mxid:")
&& let Ok(mxid) = <&UserId>::try_from(value)
&& mxid.server_name() == homeserver
{
LoginHint::MXID(mxid)
} else if let Ok(email) = lettre::Address::from_str(login_hint) {
LoginHint::Email(email)
} else {
LoginHint::None
}
}
/// Mark the authorization grant as exchanged.
///
/// # Errors
@@ -266,101 +232,3 @@ impl AuthorizationGrant {
}
}
}
#[cfg(test)]
mod tests {
use rand::SeedableRng;
use super::*;
use crate::clock::{Clock, MockClock};
#[test]
fn no_login_hint() {
let now = MockClock::default().now();
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let grant = AuthorizationGrant {
login_hint: None,
..AuthorizationGrant::sample(now, &mut rng)
};
let hint = grant.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
#[test]
fn valid_login_hint() {
let now = MockClock::default().now();
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let grant = AuthorizationGrant {
login_hint: Some(String::from("mxid:@example-user:example.com")),
..AuthorizationGrant::sample(now, &mut rng)
};
let hint = grant.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::MXID(mxid) if mxid.localpart() == "example-user"));
}
#[test]
fn valid_login_hint_with_email() {
let now = MockClock::default().now();
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let grant = AuthorizationGrant {
login_hint: Some(String::from("example@user")),
..AuthorizationGrant::sample(now, &mut rng)
};
let hint = grant.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::Email(email) if email.to_string() == "example@user"));
}
#[test]
fn invalid_login_hint() {
let now = MockClock::default().now();
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let grant = AuthorizationGrant {
login_hint: Some(String::from("example-user")),
..AuthorizationGrant::sample(now, &mut rng)
};
let hint = grant.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
#[test]
fn valid_login_hint_for_wrong_homeserver() {
let now = MockClock::default().now();
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let grant = AuthorizationGrant {
login_hint: Some(String::from("mxid:@example-user:matrix.org")),
..AuthorizationGrant::sample(now, &mut rng)
};
let hint = grant.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
#[test]
fn unknown_login_hint_type() {
let now = MockClock::default().now();
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let grant = AuthorizationGrant {
login_hint: Some(String::from("something:anything")),
..AuthorizationGrant::sample(now, &mut rng)
};
let hint = grant.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
}
+1 -3
View File
@@ -10,9 +10,7 @@ mod device_code_grant;
mod session;
pub use self::{
authorization_grant::{
AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, LoginHint, Pkce,
},
authorization_grant::{AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, Pkce},
client::{Client, InvalidRedirectUriError, JwksOrJwksUri},
device_code_grant::{DeviceCodeGrant, DeviceCodeGrantState},
session::{Session, SessionState},
+3
View File
@@ -53,6 +53,9 @@ impl Mailer {
Message::builder()
.from(self.from.clone())
.reply_to(self.reply_to.clone())
// By passing `None`, lettre generates a random message ID
// with a random UUID and the hostname for us
.message_id(None)
}
fn prepare_verification_email(
+1
View File
@@ -49,6 +49,7 @@ psl.workspace = true
rand_chacha.workspace = true
rand.workspace = true
reqwest.workspace = true
ruma-common.workspace = true
rustls.workspace = true
schemars.workspace = true
sentry.workspace = true
+1 -1
View File
@@ -166,7 +166,7 @@ pub async fn handler(
let user = repo.user().add(&mut rng, &clock, params.username).await?;
homeserver
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.map_err(RouteError::Homeserver)?;
+43 -2
View File
@@ -4,10 +4,12 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation};
use aide::{NoApi, OperationIo, transform::TransformOperation};
use axum::{Json, response::IntoResponse};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_data_model::BoxRng;
use mas_storage::queue::{ProvisionUserJob, QueueJobRepositoryExt};
use ulid::Ulid;
use crate::{
@@ -69,6 +71,7 @@ pub async fn handler(
CallContext {
mut repo, clock, ..
}: CallContext,
NoApi(mut rng): NoApi<BoxRng>,
id: UlidPathParam,
) -> Result<Json<SingleResponse<User>>, RouteError> {
let id = *id;
@@ -80,6 +83,12 @@ pub async fn handler(
let user = repo.user().lock(&clock, user).await?;
// Schedule a job to provision the user so that the lock flag is propagated
// to Synapse
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
repo.save().await?;
Ok(Json(SingleResponse::new(
@@ -93,7 +102,11 @@ mod tests {
use chrono::Duration;
use hyper::{Request, StatusCode};
use mas_data_model::Clock;
use mas_storage::{RepositoryAccess, user::UserRepository};
use mas_storage::{
RepositoryAccess,
queue::{ProvisionUserJob, QueueJobRepositoryExt},
user::UserRepository,
};
use sqlx::PgPool;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
@@ -110,8 +123,25 @@ mod tests {
.add(&mut state.rng(), &state.clock, "alice".to_owned())
.await
.unwrap();
repo.queue_job()
.schedule_job(&mut state.rng(), &state.clock, ProvisionUserJob::new(&user))
.await
.unwrap();
repo.save().await.unwrap();
state.run_jobs_in_queue().await;
assert!(
!state
.homeserver_connection
.query_user_raw("alice")
.await
.unwrap()
.locked,
"User should not be locked at start of test"
);
let request = Request::post(format!("/api/admin/v1/users/{}/lock", user.id))
.bearer(&token)
.empty();
@@ -124,6 +154,17 @@ mod tests {
body["data"]["attributes"]["locked_at"],
serde_json::json!(state.clock.now())
);
state.run_jobs_in_queue().await;
assert!(
state
.homeserver_connection
.query_user_raw("alice")
.await
.unwrap()
.locked,
"User should be locked"
);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
@@ -129,7 +129,7 @@ mod tests {
// because this endpoint will try to reactivate it
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
state
@@ -181,7 +181,7 @@ mod tests {
// Provision the user on the homeserver
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
+45 -8
View File
@@ -4,10 +4,12 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use aide::{OperationIo, transform::TransformOperation};
use aide::{NoApi, OperationIo, transform::TransformOperation};
use axum::{Json, response::IntoResponse};
use hyper::StatusCode;
use mas_axum_utils::record_error;
use mas_data_model::BoxRng;
use mas_storage::queue::{ProvisionUserJob, QueueJobRepositoryExt};
use ulid::Ulid;
use crate::{
@@ -66,7 +68,10 @@ This DOES NOT reactivate a deactivated user, which will remain unavailable until
#[tracing::instrument(name = "handler.admin.v1.users.unlock", skip_all)]
pub async fn handler(
CallContext { mut repo, .. }: CallContext,
CallContext {
mut repo, clock, ..
}: CallContext,
NoApi(mut rng): NoApi<BoxRng>,
id: UlidPathParam,
) -> Result<Json<SingleResponse<User>>, RouteError> {
let id = *id;
@@ -78,6 +83,12 @@ pub async fn handler(
let user = repo.user().unlock(user).await?;
// Schedule a job to provision the user so that the lock flag is propagated
// to Synapse
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
repo.save().await?;
Ok(Json(SingleResponse::new(
@@ -91,7 +102,11 @@ mod tests {
use hyper::{Request, StatusCode};
use mas_data_model::Clock;
use mas_matrix::{HomeserverConnection, ProvisionRequest};
use mas_storage::{RepositoryAccess, user::UserRepository};
use mas_storage::{
RepositoryAccess,
queue::{ProvisionUserJob, QueueJobRepositoryExt},
user::UserRepository,
};
use sqlx::PgPool;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
@@ -109,16 +124,27 @@ mod tests {
.await
.unwrap();
let user = repo.user().lock(&state.clock, user).await.unwrap();
repo.save().await.unwrap();
// Also provision the user on the homeserver, because this endpoint will try to
// reactivate it
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
repo.queue_job()
.schedule_job(&mut state.rng(), &state.clock, ProvisionUserJob::new(&user))
.await
.unwrap();
repo.save().await.unwrap();
state.run_jobs_in_queue().await;
assert!(
state
.homeserver_connection
.query_user_raw("alice")
.await
.unwrap()
.locked,
"User should be locked at start of test"
);
let request = Request::post(format!("/api/admin/v1/users/{}/unlock", user.id))
.bearer(&token)
.empty();
@@ -130,6 +156,17 @@ mod tests {
body["data"]["attributes"]["locked_at"],
serde_json::Value::Null
);
state.run_jobs_in_queue().await;
assert!(
!state
.homeserver_connection
.query_user_raw("alice")
.await
.unwrap()
.locked,
"User should not be locked"
);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
@@ -151,7 +188,7 @@ mod tests {
// Provision the user on the homeserver
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
// but then deactivate it
+1 -1
View File
@@ -14,7 +14,7 @@ use thiserror::Error;
use crate::BoundActivityTracker;
// https://developers.google.com/recaptcha/docs/verify#api_request
const RECAPTCHA_VERIFY_URL: &str = "https://www.google.com/recaptcha/api/siteverify";
const RECAPTCHA_VERIFY_URL: &str = "https://www.recaptcha.net/recaptcha/api/siteverify";
// https://docs.hcaptcha.com/#verify-the-user-response-server-side
const HCAPTCHA_VERIFY_URL: &str = "https://api.hcaptcha.com/siteverify";
+774
View File
@@ -0,0 +1,774 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Integration tests for session cleanup jobs.
//!
//! These tests verify that cleanup jobs correctly respect the session hierarchy
//! required for OIDC Backchannel Logout to function properly.
//!
//! Session hierarchy:
//! ```text
//! upstream_oauth_authorization_sessions (matched by sub/sid claims)
//! │ user_session_id
//! ▼
//! user_sessions (browser sessions)
//! │ user_session_id FK
//! ┌────┴──────────────┐
//! │ │
//! ▼ ▼
//! compat_sessions oauth2_sessions
//! ```
use chrono::Duration;
use hyper::{Request, StatusCode};
use mas_data_model::{
BrowserSession, Clock as _, CompatSession, Device, UpstreamOAuthAuthorizationSession,
UpstreamOAuthLink, UpstreamOAuthProvider, UpstreamOAuthProviderClaimsImports,
UpstreamOAuthProviderDiscoveryMode, UpstreamOAuthProviderOnBackchannelLogout,
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderTokenAuthMethod, User,
};
use mas_iana::jose::JsonWebSignatureAlg;
use mas_jose::jwt::{JsonWebSignatureHeader, Jwt};
use mas_storage::{
RepositoryAccess,
queue::{DeactivateUserJob, QueueJobRepositoryExt},
upstream_oauth2::UpstreamOAuthProviderParams,
};
use oauth2_types::scope::{OPENID, Scope};
use sqlx::PgPool;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{method, path},
};
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
/// Helper struct to hold all the entities created for testing the session
/// hierarchy.
struct TestSessionHierarchy {
user: User,
browser_session: BrowserSession,
compat_session: Option<CompatSession>,
oauth2_session: Option<mas_data_model::Session>,
upstream_session: Option<UpstreamOAuthAuthorizationSession>,
#[expect(dead_code)]
upstream_link: Option<UpstreamOAuthLink>,
provider: Option<UpstreamOAuthProvider>,
#[expect(dead_code)]
mock_server: MockServer,
}
const UPSTREAM_OAUTH_ISSUER: &str = "https://idp.example.com";
const UPSTREAM_OAUTH_CLIENT_ID: &str = "test-client";
const UPSTREAM_OAUTH_SESSION_ID: &str = "upstream-oauth-session-id";
const UPSTREAM_OAUTH_SUBJECT: &str = "upstream-oauth-sub";
/// Create the complete session hierarchy for testing.
///
/// This creates:
/// - A user
/// - A browser session
/// - Optionally a compat session linked to the browser session
/// - Optionally an OAuth 2.0 session linked to the browser session
/// - Optionally an upstream OAuth session linked to the browser session
async fn create_session_hierarchy(
state: &TestState,
with_compat: bool,
with_oauth2: bool,
with_upstream: bool,
) -> TestSessionHierarchy {
let mut rng = state.rng();
let mut repo = state.repository().await.unwrap();
// Start a mock server to answer to JWKS requests for the backchannel logout
// tests
let mock_server = MockServer::start().await;
let jwks = state.key_store.public_jwks();
let mock_jwks = Mock::given(method("GET"))
.and(path("jwks.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(jwks));
mock_server.register(mock_jwks).await;
// Create user
let user = repo
.user()
.add(&mut rng, &state.clock, "testuser".to_owned())
.await
.unwrap();
// Create browser session
let browser_session = repo
.browser_session()
.add(&mut rng, &state.clock, &user, None)
.await
.unwrap();
// Create compat session if requested
let compat_session = if with_compat {
let device = Device::generate(&mut rng);
let session = repo
.compat_session()
.add(
&mut rng,
&state.clock,
&user,
device,
Some(&browser_session),
false,
None,
)
.await
.unwrap();
Some(session)
} else {
None
};
// Create OAuth2 session if requested
let oauth2_session = if with_oauth2 {
// First create an OAuth2 client
let client = repo
.oauth2_client()
.add(
&mut rng,
&state.clock,
vec!["https://example.com/callback".parse().unwrap()],
None,
None,
None,
vec![],
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
let session = repo
.oauth2_session()
.add_from_browser_session(
&mut rng,
&state.clock,
&client,
&browser_session,
Scope::from_iter([OPENID]),
)
.await
.unwrap();
Some(session)
} else {
None
};
// Create upstream OAuth session if requested
let (provider, upstream_link, upstream_session) = if with_upstream {
let params = UpstreamOAuthProviderParams {
issuer: Some(UPSTREAM_OAUTH_ISSUER.to_owned()),
human_name: Some("Test IdP".to_owned()),
brand_name: None,
scope: Scope::from_iter([OPENID]),
token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::ClientSecretBasic,
token_endpoint_signing_alg: None,
id_token_signed_response_alg: JsonWebSignatureAlg::Rs256,
fetch_userinfo: false,
userinfo_signed_response_alg: None,
client_id: UPSTREAM_OAUTH_CLIENT_ID.to_owned(),
encrypted_client_secret: None,
claims_imports: UpstreamOAuthProviderClaimsImports::default(),
authorization_endpoint_override: None,
token_endpoint_override: None,
userinfo_endpoint_override: None,
// Point to the mock server to have it use a JWKS we can use for signing
jwks_uri_override: Some(format!("{}/jwks.json", mock_server.uri()).parse().unwrap()),
discovery_mode: UpstreamOAuthProviderDiscoveryMode::Disabled,
pkce_mode: UpstreamOAuthProviderPkceMode::Auto,
response_mode: None,
additional_authorization_parameters: vec![],
forward_login_hint: false,
ui_order: 0,
on_backchannel_logout: UpstreamOAuthProviderOnBackchannelLogout::LogoutAll,
};
let provider = repo
.upstream_oauth_provider()
.add(&mut rng, &state.clock, params)
.await
.unwrap();
// Create a link
let link = repo
.upstream_oauth_link()
.add(
&mut rng,
&state.clock,
&provider,
UPSTREAM_OAUTH_SUBJECT.to_owned(),
Some("test@idp.example.com".to_owned()),
)
.await
.unwrap();
// Associate link to user
repo.upstream_oauth_link()
.associate_to_user(&link, &user)
.await
.unwrap();
// Create an upstream session
let session = repo
.upstream_oauth_session()
.add(
&mut rng,
&state.clock,
&provider,
"state123".to_owned(),
Some("verifier123".to_owned()),
Some("nonce123".to_owned()),
)
.await
.unwrap();
// Complete the session with the link and ID token claims (including sub and
// sid)
let id_token_claims = serde_json::json!({
"sub": UPSTREAM_OAUTH_SUBJECT,
"sid": UPSTREAM_OAUTH_SESSION_ID,
});
let session = repo
.upstream_oauth_session()
.complete_with_link(
&state.clock,
session,
&link,
Some("fake-id-token".to_owned()),
Some(id_token_claims),
None,
None,
)
.await
.unwrap();
// Consume the session and link it to the browser session
let session = repo
.upstream_oauth_session()
.consume(&state.clock, session, &browser_session)
.await
.unwrap();
(Some(provider), Some(link), Some(session))
} else {
(None, None, None)
};
repo.save().await.unwrap();
TestSessionHierarchy {
user,
browser_session,
compat_session,
oauth2_session,
upstream_session,
upstream_link,
provider,
mock_server,
}
}
/// Test that sessions finished less than 30 days ago are NOT deleted.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_cleanup_sessions_within_retention_preserved(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create a full hierarchy with all session types
let hierarchy = create_session_hierarchy(&state, true, true, false).await;
// Finish all sessions
let mut repo = state.repository().await.unwrap();
let browser_session = repo
.browser_session()
.finish(&state.clock, hierarchy.browser_session)
.await
.unwrap();
let compat_session = repo
.compat_session()
.finish(&state.clock, hierarchy.compat_session.unwrap())
.await
.unwrap();
let oauth2_session = repo
.oauth2_session()
.finish(&state.clock, hierarchy.oauth2_session.unwrap())
.await
.unwrap();
repo.save().await.unwrap();
// Wait one day and run the cleanup jobs a few times
state.clock.advance(Duration::try_days(1).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
// Verify all sessions still exist
let mut repo = state.repository().await.unwrap();
assert!(
repo.browser_session()
.lookup(browser_session.id)
.await
.unwrap()
.is_some(),
"Browser session should still exist"
);
assert!(
repo.compat_session()
.lookup(compat_session.id)
.await
.unwrap()
.is_some(),
"Compat session should still exist"
);
assert!(
repo.oauth2_session()
.lookup(oauth2_session.id)
.await
.unwrap()
.is_some(),
"OAuth2 session should still exist"
);
}
/// Test that deactivated users eventually get all their sessions cleaned up
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_cleanup_deactivated_users(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let mut rng = state.rng();
// Create a hierarchy with all session types
let hierarchy = create_session_hierarchy(&state, true, true, true).await;
// Deactivate the user
let mut repo = state.repository().await.unwrap();
let user = repo
.user()
.lookup(hierarchy.user.id)
.await
.unwrap()
.unwrap();
let user = repo.user().deactivate(&state.clock, user).await.unwrap();
repo.queue_job()
.schedule_job(&mut rng, &state.clock, DeactivateUserJob::new(&user, false))
.await
.unwrap();
repo.save().await.unwrap();
state.run_jobs_in_queue().await;
// Verify all sessions are finished
let mut repo = state.repository().await.unwrap();
assert!(
repo.compat_session()
.lookup(hierarchy.compat_session.as_ref().unwrap().id)
.await
.unwrap()
.unwrap()
.is_finished(),
"Compat session should be finished"
);
assert!(
repo.oauth2_session()
.lookup(hierarchy.oauth2_session.as_ref().unwrap().id)
.await
.unwrap()
.unwrap()
.is_finished(),
"OAuth2 session should be finished"
);
assert!(
repo.browser_session()
.lookup(hierarchy.browser_session.id)
.await
.unwrap()
.unwrap()
.finished_at
.is_some(),
"Browser session should be there"
);
assert!(
repo.upstream_oauth_session()
.lookup(hierarchy.upstream_session.as_ref().unwrap().id)
.await
.unwrap()
.is_some(),
"Upstream OAuth session should be there"
);
// Wait 31 days and run the cleanup jobs a few times
state.clock.advance(Duration::try_days(31).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
// Verify all sessions are deleted
let mut repo = state.repository().await.unwrap();
assert!(
repo.compat_session()
.lookup(hierarchy.compat_session.as_ref().unwrap().id)
.await
.unwrap()
.is_none(),
"Compat session should be deleted"
);
assert!(
repo.oauth2_session()
.lookup(hierarchy.oauth2_session.as_ref().unwrap().id)
.await
.unwrap()
.is_none(),
"OAuth2 session should be deleted"
);
assert!(
repo.browser_session()
.lookup(hierarchy.browser_session.id)
.await
.unwrap()
.is_none(),
"Browser session should be deleted"
);
assert!(
repo.upstream_oauth_session()
.lookup(hierarchy.upstream_session.as_ref().unwrap().id)
.await
.unwrap()
.is_none(),
"Upstream OAuth session should be deleted"
);
}
/// Test that sessions finished more than 30 days ago ARE deleted.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_cleanup_sessions_after_retention_deleted(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create hierarchy with compat and oauth2 sessions
let hierarchy = create_session_hierarchy(&state, true, true, false).await;
// Finish all sessions
let mut repo = state.repository().await.unwrap();
let browser_session = repo
.browser_session()
.finish(&state.clock, hierarchy.browser_session)
.await
.unwrap();
let compat_session = repo
.compat_session()
.finish(&state.clock, hierarchy.compat_session.unwrap())
.await
.unwrap();
let oauth2_session = repo
.oauth2_session()
.finish(&state.clock, hierarchy.oauth2_session.unwrap())
.await
.unwrap();
repo.save().await.unwrap();
// Wait 31 days and run the cleanup jobs a few times
state.clock.advance(Duration::try_days(31).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
// Verify all sessions are deleted
let mut repo = state.repository().await.unwrap();
assert!(
repo.compat_session()
.lookup(compat_session.id)
.await
.unwrap()
.is_none(),
"Compat session should be deleted"
);
assert!(
repo.oauth2_session()
.lookup(oauth2_session.id)
.await
.unwrap()
.is_none(),
"OAuth2 session should be deleted"
);
// Browser session should also be deleted since children are gone
assert!(
repo.browser_session()
.lookup(browser_session.id)
.await
.unwrap()
.is_none(),
"Browser session should be deleted after children are gone"
);
}
/// Test that user sessions with remaining child sessions are NOT deleted.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_cleanup_user_session_blocked_by_child_sessions(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create hierarchy with compat session only
let hierarchy = create_session_hierarchy(&state, true, false, false).await;
// Finish only the browser session (not the compat session)
let mut repo = state.repository().await.unwrap();
let browser_session = repo
.browser_session()
.finish(&state.clock, hierarchy.browser_session)
.await
.unwrap();
repo.save().await.unwrap();
let compat_session_id = hierarchy.compat_session.as_ref().unwrap().id;
// Wait 31 days and run the cleanup jobs a few times
state.clock.advance(Duration::try_days(31).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
// Verify browser session still exists because compat session is still active
let mut repo = state.repository().await.unwrap();
assert!(
repo.browser_session()
.lookup(browser_session.id)
.await
.unwrap()
.is_some(),
"Browser session should NOT be deleted because it has an active child session"
);
assert!(
repo.compat_session()
.lookup(compat_session_id)
.await
.unwrap()
.is_some(),
"Compat session should still exist (not finished)"
);
}
/// Test that backchannel logout can find sessions before cleanup.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_backchannel_logout_works_before_cleanup(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create hierarchy with upstream session
let hierarchy = create_session_hierarchy(&state, true, true, true).await;
let provider = hierarchy.provider.as_ref().unwrap();
// The edge case we're trying to make works, is that if the browser session
// is finished for 30 days but *not* the child sessions, that browser
// session and the upstream sessions stay there so that backchannel logout
// still works
let mut repo = state.repository().await.unwrap();
let browser_session = repo
.browser_session()
.lookup(hierarchy.browser_session.id)
.await
.unwrap()
.unwrap();
repo.browser_session()
.finish(&state.clock, browser_session)
.await
.unwrap();
repo.save().await.unwrap();
// Now wait 31 days and run the cleanup jobs a few times
state.clock.advance(Duration::try_days(31).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
// Now let's craft a backchannel logout request
let ts = state.clock.now().timestamp();
let payload = serde_json::json!({
"iss": UPSTREAM_OAUTH_ISSUER,
"aud": UPSTREAM_OAUTH_CLIENT_ID,
"sub": UPSTREAM_OAUTH_SUBJECT,
"sid": UPSTREAM_OAUTH_SESSION_ID,
"jti": "iswearthisisrandom",
"iat": ts,
"exp": ts + 300,
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
});
let key = state
.key_store
.signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256)
.unwrap();
let signer = key
.params()
.signing_key_for_alg(&JsonWebSignatureAlg::Rs256)
.unwrap();
let signed = Jwt::sign(
JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256),
payload,
&signer,
)
.unwrap();
let request = Request::post(format!("/upstream/backchannel-logout/{}", provider.id)).form(
serde_json::json!({
"logout_token": signed.as_str(),
}),
);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
// The session should still exist, but are finished
let mut repo = state.repository().await.unwrap();
assert!(
!repo
.browser_session()
.lookup(hierarchy.browser_session.id)
.await
.unwrap()
.unwrap()
.active(),
"Inactive browser session should not be cleaned up"
);
assert!(
repo.compat_session()
.lookup(hierarchy.compat_session.as_ref().unwrap().id)
.await
.unwrap()
.unwrap()
.is_finished(),
"Active compat session should not be cleaned up"
);
assert!(
repo.oauth2_session()
.lookup(hierarchy.oauth2_session.as_ref().unwrap().id)
.await
.unwrap()
.unwrap()
.is_finished(),
"Active OAuth2 session should not be cleaned up"
);
// Wait again, then the sessions should be completely deleted
state.clock.advance(Duration::try_days(31).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
let mut repo = state.repository().await.unwrap();
assert!(
repo.browser_session()
.lookup(hierarchy.browser_session.id)
.await
.unwrap()
.is_none(),
"Browser session should be deleted"
);
assert!(
repo.compat_session()
.lookup(hierarchy.compat_session.as_ref().unwrap().id)
.await
.unwrap()
.is_none(),
"Compat session should be deleted"
);
assert!(
repo.oauth2_session()
.lookup(hierarchy.oauth2_session.as_ref().unwrap().id)
.await
.unwrap()
.is_none(),
"OAuth2 session should be deleted"
);
assert!(
repo.upstream_oauth_session()
.lookup(hierarchy.upstream_session.as_ref().unwrap().id)
.await
.unwrap()
.is_none(),
"Upstream OAuth session should be deleted"
);
}
/// Test that active sessions are not cleaned up even after retention period.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_active_sessions_not_cleaned_up(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create hierarchy without finishing any sessions
let hierarchy = create_session_hierarchy(&state, true, true, false).await;
let browser_session_id = hierarchy.browser_session.id;
let compat_session_id = hierarchy.compat_session.as_ref().unwrap().id;
let oauth2_session_id = hierarchy.oauth2_session.as_ref().unwrap().id;
// Wait 31 days and run the cleanup jobs a few times
state.clock.advance(Duration::try_days(31).unwrap());
state.run_jobs_in_queue().await;
for _ in 0..5 {
state.clock.advance(Duration::try_hours(1).unwrap());
state.run_jobs_in_queue().await;
}
// All sessions should still exist because they're active
let mut repo = state.repository().await.unwrap();
assert!(
repo.browser_session()
.lookup(browser_session_id)
.await
.unwrap()
.is_some(),
"Active browser session should not be cleaned up"
);
assert!(
repo.compat_session()
.lookup(compat_session_id)
.await
.unwrap()
.is_some(),
"Active compat session should not be cleaned up"
);
assert!(
repo.oauth2_session()
.lookup(oauth2_session_id)
.await
.unwrap()
.is_some(),
"Active OAuth2 session should not be cleaned up"
);
}
+3 -3
View File
@@ -935,7 +935,7 @@ mod tests {
.unwrap();
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, locked))
.await
.unwrap();
@@ -1238,7 +1238,7 @@ mod tests {
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
@@ -1343,7 +1343,7 @@ mod tests {
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
@@ -83,7 +83,7 @@ pub async fn get(
Some(CompatLoginSsoAction::Register) => {
url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id))
}
Some(CompatLoginSsoAction::Login) | None => {
Some(CompatLoginSsoAction::Login | CompatLoginSsoAction::Unknown) | None => {
url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id))
}
};
@@ -224,7 +224,7 @@ pub async fn post(
Some(CompatLoginSsoAction::Register) => {
url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id))
}
Some(CompatLoginSsoAction::Login) | None => {
Some(CompatLoginSsoAction::Login | CompatLoginSsoAction::Unknown) | None => {
url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id))
}
};
@@ -13,7 +13,6 @@ use mas_router::{CompatLoginSsoAction, CompatLoginSsoComplete, UrlBuilder};
use mas_storage::{BoxRepository, compat::CompatSsoLoginRepository};
use rand::distributions::{Alphanumeric, DistString};
use serde::Deserialize;
use serde_with::serde;
use thiserror::Error;
use url::Url;
@@ -23,12 +22,21 @@ use crate::impl_from_error_for_route;
pub struct Params {
#[serde(rename = "redirectUrl")]
redirect_url: Option<String>,
action: Option<CompatLoginSsoAction>,
#[serde(rename = "org.matrix.msc3824.action")]
unstable_action: Option<CompatLoginSsoAction>,
}
impl Params {
fn action(&self) -> Option<CompatLoginSsoAction> {
self.action
.filter(CompatLoginSsoAction::is_known)
.or(self.unstable_action.filter(CompatLoginSsoAction::is_known))
}
}
#[derive(Debug, Error)]
pub enum RouteError {
#[error(transparent)]
@@ -62,6 +70,8 @@ pub async fn get(
State(url_builder): State<UrlBuilder>,
Query(params): Query<Params>,
) -> Result<impl IntoResponse, RouteError> {
let action = params.action();
// Check the redirectUrl parameter
let redirect_url = params.redirect_url.ok_or(RouteError::MissingRedirectUrl)?;
let redirect_url = Url::parse(&redirect_url).map_err(|_| RouteError::InvalidRedirectUrl)?;
@@ -84,10 +94,7 @@ pub async fn get(
repo.save().await?;
Ok(url_builder.absolute_redirect(&CompatLoginSsoComplete::new(
login.id,
params.action.or(params.unstable_action),
)))
Ok(url_builder.absolute_redirect(&CompatLoginSsoComplete::new(login.id, action)))
}
#[cfg(test)]
@@ -121,4 +128,29 @@ mod tests {
assert!(location.contains("org.matrix.msc3824.action=register"));
assert!(location.contains("action=register"));
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_unknown_action(pool: PgPool) {
let state: TestState = TestState::from_pool(pool).await.unwrap();
let request = Request::get(
"/_matrix/client/v3/login/sso/redirect?\
redirectUrl=http://example.com/\
&org.matrix.msc3824.action=undefinedaction",
)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::SEE_OTHER);
let location = response
.headers()
.get("Location")
.unwrap()
.to_str()
.unwrap();
assert!(!location.contains("org.matrix.msc3824.action"));
assert!(!location.contains("action"));
}
}
+3
View File
@@ -25,6 +25,9 @@ pub(crate) mod logout;
pub(crate) mod logout_all;
pub(crate) mod refresh;
#[cfg(test)]
mod tests;
#[derive(Debug, Serialize)]
struct MatrixError {
errcode: &'static str,
+1 -1
View File
@@ -156,7 +156,7 @@ pub(crate) async fn post(
.await?;
repo.compat_refresh_token()
.consume(&clock, refresh_token)
.consume_and_replace(&clock, refresh_token, &new_refresh_token)
.await?;
if let Some(access_token) = access_token {
+233
View File
@@ -0,0 +1,233 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use axum::http::{Request, StatusCode};
use mas_matrix::{HomeserverConnection, ProvisionRequest};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum LoginCredentials {
#[serde(rename = "m.login.password")]
Password {
identifier: LoginIdentifier,
password: String,
},
}
#[derive(Debug, Serialize)]
#[serde(tag = "type")]
enum LoginIdentifier {
#[serde(rename = "m.id.user")]
User { user: String },
}
#[derive(Debug, Serialize)]
struct LoginRequest {
#[serde(flatten)]
credentials: LoginCredentials,
#[serde(default)]
refresh_token: bool,
}
#[derive(Debug, Deserialize)]
struct LoginResponse {
#[allow(dead_code)]
access_token: String,
#[allow(dead_code)]
user_id: String,
#[allow(dead_code)]
device_id: Option<String>,
refresh_token: Option<String>,
}
#[derive(Debug, Serialize)]
struct RefreshRequest {
refresh_token: String,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct RefreshResponse {
access_token: String,
refresh_token: String,
expires_in_ms: i64,
}
/// Test using a compatibility refresh token.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_compat_refresh(pool: sqlx::PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create a user
create_test_user(&state, "testuser").await;
// Login to get initial tokens
let login_request = Request::post("/_matrix/client/v3/login").json(&LoginRequest {
credentials: LoginCredentials::Password {
identifier: LoginIdentifier::User {
user: "testuser".to_owned(),
},
password: "password".to_owned(),
},
refresh_token: true,
});
let login_response = state.request(login_request).await;
login_response.assert_status(StatusCode::OK);
let login_response: LoginResponse = login_response.json();
let initial_refresh_token = login_response
.refresh_token
.expect("Login should return a refresh token");
// First refresh
let refresh_request = Request::post("/_matrix/client/v3/refresh").json(&RefreshRequest {
refresh_token: initial_refresh_token.clone(),
});
let first_refresh_response = state.request(refresh_request).await;
first_refresh_response.assert_status(StatusCode::OK);
let first_refresh_response: RefreshResponse = first_refresh_response.json();
let first_new_refresh_token = first_refresh_response.refresh_token.clone();
assert_eq!(
first_refresh_response,
RefreshResponse {
access_token: "mct_fNbm5KAQovodfVQz7IvDc44woP66fR_fsaiD1".to_owned(),
refresh_token: "mcr_42oTpLoieH5IecxG6gZXyvelQWW9Xq_a8g5N3".to_owned(),
expires_in_ms: 300_000
}
);
// Use the token from the /refresh response to /refresh again,
// proving that it works.
// This is a regression test: we were previously consuming the refresh token
// before it was returned from /refresh.
let second_refresh_request =
Request::post("/_matrix/client/v3/refresh").json(&RefreshRequest {
refresh_token: first_new_refresh_token.clone(),
});
let second_refresh_response = state.request(second_refresh_request).await;
second_refresh_response.assert_status(StatusCode::OK);
let second_refresh_response: RefreshResponse = second_refresh_response.json();
assert_eq!(
second_refresh_response,
RefreshResponse {
access_token: "mct_Wc6Hx4l9DGzqGtgLoYqtrtBUBcWlE4_ZFyTp2".to_owned(),
refresh_token: "mcr_Yp7FM44zJN5qePGMLvvMXC4Ds1A3lC_0YcYCM".to_owned(),
expires_in_ms: 300_000
}
);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_refresh_with_invalid_token(pool: sqlx::PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let refresh_request = RefreshRequest {
refresh_token: "invalid_token".to_owned(),
};
let refresh_request = Request::post("/_matrix/client/v3/refresh").json(&refresh_request);
let response = state.request(refresh_request).await;
response.assert_status(StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_refresh_with_consumed_token(pool: sqlx::PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
// Create a user and login
create_test_user(&state, "testuser").await;
let login_request = LoginRequest {
credentials: LoginCredentials::Password {
identifier: LoginIdentifier::User {
user: "testuser".to_owned(),
},
password: "password".to_owned(),
},
refresh_token: true,
};
let login_request = Request::post("/_matrix/client/v3/login").json(&login_request);
let login_response = state.request(login_request).await;
login_response.assert_status(StatusCode::OK);
let login_response: LoginResponse = login_response.json();
let refresh_token = login_response
.refresh_token
.expect("Login should return a refresh token");
let refresh_request = RefreshRequest {
refresh_token: refresh_token.clone(),
};
// Use the refresh token once
let first_refresh_request = Request::post("/_matrix/client/v3/refresh").json(&refresh_request);
let first_refresh_response = state.request(first_refresh_request).await;
first_refresh_response.assert_status(StatusCode::OK);
let _first_refresh_response: RefreshResponse = first_refresh_response.json();
// Try to use the same refresh token again - should fail because it's consumed
let second_refresh_request = Request::post("/_matrix/client/v3/refresh").json(&refresh_request);
let second_refresh_response = state.request(second_refresh_request).await;
second_refresh_response.assert_status(StatusCode::UNAUTHORIZED);
}
async fn create_test_user(state: &TestState, username: &str) -> mas_data_model::User {
let mut repo = state.repository().await.unwrap();
let mut rng = state.rng();
let user = repo
.user()
.add(&mut rng, &state.clock, username.to_owned())
.await
.unwrap();
let password = Zeroizing::new("password".to_owned());
let (version, hashed_password) = state
.password_manager
.hash(&mut rng, password)
.await
.unwrap();
repo.user_password()
.add(
&mut rng,
&state.clock,
&user,
version,
hashed_password,
None,
)
.await
.unwrap();
// Provision the user on the homeserver
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
repo.save().await.unwrap();
user
}
@@ -551,6 +551,12 @@ impl UserMutations {
let user = repo.user().lock(&state.clock(), user).await?;
// Schedule a job to provision the user so that the lock flag is propagated
// to Synapse
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
if deactivate {
info!(%user.id, "Scheduling deactivation of user");
repo.queue_job()
@@ -570,6 +576,8 @@ impl UserMutations {
input: UnlockUserInput,
) -> Result<UnlockUserPayload, async_graphql::Error> {
let state = ctx.state();
let clock = state.clock();
let mut rng = state.rng();
let requester = ctx.requester();
let matrix = state.homeserver_connection();
@@ -592,6 +600,12 @@ impl UserMutations {
let user = repo.user().reactivate(user).await?;
let user = repo.user().unlock(user).await?;
// Schedule a job to provision the user so that the lock flag is propagated
// to Synapse
repo.queue_job()
.schedule_job(&mut rng, &clock, ProvisionUserJob::new(&user))
.await?;
repo.save().await?;
Ok(UnlockUserPayload::Unlocked(user))
+1 -1
View File
@@ -530,7 +530,7 @@ async fn test_oauth2_client_credentials(pool: PgPool) {
// so we need to do it manually
state
.homeserver_connection
.provision_user(&ProvisionRequest::new("alice", user_id))
.provision_user(&ProvisionRequest::new("alice", user_id, false))
.await
.unwrap();
+2
View File
@@ -62,6 +62,8 @@ mod views;
mod activity_tracker;
mod captcha;
#[cfg(test)]
mod cleanup_tests;
mod preferred_language;
mod rate_limit;
mod session;
@@ -278,9 +278,15 @@ pub(crate) async fn get(
// Other cases where we don't have a session, ask for a login
repo.save().await?;
url_builder
.redirect(&mas_router::Login::and_then(continue_grant))
.into_response()
let mut url = mas_router::Login::and_then(continue_grant);
url = if let Some(login_hint) = grant.login_hint {
url.with_login_hint(login_hint)
} else {
url
};
url_builder.redirect(&url).into_response()
}
Some(user_session) => {
+2 -2
View File
@@ -805,7 +805,7 @@ mod tests {
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
@@ -1005,7 +1005,7 @@ mod tests {
state
.homeserver_connection
.provision_user(&ProvisionRequest::new(&user.username, &user.sub))
.provision_user(&ProvisionRequest::new(&user.username, &user.sub, false))
.await
.unwrap();
+47 -47
View File
@@ -476,53 +476,6 @@ pub(crate) async fn get(
let forced_or_required = provider.claims_imports.localpart.is_forced_or_required();
// We've got a localpart from the template. Let's run the policy
// engine on this registration and react early to a problem on
// the username
let res = policy
.evaluate_register(mas_policy::RegisterInput {
registration_method: mas_policy::RegistrationMethod::UpstreamOAuth2,
username: &localpart,
email: email.as_deref(),
requester: mas_policy::Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
},
})
.await?;
// We don't do a full policy check at this point, only look for violations on
// the username
if res
.violations
.iter()
.any(|violation| violation.field.as_deref() == Some("username"))
{
if !forced_or_required {
tracing::warn!(
upstream_oauth_provider.id = %provider.id,
upstream_oauth_link.id = %link.id,
"Upstream provider returned a localpart {localpart:?} which was denied by the policy ({res}). As the username is just a suggestion, it was ignored."
);
break 'localpart None;
}
// If the username policy check fails, we display an error message.
// TODO: translate
let ctx = ErrorContext::new()
.with_code("Policy error")
.with_description(format!(
r"Upstream account provider returned {localpart:?} as username,
which does not pass the policy check: {res}"
))
.with_language(&locale);
return Ok((
cookie_jar,
Html(templates.render_error(&ctx)?).into_response(),
));
}
// We got a localpart from the template. We need to check if it's
// available, and if it's not apply the conflict resolution setup in
// the config
@@ -730,6 +683,53 @@ pub(crate) async fn get(
));
}
// We've got a localpart from the template. Let's run the policy
// engine on this registration and react early to a problem on
// the username
let res = policy
.evaluate_register(mas_policy::RegisterInput {
registration_method: mas_policy::RegistrationMethod::UpstreamOAuth2,
username: &localpart,
email: email.as_deref(),
requester: mas_policy::Requester {
ip_address: activity_tracker.ip(),
user_agent: user_agent.clone(),
},
})
.await?;
// We don't do a full policy check at this point, only look for violations on
// the username
if res
.violations
.iter()
.any(|violation| violation.field.as_deref() == Some("username"))
{
if !forced_or_required {
tracing::warn!(
upstream_oauth_provider.id = %provider.id,
upstream_oauth_link.id = %link.id,
"Upstream provider returned a localpart {localpart:?} which was denied by the policy ({res}). As the username is just a suggestion, it was ignored."
);
break 'localpart None;
}
// If the username policy check fails, we display an error message.
// TODO: translate
let ctx = ErrorContext::new()
.with_code("Policy error")
.with_description(format!(
r"Upstream account provider returned {localpart:?} as username,
which does not pass the policy check: {res}"
))
.with_language(&locale);
return Ok((
cookie_jar,
Html(templates.render_error(&ctx)?).into_response(),
));
}
// Now let's check if the localpart is allowed by the homeserver. It's possible
// that it's plain invalid (although that should have been caught by the
// policy), or just reserved by an application service
+14 -8
View File
@@ -25,6 +25,9 @@ use crate::{
pub struct Params {
#[serde(default, flatten)]
action: Option<mas_router::AccountAction>,
#[serde(rename = "org.matrix.msc4198.login_hint")]
unstable_login_hint: Option<String>,
}
#[tracing::instrument(name = "handlers.views.app.get", skip_all)]
@@ -33,7 +36,10 @@ pub async fn get(
State(templates): State<Templates>,
activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>,
Query(Params { action }): Query<Params>,
Query(Params {
action,
unstable_login_hint,
}): Query<Params>,
mut repo: BoxRepository,
clock: BoxClock,
mut rng: BoxRng,
@@ -54,13 +60,13 @@ pub async fn get(
// TODO: keep the full path, not just the action
let Some(session) = maybe_session else {
return Ok((
cookie_jar,
url_builder.redirect(&mas_router::Login::and_then(
PostAuthAction::manage_account(action),
)),
)
.into_response());
let mut url = mas_router::Login::and_then(PostAuthAction::manage_account(action));
if let Some(login_hint) = unstable_login_hint {
url = url.with_login_hint(login_hint);
}
return Ok((cookie_jar, url_builder.redirect(&url)).into_response());
};
activity_tracker
+21 -16
View File
@@ -17,7 +17,7 @@ use mas_axum_utils::{
cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm},
};
use mas_data_model::{BoxClock, BoxRng, Clock, oauth2::LoginHint};
use mas_data_model::{BoxClock, BoxRng, Clock};
use mas_i18n::DataLocale;
use mas_matrix::HomeserverConnection;
use mas_router::{UpstreamOAuth2Authorize, UrlBuilder};
@@ -28,14 +28,14 @@ use mas_storage::{
};
use mas_templates::{
AccountInactiveContext, FieldError, FormError, FormState, LoginContext, LoginFormField,
PostAuthContext, PostAuthContextInner, TemplateContext, Templates, ToFormState,
TemplateContext, Templates, ToFormState,
};
use opentelemetry::{Key, KeyValue, metrics::Counter};
use rand::Rng;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use super::shared::OptionalPostAuthAction;
use super::shared::{LoginHint, OptionalPostAuthAction, QueryLoginHint};
use crate::{
BoundActivityTracker, Limiter, METER, PreferredLanguage, RequesterFingerprint, SiteConfig,
passwords::{PasswordManager, PasswordVerificationResult},
@@ -73,6 +73,7 @@ pub(crate) async fn get(
mut repo: BoxRepository,
activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>,
Query(query_login_hint): Query<QueryLoginHint>,
cookie_jar: CookieJar,
) -> Result<Response, InternalError> {
let (cookie_jar, maybe_session) = match load_session_or_fallback(
@@ -124,6 +125,7 @@ pub(crate) async fn get(
&templates,
&homeserver,
&site_config,
query_login_hint,
)
.await
}
@@ -142,7 +144,7 @@ pub(crate) async fn post(
mut repo: BoxRepository,
activity_tracker: BoundActivityTracker,
requester: RequesterFingerprint,
Query(query): Query<OptionalPostAuthAction>,
(Query(query), Query(query_login_hint)): (Query<OptionalPostAuthAction>, Query<QueryLoginHint>),
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<headers::UserAgent>>,
Form(form): Form<ProtectedForm<LoginForm>>,
@@ -180,6 +182,7 @@ pub(crate) async fn post(
&templates,
&homeserver,
&site_config,
query_login_hint,
)
.await;
}
@@ -206,6 +209,7 @@ pub(crate) async fn post(
&templates,
&homeserver,
&site_config,
query_login_hint,
)
.await;
};
@@ -226,6 +230,7 @@ pub(crate) async fn post(
&templates,
&homeserver,
&site_config,
query_login_hint,
)
.await;
}
@@ -248,6 +253,7 @@ pub(crate) async fn post(
&templates,
&homeserver,
&site_config,
query_login_hint,
)
.await;
};
@@ -293,6 +299,7 @@ pub(crate) async fn post(
&templates,
&homeserver,
&site_config,
query_login_hint,
)
.await;
}
@@ -375,7 +382,7 @@ async fn get_user_by_email_or_by_username<R: RepositoryAccess>(
fn handle_login_hint(
mut ctx: LoginContext,
next: &PostAuthContext,
query_login_hint: &QueryLoginHint,
homeserver: &dyn HomeserverConnection,
site_config: &SiteConfig,
) -> LoginContext {
@@ -386,16 +393,12 @@ fn handle_login_hint(
return ctx;
}
if let PostAuthContextInner::ContinueAuthorizationGrant { ref grant } = next.ctx {
let value = match grant.parse_login_hint(homeserver.homeserver()) {
LoginHint::MXID(mxid) => Some(mxid.localpart().to_owned()),
LoginHint::Email(email) if site_config.login_with_email_allowed => {
Some(email.to_string())
}
_ => None,
};
form_state.set_value(LoginFormField::Username, value);
}
let value = match query_login_hint.parse_login_hint(homeserver.homeserver()) {
LoginHint::Mxid(mxid) => Some(mxid.localpart().to_owned()),
LoginHint::Email(email) if site_config.login_with_email_allowed => Some(email.to_string()),
_ => None,
};
form_state.set_value(LoginFormField::Username, value);
ctx
}
@@ -411,6 +414,7 @@ async fn render(
templates: &Templates,
homeserver: &dyn HomeserverConnection,
site_config: &SiteConfig,
query_login_hint: QueryLoginHint,
) -> Result<Response, InternalError> {
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
let providers = repo.upstream_oauth_provider().all_enabled().await?;
@@ -419,12 +423,13 @@ async fn render(
.with_form_state(form_state)
.with_upstream_providers(providers);
let ctx = handle_login_hint(ctx, &query_login_hint, homeserver, site_config);
let next = action
.load_context(repo)
.await
.map_err(InternalError::from_anyhow)?;
let ctx = if let Some(next) = next {
let ctx = handle_login_hint(ctx, &next, homeserver, site_config);
ctx.with_post_action(next)
} else {
ctx
+109
View File
@@ -4,6 +4,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::str::FromStr as _;
use anyhow::Context;
use mas_router::{PostAuthAction, Route, UrlBuilder};
use mas_storage::{
@@ -13,6 +15,7 @@ use mas_storage::{
upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository},
};
use mas_templates::{PostAuthContext, PostAuthContextInner};
use ruma_common::UserId;
use serde::{Deserialize, Serialize};
use tracing::warn;
@@ -107,3 +110,109 @@ impl OptionalPostAuthAction {
}))
}
}
pub enum LoginHint<'a> {
Mxid(&'a UserId),
Email(lettre::Address),
None,
}
#[derive(Debug, Deserialize)]
pub(crate) struct QueryLoginHint {
login_hint: Option<String>,
}
impl QueryLoginHint {
/// Parse a `login_hint`
///
/// Returns `LoginHint::MXID` for valid mxid 'mxid:@john.doe:example.com'
///
/// Returns `LoginHint::Email` for valid email 'john.doe@example.com'
///
/// Otherwise returns `LoginHint::None`
pub fn parse_login_hint(&self, homeserver: &str) -> LoginHint<'_> {
let Some(login_hint) = &self.login_hint else {
return LoginHint::None;
};
if let Some(value) = login_hint.strip_prefix("mxid:")
&& let Ok(mxid) = <&UserId>::try_from(value)
&& mxid.server_name() == homeserver
{
LoginHint::Mxid(mxid)
} else if let Ok(email) = lettre::Address::from_str(login_hint) {
LoginHint::Email(email)
} else {
LoginHint::None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_login_hint() {
let query_login_hint = QueryLoginHint { login_hint: None };
let hint = query_login_hint.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
#[test]
fn valid_login_hint() {
let query_login_hint = QueryLoginHint {
login_hint: Some(String::from("mxid:@example-user:example.com")),
};
let hint = query_login_hint.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::Mxid(mxid) if mxid.localpart() == "example-user"));
}
#[test]
fn valid_login_hint_with_email() {
let query_login_hint = QueryLoginHint {
login_hint: Some(String::from("example@user")),
};
let hint = query_login_hint.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::Email(email) if email.to_string() == "example@user"));
}
#[test]
fn invalid_login_hint() {
let query_login_hint = QueryLoginHint {
login_hint: Some(String::from("example-user")),
};
let hint = query_login_hint.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
#[test]
fn valid_login_hint_for_wrong_homeserver() {
let query_login_hint = QueryLoginHint {
login_hint: Some(String::from("mxid:@example-user:matrix.org")),
};
let hint = query_login_hint.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
#[test]
fn unknown_login_hint_type() {
let query_login_hint = QueryLoginHint {
login_hint: Some(String::from("something:anything")),
};
let hint = query_login_hint.parse_login_hint("example.com");
assert!(matches!(hint, LoginHint::None));
}
}
+3
View File
@@ -141,6 +141,8 @@ impl HomeserverConnection for SynapseConnection {
set_emails: Option<Vec<String>>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
unset_emails: bool,
#[serde(skip_serializing_if = "Option::is_none")]
locked: Option<bool>,
}
let mut body = Request {
@@ -151,6 +153,7 @@ impl HomeserverConnection for SynapseConnection {
unset_avatar_url: false,
set_emails: None,
unset_emails: false,
locked: Some(request.locked()),
};
request.on_displayname(|displayname| match displayname {
+10 -1
View File
@@ -33,6 +33,7 @@ enum FieldAction<T> {
pub struct ProvisionRequest {
localpart: String,
sub: String,
locked: bool,
displayname: FieldAction<String>,
avatar_url: FieldAction<String>,
emails: FieldAction<Vec<String>>,
@@ -45,11 +46,13 @@ impl ProvisionRequest {
///
/// * `localpart` - The localpart of the user to provision.
/// * `sub` - The `sub` of the user, aka the internal ID.
/// * `locked` - Whether the user is locked.
#[must_use]
pub fn new(localpart: impl Into<String>, sub: impl Into<String>) -> Self {
pub fn new(localpart: impl Into<String>, sub: impl Into<String>, locked: bool) -> Self {
Self {
localpart: localpart.into(),
sub: sub.into(),
locked,
displayname: FieldAction::DoNothing,
avatar_url: FieldAction::DoNothing,
emails: FieldAction::DoNothing,
@@ -68,6 +71,12 @@ impl ProvisionRequest {
&self.localpart
}
/// Get the locked flag of the user to provision
#[must_use]
pub fn locked(&self) -> bool {
self.locked
}
/// Ask to set the displayname of the user.
///
/// # Parameters
+20 -4
View File
@@ -10,9 +10,10 @@ use anyhow::Context;
use async_trait::async_trait;
use tokio::sync::RwLock;
use crate::{MatrixUser, ProvisionRequest};
use crate::{HomeserverConnection as _, MatrixUser, ProvisionRequest};
struct MockUser {
#[derive(Clone)]
pub struct MockUser {
sub: String,
avatar_url: Option<String>,
displayname: Option<String>,
@@ -20,6 +21,7 @@ struct MockUser {
emails: Option<Vec<String>>,
cross_signing_reset_allowed: bool,
deactivated: bool,
pub locked: bool,
}
/// A mock implementation of a [`HomeserverConnection`], which never fails and
@@ -50,6 +52,18 @@ impl HomeserverConnection {
pub async fn reserve_localpart(&self, localpart: &'static str) {
self.reserved_localparts.write().await.insert(localpart);
}
/// Like `query_user` but get the raw test state of the user.
///
/// # Errors
///
/// Will fail if the user doesn't exist.
pub async fn query_user_raw(&self, localpart: &str) -> Result<MockUser, anyhow::Error> {
let mxid = self.mxid(localpart);
let users = self.users.read().await;
let user = users.get(&mxid).context("User not found")?;
Ok(user.clone())
}
}
#[async_trait]
@@ -85,6 +99,7 @@ impl crate::HomeserverConnection for HomeserverConnection {
emails: None,
cross_signing_reset_allowed: false,
deactivated: false,
locked: false,
});
anyhow::ensure!(
@@ -104,6 +119,8 @@ impl crate::HomeserverConnection for HomeserverConnection {
user.avatar_url = avatar_url.map(ToOwned::to_owned);
});
user.locked = request.locked();
Ok(inserted)
}
@@ -219,7 +236,6 @@ impl crate::HomeserverConnection for HomeserverConnection {
#[cfg(test)]
mod tests {
use super::*;
use crate::HomeserverConnection as _;
#[tokio::test]
async fn test_mock_connection() {
@@ -234,7 +250,7 @@ mod tests {
assert!(conn.upsert_device("test", device, None).await.is_err());
assert!(conn.delete_device("test", device).await.is_err());
let request = ProvisionRequest::new("test", "test")
let request = ProvisionRequest::new("test", "test", false)
.set_displayname("Test User".into())
.set_avatar_url("mxc://example.org/1234567890".into())
.set_emails(vec!["test@example.org".to_owned()]);
+31 -4
View File
@@ -172,20 +172,23 @@ impl SimpleRoute for Healthcheck {
}
/// `GET|POST /login`
#[derive(Default, Debug, Clone)]
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Login {
#[serde(flatten)]
post_auth_action: Option<PostAuthAction>,
login_hint: Option<String>,
}
impl Route for Login {
type Query = PostAuthAction;
type Query = Self;
fn route() -> &'static str {
"/login"
}
fn query(&self) -> Option<&Self::Query> {
self.post_auth_action.as_ref()
Some(self)
}
}
@@ -194,6 +197,7 @@ impl Login {
pub const fn and_then(action: PostAuthAction) -> Self {
Self {
post_auth_action: Some(action),
login_hint: None,
}
}
@@ -201,6 +205,7 @@ impl Login {
pub const fn and_continue_grant(id: Ulid) -> Self {
Self {
post_auth_action: Some(PostAuthAction::continue_grant(id)),
login_hint: None,
}
}
@@ -208,6 +213,7 @@ impl Login {
pub const fn and_continue_device_code_grant(id: Ulid) -> Self {
Self {
post_auth_action: Some(PostAuthAction::continue_device_code_grant(id)),
login_hint: None,
}
}
@@ -215,6 +221,7 @@ impl Login {
pub const fn and_continue_compat_sso_login(id: Ulid) -> Self {
Self {
post_auth_action: Some(PostAuthAction::continue_compat_sso_login(id)),
login_hint: None,
}
}
@@ -222,9 +229,16 @@ impl Login {
pub const fn and_link_upstream(id: Ulid) -> Self {
Self {
post_auth_action: Some(PostAuthAction::link_upstream(id)),
login_hint: None,
}
}
#[must_use]
pub fn with_login_hint(mut self, login_hint: String) -> Self {
self.login_hint = Some(login_hint);
self
}
/// Get a reference to the login's post auth action.
#[must_use]
pub fn post_auth_action(&self) -> Option<&PostAuthAction> {
@@ -241,7 +255,10 @@ impl Login {
impl From<Option<PostAuthAction>> for Login {
fn from(post_auth_action: Option<PostAuthAction>) -> Self {
Self { post_auth_action }
Self {
post_auth_action,
login_hint: None,
}
}
}
@@ -628,6 +645,16 @@ impl SimpleRoute for CompatLoginSsoRedirectIdp {
pub enum CompatLoginSsoAction {
Login,
Register,
#[serde(other)]
Unknown,
}
impl CompatLoginSsoAction {
/// Returns true if the action is a known action.
#[must_use]
pub fn is_known(&self) -> bool {
!matches!(self, Self::Unknown)
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
@@ -1,15 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE compat_refresh_tokens\n SET consumed_at = $2\n WHERE compat_session_id = $1\n AND consumed_at IS NULL\n ",
"query": "\n UPDATE compat_refresh_tokens\n SET consumed_at = $2\n WHERE compat_session_id = $1\n AND consumed_at IS NULL\n AND compat_refresh_token_id <> $3\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
"Timestamptz",
"Uuid"
]
},
"nullable": []
},
"hash": "f75e44b528234dac708640ad9a111f3f6b468a91bf0d5b574795bf8c80605f19"
"hash": "4e64540bbffe5f4b9c4a6589012cf69eb67adaa4d40fc1910dfcd2640e32ab37"
}
+17 -4
View File
@@ -437,6 +437,7 @@ mod tests {
async fn test_refresh_token_repository(pool: PgPool) {
const ACCESS_TOKEN: &str = "access_token";
const REFRESH_TOKEN: &str = "refresh_token";
const REFRESH_TOKEN2: &str = "refresh_token2";
let mut rng = ChaChaRng::seed_from_u64(42);
let clock = MockClock::default();
let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed();
@@ -508,16 +509,28 @@ mod tests {
assert!(refresh_token_lookup.is_valid());
assert!(!refresh_token_lookup.is_consumed());
// Consume it
// Consume the first token, but to do so we need a 2nd to replace it with
let refresh_token2 = repo
.compat_refresh_token()
.add(
&mut rng,
&clock,
&session,
&access_token,
REFRESH_TOKEN2.to_owned(),
)
.await
.unwrap();
let refresh_token = repo
.compat_refresh_token()
.consume(&clock, refresh_token)
.consume_and_replace(&clock, refresh_token, &refresh_token2)
.await
.unwrap();
assert!(!refresh_token.is_valid());
assert!(refresh_token.is_consumed());
// Reload it and check again
// Reload the first token and check again
let refresh_token_lookup = repo
.compat_refresh_token()
.find_by_token(REFRESH_TOKEN)
@@ -530,7 +543,7 @@ mod tests {
// Consuming it again should not work
assert!(
repo.compat_refresh_token()
.consume(&clock, refresh_token)
.consume_and_replace(&clock, refresh_token, &refresh_token2)
.await
.is_err()
);
+10 -2
View File
@@ -185,20 +185,26 @@ impl CompatRefreshTokenRepository for PgCompatRefreshTokenRepository<'_> {
}
#[tracing::instrument(
name = "db.compat_refresh_token.consume",
name = "db.compat_refresh_token.consume_and_replace",
skip_all,
fields(
db.query.text,
%compat_refresh_token.id,
%successor_compat_refresh_token.id,
compat_session.id = %compat_refresh_token.session_id,
),
err,
)]
async fn consume(
async fn consume_and_replace(
&mut self,
clock: &dyn Clock,
compat_refresh_token: CompatRefreshToken,
successor_compat_refresh_token: &CompatRefreshToken,
) -> Result<CompatRefreshToken, Self::Error> {
if compat_refresh_token.session_id != successor_compat_refresh_token.session_id {
return Err(DatabaseError::invalid_operation());
}
let consumed_at = clock.now();
let res = sqlx::query!(
r#"
@@ -206,9 +212,11 @@ impl CompatRefreshTokenRepository for PgCompatRefreshTokenRepository<'_> {
SET consumed_at = $2
WHERE compat_session_id = $1
AND consumed_at IS NULL
AND compat_refresh_token_id <> $3
"#,
Uuid::from(compat_refresh_token.session_id),
consumed_at,
Uuid::from(successor_compat_refresh_token.id),
)
.traced()
.execute(&mut *self.conn)
+18 -7
View File
@@ -69,16 +69,22 @@ pub trait CompatRefreshTokenRepository: Send + Sync {
token: String,
) -> Result<CompatRefreshToken, Self::Error>;
/// Consume a compat refresh token.
/// Consume the given compat refresh token, as well as all other refresh
/// tokens from the same session, except for the given successor compat
/// refresh token.
///
/// This also marks other refresh tokens in the same session as consumed.
/// The given successor refresh token will thereafter be the only valid
/// refresh token for the session.
///
/// # Historical context
///
/// When using a refresh token, we must be able to mark multiple other
/// refresh tokens in the same session as consumed.
/// This is desirable because the syn2mas migration process can import
/// multiple refresh tokens for one device (compat session).
/// But once the user uses one of those, the others should no longer
/// be valid.
///
/// Returns the consumed compat refresh token
///
/// # Parameters
///
/// * `clock`: The clock used to generate timestamps
@@ -86,11 +92,15 @@ pub trait CompatRefreshTokenRepository: Send + Sync {
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn consume(
/// - Returns [`Self::Error`] if the underlying repository fails
/// - Returns an error if `compat_refresh_token` is not valid to be
/// consumed.
/// - Returns an error if no refresh tokens would be consumed.
async fn consume_and_replace(
&mut self,
clock: &dyn Clock,
compat_refresh_token: CompatRefreshToken,
successor_compat_refresh_token: &CompatRefreshToken,
) -> Result<CompatRefreshToken, Self::Error>;
}
@@ -111,9 +121,10 @@ repository_impl!(CompatRefreshTokenRepository:
token: String,
) -> Result<CompatRefreshToken, Self::Error>;
async fn consume(
async fn consume_and_replace(
&mut self,
clock: &dyn Clock,
compat_refresh_token: CompatRefreshToken,
successor_compat_refresh_token: &CompatRefreshToken,
) -> Result<CompatRefreshToken, Self::Error>;
);
+88
View File
@@ -0,0 +1,88 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Miscellaneous cleanup tasks
use std::time::Duration;
use async_trait::async_trait;
use mas_storage::queue::{CleanupQueueJobsJob, PruneStalePolicyDataJob};
use tracing::{debug, info};
use ulid::Ulid;
use super::BATCH_SIZE;
use crate::{
State,
new_queue::{JobContext, JobError, RunnableJob},
};
#[async_trait]
impl RunnableJob for CleanupQueueJobsJob {
#[tracing::instrument(name = "job.cleanup_queue_jobs", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove completed and failed queue jobs after 30 days.
// Keep them for debugging purposes.
let until = state.clock.now() - chrono::Duration::days(30);
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, cursor) = repo
.queue_job()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no queue jobs to clean up");
} else {
info!(count = total, "cleaned up queue jobs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for PruneStalePolicyDataJob {
#[tracing::instrument(name = "job.prune_stale_policy_data", skip_all)]
async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// Keep the last 10 policy data
let count = repo
.policy_data()
.prune(10)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
if count == 0 {
debug!("no stale policy data to prune");
} else {
info!(count, "pruned stale policy data");
}
Ok(())
}
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Database cleanup tasks
//!
//! This module contains tasks for cleaning up old data from the database.
//! Tasks are grouped by domain:
//!
//! - [`tokens`]: OAuth token cleanup (access and refresh tokens)
//! - [`sessions`]: Session cleanup (compat, `OAuth2`, user sessions and their
//! IPs)
//! - [`oauth`]: OAuth grants and upstream OAuth cleanup
//! - [`user`]: User-related cleanup (registrations, recovery, email auth)
//! - [`misc`]: Miscellaneous cleanup (queue jobs, policy data)
mod misc;
mod oauth;
mod sessions;
mod tokens;
mod user;
pub(crate) const BATCH_SIZE: usize = 1000;
+216
View File
@@ -0,0 +1,216 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! OAuth grants and upstream OAuth cleanup tasks
use std::time::Duration;
use async_trait::async_trait;
use mas_storage::queue::{
CleanupOAuthAuthorizationGrantsJob, CleanupOAuthDeviceCodeGrantsJob,
CleanupUpstreamOAuthLinksJob, CleanupUpstreamOAuthSessionsJob,
};
use tracing::{debug, info};
use ulid::Ulid;
use super::BATCH_SIZE;
use crate::{
State,
new_queue::{JobContext, JobError, RunnableJob},
};
#[async_trait]
impl RunnableJob for CleanupOAuthAuthorizationGrantsJob {
#[tracing::instrument(name = "job.cleanup_oauth_authorization_grants", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove authorization grants after 7 days. They are in practice only
// valid for a short time, but keeping them around helps investigate abuse
// patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted grants, and the greatest ULID processed
let (count, cursor) = repo
.oauth2_authorization_grant()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no authorization grants to clean up");
} else {
info!(count = total, "cleaned up authorization grants");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupOAuthDeviceCodeGrantsJob {
#[tracing::instrument(name = "job.cleanup_oauth_device_code_grants", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove device code grants after 7 days. They are in practice only
// valid for a short time, but keeping them around helps investigate abuse
// patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted grants, and the greatest ULID processed
let (count, cursor) = repo
.oauth2_device_code_grant()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no device code grants to clean up");
} else {
info!(count = total, "cleaned up device code grants");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUpstreamOAuthSessionsJob {
#[tracing::instrument(name = "job.cleanup_upstream_oauth_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove pending upstream OAuth authorization sessions after 7 days.
let until = state.clock.now() - chrono::Duration::days(7);
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, cursor) = repo
.upstream_oauth_session()
.cleanup_orphaned(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no pending upstream OAuth sessions to clean up");
} else {
info!(count = total, "cleaned up pending upstream OAuth sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUpstreamOAuthLinksJob {
#[tracing::instrument(name = "job.cleanup_upstream_oauth_links", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove orphaned upstream OAuth links after 7 days.
let until = state.clock.now() - chrono::Duration::days(7);
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, cursor) = repo
.upstream_oauth_link()
.cleanup_orphaned(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no orphaned upstream OAuth links to clean up");
} else {
info!(count = total, "cleaned up orphaned upstream OAuth links");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
+290
View File
@@ -0,0 +1,290 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Session cleanup tasks
use std::time::Duration;
use async_trait::async_trait;
use mas_storage::queue::{
CleanupFinishedCompatSessionsJob, CleanupFinishedOAuth2SessionsJob,
CleanupFinishedUserSessionsJob, CleanupInactiveCompatSessionIpsJob,
CleanupInactiveOAuth2SessionIpsJob, CleanupInactiveUserSessionIpsJob,
};
use tracing::{debug, info};
use super::BATCH_SIZE;
use crate::{
State,
new_queue::{JobContext, JobError, RunnableJob},
};
#[async_trait]
impl RunnableJob for CleanupFinishedCompatSessionsJob {
#[tracing::instrument(name = "job.cleanup_finished_compat_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup compat sessions that were finished more than 30 days ago
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the last finished_at
// timestamp
let (count, last_finished_at) = repo
.compat_session()
.cleanup_finished(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_finished_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no finished compat sessions to clean up");
} else {
info!(count = total, "cleaned up finished compat sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupFinishedOAuth2SessionsJob {
#[tracing::instrument(name = "job.cleanup_finished_oauth2_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup OAuth2 sessions that were finished more than 30 days ago
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the last finished_at
// timestamp
let (count, last_finished_at) = repo
.oauth2_session()
.cleanup_finished(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_finished_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no finished OAuth2 sessions to clean up");
} else {
info!(count = total, "cleaned up finished OAuth2 sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupFinishedUserSessionsJob {
#[tracing::instrument(name = "job.cleanup_finished_user_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup user/browser sessions that were finished more than 30 days ago
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the last finished_at
// timestamp. Only deletes sessions that have no child sessions
// (compat_sessions or oauth2_sessions).
let (count, last_finished_at) = repo
.browser_session()
.cleanup_finished(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_finished_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no finished user sessions to clean up");
} else {
info!(count = total, "cleaned up finished user sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupInactiveOAuth2SessionIpsJob {
#[tracing::instrument(name = "job.cleanup_inactive_oauth2_session_ips", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Clear IPs from sessions inactive for 30+ days
let threshold = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, last_active_at) = repo
.oauth2_session()
.cleanup_inactive_ips(since, threshold, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_active_at;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no OAuth2 session IPs to clean up");
} else {
info!(count = total, "cleaned up inactive OAuth2 session IPs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupInactiveCompatSessionIpsJob {
#[tracing::instrument(name = "job.cleanup_inactive_compat_session_ips", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Clear IPs from sessions inactive for 30+ days
let threshold = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, last_active_at) = repo
.compat_session()
.cleanup_inactive_ips(since, threshold, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_active_at;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no compat session IPs to clean up");
} else {
info!(count = total, "cleaned up inactive compat session IPs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupInactiveUserSessionIpsJob {
#[tracing::instrument(name = "job.cleanup_inactive_user_session_ips", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Clear IPs from sessions inactive for 30+ days
let threshold = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, last_active_at) = repo
.browser_session()
.cleanup_inactive_ips(since, threshold, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_active_at;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user session IPs to clean up");
} else {
info!(count = total, "cleaned up inactive user session IPs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
+214
View File
@@ -0,0 +1,214 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! OAuth token cleanup tasks
use std::time::Duration;
use async_trait::async_trait;
use mas_storage::queue::{
CleanupConsumedOAuthRefreshTokensJob, CleanupExpiredOAuthAccessTokensJob,
CleanupRevokedOAuthAccessTokensJob, CleanupRevokedOAuthRefreshTokensJob,
};
use tracing::{debug, info};
use super::BATCH_SIZE;
use crate::{
State,
new_queue::{JobContext, JobError, RunnableJob},
};
#[async_trait]
impl RunnableJob for CleanupRevokedOAuthAccessTokensJob {
#[tracing::instrument(name = "job.cleanup_revoked_oauth_access_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that were revoked more than an hour ago
let until = state.clock.now() - chrono::Duration::hours(1);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last revoked_at timestamp
let (count, last_revoked_at) = repo
.oauth2_access_token()
.cleanup_revoked(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_revoked_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up revoked tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupExpiredOAuthAccessTokensJob {
#[tracing::instrument(name = "job.cleanup_expired_oauth_access_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that expired more than a month ago
// It is important to keep them around for a bit because of refresh
// token idempotency. When we see a refresh token twice, we allow
// reusing it *only* if both the next refresh token and the next access
// tokens were not used. By keeping expired access tokens around for a
// month, we cannot make the *correct* decision, we will assume that the
// token wasn't used. Refer to the token refresh logic for details.
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last expires_at timestamp
let (count, last_expires_at) = repo
.oauth2_access_token()
.cleanup_expired(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_expires_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up expired tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(60))
}
}
#[async_trait]
impl RunnableJob for CleanupRevokedOAuthRefreshTokensJob {
#[tracing::instrument(name = "job.cleanup_revoked_oauth_refresh_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that were revoked more than an hour ago
let until = state.clock.now() - chrono::Duration::hours(1);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last revoked_at timestamp
let (count, last_revoked_at) = repo
.oauth2_refresh_token()
.cleanup_revoked(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_revoked_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up revoked tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupConsumedOAuthRefreshTokensJob {
#[tracing::instrument(name = "job.cleanup_consumed_oauth_refresh_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that were consumed more than an hour ago
let until = state.clock.now() - chrono::Duration::hours(1);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last consumed_at timestamp
let (count, last_consumed_at) = repo
.oauth2_refresh_token()
.cleanup_consumed(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_consumed_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up consumed tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! User-related cleanup tasks
use std::time::Duration;
use async_trait::async_trait;
use mas_storage::queue::{
CleanupUserEmailAuthenticationsJob, CleanupUserRecoverySessionsJob, CleanupUserRegistrationsJob,
};
use tracing::{debug, info};
use ulid::Ulid;
use super::BATCH_SIZE;
use crate::{
State,
new_queue::{JobContext, JobError, RunnableJob},
};
#[async_trait]
impl RunnableJob for CleanupUserRegistrationsJob {
#[tracing::instrument(name = "job.cleanup_user_registrations", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove user registrations after 30 days. They are in practice only
// valid for 1h, but keeping them around helps investigate abuse patterns.
let until = state.clock.now() - chrono::Duration::days(30);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted registrations, and the greatest ULID
// processed
let (count, cursor) = repo
.user_registration()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user registrations to clean up");
} else {
info!(count = total, "cleaned up user registrations");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUserRecoverySessionsJob {
#[tracing::instrument(name = "job.cleanup_user_recovery_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove recovery sessions after 7 days. They are in practice only
// valid for a short time (tickets expire after 10 minutes), but keeping
// them around helps investigate abuse patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the greatest ULID processed
let (count, cursor) = repo
.user_recovery()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user recovery sessions to clean up");
} else {
info!(count = total, "cleaned up user recovery sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUserEmailAuthenticationsJob {
#[tracing::instrument(name = "job.cleanup_user_email_authentications", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove email authentications after 7 days. They are in practice only
// valid for a short time (codes expire after 10 minutes), but keeping
// them around helps investigate abuse patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted authentications, and the greatest ULID
// processed
let (count, cursor) = repo
.user_email()
.cleanup_authentications(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user email authentications to clean up");
} else {
info!(count = total, "cleaned up user email authentications");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
-916
View File
@@ -1,916 +0,0 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//! Database-related tasks
use std::time::Duration;
use async_trait::async_trait;
use mas_storage::queue::{
CleanupConsumedOAuthRefreshTokensJob, CleanupExpiredOAuthAccessTokensJob,
CleanupFinishedCompatSessionsJob, CleanupFinishedOAuth2SessionsJob,
CleanupFinishedUserSessionsJob, CleanupInactiveCompatSessionIpsJob,
CleanupInactiveOAuth2SessionIpsJob, CleanupInactiveUserSessionIpsJob,
CleanupOAuthAuthorizationGrantsJob, CleanupOAuthDeviceCodeGrantsJob, CleanupQueueJobsJob,
CleanupRevokedOAuthAccessTokensJob, CleanupRevokedOAuthRefreshTokensJob,
CleanupUpstreamOAuthLinksJob, CleanupUpstreamOAuthSessionsJob,
CleanupUserEmailAuthenticationsJob, CleanupUserRecoverySessionsJob,
CleanupUserRegistrationsJob, PruneStalePolicyDataJob,
};
use tracing::{debug, info};
use ulid::Ulid;
use crate::{
State,
new_queue::{JobContext, JobError, RunnableJob},
};
const BATCH_SIZE: usize = 1000;
#[async_trait]
impl RunnableJob for CleanupRevokedOAuthAccessTokensJob {
#[tracing::instrument(name = "job.cleanup_revoked_oauth_access_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that were revoked more than an hour ago
let until = state.clock.now() - chrono::Duration::hours(1);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last revoked_at timestamp
let (count, last_revoked_at) = repo
.oauth2_access_token()
.cleanup_revoked(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_revoked_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up revoked tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupExpiredOAuthAccessTokensJob {
#[tracing::instrument(name = "job.cleanup_expired_oauth_access_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that expired more than a month ago
// It is important to keep them around for a bit because of refresh
// token idempotency. When we see a refresh token twice, we allow
// reusing it *only* if both the next refresh token and the next access
// tokens were not used. By keeping expired access tokens around for a
// month, we cannot make the *correct* decision, we will assume that the
// token wasn't used. Refer to the token refresh logic for details.
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last expires_at timestamp
let (count, last_expires_at) = repo
.oauth2_access_token()
.cleanup_expired(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_expires_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up expired tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(60))
}
}
#[async_trait]
impl RunnableJob for CleanupRevokedOAuthRefreshTokensJob {
#[tracing::instrument(name = "job.cleanup_revoked_oauth_refresh_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that were revoked more than an hour ago
let until = state.clock.now() - chrono::Duration::hours(1);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last revoked_at timestamp
let (count, last_revoked_at) = repo
.oauth2_refresh_token()
.cleanup_revoked(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_revoked_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up revoked tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupConsumedOAuthRefreshTokensJob {
#[tracing::instrument(name = "job.cleanup_consumed_oauth_refresh_tokens", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup tokens that were consumed more than an hour ago
let until = state.clock.now() - chrono::Duration::hours(1);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted tokens, and the last consumed_at timestamp
let (count, last_consumed_at) = repo
.oauth2_refresh_token()
.cleanup_consumed(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_consumed_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no token to clean up");
} else {
info!(count = total, "cleaned up consumed tokens");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUserRecoverySessionsJob {
#[tracing::instrument(name = "job.cleanup_user_recovery_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove recovery sessions after 7 days. They are in practice only
// valid for a short time (tickets expire after 10 minutes), but keeping
// them around helps investigate abuse patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the greatest ULID processed
let (count, cursor) = repo
.user_recovery()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user recovery sessions to clean up");
} else {
info!(count = total, "cleaned up user recovery sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUserEmailAuthenticationsJob {
#[tracing::instrument(name = "job.cleanup_user_email_authentications", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove email authentications after 7 days. They are in practice only
// valid for a short time (codes expire after 10 minutes), but keeping
// them around helps investigate abuse patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted authentications, and the greatest ULID
// processed
let (count, cursor) = repo
.user_email()
.cleanup_authentications(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user email authentications to clean up");
} else {
info!(count = total, "cleaned up user email authentications");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUpstreamOAuthSessionsJob {
#[tracing::instrument(name = "job.cleanup_upstream_oauth_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove pending upstream OAuth authorization sessions after 7 days.
let until = state.clock.now() - chrono::Duration::days(7);
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, cursor) = repo
.upstream_oauth_session()
.cleanup_orphaned(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no pending upstream OAuth sessions to clean up");
} else {
info!(count = total, "cleaned up pending upstream OAuth sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUpstreamOAuthLinksJob {
#[tracing::instrument(name = "job.cleanup_upstream_oauth_links", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove orphaned upstream OAuth links after 7 days.
let until = state.clock.now() - chrono::Duration::days(7);
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, cursor) = repo
.upstream_oauth_link()
.cleanup_orphaned(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no orphaned upstream OAuth links to clean up");
} else {
info!(count = total, "cleaned up orphaned upstream OAuth links");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupQueueJobsJob {
#[tracing::instrument(name = "job.cleanup_queue_jobs", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove completed and failed queue jobs after 30 days.
// Keep them for debugging purposes.
let until = state.clock.now() - chrono::Duration::days(30);
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, cursor) = repo
.queue_job()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no queue jobs to clean up");
} else {
info!(count = total, "cleaned up queue jobs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupUserRegistrationsJob {
#[tracing::instrument(name = "job.cleanup_user_registrations", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove user registrations after 30 days. They are in practice only
// valid for 1h, but keeping them around helps investigate abuse patterns.
let until = state.clock.now() - chrono::Duration::days(30);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted registrations, and the greatest ULID
// processed
let (count, cursor) = repo
.user_registration()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user registrations to clean up");
} else {
info!(count = total, "cleaned up user registrations");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupFinishedCompatSessionsJob {
#[tracing::instrument(name = "job.cleanup_finished_compat_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup compat sessions that were finished more than 30 days ago
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the last finished_at
// timestamp
let (count, last_finished_at) = repo
.compat_session()
.cleanup_finished(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_finished_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no finished compat sessions to clean up");
} else {
info!(count = total, "cleaned up finished compat sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupFinishedOAuth2SessionsJob {
#[tracing::instrument(name = "job.cleanup_finished_oauth2_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup OAuth2 sessions that were finished more than 30 days ago
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the last finished_at
// timestamp
let (count, last_finished_at) = repo
.oauth2_session()
.cleanup_finished(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_finished_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no finished OAuth2 sessions to clean up");
} else {
info!(count = total, "cleaned up finished OAuth2 sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupFinishedUserSessionsJob {
#[tracing::instrument(name = "job.cleanup_finished_user_sessions", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Cleanup user/browser sessions that were finished more than 30 days ago
let until = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted sessions, and the last finished_at
// timestamp. Only deletes sessions that have no child sessions
// (compat_sessions or oauth2_sessions).
let (count, last_finished_at) = repo
.browser_session()
.cleanup_finished(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_finished_at;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no finished user sessions to clean up");
} else {
info!(count = total, "cleaned up finished user sessions");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupOAuthAuthorizationGrantsJob {
#[tracing::instrument(name = "job.cleanup_oauth_authorization_grants", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove authorization grants after 7 days. They are in practice only
// valid for a short time, but keeping them around helps investigate abuse
// patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted grants, and the greatest ULID processed
let (count, cursor) = repo
.oauth2_authorization_grant()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no authorization grants to clean up");
} else {
info!(count = total, "cleaned up authorization grants");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupOAuthDeviceCodeGrantsJob {
#[tracing::instrument(name = "job.cleanup_oauth_device_code_grants", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Remove device code grants after 7 days. They are in practice only
// valid for a short time, but keeping them around helps investigate abuse
// patterns.
let until = state.clock.now() - chrono::Duration::days(7);
// We use the fact that ULIDs include the creation time in their first 48 bits
// as a cursor
let until = Ulid::from_parts(
u64::try_from(until.timestamp_millis()).unwrap_or(u64::MIN),
u128::MAX,
);
let mut total = 0;
// Run until we get cancelled. We don't schedule a retry if we get cancelled, as
// this is a scheduled job and it will end up being rescheduled later anyway.
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// This returns the number of deleted grants, and the greatest ULID processed
let (count, cursor) = repo
.oauth2_device_code_grant()
.cleanup(since, until, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = cursor;
total += count;
// Check how many we deleted. If we deleted exactly BATCH_SIZE,
// there might be more to delete
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no device code grants to clean up");
} else {
info!(count = total, "cleaned up device code grants");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
// This job runs every hour, so having it running it for 10 minutes is fine
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for PruneStalePolicyDataJob {
#[tracing::instrument(name = "job.prune_stale_policy_data", skip_all)]
async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> {
let mut repo = state.repository().await.map_err(JobError::retry)?;
// Keep the last 10 policy data
let count = repo
.policy_data()
.prune(10)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
if count == 0 {
debug!("no stale policy data to prune");
} else {
info!(count, "pruned stale policy data");
}
Ok(())
}
}
#[async_trait]
impl RunnableJob for CleanupInactiveOAuth2SessionIpsJob {
#[tracing::instrument(name = "job.cleanup_inactive_oauth2_session_ips", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Clear IPs from sessions inactive for 30+ days
let threshold = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, last_active_at) = repo
.oauth2_session()
.cleanup_inactive_ips(since, threshold, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_active_at;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no OAuth2 session IPs to clean up");
} else {
info!(count = total, "cleaned up inactive OAuth2 session IPs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupInactiveCompatSessionIpsJob {
#[tracing::instrument(name = "job.cleanup_inactive_compat_session_ips", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Clear IPs from sessions inactive for 30+ days
let threshold = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, last_active_at) = repo
.compat_session()
.cleanup_inactive_ips(since, threshold, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_active_at;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no compat session IPs to clean up");
} else {
info!(count = total, "cleaned up inactive compat session IPs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
#[async_trait]
impl RunnableJob for CleanupInactiveUserSessionIpsJob {
#[tracing::instrument(name = "job.cleanup_inactive_user_session_ips", skip_all)]
async fn run(&self, state: &State, context: JobContext) -> Result<(), JobError> {
// Clear IPs from sessions inactive for 30+ days
let threshold = state.clock.now() - chrono::Duration::days(30);
let mut total = 0;
let mut since = None;
while !context.cancellation_token.is_cancelled() {
let mut repo = state.repository().await.map_err(JobError::retry)?;
let (count, last_active_at) = repo
.browser_session()
.cleanup_inactive_ips(since, threshold, BATCH_SIZE)
.await
.map_err(JobError::retry)?;
repo.save().await.map_err(JobError::retry)?;
since = last_active_at;
total += count;
if count != BATCH_SIZE {
break;
}
}
if total == 0 {
debug!("no user session IPs to clean up");
} else {
info!(count = total, "cleaned up inactive user session IPs");
}
Ok(())
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_secs(10 * 60))
}
}
+64 -61
View File
@@ -21,7 +21,7 @@ use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub use crate::new_queue::QueueWorker;
mod database;
mod cleanup;
mod email;
mod matrix;
mod new_queue;
@@ -162,125 +162,128 @@ pub async fn init(
.register_handler::<mas_storage::queue::CleanupInactiveCompatSessionIpsJob>()
.register_handler::<mas_storage::queue::CleanupInactiveUserSessionIpsJob>()
.register_deprecated_queue("cleanup-expired-tokens")
// Recurring jobs are spread across the hour at ~5 minute intervals
// to avoid clustering and distribute database load evenly.
.add_schedule(
"cleanup-revoked-oauth-access-tokens",
// Run this job every hour
// Run this job every hour at minute 0
"0 0 * * * *".parse()?,
mas_storage::queue::CleanupRevokedOAuthAccessTokensJob,
)
.add_schedule(
"cleanup-revoked-oauth-refresh-tokens",
// Run this job every hour
"0 10 * * * *".parse()?,
// Run this job every hour at minute 5
"0 5 * * * *".parse()?,
mas_storage::queue::CleanupRevokedOAuthRefreshTokensJob,
)
.add_schedule(
"cleanup-consumed-oauth-refresh-tokens",
// Run this job every hour
"0 20 * * * *".parse()?,
// Run this job every hour at minute 5 (safe to parallelize with revoked)
"0 5 * * * *".parse()?,
mas_storage::queue::CleanupConsumedOAuthRefreshTokensJob,
)
.add_schedule(
"cleanup-user-registrations",
// Run this job every hour
"0 30 * * * *".parse()?,
mas_storage::queue::CleanupUserRegistrationsJob,
)
.add_schedule(
"cleanup-finished-compat-sessions",
// Run this job every hour
"0 40 * * * *".parse()?,
// Run this job every hour at minute 10
"0 10 * * * *".parse()?,
mas_storage::queue::CleanupFinishedCompatSessionsJob,
)
.add_schedule(
"cleanup-finished-oauth2-sessions",
// Run this job every hour
"0 42 * * * *".parse()?,
// Run this job every hour at minute 15
"0 15 * * * *".parse()?,
mas_storage::queue::CleanupFinishedOAuth2SessionsJob,
)
.add_schedule(
"cleanup-finished-user-sessions",
// Run this job every hour
"0 44 * * * *".parse()?,
// Run this job every hour at minute 20
"0 20 * * * *".parse()?,
mas_storage::queue::CleanupFinishedUserSessionsJob,
)
.add_schedule(
"cleanup-inactive-oauth2-session-ips",
// Run this job every hour at minute 25
"0 25 * * * *".parse()?,
mas_storage::queue::CleanupInactiveOAuth2SessionIpsJob,
)
.add_schedule(
"cleanup-inactive-compat-session-ips",
// Run this job every hour at minute 25
"0 25 * * * *".parse()?,
mas_storage::queue::CleanupInactiveCompatSessionIpsJob,
)
.add_schedule(
"cleanup-inactive-user-session-ips",
// Run this job every hour at minute 25
"0 25 * * * *".parse()?,
mas_storage::queue::CleanupInactiveUserSessionIpsJob,
)
.add_schedule(
"cleanup-oauth-authorization-grants",
// Run this job every hour
"0 50 * * * *".parse()?,
// Run this job every hour at minute 30
"0 30 * * * *".parse()?,
mas_storage::queue::CleanupOAuthAuthorizationGrantsJob,
)
.add_schedule(
"cleanup-oauth-device-code-grants",
// Run this job every hour
"0 55 * * * *".parse()?,
// Run this job every hour at minute 35
"0 35 * * * *".parse()?,
mas_storage::queue::CleanupOAuthDeviceCodeGrantsJob,
)
.add_schedule(
"cleanup-user-recovery-sessions",
// Run this job every hour
"0 56 * * * *".parse()?,
mas_storage::queue::CleanupUserRecoverySessionsJob,
)
.add_schedule(
"cleanup-user-email-authentications",
// Run this job every hour
"0 57 * * * *".parse()?,
mas_storage::queue::CleanupUserEmailAuthenticationsJob,
)
.add_schedule(
"cleanup-upstream-oauth-sessions",
// Run this job every hour
"0 58 * * * *".parse()?,
// Run this job every hour at minute 40 (independent, safe to parallelize)
"0 40 * * * *".parse()?,
mas_storage::queue::CleanupUpstreamOAuthSessionsJob,
)
.add_schedule(
"cleanup-upstream-oauth-links",
// Run this job every hour
"0 59 * * * *".parse()?,
// Run this job every hour at minute 40
"0 40 * * * *".parse()?,
mas_storage::queue::CleanupUpstreamOAuthLinksJob,
)
// User cleanup jobs (minutes 45, 50)
.add_schedule(
"cleanup-user-registrations",
// Run this job every hour at minute 45
"0 45 * * * *".parse()?,
mas_storage::queue::CleanupUserRegistrationsJob,
)
.add_schedule(
"cleanup-user-recovery-sessions",
// Run this job every hour at minute 50
"0 50 * * * *".parse()?,
mas_storage::queue::CleanupUserRecoverySessionsJob,
)
.add_schedule(
"cleanup-user-email-authentications",
// Run this job every hour at minute 50
"0 50 * * * *".parse()?,
mas_storage::queue::CleanupUserEmailAuthenticationsJob,
)
.add_schedule(
"cleanup-queue-jobs",
// Run this job every hour
"0 45 * * * *".parse()?,
// Run this job every hour at minute 55
"0 55 * * * *".parse()?,
mas_storage::queue::CleanupQueueJobsJob,
)
.add_schedule(
"cleanup-expired-oauth-access-tokens",
// Run this job every 4 hours
// Run this job every 4 hours at minute 5
"0 5 */4 * * *".parse()?,
mas_storage::queue::CleanupExpiredOAuthAccessTokensJob,
)
.add_schedule(
"expire-inactive-sessions",
// Run this job every 15 minutes
// Run this job every 15 minutes at second 30
"30 */15 * * * *".parse()?,
mas_storage::queue::ExpireInactiveSessionsJob,
)
.add_schedule(
"prune-stale-policy-data",
// Run once a day
// Run once a day at 2:00 AM
"0 0 2 * * *".parse()?,
mas_storage::queue::PruneStalePolicyDataJob,
)
.add_schedule(
"cleanup-inactive-oauth2-session-ips",
// Run this job every hour
"0 46 * * * *".parse()?,
mas_storage::queue::CleanupInactiveOAuth2SessionIpsJob,
)
.add_schedule(
"cleanup-inactive-compat-session-ips",
// Run this job every hour
"0 47 * * * *".parse()?,
mas_storage::queue::CleanupInactiveCompatSessionIpsJob,
)
.add_schedule(
"cleanup-inactive-user-session-ips",
// Run this job every hour
"0 48 * * * *".parse()?,
mas_storage::queue::CleanupInactiveUserSessionIpsJob,
);
Ok(worker)
+6 -2
View File
@@ -60,8 +60,12 @@ impl RunnableJob for ProvisionUserJob {
.into_iter()
.map(|email| email.email)
.collect();
let mut request =
ProvisionRequest::new(user.username.clone(), user.sub.clone()).set_emails(emails);
let mut request = ProvisionRequest::new(
user.username.clone(),
user.sub.clone(),
user.locked_at.is_some(),
)
.set_emails(emails);
if let Some(display_name) = self.display_name_to_set() {
request = request.set_displayname(display_name.to_owned());
+3 -8
View File
@@ -1,3 +1,4 @@
# Copyright 2025, 2026 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -33,19 +34,11 @@ allow = [
"ISC",
"MIT",
"MPL-2.0",
"OpenSSL",
"Unicode-3.0",
"Zlib",
"CDLA-Permissive-2.0",
]
# Ring's license is a bit complicated, so we need to specify it manually
[[licenses.clarify]]
name = "ring"
version = "*"
expression = "MIT AND ISC AND OpenSSL"
license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }]
[bans]
# List of crates to deny
deny = [
@@ -71,6 +64,8 @@ skip = [
# cron depends on this old version
# https://github.com/zslayton/cron/pull/137
{ name = "winnow", version = "0.6.20" },
# hyper-util -> system-configuration depends on this old version
{ name = "core-foundation", version = "0.9.4" },
# We are still mainly using rand 0.8
{ name = "rand", version = "0.8.5" },
+16 -2
View File
@@ -11,9 +11,23 @@ Cleanup jobs are scheduled tasks that hard-delete old data from the database. Th
1. **Job struct** in `crates/storage/src/queue/tasks.rs` - Defines the job and queue name
2. **Storage trait** in `crates/storage/src/{domain}/` - Declares the cleanup method interface
3. **PostgreSQL implementation** in `crates/storage-pg/src/{domain}/` - Implements the actual cleanup logic
4. **Job runner** in `crates/tasks/src/database.rs` - Implements the `RunnableJob` trait with batching logic
4. **Job runner** in `crates/tasks/src/cleanup/` - Implements the `RunnableJob` trait with batching logic
5. **Registration** in `crates/tasks/src/lib.rs` - Registers the handler and schedules execution
### Module Structure
The cleanup job implementations are organized into submodules by domain:
```
crates/tasks/src/cleanup/
├── mod.rs # Re-exports, shared BATCH_SIZE constant
├── tokens.rs # OAuth token cleanup (access and refresh tokens)
├── sessions.rs # Session cleanup (compat, OAuth2, user sessions and their IPs)
├── oauth.rs # OAuth grants and upstream OAuth cleanup
├── user.rs # User-related cleanup (registrations, recovery, email auth)
└── misc.rs # Queue jobs, policy data cleanup
```
## All Cleanup Jobs
| Job | Entity | Retention | Notes |
@@ -183,7 +197,7 @@ The partial index (`WHERE timestamp_col IS NOT NULL`) makes queries more efficie
### 5. Implement RunnableJob
In `crates/tasks/src/database.rs`:
In the appropriate submodule under `crates/tasks/src/cleanup/` (e.g., `tokens.rs`, `sessions.rs`, `oauth.rs`, `user.rs`, or `misc.rs`):
```rust
#[async_trait]
+32
View File
@@ -408,6 +408,38 @@ upstream_oauth2:
```
### Kanidm
Install and configure a Kanidm instance using the [Official Docs](https://kanidm.github.io/kanidm/stable/introduction_to_kanidm.html)
Create a OAuth2 Client following the [OAuth2 Setup](https://kanidm.github.io/kanidm/stable/integrations/oauth2.html) guide
> ⚠️ **Important**
> Ensure you configure the client to prefer short usernames. This means instead of `user@domain.tld` it will return just `user` as the preferred username
> You can configure this by running `kanidm system oauth2 prefer-short-username <client_id>`.
```yaml
upstream_oauth2:
providers:
- id: "[ulid]" # randomly generated ulid (https://www.ulidtools.com/)
id_token_signed_response_alg: ES256 # This is important since Kanidm doesn't support RS256 by default
issuer: "https://<kanidm_instance>/oauth2/openid/<client_id>" # TO BE FILLED
token_endpoint_auth_method: client_secret_basic
client_id: "<client-id>" # TO BE FILLED
client_secret: "<client-secret>" # TO BE FILLED
scope: "openid profile email" # Add any additional scopes
claims_imports:
localpart:
action: require
template: "{{ user.preferred_username }}"
displayname:
action: suggest
template: "{{ user.name }}"
email:
action: suggest
template: "{{ user.email }}"
```
### Keycloak
+66 -33
View File
@@ -27,7 +27,7 @@ export type LocalazyMetadata = {
};
const localazyMetadata: LocalazyMetadata = {
projectUrl: "https://localazy.com/p/matrix-authentication-service!v1.10",
projectUrl: "https://localazy.com/p/matrix-authentication-service",
baseLocale: "en",
languages: [
{
@@ -138,6 +138,15 @@ const localazyMetadata: LocalazyMetadata = {
localizedName: "Português",
pluralType: (n) => { return (n>=0 && n<=1) ? "one" : "other"; }
},
{
language: "pt",
region: "BR",
script: "",
isRtl: false,
name: "Brazilian Portuguese",
localizedName: "Português (Brasil)",
pluralType: (n) => { return (n>=0 && n<=1) ? "one" : "other"; }
},
{
language: "ru",
region: "",
@@ -147,6 +156,15 @@ const localazyMetadata: LocalazyMetadata = {
localizedName: "Русский",
pluralType: (n) => { return ((n%10===1) && (n%100!==11)) ? "one" : ((n%10>=2 && n%10<=4) && ((n%100<12 || n%100>14))) ? "few" : "many"; }
},
{
language: "sk",
region: "",
script: "",
isRtl: false,
name: "Slovak",
localizedName: "Slovenčina",
pluralType: (n) => { return (n===1) ? "one" : (n>=2 && n<=4) ? "few" : "other"; }
},
{
language: "sv",
region: "",
@@ -165,6 +183,15 @@ const localazyMetadata: LocalazyMetadata = {
localizedName: "Українська",
pluralType: (n) => { return ((n%10===1) && (n%100!==11)) ? "one" : ((n%10>=2 && n%10<=4) && ((n%100<12 || n%100>14))) ? "few" : "many"; }
},
{
language: "uz",
region: "",
script: "",
isRtl: false,
name: "Uzbek",
localizedName: "O‘zbek",
pluralType: (n) => { return (n===1) ? "one" : "other"; }
},
{
language: "zh",
region: "",
@@ -181,22 +208,25 @@ const localazyMetadata: LocalazyMetadata = {
file: "frontend.json",
path: "",
cdnFiles: {
"cs": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json",
"da": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json",
"de": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json",
"en": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json",
"et": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json",
"fi": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json",
"fr": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json",
"hu": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json",
"nb_NO": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json",
"nl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json",
"pt": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"ru": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sv": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json",
"zh#Hans": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json"
"cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json",
"da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json",
"de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json",
"en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json",
"et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json",
"fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json",
"fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json",
"hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json",
"nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json",
"nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pl/frontend.json",
"pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt-BR/frontend.json",
"ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sk/frontend.json",
"sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json",
"uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uz/frontend.json",
"zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json"
}
},
{
@@ -204,22 +234,25 @@ const localazyMetadata: LocalazyMetadata = {
file: "file.json",
path: "",
cdnFiles: {
"cs": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json",
"da": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json",
"de": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json",
"en": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json",
"et": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json",
"fi": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json",
"fr": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json",
"hu": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json",
"nb_NO": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json",
"nl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pl": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json",
"pt": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"ru": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sv": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json",
"zh#Hans": "https://delivery.localazy.com/_a6657523900524156441ad8bd4f1/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json"
"cs": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json",
"da": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json",
"de": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json",
"en": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json",
"et": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json",
"fi": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json",
"fr": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json",
"hu": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json",
"nb_NO": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json",
"nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pl/file.json",
"pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"pt_BR": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt-BR/file.json",
"ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sk/file.json",
"sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json",
"uz": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uz/file.json",
"zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json"
}
}
]
@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.4'
const PACKAGE_VERSION = '2.12.8'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
+5 -5
View File
@@ -257,8 +257,8 @@
"button": "Identiteit resetten",
"cancelled": {
"description_1": "You can close this window and go back to the app to continue.",
"description_2": "If you're signed out everywhere and don't remember your recovery code, you'll still need to reset your identity.",
"heading": "Identity reset cancelled."
"description_2": "If don’t have access to any other verified devices and you don’t have your recovery key, then you’ll need to reset your digital identity to continue using the app.",
"heading": "Digital identity reset cancelled."
},
"description": "Als u niet bent aangemeld bij andere apparaten en u bent uw herstelsleutel kwijt, moet u uw identiteit opnieuw instellen om de app te kunnen blijven gebruiken.",
"effect_list": {
@@ -270,7 +270,7 @@
},
"failure": {
"description": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"heading": "Failed to allow crypto identity reset",
"heading": "Failed to allow digital identity reset",
"title": "Kan crypto identiteit niet toestaan"
},
"finish_reset": "Finish reset",
@@ -278,10 +278,10 @@
"start_reset": "Start reset",
"success": {
"description": "De identiteits reset is goedgekeurd voor de volgende {{minutes}} minuten. U kunt dit venster sluiten en teruggaan naar de app om door te gaan.",
"heading": "Identity reset successfully. Go back to the app to finish the process.",
"heading": "Digital identity reset successfully. Go back to the app to finish the process.",
"title": "Het opnieuw instellen van de crypto identiteit is tijdelijk toegestaan"
},
"warning": "Only reset your identity if you don't have access to another signed-in device and you've lost your recovery key."
"warning": "Only reset your digital identity if you don't have access to another verified device and you don't have your recovery key."
},
"selectable_session": {
"label": "Sessie selecteren"
+401
View File
@@ -0,0 +1,401 @@
{
"action": {
"back": "Voltar",
"cancel": "Cancelar",
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"confirm": "Confirm",
"continue": "Continuar",
"edit": "Edit",
"expand": "Expand",
"save": "Salvar",
"save_and_continue": "Save and continue",
"sign_out": "Sair",
"start_over": "Start over"
},
"branding": {
"privacy_policy": {
"alt": "Link to the service privacy policy",
"link": "Privacy Policy"
},
"terms_and_conditions": {
"alt": "Link to the service terms and conditions",
"link": "Terms & Conditions"
}
},
"common": {
"add": "Adicionar",
"e2ee": "End-to-end encryption",
"error": "Erro",
"loading": "Carregando…",
"next": "Próximo",
"password": "Senha",
"previous": "Anterior",
"saved": "Saved",
"saving": "Saving…"
},
"frontend": {
"account": {
"account_password": "Account password",
"contact_info": "Contact info",
"delete_account": {
"alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.",
"alert_title": "You’re about to lose all of your data",
"button": "Delete account",
"dialog_description": "<text>Confirm that you would like to delete your account:</text>\n<profile />\n<list>\n<item>You will not be able to reactivate your account</item>\n<item>You will no longer be able to sign in</item>\n<item>No one will be able to reuse your username (MXID), including you</item>\n<item>You will leave all rooms and direct messages you are in</item>\n<item>You will be removed from the identity server, and no one will be able to find you with your email or phone number</item>\n</list>\n<text>Your old messages will still be visible to people who received them. Would you like to hide your sent messages from people who join rooms in the future?</text>",
"dialog_title": "Delete this account?",
"erase_checkbox_label": "Yes, hide all my messages from new joiners",
"incorrect_password": "Incorrect password, please try again",
"mxid_label": "Confirm your Matrix ID ({{ mxid }})",
"mxid_mismatch": "This value does not match your Matrix ID",
"password_label": "Enter your password to continue"
},
"edit_profile": {
"display_name_help": "This is what others will see wherever you’re signed in.",
"display_name_label": "Display name",
"title": "Edit profile",
"username_label": "Nome de usuário"
},
"password": {
"change": "Alterar a senha",
"change_disabled": "Password changes are disabled by the administrator.",
"label": "Senha"
},
"sign_out": {
"button": "Sign out of account",
"dialog": "Sign out of this account?"
},
"title": "Your account"
},
"add_email_form": {
"email_denied_alert": {
"text": "O endereço de e-mail inserido não é permitido pela política do servidor.",
"title": "Email denied by policy"
},
"email_denied_error": "The entered email is not allowed by the server policy",
"email_exists_alert": {
"text": "O endereço de e-mail inserido já foi adicionado a esta conta.",
"title": "Email already exists"
},
"email_exists_error": "O endereço de e-mail inserido já foi adicionado a esta conta.",
"email_field_help": "Add an alternative email you can use to access this account.",
"email_field_label": "Adicionar e-mail",
"email_in_use_error": "The entered email is already in use",
"email_invalid_alert": {
"text": "O endereço de e-mail inserido é inválido.",
"title": "E-mail inválido"
},
"email_invalid_error": "O endereço de e-mail inserido é inválido.",
"incorrect_password_error": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to add this email address"
},
"app_sessions_list": {
"error": "Failed to load app sessions",
"heading": "Apps"
},
"browser_session_details": {
"current_badge": "Current",
"session_details_title": "Session"
},
"browser_sessions_overview": {
"body:one": "{{count}} active session",
"body:other": "{{count}} active sessions",
"heading": "Browsers",
"no_active_sessions": {
"default": "You are not signed in to any web browsers.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
},
"view_all_button": "View all"
},
"compat_session_detail": {
"client_details_title": "Client info",
"name": "Nome",
"session_details_title": "Session"
},
"device_type_icon_label": {
"desktop": "Desktop",
"mobile": "Mobile",
"pc": "Computer",
"tablet": "Tablet",
"unknown": "Unknown device type",
"web": "Web"
},
"email_in_use": {
"heading": "The email address {{email}} is already in use."
},
"end_session_button": {
"confirmation_modal_title": "Are you sure you want to end this session?",
"text": "Remove device"
},
"error": {
"hideDetails": "Hide details",
"showDetails": "Show details",
"subtitle": "An unexpected error occurred. Please try again.",
"title": "Something went wrong"
},
"error_boundary_title": "Something went wrong",
"errors": {
"field_required": "Este campo é obrigatório",
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again."
},
"last_active": {
"active_date": "Active {{relativeDate}}",
"active_now": "Active now",
"inactive_90_days": "Inactive for 90+ days"
},
"nav": {
"devices": "Devices",
"plan": "Plan",
"profile": "Perfil",
"sessions": "Sessões",
"settings": "Settings"
},
"not_found_alert_title": "Not found.",
"not_logged_in_alert": "You're not logged in.",
"oauth2_client_detail": {
"details_title": "Client info",
"id": "Client ID",
"name": "Nome",
"policy": "Policy",
"terms": "Terms of service"
},
"oauth2_session_detail": {
"client_details_name": "Nome",
"client_title": "Client info",
"session_details_title": "Session"
},
"pagination_controls": {
"total": "Total: {{totalCount}}"
},
"password_change": {
"current_password_label": "Senha atual",
"failure": {
"description": {
"account_locked": "Your account is locked and can not be recovered at this time. If this is not expected, please contact your server administrator.",
"expired_recovery_ticket": "The recovery link has expired. Please start the account recovery process again from the start.",
"invalid_new_password": "The new password you chose is invalid; it may not meet the configured security policy.",
"no_current_password": "You don't have a current password.",
"no_such_recovery_ticket": "The recovery link is invalid. If you copied the link from the recovery e-mail, please check the full link was copied.",
"password_changes_disabled": "Password changes are disabled.",
"recovery_ticket_already_used": "The recovery link has already been used. It cannot be used again.",
"unspecified": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"wrong_password": "The password you supplied as your current password is incorrect. Please try again."
},
"title": "Failed to update password"
},
"new_password_again_label": "Enter new password again",
"new_password_label": "Nova senha",
"passwords_match": "Passwords match!",
"passwords_no_match": "Passwords don't match",
"subtitle": "Choose a new password for your account.",
"success": {
"description": "Your password has been updated successfully.",
"title": "Password updated"
},
"title": "Change your password"
},
"password_reset": {
"consumed": {
"subtitle": "To create a new password, start over and select ”Forgot password“.",
"title": "The link to reset your password has already been used"
},
"expired": {
"resend_email": "Resend email",
"subtitle": "Request a new email that will be sent to: {{email}}",
"title": "The link to reset your password has expired"
},
"subtitle": "Choose a new password for your account.",
"title": "Reset your password"
},
"password_strength": {
"placeholder": "Password strength",
"score": {
"0": "Extremely weak password",
"1": "Very weak password",
"2": "Weak password",
"3": "Strong password",
"4": "Very strong password"
},
"suggestion": {
"all_uppercase": "Capitalise some, but not all letters.",
"another_word": "Add more words that are less common.",
"associated_years": "Avoid years that are associated with you.",
"capitalization": "Capitalise more than the first letter.",
"dates": "Avoid dates and years that are associated with you.",
"l33t": "Avoid predictable letter substitutions like '@' for 'a'.",
"longer_keyboard_pattern": "Use longer keyboard patterns and change typing direction multiple times.",
"no_need": "You can create strong passwords without using symbols, numbers, or uppercase letters.",
"pwned": "If you use this password elsewhere, you should change it.",
"recent_years": "Avoid recent years.",
"repeated": "Avoid repeated words and characters.",
"reverse_words": "Avoid reversed spellings of common words.",
"sequences": "Avoid common character sequences.",
"use_words": "Use multiple words, but avoid common phrases."
},
"too_weak": "This password is too weak",
"warning": {
"common": "This is a commonly used password.",
"common_names": "Common names and surnames are easy to guess.",
"dates": "Dates are easy to guess.",
"extended_repeat": "Repeated character patterns like \"abcabcabc\" are easy to guess.",
"key_pattern": "Short keyboard patterns are easy to guess.",
"names_by_themselves": "Single names or surnames are easy to guess.",
"pwned": "Your password was exposed by a data breach on the Internet.",
"recent_years": "Recent years are easy to guess.",
"sequences": "Common character sequences like \"abc\" are easy to guess.",
"similar_to_common": "This is similar to a commonly used password.",
"simple_repeat": "Repeated characters like \"aaa\" are easy to guess.",
"straight_row": "Straight rows of keys on your keyboard are easy to guess.",
"top_hundred": "This is a frequently used password.",
"top_ten": "This is a heavily used password.",
"user_inputs": "There should not be any personal or page related data.",
"word_by_itself": "Single words are easy to guess."
}
},
"reset_cross_signing": {
"button": "Reset identity",
"cancelled": {
"description_1": "You can close this window and go back to the app to continue.",
"description_2": "If don’t have access to any other verified devices and you don’t have your recovery key, then you’ll need to reset your digital identity to continue using the app.",
"heading": "Digital identity reset cancelled."
},
"description": "If don’t have access to any other verified devices and you don’t have your recovery key, then you’ll need to reset your digital identity to continue using the app.",
"effect_list": {
"negative_1": "You will lose your existing message history",
"negative_2": "You will need to verify all your existing devices and contacts again",
"neutral_1": "You will lose any message history that's stored only on the server",
"neutral_2": "You will need to verify all your existing devices and contacts again",
"positive_1": "Your account details, contacts, preferences, and chat list will be kept"
},
"failure": {
"description": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"heading": "Failed to allow digital identity reset",
"title": "Failed to allow crypto identity"
},
"finish_reset": "Finish reset",
"heading": "Reset your digital identity in case you can't confirm another way",
"start_reset": "Start reset",
"success": {
"description": "The digital identity reset has been approved for the next {{minutes}} minutes. You can close this window and go back to the app to continue.",
"heading": "Digital identity reset successfully. Go back to the app to finish the process.",
"title": "Crypto identity reset temporarily allowed"
},
"warning": "Only reset your digital identity if you don't have access to another verified device and you don't have your recovery key."
},
"selectable_session": {
"label": "Select session"
},
"session": {
"client_id_label": "Client ID",
"current": "Current",
"current_badge": "Current",
"device_id_label": "Device ID",
"finished_date": "Finished <datetime/>",
"finished_label": "Finished",
"generic_browser_session": "Browser session",
"id_label": "ID",
"ip_label": "IP Address",
"last_active_label": "Last Active",
"last_auth_label": "Last Authentication",
"name_for_platform": "{{name}} for {{platform}}",
"scopes_label": "Scopes",
"set_device_name": {
"help": "Set a name that will help you identify this device.",
"label": "Device name",
"title": "Edit device name"
},
"signed_in_date": "Signed in <datetime/>",
"signed_in_label": "Signed in",
"title": "Device details",
"unknown_browser": "Unknown browser",
"unknown_device": "Unknown device",
"uri_label": "Uri",
"user_id_label": "User ID",
"username_label": "User name"
},
"session_detail": {
"alert": {
"button": "Voltar",
"text": "This session does not exist, or is no longer active.",
"title": "Cannot find session: {{deviceId}}"
}
},
"unknown_route": "Unknown route {{route}}",
"unverified_email_alert": {
"button": "Review and verify",
"text:one": "You have {{count}} unverified email address.",
"text:other": "You have {{count}} unverified email addresses.",
"title": "Unverified email"
},
"user_email": {
"cant_delete_primary": "Choose a different primary email to delete this one.",
"delete_button_confirmation_modal": {
"action": "Delete email",
"body": "Delete this email?",
"incorrect_password": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to delete this email address"
},
"delete_button_title": "Remove email address",
"email": "E-mail",
"make_primary_button": "Make primary",
"not_verified": "Not verified",
"primary_email": "Primary email",
"retry_button": "Resend code",
"unverified": "Não verificado"
},
"user_email_list": {
"heading": "Emails",
"no_primary_email_alert": "No primary email address"
},
"user_greeting": {
"error": "Failed to load user"
},
"user_name": {
"display_name_field_label": "Nome e sobrenome"
},
"user_sessions_overview": {
"active_sessions:one": "{{count}} active session",
"active_sessions:other": "{{count}} active sessions",
"heading": "Where you're signed in",
"no_active_sessions": {
"default": "You are not signed in to any application.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
}
},
"verify_email": {
"code_expired_alert": {
"description": "The code has expired. Please request a new code.",
"title": "Code expired"
},
"code_field_error": "Code not recognised",
"code_field_label": "6-digit code",
"code_field_wrong_shape": "Code must be 6 digits",
"email_sent_alert": {
"description": "Enter the new code below.",
"title": "New code sent"
},
"enter_code_prompt": "Enter the 6-digit code sent to: <email>{{email}}</email>",
"heading": "Verifique seu e-mail",
"invalid_code_alert": {
"description": "Check the code sent to your email and update the fields below to continue.",
"title": "You entered the wrong code"
},
"resend_code": "Resend code",
"resend_email": "Resend email",
"sent": "Sent!",
"unknown_email": "Unknown email"
}
},
"mas": {
"scope": {
"edit_profile": "Edite seu perfil e seus dados de contato.",
"manage_sessions": "Gerencie seus dispositivos e sessões",
"mas_admin": "Manage users (urn:mas:admin)",
"send_messages": "Send new messages on your behalf",
"synapse_admin": "Administer the server (urn:synapse:admin:*)",
"view_messages": "View your existing messages and data",
"view_profile": "Veja as informações do seu perfil e os seus dados de contato."
}
}
}
+401
View File
@@ -0,0 +1,401 @@
{
"action": {
"back": "Naspäť",
"cancel": "Zrušiť",
"clear": "Clear",
"close": "Close",
"collapse": "Collapse",
"confirm": "Confirm",
"continue": "Pokračovať",
"edit": "Edit",
"expand": "Expand",
"save": "Uložiť",
"save_and_continue": "Save and continue",
"sign_out": "Odhlásiť sa",
"start_over": "Start over"
},
"branding": {
"privacy_policy": {
"alt": "Link to the service privacy policy",
"link": "Privacy Policy"
},
"terms_and_conditions": {
"alt": "Link to the service terms and conditions",
"link": "Terms & Conditions"
}
},
"common": {
"add": "Pridať",
"e2ee": "End-to-end encryption",
"error": "Chyba",
"loading": "Načítavanie…",
"next": "Ďalej",
"password": "Heslo",
"previous": "Previous",
"saved": "Saved",
"saving": "Saving…"
},
"frontend": {
"account": {
"account_password": "Account password",
"contact_info": "Contact info",
"delete_account": {
"alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.",
"alert_title": "You’re about to lose all of your data",
"button": "Delete account",
"dialog_description": "<text>Confirm that you would like to delete your account:</text>\n<profile />\n<list>\n<item>You will not be able to reactivate your account</item>\n<item>You will no longer be able to sign in</item>\n<item>No one will be able to reuse your username (MXID), including you</item>\n<item>You will leave all rooms and direct messages you are in</item>\n<item>You will be removed from the identity server, and no one will be able to find you with your email or phone number</item>\n</list>\n<text>Your old messages will still be visible to people who received them. Would you like to hide your sent messages from people who join rooms in the future?</text>",
"dialog_title": "Delete this account?",
"erase_checkbox_label": "Yes, hide all my messages from new joiners",
"incorrect_password": "Incorrect password, please try again",
"mxid_label": "Confirm your Matrix ID ({{ mxid }})",
"mxid_mismatch": "This value does not match your Matrix ID",
"password_label": "Enter your password to continue"
},
"edit_profile": {
"display_name_help": "This is what others will see wherever you’re signed in.",
"display_name_label": "Display name",
"title": "Edit profile",
"username_label": "Meno používateľa"
},
"password": {
"change": "Zmeniť heslo",
"change_disabled": "Password changes are disabled by the administrator.",
"label": "Heslo"
},
"sign_out": {
"button": "Sign out of account",
"dialog": "Sign out of this account?"
},
"title": "Your account"
},
"add_email_form": {
"email_denied_alert": {
"text": "The entered email is not allowed by the server policy.",
"title": "Email denied by policy"
},
"email_denied_error": "The entered email is not allowed by the server policy",
"email_exists_alert": {
"text": "The entered email is already added to this account",
"title": "Email already exists"
},
"email_exists_error": "The entered email is already added to this account",
"email_field_help": "Add an alternative email you can use to access this account.",
"email_field_label": "Add email",
"email_in_use_error": "The entered email is already in use",
"email_invalid_alert": {
"text": "The entered email is invalid",
"title": "Invalid email"
},
"email_invalid_error": "The entered email is invalid",
"incorrect_password_error": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to add this email address"
},
"app_sessions_list": {
"error": "Failed to load app sessions",
"heading": "Apps"
},
"browser_session_details": {
"current_badge": "Current",
"session_details_title": "Session"
},
"browser_sessions_overview": {
"body:one": "{{count}} active session",
"body:other": "{{count}} active sessions",
"heading": "Browsers",
"no_active_sessions": {
"default": "You are not signed in to any web browsers.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
},
"view_all_button": "Zobraziť všetky"
},
"compat_session_detail": {
"client_details_title": "Client info",
"name": "Názov",
"session_details_title": "Session"
},
"device_type_icon_label": {
"desktop": "Desktop",
"mobile": "Mobile",
"pc": "Computer",
"tablet": "Tablet",
"unknown": "Unknown device type",
"web": "Web"
},
"email_in_use": {
"heading": "The email address {{email}} is already in use."
},
"end_session_button": {
"confirmation_modal_title": "Are you sure you want to end this session?",
"text": "Remove device"
},
"error": {
"hideDetails": "Skryť podrobnosti",
"showDetails": "Zobraziť podrobnosti",
"subtitle": "An unexpected error occurred. Please try again.",
"title": "Something went wrong"
},
"error_boundary_title": "Something went wrong",
"errors": {
"field_required": "Toto pole je povinné",
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again."
},
"last_active": {
"active_date": "Active {{relativeDate}}",
"active_now": "Active now",
"inactive_90_days": "Inactive for 90+ days"
},
"nav": {
"devices": "Devices",
"plan": "Plan",
"profile": "Profil",
"sessions": "Relácie",
"settings": "Settings"
},
"not_found_alert_title": "Not found.",
"not_logged_in_alert": "You're not logged in.",
"oauth2_client_detail": {
"details_title": "Client info",
"id": "Client ID",
"name": "Názov",
"policy": "Policy",
"terms": "Terms of service"
},
"oauth2_session_detail": {
"client_details_name": "Názov",
"client_title": "Client info",
"session_details_title": "Session"
},
"pagination_controls": {
"total": "Total: {{totalCount}}"
},
"password_change": {
"current_password_label": "Súčasné heslo",
"failure": {
"description": {
"account_locked": "Your account is locked and can not be recovered at this time. If this is not expected, please contact your server administrator.",
"expired_recovery_ticket": "The recovery link has expired. Please start the account recovery process again from the start.",
"invalid_new_password": "The new password you chose is invalid; it may not meet the configured security policy.",
"no_current_password": "You don't have a current password.",
"no_such_recovery_ticket": "The recovery link is invalid. If you copied the link from the recovery e-mail, please check the full link was copied.",
"password_changes_disabled": "Password changes are disabled.",
"recovery_ticket_already_used": "The recovery link has already been used. It cannot be used again.",
"unspecified": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"wrong_password": "The password you supplied as your current password is incorrect. Please try again."
},
"title": "Failed to update password"
},
"new_password_again_label": "Enter new password again",
"new_password_label": "Nové heslo",
"passwords_match": "Passwords match!",
"passwords_no_match": "Passwords don't match",
"subtitle": "Choose a new password for your account.",
"success": {
"description": "Your password has been updated successfully.",
"title": "Password updated"
},
"title": "Change your password"
},
"password_reset": {
"consumed": {
"subtitle": "To create a new password, start over and select ”Forgot password“.",
"title": "The link to reset your password has already been used"
},
"expired": {
"resend_email": "Resend email",
"subtitle": "Request a new email that will be sent to: {{email}}",
"title": "The link to reset your password has expired"
},
"subtitle": "Choose a new password for your account.",
"title": "Reset your password"
},
"password_strength": {
"placeholder": "Password strength",
"score": {
"0": "Extremely weak password",
"1": "Very weak password",
"2": "Weak password",
"3": "Strong password",
"4": "Very strong password"
},
"suggestion": {
"all_uppercase": "Capitalise some, but not all letters.",
"another_word": "Add more words that are less common.",
"associated_years": "Avoid years that are associated with you.",
"capitalization": "Capitalise more than the first letter.",
"dates": "Avoid dates and years that are associated with you.",
"l33t": "Avoid predictable letter substitutions like '@' for 'a'.",
"longer_keyboard_pattern": "Use longer keyboard patterns and change typing direction multiple times.",
"no_need": "You can create strong passwords without using symbols, numbers, or uppercase letters.",
"pwned": "If you use this password elsewhere, you should change it.",
"recent_years": "Avoid recent years.",
"repeated": "Avoid repeated words and characters.",
"reverse_words": "Avoid reversed spellings of common words.",
"sequences": "Avoid common character sequences.",
"use_words": "Use multiple words, but avoid common phrases."
},
"too_weak": "This password is too weak",
"warning": {
"common": "This is a commonly used password.",
"common_names": "Common names and surnames are easy to guess.",
"dates": "Dates are easy to guess.",
"extended_repeat": "Repeated character patterns like \"abcabcabc\" are easy to guess.",
"key_pattern": "Short keyboard patterns are easy to guess.",
"names_by_themselves": "Single names or surnames are easy to guess.",
"pwned": "Your password was exposed by a data breach on the Internet.",
"recent_years": "Recent years are easy to guess.",
"sequences": "Common character sequences like \"abc\" are easy to guess.",
"similar_to_common": "This is similar to a commonly used password.",
"simple_repeat": "Repeated characters like \"aaa\" are easy to guess.",
"straight_row": "Straight rows of keys on your keyboard are easy to guess.",
"top_hundred": "This is a frequently used password.",
"top_ten": "This is a heavily used password.",
"user_inputs": "There should not be any personal or page related data.",
"word_by_itself": "Single words are easy to guess."
}
},
"reset_cross_signing": {
"button": "Reset identity",
"cancelled": {
"description_1": "You can close this window and go back to the app to continue.",
"description_2": "If don’t have access to any other verified devices and you don’t have your recovery key, then you’ll need to reset your digital identity to continue using the app.",
"heading": "Digital identity reset cancelled."
},
"description": "If don’t have access to any other verified devices and you don’t have your recovery key, then you’ll need to reset your digital identity to continue using the app.",
"effect_list": {
"negative_1": "You will lose your existing message history",
"negative_2": "You will need to verify all your existing devices and contacts again",
"neutral_1": "You will lose any message history that's stored only on the server",
"neutral_2": "You will need to verify all your existing devices and contacts again",
"positive_1": "Your account details, contacts, preferences, and chat list will be kept"
},
"failure": {
"description": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"heading": "Failed to allow digital identity reset",
"title": "Failed to allow crypto identity"
},
"finish_reset": "Finish reset",
"heading": "Reset your digital identity in case you can't confirm another way",
"start_reset": "Start reset",
"success": {
"description": "The digital identity reset has been approved for the next {{minutes}} minutes. You can close this window and go back to the app to continue.",
"heading": "Digital identity reset successfully. Go back to the app to finish the process.",
"title": "Crypto identity reset temporarily allowed"
},
"warning": "Only reset your digital identity if you don't have access to another verified device and you don't have your recovery key."
},
"selectable_session": {
"label": "Select session"
},
"session": {
"client_id_label": "Client ID",
"current": "Current",
"current_badge": "Current",
"device_id_label": "Device ID",
"finished_date": "Finished <datetime/>",
"finished_label": "Finished",
"generic_browser_session": "Browser session",
"id_label": "ID",
"ip_label": "IP Address",
"last_active_label": "Last Active",
"last_auth_label": "Last Authentication",
"name_for_platform": "{{name}} for {{platform}}",
"scopes_label": "Scopes",
"set_device_name": {
"help": "Set a name that will help you identify this device.",
"label": "Device name",
"title": "Edit device name"
},
"signed_in_date": "Signed in <datetime/>",
"signed_in_label": "Signed in",
"title": "Device details",
"unknown_browser": "Unknown browser",
"unknown_device": "Unknown device",
"uri_label": "Uri",
"user_id_label": "User ID",
"username_label": "User name"
},
"session_detail": {
"alert": {
"button": "Naspäť",
"text": "This session does not exist, or is no longer active.",
"title": "Cannot find session: {{deviceId}}"
}
},
"unknown_route": "Unknown route {{route}}",
"unverified_email_alert": {
"button": "Review and verify",
"text:one": "You have {{count}} unverified email address.",
"text:other": "You have {{count}} unverified email addresses.",
"title": "Unverified email"
},
"user_email": {
"cant_delete_primary": "Choose a different primary email to delete this one.",
"delete_button_confirmation_modal": {
"action": "Delete email",
"body": "Delete this email?",
"incorrect_password": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to delete this email address"
},
"delete_button_title": "Remove email address",
"email": "Email",
"make_primary_button": "Make primary",
"not_verified": "Not verified",
"primary_email": "Primary email",
"retry_button": "Resend code",
"unverified": "Neoverené"
},
"user_email_list": {
"heading": "Emails",
"no_primary_email_alert": "No primary email address"
},
"user_greeting": {
"error": "Failed to load user"
},
"user_name": {
"display_name_field_label": "Zobrazované meno"
},
"user_sessions_overview": {
"active_sessions:one": "{{count}} active session",
"active_sessions:other": "{{count}} active sessions",
"heading": "Where you're signed in",
"no_active_sessions": {
"default": "You are not signed in to any application.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
}
},
"verify_email": {
"code_expired_alert": {
"description": "The code has expired. Please request a new code.",
"title": "Code expired"
},
"code_field_error": "Code not recognised",
"code_field_label": "6-digit code",
"code_field_wrong_shape": "Code must be 6 digits",
"email_sent_alert": {
"description": "Enter the new code below.",
"title": "New code sent"
},
"enter_code_prompt": "Enter the 6-digit code sent to: <email>{{email}}</email>",
"heading": "Overte svoj e-mail",
"invalid_code_alert": {
"description": "Check the code sent to your email and update the fields below to continue.",
"title": "You entered the wrong code"
},
"resend_code": "Resend code",
"resend_email": "Resend email",
"sent": "Sent!",
"unknown_email": "Unknown email"
}
},
"mas": {
"scope": {
"edit_profile": "Upravte si profil a kontaktné údaje",
"manage_sessions": "Spravujte svoje zariadenia a prihlásenia.",
"mas_admin": "Spravovať ľubovoľného používateľa v matrix-authentication-service",
"send_messages": "Odosielať nové správy vo vašom mene",
"synapse_admin": "Spravovať domovský server",
"view_messages": "Zobraziť existujúce správy a údaje",
"view_profile": "Zobraziť informácie o vašom profile a kontaktné údaje"
}
}
}
+401
View File
@@ -0,0 +1,401 @@
{
"action": {
"back": "Orqaga",
"cancel": "Bekor qilish",
"clear": "Tozalash",
"close": "Yopish",
"collapse": "Collapse",
"confirm": "Confirm",
"continue": "Davom etish",
"edit": "Tahrirlash",
"expand": "Expand",
"save": "Saqlash",
"save_and_continue": "Save and continue",
"sign_out": "Chiqish",
"start_over": "Start over"
},
"branding": {
"privacy_policy": {
"alt": "Xizmat maxfiylik siyosatiga havola",
"link": "Maxfiylik siyosati"
},
"terms_and_conditions": {
"alt": "Xizmat ko‘rsatish shartlari va qoidalariga havola",
"link": "Foydalanish shartlari"
}
},
"common": {
"add": "Qo‘shish",
"e2ee": "End-to-end encryption",
"error": "Xatolik",
"loading": "Yuklanmoqda…",
"next": "Keyingisi",
"password": "Parol",
"previous": "Oldingi",
"saved": "Saved",
"saving": "Saving…"
},
"frontend": {
"account": {
"account_password": "Account password",
"contact_info": "Contact info",
"delete_account": {
"alert_description": "This account will be permanently erased and you’ll no longer have access to any of your messages.",
"alert_title": "You’re about to lose all of your data",
"button": "Delete account",
"dialog_description": "<text>Confirm that you would like to delete your account:</text>\n<profile />\n<list>\n<item>You will not be able to reactivate your account</item>\n<item>You will no longer be able to sign in</item>\n<item>No one will be able to reuse your username (MXID), including you</item>\n<item>You will leave all rooms and direct messages you are in</item>\n<item>You will be removed from the identity server, and no one will be able to find you with your email or phone number</item>\n</list>\n<text>Your old messages will still be visible to people who received them. Would you like to hide your sent messages from people who join rooms in the future?</text>",
"dialog_title": "Delete this account?",
"erase_checkbox_label": "Yes, hide all my messages from new joiners",
"incorrect_password": "Incorrect password, please try again",
"mxid_label": "Confirm your Matrix ID ({{ mxid }})",
"mxid_mismatch": "This value does not match your Matrix ID",
"password_label": "Enter your password to continue"
},
"edit_profile": {
"display_name_help": "Siz tizimga kirgan joyingizda boshqalar buni koʻrishadi.",
"display_name_label": "Ko'rsatiladigan ism",
"title": "Profilni tahrirlash",
"username_label": "Foydalanuvchi nomi"
},
"password": {
"change": "Parolni o‘zgartirish",
"change_disabled": "Password changes are disabled by the administrator.",
"label": "Parol"
},
"sign_out": {
"button": "Sign out of account",
"dialog": "Sign out of this account?"
},
"title": "Sizning hisobingiz"
},
"add_email_form": {
"email_denied_alert": {
"text": "Kiritilgan elektron pochta manzili server siyosati tomonidan ruxsat etilmagan.",
"title": "Elektron pochta siyosat tomonidan rad etildi"
},
"email_denied_error": "The entered email is not allowed by the server policy",
"email_exists_alert": {
"text": "Kiritilgan elektron pochta manzili ushbu hisobga allaqachon qo‘shilgan.",
"title": "Elektron pochta allaqachon mavjud"
},
"email_exists_error": "Kiritilgan elektron pochta manzili ushbu hisobga allaqachon qo‘shilgan.",
"email_field_help": "Bu hisobga kirish uchun ishlatishingiz mumkin bo‘lgan muqobil email manzilini kiriting.",
"email_field_label": "Email manzilini kiritish",
"email_in_use_error": "The entered email is already in use",
"email_invalid_alert": {
"text": "Kiritilgan elektron pochta manzili notogʻri",
"title": "Xato elektron pochta manzili"
},
"email_invalid_error": "Kiritilgan elektron pochta manzili notogʻri",
"incorrect_password_error": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to add this email address"
},
"app_sessions_list": {
"error": "Ilova seanslari yuklanmadi",
"heading": "Ilovalar"
},
"browser_session_details": {
"current_badge": "Hozirgi",
"session_details_title": "Seans"
},
"browser_sessions_overview": {
"body:one": "{{count}} ta faol seans",
"body:other": "{{count}} ta faol seans",
"heading": "Brauzerlar",
"no_active_sessions": {
"default": "You are not signed in to any web browsers.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
},
"view_all_button": "Hammasini ko‘rish"
},
"compat_session_detail": {
"client_details_title": "Mijoz haqidagi axborot",
"name": "Ism",
"session_details_title": "Seans"
},
"device_type_icon_label": {
"desktop": "Ish stoli",
"mobile": "Mobil",
"pc": "Kompyuter",
"tablet": "Planshet",
"unknown": "Noma’lum qurilma turi",
"web": "Veb"
},
"email_in_use": {
"heading": "The email address {{email}} is already in use."
},
"end_session_button": {
"confirmation_modal_title": "Haqiqatan ham bu seansni tugatmoqchimisiz?",
"text": "Qurilmani olib tashlash"
},
"error": {
"hideDetails": "Tafsilotlarni yashirish",
"showDetails": "Tafsilotlarni koʻrsatish",
"subtitle": "Kutilmagan xatolik yuz berdi. Qaytadan urinib koʻring.",
"title": "Nimadir xato ketdi"
},
"error_boundary_title": "Nimadir xato ketdi",
"errors": {
"field_required": "Ushbu qator toʻldirilishi shart",
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again."
},
"last_active": {
"active_date": "Faol {{relativeDate}}",
"active_now": "Hozir faol",
"inactive_90_days": "90+ kun davomida faol emas"
},
"nav": {
"devices": "Qurilmalar",
"plan": "Plan",
"profile": "Profil",
"sessions": "Seanslar",
"settings": "Sozlamalar"
},
"not_found_alert_title": "Topilmadi.",
"not_logged_in_alert": "Hisobingizga kirmagansiz.",
"oauth2_client_detail": {
"details_title": "Mijoz haqidagi axborot",
"id": "Mijoz ID raqami",
"name": "Ism",
"policy": "Siyosat",
"terms": "Xizmat shartlari"
},
"oauth2_session_detail": {
"client_details_name": "Ism",
"client_title": "Mijoz haqidagi axborot",
"session_details_title": "Seans"
},
"pagination_controls": {
"total": "Jami: {{totalCount}}"
},
"password_change": {
"current_password_label": "Joriy parol",
"failure": {
"description": {
"account_locked": "Your account is locked and can not be recovered at this time. If this is not expected, please contact your server administrator.",
"expired_recovery_ticket": "The recovery link has expired. Please start the account recovery process again from the start.",
"invalid_new_password": "The new password you chose is invalid; it may not meet the configured security policy.",
"no_current_password": "You don't have a current password.",
"no_such_recovery_ticket": "The recovery link is invalid. If you copied the link from the recovery e-mail, please check the full link was copied.",
"password_changes_disabled": "Password changes are disabled.",
"recovery_ticket_already_used": "The recovery link has already been used. It cannot be used again.",
"unspecified": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"wrong_password": "The password you supplied as your current password is incorrect. Please try again."
},
"title": "Failed to update password"
},
"new_password_again_label": "Enter new password again",
"new_password_label": "Yangi parol",
"passwords_match": "Passwords match!",
"passwords_no_match": "Passwords don't match",
"subtitle": "Choose a new password for your account.",
"success": {
"description": "Your password has been updated successfully.",
"title": "Password updated"
},
"title": "Change your password"
},
"password_reset": {
"consumed": {
"subtitle": "To create a new password, start over and select ”Forgot password“.",
"title": "The link to reset your password has already been used"
},
"expired": {
"resend_email": "Elektron pochtani qayta yuborish",
"subtitle": "Request a new email that will be sent to: {{email}}",
"title": "The link to reset your password has expired"
},
"subtitle": "Choose a new password for your account.",
"title": "Reset your password"
},
"password_strength": {
"placeholder": "Password strength",
"score": {
"0": "Extremely weak password",
"1": "Very weak password",
"2": "Weak password",
"3": "Strong password",
"4": "Very strong password"
},
"suggestion": {
"all_uppercase": "Capitalise some, but not all letters.",
"another_word": "Add more words that are less common.",
"associated_years": "Avoid years that are associated with you.",
"capitalization": "Capitalise more than the first letter.",
"dates": "Avoid dates and years that are associated with you.",
"l33t": "Avoid predictable letter substitutions like '@' for 'a'.",
"longer_keyboard_pattern": "Use longer keyboard patterns and change typing direction multiple times.",
"no_need": "You can create strong passwords without using symbols, numbers, or uppercase letters.",
"pwned": "If you use this password elsewhere, you should change it.",
"recent_years": "Avoid recent years.",
"repeated": "Avoid repeated words and characters.",
"reverse_words": "Avoid reversed spellings of common words.",
"sequences": "Avoid common character sequences.",
"use_words": "Use multiple words, but avoid common phrases."
},
"too_weak": "This password is too weak",
"warning": {
"common": "This is a commonly used password.",
"common_names": "Common names and surnames are easy to guess.",
"dates": "Dates are easy to guess.",
"extended_repeat": "Repeated character patterns like \"abcabcabc\" are easy to guess.",
"key_pattern": "Short keyboard patterns are easy to guess.",
"names_by_themselves": "Single names or surnames are easy to guess.",
"pwned": "Your password was exposed by a data breach on the Internet.",
"recent_years": "Recent years are easy to guess.",
"sequences": "Common character sequences like \"abc\" are easy to guess.",
"similar_to_common": "This is similar to a commonly used password.",
"simple_repeat": "Repeated characters like \"aaa\" are easy to guess.",
"straight_row": "Straight rows of keys on your keyboard are easy to guess.",
"top_hundred": "This is a frequently used password.",
"top_ten": "This is a heavily used password.",
"user_inputs": "There should not be any personal or page related data.",
"word_by_itself": "Single words are easy to guess."
}
},
"reset_cross_signing": {
"button": "Shaxsni tiklash",
"cancelled": {
"description_1": "You can close this window and go back to the app to continue.",
"description_2": "If don’t have access to any other verified devices and you don’t have your recovery key, then you’ll need to reset your digital identity to continue using the app.",
"heading": "Digital identity reset cancelled."
},
"description": "Agar boshqa tasdiqlangan qurilmalarga kirish imkoningiz boʻlmasa va sizda tiklash kaliti boʻlmasa, ilovadan foydalanishda davom etish uchun raqamli identifikatsiyangizni tiklashingiz kerak boʻladi.",
"effect_list": {
"negative_1": "You will lose your existing message history",
"negative_2": "You will need to verify all your existing devices and contacts again",
"neutral_1": "You will lose any message history that's stored only on the server",
"neutral_2": "You will need to verify all your existing devices and contacts again",
"positive_1": "Your account details, contacts, preferences, and chat list will be kept"
},
"failure": {
"description": "This might be a temporary problem, so please try again later. If the problem persists, please contact your server administrator.",
"heading": "Failed to allow digital identity reset",
"title": "Kripto identifikatsiyasiga ruxsat berilmadi"
},
"finish_reset": "Finish reset",
"heading": "Boshqa usulda tasdiqlay olmasangiz, raqamli identifikatoringizni asliga qaytaring",
"start_reset": "Start reset",
"success": {
"description": "Raqamli identifikatorni tiklash keyingi {{minutes}} daqiqa uchun tasdiqlandi. Bu oynani yopib, davom etish uchun ilovaga qaytishingiz mumkin.",
"heading": "Digital identity reset successfully. Go back to the app to finish the process.",
"title": "Kripto identifikatorini asliga qaytarishga vaqtincha ruxsat berildi"
},
"warning": "Only reset your digital identity if you don't have access to another verified device and you don't have your recovery key."
},
"selectable_session": {
"label": "Sessiyani tanlang"
},
"session": {
"client_id_label": "Mijoz ID raqami",
"current": "Hozirgi",
"current_badge": "Hozirgi",
"device_id_label": "Qurilma ID",
"finished_date": "Tugadi <datetime/>",
"finished_label": "Tugadi",
"generic_browser_session": "Browser session",
"id_label": "ID",
"ip_label": "IP-manzil",
"last_active_label": "Oxirgi faollik",
"last_auth_label": "Oxirgi autentifikatsiya",
"name_for_platform": "{{platform}} uchun {{name}}",
"scopes_label": "Ko‘lamlar",
"set_device_name": {
"help": "Set a name that will help you identify this device.",
"label": "Device name",
"title": "Edit device name"
},
"signed_in_date": "<datetime/> kirildi",
"signed_in_label": "Kirish",
"title": "Qurilma tafsilotlari",
"unknown_browser": "Noma’lum brauzer",
"unknown_device": "Noma’lum qurilma.",
"uri_label": "Uri",
"user_id_label": "Foydalanuvchi ID'si",
"username_label": "Foydalanuvchi nomi"
},
"session_detail": {
"alert": {
"button": "Ortga qaytish",
"text": "Bu sessiya mavjud emas yoki endi faol emas.",
"title": "Sessiya topilmadi:{{deviceId}}"
}
},
"unknown_route": "Nomaʼlum yoʻnalish {{route}}",
"unverified_email_alert": {
"button": "Koʻrib chiqing va tasdiqlang",
"text:one": "Sizda {{count}} ta tasdiqlanmagan email manzili bor.",
"text:other": "Sizda {{count}} ta tasdiqlanmagan email manzili bor.",
"title": "Tasdiqlanmagan elektron pochta"
},
"user_email": {
"cant_delete_primary": "Buni oʻchirish uchun boshqa asosiy elektron pochta manzilini tanlang.",
"delete_button_confirmation_modal": {
"action": "Elektron pochtani oʻchirish",
"body": "Bu email o‘chirib tashlansinmi?",
"incorrect_password": "Incorrect password, please try again",
"password_confirmation": "Confirm your account password to delete this email address"
},
"delete_button_title": "Elektron pochta manzilini olib tashlash",
"email": "Elektron pochta",
"make_primary_button": "Asosiy qilish",
"not_verified": "Tasdiqlanmagan",
"primary_email": "Asosiy elektron pochta",
"retry_button": "Kodni qayta yuborish",
"unverified": "Tasdiqlanmagan"
},
"user_email_list": {
"heading": "Elektron pochta xabarlari",
"no_primary_email_alert": "Asosiy elektron pochta manzili yo‘q"
},
"user_greeting": {
"error": "Foydalanuvchi yuklanmadi"
},
"user_name": {
"display_name_field_label": "Ko'rsatiladigan ism"
},
"user_sessions_overview": {
"active_sessions:one": "{{count}} ta faol seans",
"active_sessions:other": "{{count}} ta faol seans",
"heading": "Qayerda tizimga kirgansiz",
"no_active_sessions": {
"default": "You are not signed in to any application.",
"inactive_90_days": "All your sessions have been active in the last 90 days."
}
},
"verify_email": {
"code_expired_alert": {
"description": "The code has expired. Please request a new code.",
"title": "Code expired"
},
"code_field_error": "Kod tan olinmadi",
"code_field_label": "6 xonali kod",
"code_field_wrong_shape": "Kod 6 xonali bo‘lishi kerak",
"email_sent_alert": {
"description": "Quyidagi yangi kodni kiriting.",
"title": "Yangi kod yuborildi"
},
"enter_code_prompt": "<email>{{email}}</email> manziliga yuborilgan 6 xonali kodni kiriting",
"heading": "Elektron pochtangizni tasdiqlang",
"invalid_code_alert": {
"description": "Davom etish uchun elektron pochtangizga yuborilgan kodni tekshiring va quyidagi maydonlarni yangilang.",
"title": "Kodni xato kiritdingiz"
},
"resend_code": "Kodni qayta yuborish",
"resend_email": "Elektron pochtani qayta yuborish",
"sent": "Yuborildi!",
"unknown_email": "Noma’lum email"
}
},
"mas": {
"scope": {
"edit_profile": "Profilingiz va aloqa maʼlumotlaringizni tahrirlang",
"manage_sessions": "Qurilmalaringiz va sessiyalaringizni boshqaring",
"mas_admin": "Foydalanuvchilarni boshqarish (urn:mas:admin)",
"send_messages": "Sizning nomingizdan yangi xabarlar yuborish",
"synapse_admin": "Serverni boshqarish (urn:synapse:admin:*)",
"view_messages": "Mavjud xabar va ma’lumotlaringizni ko‘rish",
"view_profile": "Profilingiz ma’lumotlari va kontakt tafsilotlarini ko‘rish"
}
}
}
+4 -4
View File
@@ -99,7 +99,7 @@
"session_details_title": "会话"
},
"browser_sessions_overview": {
"body:other": "{{count}}活跃会话",
"body:other": "{{count}} 个活跃会话",
"heading": "浏览器",
"no_active_sessions": {
"default": "你尚未登录任何 Web 浏览器。",
@@ -145,7 +145,7 @@
},
"nav": {
"devices": "设备",
"plan": "Plan",
"plan": "配额",
"profile": "个人资料",
"sessions": "会话",
"settings": "设置"
@@ -353,7 +353,7 @@
"display_name_field_label": "显示名称"
},
"user_sessions_overview": {
"active_sessions:other": "{{count}}活跃会话",
"active_sessions:other": "{{count}} 个活跃会话",
"heading": "你已登录的位置",
"no_active_sessions": {
"default": "你尚未登录任何 app。",
@@ -373,7 +373,7 @@
"title": "新代码已发送"
},
"enter_code_prompt": "输入发送至以下地址的6位数代码:<email>{{email}}</email>",
"heading": "验证邮箱",
"heading": "验证邮件地址",
"invalid_code_alert": {
"description": "检查发送到你的邮件地址中的代码,并更新以下字段以继续。",
"title": "你输入的代码错误"
+1386 -1537
View File
File diff suppressed because it is too large Load Diff
+24 -24
View File
@@ -22,61 +22,61 @@
"@fontsource/inter": "^5.2.8",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-router": "^1.150.0",
"@vector-im/compound-design-tokens": "6.4.3",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.167.4",
"@vector-im/compound-design-tokens": "6.10.1",
"@vector-im/compound-web": "^8.3.5",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"classnames": "^2.5.1",
"date-fns": "^4.1.0",
"i18next": "^25.7.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.3",
"i18next": "^25.8.18",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"swagger-ui-dist": "^5.31.0",
"valibot": "^1.2.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@biomejs/biome": "^2.3.9",
"@biomejs/biome": "^2.4.2",
"@browser-logos/chrome": "^2.0.0",
"@browser-logos/firefox": "^3.0.10",
"@browser-logos/safari": "^2.1.0",
"@graphql-codegen/cli": "^6.1.0",
"@graphql-codegen/client-preset": "^5.2.2",
"@graphql-codegen/cli": "^6.1.2",
"@graphql-codegen/client-preset": "^5.2.3",
"@graphql-codegen/typescript-msw": "^3.0.1",
"@storybook/addon-docs": "^10.1.11",
"@storybook/react-vite": "^10.1.11",
"@tanstack/react-query-devtools": "^5.91.2",
"@tanstack/react-router-devtools": "^1.150.0",
"@tanstack/router-plugin": "^1.150.0",
"@storybook/addon-docs": "^10.2.19",
"@storybook/react-vite": "^10.2.19",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.166.9",
"@tanstack/router-plugin": "^1.166.13",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.0.3",
"@types/react": "19.2.7",
"@types/node": "^25.3.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/swagger-ui-dist": "^3.30.6",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.23",
"browserslist-to-esbuild": "^2.1.1",
"graphql": "^16.12.0",
"happy-dom": "^20.0.11",
"i18next-cli": "^1.36.1",
"happy-dom": "^20.5.0",
"i18next-cli": "^1.50.3",
"knip": "^5.81.0",
"msw": "^2.12.4",
"msw": "^2.12.8",
"msw-storybook-addon": "^2.0.6",
"postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2",
"postcss-nesting": "^14.0.0",
"rimraf": "^6.1.2",
"storybook": "^10.1.11",
"tailwindcss": "^3.4.19",
"tinyglobby": "^0.2.15",
"typescript": "^5.9.3",
"vite": "7.3.1",
"vite": "8.0.0",
"vite-plugin-graphql-codegen": "^3.8.0",
"vitest": "^4.0.15"
},
@@ -109,8 +109,8 @@ const AccountDeleteButton: React.FC<Props> = (props) => {
setAllowSubmitting(false);
}, [isMaybeValid]);
const onPasswordChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const onPasswordInput = useCallback(
(e: React.InputEvent<HTMLInputElement>) => {
// We don't know if the password is correct, so we consider the form as
// valid if the field is not empty
setIsMaybeValid(e.currentTarget.value !== "");
@@ -118,8 +118,8 @@ const AccountDeleteButton: React.FC<Props> = (props) => {
[],
);
const onMxidChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const onMxidInput = useCallback(
(e: React.InputEvent<HTMLInputElement>) => {
setIsMaybeValid(e.currentTarget.value === user.matrix.mxid);
},
[user.matrix.mxid],
@@ -208,7 +208,7 @@ const AccountDeleteButton: React.FC<Props> = (props) => {
<Form.PasswordControl
autoComplete="current-password"
required
onInput={onPasswordChange}
onInput={onPasswordInput}
/>
<Form.ErrorMessage match="valueMissing">
@@ -232,7 +232,7 @@ const AccountDeleteButton: React.FC<Props> = (props) => {
<Form.TextControl
required
placeholder={user.matrix.mxid}
onInput={onMxidChange}
onInput={onMxidInput}
/>
<Form.ErrorMessage match="valueMissing">
+1 -1
View File
@@ -10,7 +10,7 @@
// Ideally later on we could find a way to hydrate full React components instead
// of doing this, as this can very quickly get out of hands.
const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/-]+|@[a-z0-9.=_/-]+(:.*)?)\s*$/g;
const VALID_USERNAME_RE = /^\s*([a-z0-9.=_/+-]+|@[a-z0-9.=_/+-]+(:.*)?)\s*$/g;
/** Grab the nearest error message inserted by the templates by error kind and code */
function grabErrorMessage(
+15 -15
View File
@@ -117,22 +117,22 @@ const AccountSessionsBrowsersRoute = AccountSessionsBrowsersRouteImport.update({
} as any)
export interface FileRoutesByFullPath {
'/': typeof AccountIndexRoute
'/reset-cross-signing': typeof ResetCrossSigningRouteWithChildren
'/clients/$id': typeof ClientsIdRoute
'/devices/$': typeof DevicesSplatRoute
'/reset-cross-signing/cancelled': typeof ResetCrossSigningCancelledRoute
'/reset-cross-signing/success': typeof ResetCrossSigningSuccessRoute
'/sessions/$id': typeof SessionsIdRoute
'/': typeof AccountIndexRoute
'/reset-cross-signing/': typeof ResetCrossSigningIndexRoute
'/sessions/browsers': typeof AccountSessionsBrowsersRoute
'/emails/$id/in-use': typeof EmailsIdInUseRoute
'/emails/$id/verify': typeof EmailsIdVerifyRoute
'/password/change/success': typeof PasswordChangeSuccessRoute
'/plan': typeof AccountPlanIndexRoute
'/sessions': typeof AccountSessionsIndexRoute
'/password/change': typeof PasswordChangeIndexRoute
'/password/recovery': typeof PasswordRecoveryIndexRoute
'/plan/': typeof AccountPlanIndexRoute
'/sessions/': typeof AccountSessionsIndexRoute
'/password/change/': typeof PasswordChangeIndexRoute
'/password/recovery/': typeof PasswordRecoveryIndexRoute
}
export interface FileRoutesByTo {
'/clients/$id': typeof ClientsIdRoute
@@ -174,22 +174,22 @@ export interface FileRoutesById {
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/reset-cross-signing'
| '/clients/$id'
| '/devices/$'
| '/reset-cross-signing/cancelled'
| '/reset-cross-signing/success'
| '/sessions/$id'
| '/'
| '/reset-cross-signing/'
| '/sessions/browsers'
| '/emails/$id/in-use'
| '/emails/$id/verify'
| '/password/change/success'
| '/plan'
| '/sessions'
| '/password/change'
| '/password/recovery'
| '/plan/'
| '/sessions/'
| '/password/change/'
| '/password/recovery/'
fileRoutesByTo: FileRoutesByTo
to:
| '/clients/$id'
@@ -253,7 +253,7 @@ declare module '@tanstack/react-router' {
'/_account': {
id: '/_account'
path: ''
fullPath: ''
fullPath: '/'
preLoaderRoute: typeof AccountRouteImport
parentRoute: typeof rootRouteImport
}
@@ -309,28 +309,28 @@ declare module '@tanstack/react-router' {
'/password/recovery/': {
id: '/password/recovery/'
path: '/password/recovery'
fullPath: '/password/recovery'
fullPath: '/password/recovery/'
preLoaderRoute: typeof PasswordRecoveryIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/password/change/': {
id: '/password/change/'
path: '/password/change'
fullPath: '/password/change'
fullPath: '/password/change/'
preLoaderRoute: typeof PasswordChangeIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_account/sessions/': {
id: '/_account/sessions/'
path: '/sessions'
fullPath: '/sessions'
fullPath: '/sessions/'
preLoaderRoute: typeof AccountSessionsIndexRouteImport
parentRoute: typeof AccountRoute
}
'/_account/plan/': {
id: '/_account/plan/'
path: '/plan'
fullPath: '/plan'
fullPath: '/plan/'
preLoaderRoute: typeof AccountPlanIndexRouteImport
parentRoute: typeof AccountRoute
}
+1 -19
View File
@@ -9,9 +9,7 @@ import {
createRootRoute,
createRoute,
createRouter,
matchContext,
RouterContextProvider,
useRouterState,
} from "@tanstack/react-router";
const rootRoute = createRootRoute();
@@ -27,22 +25,6 @@ const router = createRouter({
});
router.load();
const InnerProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const matchId = useRouterState({
select: (s) => {
return s.matches[0]?.id;
},
});
return (
<matchContext.Provider value={matchId}>{children}</matchContext.Provider>
);
};
export const DummyRouter: React.FC<React.PropsWithChildren> = ({
children,
}) => (
<RouterContextProvider router={router}>
<InnerProvider>{children}</InnerProvider>
</RouterContextProvider>
);
}) => <RouterContextProvider router={router}>{children}</RouterContextProvider>;
+2 -2
View File
@@ -7,9 +7,9 @@
DOCKER := 0
PODMAN := 0
# Keep in sync with Dockerfile and .github/actions/build-policies/action.yml
OPA_DOCKER_IMAGE := docker.io/openpolicyagent/opa:1.8.0-debug
OPA_DOCKER_IMAGE := docker.io/openpolicyagent/opa:1.13.1
# Keep in sync with .github/workflows/ci.yaml
REGAL_DOCKER_IMAGE := ghcr.io/open-policy-agent/regal:0.36.1
REGAL_DOCKER_IMAGE := ghcr.io/open-policy-agent/regal:0.38.1
INPUTS := \
common/common.rego \
+1 -1
View File
@@ -49,7 +49,7 @@ violation contains {
"field": "username", "code": "username-invalid-chars",
"msg": "username contains invalid characters",
} if {
not regex.match(`^[a-z0-9.=_/-]+$`, input.username)
not regex.match(`^[a-z0-9.=_/+-]+$`, input.username)
}
violation contains {
+1 -1
View File
@@ -29,7 +29,7 @@ Please see LICENSE files in the repository root for full details.
{% macro head() -%}
{%- if captcha|default(False) -%}
{%- if captcha.service == "recaptcha_v2" -%}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<script src="https://www.recaptcha.net/recaptcha/api.js" async defer></script>
{%- elif captcha.service == "cloudflare_turnstile" -%}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{%- elif captcha.service == "hcaptcha" -%}
+104
View File
@@ -0,0 +1,104 @@
{
"action": {
"back": "Voltar",
"cancel": "Cancelar",
"continue": "Continuar",
"create_account": "Criar Conta",
"sign_in": "Entrar",
"sign_out": "Sair",
"submit": "Enviar"
},
"common": {
"display_name": "Nome e sobrenome",
"email_address": "Endereço de e-mail",
"loading": "Carregando…",
"password": "Senha",
"password_confirm": "Confirme a nova senha",
"username": "Nome de usuário"
},
"error": {
"unexpected": "Erro inesperado"
},
"mas": {
"back_to_homepage": "Voltar à página inicial",
"change_password": {
"change": "Alterar a senha",
"confirm": "Confirme a nova senha",
"current": "Senha atual",
"heading": "Alterar minha senha",
"new": "Nova senha"
},
"emails": {
"greeting": "Olá, %(username)s,",
"verify": {
"body_html": "Seu código de verificação para este endereço de e-mail é: <strong>%(code)s</strong>",
"body_text": "Seu código de verificação para este endereço de e-mail é: %(code)s",
"subject": "Seu código de verificação de e-mail é: %(code)s"
}
},
"errors": {
"denied_policy": "Negado de acordo com a norma: %(policy)s",
"field_required": "Este campo é obrigatório",
"invalid_credentials": "Credenciais inválidas",
"password_mismatch": "As senhas digitadas não são iguais",
"username_taken": "Este nome de usuário já está em uso"
},
"login": {
"call_to_register": "Ainda não tem uma conta?",
"continue_with_provider": "Continuar com %(provider)s",
"description": "Faça login para continuar:",
"headline": "Entrar",
"no_login_methods": "Não há métodos de login disponíveis.",
"separator": "Ou"
},
"navbar": {
"my_account": "Minha conta",
"register": "Criar uma conta",
"signed_in_as": "Entrou como <span class=\"font-semibold\">%(username)s</span>."
},
"not_found": {
"description": "A página que você estava procurando não existe ou foi removida.",
"heading": "Página não encontrada"
},
"not_you": "Não é %(username)s?",
"or_separator": "Ou",
"recovery": {
"finish": {
"new": "Nova senha"
}
},
"register": {
"call_to_login": "Já tem uma conta?",
"create_account": {
"description": "Escolha um nome de usuário para continuar.",
"heading": "Criar uma conta"
},
"sign_in_instead": "Em vez disso, faça login"
},
"scope": {
"edit_profile": "Edite seu perfil e seus dados de contato.",
"manage_sessions": "Gerencie seus dispositivos e sessões",
"view_profile": "Veja as informações do seu perfil e os seus dados de contato."
},
"upstream_oauth2": {
"login_link": {
"action": "Continuar"
},
"register": {
"choose_username": {
"heading": "Escolha seu nome de usuário"
},
"create_account": "Criar uma nova conta",
"suggested_display_name": "Importar nome de exibição",
"suggested_email": "Importar endereço de e-mail"
},
"suggest_link": {
"action": "Ligação"
}
},
"verify_email": {
"description": "Digite o código de 6 dígitos enviado para <em>%(email)s</em>",
"headline": "Verifique seu e-mail"
}
}
}
+122
View File
@@ -0,0 +1,122 @@
{
"action": {
"back": "Naspäť",
"cancel": "Zrušiť",
"continue": "Pokračovať",
"create_account": "Vytvoriť účet",
"sign_in": "Prihlásiť sa",
"sign_out": "Odhlásiť sa",
"submit": "Odoslať"
},
"app": {
"human_name": "Matrix Authentication Service",
"name": "matrix-authentication-service",
"technical_description": "Dokument zisťovania OpenID Connect: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>"
},
"common": {
"display_name": "Zobrazované meno",
"email_address": "Emailová adresa",
"loading": "Načítavanie…",
"password": "Heslo",
"password_confirm": "Potvrdiť heslo",
"username": "Meno používateľa"
},
"error": {
"unexpected": "Neočakávaná chyba"
},
"mas": {
"add_email": {
"heading": "Pridať email"
},
"back_to_homepage": "Späť na domovskú stránku",
"change_password": {
"change": "Zmeniť heslo",
"confirm": "Potvrdiť heslo",
"current": "Súčasné heslo",
"heading": "Zmena hesla",
"new": "Nové heslo"
},
"emails": {
"greeting": "Ahoj %(username)s,",
"verify": {
"body_html": "Váš overovací kód na potvrdenie tejto e-mailovej adresy je: <strong>%(code)s</strong>",
"body_text": "Váš kód na potvrdenie tejto e-mailovej adresy je: %(code)s",
"subject": "Váš kód e-mailu je: %(code)s"
}
},
"errors": {
"denied_policy": "Zamietnuté pravidlami:%(policy)s",
"field_required": "Toto pole je povinné",
"invalid_credentials": "Neplatné poverenia",
"password_mismatch": "Heslá sa nezhodujú.",
"username_taken": "Toto používateľské meno je už obsadené"
},
"login": {
"call_to_register": "Ešte nemáte účet?",
"continue_with_provider": "Pokračovať s %(provider)s",
"description": "Ak chcete pokračovať, prihláste sa:",
"headline": "Prihlásiť sa",
"link": {
"description": "Prepojenie vášho <span class=\"break-keep text-links\"> %(provider)s </span> účtu"
},
"no_login_methods": "Nie sú k dispozícii žiadne metódy prihlásenia.",
"separator": "Alebo"
},
"navbar": {
"my_account": "Môj účet",
"register": "Vytvoriť účet",
"signed_in_as": "Prihlásený ako <span class=\"font-semibold\">%(username)s</span>."
},
"not_found": {
"description": "Stránka, ktorú hľadáte, neexistuje alebo bola presunutá.",
"heading": "Stránka nenájdená"
},
"not_you": "Nie ste prihlásený ako %(username)s?",
"or_separator": "Alebo",
"policy_violation": {
"description": "Môže to byť spôsobené klientom, ktorý požiadavku vytvoril, aktuálne prihláseným používateľom alebo samotnou požiadavkou.",
"heading": "Žiadosť o autorizáciu bola zamietnutá na základe pravidiel uplatňovaných touto službou.",
"logged_as": "Prihlásený ako <span class=\"font-semibold\">%(username)s</span>"
},
"recovery": {
"finish": {
"new": "Nové heslo"
}
},
"register": {
"call_to_login": "Už máte účet?",
"create_account": {
"description": "Pre pokračovanie vyberte používateľské meno.",
"heading": "Vytvoriť účet"
},
"sign_in_instead": "Radšej sa prihlásiť"
},
"scope": {
"edit_profile": "Upravte si profil a kontaktné údaje",
"manage_sessions": "Spravujte svoje zariadenia a prihlásenia.",
"mas_admin": "Spravovať ľubovoľného používateľa v matrix-authentication-service",
"send_messages": "Odosielať nové správy vo vašom mene",
"synapse_admin": "Spravovať domovský server",
"view_messages": "Zobraziť existujúce správy a údaje",
"view_profile": "Zobraziť informácie o vašom profile a kontaktné údaje"
},
"upstream_oauth2": {
"login_link": {
"action": "Pokračovať"
},
"register": {
"choose_username": {
"heading": "Vyberte si svoje používateľské meno"
},
"create_account": "Vytvoriť nový účet"
},
"suggest_link": {
"action": "Odkaz"
}
},
"verify_email": {
"description": "Zadajte 6-ciferný kód, ktorý bol odoslaný na: <em>%(email)s</em>",
"headline": "Overte svoj e-mail"
}
}
}
+195
View File
@@ -0,0 +1,195 @@
{
"action": {
"back": "Orqaga",
"cancel": "Bekor qilish",
"continue": "Davom etish",
"create_account": "Ro'yxatdan o'tish",
"sign_in": "Kirish",
"sign_out": "Chiqish",
"submit": "Yuborish"
},
"app": {
"human_name": "Matrix Authentication Service",
"name": "matritsa-autentifikatsiya-xizmati",
"technical_description": "OpenID Connect kashfiyot hujjati:<a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>"
},
"branding": {
"privacy_policy": {
"alt": "Xizmat maxfiylik siyosatiga havola",
"link": "Maxfiylik siyosati"
},
"terms_and_conditions": {
"alt": "Xizmat ko‘rsatish shartlari va qoidalariga havola",
"link": "Foydalanish shartlari"
}
},
"common": {
"display_name": "Ko'rsatiladigan ism",
"email_address": "Email manzili",
"loading": "Yuklanmoqda…",
"mxid": "Matrix ID",
"password": "Parol",
"password_confirm": "Parolni tasdiqlash",
"username": "Foydalanuvchi nomi"
},
"error": {
"unexpected": "Kutilmagan xato"
},
"mas": {
"add_email": {
"description": "Agar hisobingizga kira olmasangiz, uni tiklash uchun email manzilini kiriting.",
"heading": "Email manzilini qo‘shish"
},
"back_to_homepage": "Bosh sahifaga qaytish",
"change_password": {
"change": "Parolni o‘zgartirish",
"confirm": "Parolni tasdiqlash",
"current": "Joriy parol",
"description": "Bunda hisobingizdagi parol o‘zgaradi.",
"heading": "Parolni o‘zgartirish",
"new": "Yangi parol"
},
"consent": {
"client_wants_access": "<span>%(client_name)s</span> da <span>%(redirect_uri)s</span> hisobingizga kirmoqchi.",
"heading": "Hisobingizga kirishga ruxsat berilsinmi?",
"make_sure_you_trust": "<span>%(client_name)s</span>ga ishonch hosil qiling.",
"this_will_allow": "Bu <span>%(client_name)s</span>ga quyidagilarga ruxsat beradi:",
"you_may_be_sharing": "Bu sayt yoki ilovaga maxfiy axborotlarni ulashayotgan bo‘lishingiz mumkin."
},
"device_card": {
"access_requested": "Ruxsat so‘raldi",
"device_code": "Kod",
"generic_device": "Qurilma",
"ip_address": "IP manzili"
},
"device_code_link": {
"description": "Qurilmani ulang",
"headline": "Qurilmangizda chiqqan kodni kiriting"
},
"device_consent": {
"another_device_access": "Boshqa qurilma hisobingizga kirishni xohlamoqda.",
"denied": {
"description": "Siz %(client_name)s uchun ruxsatni rad etdingiz. Bu oynani yopishingiz mumkin.",
"heading": "Ruxsat berilmadi"
},
"granted": {
"description": "Siz %(client_name)s uchun ruxsat berdingiz. Bu oynani yopishingiz mumkin.",
"heading": "Ruxsat berildi"
}
},
"device_display_name": {
"unknown_device": "Noma’lum qurilma."
},
"emails": {
"greeting": "Salom %(username)s ,",
"verify": {
"body_html": "Ushbu elektron pochta manzilini tasdiqlash uchun tasdiqlash kodingiz: <strong>%(code)s</strong>",
"body_text": "Ushbu elektron pochta manzilini tasdiqlash uchun tasdiqlash kodingiz: %(code)s",
"subject": "Elektron pochtangizni tasdiqlash kodingiz: %(code)s"
}
},
"errors": {
"denied_policy": "Siyosat tomonidan rad etilgan: %(policy)s",
"field_required": "Ushbu qator toʻldirilishi shart",
"invalid_credentials": "Hisob ma’lumotlari yaroqsiz",
"password_mismatch": "Parol maydonlari mos kelmayapti",
"username_taken": "Bu foydalanuvchi nomi allaqachon band"
},
"login": {
"call_to_register": "Hali hisobingiz yo‘qmi?",
"continue_with_provider": "%(provider)s bilan davom etish",
"description": "Davom etish uchun tizimga kiring:",
"headline": "Kirish",
"link": {
"description": "<span class=\"break-keep text-links\">%(provider)s</span> hisobingiz ulanmoqda",
"headline": "Havola uchun tizimga kiring"
},
"no_login_methods": "Kirish usullari mavjud emas.",
"separator": "Yoki"
},
"navbar": {
"my_account": "Mening hisobim",
"register": "Hisob yaratish",
"signed_in_as": "Tizimga kirgan:<span class=\"font-semibold\">%(username)s</span> ."
},
"not_found": {
"description": "Siz qidirayotgan sahifa mavjud emas yoki boshqa joyga ko‘chirilgan.",
"heading": "Sahifa topilmadi"
},
"not_you": "%(username)s emasmi ?",
"or_separator": "Yoki",
"policy_violation": {
"description": "Bu so‘rovni yaratgan mijoz, hozirda tizimga kirgan foydalanuvchi yoki so‘rovning o‘zi bilan bog‘liq bo‘lishi mumkin.",
"heading": "Avtorizatsiya so‘rovi ushbu xizmatda amalda bo‘lgan siyosat tufayli rad etildi.",
"logged_as": "<span class=\"font-semibold\">%(username)s</span> sifatida kirildi"
},
"recovery": {
"expired": {
"resend_email": "Elektron pochtani qayta yuborish"
},
"finish": {
"new": "Yangi parol"
},
"progress": {
"resend_email": "Elektron pochtani qayta yuborish"
}
},
"register": {
"call_to_login": "Hisobingiz allaqachon bormi?",
"create_account": {
"description": "Davom etish uchun foydalanuvchi nomini tanlang.",
"heading": "Hisob yaratish"
},
"sign_in_instead": "Buning o‘rniga tizimga kiring",
"terms_of_service": "Men roziman <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Foydalanish shartlari</a>"
},
"scope": {
"edit_profile": "Profilingiz va aloqa maʼlumotlaringizni tahrirlang",
"manage_sessions": "Qurilmalaringiz va sessiyalaringizni boshqaring",
"mas_admin": "Foydalanuvchilarni boshqarish (urn:mas:admin)",
"send_messages": "Sizning nomingizdan yangi xabarlar yuborish",
"synapse_admin": "Serverni boshqarish (urn:synapse:admin:*)",
"view_messages": "Mavjud xabar va ma’lumotlaringizni ko‘rish",
"view_profile": "Profilingiz ma’lumotlari va kontakt tafsilotlarini ko‘rish"
},
"upstream_oauth2": {
"link_mismatch": {
"heading": "Bu yuqori oqim hisobi allaqachon boshqa hisobga ulangan."
},
"login_link": {
"action": "Davom etish",
"heading": "Mavjud hisobingizga havola"
},
"register": {
"choose_username": {
"description": "Buni keyin o‘zgartirib bo‘lmaydi.",
"heading": "Foydalanuvchi nomingizni tanlang"
},
"create_account": "Yangi hisob yaratish",
"enforced_by_policy": "Server siyosati tomonidan amalga oshiriladi",
"forced_display_name": "Quyidagi ko‘rsatish nomidan foydalanadi",
"forced_email": "Quyidagi elektron pochta manzilidan foydalaniladi",
"forced_localpart": "Quyidagi foydalanuvchi nomidan foydalaniladi",
"import_data": {
"description": "Yangi %(server_name)s hisobingizga ulanadigan ma’lumotlarni tasdiqlang.",
"heading": "Ma’lumotlaringizni import qiling"
},
"imported_from_upstream": "Yuqori darajadagi hisobingizdan import qilindi",
"link_existing": "Mavjud hisobga havola",
"suggested_display_name": "Ko‘rsatish nomini import qilish",
"suggested_email": "Elektron pochta manzilini import qilish",
"use": "Foydalanish"
},
"suggest_link": {
"action": "Havola",
"heading": "Mavjud hisobingizga havola"
}
},
"verify_email": {
"6_digit_code": "6 xonali kod",
"code": "Kod",
"description": "Yuborilgan 6 xonali kodni kiriting: <em>%(email)s</em>",
"headline": "Elektron pochtangizni tasdiqlang"
}
}
}
+10 -2
View File
@@ -74,9 +74,13 @@
},
"consent": {
"client_wants_access": "<span>%(client_name)s</span> 位于 <span>%(redirect_uri)s</span>,请求访问您的账户。",
"continue_to": "继续前往 <span>%(client_name)s</span>?",
"heading": "允许访问你的账户?",
"make_sure_you_trust": "请确保你信任 <span>%(client_name)s</span> 。",
"scope_list_preface": "一旦继续你将允许 <span>%(client_name)s</span>:",
"this_will_allow": "这将允许 <span>%(client_name)s</span> :",
"this_will_setup": "这将使用你在 <span>%(server_name)s</span> 上的账户配置 %(client_name)s(<span>%(client_uri)s</span>)。",
"use_another_account": "使用另一账户",
"you_may_be_sharing": "你可能正在与本网站或 app 分享敏感信息。"
},
"device_card": {
@@ -98,7 +102,8 @@
"granted": {
"description": "您已授予 %(client_name)s 访问权限。您可以关闭此窗口。",
"heading": "已授予访问权限"
}
},
"this_will_setup": "另一设备尝试使用你在上的账户 <span>%(server_name)s</span> 配置 %(client_name)s(<span>%(client_uri)s</span>)。请确保你认识该设备。"
},
"device_display_name": {
"client_on_device": "运行在 %(device_name)s 的 %(client_name)s",
@@ -145,6 +150,9 @@
"username_too_long": "用户名太长",
"username_too_short": "用户名太短"
},
"legacy_consent": {
"this_will_setup": "这将设置你位于 <span>%(server_name)s</span> 上的账户:<span>%(client_name)s</span>。"
},
"login": {
"call_to_register": "还没有账户?",
"continue_with_provider": "以 %(provider)s 继续",
@@ -275,7 +283,7 @@
"6_digit_code": "6位数代码",
"code": "代码",
"description": "输入发送至以下邮箱的6位数代码:<em>%(email)s</em>",
"headline": "验证邮箱"
"headline": "验证邮件地址"
}
}
}