Compare commits

..

1 Commits

Author SHA1 Message Date
Daniel García e85a42a45f Don't update non editable fields from the API 2025-01-24 16:49:16 +01:00
15 changed files with 219 additions and 401 deletions
+8 -8
View File
@@ -120,37 +120,37 @@ jobs:
# First test all features together, afterwards test them separately.
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc,query_logger"
id: test_sqlite_mysql_postgresql_mimalloc_logger
if: ${{ !cancelled() }}
if: $${{ always() }}
run: |
cargo test --features sqlite,mysql,postgresql,enable_mimalloc,query_logger
- name: "test features: sqlite,mysql,postgresql,enable_mimalloc"
id: test_sqlite_mysql_postgresql_mimalloc
if: ${{ !cancelled() }}
if: $${{ always() }}
run: |
cargo test --features sqlite,mysql,postgresql,enable_mimalloc
- name: "test features: sqlite,mysql,postgresql"
id: test_sqlite_mysql_postgresql
if: ${{ !cancelled() }}
if: $${{ always() }}
run: |
cargo test --features sqlite,mysql,postgresql
- name: "test features: sqlite"
id: test_sqlite
if: ${{ !cancelled() }}
if: $${{ always() }}
run: |
cargo test --features sqlite
- name: "test features: mysql"
id: test_mysql
if: ${{ !cancelled() }}
if: $${{ always() }}
run: |
cargo test --features mysql
- name: "test features: postgresql"
id: test_postgresql
if: ${{ !cancelled() }}
if: $${{ always() }}
run: |
cargo test --features postgresql
# End Run cargo tests
@@ -159,7 +159,7 @@ jobs:
# Run cargo clippy, and fail on warnings
- name: "clippy features: sqlite,mysql,postgresql,enable_mimalloc"
id: clippy
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
if: ${{ always() && matrix.channel == 'rust-toolchain' }}
run: |
cargo clippy --features sqlite,mysql,postgresql,enable_mimalloc -- -D warnings
# End Run cargo clippy
@@ -168,7 +168,7 @@ jobs:
# Run cargo fmt (Only run on rust-toolchain defined version)
- name: "check formatting"
id: formatting
if: ${{ !cancelled() && matrix.channel == 'rust-toolchain' }}
if: ${{ always() && matrix.channel == 'rust-toolchain' }}
run: |
cargo fmt --all -- --check
# End Run cargo fmt
Generated
+80 -84
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -44,7 +44,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.25"
log = "0.4.22"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
tracing = { version = "0.1.41", features = ["log"] } # Needed to have lettre and webauthn-rs trace logging to work
@@ -71,11 +71,11 @@ dashmap = "6.1.0"
# Async futures
futures = "0.3.31"
tokio = { version = "1.43.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
tokio = { version = "1.42.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
# A generic serialization/deserialization framework
serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.137"
serde_json = "1.0.135"
# A safe, extensible ORM and Query builder
diesel = { version = "2.2.6", features = ["chrono", "r2d2", "numeric"] }
@@ -93,18 +93,18 @@ rand = { version = "0.8.5", features = ["small_rng"] }
ring = "0.17.8"
# UUID generation
uuid = { version = "1.12.1", features = ["v4"] }
uuid = { version = "1.11.0", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.39", features = ["clock", "serde"], default-features = false }
chrono-tz = "0.10.1"
chrono-tz = "0.10.0"
time = "0.3.37"
# Job scheduler
job_scheduler_ng = "2.0.5"
# Data encoding library Hex/Base32/Base64
data-encoding = "2.7.0"
data-encoding = "2.6.0"
# JWT library
jsonwebtoken = "9.3.0"
@@ -157,7 +157,7 @@ paste = "1.0.15"
governor = "0.8.0"
# Check client versions for specific features.
semver = "1.0.25"
semver = "1.0.24"
# Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow
+1 -1
View File
@@ -10,4 +10,4 @@ proc-macro = true
[dependencies]
quote = "1.0.38"
syn = "2.0.96"
syn = "2.0.94"
+16 -16
View File
@@ -171,7 +171,7 @@ struct LoginForm {
redirect: Option<String>,
}
#[post("/", format = "application/x-www-form-urlencoded", data = "<data>")]
#[post("/", data = "<data>")]
fn post_admin_login(
data: Form<LoginForm>,
cookies: &CookieJar<'_>,
@@ -289,7 +289,7 @@ async fn get_user_or_404(user_id: &UserId, conn: &mut DbConn) -> ApiResult<User>
}
}
#[post("/invite", format = "application/json", data = "<data>")]
#[post("/invite", data = "<data>")]
async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbConn) -> JsonResult {
let data: InviteData = data.into_inner();
if User::find_by_mail(&data.email, &mut conn).await.is_some() {
@@ -315,7 +315,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
Ok(Json(user.to_json(&mut conn).await))
}
#[post("/test/smtp", format = "application/json", data = "<data>")]
#[post("/test/smtp", data = "<data>")]
async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
let data: InviteData = data.into_inner();
@@ -393,7 +393,7 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, mut conn: DbConn) ->
Ok(Json(usr))
}
#[post("/users/<user_id>/delete", format = "application/json")]
#[post("/users/<user_id>/delete")]
async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
let user = get_user_or_404(&user_id, &mut conn).await?;
@@ -417,7 +417,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Em
res
}
#[post("/users/<user_id>/deauth", format = "application/json")]
#[post("/users/<user_id>/deauth")]
async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &mut conn).await?;
@@ -438,7 +438,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt:
user.save(&mut conn).await
}
#[post("/users/<user_id>/disable", format = "application/json")]
#[post("/users/<user_id>/disable")]
async fn disable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &mut conn).await?;
Device::delete_all_by_user(&user.uuid, &mut conn).await?;
@@ -452,7 +452,7 @@ async fn disable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn, nt:
save_result
}
#[post("/users/<user_id>/enable", format = "application/json")]
#[post("/users/<user_id>/enable")]
async fn enable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &mut conn).await?;
user.enabled = true;
@@ -460,7 +460,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> E
user.save(&mut conn).await
}
#[post("/users/<user_id>/remove-2fa", format = "application/json")]
#[post("/users/<user_id>/remove-2fa")]
async fn remove_2fa(user_id: UserId, token: AdminToken, mut conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &mut conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &mut conn).await?;
@@ -469,7 +469,7 @@ async fn remove_2fa(user_id: UserId, token: AdminToken, mut conn: DbConn) -> Emp
user.save(&mut conn).await
}
#[post("/users/<user_id>/invite/resend", format = "application/json")]
#[post("/users/<user_id>/invite/resend")]
async fn resend_user_invite(user_id: UserId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
if let Some(user) = User::find_by_uuid(&user_id, &mut conn).await {
//TODO: replace this with user.status check when it will be available (PR#3397)
@@ -496,7 +496,7 @@ struct MembershipTypeData {
org_uuid: OrganizationId,
}
#[post("/users/org_type", format = "application/json", data = "<data>")]
#[post("/users/org_type", data = "<data>")]
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, mut conn: DbConn) -> EmptyResult {
let data: MembershipTypeData = data.into_inner();
@@ -550,7 +550,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
member_to_edit.save(&mut conn).await
}
#[post("/users/update_revision", format = "application/json")]
#[post("/users/update_revision")]
async fn update_revision_users(_token: AdminToken, mut conn: DbConn) -> EmptyResult {
User::update_all_revisions(&mut conn).await
}
@@ -575,7 +575,7 @@ async fn organizations_overview(_token: AdminToken, mut conn: DbConn) -> ApiResu
Ok(Html(text))
}
#[post("/organizations/<org_id>/delete", format = "application/json")]
#[post("/organizations/<org_id>/delete")]
async fn delete_organization(org_id: OrganizationId, _token: AdminToken, mut conn: DbConn) -> EmptyResult {
let org = Organization::find_by_uuid(&org_id, &mut conn).await.map_res("Organization doesn't exist")?;
org.delete(&mut conn).await
@@ -733,7 +733,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
Ok(Html(text))
}
#[get("/diagnostics/config", format = "application/json")]
#[get("/diagnostics/config")]
fn get_diagnostics_config(_token: AdminToken) -> Json<Value> {
let support_json = CONFIG.get_support_json();
Json(support_json)
@@ -744,7 +744,7 @@ fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult {
err_code!(format!("Testing error {code} response"), code);
}
#[post("/config", format = "application/json", data = "<data>")]
#[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner();
if let Err(e) = CONFIG.update_config(data, true) {
@@ -753,7 +753,7 @@ fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
Ok(())
}
#[post("/config/delete", format = "application/json")]
#[post("/config/delete")]
fn delete_config(_token: AdminToken) -> EmptyResult {
if let Err(e) = CONFIG.delete_user_config() {
err!(format!("Unable to delete config: {e:?}"))
@@ -761,7 +761,7 @@ fn delete_config(_token: AdminToken) -> EmptyResult {
Ok(())
}
#[post("/config/backup_db", format = "application/json")]
#[post("/config/backup_db")]
async fn backup_db(_token: AdminToken, mut conn: DbConn) -> ApiResult<String> {
if *CAN_BACKUP {
match backup_database(&mut conn).await {
+2 -9
View File
@@ -34,13 +34,9 @@ struct EventRange {
async fn get_org_events(
org_id: OrganizationId,
data: EventRange,
headers: AdminHeaders,
_headers: AdminHeaders,
mut conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
@@ -104,12 +100,9 @@ async fn get_user_events(
org_id: OrganizationId,
member_id: MembershipId,
data: EventRange,
headers: AdminHeaders,
_headers: AdminHeaders,
mut conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if !CONFIG.org_events_enabled() {
+2 -2
View File
@@ -210,8 +210,8 @@ fn config() -> Json<Value> {
// This means they expect a version that closely matches the Bitwarden server version
// We should make sure that we keep this updated when we support the new server features
// Version history:
// - Individual cipher key encryption: 2024.2.0
"version": "2025.1.0",
// - Individual cipher key encryption: 2023.9.1
"version": "2024.2.0",
"gitHash": option_env!("GIT_REV"),
"server": {
"name": "Vaultwarden",
File diff suppressed because it is too large Load Diff
+14 -38
View File
@@ -557,17 +557,24 @@ impl<'r> FromRequest<'r> for OrgHeaders {
// but there are cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values.
let url_org_id: Option<OrganizationId> = {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id.clone())
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
Some(org_id.clone())
} else {
None
let mut url_org_id = None;
if let Some(Ok(org_id)) = request.param::<&str>(1) {
if uuid::Uuid::parse_str(org_id).is_ok() {
url_org_id = Some(org_id.to_string().into());
}
}
if let Some(Ok(org_id)) = request.query_value::<&str>("organizationId") {
if uuid::Uuid::parse_str(org_id).is_ok() {
url_org_id = Some(org_id.to_string().into());
}
}
url_org_id
};
match url_org_id {
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {
Some(org_id) => {
let mut conn = match DbConn::from_request(request).await {
Outcome::Success(conn) => conn,
_ => err_handler!("Error getting DB"),
@@ -612,7 +619,6 @@ pub struct AdminHeaders {
pub user: User,
pub membership_type: MembershipType,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
@@ -628,7 +634,6 @@ impl<'r> FromRequest<'r> for AdminHeaders {
user: headers.user,
membership_type: headers.membership_type,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Admin or Owner to call this endpoint")
@@ -674,7 +679,6 @@ pub struct ManagerHeaders {
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
@@ -703,7 +707,6 @@ impl<'r> FromRequest<'r> for ManagerHeaders {
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be a Manager, Admin or Owner to call this endpoint")
@@ -783,7 +786,6 @@ impl ManagerHeaders {
device: h.device,
user: h.user,
ip: h.ip,
org_id: h.membership.org_uuid,
})
}
}
@@ -792,7 +794,6 @@ pub struct OwnerHeaders {
pub device: Device,
pub user: User,
pub ip: ClientIp,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
@@ -806,7 +807,6 @@ impl<'r> FromRequest<'r> for OwnerHeaders {
device: headers.device,
user: headers.user,
ip: headers.ip,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be Owner to call this endpoint")
@@ -814,30 +814,6 @@ impl<'r> FromRequest<'r> for OwnerHeaders {
}
}
pub struct OrgMemberHeaders {
pub host: String,
pub user: User,
pub org_id: OrganizationId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for OrgMemberHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(OrgHeaders::from_request(request).await);
if headers.membership_type >= MembershipType::User {
Outcome::Success(Self {
host: headers.host,
user: headers.user,
org_id: headers.membership.org_uuid,
})
} else {
err_handler!("You need to be a Member of the Organization to call this endpoint")
}
}
}
//
// Client IP address detection
//
+14 -16
View File
@@ -1,10 +1,8 @@
use std::{
env::consts::EXE_SUFFIX,
process::exit,
sync::{
atomic::{AtomicBool, Ordering},
RwLock,
},
use std::env::consts::EXE_SUFFIX;
use std::process::exit;
use std::sync::{
atomic::{AtomicBool, Ordering},
RwLock,
};
use job_scheduler_ng::Schedule;
@@ -14,7 +12,7 @@ use reqwest::Url;
use crate::{
db::DbConnType,
error::Error,
util::{get_env, get_env_bool, get_web_vault_version, is_valid_email, parse_experimental_client_feature_flags},
util::{get_env, get_env_bool, get_web_vault_version, parse_experimental_client_feature_flags},
};
static CONFIG_FILE: Lazy<String> = Lazy::new(|| {
@@ -687,7 +685,7 @@ make_config! {
/// Use Sendmail |> Whether to send mail via the `sendmail` command
use_sendmail: bool, true, def, false;
/// Sendmail Command |> Which sendmail command to use. The one found in the $PATH is used if not specified.
sendmail_command: String, false, option;
sendmail_command: String, false, option;
/// Host
smtp_host: String, true, option;
/// DEPRECATED smtp_ssl |> DEPRECATED - Please use SMTP_SECURITY
@@ -903,12 +901,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
let command = cfg.sendmail_command.clone().unwrap_or_else(|| format!("sendmail{EXE_SUFFIX}"));
let mut path = std::path::PathBuf::from(&command);
// Check if we can find the sendmail command to execute when no absolute path is given
if !path.is_absolute() {
let Ok(which_path) = which::which(&command) else {
err!(format!("sendmail command {command} not found in $PATH"))
};
path = which_path;
match which::which(&command) {
Ok(result) => path = result,
Err(_) => err!(format!("sendmail command {command:?} not found in $PATH")),
}
}
match path.metadata() {
@@ -942,8 +940,8 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
}
}
if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) {
err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from))
if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !cfg.smtp_from.contains('@') {
err!("SMTP_FROM does not contain a mandatory @ sign")
}
if cfg._enable_email_2fa && cfg.email_token_size < 6 {
+2 -3
View File
@@ -152,7 +152,6 @@ impl PartialOrd<MembershipType> for i32 {
/// Local methods
impl Organization {
pub fn new(name: String, billing_email: String, private_key: Option<String>, public_key: Option<String>) -> Self {
let billing_email = billing_email.to_lowercase();
Self {
uuid: OrganizationId(crate::util::get_uuid()),
name,
@@ -308,8 +307,8 @@ use crate::error::MapResult;
/// Database methods
impl Organization {
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.billing_email) {
err!(format!("BillingEmail {} is not a valid email address", self.billing_email))
if !email_address::EmailAddress::is_valid(self.billing_email.trim()) {
err!(format!("BillingEmail {} is not a valid email address", self.billing_email.trim()))
}
for member in Membership::find_by_org(&self.uuid, conn).await.iter() {
+4 -4
View File
@@ -267,8 +267,8 @@ impl User {
}
pub async fn save(&mut self, conn: &mut DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.email) {
err!(format!("User email {} is not a valid email address", self.email))
if self.email.trim().is_empty() {
err!("User email can't be empty")
}
self.updated_at = Utc::now().naive_utc();
@@ -408,8 +408,8 @@ impl Invitation {
}
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
if !crate::util::is_valid_email(&self.email) {
err!(format!("Invitation email {} is not a valid email address", self.email))
if self.email.trim().is_empty() {
err!("Invitation email can't be empty")
}
db_run! {conn:
+6 -5
View File
@@ -1,6 +1,7 @@
use std::str::FromStr;
use chrono::NaiveDateTime;
use percent_encoding::{percent_encode, NON_ALPHANUMERIC};
use std::{env::consts::EXE_SUFFIX, str::FromStr};
use lettre::{
message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart},
@@ -25,7 +26,7 @@ fn sendmail_transport() -> AsyncSendmailTransport<Tokio1Executor> {
if let Some(command) = CONFIG.sendmail_command() {
AsyncSendmailTransport::new_with_command(command)
} else {
AsyncSendmailTransport::new_with_command(format!("sendmail{EXE_SUFFIX}"))
AsyncSendmailTransport::new()
}
}
@@ -594,13 +595,13 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult {
// Match some common errors and make them more user friendly
Err(e) => {
if e.is_client() {
debug!("Sendmail client error: {:?}", e);
debug!("Sendmail client error: {:#?}", e);
err!(format!("Sendmail client error: {e}"));
} else if e.is_response() {
debug!("Sendmail response error: {:?}", e);
debug!("Sendmail response error: {:#?}", e);
err!(format!("Sendmail response error: {e}"));
} else {
debug!("Sendmail error: {:?}", e);
debug!("Sendmail error: {:#?}", e);
err!(format!("Sendmail error: {e}"));
}
}
+1 -4
View File
@@ -236,11 +236,8 @@ function checkSecurityHeaders(headers, omit) {
"referrer-policy": ["same-origin"],
"x-xss-protection": ["0"],
"x-robots-tag": ["noindex", "nofollow"],
"cross-origin-resource-policy": ["same-origin"],
"content-security-policy": [
"default-src 'none'",
"font-src 'self'",
"manifest-src 'self'",
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'self' blob:",
+1 -22
View File
@@ -55,8 +55,6 @@ impl Fairing for AppHeaders {
res.set_raw_header("Referrer-Policy", "same-origin");
res.set_raw_header("X-Content-Type-Options", "nosniff");
res.set_raw_header("X-Robots-Tag", "noindex, nofollow");
res.set_raw_header("Cross-Origin-Resource-Policy", "same-origin");
// Obsolete in modern browsers, unsafe (XS-Leak), and largely replaced by CSP
res.set_raw_header("X-XSS-Protection", "0");
@@ -76,9 +74,7 @@ impl Fairing for AppHeaders {
// # Mail Relay: https://bitwarden.com/blog/add-privacy-and-security-using-email-aliases-with-bitwarden/
// app.simplelogin.io, app.addy.io, api.fastmail.com, quack.duckduckgo.com
let csp = format!(
"default-src 'none'; \
font-src 'self'; \
manifest-src 'self'; \
"default-src 'self'; \
base-uri 'self'; \
form-action 'self'; \
object-src 'self' blob:; \
@@ -465,23 +461,6 @@ pub fn parse_date(date: &str) -> NaiveDateTime {
DateTime::parse_from_rfc3339(date).unwrap().naive_utc()
}
/// Returns true or false if an email address is valid or not
///
/// Some extra checks instead of only using email_address
/// This prevents from weird email formats still excepted but in the end invalid
pub fn is_valid_email(email: &str) -> bool {
let Ok(email) = email_address::EmailAddress::from_str(email) else {
return false;
};
let Ok(email_url) = url::Url::parse(&format!("https://{}", email.domain())) else {
return false;
};
if email_url.path().ne("/") || email_url.domain().is_none() || email_url.query().is_some() {
return false;
}
true
}
//
// Deployment environment methods
//