mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-09-27 11:30:20 +00:00
Merge branch 'main' into rei/violations_are_tagged
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+39
-38
@@ -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
@@ -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 ##
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>;
|
||||
);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "你输入的代码错误"
|
||||
|
||||
Generated
+1386
-1537
File diff suppressed because it is too large
Load Diff
+24
-24
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" -%}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "验证邮件地址"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user