From 8da5b994828cedad67c2d32df8d89fa79749b04f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Daniel=20Garc=C3=ADa?=
 <dani-garcia@users.noreply.github.com>
Date: Sun, 14 Mar 2021 23:35:55 +0100
Subject: [PATCH] Send API

---
 .env.template                                 |   1 +
 .../2021-03-11-190243_add_sends/down.sql      |   1 +
 .../mysql/2021-03-11-190243_add_sends/up.sql  |  25 ++
 .../2021-03-11-190243_add_sends/down.sql      |   1 +
 .../2021-03-11-190243_add_sends/up.sql        |  25 ++
 .../2021-03-11-190243_add_sends/down.sql      |   1 +
 .../sqlite/2021-03-11-190243_add_sends/up.sql |  25 ++
 src/api/core/ciphers.rs                       |   7 +
 src/api/core/mod.rs                           |   2 +
 src/api/core/sends.rs                         | 383 ++++++++++++++++++
 src/api/notifications.rs                      |   4 +
 src/api/web.rs                                |   7 +-
 src/config.rs                                 |   2 +
 src/db/models/mod.rs                          |   2 +
 src/db/models/send.rs                         | 235 +++++++++++
 src/db/models/user.rs                         |   3 +-
 src/db/schemas/mysql/schema.rs                |  26 ++
 src/db/schemas/postgresql/schema.rs           |  26 ++
 src/db/schemas/sqlite/schema.rs               |  26 ++
 src/error.rs                                  |  12 +
 20 files changed, 812 insertions(+), 2 deletions(-)
 create mode 100644 migrations/mysql/2021-03-11-190243_add_sends/down.sql
 create mode 100644 migrations/mysql/2021-03-11-190243_add_sends/up.sql
 create mode 100644 migrations/postgresql/2021-03-11-190243_add_sends/down.sql
 create mode 100644 migrations/postgresql/2021-03-11-190243_add_sends/up.sql
 create mode 100644 migrations/sqlite/2021-03-11-190243_add_sends/down.sql
 create mode 100644 migrations/sqlite/2021-03-11-190243_add_sends/up.sql
 create mode 100644 src/api/core/sends.rs
 create mode 100644 src/db/models/send.rs

diff --git a/.env.template b/.env.template
index 8e5fab73..34e0e294 100644
--- a/.env.template
+++ b/.env.template
@@ -28,6 +28,7 @@
 # RSA_KEY_FILENAME=data/rsa_key
 # ICON_CACHE_FOLDER=data/icon_cache
 # ATTACHMENTS_FOLDER=data/attachments
+# SENDS_FOLDER=data/sends
 
 ## Templates data folder, by default uses embedded templates
 ## Check source code to see the format
