Compare commits

...

12 Commits

Author SHA1 Message Date
Franz Heinzmann (Frando)
be4d91fcb3 Download & decrypt uploaded attachements.
- Parse the "Chat-Upload-Url" header
- Add message::schedule_download function to schedule download job
- In the job download, decrypt, save
2020-06-15 15:14:35 +02:00
Franz Heinzmann (Frando)
1350495574 repl: add openfile command 2020-06-13 22:49:18 +02:00
Franz Heinzmann (Frando)
401a0fd37f Encrypt HTTP uploads with PGP (symmetrically)
The symmetric key (passphrase) is put into the fragment part of the
upload URL. The upload URL is part of the message as a protected header
and in the body payload. If the message is encrypted itself, the
symmetric key is encrypted with the message.
2020-06-13 17:25:09 +02:00
Franz Heinzmann (Frando)
a860fb15e5 Merge remote-tracking branch 'origin/draft-dl-ffi' into http-upload 2020-06-13 15:13:11 +02:00
Franz Heinzmann (Frando)
311fffcfa4 Clippy 2020-06-13 15:10:45 +02:00
Franz Heinzmann (Frando)
7d2105dbc9 Move file upload into SMTP send job.
- This adds params for the upload URL and local file path, so that the
actual upload can happen in the send job.
- This also moves the URL generation to the client side so that we can
  generate a valid URL before the upload (because the MIME rendering of
  the mail message happens earlier and we want to include the URL there)
2020-06-13 15:10:44 +02:00
Franz Heinzmann (Frando)
060492afe8 Add demo server for http upload feature 2020-06-13 15:09:57 +02:00
Franz Heinzmann (Frando)
b0330f5c0a Initial draft for HTTP file upload. 2020-06-13 15:09:57 +02:00
B. Petersen
4da6177219 use DC_DOWNLOAD_NO_URL 2020-06-11 17:10:26 +02:00
B. Petersen
fa159cde3d draft, 2nd round 2020-06-11 01:44:03 +02:00
B. Petersen
194970a164 wording 2020-06-11 00:55:13 +02:00
B. Petersen
1208de7c92 draft a possible download-api 2020-06-11 00:55:13 +02:00
18 changed files with 523 additions and 14 deletions

10
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"]

View File

@@ -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.
*

View File

@@ -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;

View File

@@ -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",

View File

@@ -34,6 +34,7 @@ pub enum HeaderDef {
ChatContent,
ChatDuration,
ChatDispositionNotificationTo,
ChatUploadUrl,
Autocrypt,
AutocryptSetupMessage,
SecureJoin,

View File

@@ -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))

View File

@@ -67,6 +67,7 @@ mod simplify;
mod smtp;
pub mod stock;
mod token;
pub(crate) mod upload;
#[macro_use]
mod dehtml;

View File

@@ -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;

View File

@@ -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.",

View File

@@ -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

View File

@@ -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())

View File

@@ -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
View 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
View File

@@ -0,0 +1,4 @@
uploads
node_modules
yarn*
.gitfoo

17
upload-server/README.md Normal file
View 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]/`)

View 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
View 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}`)
})