license and some functions
This commit is contained in:
+58
-22
@@ -1,8 +1,17 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
mod smtp;
|
||||
mod values;
|
||||
|
||||
use std::{net::IpAddr, path::Path};
|
||||
use smtp::load_smtp;
|
||||
use std::{net::IpAddr, path::Path};
|
||||
use values::ConfigValues;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -14,7 +23,12 @@ pub enum AuthorizationType {
|
||||
|
||||
impl AuthorizationType {
|
||||
fn from_values(values: &ConfigValues) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() {
|
||||
match values
|
||||
.get("AUTHORIZATION_TYPE", "local")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"local" => Ok(Self::Local),
|
||||
"ldap" => Ok(Self::Ldap),
|
||||
"ad" => Ok(Self::Ad),
|
||||
@@ -63,13 +77,22 @@ impl Config {
|
||||
let port = values.get("APP_PORT", "3000").parse()?;
|
||||
let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?;
|
||||
let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?;
|
||||
let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
||||
let anonymous_access_token_ttl_days =
|
||||
values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
|
||||
let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?;
|
||||
let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
||||
let unconfirmed_account_ttl_days =
|
||||
values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?;
|
||||
let files_dir = values.get("FILES_DIR", "data/files");
|
||||
|
||||
let storage = match values.get("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() {
|
||||
"local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() },
|
||||
let storage = match values
|
||||
.get("STORAGE_DRIVER", "local")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"local" => crate::storage::StorageConfig::Local {
|
||||
root: files_dir.clone().into(),
|
||||
},
|
||||
"s3" => crate::storage::StorageConfig::S3 {
|
||||
endpoint: values.optional("S3_ENDPOINT"),
|
||||
region: values.get("S3_REGION", "us-east-1"),
|
||||
@@ -92,7 +115,10 @@ impl Config {
|
||||
let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str());
|
||||
let (default_filter, default_username_attribute) = match authorization_type {
|
||||
AuthorizationType::Ldap => ("(uid={username})", "uid"),
|
||||
AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"),
|
||||
AuthorizationType::Ad => (
|
||||
"(|(sAMAccountName={username})(userPrincipalName={username}))",
|
||||
"sAMAccountName",
|
||||
),
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
};
|
||||
Some(crate::auth::ldap::LdapConfig {
|
||||
@@ -102,21 +128,28 @@ impl Config {
|
||||
bind_password: values.get("LDAP_BIND_PASSWORD", ""),
|
||||
base_dn: values.required("LDAP_BASE_DN", &context)?,
|
||||
user_filter: values.get("LDAP_USER_FILTER", default_filter),
|
||||
username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
|
||||
username_attribute: values
|
||||
.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute),
|
||||
email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"),
|
||||
display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||
external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type {
|
||||
AuthorizationType::Ldap => "entryUUID",
|
||||
AuthorizationType::Ad => "objectGUID",
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
}),
|
||||
display_name_attribute: values
|
||||
.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"),
|
||||
external_id_attribute: values.get(
|
||||
"LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||
match authorization_type {
|
||||
AuthorizationType::Ldap => "entryUUID",
|
||||
AuthorizationType::Ad => "objectGUID",
|
||||
AuthorizationType::Local => unreachable!(),
|
||||
},
|
||||
),
|
||||
organization: values.get("LDAP_ORGANIZATION", "organization"),
|
||||
provider: authorization_type.as_str().to_owned(),
|
||||
email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?,
|
||||
link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?,
|
||||
tls_verify: values.bool("LDAP_TLS_VERIFY", true)?,
|
||||
connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
||||
operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
|
||||
connect_timeout_seconds: values
|
||||
.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?,
|
||||
operation_timeout_seconds: values
|
||||
.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?,
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -131,10 +164,14 @@ impl Config {
|
||||
static_dir: values.get("STATIC_DIR", "static"),
|
||||
files_dir,
|
||||
storage,
|
||||
upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
|
||||
upload_max_size_bytes: upload_max_size_mb
|
||||
.checked_mul(1024 * 1024)
|
||||
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
|
||||
asset_version: env!("CARGO_PKG_VERSION").to_owned(),
|
||||
asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
asset_cache_max_age_seconds: values
|
||||
.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
file_cache_max_age_seconds: values
|
||||
.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?,
|
||||
smtp,
|
||||
registration_enabled: values.bool("REGISTRATION_ENABLED", false)?,
|
||||
account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
|
||||
@@ -165,12 +202,11 @@ impl Config {
|
||||
return Err("STATIC_DIR and FILES_DIR cannot be empty".into());
|
||||
}
|
||||
if let Some(smtp) = &self.smtp {
|
||||
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) {
|
||||
if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://"))
|
||||
{
|
||||
return Err("PUBLIC_URL must start with http:// or https://".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+13
-1
@@ -1,3 +1,12 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use lettre::message::Mailbox;
|
||||
|
||||
use crate::state::{SmtpConfig, SmtpSecurity};
|
||||
@@ -84,7 +93,10 @@ mod tests {
|
||||
#[test]
|
||||
fn accepts_supported_security_modes() {
|
||||
assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None);
|
||||
assert_eq!(parse_smtp_security("starttls").unwrap(), SmtpSecurity::StartTls);
|
||||
assert_eq!(
|
||||
parse_smtp_security("starttls").unwrap(),
|
||||
SmtpSecurity::StartTls
|
||||
);
|
||||
assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls);
|
||||
assert!(parse_smtp_security("auto").is_err());
|
||||
}
|
||||
|
||||
+143
-37
@@ -1,19 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use std::{collections::HashMap, env, path::Path};
|
||||
|
||||
const KNOWN_CONFIG_KEYS: &[&str] = &[
|
||||
"APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS",
|
||||
"STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB",
|
||||
"ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS",
|
||||
"REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED",
|
||||
"FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS",
|
||||
"UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE",
|
||||
"S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY",
|
||||
"S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_SECURITY", "SMTP_USERNAME", "SMTP_PASSWORD",
|
||||
"SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN",
|
||||
"LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE",
|
||||
"LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||
"LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL",
|
||||
"LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS",
|
||||
"APP_HOST",
|
||||
"APP_PORT",
|
||||
"DATABASE_URL",
|
||||
"DATABASE_MAX_CONNECTIONS",
|
||||
"STATIC_DIR",
|
||||
"FILES_DIR",
|
||||
"STORAGE_DRIVER",
|
||||
"UPLOAD_MAX_SIZE_MB",
|
||||
"ASSET_CACHE_MAX_AGE_SECONDS",
|
||||
"FILE_CACHE_MAX_AGE_SECONDS",
|
||||
"REGISTRATION_ENABLED",
|
||||
"ACCOUNT_CONFIRMATION_REQUIRED",
|
||||
"SHARE_CONFIRMATION_REQUIRED",
|
||||
"FRONTEND_LOG_LEVEL",
|
||||
"ANONYMOUS_ACCESS_TOKEN_TTL_DAYS",
|
||||
"USER_SESSION_TTL_DAYS",
|
||||
"UNCONFIRMED_ACCOUNT_TTL_DAYS",
|
||||
"AUTHORIZATION_TYPE",
|
||||
"S3_ENDPOINT",
|
||||
"S3_REGION",
|
||||
"S3_BUCKET",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"S3_FORCE_PATH_STYLE",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURITY",
|
||||
"SMTP_USERNAME",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_FROM",
|
||||
"PUBLIC_URL",
|
||||
"LDAP_URL",
|
||||
"LDAP_STARTTLS",
|
||||
"LDAP_BIND_DN",
|
||||
"LDAP_BIND_PASSWORD",
|
||||
"LDAP_BASE_DN",
|
||||
"LDAP_USER_FILTER",
|
||||
"LDAP_USERNAME_ATTRIBUTE",
|
||||
"LDAP_EMAIL_ATTRIBUTE",
|
||||
"LDAP_DISPLAY_NAME_ATTRIBUTE",
|
||||
"LDAP_EXTERNAL_ID_ATTRIBUTE",
|
||||
"LDAP_ORGANIZATION",
|
||||
"LDAP_EMAIL_REQUIRED",
|
||||
"LDAP_LINK_EXISTING_BY_EMAIL",
|
||||
"LDAP_TLS_VERIFY",
|
||||
"LDAP_CONNECT_TIMEOUT_SECONDS",
|
||||
"LDAP_OPERATION_TIMEOUT_SECONDS",
|
||||
];
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -23,7 +66,9 @@ pub(super) struct ConfigValues {
|
||||
|
||||
impl ConfigValues {
|
||||
pub(super) fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let Some(path) = path else { return Ok(Self::default()); };
|
||||
let Some(path) = path else {
|
||||
return Ok(Self::default());
|
||||
};
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|error| format!("cannot read config file {}: {error}", path.display()))?;
|
||||
let file = parse_yaml_config(&content)
|
||||
@@ -37,26 +82,50 @@ impl ConfigValues {
|
||||
}
|
||||
|
||||
pub(super) fn get(&self, name: &str, default: &str) -> String {
|
||||
env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned())
|
||||
env::var(name)
|
||||
.ok()
|
||||
.or_else(|| self.file.get(name).cloned())
|
||||
.unwrap_or_else(|| default.to_owned())
|
||||
}
|
||||
|
||||
pub(super) fn optional(&self, name: &str) -> Option<String> {
|
||||
env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty())
|
||||
env::var(name)
|
||||
.ok()
|
||||
.or_else(|| self.file.get(name).cloned())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn required(&self, name: &str, context: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into())
|
||||
pub(super) fn required(
|
||||
&self,
|
||||
name: &str,
|
||||
context: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
self.optional(name)
|
||||
.ok_or_else(|| format!("{name} is required when {context}").into())
|
||||
}
|
||||
|
||||
pub(super) fn bool(&self, name: &str, default: bool) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() {
|
||||
pub(super) fn bool(
|
||||
&self,
|
||||
name: &str,
|
||||
default: bool,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
match self
|
||||
.get(name, if default { "true" } else { "false" })
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" => Ok(false),
|
||||
_ => Err(format!("{name} must be true or false").into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn log_level(&self, name: &str, default: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
pub(super) fn log_level(
|
||||
&self,
|
||||
name: &str,
|
||||
default: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let value = self.get(name, default).trim().to_ascii_lowercase();
|
||||
match value.as_str() {
|
||||
"off" | "error" | "warn" | "info" | "debug" => Ok(value),
|
||||
@@ -64,20 +133,44 @@ impl ConfigValues {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn positive_i64(&self, name: &str, default: i64) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?;
|
||||
if value <= 0 { return Err(format!("{name} must be greater than 0").into()); }
|
||||
pub(super) fn positive_i64(
|
||||
&self,
|
||||
name: &str,
|
||||
default: i64,
|
||||
) -> Result<i64, Box<dyn std::error::Error>> {
|
||||
let value: i64 = self
|
||||
.get(name, &default.to_string())
|
||||
.parse()
|
||||
.map_err(|_| format!("{name} must be an integer"))?;
|
||||
if value <= 0 {
|
||||
return Err(format!("{name} must be greater than 0").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn positive_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?;
|
||||
if value == 0 { return Err(format!("{name} must be greater than 0").into()); }
|
||||
pub(super) fn positive_u64(
|
||||
&self,
|
||||
name: &str,
|
||||
default: u64,
|
||||
) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
let value: u64 = self
|
||||
.get(name, &default.to_string())
|
||||
.parse()
|
||||
.map_err(|_| format!("{name} must be a non-negative integer"))?;
|
||||
if value == 0 {
|
||||
return Err(format!("{name} must be greater than 0").into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn nonnegative_u64(&self, name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into())
|
||||
pub(super) fn nonnegative_u64(
|
||||
&self,
|
||||
name: &str,
|
||||
default: u64,
|
||||
) -> Result<u64, Box<dyn std::error::Error>> {
|
||||
self.get(name, &default.to_string())
|
||||
.parse()
|
||||
.map_err(|_| format!("{name} must be a non-negative integer").into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +181,9 @@ fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
|
||||
for (index, original) in content.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
if original.contains('\t') {
|
||||
return Err(format!("line {line_number}: tabs are not allowed for indentation"));
|
||||
return Err(format!(
|
||||
"line {line_number}: tabs are not allowed for indentation"
|
||||
));
|
||||
}
|
||||
let without_comment = strip_yaml_comment(original);
|
||||
if without_comment.trim().is_empty() || without_comment.trim() == "---" {
|
||||
@@ -100,10 +195,17 @@ fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
|
||||
.split_once(':')
|
||||
.ok_or_else(|| format!("line {line_number}: expected key: value"))?;
|
||||
let key = raw_key.trim();
|
||||
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
|
||||
if key.is_empty()
|
||||
|| !key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(format!("line {line_number}: invalid key {key:?}"));
|
||||
}
|
||||
while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) {
|
||||
while sections
|
||||
.last()
|
||||
.is_some_and(|(section_indent, _)| *section_indent >= indent)
|
||||
{
|
||||
sections.pop();
|
||||
}
|
||||
let normalized = key.to_ascii_uppercase().replace('-', "_");
|
||||
@@ -112,14 +214,19 @@ fn parse_yaml_config(content: &str) -> Result<HashMap<String, String>, String> {
|
||||
sections.push((indent, normalized));
|
||||
continue;
|
||||
}
|
||||
if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) {
|
||||
return Err(format!("line {line_number}: only scalar values and nested mappings are supported"));
|
||||
if matches!(
|
||||
value.chars().next(),
|
||||
Some('[' | '{' | '|' | '>' | '&' | '*' | '!')
|
||||
) {
|
||||
return Err(format!(
|
||||
"line {line_number}: only scalar values and nested mappings are supported"
|
||||
));
|
||||
}
|
||||
let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect();
|
||||
path.push(&normalized);
|
||||
let full_key = path.join("_");
|
||||
let parsed_value = parse_yaml_scalar(value)
|
||||
.map_err(|error| format!("line {line_number}: {error}"))?;
|
||||
let parsed_value =
|
||||
parse_yaml_scalar(value).map_err(|error| format!("line {line_number}: {error}"))?;
|
||||
if output.insert(full_key.clone(), parsed_value).is_some() {
|
||||
return Err(format!("line {line_number}: duplicate key {full_key}"));
|
||||
}
|
||||
@@ -166,4 +273,3 @@ fn parse_yaml_scalar(value: &str) -> Result<String, String> {
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user