mirror of
https://github.com/chatmail/core.git
synced 2026-04-02 05:22:14 +03:00
Compare commits
12 Commits
v2.0.0
...
http-uploa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4d91fcb3 | ||
|
|
1350495574 | ||
|
|
401a0fd37f | ||
|
|
a860fb15e5 | ||
|
|
311fffcfa4 | ||
|
|
7d2105dbc9 | ||
|
|
060492afe8 | ||
|
|
b0330f5c0a | ||
|
|
4da6177219 | ||
|
|
fa159cde3d | ||
|
|
194970a164 | ||
|
|
1208de7c92 |
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -834,6 +834,7 @@ dependencies = [
|
||||
"native-tls",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"open",
|
||||
"percent-encoding",
|
||||
"pgp",
|
||||
"pretty_assertions",
|
||||
@@ -1978,6 +1979,15 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c283bf0114efea9e42f1a60edea9859e8c47528eae09d01df4b29c1e489cc48"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.29"
|
||||
|
||||
@@ -63,6 +63,7 @@ pretty_env_logger = { version = "0.4.0", optional = true }
|
||||
log = {version = "0.4.8", optional = true }
|
||||
rustyline = { version = "4.1.0", optional = true }
|
||||
ansi_term = { version = "0.12.1", optional = true }
|
||||
open = { version = "1.4.0", optional = true }
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -93,7 +94,7 @@ required-features = ["repl"]
|
||||
[features]
|
||||
default = []
|
||||
internals = []
|
||||
repl = ["internals", "rustyline", "log", "pretty_env_logger", "ansi_term"]
|
||||
repl = ["internals", "rustyline", "log", "pretty_env_logger", "ansi_term", "open"]
|
||||
vendored = ["async-native-tls/vendored", "async-smtp/native-tls-vendored"]
|
||||
nightly = ["pgp/nightly"]
|
||||
|
||||
|
||||
@@ -3163,6 +3163,54 @@ int dc_msg_is_setupmessage (const dc_msg_t* msg);
|
||||
char* dc_msg_get_setupcodebegin (const dc_msg_t* msg);
|
||||
|
||||
|
||||
/**
|
||||
* Check if the message is completely downloaded
|
||||
* or if some further action is needed.
|
||||
*
|
||||
* The function returns one of:
|
||||
* - @ref DC_DOWNLOAD_NO_URL - The message does not need any further download action
|
||||
* and should be rendered as usual.
|
||||
* - @ref DC_DOWNLOAD_AVAILABLE - There is additional content to download.
|
||||
* Tn addition to the usual message rendering,
|
||||
* the UI shall show a download button that starts dc_schedule_download()
|
||||
* - @ref DC_DOWNLOAD_IN_PROGRESS - Download was started with dc_schedule_download() and is still in progress.
|
||||
* On progress changes and if the download fails or succeeds,
|
||||
* the event @ref DC_EVENT_DOWNLOAD_PROGRESS will be emitted.
|
||||
* - @ref DC_DOWNLOAD_DONE - Download finished successfully
|
||||
* - @ref DC_DOWNLOAD_FAILURE - Download error, the user may start over calling dc_schedule_download() again.
|
||||
*
|
||||
* @memberof dc_msg_t
|
||||
* @param msg The message object.
|
||||
* @return One of the @ref DC_DOWNLOAD values
|
||||
*/
|
||||
int dc_msg_download_status(const dc_msg_t* msg);
|
||||
|
||||
|
||||
/**
|
||||
* Advices the core to start downloading a message.
|
||||
* This function is typically called when the user hits the "Download" button
|
||||
* that is shown by the UI in case dc_msg_download_status()
|
||||
* returns @ref DC_DOWNLOAD_AVAILABLE or @ref DC_DOWNLOAD_FAILURE.
|
||||
*
|
||||
* The UI may want to show a file selector and let the user chose a download location.
|
||||
* The file name in the file selector may be prefilled using dc_msg_get_filename().
|
||||
*
|
||||
* During the download, the progress, errors and success
|
||||
* are reported using @ref DC_EVENT_DOWNLOAD_PROGRESS.
|
||||
*
|
||||
* Once the @ref DC_EVENT_DOWNLOAD_PROGRESS reports success,
|
||||
* The file can be accessed as usual using dc_msg_get_file().
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @param path Path to the destination file.
|
||||
* You can specify NULL here to download
|
||||
* to a reasonable file name in the internal blob-directory.
|
||||
* @param msg_id Message-ID to download the content for.
|
||||
*/
|
||||
void dc_schedule_download(dc_context_t* context, int msg_id, const char* path);
|
||||
|
||||
|
||||
/**
|
||||
* Set the text of a message object.
|
||||
* This does not alter any information in the database; this may be done by dc_send_msg() later.
|
||||
@@ -4231,6 +4279,16 @@ void dc_event_unref(dc_event_t* event);
|
||||
*/
|
||||
#define DC_EVENT_SECUREJOIN_JOINER_PROGRESS 2061
|
||||
|
||||
|
||||
/**
|
||||
* Inform about the progress of a download started by dc_schedule_download().
|
||||
*
|
||||
* @param data1 (int) Message-ID the progress is reported for.
|
||||
* @param data2 (int) 0=error, 1-999=progress in permille, 1000=success and done
|
||||
*/
|
||||
#define DC_EVENT_DOWNLOAD_PROGRESS 2070
|
||||
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
@@ -4370,6 +4428,29 @@ void dc_event_unref(dc_event_t* event);
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* @defgroup DC_DOWNLOAD DC_DOWNLOAD
|
||||
*
|
||||
* These constants describe the download state of a message.
|
||||
* The download state can be retrieved using dc_msg_download_status()
|
||||
* and usually changes after calling dc_schedule_download().
|
||||
*
|
||||
* @addtogroup DC_DOWNLOAD
|
||||
* @{
|
||||
*/
|
||||
|
||||
#define DC_DOWNLOAD_NO_URL 10 ///< Download not needed, see dc_msg_download_status() for details.
|
||||
#define DC_DOWNLOAD_AVAILABLE 20 ///< Download available, see dc_msg_download_status() for details.
|
||||
#define DC_DOWNLOAD_IN_PROGRESS 30 ///< Download in progress, see dc_msg_download_status() for details.
|
||||
#define DC_DOWNLOAD_DONE 40 ///< Download done, see dc_msg_download_status() for details.
|
||||
#define DC_DOWNLOAD_FAILURE 50 ///< Download failed, see dc_msg_download_status() for details.
|
||||
|
||||
/**
|
||||
* @}
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* TODO: Strings need some doumentation about used placeholders.
|
||||
*
|
||||
|
||||
@@ -370,6 +370,8 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
===========================Message commands==\n\
|
||||
listmsgs <query>\n\
|
||||
msginfo <msg-id>\n\
|
||||
openfile <msg-id>\n\
|
||||
download <msg-id>\n\
|
||||
listfresh\n\
|
||||
forward <msg-id> <chat-id>\n\
|
||||
markseen <msg-id>\n\
|
||||
@@ -890,6 +892,25 @@ pub async fn cmdline(context: Context, line: &str, chat_id: &mut ChatId) -> Resu
|
||||
let res = message::get_msg_info(&context, id).await;
|
||||
println!("{}", res);
|
||||
}
|
||||
"openfile" => {
|
||||
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
|
||||
let id = MsgId::new(arg1.parse()?);
|
||||
let msg = Message::load_from_db(&context, id).await?;
|
||||
let filepath = msg.get_file(&context);
|
||||
ensure!(filepath.is_some(), "Message has no file.");
|
||||
let filepath = filepath.unwrap();
|
||||
open::that(filepath)?;
|
||||
}
|
||||
"download" => {
|
||||
ensure!(!arg1.is_empty(), "Argument <msg-id> missing.");
|
||||
let id = MsgId::new(arg1.parse()?);
|
||||
let path = if !arg2.is_empty() {
|
||||
Some(arg2.into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
message::schedule_download(&context, id, path).await?;
|
||||
}
|
||||
"listfresh" => {
|
||||
let msglist = context.get_fresh_msgs().await;
|
||||
|
||||
|
||||
@@ -186,9 +186,11 @@ const CHAT_COMMANDS: [&str; 26] = [
|
||||
"unpin",
|
||||
"delchat",
|
||||
];
|
||||
const MESSAGE_COMMANDS: [&str; 8] = [
|
||||
const MESSAGE_COMMANDS: [&str; 10] = [
|
||||
"listmsgs",
|
||||
"msginfo",
|
||||
"openfile",
|
||||
"download",
|
||||
"listfresh",
|
||||
"forward",
|
||||
"markseen",
|
||||
|
||||
@@ -34,6 +34,7 @@ pub enum HeaderDef {
|
||||
ChatContent,
|
||||
ChatDuration,
|
||||
ChatDispositionNotificationTo,
|
||||
ChatUploadUrl,
|
||||
Autocrypt,
|
||||
AutocryptSetupMessage,
|
||||
SecureJoin,
|
||||
|
||||
49
src/job.rs
49
src/job.rs
@@ -3,6 +3,7 @@
|
||||
//! This module implements a job queue maintained in the SQLite database
|
||||
//! and job types.
|
||||
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
|
||||
@@ -31,6 +32,7 @@ use crate::message::{self, Message, MessageState};
|
||||
use crate::mimefactory::MimeFactory;
|
||||
use crate::param::*;
|
||||
use crate::smtp::Smtp;
|
||||
use crate::upload::{download_message_file, generate_upload_url, upload_file};
|
||||
use crate::{scheduler::InterruptInfo, sql};
|
||||
|
||||
// results in ~3 weeks for the last backoff timespan
|
||||
@@ -107,6 +109,8 @@ pub enum Action {
|
||||
MaybeSendLocationsEnded = 5007,
|
||||
SendMdn = 5010,
|
||||
SendMsgToSmtp = 5901, // ... high priority
|
||||
|
||||
DownloadMessageFile = 7000,
|
||||
}
|
||||
|
||||
impl Default for Action {
|
||||
@@ -133,6 +137,9 @@ impl From<Action> for Thread {
|
||||
MaybeSendLocationsEnded => Thread::Smtp,
|
||||
SendMdn => Thread::Smtp,
|
||||
SendMsgToSmtp => Thread::Smtp,
|
||||
|
||||
// TODO: Where does downloading fit in the thread architecture?
|
||||
DownloadMessageFile => Thread::Imap,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,6 +337,14 @@ impl Job {
|
||||
}
|
||||
|
||||
pub(crate) async fn send_msg_to_smtp(&mut self, context: &Context, smtp: &mut Smtp) -> Status {
|
||||
// Upload file to HTTP if set in params.
|
||||
if let (Some(upload_url), Ok(Some(upload_path))) = (
|
||||
self.param.get_upload_url(),
|
||||
self.param.get_upload_path(context),
|
||||
) {
|
||||
job_try!(upload_file(context, upload_url.to_string(), upload_path).await);
|
||||
}
|
||||
|
||||
// SMTP server, if not yet done
|
||||
if !smtp.is_connected().await {
|
||||
let loginparam = LoginParam::from_database(context, "configured_").await;
|
||||
@@ -658,6 +673,13 @@ impl Job {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn download_message_file(&mut self, context: &Context) -> Status {
|
||||
let msg_id = MsgId::new(self.foreign_id);
|
||||
let download_path = job_try!(self.param.get_upload_path(context));
|
||||
job_try!(download_message_file(context, msg_id, download_path).await);
|
||||
Status::Finished(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all pending jobs with the given action.
|
||||
@@ -726,7 +748,23 @@ pub async fn send_msg_job(context: &Context, msg_id: MsgId) -> Result<Option<Job
|
||||
}
|
||||
};
|
||||
|
||||
let mimefactory = MimeFactory::from_msg(context, &msg, attach_selfavatar).await?;
|
||||
let mut mimefactory = MimeFactory::from_msg(context, &msg, attach_selfavatar).await?;
|
||||
|
||||
// Prepare file upload if DCC_UPLOAD_URL env variable is set.
|
||||
// See upload-server folder for an example server impl.
|
||||
// Here a new URL is generated, which the mimefactory includes in the message instead of the
|
||||
// actual attachement. The upload then happens in the smtp send job.
|
||||
let upload = if let Some(file) = msg.get_file(context) {
|
||||
if let Ok(endpoint) = env::var("DCC_UPLOAD_URL") {
|
||||
let upload_url = generate_upload_url(context, endpoint);
|
||||
mimefactory.set_upload_url(upload_url.clone());
|
||||
Some((upload_url, file))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut recipients = mimefactory.recipients();
|
||||
|
||||
@@ -818,6 +856,11 @@ pub async fn send_msg_job(context: &Context, msg_id: MsgId) -> Result<Option<Job
|
||||
param.set(Param::File, blob.as_name());
|
||||
param.set(Param::Recipients, &recipients);
|
||||
|
||||
if let Some((upload_url, upload_path)) = upload {
|
||||
param.set_upload_url(upload_url);
|
||||
param.set_upload_path(upload_path);
|
||||
}
|
||||
|
||||
let job = create(Action::SendMsgToSmtp, msg_id.to_u32() as i32, param, 0)?;
|
||||
|
||||
Ok(Some(job))
|
||||
@@ -978,6 +1021,7 @@ async fn perform_job_action(
|
||||
sql::housekeeping(context).await;
|
||||
Status::Finished(Ok(()))
|
||||
}
|
||||
Action::DownloadMessageFile => job.download_message_file(context).await,
|
||||
};
|
||||
|
||||
info!(
|
||||
@@ -1034,7 +1078,8 @@ pub async fn add(context: &Context, job: Job) {
|
||||
| Action::OldDeleteMsgOnImap
|
||||
| Action::DeleteMsgOnImap
|
||||
| Action::MarkseenMsgOnImap
|
||||
| Action::MoveMsg => {
|
||||
| Action::MoveMsg
|
||||
| Action::DownloadMessageFile => {
|
||||
info!(context, "interrupt: imap");
|
||||
context
|
||||
.interrupt_inbox(InterruptInfo::new(false, None))
|
||||
|
||||
@@ -67,6 +67,7 @@ mod simplify;
|
||||
mod smtp;
|
||||
pub mod stock;
|
||||
mod token;
|
||||
pub(crate) mod upload;
|
||||
#[macro_use]
|
||||
mod dehtml;
|
||||
|
||||
|
||||
@@ -1545,6 +1545,33 @@ pub async fn update_server_uid(
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule attachement download for a message.
|
||||
pub async fn schedule_download(
|
||||
context: &Context,
|
||||
msg_id: MsgId,
|
||||
path: Option<PathBuf>,
|
||||
) -> Result<(), Error> {
|
||||
let msg = Message::load_from_db(context, msg_id).await?;
|
||||
if let Some(_upload_url) = msg.param.get_upload_url() {
|
||||
// TODO: Check if message was already downloaded.
|
||||
let mut params = Params::new();
|
||||
if let Some(path) = path {
|
||||
params.set_upload_path(path);
|
||||
}
|
||||
job::add(
|
||||
context,
|
||||
job::Job::new(Action::DownloadMessageFile, msg_id.to_u32(), params, 0),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
warn!(
|
||||
context,
|
||||
"Tried to schedule download for message {} which has no uploads", msg_id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn dc_empty_server(context: &Context, flags: u32) {
|
||||
job::kill_action(context, Action::EmptyServer).await;
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct MimeFactory<'a, 'b> {
|
||||
context: &'a Context,
|
||||
last_added_location_id: u32,
|
||||
attach_selfavatar: bool,
|
||||
upload_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of rendering a message, ready to be submitted to a send job.
|
||||
@@ -159,6 +160,7 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
|
||||
last_added_location_id: 0,
|
||||
attach_selfavatar,
|
||||
context,
|
||||
upload_url: None,
|
||||
};
|
||||
Ok(factory)
|
||||
}
|
||||
@@ -206,6 +208,7 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
|
||||
req_mdn: false,
|
||||
last_added_location_id: 0,
|
||||
attach_selfavatar: false,
|
||||
upload_url: None,
|
||||
};
|
||||
|
||||
Ok(res)
|
||||
@@ -409,6 +412,10 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn set_upload_url(&mut self, upload_url: String) {
|
||||
self.upload_url = Some(upload_url)
|
||||
}
|
||||
|
||||
pub async fn render(mut self) -> Result<RenderedEmail, Error> {
|
||||
// Headers that are encrypted
|
||||
// - Chat-*, except Chat-Version
|
||||
@@ -879,11 +886,21 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
|
||||
}
|
||||
};
|
||||
|
||||
// if upload url is present: add as header and to message text
|
||||
// TODO: make text part translatable (or remove)
|
||||
let upload_url_text = if let Some(ref upload_url) = self.upload_url {
|
||||
protected_headers.push(Header::new("Chat-Upload-Url".into(), upload_url.clone()));
|
||||
Some(format!("\n\nFile attachement: {}", upload_url.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let footer = &self.selfstatus;
|
||||
let message_text = format!(
|
||||
"{}{}{}{}{}",
|
||||
"{}{}{}{}{}{}",
|
||||
fwdhint.unwrap_or_default(),
|
||||
escape_message_footer_marks(final_text),
|
||||
upload_url_text.unwrap_or_default(),
|
||||
if !final_text.is_empty() && !footer.is_empty() {
|
||||
"\r\n\r\n"
|
||||
} else {
|
||||
@@ -899,8 +916,8 @@ impl<'a, 'b> MimeFactory<'a, 'b> {
|
||||
.body(message_text);
|
||||
let mut parts = Vec::new();
|
||||
|
||||
// add attachment part
|
||||
if chat::msgtype_has_file(self.msg.viewtype) {
|
||||
// add attachment part, skip if upload url was provided
|
||||
if chat::msgtype_has_file(self.msg.viewtype) && self.upload_url.is_none() {
|
||||
if !is_file_size_okay(context, &self.msg).await {
|
||||
bail!(
|
||||
"Message exceeds the recommended {} MB.",
|
||||
|
||||
@@ -334,6 +334,13 @@ impl MimeMessage {
|
||||
}
|
||||
}
|
||||
|
||||
let upload_url = self.get(HeaderDef::ChatUploadUrl).map(|v| v.to_string());
|
||||
if let Some(upload_url) = upload_url {
|
||||
for part in self.parts.iter_mut() {
|
||||
part.param.set_upload_url(upload_url.clone());
|
||||
}
|
||||
}
|
||||
|
||||
self.parse_attachments();
|
||||
|
||||
// See if an MDN is requested from the other side
|
||||
|
||||
23
src/param.rs
23
src/param.rs
@@ -120,6 +120,12 @@ pub enum Param {
|
||||
|
||||
/// For MDN-sending job
|
||||
MsgId = b'I',
|
||||
|
||||
/// For messages that have a HTTP file upload instead of attachement
|
||||
UploadUrl = b'y',
|
||||
|
||||
/// For messages that have a HTTP file upload instead of attachement: Path to local file
|
||||
UploadPath = b'Y',
|
||||
}
|
||||
|
||||
/// Possible values for `Param::ForcePlaintext`.
|
||||
@@ -317,6 +323,23 @@ impl Params {
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
pub fn get_upload_url(&self) -> Option<&str> {
|
||||
self.get(Param::UploadUrl)
|
||||
}
|
||||
|
||||
pub fn get_upload_path(&self, context: &Context) -> Result<Option<PathBuf>, BlobError> {
|
||||
self.get_path(Param::UploadPath, context)
|
||||
}
|
||||
|
||||
pub fn set_upload_path(&mut self, path: PathBuf) {
|
||||
// TODO: Remove unwrap? May panic for invalid UTF8 in path.
|
||||
self.set(Param::UploadPath, path.to_str().unwrap());
|
||||
}
|
||||
|
||||
pub fn set_upload_url(&mut self, url: impl AsRef<str>) {
|
||||
self.set(Param::UploadUrl, url);
|
||||
}
|
||||
|
||||
pub fn get_msg_id(&self) -> Option<MsgId> {
|
||||
self.get(Param::MsgId)
|
||||
.and_then(|x| x.parse::<u32>().ok())
|
||||
|
||||
38
src/pgp.rs
38
src/pgp.rs
@@ -10,6 +10,7 @@ use pgp::composed::{
|
||||
SignedPublicSubKey, SignedSecretKey, SubkeyParamsBuilder,
|
||||
};
|
||||
use pgp::crypto::{HashAlgorithm, SymmetricKeyAlgorithm};
|
||||
use pgp::ser::Serialize;
|
||||
use pgp::types::{
|
||||
CompressionAlgorithm, KeyTrait, Mpi, PublicKeyTrait, SecretKeyTrait, StringToKey,
|
||||
};
|
||||
@@ -322,8 +323,22 @@ pub async fn pk_decrypt(
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
/// Symmetric encryption.
|
||||
/// Symmetric encryption with armored base64 text output.
|
||||
pub async fn symm_encrypt(passphrase: &str, plain: &[u8]) -> Result<String> {
|
||||
let message = symm_encrypt_to_message(passphrase, plain).await?;
|
||||
let encoded_msg = message.to_armored_string(None)?;
|
||||
Ok(encoded_msg)
|
||||
}
|
||||
|
||||
/// Symmetric encryption with binary output.
|
||||
pub async fn symm_encrypt_bytes(passphrase: &str, plain: &[u8]) -> Result<Vec<u8>> {
|
||||
let message = symm_encrypt_to_message(passphrase, plain).await?;
|
||||
let mut buf = Vec::new();
|
||||
message.to_writer(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
async fn symm_encrypt_to_message(passphrase: &str, plain: &[u8]) -> Result<Message> {
|
||||
let lit_msg = Message::new_literal_bytes("", plain);
|
||||
let passphrase = passphrase.to_string();
|
||||
|
||||
@@ -332,24 +347,33 @@ pub async fn symm_encrypt(passphrase: &str, plain: &[u8]) -> Result<String> {
|
||||
let s2k = StringToKey::new_default(&mut rng);
|
||||
let msg =
|
||||
lit_msg.encrypt_with_password(&mut rng, s2k, Default::default(), || passphrase)?;
|
||||
|
||||
let encoded_msg = msg.to_armored_string(None)?;
|
||||
|
||||
Ok(encoded_msg)
|
||||
Ok(msg)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Symmetric decryption.
|
||||
/// Symmetric decryption from armored text.
|
||||
pub async fn symm_decrypt<T: std::io::Read + std::io::Seek>(
|
||||
passphrase: &str,
|
||||
ctext: T,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (enc_msg, _) = Message::from_armor_single(ctext)?;
|
||||
symm_decrypt_from_message(enc_msg, passphrase).await
|
||||
}
|
||||
|
||||
/// Symmetric decryption from bytes.
|
||||
pub async fn symm_decrypt_bytes<T: std::io::Read + std::io::Seek>(
|
||||
passphrase: &str,
|
||||
cbytes: T,
|
||||
) -> Result<Vec<u8>> {
|
||||
let enc_msg = Message::from_bytes(cbytes)?;
|
||||
symm_decrypt_from_message(enc_msg, passphrase).await
|
||||
}
|
||||
|
||||
async fn symm_decrypt_from_message(message: Message, passphrase: &str) -> Result<Vec<u8>> {
|
||||
let passphrase = passphrase.to_string();
|
||||
async_std::task::spawn_blocking(move || {
|
||||
let decryptor = enc_msg.decrypt_with_password(|| passphrase)?;
|
||||
let decryptor = message.decrypt_with_password(|| passphrase)?;
|
||||
|
||||
let msgs = decryptor.collect::<pgp::errors::Result<Vec<_>>>()?;
|
||||
ensure!(!msgs.is_empty(), "No valid messages found");
|
||||
|
||||
142
src/upload.rs
Normal file
142
src/upload.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use crate::blob::BlobObject;
|
||||
// use crate::constants::Viewtype;
|
||||
use crate::context::Context;
|
||||
use crate::error::{bail, format_err, Result};
|
||||
use crate::message::{Message, MsgId};
|
||||
use crate::pgp::{symm_decrypt_bytes, symm_encrypt_bytes};
|
||||
use async_std::fs;
|
||||
use async_std::path::PathBuf;
|
||||
use rand::Rng;
|
||||
use std::io::Cursor;
|
||||
use url::Url;
|
||||
|
||||
/// Upload file to a HTTP upload endpoint.
|
||||
pub async fn upload_file(
|
||||
context: &Context,
|
||||
url: impl AsRef<str>,
|
||||
filepath: PathBuf,
|
||||
) -> Result<String> {
|
||||
let (passphrase, url) = parse_upload_url(url)?;
|
||||
|
||||
let content = fs::read(filepath).await?;
|
||||
let encrypted = symm_encrypt_bytes(&passphrase, &content).await?;
|
||||
|
||||
// TODO: Use tokens for upload.
|
||||
info!(context, "uploading encrypted file to {}", &url);
|
||||
let response = surf::put(url).body_bytes(encrypted).await;
|
||||
if let Err(err) = response {
|
||||
bail!("Upload failed: {}", err);
|
||||
}
|
||||
let mut response = response.unwrap();
|
||||
match response.body_string().await {
|
||||
Ok(string) => Ok(string),
|
||||
Err(err) => bail!("Invalid response from upload: {}", err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_message_file(
|
||||
context: &Context,
|
||||
msg_id: MsgId,
|
||||
download_path: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
let mut message = Message::load_from_db(context, msg_id).await?;
|
||||
let upload_url = message
|
||||
.param
|
||||
.get_upload_url()
|
||||
.ok_or_else(|| format_err!("Message has no upload URL"))?;
|
||||
|
||||
let (passphrase, url) = parse_upload_url(upload_url)?;
|
||||
|
||||
let filename: String = url
|
||||
.path_segments()
|
||||
.ok_or_else(|| format_err!("Invalid upload URL"))?
|
||||
.last()
|
||||
.ok_or_else(|| format_err!("Invalid upload URL"))?
|
||||
.to_string();
|
||||
|
||||
let data = download_file(context, url, passphrase).await?;
|
||||
let saved_path = if let Some(download_path) = download_path {
|
||||
fs::write(&download_path, data).await?;
|
||||
download_path.to_string_lossy().to_string()
|
||||
} else {
|
||||
let blob = BlobObject::create(context, filename.clone(), &data)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format_err!(
|
||||
"Could not add blob for file download {}, error {}",
|
||||
filename,
|
||||
err
|
||||
)
|
||||
})?;
|
||||
blob.as_name().to_string()
|
||||
};
|
||||
info!(context, "saved download to: {:?}", saved_path);
|
||||
|
||||
// TODO: Support getting the mime type.
|
||||
let filemime = None;
|
||||
|
||||
message.set_file(saved_path, filemime);
|
||||
message.save_param_to_disk(context).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download and decrypt a file from a HTTP endpoint.
|
||||
pub async fn download_file(
|
||||
context: &Context,
|
||||
url: impl AsRef<str>,
|
||||
passphrase: String,
|
||||
) -> Result<Vec<u8>> {
|
||||
info!(context, "downloading file from {}", &url.as_ref());
|
||||
let response = surf::get(url).recv_bytes().await;
|
||||
if let Err(err) = response {
|
||||
bail!("Download failed: {}", err);
|
||||
}
|
||||
let bytes = response.unwrap();
|
||||
info!(context, "download complete, len: {}", bytes.len());
|
||||
let reader = Cursor::new(bytes);
|
||||
let decrypted = symm_decrypt_bytes(&passphrase, reader).await?;
|
||||
Ok(decrypted)
|
||||
}
|
||||
|
||||
/// Parse a URL from a string and take out the hash fragment.
|
||||
fn parse_upload_url(url: impl AsRef<str>) -> Result<(String, Url)> {
|
||||
let mut url = url::Url::parse(url.as_ref())?;
|
||||
let passphrase = url.fragment();
|
||||
if passphrase.is_none() {
|
||||
bail!("Missing passphrase for upload URL");
|
||||
}
|
||||
let passphrase = passphrase.unwrap().to_string();
|
||||
url.set_fragment(None);
|
||||
Ok((passphrase, url))
|
||||
}
|
||||
|
||||
/// Generate a random URL based on the provided endpoint.
|
||||
pub fn generate_upload_url(_context: &Context, mut endpoint: String) -> String {
|
||||
// equals at least 16 random bytes (base32 takes 160% of binary size).
|
||||
const FILENAME_LEN: usize = 26;
|
||||
// equals at least 32 random bytes.
|
||||
const PASSPHRASE_LEN: usize = 52;
|
||||
|
||||
if endpoint.ends_with('/') {
|
||||
endpoint.pop();
|
||||
}
|
||||
let passphrase = generate_token_string(PASSPHRASE_LEN);
|
||||
let filename = generate_token_string(FILENAME_LEN);
|
||||
format!("{}/{}#{}", endpoint, filename, passphrase)
|
||||
}
|
||||
|
||||
/// Generate a random string encoded in base32.
|
||||
/// Len is the desired string length of the result.
|
||||
/// TODO: There's likely better methods to create random tokens.
|
||||
pub fn generate_token_string(len: usize) -> String {
|
||||
const CROCKFORD_ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
|
||||
let mut rng = rand::thread_rng();
|
||||
let token: String = (0..len)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0, CROCKFORD_ALPHABET.len());
|
||||
CROCKFORD_ALPHABET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
token
|
||||
}
|
||||
4
upload-server/.gitignore
vendored
Normal file
4
upload-server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
uploads
|
||||
node_modules
|
||||
yarn*
|
||||
.gitfoo
|
||||
17
upload-server/README.md
Normal file
17
upload-server/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# deltachat-upload-server
|
||||
|
||||
Demo server for the HTTP file upload feature.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
npm install
|
||||
node server.js
|
||||
```
|
||||
|
||||
Configure with environment variables:
|
||||
* `UPLOAD_PATH`: Path to upload files to (default: `./uploads`)
|
||||
* `PORT`: Port to listen on (default: `8080`)
|
||||
* `HOSTNAME`: Hostname to listen on (default: `0.0.0.0`)
|
||||
* `BASEURL`: Base URL for generated links (default: `http://[hostname]:[port]/`)
|
||||
|
||||
13
upload-server/package.json
Normal file
13
upload-server/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "deltachat-upload-server",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"base32": "^0.0.6",
|
||||
"express": "^4.17.1"
|
||||
}
|
||||
}
|
||||
73
upload-server/server.js
Normal file
73
upload-server/server.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const p = require('path')
|
||||
const express = require('express')
|
||||
const fs = require('fs')
|
||||
const { pipeline } = require('stream')
|
||||
|
||||
const app = express()
|
||||
|
||||
const config = {
|
||||
path: process.env.UPLOAD_PATH || p.resolve('./uploads'),
|
||||
port: process.env.PORT || 8080,
|
||||
hostname: process.env.HOSTNAME || '0.0.0.0',
|
||||
baseurl: process.env.BASE_URL
|
||||
}
|
||||
|
||||
if (!config.baseurl) config.baseurl = `http://${config.hostname}:${config.port}/`
|
||||
if (!config.baseurl.endsWith('/')) config.baseurl = config.baseurl + '/'
|
||||
|
||||
if (!fs.existsSync(config.path)) {
|
||||
fs.mkdirSync(config.path, { recursive: true })
|
||||
}
|
||||
|
||||
app.use('/:filename', checkFilenameMiddleware)
|
||||
app.put('/:filename', (req, res) => {
|
||||
const uploadpath = req.uploadpath
|
||||
const filename = req.params.filename
|
||||
fs.stat(uploadpath, (err, stat) => {
|
||||
if (err && err.code !== 'ENOENT') {
|
||||
console.error('error', err.message)
|
||||
return res.code(500).send('internal server error')
|
||||
}
|
||||
if (stat) return res.status(500).send('filename in use')
|
||||
|
||||
const ws = fs.createWriteStream(uploadpath)
|
||||
pipeline(req, ws, err => {
|
||||
if (err) {
|
||||
console.error('error', err.message)
|
||||
return res.status(500).send('internal server error')
|
||||
}
|
||||
console.log('file uploaded: ' + uploadpath)
|
||||
const url = config.baseurl + filename
|
||||
res.end(url)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:filename', (req, res) => {
|
||||
const uploadpath = req.uploadpath
|
||||
const rs = fs.createReadStream(uploadpath)
|
||||
res.setHeader('content-type', 'application/octet-stream')
|
||||
pipeline(rs, res, err => {
|
||||
if (err) console.error('error', err.message)
|
||||
if (err) return res.status(500).send(err.message)
|
||||
})
|
||||
})
|
||||
|
||||
function checkFilenameMiddleware (req, res, next) {
|
||||
const filename = req.params.filename
|
||||
if (!filename) return res.status(500).send('missing filename')
|
||||
if (!filename.match(/^[a-zA-Z0-9]{26,32}$/)) {
|
||||
return res.status(500).send('illegal filename')
|
||||
}
|
||||
const uploadpath = p.normalize(p.join(config.path, req.params.filename))
|
||||
if (!uploadpath.startsWith(config.path)) {
|
||||
return res.code(500).send('bad request')
|
||||
}
|
||||
req.uploadpath = uploadpath
|
||||
next()
|
||||
}
|
||||
|
||||
app.listen(config.port, err => {
|
||||
if (err) console.error(err)
|
||||
else console.log(`Listening on ${config.baseurl}`)
|
||||
})
|
||||
Reference in New Issue
Block a user