diff --git a/migrations/mysql/2021-03-11-190243_add_sends/down.sql b/migrations/mysql/2021-03-11-190243_add_sends/down.sql
new file mode 100644
index 00000000..b843b76a
--- /dev/null
+++ b/migrations/mysql/2021-03-11-190243_add_sends/down.sql
@@ -0,0 +1 @@
+DROP TABLE sends;
diff --git a/migrations/mysql/2021-03-11-190243_add_sends/up.sql b/migrations/mysql/2021-03-11-190243_add_sends/up.sql
new file mode 100644
index 00000000..ccc457dc
--- /dev/null
+++ b/migrations/mysql/2021-03-11-190243_add_sends/up.sql
@@ -0,0 +1,25 @@
+CREATE TABLE sends (
+  uuid              CHAR(36) NOT NULL   PRIMARY KEY,
+  user_uuid         CHAR(36)            REFERENCES users (uuid),
+  organization_uuid CHAR(36)            REFERENCES organizations (uuid),
+
+  name              TEXT    NOT NULL,
+  notes             TEXT,
+
+  atype             INTEGER NOT NULL,
+  data              TEXT    NOT NULL,
+  key               TEXT    NOT NULL,
+  password_hash     BLOB,
+  password_salt     BLOB,
+  password_iter     INTEGER,
+
+  max_access_count  INTEGER,
+  access_count      INTEGER NOT NULL,
+
+  creation_date     DATETIME NOT NULL,
+  revision_date     DATETIME NOT NULL,
+  expiration_date   DATETIME,
+  deletion_date     DATETIME NOT NULL,
+
+  disabled          BOOLEAN NOT NULL
+);
\ No newline at end of file
diff --git a/migrations/postgresql/2021-03-11-190243_add_sends/down.sql b/migrations/postgresql/2021-03-11-190243_add_sends/down.sql
new file mode 100644
index 00000000..b843b76a
--- /dev/null
+++ b/migrations/postgresql/2021-03-11-190243_add_sends/down.sql
@@ -0,0 +1 @@
+DROP TABLE sends;
diff --git a/migrations/postgresql/2021-03-11-190243_add_sends/up.sql b/migrations/postgresql/2021-03-11-190243_add_sends/up.sql
new file mode 100644
index 00000000..c57ca93b
--- /dev/null
+++ b/migrations/postgresql/2021-03-11-190243_add_sends/up.sql
@@ -0,0 +1,25 @@
+CREATE TABLE sends (
+  uuid              CHAR(36) NOT NULL   PRIMARY KEY,
+  user_uuid         CHAR(36)            REFERENCES users (uuid),
+  organization_uuid CHAR(36)            REFERENCES organizations (uuid),
+
+  name              TEXT    NOT NULL,
+  notes             TEXT,
+
+  atype             INTEGER NOT NULL,
+  data              TEXT    NOT NULL,
+  key               TEXT    NOT NULL,
+  password_hash     BYTEA,
+  password_salt     BYTEA,
+  password_iter     INTEGER,
+
+  max_access_count  INTEGER,
+  access_count      INTEGER NOT NULL,
+
+  creation_date     TIMESTAMP NOT NULL,
+  revision_date     TIMESTAMP NOT NULL,
+  expiration_date   TIMESTAMP,
+  deletion_date     TIMESTAMP NOT NULL,
+
+  disabled          BOOLEAN NOT NULL
+);
\ No newline at end of file
diff --git a/migrations/sqlite/2021-03-11-190243_add_sends/down.sql b/migrations/sqlite/2021-03-11-190243_add_sends/down.sql
new file mode 100644
index 00000000..b843b76a
--- /dev/null
+++ b/migrations/sqlite/2021-03-11-190243_add_sends/down.sql
@@ -0,0 +1 @@
+DROP TABLE sends;
diff --git a/migrations/sqlite/2021-03-11-190243_add_sends/up.sql b/migrations/sqlite/2021-03-11-190243_add_sends/up.sql
new file mode 100644
index 00000000..0c1e17ba
--- /dev/null
+++ b/migrations/sqlite/2021-03-11-190243_add_sends/up.sql
@@ -0,0 +1,25 @@
+CREATE TABLE sends (
+  uuid              TEXT NOT NULL   PRIMARY KEY,
+  user_uuid         TEXT            REFERENCES users (uuid),
+  organization_uuid TEXT            REFERENCES organizations (uuid),
+
+  name              TEXT    NOT NULL,
+  notes             TEXT,
+
+  atype             INTEGER NOT NULL,
+  data              TEXT    NOT NULL,
+  key               TEXT    NOT NULL,
+  password_hash     BLOB,
+  password_salt     BLOB,
+  password_iter     INTEGER,
+
+  max_access_count  INTEGER,
+  access_count      INTEGER NOT NULL,
+
+  creation_date     DATETIME NOT NULL,
+  revision_date     DATETIME NOT NULL,
+  expiration_date   DATETIME,
+  deletion_date     DATETIME NOT NULL,
+
+  disabled          BOOLEAN NOT NULL
+);
\ No newline at end of file
diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs
index a882be97..fe815401 100644
--- a/src/api/core/ciphers.rs
+++ b/src/api/core/ciphers.rs
@@ -104,6 +104,12 @@ fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> JsonResult {
         .map(|c| c.to_json(&headers.host, &headers.user.uuid, &conn))
         .collect();
 
+    let sends = Send::find_by_user(&headers.user.uuid, &conn);
+    let sends_json: Vec<Value> = sends
+        .iter()
+        .map(|s| s.to_json())
+        .collect();
+
     let domains_json = if data.exclude_domains {
         Value::Null
     } else {
@@ -117,6 +123,7 @@ fn sync(data: Form<SyncData>, headers: Headers, conn: DbConn) -> JsonResult {
         "Policies": policies_json,
         "Ciphers": ciphers_json,
         "Domains": domains_json,
+        "Sends": sends_json,
         "Object": "sync"
     })))
 }
diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs
index a2af79a5..4f0c19c9 100644
--- a/src/api/core/mod.rs
+++ b/src/api/core/mod.rs
@@ -3,6 +3,7 @@ mod ciphers;
 mod folders;
 mod organizations;
 pub mod two_factor;
