Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion api-description.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ tags:
description: "Upload files"
- name: "File download"
description: "Download files"
- name: "Usage"
description: "Upload usage quotas"
paths:
/health:
get:
Expand Down Expand Up @@ -144,6 +146,12 @@ paths:
type: "string"
"404":
description: "Partially uploaded file does not exist."
"413":
description: "The upload exceeds the per-upload size limit (5 GiB)."
content:
application/json:
schema:
$ref: "#/components/schemas/PayloadTooLarge"

/fileupload/finalize/{uuid}:
post:
Expand Down Expand Up @@ -188,9 +196,74 @@ paths:
type: "string"
"404":
description: "Partially upload file does not exist."
"413":
description: "The sender has exceeded the rolling 14-day upload limit (15 GiB)."
content:
application/json:
schema:
$ref: "#/components/schemas/PayloadTooLarge"
"422":
description: "Data is missing to form complete file."


/usage:
get:
tags:
- "Usage"
summary: "Get rolling upload usage for a sender email"
description:
"Returns the bytes uploaded by the given email address in the last 14 days,
together with the applicable limits. Frontends use this to warn the user
before they hit the limit."
operationId: "getUsage"
parameters:
- in: "query"
name: "email"
description: "The sender email address to query."
required: true
schema:
type: "string"
format: "email"
responses:
"200":
description: "Usage information for the given email."
content:
application/json:
schema:
type: "object"
required:
- email
- used_bytes
- limit_bytes
- window_days
- per_upload_limit_bytes
properties:
email:
type: "string"
format: "email"
used_bytes:
type: "integer"
format: "int64"
description: "Bytes uploaded by this email in the rolling window."
limit_bytes:
type: "integer"
format: "int64"
description: "Rolling-window upload limit in bytes (15 GiB)."
window_days:
type: "integer"
description: "Length of the rolling window in days."
per_upload_limit_bytes:
type: "integer"
format: "int64"
description: "Maximum size of a single upload in bytes (5 GiB)."
resets_at:
type: "string"
format: "date-time"
nullable: true
description:
"RFC-3339 timestamp at which the oldest recorded upload
falls out of the rolling window, partially freeing quota.
Null if the sender has no recorded uploads."

/filedownload/{uuid}:
get:
tags:
Expand Down Expand Up @@ -224,3 +297,34 @@ paths:
type: "string"
"404":
description: "Uploaded file does not exist."

components:
schemas:
PayloadTooLarge:
type: "object"
required:
- error
- limit
- used_bytes
- limit_bytes
properties:
error:
type: "string"
description: "Human-readable explanation of which limit was hit."
limit:
type: "string"
enum:
- "per_upload"
- "rolling_window"
description: "Which limit tripped the 413 response."
used_bytes:
type: "integer"
format: "int64"
description:
"Bytes already attributed to the sender in the relevant window.
For per_upload, this is the bytes already written for the current
upload before the rejected chunk."
limit_bytes:
type: "integer"
format: "int64"
description: "The limit value in bytes."
31 changes: 26 additions & 5 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
#![allow(clippy::enum_variant_names)]

use rocket::http::ContentType;
use rocket::response::{self, Responder};
use rocket::serde::json::Json;
use serde::Serialize;

#[derive(Debug, Serialize)]
pub struct PayloadTooLargeBody {
pub error: String,
pub limit: &'static str,
pub used_bytes: u64,
pub limit_bytes: u64,
}

#[derive(Debug)]
pub enum Error {
BadRequest(Option<String>),
UnprocessableEntity(Option<String>),
InternalServerError(Option<String>),
PayloadTooLarge(PayloadTooLargeBody),
}

