Merge remote-tracking branch 'origin/main' into quenting/better-logging

This commit is contained in:
Quentin Gliech
2025-04-23 17:28:40 +02:00
53 changed files with 3415 additions and 1395 deletions

60
Cargo.lock generated
View File

@@ -3114,7 +3114,7 @@ dependencies = [
[[package]]
name = "mas-axum-utils"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"axum",
"axum-extra",
@@ -3147,7 +3147,7 @@ dependencies = [
[[package]]
name = "mas-cli"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"axum",
@@ -3220,7 +3220,7 @@ dependencies = [
[[package]]
name = "mas-config"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"camino",
@@ -3250,7 +3250,7 @@ dependencies = [
[[package]]
name = "mas-context"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"console",
"opentelemetry",
@@ -3266,7 +3266,7 @@ dependencies = [
[[package]]
name = "mas-data-model"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"base64ct",
"chrono",
@@ -3287,7 +3287,7 @@ dependencies = [
[[package]]
name = "mas-email"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"async-trait",
"lettre",
@@ -3298,7 +3298,7 @@ dependencies = [
[[package]]
name = "mas-handlers"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"aide",
"anyhow",
@@ -3376,7 +3376,7 @@ dependencies = [
[[package]]
name = "mas-http"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"futures-util",
"headers",
@@ -3397,7 +3397,7 @@ dependencies = [
[[package]]
name = "mas-i18n"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"camino",
"icu_calendar",
@@ -3419,7 +3419,7 @@ dependencies = [
[[package]]
name = "mas-i18n-scan"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"camino",
"clap",
@@ -3433,7 +3433,7 @@ dependencies = [
[[package]]
name = "mas-iana"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"schemars",
"serde",
@@ -3441,7 +3441,7 @@ dependencies = [
[[package]]
name = "mas-iana-codegen"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3457,7 +3457,7 @@ dependencies = [
[[package]]
name = "mas-jose"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"base64ct",
"chrono",
@@ -3487,7 +3487,7 @@ dependencies = [
[[package]]
name = "mas-keystore"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"aead",
"base64ct",
@@ -3515,7 +3515,7 @@ dependencies = [
[[package]]
name = "mas-listener"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"bytes",
@@ -3540,7 +3540,7 @@ dependencies = [
[[package]]
name = "mas-matrix"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3550,7 +3550,7 @@ dependencies = [
[[package]]
name = "mas-matrix-synapse"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3567,7 +3567,7 @@ dependencies = [
[[package]]
name = "mas-oidc-client"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"assert_matches",
"async-trait",
@@ -3603,7 +3603,7 @@ dependencies = [
[[package]]
name = "mas-policy"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -3620,7 +3620,7 @@ dependencies = [
[[package]]
name = "mas-router"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"axum",
"serde",
@@ -3631,7 +3631,7 @@ dependencies = [
[[package]]
name = "mas-spa"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"camino",
"serde",
@@ -3640,7 +3640,7 @@ dependencies = [
[[package]]
name = "mas-storage"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"async-trait",
"chrono",
@@ -3662,7 +3662,7 @@ dependencies = [
[[package]]
name = "mas-storage-pg"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"async-trait",
"chrono",
@@ -3688,7 +3688,7 @@ dependencies = [
[[package]]
name = "mas-tasks"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"async-trait",
@@ -3720,7 +3720,7 @@ dependencies = [
[[package]]
name = "mas-templates"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -3750,7 +3750,7 @@ dependencies = [
[[package]]
name = "mas-tower"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"http",
"opentelemetry",
@@ -4020,7 +4020,7 @@ dependencies = [
[[package]]
name = "oauth2-types"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"assert_matches",
"base64ct",
@@ -6170,7 +6170,7 @@ dependencies = [
[[package]]
name = "syn2mas"
version = "0.15.0-rc.0"
version = "0.15.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -6182,14 +6182,17 @@ dependencies = [
"futures-util",
"insta",
"mas-config",
"mas-iana",
"mas-storage",
"mas-storage-pg",
"oauth2-types",
"opentelemetry",
"opentelemetry-semantic-conventions",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.12",
"thiserror-ext",
@@ -6197,6 +6200,7 @@ dependencies = [
"tokio-util",
"tracing",
"ulid",
"url",
"uuid",
]

View File

@@ -4,7 +4,7 @@ members = ["crates/*"]
resolver = "2"
# Updated in the CI with a `sed` command
package.version = "0.15.0-rc.0"
package.version = "0.15.0"
package.license = "AGPL-3.0-only"
package.authors = ["Element Backend Team"]
package.edition = "2024"
@@ -27,35 +27,35 @@ broken_intra_doc_links = "deny"
[workspace.dependencies]
# Workspace crates
mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.15.0-rc.0" }
mas-cli = { path = "./crates/cli/", version = "=0.15.0-rc.0" }
mas-config = { path = "./crates/config/", version = "=0.15.0-rc.0" }
mas-context = { path = "./crates/context/", version = "=0.15.0-rc.0" }
mas-data-model = { path = "./crates/data-model/", version = "=0.15.0-rc.0" }
mas-email = { path = "./crates/email/", version = "=0.15.0-rc.0" }
mas-graphql = { path = "./crates/graphql/", version = "=0.15.0-rc.0" }
mas-handlers = { path = "./crates/handlers/", version = "=0.15.0-rc.0" }
mas-http = { path = "./crates/http/", version = "=0.15.0-rc.0" }
mas-i18n = { path = "./crates/i18n/", version = "=0.15.0-rc.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.15.0-rc.0" }
mas-iana = { path = "./crates/iana/", version = "=0.15.0-rc.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.15.0-rc.0" }
mas-jose = { path = "./crates/jose/", version = "=0.15.0-rc.0" }
mas-keystore = { path = "./crates/keystore/", version = "=0.15.0-rc.0" }
mas-listener = { path = "./crates/listener/", version = "=0.15.0-rc.0" }
mas-matrix = { path = "./crates/matrix/", version = "=0.15.0-rc.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.15.0-rc.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.15.0-rc.0" }
mas-policy = { path = "./crates/policy/", version = "=0.15.0-rc.0" }
mas-router = { path = "./crates/router/", version = "=0.15.0-rc.0" }
mas-spa = { path = "./crates/spa/", version = "=0.15.0-rc.0" }
mas-storage = { path = "./crates/storage/", version = "=0.15.0-rc.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.15.0-rc.0" }
mas-tasks = { path = "./crates/tasks/", version = "=0.15.0-rc.0" }
mas-templates = { path = "./crates/templates/", version = "=0.15.0-rc.0" }
mas-tower = { path = "./crates/tower/", version = "=0.15.0-rc.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=0.15.0-rc.0" }
syn2mas = { path = "./crates/syn2mas", version = "=0.15.0-rc.0" }
mas-axum-utils = { path = "./crates/axum-utils/", version = "=0.15.0" }
mas-cli = { path = "./crates/cli/", version = "=0.15.0" }
mas-config = { path = "./crates/config/", version = "=0.15.0" }
mas-context = { path = "./crates/context/", version = "=0.15.0" }
mas-data-model = { path = "./crates/data-model/", version = "=0.15.0" }
mas-email = { path = "./crates/email/", version = "=0.15.0" }
mas-graphql = { path = "./crates/graphql/", version = "=0.15.0" }
mas-handlers = { path = "./crates/handlers/", version = "=0.15.0" }
mas-http = { path = "./crates/http/", version = "=0.15.0" }
mas-i18n = { path = "./crates/i18n/", version = "=0.15.0" }
mas-i18n-scan = { path = "./crates/i18n-scan/", version = "=0.15.0" }
mas-iana = { path = "./crates/iana/", version = "=0.15.0" }
mas-iana-codegen = { path = "./crates/iana-codegen/", version = "=0.15.0" }
mas-jose = { path = "./crates/jose/", version = "=0.15.0" }
mas-keystore = { path = "./crates/keystore/", version = "=0.15.0" }
mas-listener = { path = "./crates/listener/", version = "=0.15.0" }
mas-matrix = { path = "./crates/matrix/", version = "=0.15.0" }
mas-matrix-synapse = { path = "./crates/matrix-synapse/", version = "=0.15.0" }
mas-oidc-client = { path = "./crates/oidc-client/", version = "=0.15.0" }
mas-policy = { path = "./crates/policy/", version = "=0.15.0" }
mas-router = { path = "./crates/router/", version = "=0.15.0" }
mas-spa = { path = "./crates/spa/", version = "=0.15.0" }
mas-storage = { path = "./crates/storage/", version = "=0.15.0" }
mas-storage-pg = { path = "./crates/storage-pg/", version = "=0.15.0" }
mas-tasks = { path = "./crates/tasks/", version = "=0.15.0" }
mas-templates = { path = "./crates/templates/", version = "=0.15.0" }
mas-tower = { path = "./crates/tower/", version = "=0.15.0" }
oauth2-types = { path = "./crates/oauth2-types/", version = "=0.15.0" }
syn2mas = { path = "./crates/syn2mas", version = "=0.15.0" }
# OpenAPI schema generation and validation
[workspace.dependencies.aide]

View File

@@ -11,7 +11,7 @@ use camino::Utf8PathBuf;
use clap::Parser;
use figment::Figment;
use mas_config::{ConfigurationSection, RootConfig, SyncConfig};
use mas_storage::SystemClock;
use mas_storage::{Clock as _, SystemClock};
use mas_storage_pg::MIGRATOR;
use rand::SeedableRng;
use tokio::io::AsyncWriteExt;
@@ -46,6 +46,10 @@ enum Subcommand {
/// If not specified, the config will be written to stdout
#[clap(short, long)]
output: Option<Utf8PathBuf>,
/// Existing Synapse configuration used to generate the MAS config
#[arg(short, long, action = clap::ArgAction::Append)]
synapse_config: Vec<Utf8PathBuf>,
},
/// Sync the clients and providers from the config file to the database
@@ -88,14 +92,24 @@ impl Options {
info!("Configuration file looks good");
}
SC::Generate { output } => {
SC::Generate {
output,
synapse_config,
} => {
let _span = info_span!("cli.config.generate").entered();
let clock = SystemClock::default();
// XXX: we should disallow SeedableRng::from_entropy
let rng = rand_chacha::ChaChaRng::from_entropy();
let config = RootConfig::generate(rng).await?;
let config = serde_yaml::to_string(&config)?;
let mut rng = rand_chacha::ChaChaRng::from_entropy();
let mut config = RootConfig::generate(&mut rng).await?;
if !synapse_config.is_empty() {
info!("Adjusting MAS config to match Synapse config from {synapse_config:?}");
let synapse_config = syn2mas::synapse_config::Config::load(&synapse_config)?;
config = synapse_config.adjust_mas_config(config, &mut rng, clock.now());
}
let config = serde_yaml::to_string(&config)?;
if let Some(output) = output {
info!("Writing configuration to {output:?}");
let mut file = tokio::fs::File::create(output).await?;

View File

@@ -1,3 +1,8 @@
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::{collections::HashMap, process::ExitCode, time::Duration};
use anyhow::Context;
@@ -15,7 +20,7 @@ use sqlx::{Connection, Either, PgConnection, postgres::PgConnectOptions, types::
use syn2mas::{
LockedMasDatabase, MasWriter, Progress, ProgressStage, SynapseReader, synapse_config,
};
use tracing::{Instrument, error, info, info_span, warn};
use tracing::{Instrument, error, info, info_span};
use crate::util::{DatabaseConnectOptions, database_connection_from_config_with_options};
@@ -32,19 +37,10 @@ pub(super) struct Options {
#[command(subcommand)]
subcommand: Subcommand,
/// This version of the syn2mas tool is EXPERIMENTAL and INCOMPLETE. It is
/// only suitable for TESTING. If you want to use this tool anyway,
/// please pass this argument.
///
/// If you want to migrate from Synapse to MAS today, please use the
/// Node.js-based tool in the MAS repository.
#[clap(long = "i-swear-i-am-just-testing-in-a-staging-environment")]
experimental_accepted: bool,
/// Path to the Synapse configuration (in YAML format).
/// May be specified multiple times if multiple Synapse configuration files
/// are in use.
#[clap(long = "synapse-config")]
#[clap(long = "synapse-config", global = true)]
synapse_configuration_files: Vec<Utf8PathBuf>,
/// Override the Synapse database URI.
@@ -64,7 +60,7 @@ pub(super) struct Options {
/// environment variables `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`,
/// `PGPASSWORD`, etc. It is valid to specify the URL `postgresql:` and
/// configure all values through those environment variables.
#[clap(long = "synapse-database-uri")]
#[clap(long = "synapse-database-uri", global = true)]
synapse_database_uri: Option<PgConnectOptions>,
}
@@ -74,8 +70,17 @@ enum Subcommand {
///
/// It is OK for Synapse to be online during these checks.
Check,
/// Perform a migration. Synapse must be offline during this process.
Migrate,
Migrate {
/// Perform a dry-run migration, which is safe to run with Synapse
/// running, and will restore the MAS database to an empty state.
///
/// This still *does* write to the MAS database, making it more
/// realistic compared to the final migration.
#[clap(long)]
dry_run: bool,
},
}
/// The number of parallel writing transactions active against the MAS database.
@@ -85,14 +90,6 @@ impl Options {
#[tracing::instrument("cli.syn2mas.run", skip_all)]
#[allow(clippy::too_many_lines)]
pub async fn run(self, figment: &Figment) -> anyhow::Result<ExitCode> {
warn!(
"This version of the syn2mas tool is EXPERIMENTAL and INCOMPLETE. Do not use it, except for TESTING."
);
if !self.experimental_accepted {
error!("Please agree that you can only use this tool for testing.");
return Ok(ExitCode::FAILURE);
}
if self.synapse_configuration_files.is_empty() {
error!("Please specify the path to the Synapse configuration file(s).");
return Ok(ExitCode::FAILURE);
@@ -130,11 +127,10 @@ impl Options {
.await
.context("could not run migrations")?;
if matches!(&self.subcommand, Subcommand::Migrate) {
if matches!(&self.subcommand, Subcommand::Migrate { .. }) {
// First perform a config sync
// This is crucial to ensure we register upstream OAuth providers
// in the MAS database
//
let config = SyncConfig::extract(figment)?;
let clock = SystemClock::default();
let encrypter = config.secrets.encrypter();
@@ -214,7 +210,8 @@ impl Options {
Ok(ExitCode::SUCCESS)
}
Subcommand::Migrate => {
Subcommand::Migrate { dry_run } => {
let provider_id_mappings: HashMap<String, Uuid> = {
let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(figment)?;
@@ -230,21 +227,20 @@ impl Options {
// TODO how should we handle warnings at this stage?
// TODO this dry-run flag should be set to false in real circumstances !!!
let reader = SynapseReader::new(&mut syn_conn, true).await?;
let mut writer_mas_connections = Vec::with_capacity(NUM_WRITER_CONNECTIONS);
for _ in 0..NUM_WRITER_CONNECTIONS {
writer_mas_connections.push(
let reader = SynapseReader::new(&mut syn_conn, dry_run).await?;
let writer_mas_connections =
futures_util::future::try_join_all((0..NUM_WRITER_CONNECTIONS).map(|_| {
database_connection_from_config_with_options(
&config,
&DatabaseConnectOptions {
log_slow_statements: false,
},
)
.await?,
);
}
let writer = MasWriter::new(mas_connection, writer_mas_connections).await?;
}))
.instrument(tracing::info_span!("syn2mas.mas_writer_connections"))
.await?;
let writer =
MasWriter::new(mas_connection, writer_mas_connections, dry_run).await?;
let clock = SystemClock::default();
// TODO is this rng ok?
@@ -257,7 +253,6 @@ impl Options {
tokio::spawn(occasional_progress_logger(progress.clone()));
let mas_matrix = MatrixConfig::extract(figment)?;
eprintln!("\n\n");
syn2mas::migrate(
reader,
writer,
@@ -277,13 +272,13 @@ impl Options {
}
}
/// Logs progress every 30 seconds, as a lightweight alternative to a progress
/// bar. For most deployments, the migration will not take 30 seconds so this
/// Logs progress every 5 seconds, as a lightweight alternative to a progress
/// bar. For most deployments, the migration will not take 5 seconds so this
/// will not be relevant. In other cases, this will give the operator an idea of
/// what's going on.
async fn occasional_progress_logger(progress: Progress) {
loop {
tokio::time::sleep(Duration::from_secs(30)).await;
tokio::time::sleep(Duration::from_secs(5)).await;
match &**progress.get_current_stage() {
ProgressStage::SettingUp => {
info!(name: "progress", "still setting up");

View File

@@ -10,7 +10,7 @@ use std::{io::IsTerminal, process::ExitCode, sync::Arc};
use anyhow::Context;
use clap::Parser;
use mas_config::{ConfigurationSection, TelemetryConfig};
use mas_config::{ConfigurationSectionExt, TelemetryConfig};
use sentry_tracing::EventFilter;
use tracing_subscriber::{
EnvFilter, Layer, Registry, filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt,
@@ -109,7 +109,7 @@ async fn try_main() -> anyhow::Result<ExitCode> {
let figment = opts.figment();
let telemetry_config =
TelemetryConfig::extract(&figment).context("Failed to load telemetry config")?;
TelemetryConfig::extract_or_default(&figment).context("Failed to load telemetry config")?;
// Setup Sentry
let sentry = sentry::init((

View File

@@ -38,7 +38,9 @@ pub use self::{
Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp,
},
matrix::{HomeserverKind, MatrixConfig},
passwords::{Algorithm as PasswordAlgorithm, PasswordsConfig},
passwords::{
Algorithm as PasswordAlgorithm, HashingScheme as PasswordHashingScheme, PasswordsConfig,
},
policy::PolicyConfig,
rate_limiting::RateLimitingConfig,
secrets::SecretsConfig,

View File

@@ -16,7 +16,7 @@ use crate::ConfigurationSection;
fn default_schemes() -> Vec<HashingScheme> {
vec![HashingScheme {
version: 1,
algorithm: Algorithm::Argon2id,
algorithm: Algorithm::default(),
cost: None,
secret: None,
secret_file: None,
@@ -36,10 +36,14 @@ fn default_minimum_complexity() -> u8 {
pub struct PasswordsConfig {
/// Whether password-based authentication is enabled
#[serde(default = "default_enabled")]
enabled: bool,
pub enabled: bool,
/// The hashing schemes to use for hashing and validating passwords
///
/// The hashing scheme with the highest version number will be used for
/// hashing new passwords.
#[serde(default = "default_schemes")]
schemes: Vec<HashingScheme>,
pub schemes: Vec<HashingScheme>,
/// Score between 0 and 4 determining the minimum allowed password
/// complexity. Scores are based on the ESTIMATED number of guesses
@@ -154,23 +158,30 @@ impl PasswordsConfig {
}
}
/// Parameters for a password hashing scheme
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HashingScheme {
version: u16,
/// The version of the hashing scheme. They must be unique, and the highest
/// version will be used for hashing new passwords.
pub version: u16,
algorithm: Algorithm,
/// The hashing algorithm to use
pub algorithm: Algorithm,
/// Cost for the bcrypt algorithm
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(default = "default_bcrypt_cost")]
cost: Option<u32>,
pub cost: Option<u32>,
/// An optional secret to use when hashing passwords. This makes it harder
/// to brute-force the passwords in case of a database leak.
#[serde(skip_serializing_if = "Option::is_none")]
secret: Option<String>,
pub secret: Option<String>,
/// Same as `secret`, but read from a file.
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
secret_file: Option<Utf8PathBuf>,
pub secret_file: Option<Utf8PathBuf>,
}
#[allow(clippy::unnecessary_wraps)]
@@ -179,13 +190,14 @@ fn default_bcrypt_cost() -> Option<u32> {
}
/// A hashing algorithm
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Algorithm {
/// bcrypt
Bcrypt,
/// argon2id
#[default]
Argon2id,
/// PBKDF2

View File

@@ -418,6 +418,23 @@ pub struct Provider {
)]
pub id: Ulid,
/// The ID of the provider that was used by Synapse.
/// In order to perform a Synapse-to-MAS migration, this must be specified.
///
/// ## For providers that used OAuth 2.0 or OpenID Connect in Synapse
///
/// ### For `oidc_providers`:
/// This should be specified as `oidc-` followed by the ID that was
/// configured as `idp_id` in one of the `oidc_providers` in the Synapse
/// configuration.
/// For example, if Synapse's configuration contained `idp_id: wombat` for
/// this provider, then specify `oidc-wombat` here.
///
/// ### For `oidc_config` (legacy):
/// Specify `oidc` here.
#[serde(skip_serializing_if = "Option::is_none")]
pub synapse_idp_id: Option<String>,
/// The OIDC issuer URL
///
/// This is required if OIDC discovery is enabled (which is the default)
@@ -548,21 +565,4 @@ pub struct Provider {
/// Orders of the keys are not preserved.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub additional_authorization_parameters: BTreeMap<String, String>,
/// The ID of the provider that was used by Synapse.
/// In order to perform a Synapse-to-MAS migration, this must be specified.
///
/// ## For providers that used OAuth 2.0 or OpenID Connect in Synapse
///
/// ### For `oidc_providers`:
/// This should be specified as `oidc-` followed by the ID that was
/// configured as `idp_id` in one of the `oidc_providers` in the Synapse
/// configuration.
/// For example, if Synapse's configuration contained `idp_id: wombat` for
/// this provider, then specify `oidc-wombat` here.
///
/// ### For `oidc_config` (legacy):
/// Specify `oidc` here.
#[serde(skip_serializing_if = "Option::is_none")]
pub synapse_idp_id: Option<String>,
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__upstream_oauth_links\n (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "026adeffc646b41ebc096bb874d110039b9a4a0425fd566e401f56ea215de0dd"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__user_emails\n (user_email_id, user_id, email, created_at, confirmed_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "08ad2855f0baaaed9d6af23c8bf035e9a087ff27b06e804464a432d93e5a25f1"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__compat_sessions (\n compat_session_id, user_id,\n device_id, human_name,\n created_at, is_synapse_admin,\n last_active_at, last_active_ip,\n user_agent)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::UUID[],\n $3::TEXT[], $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[], $6::BOOLEAN[],\n $7::TIMESTAMP WITH TIME ZONE[], $8::INET[],\n $9::TEXT[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TextArray",
"TimestamptzArray",
"BoolArray",
"TimestamptzArray",
"InetArray",
"TextArray"
]
},
"nullable": []
},
"hash": "09db58b250c20ab9d1701653165233e5c9aabfdae1f0ee9b77c909b2bb2f3e25"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__compat_refresh_tokens (\n compat_refresh_token_id,\n compat_session_id,\n compat_access_token_id,\n refresh_token,\n created_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::UUID[],\n $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "1d1004d0fb5939fbf30c1986b80b986b1b4864a778525d0b8b0ad6678aef3e9f"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__user_unsupported_third_party_ids\n (user_id, medium, address, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"TextArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "204cf4811150a7fdeafa9373647a9cd62ac3c9e58155882858c6056e2ef6c30d"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__users (\n user_id, username,\n created_at, locked_at,\n deactivated_at,\n can_request_admin, is_guest)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[],\n $6::BOOL[], $7::BOOL[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"TextArray",
"TimestamptzArray",
"TimestamptzArray",
"TimestamptzArray",
"BoolArray",
"BoolArray"
]
},
"nullable": []
},
"hash": "207b880ec2dd484ad05a7138ba485277958b66e4534561686c073e282fafaf2a"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__user_passwords\n (user_password_id, user_id, hashed_password, created_at, version)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray",
"Int4Array"
]
},
"nullable": []
},
"hash": "24f6ce6280dc6675ab1ebdde0c5e3db8ff7a686180d71052911879f186ed1c8e"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__compat_sessions (\n compat_session_id, user_id,\n device_id, human_name,\n created_at, is_synapse_admin,\n last_active_at, last_active_ip,\n user_agent)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::UUID[],\n $3::TEXT[], $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[], $6::BOOLEAN[],\n $7::TIMESTAMP WITH TIME ZONE[], $8::INET[],\n $9::TEXT[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TextArray",
"TimestamptzArray",
"BoolArray",
"TimestamptzArray",
"InetArray",
"TextArray"
]
},
"nullable": []
},
"hash": "396c97dbfbc932c73301daa7376e36f4ef03e1224a0a89a921e5a3d398a5d35c"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__compat_access_tokens (\n compat_access_token_id,\n compat_session_id,\n access_token,\n created_at,\n expires_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::TEXT[],\n $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "86b2b02fbb6350100d794e4d0fa3c67bf00fd3e411f769b9f25dec27428489ed"
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__compat_refresh_tokens (\n compat_refresh_token_id,\n compat_session_id,\n compat_access_token_id,\n refresh_token,\n created_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::UUID[],\n $4::TEXT[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "88975196c4c174d464b33aa015ce5d8cac3836701fc24922f4f0e8b98d330796"
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__user_unsupported_third_party_ids\n (user_id, medium, address, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::TEXT[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"TextArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "b11590549fdd4cdcd36c937a353b5b37ab50db3505712c35610b822cda322b5b"
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__user_passwords\n (user_password_id, user_id, hashed_password, created_at, version)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $5::INTEGER[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray",
"Int4Array"
]
},
"nullable": []
},
"hash": "c6c7db1d578efc45b9e8c8bfea47cafe3f85d639452fd0593b2773997dfc7425"
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__compat_access_tokens (\n compat_access_token_id,\n compat_session_id,\n access_token,\n created_at,\n expires_at)\n SELECT * FROM UNNEST(\n $1::UUID[],\n $2::UUID[],\n $3::TEXT[],\n $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "d55adc78a0c222e19688e6ac810ad976c3a0ff238046872b17d3f412beda62c7"
}

View File

@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__upstream_oauth_links\n (upstream_oauth_link_id, user_id, upstream_oauth_provider_id, subject, created_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::UUID[], $4::TEXT[], $5::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "d79fd99ebed9033711f96113005096c848ae87c43b6430246ef3b6a1dc6a7a32"
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__user_emails\n (user_email_id, user_id, email, created_at, confirmed_at)\n SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::TEXT[], $4::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"UuidArray",
"TextArray",
"TimestamptzArray"
]
},
"nullable": []
},
"hash": "dfbd462f7874d3dae551f2a0328a853a8a7efccdc20b968d99d8c18deda8dd00"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO syn2mas__users (\n user_id, username,\n created_at, locked_at,\n deactivated_at,\n can_request_admin, is_guest)\n SELECT * FROM UNNEST(\n $1::UUID[], $2::TEXT[],\n $3::TIMESTAMP WITH TIME ZONE[], $4::TIMESTAMP WITH TIME ZONE[],\n $5::TIMESTAMP WITH TIME ZONE[],\n $6::BOOL[], $7::BOOL[])\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"TextArray",
"TimestamptzArray",
"TimestamptzArray",
"TimestamptzArray",
"BoolArray",
"BoolArray"
]
},
"nullable": []
},
"hash": "f2820b3752cf66669551ef90a10817cb6fe71203b2d3471e838f841b53e688d1"
}

View File

@@ -16,6 +16,7 @@ bitflags.workspace = true
camino.workspace = true
figment.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
thiserror-ext.workspace = true
tokio.workspace = true
@@ -26,6 +27,7 @@ compact_str.workspace = true
tracing.workspace = true
futures-util = "0.3.31"
rustc-hash = "2.1.1"
url.workspace = true
rand.workspace = true
rand_chacha = "0.3.1"
@@ -33,7 +35,9 @@ uuid = "1.16.0"
ulid = { workspace = true, features = ["uuid"] }
mas-config.workspace = true
mas-iana.workspace = true
mas-storage.workspace = true
oauth2-types.workspace = true
opentelemetry.workspace = true
opentelemetry-semantic-conventions.workspace = true

View File

@@ -1,4 +1,4 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
@@ -16,10 +16,12 @@ use super::{MAS_TABLES_AFFECTED_BY_MIGRATION, is_syn2mas_in_progress, locking::L
#[derive(Debug, Error, ContextInto)]
pub enum Error {
#[error("the MAS database is not empty: rows found in at least `{table}`")]
#[error(
"The MAS database is not empty: rows found in at least `{table}`. Please drop and recreate the database, then try again."
)]
MasDatabaseNotEmpty { table: &'static str },
#[error("query against {table} failed — is this actually a MAS database?")]
#[error("Query against {table} failed — is this actually a MAS database?")]
MaybeNotMas {
#[source]
source: sqlx::Error,
@@ -29,7 +31,7 @@ pub enum Error {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
#[error("unable to check if syn2mas is already in progress")]
#[error("Unable to check if syn2mas is already in progress")]
UnableToCheckInProgress(#[source] super::Error),
}

View File

@@ -1,4 +1,4 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
@@ -123,7 +123,6 @@ pub async fn restore_constraint(
table_name,
definition,
} = &constraint;
info!("rebuilding constraint {name}");
sqlx::query(&format!(
"ALTER TABLE {table_name} ADD CONSTRAINT {name} {definition};"

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO upstream_oauth_providers
(
upstream_oauth_provider_id,

View File

@@ -1,4 +1,4 @@
// Copyright 2024 New Vector Ltd.
// Copyright 2024, 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,12 @@
//! This module does not implement any of the safety checks that should be run
//! *before* the migration.
use std::{pin::pin, time::Instant};
use std::time::Instant;
use chrono::{DateTime, Utc};
use compact_str::CompactString;
use futures_util::{SinkExt, StreamExt as _, TryFutureExt, TryStreamExt as _};
use mas_storage::Clock;
use opentelemetry::{KeyValue, metrics::Counter};
use rand::{RngCore, SeedableRng};
use thiserror::Error;
use thiserror_ext::ContextInto;
@@ -33,16 +32,11 @@ use crate::{
MasNewEmailThreepid, MasNewUnsupportedThreepid, MasNewUpstreamOauthLink, MasNewUser,
MasNewUserPassword, MasWriteBuffer, MasWriter,
},
progress::Progress,
progress::{EntityType, Progress},
synapse_reader::{
self, ExtractLocalpartError, FullUserId, SynapseAccessToken, SynapseDevice,
SynapseExternalId, SynapseRefreshableTokenPair, SynapseThreepid, SynapseUser,
},
telemetry::{
K_ENTITY, METER, V_ENTITY_DEVICES, V_ENTITY_EXTERNAL_IDS,
V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS, V_ENTITY_REFRESHABLE_TOKEN_PAIRS,
V_ENTITY_THREEPIDS, V_ENTITY_USERS,
},
};
#[derive(Debug, Error, ContextInto)]
@@ -146,7 +140,7 @@ struct MigrationState {
///
/// - An underlying database access error, either to MAS or to Synapse.
/// - Invalid data in the Synapse database.
#[allow(clippy::implicit_hasher, clippy::too_many_lines)]
#[expect(clippy::implicit_hasher)]
pub async fn migrate(
mut synapse: SynapseReader<'_>,
mas: MasWriter,
@@ -158,49 +152,6 @@ pub async fn migrate(
) -> Result<(), Error> {
let counts = synapse.count_rows().await.into_synapse("counting users")?;
let approx_total_counter = METER
.u64_counter("syn2mas.entity.approx_total")
.with_description("Approximate number of entities of this type to be migrated")
.build();
let migrated_otel_counter = METER
.u64_counter("syn2mas.entity.migrated")
.with_description("Number of entities of this type that have been migrated so far")
.build();
let skipped_otel_counter = METER
.u64_counter("syn2mas.entity.skipped")
.with_description("Number of entities of this type that have been skipped so far")
.build();
approx_total_counter.add(
counts.users as u64,
&[KeyValue::new(K_ENTITY, V_ENTITY_USERS)],
);
approx_total_counter.add(
counts.devices as u64,
&[KeyValue::new(K_ENTITY, V_ENTITY_DEVICES)],
);
approx_total_counter.add(
counts.threepids as u64,
&[KeyValue::new(K_ENTITY, V_ENTITY_THREEPIDS)],
);
approx_total_counter.add(
counts.external_ids as u64,
&[KeyValue::new(K_ENTITY, V_ENTITY_EXTERNAL_IDS)],
);
// assume 1 refreshable access token per refresh token.
let approx_nonrefreshable_access_tokens = counts.access_tokens - counts.refresh_tokens;
approx_total_counter.add(
approx_nonrefreshable_access_tokens as u64,
&[KeyValue::new(
K_ENTITY,
V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS,
)],
);
approx_total_counter.add(
counts.refresh_tokens as u64,
&[KeyValue::new(K_ENTITY, V_ENTITY_REFRESHABLE_TOKEN_PAIRS)],
);
let state = MigrationState {
server_name,
// We oversize the hashmaps, as the estimates are innaccurate, and we would like to avoid
@@ -213,83 +164,32 @@ pub async fn migrate(
provider_id_mapping,
};
let progress_counter = progress.migrating_data(V_ENTITY_USERS, counts.users);
let (mas, state) = migrate_users(
&mut synapse,
mas,
state,
rng,
progress_counter,
migrated_otel_counter.clone(),
skipped_otel_counter.clone(),
)
.await?;
let progress_counter = progress.migrating_data(EntityType::Users, counts.users);
let (mas, state) = migrate_users(&mut synapse, mas, state, rng, progress_counter).await?;
let progress_counter = progress.migrating_data(V_ENTITY_THREEPIDS, counts.threepids);
let (mas, state) = migrate_threepids(
&mut synapse,
mas,
rng,
state,
progress_counter,
migrated_otel_counter.clone(),
skipped_otel_counter.clone(),
)
.await?;
let progress_counter = progress.migrating_data(EntityType::ThreePids, counts.threepids);
let (mas, state) = migrate_threepids(&mut synapse, mas, rng, state, progress_counter).await?;
let progress_counter = progress.migrating_data(V_ENTITY_EXTERNAL_IDS, counts.external_ids);
let (mas, state) = migrate_external_ids(
&mut synapse,
mas,
rng,
state,
progress_counter,
migrated_otel_counter.clone(),
skipped_otel_counter.clone(),
)
.await?;
let progress_counter = progress.migrating_data(EntityType::ExternalIds, counts.external_ids);
let (mas, state) =
migrate_external_ids(&mut synapse, mas, rng, state, progress_counter).await?;
let progress_counter = progress.migrating_data(
V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS,
EntityType::NonRefreshableAccessTokens,
counts.access_tokens - counts.refresh_tokens,
);
let (mas, state) = migrate_unrefreshable_access_tokens(
&mut synapse,
mas,
clock,
rng,
state,
progress_counter,
migrated_otel_counter.clone(),
skipped_otel_counter.clone(),
)
.await?;
let (mas, state) =
migrate_unrefreshable_access_tokens(&mut synapse, mas, clock, rng, state, progress_counter)
.await?;
let progress_counter =
progress.migrating_data(V_ENTITY_REFRESHABLE_TOKEN_PAIRS, counts.refresh_tokens);
let (mas, state) = migrate_refreshable_token_pairs(
&mut synapse,
mas,
clock,
rng,
state,
progress_counter,
migrated_otel_counter.clone(),
skipped_otel_counter.clone(),
)
.await?;
progress.migrating_data(EntityType::RefreshableTokens, counts.refresh_tokens);
let (mas, state) =
migrate_refreshable_token_pairs(&mut synapse, mas, clock, rng, state, progress_counter)
.await?;
let progress_counter = progress.migrating_data("devices", counts.devices);
let (mas, _state) = migrate_devices(
&mut synapse,
mas,
rng,
state,
progress_counter,
migrated_otel_counter.clone(),
skipped_otel_counter.clone(),
)
.await?;
let progress_counter = progress.migrating_data(EntityType::Devices, counts.devices);
let (mas, _state) = migrate_devices(&mut synapse, mas, rng, state, progress_counter).await?;
synapse
.finish()
@@ -310,21 +210,19 @@ async fn migrate_users(
mut state: MigrationState,
rng: &mut impl RngCore,
progress_counter: ProgressCounter,
migrated_otel_counter: Counter<u64>,
skipped_otel_counter: Counter<u64>,
) -> Result<(MasWriter, MigrationState), Error> {
let start = Instant::now();
let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_USERS)];
let progress_counter_ = progress_counter.clone();
let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseUser>(10 * 1024 * 1024);
let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseUser>(100 * 1024);
// create a new RNG seeded from the passed RNG so that we can move it into the
// spawned task
let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
let task = tokio::spawn(
async move {
let mut user_buffer = MasWriteBuffer::new(&mas, MasWriter::write_users);
let mut password_buffer = MasWriteBuffer::new(&mas, MasWriter::write_passwords);
let mut user_buffer = MasWriteBuffer::new(&mas);
let mut password_buffer = MasWriteBuffer::new(&mas);
while let Some(user) = rx.recv().await {
// Handling an edge case: some AS users may have invalid localparts containing
@@ -356,7 +254,6 @@ async fn migrate_users(
if user.appservice_id.is_some() {
flags |= UserFlags::IS_APPSERVICE;
skipped_otel_counter.add(1, &otel_kv);
progress_counter.increment_skipped();
// Special case for appservice users: we don't insert them into the database
@@ -391,7 +288,6 @@ async fn migrate_users(
.into_mas("writing password")?;
}
migrated_otel_counter.add(1, &otel_kv);
progress_counter.increment_migrated();
}
@@ -423,7 +319,9 @@ async fn migrate_users(
res?;
info!(
"users migrated in {:.1}s",
"{} users migrated ({} skipped) in {:.1}s",
progress_counter_.migrated(),
progress_counter_.skipped(),
Instant::now().duration_since(start).as_secs_f64()
);
@@ -437,98 +335,116 @@ async fn migrate_threepids(
rng: &mut impl RngCore,
state: MigrationState,
progress_counter: ProgressCounter,
migrated_otel_counter: Counter<u64>,
skipped_otel_counter: Counter<u64>,
) -> Result<(MasWriter, MigrationState), Error> {
let start = Instant::now();
let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_THREEPIDS)];
let progress_counter_ = progress_counter.clone();
let mut email_buffer = MasWriteBuffer::new(&mas, MasWriter::write_email_threepids);
let mut unsupported_buffer = MasWriteBuffer::new(&mas, MasWriter::write_unsupported_threepids);
let mut users_stream = pin!(synapse.read_threepids());
let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseThreepid>(100 * 1024);
while let Some(threepid_res) = users_stream.next().await {
let SynapseThreepid {
user_id: synapse_user_id,
medium,
address,
added_at,
} = threepid_res.into_synapse("reading threepid")?;
let created_at: DateTime<Utc> = added_at.into();
// create a new RNG seeded from the passed RNG so that we can move it into the
// spawned task
let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
let task = tokio::spawn(
async move {
let mut email_buffer = MasWriteBuffer::new(&mas);
let mut unsupported_buffer = MasWriteBuffer::new(&mas);
let username = synapse_user_id
.extract_localpart(&state.server_name)
.into_extract_localpart(synapse_user_id.clone())?
.to_owned();
let Some(user_infos) = state.users.get(username.as_str()).copied() else {
return Err(Error::MissingUserFromDependentTable {
table: "user_threepids".to_owned(),
user: synapse_user_id,
});
};
while let Some(threepid) = rx.recv().await {
let SynapseThreepid {
user_id: synapse_user_id,
medium,
address,
added_at,
} = threepid;
let created_at: DateTime<Utc> = added_at.into();
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
};
let username = synapse_user_id
.extract_localpart(&state.server_name)
.into_extract_localpart(synapse_user_id.clone())?
.to_owned();
let Some(user_infos) = state.users.get(username.as_str()).copied() else {
return Err(Error::MissingUserFromDependentTable {
table: "user_threepids".to_owned(),
user: synapse_user_id,
});
};
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
continue;
};
if medium == "email" {
email_buffer
.write(
&mut mas,
MasNewEmailThreepid {
user_id: mas_user_id,
user_email_id: Uuid::from(Ulid::from_datetime_with_source(
created_at.into(),
&mut rng,
)),
email: address,
created_at,
},
)
.await
.into_mas("writing email")?;
} else {
unsupported_buffer
.write(
&mut mas,
MasNewUnsupportedThreepid {
user_id: mas_user_id,
medium,
address,
created_at,
},
)
.await
.into_mas("writing unsupported threepid")?;
}
progress_counter.increment_migrated();
}
if medium == "email" {
email_buffer
.write(
&mut mas,
MasNewEmailThreepid {
user_id: mas_user_id,
user_email_id: Uuid::from(Ulid::from_datetime_with_source(
created_at.into(),
rng,
)),
email: address,
created_at,
},
)
.finish(&mut mas)
.await
.into_mas("writing email")?;
} else {
.into_mas("writing email threepids")?;
unsupported_buffer
.write(
&mut mas,
MasNewUnsupportedThreepid {
user_id: mas_user_id,
medium,
address,
created_at,
},
)
.finish(&mut mas)
.await
.into_mas("writing unsupported threepid")?;
.into_mas("writing unsupported threepids")?;
Ok((mas, state))
}
.instrument(tracing::info_span!("ingest_task")),
);
migrated_otel_counter.add(1, &otel_kv);
progress_counter.increment_migrated();
}
// In case this has an error, we still want to join the task, so we look at the
// error later
let res = synapse
.read_threepids()
.map_err(|e| e.into_synapse("reading threepids"))
.forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
.inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
.await;
email_buffer
.finish(&mut mas)
.await
.into_mas("writing email threepids")?;
unsupported_buffer
.finish(&mut mas)
.await
.into_mas("writing unsupported threepids")?;
let (mas, state) = task.await.into_join("threepid write task")??;
res?;
info!(
"third-party IDs migrated in {:.1}s",
"{} third-party IDs migrated ({} skipped) in {:.1}s",
progress_counter_.migrated(),
progress_counter_.skipped(),
Instant::now().duration_since(start).as_secs_f64()
);
Ok((mas, state))
}
/// # Parameters
///
/// - `provider_id_mapping`: mapping from Synapse `auth_provider` ID to UUID of
/// the upstream provider in MAS.
#[tracing::instrument(skip_all, level = Level::INFO)]
async fn migrate_external_ids(
synapse: &mut SynapseReader<'_>,
@@ -536,76 +452,100 @@ async fn migrate_external_ids(
rng: &mut impl RngCore,
state: MigrationState,
progress_counter: ProgressCounter,
migrated_otel_counter: Counter<u64>,
skipped_otel_counter: Counter<u64>,
) -> Result<(MasWriter, MigrationState), Error> {
let start = Instant::now();
let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_EXTERNAL_IDS)];
let progress_counter_ = progress_counter.clone();
let mut write_buffer = MasWriteBuffer::new(&mas, MasWriter::write_upstream_oauth_links);
let mut extids_stream = pin!(synapse.read_user_external_ids());
let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseExternalId>(100 * 1024);
while let Some(extid_res) = extids_stream.next().await {
let SynapseExternalId {
user_id: synapse_user_id,
auth_provider,
external_id: subject,
} = extid_res.into_synapse("reading external ID")?;
let username = synapse_user_id
.extract_localpart(&state.server_name)
.into_extract_localpart(synapse_user_id.clone())?
.to_owned();
let Some(user_infos) = state.users.get(username.as_str()).copied() else {
return Err(Error::MissingUserFromDependentTable {
table: "user_external_ids".to_owned(),
user: synapse_user_id,
});
};
// create a new RNG seeded from the passed RNG so that we can move it into the
// spawned task
let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
let task = tokio::spawn(
async move {
let mut write_buffer = MasWriteBuffer::new(&mas);
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
};
while let Some(extid) = rx.recv().await {
let SynapseExternalId {
user_id: synapse_user_id,
auth_provider,
external_id: subject,
} = extid;
let username = synapse_user_id
.extract_localpart(&state.server_name)
.into_extract_localpart(synapse_user_id.clone())?
.to_owned();
let Some(user_infos) = state.users.get(username.as_str()).copied() else {
return Err(Error::MissingUserFromDependentTable {
table: "user_external_ids".to_owned(),
user: synapse_user_id,
});
};
let Some(&upstream_provider_id) = state.provider_id_mapping.get(&auth_provider) else {
return Err(Error::MissingAuthProviderMapping {
synapse_id: auth_provider,
user: synapse_user_id,
});
};
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
continue;
};
// To save having to store user creation times, extract it from the ULID
// This gives millisecond precision — good enough.
let user_created_ts = Ulid::from(mas_user_id.get()).datetime();
let Some(&upstream_provider_id) = state.provider_id_mapping.get(&auth_provider)
else {
return Err(Error::MissingAuthProviderMapping {
synapse_id: auth_provider,
user: synapse_user_id,
});
};
let link_id: Uuid = Ulid::from_datetime_with_source(user_created_ts, rng).into();
// To save having to store user creation times, extract it from the ULID
// This gives millisecond precision — good enough.
let user_created_ts = Ulid::from(mas_user_id.get()).datetime();
write_buffer
.write(
&mut mas,
MasNewUpstreamOauthLink {
link_id,
user_id: mas_user_id,
upstream_provider_id,
subject,
created_at: user_created_ts.into(),
},
)
.await
.into_mas("failed to write upstream link")?;
let link_id: Uuid =
Ulid::from_datetime_with_source(user_created_ts, &mut rng).into();
migrated_otel_counter.add(1, &otel_kv);
progress_counter.increment_migrated();
}
write_buffer
.write(
&mut mas,
MasNewUpstreamOauthLink {
link_id,
user_id: mas_user_id,
upstream_provider_id,
subject,
created_at: user_created_ts.into(),
},
)
.await
.into_mas("failed to write upstream link")?;
write_buffer
.finish(&mut mas)
.await
.into_mas("writing upstream links")?;
progress_counter.increment_migrated();
}
write_buffer
.finish(&mut mas)
.await
.into_mas("writing upstream links")?;
Ok((mas, state))
}
.instrument(tracing::info_span!("ingest_task")),
);
// In case this has an error, we still want to join the task, so we look at the
// error later
let res = synapse
.read_user_external_ids()
.map_err(|e| e.into_synapse("reading external ID"))
.forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
.inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
.await;
let (mas, state) = task.await.into_join("external IDs write task")??;
res?;
info!(
"upstream links (external IDs) migrated in {:.1}s",
"{} upstream links (external IDs) migrated ({} skipped) in {:.1}s",
progress_counter_.migrated(),
progress_counter_.skipped(),
Instant::now().duration_since(start).as_secs_f64()
);
@@ -627,20 +567,18 @@ async fn migrate_devices(
rng: &mut impl RngCore,
mut state: MigrationState,
progress_counter: ProgressCounter,
migrated_otel_counter: Counter<u64>,
skipped_otel_counter: Counter<u64>,
) -> Result<(MasWriter, MigrationState), Error> {
let start = Instant::now();
let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_DEVICES)];
let progress_counter_ = progress_counter.clone();
let (tx, mut rx) = tokio::sync::mpsc::channel(10 * 1024 * 1024);
let (tx, mut rx) = tokio::sync::mpsc::channel(100 * 1024);
// create a new RNG seeded from the passed RNG so that we can move it into the
// spawned task
let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
let task = tokio::spawn(
async move {
let mut write_buffer = MasWriteBuffer::new(&mas, MasWriter::write_compat_sessions);
let mut write_buffer = MasWriteBuffer::new(&mas);
while let Some(device) = rx.recv().await {
let SynapseDevice {
@@ -664,7 +602,6 @@ async fn migrate_devices(
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
};
@@ -721,7 +658,6 @@ async fn migrate_devices(
.await
.into_mas("writing compat sessions")?;
migrated_otel_counter.add(1, &otel_kv);
progress_counter.increment_migrated();
}
@@ -749,7 +685,9 @@ async fn migrate_devices(
res?;
info!(
"devices migrated in {:.1}s",
"{} devices migrated ({} skipped) in {:.1}s",
progress_counter_.migrated(),
progress_counter_.skipped(),
Instant::now().duration_since(start).as_secs_f64()
);
@@ -759,7 +697,6 @@ async fn migrate_devices(
/// Migrates unrefreshable access tokens (those without an associated refresh
/// token). Some of these may be deviceless.
#[tracing::instrument(skip_all, level = Level::INFO)]
#[allow(clippy::too_many_arguments)]
async fn migrate_unrefreshable_access_tokens(
synapse: &mut SynapseReader<'_>,
mut mas: MasWriter,
@@ -767,16 +704,11 @@ async fn migrate_unrefreshable_access_tokens(
rng: &mut impl RngCore,
mut state: MigrationState,
progress_counter: ProgressCounter,
migrated_otel_counter: Counter<u64>,
skipped_otel_counter: Counter<u64>,
) -> Result<(MasWriter, MigrationState), Error> {
let start = Instant::now();
let otel_kv = [KeyValue::new(
K_ENTITY,
V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS,
)];
let progress_counter_ = progress_counter.clone();
let (tx, mut rx) = tokio::sync::mpsc::channel(10 * 1024 * 1024);
let (tx, mut rx) = tokio::sync::mpsc::channel(100 * 1024);
let now = clock.now();
// create a new RNG seeded from the passed RNG so that we can move it into the
@@ -784,9 +716,8 @@ async fn migrate_unrefreshable_access_tokens(
let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
let task = tokio::spawn(
async move {
let mut write_buffer = MasWriteBuffer::new(&mas, MasWriter::write_compat_access_tokens);
let mut deviceless_session_write_buffer =
MasWriteBuffer::new(&mas, MasWriter::write_compat_sessions);
let mut write_buffer = MasWriteBuffer::new(&mas);
let mut deviceless_session_write_buffer = MasWriteBuffer::new(&mas);
while let Some(token) = rx.recv().await {
let SynapseAccessToken {
@@ -809,7 +740,6 @@ async fn migrate_unrefreshable_access_tokens(
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
};
@@ -818,7 +748,6 @@ async fn migrate_unrefreshable_access_tokens(
|| user_infos.flags.is_appservice()
{
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
}
@@ -879,7 +808,6 @@ async fn migrate_unrefreshable_access_tokens(
.await
.into_mas("writing compat access tokens")?;
migrated_otel_counter.add(1, &otel_kv);
progress_counter.increment_migrated();
}
write_buffer
@@ -910,7 +838,9 @@ async fn migrate_unrefreshable_access_tokens(
res?;
info!(
"non-refreshable access tokens migrated in {:.1}s",
"{} non-refreshable access tokens migrated ({} skipped) in {:.1}s",
progress_counter_.migrated(),
progress_counter_.skipped(),
Instant::now().duration_since(start).as_secs_f64()
);
@@ -920,7 +850,6 @@ async fn migrate_unrefreshable_access_tokens(
/// Migrates (access token, refresh token) pairs.
/// Does not migrate non-refreshable access tokens.
#[tracing::instrument(skip_all, level = Level::INFO)]
#[allow(clippy::too_many_arguments)]
async fn migrate_refreshable_token_pairs(
synapse: &mut SynapseReader<'_>,
mut mas: MasWriter,
@@ -928,111 +857,134 @@ async fn migrate_refreshable_token_pairs(
rng: &mut impl RngCore,
mut state: MigrationState,
progress_counter: ProgressCounter,
migrated_otel_counter: Counter<u64>,
skipped_otel_counter: Counter<u64>,
) -> Result<(MasWriter, MigrationState), Error> {
let start = Instant::now();
let otel_kv = [KeyValue::new(K_ENTITY, V_ENTITY_REFRESHABLE_TOKEN_PAIRS)];
let progress_counter_ = progress_counter.clone();
let mut token_stream = pin!(synapse.read_refreshable_token_pairs());
let mut access_token_write_buffer =
MasWriteBuffer::new(&mas, MasWriter::write_compat_access_tokens);
let mut refresh_token_write_buffer =
MasWriteBuffer::new(&mas, MasWriter::write_compat_refresh_tokens);
let (tx, mut rx) = tokio::sync::mpsc::channel::<SynapseRefreshableTokenPair>(100 * 1024);
while let Some(token_res) = token_stream.next().await {
let SynapseRefreshableTokenPair {
user_id: synapse_user_id,
device_id,
access_token,
refresh_token,
valid_until_ms,
last_validated,
} = token_res.into_synapse("reading Synapse refresh token")?;
// create a new RNG seeded from the passed RNG so that we can move it into the
// spawned task
let mut rng = rand_chacha::ChaChaRng::from_rng(rng).expect("failed to seed rng");
let now = clock.now();
let task = tokio::spawn(
async move {
let mut access_token_write_buffer = MasWriteBuffer::new(&mas);
let mut refresh_token_write_buffer = MasWriteBuffer::new(&mas);
let username = synapse_user_id
.extract_localpart(&state.server_name)
.into_extract_localpart(synapse_user_id.clone())?
.to_owned();
let Some(user_infos) = state.users.get(username.as_str()).copied() else {
return Err(Error::MissingUserFromDependentTable {
table: "refresh_tokens".to_owned(),
user: synapse_user_id,
});
};
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
};
if user_infos.flags.is_deactivated()
|| user_infos.flags.is_guest()
|| user_infos.flags.is_appservice()
{
progress_counter.increment_skipped();
skipped_otel_counter.add(1, &otel_kv);
continue;
}
// It's not always accurate, but last_validated is *often* the creation time of
// the device If we don't have one, then use the current time as a
// fallback.
let created_at = last_validated.map_or_else(|| clock.now(), DateTime::from);
// Use the existing device_id if this is the second token for a device
let session_id = *state
.devices_to_compat_sessions
.entry((mas_user_id, CompactString::new(&device_id)))
.or_insert_with(|| Uuid::from(Ulid::from_datetime_with_source(created_at.into(), rng)));
let access_token_id = Uuid::from(Ulid::from_datetime_with_source(created_at.into(), rng));
let refresh_token_id = Uuid::from(Ulid::from_datetime_with_source(created_at.into(), rng));
access_token_write_buffer
.write(
&mut mas,
MasNewCompatAccessToken {
token_id: access_token_id,
session_id,
while let Some(token) = rx.recv().await {
let SynapseRefreshableTokenPair {
user_id: synapse_user_id,
device_id,
access_token,
created_at,
expires_at: valid_until_ms.map(DateTime::from),
},
)
.await
.into_mas("writing compat access tokens")?;
refresh_token_write_buffer
.write(
&mut mas,
MasNewCompatRefreshToken {
refresh_token_id,
session_id,
access_token_id,
refresh_token,
created_at,
},
)
.await
.into_mas("writing compat refresh tokens")?;
valid_until_ms,
last_validated,
} = token;
migrated_otel_counter.add(1, &otel_kv);
progress_counter.increment_migrated();
}
let username = synapse_user_id
.extract_localpart(&state.server_name)
.into_extract_localpart(synapse_user_id.clone())?
.to_owned();
let Some(user_infos) = state.users.get(username.as_str()).copied() else {
return Err(Error::MissingUserFromDependentTable {
table: "refresh_tokens".to_owned(),
user: synapse_user_id,
});
};
access_token_write_buffer
.finish(&mut mas)
.await
.into_mas("writing compat access tokens")?;
let Some(mas_user_id) = user_infos.mas_user_id else {
progress_counter.increment_skipped();
continue;
};
refresh_token_write_buffer
.finish(&mut mas)
.await
.into_mas("writing compat refresh tokens")?;
if user_infos.flags.is_deactivated()
|| user_infos.flags.is_guest()
|| user_infos.flags.is_appservice()
{
progress_counter.increment_skipped();
continue;
}
// It's not always accurate, but last_validated is *often* the creation time of
// the device If we don't have one, then use the current time as a
// fallback.
let created_at = last_validated.map_or_else(|| now, DateTime::from);
// Use the existing device_id if this is the second token for a device
let session_id = *state
.devices_to_compat_sessions
.entry((mas_user_id, CompactString::new(&device_id)))
.or_insert_with(|| {
Uuid::from(Ulid::from_datetime_with_source(created_at.into(), &mut rng))
});
let access_token_id =
Uuid::from(Ulid::from_datetime_with_source(created_at.into(), &mut rng));
let refresh_token_id =
Uuid::from(Ulid::from_datetime_with_source(created_at.into(), &mut rng));
access_token_write_buffer
.write(
&mut mas,
MasNewCompatAccessToken {
token_id: access_token_id,
session_id,
access_token,
created_at,
expires_at: valid_until_ms.map(DateTime::from),
},
)
.await
.into_mas("writing compat access tokens")?;
refresh_token_write_buffer
.write(
&mut mas,
MasNewCompatRefreshToken {
refresh_token_id,
session_id,
access_token_id,
refresh_token,
created_at,
},
)
.await
.into_mas("writing compat refresh tokens")?;
progress_counter.increment_migrated();
}
access_token_write_buffer
.finish(&mut mas)
.await
.into_mas("writing compat access tokens")?;
refresh_token_write_buffer
.finish(&mut mas)
.await
.into_mas("writing compat refresh tokens")?;
Ok((mas, state))
}
.instrument(tracing::info_span!("ingest_task")),
);
// In case this has an error, we still want to join the task, so we look at the
// error later
let res = synapse
.read_refreshable_token_pairs()
.map_err(|e| e.into_synapse("reading refresh token pairs"))
.forward(PollSender::new(tx).sink_map_err(|_| Error::ChannelClosed))
.inspect_err(|e| tracing::error!(error = e as &dyn std::error::Error))
.await;
let (mas, state) = task.await.into_join("refresh token write task")??;
res?;
info!(
"refreshable token pairs migrated in {:.1}s",
"{} refreshable token pairs migrated ({} skipped) in {:.1}s",
progress_counter_.migrated(),
progress_counter_.skipped(),
Instant::now().duration_since(start).as_secs_f64()
);

View File

@@ -1,6 +1,89 @@
use std::sync::{Arc, atomic::AtomicU32};
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::sync::{Arc, LazyLock, atomic::AtomicU32};
use arc_swap::ArcSwap;
use opentelemetry::{
KeyValue,
metrics::{Counter, Gauge},
};
use crate::telemetry::METER;
/// A gauge that tracks the approximate number of entities of a given type
/// that will be migrated.
pub static APPROX_TOTAL_GAUGE: LazyLock<Gauge<u64>> = LazyLock::new(|| {
METER
.u64_gauge("syn2mas.entity.approx_total")
.with_description("Approximate number of entities of this type to be migrated")
.build()
});
/// A counter that tracks the number of entities of a given type that have
/// been migrated so far.
pub static MIGRATED_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("syn2mas.entity.migrated")
.with_description("Number of entities of this type that have been migrated so far")
.build()
});
/// A counter that tracks the number of entities of a given type that have
/// been skipped so far.
pub static SKIPPED_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("syn2mas.entity.skipped")
.with_description("Number of entities of this type that have been skipped so far")
.build()
});
/// Enum representing the different types of entities that syn2mas can migrate.
#[derive(Debug, Clone, Copy)]
pub enum EntityType {
/// Represents users
Users,
/// Represents devices
Devices,
/// Represents third-party IDs
ThreePids,
/// Represents external IDs
ExternalIds,
/// Represents non-refreshable access tokens
NonRefreshableAccessTokens,
/// Represents refreshable access tokens
RefreshableTokens,
}
impl std::fmt::Display for EntityType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
impl EntityType {
pub const fn name(self) -> &'static str {
match self {
Self::Users => "users",
Self::Devices => "devices",
Self::ThreePids => "threepids",
Self::ExternalIds => "external_ids",
Self::NonRefreshableAccessTokens => "nonrefreshable_access_tokens",
Self::RefreshableTokens => "refreshable_tokens",
}
}
pub fn as_kv(self) -> KeyValue {
KeyValue::new("entity", self.name())
}
}
/// Tracker for the progress of the migration
///
@@ -11,25 +94,37 @@ pub struct Progress {
current_stage: Arc<ArcSwap<ProgressStage>>,
}
#[derive(Clone, Default)]
#[derive(Clone)]
pub struct ProgressCounter {
inner: Arc<ProgressCounterInner>,
}
#[derive(Default)]
struct ProgressCounterInner {
kv: [KeyValue; 1],
migrated: AtomicU32,
skipped: AtomicU32,
}
impl ProgressCounter {
fn new(entity: EntityType) -> Self {
Self {
inner: Arc::new(ProgressCounterInner {
kv: [entity.as_kv()],
migrated: AtomicU32::new(0),
skipped: AtomicU32::new(0),
}),
}
}
pub fn increment_migrated(&self) {
MIGRATED_COUNTER.add(1, &self.inner.kv);
self.inner
.migrated
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
pub fn increment_skipped(&self) {
SKIPPED_COUNTER.add(1, &self.inner.kv);
self.inner
.skipped
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@@ -52,8 +147,9 @@ impl ProgressCounter {
impl Progress {
#[must_use]
pub fn migrating_data(&self, entity: &'static str, approx_count: usize) -> ProgressCounter {
let counter = ProgressCounter::default();
pub fn migrating_data(&self, entity: EntityType, approx_count: usize) -> ProgressCounter {
let counter = ProgressCounter::new(entity);
APPROX_TOTAL_GAUGE.record(approx_count as u64, &[entity.as_kv()]);
self.set_current_stage(ProgressStage::MigratingData {
entity,
counter: counter.clone(),
@@ -99,7 +195,7 @@ impl Default for Progress {
pub enum ProgressStage {
SettingUp,
MigratingData {
entity: &'static str,
entity: EntityType,
counter: ProgressCounter,
approx_count: u64,
},

View File

@@ -73,6 +73,11 @@ pub enum CheckError {
)]
SynapseMissingOAuthProvider { provider: String, num_users: i64 },
#[error(
"Synapse database has {num_users} mapping entries from a previously-configured MAS instance. If this is from a previous migration attempt, run the following SQL query against the Synapse database: `DELETE FROM user_external_ids WHERE auth_provider = 'oauth-delegated';` and then run the migration again."
)]
ExistingOAuthDelegated { num_users: i64 },
#[error(
"Synapse config contains an OpenID Connect or OAuth2 provider '{provider}' (issuer: {issuer:?}) used by {num_users} users which must also be configured in the MAS configuration as an upstream provider."
)]
@@ -157,7 +162,7 @@ pub fn synapse_config_check(synapse_config: &Config) -> (Vec<CheckWarning>, Vec<
));
}
if synapse_config.enable_3pid_changes {
if synapse_config.enable_3pid_changes == Some(true) {
errors.push(CheckError::ThreepidChangesEnabled);
}
@@ -292,6 +297,14 @@ pub async fn synapse_database_check(
let syn_oauth2 = synapse.all_oidc_providers();
let mas_oauth2 = UpstreamOAuth2Config::extract_or_default(mas)?;
for row in oauth_provider_user_counts {
// This is a special case of a previous migration attempt to MAS
if row.auth_provider == "oauth-delegated" {
errors.push(CheckError::ExistingOAuthDelegated {
num_users: row.num_users,
});
continue;
}
let matching_syn = syn_oauth2.get(&row.auth_provider);
let Some(matching_syn) = matching_syn else {

View File

@@ -3,12 +3,21 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
mod oidc;
use std::collections::BTreeMap;
use camino::Utf8PathBuf;
use chrono::{DateTime, Utc};
use figment::providers::{Format, Yaml};
use mas_config::{PasswordAlgorithm, PasswordHashingScheme};
use rand::Rng;
use serde::Deserialize;
use sqlx::postgres::PgConnectOptions;
use tracing::warn;
use url::Url;
pub use self::oidc::OidcProvider;
/// The root of a Synapse configuration.
/// This struct only includes fields which the Synapse-to-MAS migration is
@@ -16,13 +25,15 @@ use sqlx::postgres::PgConnectOptions;
///
/// See: <https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html>
#[derive(Deserialize)]
#[allow(clippy::struct_excessive_bools)]
#[expect(clippy::struct_excessive_bools)]
pub struct Config {
pub database: DatabaseSection,
#[serde(default)]
pub password_config: PasswordSection,
pub bcrypt_rounds: Option<u32>,
#[serde(default)]
pub allow_guest_access: bool,
@@ -31,11 +42,16 @@ pub struct Config {
#[serde(default)]
pub enable_registration_captcha: bool,
pub recaptcha_public_key: Option<String>,
pub recaptcha_private_key: Option<String>,
/// Normally this defaults to true, but when MAS integration is enabled in
/// Synapse it defaults to false.
#[serde(default)]
pub enable_3pid_changes: bool,
pub enable_3pid_changes: Option<bool>,
#[serde(default = "default_true")]
enable_set_display_name: bool,
#[serde(default)]
pub user_consent: Option<UserConsentSection>,
@@ -67,6 +83,8 @@ pub struct Config {
pub oidc_providers: Vec<OidcProvider>,
pub server_name: String,
pub public_baseurl: Option<Url>,
}
impl Config {
@@ -100,21 +118,97 @@ impl Config {
let mut out = BTreeMap::new();
if let Some(provider) = &self.oidc_config {
if provider.issuer.is_some() {
if provider.has_required_fields() {
let mut provider = provider.clone();
// The legacy configuration has an implied IdP ID of `oidc`.
out.insert("oidc".to_owned(), provider.clone());
let idp_id = provider.idp_id.take().unwrap_or("oidc".to_owned());
provider.idp_id = Some(idp_id.clone());
out.insert(idp_id, provider);
}
}
for provider in &self.oidc_providers {
if let Some(idp_id) = &provider.idp_id {
let mut provider = provider.clone();
let idp_id = match provider.idp_id.take() {
None => "oidc".to_owned(),
Some(idp_id) if idp_id == "oidc" => idp_id,
// Synapse internally prefixes the IdP IDs with `oidc-`.
out.insert(format!("oidc-{idp_id}"), provider.clone());
}
Some(idp_id) => format!("oidc-{idp_id}"),
};
provider.idp_id = Some(idp_id.clone());
out.insert(idp_id, provider);
}
out
}
/// Adjust a MAS configuration to match this Synapse configuration.
#[must_use]
pub fn adjust_mas_config(
self,
mut mas_config: mas_config::RootConfig,
rng: &mut impl Rng,
now: DateTime<Utc>,
) -> mas_config::RootConfig {
let providers = self.all_oidc_providers();
for provider in providers.into_values() {
let Some(mas_provider_config) = provider.into_mas_config(rng, now) else {
// TODO: better log message
warn!("Could not convert OIDC provider to MAS config");
continue;
};
mas_config
.upstream_oauth2
.providers
.push(mas_provider_config);
}
// TODO: manage when the option is not set
if let Some(enable_3pid_changes) = self.enable_3pid_changes {
mas_config.account.email_change_allowed = enable_3pid_changes;
}
mas_config.account.displayname_change_allowed = self.enable_set_display_name;
if self.password_config.enabled {
mas_config.passwords.enabled = true;
mas_config.passwords.schemes = vec![
// This is the password hashing scheme synapse uses
PasswordHashingScheme {
version: 1,
algorithm: PasswordAlgorithm::Bcrypt,
cost: self.bcrypt_rounds,
secret: self.password_config.pepper,
secret_file: None,
},
// Use the default algorithm MAS uses as a second hashing scheme, so that users
// will get their password hash upgraded to a more modern algorithm over time
PasswordHashingScheme {
version: 2,
algorithm: PasswordAlgorithm::default(),
cost: None,
secret: None,
secret_file: None,
},
];
mas_config.account.password_registration_enabled = self.enable_registration;
} else {
mas_config.passwords.enabled = false;
}
if self.enable_registration_captcha {
mas_config.captcha.service = Some(mas_config::CaptchaServiceKind::RecaptchaV2);
mas_config.captcha.site_key = self.recaptcha_public_key;
mas_config.captcha.secret_key = self.recaptcha_private_key;
}
mas_config.matrix.homeserver = self.server_name;
if let Some(public_baseurl) = self.public_baseurl {
mas_config.matrix.endpoint = public_baseurl;
}
mas_config
}
}
/// The `database` section of the Synapse configuration.
@@ -215,17 +309,6 @@ pub struct EnableableSection {
pub enabled: bool,
}
#[derive(Clone, Deserialize)]
pub struct OidcProvider {
/// At least for `oidc_config`, if the dict is present but left empty then
/// the config should be ignored, so this field must be optional.
pub issuer: Option<String>,
/// Required, except for the old `oidc_config` where this is implied to be
/// "oidc".
pub idp_id: Option<String>,
}
fn default_true() -> bool {
true
}
@@ -239,7 +322,7 @@ mod test {
#[test]
fn test_to_sqlx_postgres() {
#[track_caller]
#[allow(clippy::needless_pass_by_value)]
#[expect(clippy::needless_pass_by_value)]
fn assert_eq_options(config: DatabaseSection, uri: &str) {
let config_connect_options = config
.to_sqlx_postgres()

View File

@@ -0,0 +1,347 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::{collections::BTreeMap, str::FromStr as _};
use chrono::{DateTime, Utc};
use mas_config::{
UpstreamOAuth2ClaimsImports, UpstreamOAuth2DiscoveryMode, UpstreamOAuth2ImportAction,
UpstreamOAuth2PkceMethod, UpstreamOAuth2ResponseMode, UpstreamOAuth2TokenAuthMethod,
};
use mas_iana::jose::JsonWebSignatureAlg;
use oauth2_types::scope::{OPENID, Scope, ScopeToken};
use rand::Rng;
use serde::Deserialize;
use tracing::warn;
use ulid::Ulid;
use url::Url;
#[derive(Clone, Deserialize, Default)]
enum UserMappingProviderModule {
#[default]
#[serde(rename = "synapse.handlers.oidc.JinjaOidcMappingProvider")]
Jinja,
#[serde(rename = "synapse.handlers.oidc_handler.JinjaOidcMappingProvider")]
JinjaLegacy,
#[serde(other)]
Other,
}
#[derive(Clone, Deserialize, Default)]
struct UserMappingProviderConfig {
subject_template: Option<String>,
subject_claim: Option<String>,
localpart_template: Option<String>,
display_name_template: Option<String>,
email_template: Option<String>,
#[serde(default)]
confirm_localpart: bool,
}
impl UserMappingProviderConfig {
fn into_mas_config(self) -> UpstreamOAuth2ClaimsImports {
let mut config = UpstreamOAuth2ClaimsImports::default();
match (self.subject_claim, self.subject_template) {
(Some(_), Some(subject_template)) => {
warn!(
"Both `subject_claim` and `subject_template` options are set, using `subject_template`."
);
config.subject.template = Some(subject_template);
}
(None, Some(subject_template)) => {
config.subject.template = Some(subject_template);
}
(Some(subject_claim), None) => {
config.subject.template = Some(format!("{{{{ user.{subject_claim} }}}}"));
}
(None, None) => {}
}
if let Some(localpart_template) = self.localpart_template {
config.localpart.template = Some(localpart_template);
config.localpart.action = if self.confirm_localpart {
UpstreamOAuth2ImportAction::Suggest
} else {
UpstreamOAuth2ImportAction::Require
};
}
if let Some(displayname_template) = self.display_name_template {
config.displayname.template = Some(displayname_template);
config.displayname.action = if self.confirm_localpart {
UpstreamOAuth2ImportAction::Suggest
} else {
UpstreamOAuth2ImportAction::Force
};
}
if let Some(email_template) = self.email_template {
config.email.template = Some(email_template);
config.email.action = if self.confirm_localpart {
UpstreamOAuth2ImportAction::Suggest
} else {
UpstreamOAuth2ImportAction::Force
};
}
config
}
}
#[derive(Clone, Deserialize, Default)]
struct UserMappingProvider {
#[serde(default)]
module: UserMappingProviderModule,
#[serde(default)]
config: UserMappingProviderConfig,
}
#[derive(Clone, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
enum PkceMethod {
#[default]
Auto,
Always,
Never,
#[serde(other)]
Other,
}
#[derive(Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
enum UserProfileMethod {
#[default]
Auto,
UserinfoEndpoint,
#[serde(other)]
Other,
}
#[derive(Clone, Deserialize)]
#[expect(clippy::struct_excessive_bools)]
pub struct OidcProvider {
pub issuer: Option<String>,
/// Required, except for the old `oidc_config` where this is implied to be
/// "oidc".
pub idp_id: Option<String>,
idp_name: Option<String>,
idp_brand: Option<String>,
#[serde(default = "default_true")]
discover: bool,
client_id: Option<String>,
client_secret: Option<String>,
// Unsupported, we want to shout about it
client_secret_path: Option<String>,
// Unsupported, we want to shout about it
client_secret_jwt_key: Option<serde_json::Value>,
client_auth_method: Option<UpstreamOAuth2TokenAuthMethod>,
#[serde(default)]
pkce_method: PkceMethod,
// Unsupported, we want to shout about it
id_token_signing_alg_values_supported: Option<Vec<String>>,
scopes: Option<Vec<String>>,
authorization_endpoint: Option<Url>,
token_endpoint: Option<Url>,
userinfo_endpoint: Option<Url>,
jwks_uri: Option<Url>,
#[serde(default)]
skip_verification: bool,
// Unsupported, we want to shout about it
#[serde(default)]
backchannel_logout_enabled: bool,
#[serde(default)]
user_profile_method: UserProfileMethod,
// Unsupported, we want to shout about it
attribute_requirements: Option<serde_json::Value>,
// Unsupported, we want to shout about it
#[serde(default = "default_true")]
enable_registration: bool,
#[serde(default)]
additional_authorization_parameters: BTreeMap<String, String>,
#[serde(default)]
user_mapping_provider: UserMappingProvider,
}
fn default_true() -> bool {
true
}
impl OidcProvider {
/// Returns true if the two 'required' fields are set. This is used to
/// ignore an empty dict on the `oidc_config` section.
#[must_use]
pub(crate) fn has_required_fields(&self) -> bool {
self.issuer.is_some() && self.client_id.is_some()
}
/// Map this Synapse OIDC provider config to a MAS upstream provider config.
#[expect(clippy::too_many_lines)]
pub(crate) fn into_mas_config(
self,
rng: &mut impl Rng,
now: DateTime<Utc>,
) -> Option<mas_config::UpstreamOAuth2Provider> {
let client_id = self.client_id?;
if self.client_secret_path.is_some() {
warn!(
"The `client_secret_path` option is not supported, ignoring. You *will* need to include the secret in the `client_secret` field."
);
}
if self.client_secret_jwt_key.is_some() {
warn!("The `client_secret_jwt_key` option is not supported, ignoring.");
}
if self.attribute_requirements.is_some() {
warn!("The `attribute_requirements` option is not supported, ignoring.");
}
if self.id_token_signing_alg_values_supported.is_some() {
warn!("The `id_token_signing_alg_values_supported` option is not supported, ignoring.");
}
if self.backchannel_logout_enabled {
warn!("The `backchannel_logout_enabled` option is not supported, ignoring.");
}
if !self.enable_registration {
warn!(
"Setting the `enable_registration` option to `false` is not supported, ignoring."
);
}
let scope: Scope = match self.scopes {
None => [OPENID].into_iter().collect(), // Synapse defaults to the 'openid' scope
Some(scopes) => scopes
.into_iter()
.filter_map(|scope| match ScopeToken::from_str(&scope) {
Ok(scope) => Some(scope),
Err(err) => {
warn!("OIDC provider scope '{scope}' is invalid: {err}");
None
}
})
.collect(),
};
let id = Ulid::from_datetime_with_source(now.into(), rng);
let token_endpoint_auth_method = self.client_auth_method.unwrap_or_else(|| {
// The token auth method defaults to 'none' if no client_secret is set and
// 'client_secret_basic' otherwise
if self.client_secret.is_some() {
UpstreamOAuth2TokenAuthMethod::ClientSecretBasic
} else {
UpstreamOAuth2TokenAuthMethod::None
}
});
let discovery_mode = match (self.discover, self.skip_verification) {
(true, false) => UpstreamOAuth2DiscoveryMode::Oidc,
(true, true) => UpstreamOAuth2DiscoveryMode::Insecure,
(false, _) => UpstreamOAuth2DiscoveryMode::Disabled,
};
let pkce_method = match self.pkce_method {
PkceMethod::Auto => UpstreamOAuth2PkceMethod::Auto,
PkceMethod::Always => UpstreamOAuth2PkceMethod::Always,
PkceMethod::Never => UpstreamOAuth2PkceMethod::Never,
PkceMethod::Other => {
warn!(
"The `pkce_method` option is not supported, expected 'auto', 'always', or 'never'; assuming 'auto'."
);
UpstreamOAuth2PkceMethod::default()
}
};
// "auto" doesn't mean the same thing depending on whether we request the openid
// scope or not
let has_openid_scope = scope.contains(&OPENID);
let fetch_userinfo = match self.user_profile_method {
UserProfileMethod::Auto => has_openid_scope,
UserProfileMethod::UserinfoEndpoint => true,
UserProfileMethod::Other => {
warn!(
"The `user_profile_method` option is not supported, expected 'auto' or 'userinfo_endpoint'; assuming 'auto'."
);
has_openid_scope
}
};
// Check if there is a `response_mode` set in the additional authorization
// parameters
let mut additional_authorization_parameters = self.additional_authorization_parameters;
let response_mode = if let Some(response_mode) =
additional_authorization_parameters.remove("response_mode")
{
match response_mode.to_ascii_lowercase().as_str() {
"query" => Some(UpstreamOAuth2ResponseMode::Query),
"form_post" => Some(UpstreamOAuth2ResponseMode::FormPost),
_ => {
warn!(
"Invalid `response_mode` in the `additional_authorization_parameters` option, expected 'query' or 'form_post'; ignoring."
);
None
}
}
} else {
None
};
let claims_imports = if matches!(
self.user_mapping_provider.module,
UserMappingProviderModule::Other
) {
warn!(
"The `user_mapping_provider` module specified is not supported, ignoring. Please adjust the `claims_imports` to match the mapping provider behaviour."
);
UpstreamOAuth2ClaimsImports::default()
} else {
self.user_mapping_provider.config.into_mas_config()
};
Some(mas_config::UpstreamOAuth2Provider {
enabled: true,
id,
synapse_idp_id: self.idp_id,
issuer: self.issuer,
human_name: self.idp_name,
brand_name: self.idp_brand,
client_id,
client_secret: self.client_secret,
token_endpoint_auth_method,
sign_in_with_apple: None,
token_endpoint_auth_signing_alg: None,
id_token_signed_response_alg: JsonWebSignatureAlg::Rs256,
scope: scope.to_string(),
discovery_mode,
pkce_method,
fetch_userinfo,
userinfo_signed_response_alg: None,
authorization_endpoint: self.authorization_endpoint,
userinfo_endpoint: self.userinfo_endpoint,
token_endpoint: self.token_endpoint,
jwks_uri: self.jwks_uri,
response_mode,
claims_imports,
additional_authorization_parameters,
})
}
}

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO access_tokens
(
id,

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO access_tokens
(
id,

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO access_tokens
(
id,

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO access_tokens
(
id,

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO devices
(
user_id,

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO user_external_ids
(
user_id,

View File

@@ -1,3 +1,8 @@
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO user_threepids
(
user_id,

View File

@@ -1,4 +1,8 @@
--
-- Copyright 2024, 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
INSERT INTO users
(
name,
@@ -37,4 +41,3 @@ INSERT INTO users
false,
false
);

View File

@@ -1,3 +1,8 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
use std::sync::LazyLock;
use opentelemetry::{InstrumentationScope, metrics::Meter};
@@ -12,21 +17,3 @@ static SCOPE: LazyLock<InstrumentationScope> = LazyLock::new(|| {
pub static METER: LazyLock<Meter> =
LazyLock::new(|| opentelemetry::global::meter_with_scope(SCOPE.clone()));
/// Attribute key for syn2mas.entity metrics representing what entity.
pub const K_ENTITY: &str = "entity";
/// Attribute value for syn2mas.entity metrics representing users.
pub const V_ENTITY_USERS: &str = "users";
/// Attribute value for syn2mas.entity metrics representing devices.
pub const V_ENTITY_DEVICES: &str = "devices";
/// Attribute value for syn2mas.entity metrics representing threepids.
pub const V_ENTITY_THREEPIDS: &str = "threepids";
/// Attribute value for syn2mas.entity metrics representing external IDs.
pub const V_ENTITY_EXTERNAL_IDS: &str = "external_ids";
/// Attribute value for syn2mas.entity metrics representing non-refreshable
/// access token entities.
pub const V_ENTITY_NONREFRESHABLE_ACCESS_TOKENS: &str = "nonrefreshable_access_tokens";
/// Attribute value for syn2mas.entity metrics representing refreshable
/// access/refresh token pairs.
pub const V_ENTITY_REFRESHABLE_TOKEN_PAIRS: &str = "refreshable_token_pairs";

View File

@@ -1566,6 +1566,7 @@
"type": "boolean"
},
"schemes": {
"description": "The hashing schemes to use for hashing and validating passwords\n\nThe hashing scheme with the highest version number will be used for hashing new passwords.",
"default": [
{
"version": 1,
@@ -1587,6 +1588,7 @@
}
},
"HashingScheme": {
"description": "Parameters for a password hashing scheme",
"type": "object",
"required": [
"algorithm",
@@ -1594,12 +1596,18 @@
],
"properties": {
"version": {
"description": "The version of the hashing scheme. They must be unique, and the highest version will be used for hashing new passwords.",
"type": "integer",
"format": "uint16",
"minimum": 0.0
},
"algorithm": {
"$ref": "#/definitions/Algorithm"
"description": "The hashing algorithm to use",
"allOf": [
{
"$ref": "#/definitions/Algorithm"
}
]
},
"cost": {
"description": "Cost for the bcrypt algorithm",
@@ -1609,9 +1617,11 @@
"minimum": 0.0
},
"secret": {
"description": "An optional secret to use when hashing passwords. This makes it harder to brute-force the passwords in case of a database leak.",
"type": "string"
},
"secret_file": {
"description": "Same as `secret`, but read from a file.",
"type": "string"
}
}
@@ -1973,6 +1983,10 @@
"type": "string",
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
},
"synapse_idp_id": {
"description": "The ID of the provider that was used by Synapse. In order to perform a Synapse-to-MAS migration, this must be specified.\n\n## For providers that used OAuth 2.0 or OpenID Connect in Synapse\n\n### For `oidc_providers`: This should be specified as `oidc-` followed by the ID that was configured as `idp_id` in one of the `oidc_providers` in the Synapse configuration. For example, if Synapse's configuration contained `idp_id: wombat` for this provider, then specify `oidc-wombat` here.\n\n### For `oidc_config` (legacy): Specify `oidc` here.",
"type": "string"
},
"issuer": {
"description": "The OIDC issuer URL\n\nThis is required if OIDC discovery is enabled (which is the default)",
"type": "string"
@@ -2100,10 +2114,6 @@
"additionalProperties": {
"type": "string"
}
},
"synapse_idp_id": {
"description": "The ID of the provider that was used by Synapse. In order to perform a Synapse-to-MAS migration, this must be specified.\n\n## For providers that used OAuth 2.0 or OpenID Connect in Synapse\n\n### For `oidc_providers`: This should be specified as `oidc-` followed by the ID that was configured as `idp_id` in one of the `oidc_providers` in the Synapse configuration. For example, if Synapse's configuration contained `idp_id: wombat` for this provider, then specify `oidc-wombat` here.\n\n### For `oidc_config` (legacy): Specify `oidc` here.",
"type": "string"
}
}
},

View File

@@ -560,7 +560,7 @@ telemetry:
dsn: https://public@host:port/1
```
### `email`
## `email`
Settings related to sending emails
@@ -583,19 +583,15 @@ email:
# Send emails by calling a local sendmail binary
#transport: sendmail
#command: /usr/sbin/sendmail
# Send emails through the AWS SESv2 API
# This uses the AWS SDK, so the usual AWS environment variables are supported
#transport: aws_ses
```
### `upstream_oauth2`
## `upstream_oauth2`
Settings related to upstream OAuth 2.0/OIDC providers.
Additions and modifications within this section are synced with the database on server startup.
Removed entries are only removed with the [`config sync --prune`](./cli/config.md#config-sync---prune---dry-run) command.
#### `upstream_oauth2.providers`
### `upstream_oauth2.providers`
A list of upstream OAuth 2.0/OIDC providers to use to authenticate users.

View File

@@ -27,7 +27,7 @@ export type LocalazyMetadata = {
};
const localazyMetadata: LocalazyMetadata = {
projectUrl: "https://localazy.com/p/matrix-authentication-service",
projectUrl: "https://localazy.com/p/matrix-authentication-service!v0.15",
baseLocale: "en",
languages: [
{
@@ -93,6 +93,24 @@ const localazyMetadata: LocalazyMetadata = {
localizedName: "Français",
pluralType: (n) => { return (n===0 || n===1) ? "one" : "other"; }
},
{
language: "hu",
region: "",
script: "",
isRtl: false,
name: "Hungarian",
localizedName: "Magyar",
pluralType: (n) => { return (n===1) ? "one" : "other"; }
},
{
language: "nb",
region: "NO",
script: "",
isRtl: false,
name: "Norwegian Bokmål (Norway)",
localizedName: "Norsk bokmål (Norge)",
pluralType: (n) => { return (n===1) ? "one" : "other"; }
},
{
language: "nl",
region: "",
@@ -154,19 +172,21 @@ const localazyMetadata: LocalazyMetadata = {
file: "frontend.json",
path: "",
cdnFiles: {
"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",
"nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json",
"zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json"
"cs": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/cs/frontend.json",
"da": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/da/frontend.json",
"de": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/de/frontend.json",
"en": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/en/frontend.json",
"et": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/et/frontend.json",
"fi": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fi/frontend.json",
"fr": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/fr/frontend.json",
"hu": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/hu/frontend.json",
"nb_NO": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nb-NO/frontend.json",
"nl": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/nl/frontend.json",
"pt": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/pt/frontend.json",
"ru": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/ru/frontend.json",
"sv": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/sv/frontend.json",
"uk": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/uk/frontend.json",
"zh#Hans": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/7c203a8ac8bd48c3c4609a8effcd0fbac430f9b2/zh-Hans/frontend.json"
}
},
{
@@ -174,19 +194,21 @@ const localazyMetadata: LocalazyMetadata = {
file: "file.json",
path: "",
cdnFiles: {
"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",
"nl": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pt": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"ru": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sv": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json",
"zh#Hans": "https://delivery.localazy.com/_a7686032324574572744739e0707/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json"
"cs": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/cs/file.json",
"da": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/da/file.json",
"de": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/de/file.json",
"en": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/en/file.json",
"et": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/et/file.json",
"fi": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fi/file.json",
"fr": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/fr/file.json",
"hu": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/hu/file.json",
"nb_NO": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nb-NO/file.json",
"nl": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/nl/file.json",
"pt": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/pt/file.json",
"ru": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/ru/file.json",
"sv": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/sv/file.json",
"uk": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/uk/file.json",
"zh#Hans": "https://delivery.localazy.com/_a69844934179046823895c13fb73/_e0/5b69b0350dccfd47c245a5d41c1b9fdf6912cc6e/zh-Hans/file.json"
}
}
]

395
frontend/locales/hu.json Normal file
View File

@@ -0,0 +1,395 @@
{
"action": {
"back": "Vissza",
"cancel": "Mégse",
"clear": "Törlés",
"close": "Bezárás",
"collapse": "Összecsukás",
"confirm": "Megerősítés",
"continue": "Folytatás",
"edit": "Szerkesztés",
"expand": "Kibontás",
"save": "Mentés",
"save_and_continue": "Mentés és folytatás",
"sign_out": "Kijelentkezés",
"start_over": "Újrakezdés"
},
"branding": {
"privacy_policy": {
"alt": "Hivatkozás a szolgáltatás adatvédelmi irányelveire",
"link": "Adatvédelmi irányelvek"
},
"terms_and_conditions": {
"alt": "Hivatkozás a szolgáltatási feltételekre",
"link": "Szolgáltatási feltételek"
}
},
"common": {
"add": "Hozzáadás",
"e2ee": "Végpontok közti titkosítás",
"error": "Hiba",
"loading": "Betöltés…",
"next": "Következő",
"password": "Jelszó",
"previous": "Előző",
"saved": "Mentve",
"saving": "Mentés…"
},
"frontend": {
"account": {
"account_password": "Fiók jelszava",
"contact_info": "Kapcsolati információ",
"delete_account": {
"alert_description": "Ez a fiók véglegesen törölve lesz, és többé nem fog hozzáférni az üzeneteihez.",
"alert_title": "Hamarosan elveszíti az összes adatát",
"button": "Fiók törlése",
"dialog_description": "<text>Erősítse meg, hogy törölné a fiókját:</text>\n<profile />\n<list>\n<item>Nem fogja tudni újraaktiválni a fiókját</item>\n<item>Többé nem fog tudni bejelentkezni</item>\n<item>Senki sem fogja tudni használni a felhasználónevét (MXID), Önt is beleértve</item>\n<item>Elhagyja az összes szobáját és a közvetlen üzeneteit</item>\n<item>El lesz távolítva az azonosítási kiszolgálóról, és senki sem fogja tudni megtalálni az e-mail-címe vagy telefonszáma alapján</item>\n</list>\n<text>A régi üzenetei továbbra is láthatóak lesznek azok számára, akik megkapták azokat. Elrejti az elküldött üzeneteit azok elől, akik a jövőben csatlakoznak a szobákhoz?</text>",
"dialog_title": "Törli ezt a fiókot?",
"erase_checkbox_label": "Igen, az összes üzenet elrejtése az új érkezők elől",
"incorrect_password": "Helytelen jelszó, próbálja újra",
"mxid_label": "Erősítse meg a Matrix-azonosítóját ({{ mxid }})",
"mxid_mismatch": "Ez az érték nem egyezik a Matrix-azonosítójával",
"password_label": "A folytatáshoz adja meg a jelszavát"
},
"edit_profile": {
"display_name_help": "Ez az, amit mások látni fognak, ha be van jelentkezve.",
"display_name_label": "Megjelenítési név",
"title": "Profil szerkesztése",
"username_label": "Felhasználói név"
},
"password": {
"change": "Jelszó módosítása",
"change_disabled": "A jelszóváltoztatást letiltotta a rendszergazda.",
"label": "Jelszó"
},
"sign_out": {
"button": "Kijelentkezés a fiókból",
"dialog": "Kijelentkezik ebből a fiókból?"
},
"title": "Saját fiók"
},
"add_email_form": {
"email_denied_alert": {
"text": "A megadott e-mail-címet nem engedélyezi a kiszolgáló házirendje.",
"title": "E-mail-cím házirend alapján elutasítva"
},
"email_denied_error": "A megadott e-mail-címet nem engedélyezi a kiszolgáló házirendje",
"email_exists_alert": {
"text": "A megadott e-mail-cím már hozzá lett adva ehhez a fiókhoz",
"title": "Az e-mail-cím már létezik"
},
"email_exists_error": "A megadott e-mail-cím már hozzá lett adva ehhez a fiókhoz",
"email_field_help": "Alternatív e-mail-cím hozzáadása, mellyel hozzáférhet ehhez a fiókhoz.",
"email_field_label": "E-mail-cím hozzáadása",
"email_in_use_error": "A megadott e-mail-cím már használatban van",
"email_invalid_alert": {
"text": "A megadott e-mail-cím érvénytelen",
"title": "Érvénytelen e-mail-cím"
},
"email_invalid_error": "A megadott e-mail-cím érvénytelen",
"incorrect_password_error": "Helytelen jelszó, próbálja újra",
"password_confirmation": "Erősítse meg a fiókja jelszavát az e-mail-cím hozzáadásához"
},
"app_sessions_list": {
"error": "Az alkalmazás munkameneteinek betöltése sikertelen",
"heading": "Alkalmazások"
},
"browser_session_details": {
"current_badge": "Jelenlegi",
"session_details_title": "Munkamenet"
},
"browser_sessions_overview": {
"body:one": "{{count}} aktív munkamenet",
"body:other": "{{count}} aktív munkamenet",
"heading": "Böngészők",
"no_active_sessions": {
"default": "Még egyetlen webböngészőben sem jelentkezett be.",
"inactive_90_days": "Az összes munkamenete aktív volt az elmúlt 90 napban."
},
"view_all_button": "Összes megtekintése"
},
"compat_session_detail": {
"client_details_title": "Kliensinformációk",
"name": "Név",
"session_details_title": "Munkamenet"
},
"device_type_icon_label": {
"desktop": "Asztali számítógép",
"mobile": "Mobil",
"pc": "Számítógép",
"tablet": "Táblagép",
"unknown": "Ismeretlen eszköztípus",
"web": "Web"
},
"email_in_use": {
"heading": "A(z) {{email}} e-mail-cím már használatban van."
},
"end_session_button": {
"confirmation_modal_title": "Biztos, hogy befejezi a munkamenetet?",
"text": "Eszköz eltávolítása"
},
"error": {
"hideDetails": "Részletek elrejtése",
"showDetails": "Részletek megjelenítése",
"subtitle": "Váratlan hiba történt. Próbálja újra.",
"title": "Valamilyen hiba történt"
},
"error_boundary_title": "Valamilyen hiba történt",
"errors": {
"field_required": "Ez a mező kötelező",
"rate_limit_exceeded": "Túl sok kérést adott fel egy rövid időszak alatt. Várjon néhány percet, és próbálja újra."
},
"last_active": {
"active_date": "{{relativeDate}} aktív",
"active_now": "Jelenleg aktív",
"inactive_90_days": "90+ napja inaktív"
},
"nav": {
"devices": "Eszközök",
"profile": "Profil",
"sessions": "Munkamenetek",
"settings": "Beállítások"
},
"not_found_alert_title": "Nem található.",
"not_logged_in_alert": "Nincs bejelentkezve.",
"oauth2_client_detail": {
"details_title": "Kliensinformációk",
"id": "Kliensazonosító",
"name": "Név",
"policy": "Házirend",
"terms": "Szolgáltatás feltételei"
},
"oauth2_session_detail": {
"client_details_name": "Név",
"client_title": "Kliensinformációk",
"session_details_title": "Munkamenet"
},
"pagination_controls": {
"total": "Összesen: {{totalCount}}"
},
"password_change": {
"current_password_label": "Jelenlegi jelszó",
"failure": {
"description": {
"account_locked": "A fiókja zárolva van, és jelenleg nem állítható helyre. Ha erre nem számított, akkor lépjen kapcsolatba a kiszolgáló rendszergazdájával.",
"expired_recovery_ticket": "A helyreállítási hivatkozás lejárt. Kezdje az elejéről a fiókja helyreállítási folyamatát.",
"invalid_new_password": "A választott új jelszó érvénytelen; lehet, hogy nem felel meg a beállított biztonsági házirendnek.",
"no_current_password": "Nincs jelenlegi jelszava.",
"no_such_recovery_ticket": "A helyreállítási hivatkozás érvénytelen. Ha a helyreállítási üzenetből másolta ki a hivatkozást, akkor ellenőrizze, hogy a teljes hivatkozást átmásolta-e.",
"password_changes_disabled": "A jelszómódosítás le van tiltva.",
"recovery_ticket_already_used": "A helyreállítási hivatkozás már fel lett használva. Többé nem használható.",
"unspecified": "Ez ideiglenes probléma is lehet, így próbálja újra később. Ha a probléma továbbra is fennáll, lépjen kapcsolatba a kiszolgáló rendszergazdájával.",
"wrong_password": "A jelenlegi jelszóként megadott jelszó helytelen. Próbálja újra."
},
"title": "A jelszó frissítése sikertelen"
},
"new_password_again_label": "Adja meg a jelszót újból",
"new_password_label": "Új jelszó",
"passwords_match": "A jelszavak megegyeznek!",
"passwords_no_match": "A jelszavak nem egyeznek",
"subtitle": "Válasszon egy új jelszót a fiókjához.",
"success": {
"description": "A jelszava sikeresen frissült.",
"title": "Jelszó frissítve"
},
"title": "Jelszó módosítása"
},
"password_reset": {
"consumed": {
"subtitle": "Új jelszó létrehozásához kezdje elölről, és válassza a „Elfelejtett jelszó” lehetőséget.",
"title": "A jelszóhelyreállítási hivatkozás már fel lett használva"
},
"expired": {
"resend_email": "Levél újraküldése",
"subtitle": "Új levél kérése, amely ide lesz elküldve: {{email}}",
"title": "A jelszóvisszaállítási hivatkozás lejárt"
},
"subtitle": "Válasszon egy új jelszót a fiókjához.",
"title": "Jelszó visszaállítása"
},
"password_strength": {
"placeholder": "Jelszó erőssége",
"score": {
"0": "Rendkívül gyenge jelszó",
"1": "Nagyon gyenge jelszó",
"2": "Gyenge jelszó",
"3": "Erős jelszó",
"4": "Nagyon erős jelszó"
},
"suggestion": {
"all_uppercase": "Használjon nagybetűket, de nem mindnél.",
"another_word": "Adjon hozzá néhány kevésbé gyakori szót.",
"associated_years": "Kerülje az Önhöz köthető éveket.",
"capitalization": "Ne csak az első betű legyen nagybetűs.",
"dates": "Kerülje az Önhöz köthető dátumokat és éveket.",
"l33t": "Kerülje a kiszámítható betűhelyettesítéseket, mint az „a” helyetti „@”.",
"longer_keyboard_pattern": "Használjon hosszabb billentyűzetmintát, és többször módosítsa a gépelési irányt.",
"no_need": "Anélkül is hozhat létre erős jelszavakat, hogy szimbólumokat, számokat vagy nagybetűket használna.",
"pwned": "Ha máshol is használja ezt a jelszót, akkor változtassa meg.",
"recent_years": "Kerülje a közelmúltbeli éveket.",
"repeated": "Kerülje az ismétlődő szavakat és karaktereket.",
"reverse_words": "Kerülje a gyakori szavak fordított betűzését.",
"sequences": "Kerülje a gyakori karaktersorozatokat.",
"use_words": "Használjon több szót, de kerülje a gyakori kifejezéseket."
},
"too_weak": "Ez a jelszó túl gyenge",
"warning": {
"common": "Ez egy gyakran használt jelszó.",
"common_names": "A gyakori nevek és vezetéknevek könnyen kitalálhatóak.",
"dates": "A dátumok könnyen kitalálhatóak.",
"extended_repeat": "Az ismétlődő karaktersorozatok, mint az „abcabcabc” könnyen kitalálhatóak.",
"key_pattern": "A rövid billentyűzetminták könnyen kitalálhatóak.",
"names_by_themselves": "Az egy nevet vagy vezetéknevet tartalmazó jelszók könnyen kitalálhatók.",
"pwned": "A jelszava egy adatvédelmi incidensben kikerült az internetre.",
"recent_years": "A közelmúltbeli évek könnyen kitalálhatóak.",
"sequences": "A gyakori karaktersorozatok, mint az „abc”, könnyen kitalálhatóak.",
"similar_to_common": "Ez hasonlít egy gyakran használt jelszóhoz.",
"simple_repeat": "Az ismétlődő karakterek, mint az „aaa” könnyen kitalálhatóak.",
"straight_row": "A billentyűzeten szereplő karaktersorok könnyen kitalálhatóak.",
"top_hundred": "Ez egy gyakran használt jelszó.",
"top_ten": "Ez egy nagyon gyakran használt jelszó.",
"user_inputs": "Ne legyen benne személyes, vagy az oldallal kapcsolatos adat.",
"word_by_itself": "Az egy szavas jelszavak könnyen kitalálhatóak."
}
},
"reset_cross_signing": {
"button": "Személyazonosság visszaállítása",
"cancelled": {
"description_1": "A folytatáshoz bezárhatja ezt az ablakot, és visszatérhet az alkalmazáshoz.",
"description_2": "Ha mindenhonnan kijelentkezett, és nem emlékszik a helyreállítási kódjára, akkor vissza kell állítania a személyazonosságát.",
"heading": "Személyazonosság visszaállítása megszakítva."
},
"description": "Ha nincs bejelentkezve egyetlen más eszközön sem, és elvesztette a helyreállítási kulcsát, akkor az alkalmazás használatának folytatásához alaphelyzetbe kell állítania a személyazonosságát.",
"effect_list": {
"negative_1": "El fogja veszíteni a meglévő üzenetelőzményeit",
"negative_2": "Újból ellenőriznie kell az összes meglévő eszközét és kapcsolatát",
"neutral_1": "El fogja veszíteni a csak a kiszolgálón tárolt üzenetelőzményeit",
"neutral_2": "Újból ellenőriznie kell az összes meglévő eszközét és kapcsolatát",
"positive_1": "A fiókja részletei, a névjegyei, a beállításai és a csevegéslistája meg lesz tartva"
},
"failure": {
"description": "Ez ideiglenes probléma is lehet, így próbálja újra később. Ha a probléma továbbra is fennáll, lépjen kapcsolatba a kiszolgáló rendszergazdájával.",
"heading": "A kriptográfiai személyazonossága alaphelyzetbe állításának engedélyezése sikertelen",
"title": "A kriptográfiai személyazonosságának engedélyezése sikertelen"
},
"finish_reset": "Alaphelyzetbe állítás befejezése",
"heading": "Állítsa alaphelyzetbe a személyazonosságát, ha semmilyen más módon nem tudja megerősíteni",
"start_reset": "Alaphelyzetbe állítás elkezdése",
"success": {
"description": "A személyazonosság alaphelyzetbe állítása engedélyezve a következő {{minutes}} percre. A folytatáshoz bezárhatja azt az ablakot, és visszatérhet az alkalmazáshoz.",
"heading": "Személyazonosság sikeresen alaphelyzetbe állítva. A folyamat befejezéséhez térjen vissza az alkalmazáshoz.",
"title": "A kriptográfiai személyazonossága alaphelyzetbe állítása ideiglenesen engedélyezve"
},
"warning": "Csak akkor állítsa alaphelyzetbe a személyazonosságát, ha nem fér hozzá más bejelentkezett eszközhöz, és elveszítette a helyreállítási kulcsát."
},
"selectable_session": {
"label": "Munkamenet kiválasztása"
},
"session": {
"client_id_label": "Kliensazonosító",
"current": "Jelenlegi",
"current_badge": "Jelenlegi",
"device_id_label": "Eszközazonosító",
"finished_date": "Befejezve: <datetime/>",
"finished_label": "Befejezve",
"generic_browser_session": "Böngésző-munkamenet",
"id_label": "Azonosító",
"ip_label": "IP-cím",
"last_active_label": "Legutóbb aktív",
"last_auth_label": "Legutóbbi hitelesítés",
"name_for_platform": "{{name}} erre: {{platform}}",
"scopes_label": "Hatókörök",
"signed_in_date": "Bejelentkezve: <datetime/>",
"signed_in_label": "Bejelentkezve",
"title": "Eszköz részletei",
"unknown_browser": "Ismeretlen böngésző",
"unknown_device": "Ismeretlen eszköz",
"uri_label": "URI",
"user_id_label": "Felhasználóazonosító",
"username_label": "Felhasználónév"
},
"session_detail": {
"alert": {
"button": "Vissza",
"text": "Ez a munkamenet nem létezik, vagy már nem aktív.",
"title": "A munkamenet nem található: {{deviceId}}"
}
},
"unknown_route": "Ismeretlen útvonal: {{route}}",
"unverified_email_alert": {
"button": "Áttekintés és ellenőrzés",
"text:one": "{{count}} nem ellenőrzött e-mail-címe van.",
"text:other": "{{count}} nem ellenőrzött e-mail-címe van.",
"title": "Nem megerősített e-mail-cím"
},
"user_email": {
"cant_delete_primary": "Válasszon egy másik elsődleges e-mail-címet, hogy törölhesse ezt.",
"delete_button_confirmation_modal": {
"action": "E-mail-cím törlése",
"body": "Törli ezt az e-mail-címet?",
"incorrect_password": "Helytelen jelszó, próbálja újra",
"password_confirmation": "Erősítse meg a fiókja jelszavát az e-mail-cím törléséhez"
},
"delete_button_title": "E-mail-cím eltávolítása",
"email": "E-mail",
"make_primary_button": "Elsődlegessé tétel",
"not_verified": "Nincs ellenőrizve",
"primary_email": "Elsődleges e-mail-cím",
"retry_button": "Kód újraküldése",
"unverified": "Ellenőrizetlen"
},
"user_email_list": {
"heading": "Levelek",
"no_primary_email_alert": "Nincs elsődleges e-mail-cím"
},
"user_greeting": {
"error": "A felhasználó betöltése sikertelen"
},
"user_name": {
"display_name_field_label": "Megjelenítési név"
},
"user_sessions_overview": {
"active_sessions:one": "{{count}} aktív munkamenet",
"active_sessions:other": "{{count}} aktív munkamenet",
"heading": "Hol jelentkezett be",
"no_active_sessions": {
"default": "Egyetlen alkalmazásba sincs bejelentkezve.",
"inactive_90_days": "Az összes munkamenete aktív volt az elmúlt 90 napban."
}
},
"verify_email": {
"code_expired_alert": {
"description": "A kód lejárt. Kérjen egy újat.",
"title": "A kód lejárt"
},
"code_field_error": "A kód nem ismerhető fel",
"code_field_label": "6 számjegyű kód",
"code_field_wrong_shape": "A kódnak 6 számjegyűnek kell lennie",
"email_sent_alert": {
"description": "Adja meg alább az új kódot.",
"title": "Új kód küldve"
},
"enter_code_prompt": "Adja meg az ide küldött 6 számjegyű kódot: <email>{{email}}</email>",
"heading": "E-mail-cím ellenőrzése",
"invalid_code_alert": {
"description": "A folytatáshoz ellenőrizze az e-mail-címére küldött kódot, és frissítse a lenti mezőket.",
"title": "Hibás kódot adott meg"
},
"resend_code": "Kód újraküldése",
"resend_email": "Levél újraküldése",
"sent": "Elküldve!",
"unknown_email": "Ismeretlen e-mail-cím"
}
},
"mas": {
"scope": {
"edit_profile": "Profil és elérhetőségek szerkesztése",
"manage_sessions": "Eszközök és munkamenetek kezelése",
"mas_admin": "Bármely felhasználó kezelése a matrix-authentication-service szolgáltatásban",
"send_messages": "Új üzenetek küldése az Ön nevében",
"synapse_admin": "A Synapse Matrix-kiszolgáló kezelése",
"view_messages": "Meglévő üzenetek és adatok megtekintése",
"view_profile": "Saját profilinformációk és kapcsolati részletek megtekintése"
}
}
}

395
frontend/locales/nb-NO.json Normal file
View File

@@ -0,0 +1,395 @@
{
"action": {
"back": "Tilbake",
"cancel": "Avbryt",
"clear": "Tøm",
"close": "Lukk",
"collapse": "Skjul",
"confirm": "Bekreft",
"continue": "Fortsett",
"edit": "Rediger",
"expand": "Utvid",
"save": "Lagre",
"save_and_continue": "Lagre og fortsett",
"sign_out": "Logg ut",
"start_over": "Begynn på nytt"
},
"branding": {
"privacy_policy": {
"alt": "Lenke til tjenestens personvernerklæring",
"link": "Personvernerklæring"
},
"terms_and_conditions": {
"alt": "Lenke til tjenestens vilkår og betingelser",
"link": "Vilkår og betingelser"
}
},
"common": {
"add": "Legg til",
"e2ee": "Ende-til-ende-kryptering",
"error": "Feil",
"loading": "Laster inn...",
"next": "Neste",
"password": "Passord",
"previous": "Forrige",
"saved": "Lagret",
"saving": "Lagrer…"
},
"frontend": {
"account": {
"account_password": "Passord for konto",
"contact_info": "Kontaktopplysninger",
"delete_account": {
"alert_description": "Denne kontoen vil bli slettet permanent, og du vil ikke lenger ha tilgang til noen av meldingene dine.",
"alert_title": "Du er i ferd med å miste alle dataene dine",
"button": "Slett konto",
"dialog_description": "<text>Bekreft at du ønsker å slette kontoen din:</text>\n<profile />\n<list>\n<item>Du vil ikke kunne aktivere kontoen din på nytt</item>\n<item>Du vil ikke lenger kunne logge på</item>\n<item>Ingen vil kunne gjenbruke brukernavnet ditt (MXID), heller ikke du</item>\n<item>Du vil forlate alle rom og direktemeldinger du er en del av</item>\n<item>Du vil bli fjernet fra identitetsserveren, og ingen vil kunne finne deg med e-postadressen eller telefonnummeret ditt</item>\n</list>\n<text>Dine gamle meldinger vil fortsatt være synlige for personer som har mottatt dem. Ønsker du å skjule dine sendte meldinger for personer som blir med i rommene i fremtiden?</text>",
"dialog_title": "Slett denne kontoen?",
"erase_checkbox_label": "Ja, skjul alle meldingene mine for nye medlemmer",
"incorrect_password": "Feil passord, prøv igjen",
"mxid_label": "Bekreft din Matrix ID ({{ mxid }})",
"mxid_mismatch": "Denne verdien samsvarer ikke med din Matrix ID",
"password_label": "Skriv inn passordet ditt for å fortsette"
},
"edit_profile": {
"display_name_help": "Dette er det andre vil se uansett hvor du er logget inn.",
"display_name_label": "Visningsnavn",
"title": "Rediger profil",
"username_label": "Brukernavn"
},
"password": {
"change": "Endre passord",
"change_disabled": "Endring av passord er deaktivert av administrator.",
"label": "Passord"
},
"sign_out": {
"button": "Logg av konto",
"dialog": "Logg ut av denne kontoen?"
},
"title": "Din konto"
},
"add_email_form": {
"email_denied_alert": {
"text": "Den angitte e-postadressen er ikke tillatt av serverpolicyen.",
"title": "E-post avvist av policy"
},
"email_denied_error": "Den angitte e-postadressen er ikke tillatt av serverpolicyen",
"email_exists_alert": {
"text": "Den angitte e-postadressen er allerede lagt til denne kontoen",
"title": "E-posten finnes allerede"
},
"email_exists_error": "Den angitte e-postadressen er allerede lagt til denne kontoen",
"email_field_help": "Legg til en alternativ e-postadresse du kan bruke for å få tilgang til denne kontoen.",
"email_field_label": "Legg til e-post",
"email_in_use_error": "Den angitte e-postadressen er allerede i bruk",
"email_invalid_alert": {
"text": "Den angitte e-postadressen er ugyldig",
"title": "Ugyldig e-post"
},
"email_invalid_error": "Den angitte e-postadressen er ugyldig",
"incorrect_password_error": "Feil passord, prøv igjen",
"password_confirmation": "Bekreft passordet ditt for å legge til denne e-postadressen"
},
"app_sessions_list": {
"error": "Kunne ikke laste inn appsesjoner",
"heading": "Applikasjoner"
},
"browser_session_details": {
"current_badge": "Nåværende",
"session_details_title": "Sesjon"
},
"browser_sessions_overview": {
"body:one": "{{count}} aktiv sesjon",
"body:other": "{{count}} aktive sesjoner",
"heading": "Nettlesere",
"no_active_sessions": {
"default": "Du er ikke logget inn på noen nettlesere.",
"inactive_90_days": "Alle sesjonene dine har vært aktive de siste 90 dagene."
},
"view_all_button": "Vis alle"
},
"compat_session_detail": {
"client_details_title": "Klient informasjon",
"name": "Navn",
"session_details_title": "Sesjon"
},
"device_type_icon_label": {
"desktop": "Skrivebord",
"mobile": "Mobil",
"pc": "Datamaskin",
"tablet": "Nettbrett",
"unknown": "Ukjent enhetstype",
"web": "Web"
},
"email_in_use": {
"heading": "E-postadressen {{email}} er allerede i bruk."
},
"end_session_button": {
"confirmation_modal_title": "Er du sikker på at du vil avslutte denne sesjonen?",
"text": "Fjern enheten"
},
"error": {
"hideDetails": "Skjul detaljer",
"showDetails": "Vis detaljer",
"subtitle": "Det oppstod en uventet feil. Vennligst prøv igjen.",
"title": "Noe gikk galt"
},
"error_boundary_title": "Noe gikk galt",
"errors": {
"field_required": "Dette feltet er obligatorisk",
"rate_limit_exceeded": "Du har kommet med for mange forespørsler på kort tid. Vent noen minutter og prøv igjen."
},
"last_active": {
"active_date": "Aktiv {{relativeDate}}",
"active_now": "Aktiv nå",
"inactive_90_days": "Inaktiv i 90+ dager"
},
"nav": {
"devices": "Enheter",
"profile": "Profil",
"sessions": "Sesjoner",
"settings": "Innstillinger"
},
"not_found_alert_title": "Ikke funnet.",
"not_logged_in_alert": "Du er ikke innlogget.",
"oauth2_client_detail": {
"details_title": "Klient informasjon",
"id": "Klient-ID",
"name": "Navn",
"policy": "Retningslinjer",
"terms": "Vilkår for bruk"
},
"oauth2_session_detail": {
"client_details_name": "Navn",
"client_title": "Klient informasjon",
"session_details_title": "Sesjon"
},
"pagination_controls": {
"total": "Totalt: {{totalCount}}"
},
"password_change": {
"current_password_label": "Nåværende passord",
"failure": {
"description": {
"account_locked": "Kontoen din er låst og kan ikke gjenopprettes på dette tidspunktet. Hvis dette ikke er forventet, kan du kontakte serveradministratoren din.",
"expired_recovery_ticket": "Gjenopprettingslenken er utløpt. Start kontogjenopprettingsprosessen på nytt.",
"invalid_new_password": "Det nye passordet du valgte er ugyldig. Det kan hende at den ikke oppfyller den gjeldende sikkerhetspolicyen.",
"no_current_password": "Du har ikke et gjeldende passord.",
"no_such_recovery_ticket": "Gjenopprettingslenken er ugyldig. Hvis du kopierte lenken fra gjenopprettingseposten, vennligst sjekk at hele lenken ble kopiert.",
"password_changes_disabled": "Endring av passord er deaktivert.",
"recovery_ticket_already_used": "Gjenopprettingslenken er allerede brukt. Den kan ikke brukes igjen.",
"unspecified": "Dette kan være et midlertidig problem, så prøv igjen senere. Hvis problemet vedvarer, vennligst kontakt serveradministratoren din.",
"wrong_password": "Passordet du oppga som ditt nåværende passord er feil. Prøv igjen."
},
"title": "Kunne ikke oppdatere passordet"
},
"new_password_again_label": "Skriv inn nytt passord igjen",
"new_password_label": "Nytt passord",
"passwords_match": "Passordene stemmer overens!",
"passwords_no_match": "Passord stemmer ikke overens",
"subtitle": "Velg et nytt passord for kontoen din.",
"success": {
"description": "Passordet ditt har blitt oppdatert.",
"title": "Passord oppdatert"
},
"title": "Bytt passordet ditt"
},
"password_reset": {
"consumed": {
"subtitle": "For å opprette et nytt passord, start på nytt og velg «Glemt passord».",
"title": "Lenken for å tilbakestille passordet ditt har allerede blitt brukt"
},
"expired": {
"resend_email": "Send e-post på nytt",
"subtitle": "Be om en ny e-post som vil bli sendt til: {{email}}",
"title": "Lenken for å tilbakestille passordet ditt er utløpt"
},
"subtitle": "Velg et nytt passord for kontoen din.",
"title": "Tilbakestill passordet ditt"
},
"password_strength": {
"placeholder": "Passordstyrke",
"score": {
"0": "Ekstremt svakt passord",
"1": "Veldig svakt passord",
"2": "Svakt passord",
"3": "Sterkt passord",
"4": "Veldig sterkt passord"
},
"suggestion": {
"all_uppercase": "Bruk store bokstaver, men ikke for alle bokstaver.",
"another_word": "Legg til flere ord som er mindre vanlige.",
"associated_years": "Unngå år som er knyttet til deg",
"capitalization": "Bruk stor bokstav på mer enn den første bokstaven.",
"dates": "Unngå datoer og år som er knyttet til deg",
"l33t": "Unngå forutsigbare bokstavbytter som \"@\" i stedet for \"a\".",
"longer_keyboard_pattern": "Bruk lengre tastaturmønstre og endre skriveretning flere ganger.",
"no_need": "Du kan lage sterke passord uten å bruke symboler, tall eller store bokstaver.",
"pwned": "Hvis du bruker dette passordet andre steder, bør du endre det.",
"recent_years": "Unngå nylige år",
"repeated": "Unngå gjentatte ord og tegn.",
"reverse_words": "Unngå omvendt staving av vanlige ord.",
"sequences": "Unngå vanlige tegnsekvenser.",
"use_words": "Bruk flere ord, men unngå vanlige fraser."
},
"too_weak": "Dette passordet er for svakt",
"warning": {
"common": "Dette er et ofte brukt passord.",
"common_names": "Vanlige navn og etternavn er lette å gjette seg til.",
"dates": "Datoer er enkle å gjette seg til.",
"extended_repeat": "Gjentatte tegnmønstre som \"abcabcabc\" er lette å gjette seg til.",
"key_pattern": "Korte tastaturmønstre er enkle å gjette.",
"names_by_themselves": "Enkeltnavn eller etternavn er lette å gjette.",
"pwned": "Passordet ditt ble eksponert ved et datainnbrudd på Internett.",
"recent_years": "De siste årene er enkle å gjette seg til.",
"sequences": "Vanlige tegnsekvenser som «abc» er enkle å gjette.",
"similar_to_common": "Dette ligner på et ofte brukt passord.",
"simple_repeat": "Gjentatte tegn som \"aaa\" er lette å gjette.",
"straight_row": "Rette tasterader på tastaturet er enkle å gjette seg til.",
"top_hundred": "Dette er et ofte brukt passord.",
"top_ten": "Dette er et mye brukt passord.",
"user_inputs": "Det skal ikke være noen personlige eller siderelaterte data.",
"word_by_itself": "Enkeltord er lette å gjette."
}
},
"reset_cross_signing": {
"button": "Tilbakestill identitet",
"cancelled": {
"description_1": "Du kan lukke dette vinduet og gå tilbake til appen for å fortsette.",
"description_2": "Hvis du er logget av overalt og ikke husker gjenopprettingskoden, må du fortsatt tilbakestille identiteten din.",
"heading": "Tilbakestilling av identitet kansellert."
},
"description": "Hvis du ikke er logget på andre enheter, og du har mistet gjenopprettingsnøkkelen, må du tilbakestille identiteten din for å fortsette å bruke appen.",
"effect_list": {
"negative_1": "Du vil miste din eksisterende meldingshistorikk",
"negative_2": "Du må bekrefte alle eksisterende enheter og kontakter på nytt",
"neutral_1": "Du vil miste all meldingshistorikk som bare er lagret på serveren",
"neutral_2": "Du må bekrefte alle eksisterende enheter og kontakter på nytt",
"positive_1": "Dine kontodetaljer, kontakter, preferanser og chatteliste vil bli beholdt"
},
"failure": {
"description": "Dette kan være et midlertidig problem, så prøv igjen senere. Hvis problemet vedvarer, vennligst kontakt serveradministratoren din.",
"heading": "Kunne ikke tillate tilbakestilling av kryptoidentitet",
"title": "Kunne ikke tillate kryptoidentitet"
},
"finish_reset": "Fullfør tilbakestillingen",
"heading": "Tilbakestill identiteten din i tilfelle du ikke kan bekrefte på en annen måte",
"start_reset": "Start tilbakestilling",
"success": {
"description": "Tilbakestillingen av identiteten er godkjent for de neste {{minutes}} minuttene. Du kan lukke dette vinduet og gå tilbake til appen for å fortsette.",
"heading": "Identitet tilbakestilt. Gå tilbake til appen for å fullføre prosessen.",
"title": "Tilbakestilling av kryptoidentitet midlertidig tillatt"
},
"warning": "Tilbakestill identiteten din bare hvis du ikke har tilgang til en annen pålogget enhet og du har mistet gjenopprettingsnøkkelen."
},
"selectable_session": {
"label": "Velg sesjon"
},
"session": {
"client_id_label": "Klient-ID",
"current": "Nåværende",
"current_badge": "Nåværende",
"device_id_label": "Enhets-ID",
"finished_date": "Fullført <datetime/>",
"finished_label": "Fullført",
"generic_browser_session": "Nettlesersesjon",
"id_label": "ID",
"ip_label": "IP-adresse",
"last_active_label": "Sist aktiv",
"last_auth_label": "Siste autentisering",
"name_for_platform": "{{name}} for {{platform}}",
"scopes_label": "Omfang",
"signed_in_date": "Logget på <datetime/>",
"signed_in_label": "Logget på",
"title": "Detaljer om enheten",
"unknown_browser": "Ukjent nettleser",
"unknown_device": "Ukjent enhet",
"uri_label": "Uri",
"user_id_label": "Bruker ID",
"username_label": "Brukernavn"
},
"session_detail": {
"alert": {
"button": "Gå tilbake",
"text": "Denne sesjonen finnes ikke, eller er ikke lenger aktiv.",
"title": "Finner ikke sesjonen: {{deviceId}}"
}
},
"unknown_route": "Ukjent rute {{route}}",
"unverified_email_alert": {
"button": "Gjennomgå og verifiser",
"text:one": "Du har {{count}} ubekreftet e-postadresse.",
"text:other": "Du har {{count}} ubekreftede e-postadresser.",
"title": "Ubekreftet e-post"
},
"user_email": {
"cant_delete_primary": "Velg en annen primær e-postadresse for å slette denne.",
"delete_button_confirmation_modal": {
"action": "Slett e-post",
"body": "Vil du slette denne e-posten?",
"incorrect_password": "Feil passord, prøv igjen",
"password_confirmation": "Bekreft kontopassordet ditt for å slette denne e-postadressen"
},
"delete_button_title": "Fjern e-postadresse",
"email": "E-post",
"make_primary_button": "Gjøre til primær",
"not_verified": "Ikke verifisert",
"primary_email": "Primær e-postadresse",
"retry_button": "Send kode på nytt",
"unverified": "Ikke verifisert"
},
"user_email_list": {
"heading": "E-poster",
"no_primary_email_alert": "Ingen primær e-postadresse"
},
"user_greeting": {
"error": "Kunne ikke laste inn bruker"
},
"user_name": {
"display_name_field_label": "Visningsnavn"
},
"user_sessions_overview": {
"active_sessions:one": "{{count}} aktiv sesjon",
"active_sessions:other": "{{count}} aktive sesjoner",
"heading": "Hvor du er logget inn",
"no_active_sessions": {
"default": "Du er ikke logget på noen applikasjoner.",
"inactive_90_days": "Alle sesjonene dine har vært aktive de siste 90 dagene."
}
},
"verify_email": {
"code_expired_alert": {
"description": "Koden er utløpt. Be om en ny kode.",
"title": "Koden er utløpt"
},
"code_field_error": "Kode ikke gjenkjent",
"code_field_label": "6-sifret kode",
"code_field_wrong_shape": "Koden må være 6 sifre",
"email_sent_alert": {
"description": "Skriv inn den nye koden nedenfor.",
"title": "Ny kode sendt"
},
"enter_code_prompt": "Skriv inn den 6-sifrede koden sendt til: <email>{{email}}</email>",
"heading": "Bekreft e-postadressen din",
"invalid_code_alert": {
"description": "Sjekk koden som er sendt til e-posten din, og oppdater feltene nedenfor for å fortsette.",
"title": "Du skrev inn feil kode"
},
"resend_code": "Send kode på nytt",
"resend_email": "Send e-post på nytt",
"sent": "Sendt!",
"unknown_email": "Ukjent e-postadresse"
}
},
"mas": {
"scope": {
"edit_profile": "Rediger din profil og kontaktdetaljer",
"manage_sessions": "Administrer enhetene og sesjonene dine",
"mas_admin": "Administrer alle brukere på matrix-authentication-service",
"send_messages": "Send nye meldinger på dine vegne",
"synapse_admin": "Administrer Synapse-hjemmeserveren",
"view_messages": "Se dine eksisterende meldinger og data",
"view_profile": "Se din profilinformasjon og kontaktdetaljer"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@vector-im/syn2mas",
"version": "0.15.0-rc.0",
"version": "0.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@vector-im/syn2mas",
"version": "0.15.0-rc.0",
"version": "0.15.0",
"license": "AGPL-3.0-only",
"dependencies": {
"command-line-args": "^6.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@vector-im/syn2mas",
"version": "0.15.0-rc.0",
"version": "0.15.0",
"description": "A tool to migrate Synapse users and sessions to the Matrix Authentication Service",
"license": "AGPL-3.0-only",
"author": "Matrix.org",

265
translations/hu.json Normal file
View File

@@ -0,0 +1,265 @@
{
"action": {
"back": "Vissza",
"cancel": "Mégse",
"continue": "Folytatás",
"create_account": "Fiók létrehozása",
"sign_in": "Bejelentkezés",
"sign_out": "Kijelentkezés",
"skip": "Kihagyás",
"start_over": "Újrakezdés",
"submit": "Elküldés"
},
"app": {
"human_name": "Matrix hitelesítési szolgáltatás",
"name": "matrix-authentication-service",
"technical_description": "OpenID Connect felfedezési dokumentum: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>"
},
"branding": {
"privacy_policy": {
"alt": "Hivatkozás a szolgáltatás adatvédelmi irányelveire",
"link": "Adatvédelmi irányelvek"
},
"terms_and_conditions": {
"alt": "Hivatkozás a szolgáltatási feltételekre",
"link": "Szolgáltatási feltételek"
}
},
"common": {
"display_name": "Megjelenítési név",
"email_address": "E-mail-cím",
"loading": "Betöltés…",
"mxid": "Matrix-azonosító",
"password": "Jelszó",
"password_confirm": "Jelszó megerősítése",
"username": "Felhasználói név"
},
"error": {
"unexpected": "Váratlan hiba"
},
"mas": {
"account": {
"deactivated": {
"description": "Ez a fiók (<em>%(mxid)s</em>) törölve lett. Ha erre nem számított, vegye fel a kapcsolatot a kiszolgáló rendszergazdájával.",
"heading": "Fiók törölve"
},
"locked": {
"description": "Ez a fiók (<em>%(mxid)s</em>) zárolva lett. Ha erre nem számított, vegye fel a kapcsolatot a kiszolgáló rendszergazdájával.",
"heading": "Fiók zárolva"
},
"logged_out": {
"description": "Ez a munkamenet véget ért. Jelentkezzen ki, hogy újra bejelentkezhessen.",
"heading": "Munkamenet befejezve"
}
},
"add_email": {
"description": "Adjon meg egy e-mail-címet a fiókja helyreállításához, arra az esetre, ha elveszítené a hozzáférését.",
"heading": "E-mail-cím hozzáadása"
},
"back_to_homepage": "Vissza a kezdőlaphoz",
"captcha": {
"noscript": "Ezt az űrlapot CAPTCHA védi, és az elküldéséhez engedélyezni kell a JavaScriptet. Engedélyezze a böngészőben a JavaScriptet, és töltse újra az oldalt."
},
"change_password": {
"change": "Jelszó módosítása",
"confirm": "Jelszó megerősítése",
"current": "Jelenlegi jelszó",
"description": "Ez módosítja a fiókja jelszavát.",
"heading": "Saját jelszó módosítása",
"new": "Új jelszó"
},
"choose_display_name": {
"description": "Ez az a név, melyet a többi ember látni fog. Ezt bármikor módosíthatja.",
"headline": "Válasszon megjelenítési nevet"
},
"consent": {
"client_wants_access": "A(z) <span>%(client_name)s</span> (itt: <span>%(redirect_uri)s</span>) hozzá akar férni a fiókjához.",
"heading": "Engedélyezi a hozzáférést a fiókjához?",
"make_sure_you_trust": "Győződjön meg arról, hogy megbízik a(z) <span>%(client_name)s</span> kliensben.",
"this_will_allow": "Ez lehetővé teszi, hogy a(z) <span>%(client_name)s</span> a következőket tegye:",
"you_may_be_sharing": "Bizalmas információkat oszthat meg ezzel az oldallal vagy alkalmazással."
},
"device_card": {
"access_requested": "Hozzáférés kérve",
"device_code": "Kód",
"generic_device": "Eszköz",
"ip_address": "IP-cím"
},
"device_code_link": {
"description": "Eszköz összekapcsolása",
"headline": "Adja meg az eszközén megjelenített kódot"
},
"device_consent": {
"another_device_access": "Egy másik eszköz akarja elérni a fiókját.",
"denied": {
"description": "Elutasította a(z) %(client_name)s hozzáférését. Bezárhatja ezt az ablakot.",
"heading": "Hozzáférés megtagadva"
},
"granted": {
"description": "Megadta a(z) %(client_name)s hozzáférését. Bezárhatja ezt az ablakot.",
"heading": "Hozzáférés megadva"
}
},
"email_in_use": {
"description": "Ha elfelejtette a fiókja hitelesítő adatait, akkor helyreállíthatja a fiókját. Újra is kezdheti egy másik e-mail-címmel.",
"title": "A(z) <span>%(email)s</span> e-mail-cím már használatban van"
},
"emails": {
"greeting": "Kedves %(username)s!",
"recovery": {
"click_button": "Kattintson a lenti gombra az új jelszava létrehozásához:",
"copy_link": "Másolja a következő hivatkozást, és illessze be a böngészőbe egy új jelszó létrehozásához:",
"create_new_password": "Új jelszó létrehozása",
"fallback": "A gomb nem működik?",
"headline": "Jelszóvisszaállítást kért a(z) %(client_name)s fiókjához.",
"subject": "Fiókjelszó visszaállítása (%(mxid)s)",
"you_can_ignore": "Ha nem kért új jelszót, akkor figyelmen kívül hagyhatja ezt a levelet. A jelenlegi jelszava továbbra is működni fog."
},
"verify": {
"body_html": "Az ellenőrzőkódja az e-mail-címe megerősítéséhez: <strong>%(code)s</strong>",
"body_text": "Az ellenőrzőkódja az e-mail-címe megerősítéséhez: %(code)s",
"subject": "Az e-mail-elleőrzőkódja: %(code)s"
}
},
"errors": {
"captcha": "A CAPTCHA ellenőrzés sikertelen, próbálja újra",
"denied_policy": "Házirend alapján elutasítva: %(policy)s",
"email_banned": "Az e-mail-címet a kiszolgáló-házirend tiltja",
"email_domain_banned": "Az e-mail-tartományt a kiszolgáló-házirend tiltja",
"email_domain_not_allowed": "Az e-mail-tartományt nem engedélyezi a kiszolgáló-házirend",
"email_not_allowed": "Az e-mail-címet nem engedélyezi a kiszolgáló-házirend",
"field_required": "Ez a mező kötelező",
"invalid_credentials": "Érvénytelen hitelesítő adatok",
"password_mismatch": "A jelszómezők nem egyeznek",
"rate_limit_exceeded": "Túl sok kérést adott fel egy rövid időszak alatt. Várjon néhány percet, és próbálja újra.",
"username_all_numeric": "A felhasználónév nem állhat pusztán számokból",
"username_banned": "A felhasználónevet a kiszolgáló-házirend tiltja",
"username_invalid_chars": "A felhasználónév érvénytelen karaktereket tartalmaz. Csak kisbetűket, számokat, kötőjeleket és aláhúzásokat használjon.",
"username_not_allowed": "A felhasználónevet nem engedélyezi a kiszolgáló-házirend",
"username_taken": "A felhasználónév már foglalt",
"username_too_long": "A felhasználónév túl hosszú",
"username_too_short": "A felhasználónév túl rövid"
},
"login": {
"call_to_register": "Még nincs fiókja?",
"continue_with_provider": "Folytatás ezzel a szolgáltatóval: %(provider)s",
"description": "Jelentkezzen be a folytatáshoz:",
"forgot_password": "Elfelejtette a jelszavát?",
"headline": "Bejelentkezés",
"link": {
"description": "A(z) <span class=\"break-keep text-links\">%(provider)s</span> fiókjának összekötése",
"headline": "Bejelentkezés az összekötéshez"
},
"no_login_methods": "Nem érhetőek el bejelentkezési módok.",
"separator": "vagy",
"username_or_email": "Felhasználónév vagy e-mail-cím"
},
"navbar": {
"my_account": "Saját fiók",
"register": "Fiók létrehozása",
"signed_in_as": "Bejelentkezve mint <span class=\"font-semibold\">%(username)s</span>."
},
"not_found": {
"description": "A keresett oldal nem létezik vagy áthelyezték",
"heading": "Az oldal nem található"
},
"not_you": "Nem %(username)s?",
"or_separator": "vagy",
"policy_violation": {
"description": "Ez a kérést feladó kliens, a jelenleg bejelentkezett felhasználó, vagy maga a kérés miatt is lehet.",
"heading": "Az engedélyezési kérés a szolgáltatás által betartatott házirend alapján elutasítva",
"logged_as": "Bejelentkezve mint <span class=\"font-semibold\">%(username)s</span>"
},
"recovery": {
"consumed": {
"description": "Új jelszó létrehozásához kezdje elölről, és válassza a „Elfelejtett jelszó” lehetőséget.",
"heading": "A jelszóhelyreállítási hivatkozás már fel lett használva"
},
"disabled": {
"description": "Ha elvesztette a hitelesítő adatait, akkor a fiókja visszaszerzéséhez lépjen kapcsolatba a rendszergazdával.",
"heading": "A fiókhelyreállítás le van tiltva"
},
"expired": {
"description": "Új e-mail kérése, amely ide lesz küldve: <span>%(email)s</span>.",
"heading": "A jelszóvisszaállítási hivatkozás lejárt",
"resend_email": "Levél újraküldése"
},
"finish": {
"confirm": "Adja meg a jelszót újból",
"description": "Válasszon egy új jelszót a fiókjához.",
"heading": "Jelszó visszaállítása",
"new": "Új jelszó",
"save_and_continue": "Mentés és folytatás"
},
"progress": {
"change_email": "Próbálkozás másik e-mail-címmel",
"description": "Egy jelszóvisszaállítási hivatkozást tartalmazó levelet küldtünk, ha van fiókja a(z) <span>%(email)s</span> címmel.",
"heading": "Nézze meg a leveleit",
"resend_email": "Levél újraküldése"
},
"start": {
"description": "Egy jelszóvisszaállítási hivatkozást tartalmazó levél lesz küldve.",
"heading": "A folytatáshoz adja meg az e-mail-címét"
}
},
"register": {
"call_to_login": "Már van fiókja?",
"continue_with_email": "Folytatás az e-mail-címével",
"create_account": {
"description": "A folytatáshoz válasszon felhasználónevet.",
"heading": "Fiók létrehozása"
},
"sign_in_instead": "Bejelentkezés",
"terms_of_service": "Elfogadom a <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">Szolgáltatási feltételeket</a>"
},
"scope": {
"edit_profile": "Profil és elérhetőségek szerkesztése",
"manage_sessions": "Eszközök és munkamenetek kezelése",
"mas_admin": "Bármely felhasználó kezelése a matrix-authentication-service szolgáltatásban",
"send_messages": "Új üzenetek küldése az Ön nevében",
"synapse_admin": "A Synapse Matrix-kiszolgáló kezelése",
"view_messages": "Meglévő üzenetek és adatok megtekintése",
"view_profile": "Saját profilinformációk és kapcsolati részletek megtekintése"
},
"upstream_oauth2": {
"link_mismatch": {
"heading": "Ez a forrásfiók már egy másik fiókkal van összekötve."
},
"register": {
"choose_username": {
"description": "Ez később nem változtatható meg.",
"heading": "Válasszon felhasználónevet"
},
"create_account": "Új fiók létrehozása",
"enforced_by_policy": "Kiszolgáló-házirend által betartatva",
"forced_display_name": "A következő megjelenítési nevet fogja használni",
"forced_email": "A következő e-mail-címet fogja használni",
"forced_localpart": "A következő felhasználónevet fogja használni",
"import_data": {
"description": "Erősítse meg az információkat, melyek a(z) %(server_name)s fiókjához lesznek kapcsolva.",
"heading": "Saját adatok importálása"
},
"imported_from_upstream": "Importálva a forrásfiókjából",
"imported_from_upstream_with_name": "Importálva a(z) %(human_name)s fiókjából",
"link_existing": "Hivatkozás egy meglévő fiókra",
"provider_name": "%(human_name)s fiók",
"signup_with_upstream": {
"heading": "Folytassa a regisztrációt a(z) %(human_name)s fiókjával"
},
"suggested_display_name": "Megjelenítési név importálása",
"suggested_email": "E-mail-cím importálása",
"use": "Használat"
},
"suggest_link": {
"action": "Hivatkozás",
"heading": "Meglévő fiók összekötése"
}
},
"verify_email": {
"6_digit_code": "6 számjegyű kód",
"code": "Kód",
"description": "Adja meg az ide küldött 6 számjegyű kódot: <em>%(email)s</em>",
"headline": "E-mail-cím ellenőrzése"
}
}
}

265
translations/nb-NO.json Normal file
View File

@@ -0,0 +1,265 @@
{
"action": {
"back": "Tilbake",
"cancel": "Avbryt",
"continue": "Fortsett",
"create_account": "Opprett konto",
"sign_in": "Logg inn",
"sign_out": "Logg ut",
"skip": "Hopp over",
"start_over": "Begynn på nytt",
"submit": "Send"
},
"app": {
"human_name": "Matrix Authentication Service",
"name": "matrix-authentication-service",
"technical_description": "OpenID Connect oppdagelsesdokument: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>"
},
"branding": {
"privacy_policy": {
"alt": "Lenke til tjenestens personvernerklæring",
"link": "Personvernerklæring"
},
"terms_and_conditions": {
"alt": "Lenke til tjenestens vilkår og betingelser",
"link": "Vilkår og betingelser"
}
},
"common": {
"display_name": "Visningsnavn",
"email_address": "E-postadresse",
"loading": "Laster inn...",
"mxid": "Matrix ID",
"password": "Passord",
"password_confirm": "Bekreft passord",
"username": "Brukernavn"
},
"error": {
"unexpected": "Uventet feil"
},
"mas": {
"account": {
"deactivated": {
"description": "Denne kontoen (<em>%(mxid)s</em>) har blitt slettet. Hvis dette ikke er forventet, må du kontakte serveradministratoren.",
"heading": "Konto slettet"
},
"locked": {
"description": "Denne kontoen (<em>%(mxid)s</em> ) har blitt låst. Hvis dette ikke er forventet, kontakt serveradministratoren din.",
"heading": "Konto låst"
},
"logged_out": {
"description": "Denne økten har blitt avsluttet. Logg ut for å kunne logge inn igjen",
"heading": "Sesjonen er avsluttet"
}
},
"add_email": {
"description": "Skriv inn en e-postadresse for å gjenopprette kontoen din i tilfelle du mister tilgangen til den.",
"heading": "Legg til en e-postadresse"
},
"back_to_homepage": "Gå tilbake til hjemmesiden",
"captcha": {
"noscript": "Dette skjemaet er beskyttet av en CAPTCHA og krever at JavaScript er aktivert for å kunne sendes inn. Aktiver JavaScript i nettleseren din og last inn denne siden på nytt."
},
"change_password": {
"change": "Endre passord",
"confirm": "Bekreft passord",
"current": "Nåværende passord",
"description": "Dette vil endre passordet på kontoen din.",
"heading": "Endre passordet mitt",
"new": "Nytt passord"
},
"choose_display_name": {
"description": "Dette er navnet andre vil se. Du kan endre dette når som helst.",
"headline": "Velg visningsnavnet ditt"
},
"consent": {
"client_wants_access": "<span>%(client_name)s</span> på <span>%(redirect_uri)s</span> ønsker å få tilgang til kontoen din.",
"heading": "Vil du gi tilgang til kontoen din?",
"make_sure_you_trust": "Vær sikker på at du stoler på <span>%(client_name)s</span>.",
"this_will_allow": "Dette vil tillate <span> %(client_name)s </span> å:",
"you_may_be_sharing": "Det kan hende du deler sensitiv informasjon med denne siden eller appen."
},
"device_card": {
"access_requested": "Tilgang forespurt",
"device_code": "Kode",
"generic_device": "Enhet",
"ip_address": "IP adresse"
},
"device_code_link": {
"description": "Koble til en enhet",
"headline": "Skriv inn koden som vises på enheten din"
},
"device_consent": {
"another_device_access": "En annen enhet vil ha tilgang til kontoen din.",
"denied": {
"description": "Du nektet tilgang til %(client_name)s. Du kan lukke dette vinduet.",
"heading": "Tilgang nektet"
},
"granted": {
"description": "Du har gitt tilgang til %(client_name)s. Du kan lukke dette vinduet.",
"heading": "Tilgang gitt"
}
},
"email_in_use": {
"description": "Hvis du har glemt kontolegitimasjonen din, kan du gjenopprette kontoen din. Du kan også starte på nytt og bruke en annen e-postadresse.",
"title": "E-postadressen <span> %(email)s </span> er allerede i bruk"
},
"emails": {
"greeting": "Hallo %(username)s,",
"recovery": {
"click_button": "Klikk på knappen nedenfor for å opprette et nytt passord:",
"copy_link": "Kopier følgende lenke og lim den inn i en nettleser for å opprette et nytt passord:",
"create_new_password": "Opprett nytt passord",
"fallback": "Fungerer ikke knappen for deg?",
"headline": "Du har bedt om tilbakestilling av passord for %(server_name)s kontoen din.",
"subject": "Tilbakestill kontopassordet ditt (%(mxid)s)",
"you_can_ignore": "Hvis du ikke ba om et nytt passord, kan du ignorere denne e-posten. Ditt nåværende passord vil fortsette å fungere."
},
"verify": {
"body_html": "Verifiseringskoden for å bekrefte denne e-postadressen er: <strong>%(code)s</strong>",
"body_text": "Verifiseringskoden for å bekrefte denne e-postadressen er: %(code)s",
"subject": "Verifiseringskoden for e-posten din er: %(code)s"
}
},
"errors": {
"captcha": "CAPTCHA-verifisering mislyktes. Prøv igjen",
"denied_policy": "Avvist av policy: %(policy)s",
"email_banned": "E-post er utestengt av serverpolicyen",
"email_domain_banned": "E-postdomenet er utestengt av serverpolicyen",
"email_domain_not_allowed": "E-postdomene er ikke tillatt av serverpolicyen",
"email_not_allowed": "E-post er ikke tillatt av serverpolicyen",
"field_required": "Dette feltet er obligatorisk",
"invalid_credentials": "Ugyldig legitimasjon",
"password_mismatch": "Passordfeltene stemmer ikke overens",
"rate_limit_exceeded": "Du har kommet med for mange forespørsler på kort tid. Vent noen minutter og prøv igjen.",
"username_all_numeric": "Brukernavn kan ikke bare bestå av tall",
"username_banned": "Brukernavn er utestengt av serverpolicyen",
"username_invalid_chars": "Brukernavnet inneholder ugyldige tegn. Bruk bare små bokstaver, tall, bindestrek og understrek.",
"username_not_allowed": "Brukernavnet er ikke tillatt av serverpolicyen",
"username_taken": "Dette brukernavnet er allerede tatt",
"username_too_long": "Brukernavnet er for langt",
"username_too_short": "Brukernavnet er for kort"
},
"login": {
"call_to_register": "Har du ikke en konto ennå?",
"continue_with_provider": "Fortsett med %(provider)s",
"description": "Logg på for å fortsette:",
"forgot_password": "Glemt passordet?",
"headline": "Logg inn",
"link": {
"description": "Kobler din <span class=\"break-keep text-links\">%(provider)s</span> konto",
"headline": "Logg inn for å koble"
},
"no_login_methods": "Ingen påloggingsmetoder tilgjengelig.",
"separator": "Eller",
"username_or_email": "Brukernavn eller e-postadresse"
},
"navbar": {
"my_account": "Min konto",
"register": "Opprett en konto",
"signed_in_as": "Logget på som <span class=\"font-semibold\">%(username)s</span>."
},
"not_found": {
"description": "Siden du lette etter eksisterer ikke eller har blitt flyttet",
"heading": "Side ikke funnet"
},
"not_you": "Ikke %(username)s?",
"or_separator": "Eller",
"policy_violation": {
"description": "Dette kan skyldes klienten som har opprettet forespørselen, den påloggede brukeren eller selve forespørselen.",
"heading": "Autorisasjonsforespørselen ble avvist av policyen som håndheves av denne tjenesten",
"logged_as": "Logget inn som <span class=\"font-semibold\">%(username)s</span>"
},
"recovery": {
"consumed": {
"description": "For å opprette et nytt passord, start på nytt og velg «Glemt passord».",
"heading": "Lenken for å tilbakestille passordet ditt har allerede blitt brukt"
},
"disabled": {
"description": "Hvis du har mistet legitimasjonen din, vennligst kontakt administratoren for å gjenopprette kontoen din.",
"heading": "Muligheten for kontogjenoppretting er skrudd av"
},
"expired": {
"description": "Be om en ny e-post som vil bli sendt til: <span>%(email)s</span>.",
"heading": "Lenken for å tilbakestille passordet ditt er utløpt",
"resend_email": "Send e-post på nytt"
},
"finish": {
"confirm": "Skriv inn nytt passord igjen",
"description": "Velg et nytt passord for kontoen din.",
"heading": "Tilbakestill passordet ditt",
"new": "Nytt passord",
"save_and_continue": "Lagre og fortsett"
},
"progress": {
"change_email": "Prøv en annen e-postadresse",
"description": "Vi sendte en e-post med en lenke for å tilbakestille passordet ditt hvis det er en konto som bruker <span> %(email)s</span>.",
"heading": "Sjekk e-posten din",
"resend_email": "Send e-post på nytt"
},
"start": {
"description": "En e-post vil bli sendt med en lenke for å tilbakestille passordet ditt.",
"heading": "Skriv inn e-postadressen din for å fortsette"
}
},
"register": {
"call_to_login": "Har du allerede en konto?",
"continue_with_email": "Fortsett med e-postadresse",
"create_account": {
"description": "Velg et brukernavn for å fortsette.",
"heading": "Opprett en konto"
},
"sign_in_instead": "Logg på i stedet",
"terms_of_service": "Jeg godtar <a href=\"%s\" data-kind=\"primary\" class=\"cpd-link\">vilkårene og betingelsene</a>"
},
"scope": {
"edit_profile": "Rediger din profil og kontaktdetaljer",
"manage_sessions": "Administrer enhetene og sesjonene dine",
"mas_admin": "Administrer alle brukere på matrix-authentication-service",
"send_messages": "Send nye meldinger på dine vegne",
"synapse_admin": "Administrer Synapse-hjemmeserveren",
"view_messages": "Se dine eksisterende meldinger og data",
"view_profile": "Se din profilinformasjon og kontaktdetaljer"
},
"upstream_oauth2": {
"link_mismatch": {
"heading": "Denne oppstrømskontoen er allerede knyttet til en annen konto."
},
"register": {
"choose_username": {
"description": "Dette kan ikke endres senere.",
"heading": "Velg ditt brukernavn"
},
"create_account": "Opprett en ny konto",
"enforced_by_policy": "Håndhevet av serverpolicy",
"forced_display_name": "Vil bruke følgende visningsnavn",
"forced_email": "Vil bruke følgende e-postadresse",
"forced_localpart": "Vil bruke følgende brukernavn",
"import_data": {
"description": "Bekreft informasjonen som vil bli knyttet til din nye %(server_name)s konto.",
"heading": "Importer dataene dine"
},
"imported_from_upstream": "Importert fra oppstrømskontoen din",
"imported_from_upstream_with_name": "Importert fra %(human_name)s kontoen din",
"link_existing": "Koble til en eksisterende konto",
"provider_name": "%(human_name)s konto",
"signup_with_upstream": {
"heading": "Fortsett å registrere deg med din %(human_name)s konto"
},
"suggested_display_name": "Importer visningsnavn",
"suggested_email": "Importer e-postadresse",
"use": "Bruk"
},
"suggest_link": {
"action": "Lenke",
"heading": "Koble til din eksisterende konto"
}
},
"verify_email": {
"6_digit_code": "6-sifret kode",
"code": "Kode",
"description": "Skriv inn den 6-sifrede koden sendt til: <em>%(email)s</em>",
"headline": "Bekreft e-postadressen din"
}
}
}