+mod sends;
 
 pub fn routes() -> Vec<Route> {
     let mut mod_routes = routes![
@@ -20,6 +21,7 @@ pub fn routes() -> Vec<Route> {
     routes.append(&mut folders::routes());
     routes.append(&mut organizations::routes());
     routes.append(&mut two_factor::routes());
+    routes.append(&mut sends::routes());
     routes.append(&mut mod_routes);
 
     routes
diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs
new file mode 100644
index 00000000..6fc1dcbc
--- /dev/null
+++ b/src/api/core/sends.rs
@@ -0,0 +1,383 @@
+use std::{io::Read, path::Path};
+
+use chrono::{DateTime, Duration, Utc};
+use multipart::server::{save::SavedData, Multipart, SaveResult};
+use rocket::{http::ContentType, Data};
+use rocket_contrib::json::Json;
+use serde_json::Value;
+
+use crate::{
+    api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, Notify, UpdateType},
+    auth::{Headers, Host},
+    db::{models::*, DbConn},
+    CONFIG,
+};
+
+pub fn routes() -> Vec<rocket::Route> {
+    routes![
+        post_send,
+        post_send_file,
+        post_access,
+        post_access_file,
+        put_send,
+        delete_send,
+        put_remove_password
+    ]
+}
+
+#[derive(Deserialize)]
+#[allow(non_snake_case)]
+pub struct SendData {
+    pub Type: i32,
+    pub Key: String,
+    pub Password: Option<String>,
+    pub MaxAccessCount: Option<i32>,
+    pub ExpirationDate: Option<DateTime<Utc>>,
+    pub DeletionDate: DateTime<Utc>,
+    pub Disabled: bool,
+
+    // Data field
+    pub Name: String,
+    pub Notes: Option<String>,
+    pub Text: Option<Value>,
+    pub File: Option<Value>,
+}
+
+fn create_send(data: SendData, user_uuid: String) -> ApiResult<Send> {
+    let data_val = if data.Type == SendType::Text as i32 {
+        data.Text
+    } else if data.Type == SendType::File as i32 {
+        data.File
+    } else {
+        err!("Invalid Send type")
+    };
+
+    let data_str = if let Some(mut d) = data_val {
+        d.as_object_mut().and_then(|o| o.remove("Response"));
+        serde_json::to_string(&d)?
+    } else {
+        err!("Send data not provided");
+    };
+
+    if data.DeletionDate > Utc::now() + Duration::days(31) {
+        err!(
+            "You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again."
+        );
+    }
+
+    let mut send = Send::new(data.Type, data.Name, data_str, data.Key, data.DeletionDate.naive_utc());
+    send.user_uuid = Some(user_uuid);
+    send.notes = data.Notes;
+    send.max_access_count = data.MaxAccessCount;
+    send.expiration_date = data.ExpirationDate.map(|d| d.naive_utc());
+    send.disabled = data.Disabled;
+    send.atype = data.Type;
+
+    send.set_password(data.Password.as_deref());
+
+    Ok(send)
+}
+
+#[post("/sends", data = "<data>")]
+fn post_send(data: JsonUpcase<SendData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
+    let data: SendData = data.into_inner().data;
+
+    if data.Type == SendType::File as i32 {
+        err!("File sends should use /api/sends/file")
+    }
+
+    let mut send = create_send(data, headers.user.uuid.clone())?;
+    send.save(&conn)?;
+    nt.send_user_update(UpdateType::SyncSendCreate, &headers.user);
+
+    Ok(Json(send.to_json()))
+}
+
+#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
+fn post_send_file(data: Data, content_type: &ContentType, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
+    let boundary = content_type.params().next().expect("No boundary provided").1;
+
+    let mut mpart = Multipart::with_body(data.open(), boundary);
+
+    // First entry is the SendData JSON
+    let mut model_entry = match mpart.read_entry()? {
+        Some(e) if &*e.headers.name == "model" => e,
+        Some(_) => err!("Invalid entry name"),
+        None => err!("No model entry present"),
+    };
+
+    let mut buf = String::new();
+    model_entry.data.read_to_string(&mut buf)?;
+    let data = serde_json::from_str::<crate::util::UpCase<SendData>>(&buf)?;
+
+    // Get the file length and add an extra 10% to avoid issues
+    const SIZE_110_MB: u64 = 115_343_360;
+
+    let size_limit = match CONFIG.user_attachment_limit() {
+        Some(0) => err!("File uploads are disabled"),
+        Some(limit_kb) => {
+            let left = (limit_kb * 1024) - Attachment::size_by_user(&headers.user.uuid, &conn);
+            if left <= 0 {
+                err!("Attachment size limit reached! Delete some files to open space")
+            }
+            std::cmp::Ord::max(left as u64, SIZE_110_MB)
+        }
+        None => SIZE_110_MB,
+    };
+
+    // Create the Send
+    let mut send = create_send(data.data, headers.user.uuid.clone())?;
+    let file_id: String = data_encoding::HEXLOWER.encode(&crate::crypto::get_random(vec![0; 32]));
+
+    if send.atype != SendType::File as i32 {
+        err!("Send content is not a file");
+    }
+
+    let file_path = Path::new(&CONFIG.sends_folder()).join(&send.uuid).join(&file_id);
+
+    // Read the data entry and save the file
+    let mut data_entry = match mpart.read_entry()? {
+        Some(e) if &*e.headers.name == "data" => e,
+        Some(_) => err!("Invalid entry name"),
+        None => err!("No model entry present"),
+    };
+
+    let size = match data_entry
+        .data
+        .save()
+        .memory_threshold(0)
+        .size_limit(size_limit)
+        .with_path(&file_path)
+    {
+        SaveResult::Full(SavedData::File(_, size)) => size as i32,
+        SaveResult::Full(other) => {
+            std::fs::remove_file(&file_path).ok();
+            err!(format!("Attachment is not a file: {:?}", other));
+        }
+        SaveResult::Partial(_, reason) => {
+            std::fs::remove_file(&file_path).ok();
+            err!(format!("Attachment size limit exceeded with this file: {:?}", reason));
+        }
+        SaveResult::Error(e) => {
+            std::fs::remove_file(&file_path).ok();
+            err!(format!("Error: {:?}", e));
+        }
+    };
+
+    // Set ID and sizes
+    let mut data_value: Value = serde_json::from_str(&send.data)?;
+    if let Some(o) = data_value.as_object_mut() {
+        o.insert(String::from("Id"), Value::String(file_id));
+        o.insert(String::from("Size"), Value::Number(size.into()));
+        o.insert(
+            String::from("SizeName"),
+            Value::String(crate::util::get_display_size(size)),
+        );
+    }
+    send.data = serde_json::to_string(&data_value)?;
+
+    // Save the changes in the database
+    send.save(&conn)?;
+    nt.send_user_update(UpdateType::SyncSendCreate, &headers.user);
+
+    Ok(Json(send.to_json()))
+}
+
+#[derive(Deserialize)]
+#[allow(non_snake_case)]
+pub struct SendAccessData {
+    pub Password: Option<String>,
+}
+
+#[post("/sends/access/<access_id>", data = "<data>")]
+fn post_access(access_id: String, data: JsonUpcase<SendAccessData>, conn: DbConn) -> JsonResult {
+    let mut send = match Send::find_by_access_id(&access_id, &conn) {
+        Some(s) => s,
+        None => err_code!("Send not found", 404),
+    };
+
+    if let Some(max_access_count) = send.max_access_count {
+        if send.access_count > max_access_count {
+            err_code!("Max access count reached", 404);
+        }
+    }
+
+    if let Some(expiration) = send.expiration_date {
+        if Utc::now().naive_utc() > expiration {
+            err_code!("Send has expired", 404)
+        }
+    }
+
+    if Utc::now().naive_utc() > send.deletion_date {
+        err_code!("Send has been deleted", 404)
+    }
+
+    if send.disabled {
+        err_code!("Send has been disabled", 404)
+    }
+
+    if send.password_hash.is_some() {
+        match data.into_inner().data.Password {
+            Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
+            Some(_) => err!("Invalid password."),
+            None => err_code!("Password not provided", 401),
+        }
+    }
+
+    // Files are incremented during the download
+    if send.atype == SendType::Text as i32 {
+        send.access_count += 1;
+    }
+
+    send.save(&conn)?;
+
+    Ok(Json(send.to_json()))
+}
+
+#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
+fn post_access_file(
+    send_id: String,
+    file_id: String,
+    data: JsonUpcase<SendAccessData>,
+    host: Host,
+    conn: DbConn,
+) -> JsonResult {
+    let mut send = match Send::find_by_uuid(&send_id, &conn) {
+        Some(s) => s,
+        None => err_code!("Send not found", 404),
+    };
+
+    if let Some(max_access_count) = send.max_access_count {
+        if send.access_count > max_access_count {
+            err_code!("Max access count reached", 404);
+        }
+    }
+
+    if let Some(expiration) = send.expiration_date {
+        if Utc::now().naive_utc() > expiration {
+            err_code!("Send has expired", 404)
+        }
+    }
+
+    if Utc::now().naive_utc() > send.deletion_date {
+        err_code!("Send has been deleted", 404)
+    }
+
+    if send.disabled {
+        err_code!("Send has been disabled", 404)
+    }
+
+    if send.password_hash.is_some() {
+        match data.into_inner().data.Password {
+            Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
+            Some(_) => err!("Invalid password."),
+            None => err_code!("Password not provided", 401),
+        }
+    }
+
+    send.access_count += 1;
+
+    send.save(&conn)?;
+
+    Ok(Json(json!({
+        "Object": "send-fileDownload",
+        "Id": file_id,
+        "Url": format!("{}/sends/{}/{}", &host.host, send_id, file_id)
+    })))
+}
+
+#[put("/sends/<id>", data = "<data>")]
+fn put_send(id: String, data: JsonUpcase<SendData>, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
+    let data: SendData = data.into_inner().data;
+
+    let mut send = match Send::find_by_uuid(&id, &conn) {
+        Some(s) => s,
+        None => err!("Send not found"),
+    };
+
+    if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
+        err!("Send is not owned by user")
+    }
+
+    if send.atype != data.Type {
+        err!("Sends can't change type")
+    }
+
+    let data_val = if data.Type == SendType::Text as i32 {
+        data.Text
+    } else if data.Type == SendType::File as i32 {
+        data.File
+    } else {
+        err!("Invalid Send type")
+    };
+
+    let data_str = if let Some(mut d) = data_val {
+        d.as_object_mut().and_then(|d| d.remove("Response"));
+        serde_json::to_string(&d)?
+    } else {
+        err!("Send data not provided");
+    };
+
+    if data.DeletionDate > Utc::now() + Duration::days(31) {
+        err!(
+            "You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again."
+        );
+    }
+    send.data = data_str;
+    send.name = data.Name;
+    send.key = data.Key;
+    send.deletion_date = data.DeletionDate.naive_utc();
+    send.notes = data.Notes;
+    send.max_access_count = data.MaxAccessCount;
+    send.expiration_date = data.ExpirationDate.map(|d| d.naive_utc());
+    send.disabled = data.Disabled;
+
+    // Only change the value if it's present
+    if let Some(password) = data.Password {
+        send.set_password(Some(&password));
+    }
+
+    send.save(&conn)?;
+    nt.send_user_update(UpdateType::SyncSendUpdate, &headers.user);
+
+    Ok(Json(send.to_json()))
+}
+
+#[delete("/sends/<id>")]
+fn delete_send(id: String, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
+    let send = match Send::find_by_uuid(&id, &conn) {
+        Some(s) => s,
+        None => err!("Send not found"),
+    };
+
+    if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
+        err!("Send is not owned by user")
+    }
+
+    if send.atype == SendType::File as i32 {
+        std::fs::remove_dir_all(Path::new(&CONFIG.sends_folder()).join(&send.uuid)).ok();
+    }
+
+    send.delete(&conn)?;
+    nt.send_user_update(UpdateType::SyncSendDelete, &headers.user);
+
+    Ok(())
+}
+
+#[put("/sends/<id>/remove-password")]
+fn put_remove_password(id: String, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult {
+    let mut send = match Send::find_by_uuid(&id, &conn) {
+        Some(s) => s,
+        None => err!("Send not found"),
+    };
+
+    if send.user_uuid.as_ref() != Some(&headers.user.uuid) {
+        err!("Send is not owned by user")
+    }
+
+    send.set_password(None);
+    send.save(&conn)?;
+    nt.send_user_update(UpdateType::SyncSendUpdate, &headers.user);
+
+    Ok(Json(send.to_json()))
+}
diff --git a/src/api/notifications.rs b/src/api/notifications.rs
index 2e81b620..3a6423c1 100644
--- a/src/api/notifications.rs
+++ b/src/api/notifications.rs
@@ -394,6 +394,10 @@ pub enum UpdateType {
 
     LogOut = 11,
 
+    SyncSendCreate = 12,
+    SyncSendUpdate = 13,
+    SyncSendDelete = 14,
+
     None = 100,
 }
 
diff --git a/src/api/web.rs b/src/api/web.rs
index 7956c674..90e572a8 100644
--- a/src/api/web.rs
+++ b/src/api/web.rs
@@ -10,7 +10,7 @@ pub fn routes() -> Vec<Route> {
     // If addding more routes here, consider also adding them to
     // crate::utils::LOGGED_ROUTES to make sure they appear in the log
     if CONFIG.web_vault_enabled() {
-        routes![web_index, app_id, web_files, attachments, alive, static_files]
+        routes![web_index, app_id, web_files, attachments, sends, alive, static_files]
     } else {
         routes![attachments, alive, static_files]
     }
@@ -60,6 +60,11 @@ fn attachments(uuid: String, file: PathBuf) -> Option<NamedFile> {
     NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file)).ok()
 }
 