impl<'r, 'o: 'r> rocket::response::Responder<'r, 'o> for Error {
fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'o> {
impl<'r, 'o: 'r> Responder<'r, 'o> for Error {
fn respond_to(self, request: &'r rocket::Request<'_>) -> response::Result<'o> {
match self {
Error::BadRequest(e) => rocket::response::status::BadRequest(e).respond_to(request),
Error::BadRequest(e) => response::status::BadRequest(e).respond_to(request),
// response::status::Custom apparently doesn't support Option<R>
Error::UnprocessableEntity(e) => rocket::response::status::Custom::<String>(
Error::UnprocessableEntity(e) => response::status::Custom::<String>(
rocket::http::Status::UnprocessableEntity,
e.unwrap_or_else(|| "".to_owned()),
)
.respond_to(request),
Error::InternalServerError(e) => rocket::response::status::Custom::<String>(
Error::InternalServerError(e) => response::status::Custom::<String>(
rocket::http::Status::InternalServerError,
e.unwrap_or_else(|| "".to_owned()),
)
.respond_to(request),
Error::PayloadTooLarge(body) => response::Response::build_from(
Json(body).respond_to(request)?,
)
.status(rocket::http::Status::PayloadTooLarge)
.header(ContentType::JSON)
.ok(),
}
}
}
72 changes: 69 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ mod store;

use crate::config::CryptifyConfig;
use crate::email::send_email;
use crate::error::Error;
use crate::error::{Error, PayloadTooLargeBody};
use crate::store::{PER_UPLOAD_LIMIT, ROLLING_LIMIT, ROLLING_WINDOW_SECS};

use std::path::Path;
use std::str::FromStr;
Expand Down Expand Up @@ -286,6 +287,18 @@ async fn upload_chunk(
)));
}

if end > PER_UPLOAD_LIMIT {
return Err(Error::PayloadTooLarge(PayloadTooLargeBody {
error: format!(
"Upload exceeds the per-upload limit of {} bytes",
PER_UPLOAD_LIMIT
),
limit: "per_upload",
used_bytes: state.uploaded,
limit_bytes: PER_UPLOAD_LIMIT,
}));
}

if headers.cryptify_token != state.cryptify_token {
return Err(Error::BadRequest(Some(
"Cryptify Token header does not match".to_owned(),
Expand Down Expand Up @@ -408,17 +421,70 @@ async fn upload_finalize(
})
.collect();

state.sender = sender;
let now_secs = chrono::offset::Utc::now().timestamp();
if let Some(sender_email) = sender.as_deref() {
let usage = store.get_usage(sender_email, now_secs);
if usage.used_bytes.saturating_add(state.uploaded) > ROLLING_LIMIT {
drop(state);
store.remove(uuid);
let _ = rocket::tokio::fs::remove_file(Path::new(config.data_dir()).join(uuid)).await;
return Err(Error::PayloadTooLarge(PayloadTooLargeBody {
error: format!(
"Sender has exceeded the {}-day rolling limit of {} bytes",
ROLLING_WINDOW_SECS / 86_400,
ROLLING_LIMIT
),
limit: "rolling_window",
used_bytes: usage.used_bytes,
limit_bytes: ROLLING_LIMIT,
}));
}
}

state.sender = sender.clone();
state.sender_attributes = sender_attributes;

send_email(config, &state, uuid).await.map_err(|e| {
log::error!("{}", e);
Error::InternalServerError(Some("could not send email".to_owned()))
})?;

if let Some(sender_email) = sender {
store.record_upload(sender_email, state.uploaded, now_secs);
}

Ok(Some(()))
}

#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
struct UsageResponse {
email: String,
used_bytes: u64,
limit_bytes: u64,
window_days: u64,
per_upload_limit_bytes: u64,
resets_at: Option<String>,
}

#[get("/usage?<email>")]
fn usage(store: &State<Store>, email: String) -> Json<UsageResponse> {
let now = chrono::offset::Utc::now().timestamp();
let usage = store.get_usage(&email, now);
let resets_at = usage
.oldest_expires_at
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0))
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
Json(UsageResponse {
email,
used_bytes: usage.used_bytes,
limit_bytes: ROLLING_LIMIT,
window_days: (ROLLING_WINDOW_SECS / 86_400) as u64,
per_upload_limit_bytes: PER_UPLOAD_LIMIT,
resets_at,
})
}

#[launch]
async fn rocket() -> _ {
let rocket = rocket::build();
Expand Down Expand Up @@ -452,7 +518,7 @@ async fn rocket() -> _ {

rocket
.attach(cors)
.mount("/", routes![health,upload_init, upload_chunk, upload_finalize,])
.mount("/", routes![health, upload_init, upload_chunk, upload_finalize, usage])
.mount("/filedownload", FileServer::from(config.data_dir()))
.attach(AdHoc::config::<CryptifyConfig>())
.manage(Store::new())
Expand Down
Loading