+#[get("/sends/<send_id>/<file_id>")]
+fn sends(send_id: String, file_id: String) -> Option<NamedFile> {
+    NamedFile::open(Path::new(&CONFIG.sends_folder()).join(send_id).join(file_id)).ok()
+}
+
 #[get("/alive")]
 fn alive() -> Json<String> {
     use crate::util::format_date;
diff --git a/src/config.rs b/src/config.rs
index 3f344f01..6c41c975 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -299,6 +299,8 @@ make_config! {
         icon_cache_folder:      String, false,  auto,   |c| format!("{}/{}", c.data_folder, "icon_cache");
         /// Attachments folder
         attachments_folder:     String, false,  auto,   |c| format!("{}/{}", c.data_folder, "attachments");
+        /// Sends folder
+        sends_folder:           String, false,  auto,   |c| format!("{}/{}", c.data_folder, "sends");
         /// Templates folder
         templates_folder:       String, false,  auto,   |c| format!("{}/{}", c.data_folder, "templates");
         /// Session JWT key
diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs
index 8c886fb4..a4fb635b 100644
--- a/src/db/models/mod.rs
+++ b/src/db/models/mod.rs
@@ -8,6 +8,7 @@ mod org_policy;
 mod organization;
 mod two_factor;
 mod user;
+mod send;
 
 pub use self::attachment::Attachment;
 pub use self::cipher::Cipher;
@@ -19,3 +20,4 @@ pub use self::org_policy::{OrgPolicy, OrgPolicyType};
 pub use self::organization::{Organization, UserOrgStatus, UserOrgType, UserOrganization};
 pub use self::two_factor::{TwoFactor, TwoFactorType};
 pub use self::user::{Invitation, User, UserStampException};
+pub use self::send::{Send, SendType};
\ No newline at end of file
diff --git a/src/db/models/send.rs b/src/db/models/send.rs
new file mode 100644
index 00000000..674337ed
--- /dev/null
+++ b/src/db/models/send.rs
@@ -0,0 +1,235 @@
+use chrono::{NaiveDateTime, Utc};
+use serde_json::Value;
+
+use super::{Organization, User};
+
+db_object! {
+    #[derive(Identifiable, Queryable, Insertable, Associations, AsChangeset)]
+    #[table_name = "sends"]
+    #[changeset_options(treat_none_as_null="true")]
+    #[belongs_to(User, foreign_key = "user_uuid")]
+    #[belongs_to(Organization, foreign_key = "organization_uuid")]
+    #[primary_key(uuid)]
+    pub struct Send {
+        pub uuid: String,
+
+        pub user_uuid: Option<String>,
+        pub organization_uuid: Option<String>,
+
+
+        pub name: String,
+        pub notes: Option<String>,
+
+        pub atype: i32,
+        pub data: String,
+        pub key: String,
+        pub password_hash: Option<Vec<u8>>,
+        password_salt: Option<Vec<u8>>,
+        password_iter: Option<i32>,
+
+        pub max_access_count: Option<i32>,
+        pub access_count: i32,
+
+        pub creation_date: NaiveDateTime,
+        pub revision_date: NaiveDateTime,
+        pub expiration_date: Option<NaiveDateTime>,
+        pub deletion_date: NaiveDateTime,
+
+        pub disabled: bool,
+    }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)]
+pub enum SendType {
+    Text = 0,
+    File = 1,
+}
+
+impl Send {
+    pub fn new(atype: i32, name: String, data: String, key: String, deletion_date: NaiveDateTime) -> Self {
+        let now = Utc::now().naive_utc();
+
+        Self {
+            uuid: crate::util::get_uuid(),
+            user_uuid: None,
+            organization_uuid: None,
+
+            name,
+            notes: None,
+
+            atype,
+            data,
+            key,
+            password_hash: None,
+            password_salt: None,
+            password_iter: None,
+
+            max_access_count: None,
+            access_count: 0,
+
+            creation_date: now,
+            revision_date: now,
+            expiration_date: None,
+            deletion_date,
+
+            disabled: false,
+        }
+    }
+    
+    pub fn set_password(&mut self, password: Option<&str>) {
+        const PASSWORD_ITER: i32 = 100_000;
+
+        if let Some(password) = password {
+            self.password_iter = Some(PASSWORD_ITER);
+            let salt = crate::crypto::get_random_64();
+            let hash = crate::crypto::hash_password(password.as_bytes(), &salt, PASSWORD_ITER as u32);
+            self.password_salt = Some(salt);
+            self.password_hash = Some(hash);
+        } else {
+            self.password_iter = None;
+            self.password_salt = None;
+            self.password_hash = None;
+        }
+    }
+
+    pub fn check_password(&self, password: &str) -> bool {
+        match (&self.password_hash, &self.password_salt, self.password_iter) {
+            (Some(hash), Some(salt), Some(iter)) => {
+                crate::crypto::verify_password_hash(password.as_bytes(), salt, hash, iter as u32)
+            }
+            _ => false,
+        }
+    }
+
+    pub fn to_json(&self) -> Value {
+        use crate::util::format_date;
+        use data_encoding::BASE64URL_NOPAD;
+        use uuid::Uuid;
+
+        let data: Value = serde_json::from_str(&self.data).unwrap_or_default();
+
+        json!({
+            "Id": self.uuid,
+            "AccessId": BASE64URL_NOPAD.encode(Uuid::parse_str(&self.uuid).unwrap_or_default().as_bytes()),
+            "Type": self.atype,
+
+            "Name": self.name,
+            "Notes": self.notes,
+            "Text": if self.atype == SendType::Text as i32 { Some(&data) } else { None },
+            "File": if self.atype == SendType::File as i32 { Some(&data) } else { None },
+
+            "Key": self.key,
+            "MaxAccessCount": self.max_access_count,
+            "AccessCount": self.access_count,
+            "Password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
+            "Disabled": self.disabled,
+
+            "RevisionDate": format_date(&self.revision_date),
+            "ExpirationDate": self.expiration_date.as_ref().map(format_date),
+            "DeletionDate": format_date(&self.deletion_date),
+            "Object": "send",
+        })
+    }
+}
+
+use crate::db::DbConn;
+
+use crate::api::EmptyResult;
+use crate::error::MapResult;
+
+impl Send {
+    pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
+        // self.update_users_revision(conn);
+        self.revision_date = Utc::now().naive_utc();
+
+        db_run! { conn:
+            sqlite, mysql {
+                match diesel::replace_into(sends::table)
+                    .values(SendDb::to_db(self))
+                    .execute(conn)
+                {
+                    Ok(_) => Ok(()),
+                    // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
+                    Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
+                        diesel::update(sends::table)
+                            .filter(sends::uuid.eq(&self.uuid))
+                            .set(SendDb::to_db(self))
+                            .execute(conn)
+                            .map_res("Error saving send")
+                    }
+                    Err(e) => Err(e.into()),
+                }.map_res("Error saving send")
+            }
+            postgresql {
+                let value = SendDb::to_db(self);
+                diesel::insert_into(sends::table)
+                    .values(&value)
+                    .on_conflict(sends::uuid)
+                    .do_update()
+                    .set(&value)
+                    .execute(conn)
+                    .map_res("Error saving send")
+            }
+        }
+    }
+
+    pub fn delete(&self, conn: &DbConn) -> EmptyResult {
+        // self.update_users_revision(conn);
+
+        db_run! { conn: {
+            diesel::delete(sends::table.filter(sends::uuid.eq(&self.uuid)))
+                .execute(conn)
+                .map_res("Error deleting send")
+        }}
+    }
+
+    pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> EmptyResult {
+        for send in Self::find_by_user(user_uuid, &conn) {
+            send.delete(&conn)?;
+        }
+        Ok(())
+    }
+
+    pub fn find_by_access_id(access_id: &str, conn: &DbConn) -> Option<Self> {
+        use data_encoding::BASE64URL_NOPAD;
+        use uuid::Uuid;
+
+        let uuid_vec = match BASE64URL_NOPAD.decode(access_id.as_bytes()) {
+            Ok(v) => v,
+            Err(_) => return None,
+        };
+
+        let uuid = match Uuid::from_slice(&uuid_vec) {
+            Ok(u) => u.to_string(),
+            Err(_) => return None,
+        };
+
+        Self::find_by_uuid(&uuid, conn)
+    }
+
+    pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
+        db_run! {conn: {
+            sends::table
+                .filter(sends::uuid.eq(uuid))
+                .first::<SendDb>(conn)
+                .ok()
+                .from_db()
+        }}
+    }
+
+    pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
+        db_run! {conn: {
+            sends::table
+                .filter(sends::user_uuid.eq(user_uuid))
+                .load::<SendDb>(conn).expect("Error loading sends").from_db()
+        }}
+    }
+
+    pub fn find_by_org(org_uuid: &str, conn: &DbConn) -> Vec<Self> {
+        db_run! {conn: {
+            sends::table
+                .filter(sends::organization_uuid.eq(org_uuid))
+                .load::<SendDb>(conn).expect("Error loading sends").from_db()
+        }}
+    }
+}
diff --git a/src/db/models/user.rs b/src/db/models/user.rs
index e1ddf456..fdd2dcae 100644
--- a/src/db/models/user.rs
+++ b/src/db/models/user.rs
@@ -177,7 +177,7 @@ impl User {
     }
 }
 
-use super::{Cipher, Device, Favorite, Folder, TwoFactor, UserOrgType, UserOrganization};
+use super::{Cipher, Device, Favorite, Folder, Send, TwoFactor, UserOrgType, UserOrganization};
 use crate::db::DbConn;
 
 use crate::api::EmptyResult;
@@ -263,6 +263,7 @@ impl User {
             }
         }
 
+        Send::delete_all_by_user(&self.uuid, conn)?;
         UserOrganization::delete_all_by_user(&self.uuid, conn)?;
         Cipher::delete_all_by_user(&self.uuid, conn)?;
         Favorite::delete_all_by_user(&self.uuid, conn)?;
diff --git a/src/db/schemas/mysql/schema.rs b/src/db/schemas/mysql/schema.rs
index d1a9962a..7f1f4805 100644
--- a/src/db/schemas/mysql/schema.rs
+++ b/src/db/schemas/mysql/schema.rs
@@ -102,6 +102,29 @@ table! {
     }
 }
 
+table! {
+    sends (uuid) {
+        uuid -> Text,
+        user_uuid -> Nullable<Text>,
+        organization_uuid -> Nullable<Text>,
+        name -> Text,
+        notes -> Nullable<Text>,
+        atype -> Integer,
+        data -> Text,
+        key -> Text,
+        password_hash -> Nullable<Binary>,
+        password_salt -> Nullable<Binary>,
+        password_iter -> Nullable<Integer>,
+        max_access_count -> Nullable<Integer>,
+        access_count -> Integer,
+        creation_date -> Datetime,
+        revision_date -> Datetime,
+        expiration_date -> Nullable<Datetime>,
+        deletion_date -> Datetime,
+        disabled -> Bool,
+    }
+}
+
 table! {
     twofactor (uuid) {
         uuid -> Text,
@@ -176,6 +199,8 @@ joinable!(folders -> users (user_uuid));
 joinable!(folders_ciphers -> ciphers (cipher_uuid));
 joinable!(folders_ciphers -> folders (folder_uuid));
 joinable!(org_policies -> organizations (org_uuid));
+joinable!(sends -> organizations (organization_uuid));
+joinable!(sends -> users (user_uuid));
 joinable!(twofactor -> users (user_uuid));
 joinable!(users_collections -> collections (collection_uuid));
 joinable!(users_collections -> users (user_uuid));
@@ -193,6 +218,7 @@ allow_tables_to_appear_in_same_query!(
     invitations,
     org_policies,
     organizations,
+    sends,
     twofactor,
     users,
     users_collections,
diff --git a/src/db/schemas/postgresql/schema.rs b/src/db/schemas/postgresql/schema.rs
index 5af0ece6..3eb94518 100644
--- a/src/db/schemas/postgresql/schema.rs
+++ b/src/db/schemas/postgresql/schema.rs
@@ -102,6 +102,29 @@ table! {
     }
 }
 
+table! {
+    sends (uuid) {
+        uuid -> Text,
+        user_uuid -> Nullable<Text>,
+        organization_uuid -> Nullable<Text>,
+        name -> Text,
+        notes -> Nullable<Text>,
+        atype -> Integer,
+        data -> Text,
+        key -> Text,
+        password_hash -> Nullable<Binary>,
+        password_salt -> Nullable<Binary>,
+        password_iter -> Nullable<Integer>,
+        max_access_count -> Nullable<Integer>,
+        access_count -> Integer,
+        creation_date -> Timestamp,
+        revision_date -> Timestamp,
+        expiration_date -> Nullable<Timestamp>,
+        deletion_date -> Timestamp,
+        disabled -> Bool,
+    }
+}
+
 table! {
     twofactor (uuid) {
         uuid -> Text,
@@ -176,6 +199,8 @@ joinable!(folders -> users (user_uuid));
 joinable!(folders_ciphers -> ciphers (cipher_uuid));
 joinable!(folders_ciphers -> folders (folder_uuid));
 joinable!(org_policies -> organizations (org_uuid));
+joinable!(sends -> organizations (organization_uuid));
+joinable!(sends -> users (user_uuid));
 joinable!(twofactor -> users (user_uuid));
 joinable!(users_collections -> collections (collection_uuid));
 joinable!(users_collections -> users (user_uuid));
@@ -193,6 +218,7 @@ allow_tables_to_appear_in_same_query!(
     invitations,
     org_policies,
     organizations,
+    sends,
     twofactor,
     users,
     users_collections,
diff --git a/src/db/schemas/sqlite/schema.rs b/src/db/schemas/sqlite/schema.rs
index 5af0ece6..3eb94518 100644
--- a/src/db/schemas/sqlite/schema.rs
+++ b/src/db/schemas/sqlite/schema.rs
@@ -102,6 +102,29 @@ table! {
     }
 }
 
+table! {
+    sends (uuid) {
+        uuid -> Text,
+        user_uuid -> Nullable<Text>,
+        organization_uuid -> Nullable<Text>,
+        name -> Text,
+        notes -> Nullable<Text>,
+        atype -> Integer,
+        data -> Text,
+        key -> Text,
+        password_hash -> Nullable<Binary>,
+        password_salt -> Nullable<Binary>,
+        password_iter -> Nullable<Integer>,
+        max_access_count -> Nullable<Integer>,
+        access_count -> Integer,
+        creation_date -> Timestamp,
+        revision_date -> Timestamp,
+        expiration_date -> Nullable<Timestamp>,
+        deletion_date -> Timestamp,
+        disabled -> Bool,
+    }
+}
+
 table! {
     twofactor (uuid) {
         uuid -> Text,
@@ -176,6 +199,8 @@ joinable!(folders -> users (user_uuid));
 joinable!(folders_ciphers -> ciphers (cipher_uuid));
 joinable!(folders_ciphers -> folders (folder_uuid));
 joinable!(org_policies -> organizations (org_uuid));
+joinable!(sends -> organizations (organization_uuid));
+joinable!(sends -> users (user_uuid));
 joinable!(twofactor -> users (user_uuid));
 joinable!(users_collections -> collections (collection_uuid));
 joinable!(users_collections -> users (user_uuid));
@@ -193,6 +218,7 @@ allow_tables_to_appear_in_same_query!(
     invitations,
     org_policies,
     organizations,
+    sends,
     twofactor,
     users,
     users_collections,
diff --git a/src/error.rs b/src/error.rs
index 80f9dafc..fa90f1d3 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -220,6 +220,18 @@ macro_rules! err {
     }};
 }
 
+#[macro_export]
+macro_rules! err_code {
+    ($msg:expr, $err_code: literal) => {{
+        error!("{}", $msg);
+        return Err(crate::error::Error::new($msg, $msg).with_code($err_code));
+    }};
+    ($usr_msg:expr, $log_value:expr, $err_code: literal) => {{
+        error!("{}. {}", $usr_msg, $log_value);
+        return Err(crate::error::Error::new($usr_msg, $log_value).with_code($err_code));
+    }};
+}
+
 #[macro_export]
 macro_rules! err_discard {
     ($msg:expr, $data:expr) => {{
-- 
GitLab