Compare commits

...

2 commits

Author SHA1 Message Date
xenofem d80180956f factor out file size display code 2023-04-09 23:05:46 -04:00
xenofem 920b28f5f5 add beginnings of admin panel 2023-04-09 23:05:46 -04:00
9 changed files with 312 additions and 10 deletions

151
Cargo.lock generated
View file

@ -166,6 +166,23 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "actix-session"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d324d2a6e670f8746ae64f333c2c0fe856bdf624bcb72fb56e250e62a7e9a85f"
dependencies = [
"actix-service",
"actix-utils",
"actix-web",
"anyhow",
"async-trait",
"derive_more",
"serde",
"serde_json",
"tracing",
]
[[package]] [[package]]
name = "actix-utils" name = "actix-utils"
version = "3.0.0" version = "3.0.0"
@ -262,6 +279,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aead"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "aes" name = "aes"
version = "0.7.5" version = "0.7.5"
@ -274,6 +300,20 @@ dependencies = [
"opaque-debug", "opaque-debug",
] ]
[[package]]
name = "aes-gcm"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.6" version = "0.7.6"
@ -309,6 +349,23 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "anyhow"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1485d4d2cc45e7b201ee3767015c96faa5904387c9d87c6efdd0fb511f12d305"
[[package]]
name = "argon2"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db4ce4441f99dbd377ca8a8f57b698c44d0d6e712d8329b5040da5a64aa1ce73"
dependencies = [
"base64ct",
"blake2",
"password-hash 0.4.2",
]
[[package]] [[package]]
name = "askama" name = "askama"
version = "0.11.1" version = "0.11.1"
@ -368,6 +425,17 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "async-trait"
version = "0.1.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed6aa3524a2dfcf9fe180c51eae2b58738348d819517ceadf95789c51fff7600"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "atty" name = "atty"
version = "0.2.14" version = "0.2.14"
@ -403,6 +471,15 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "blake2"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.2" version = "0.10.2"
@ -543,7 +620,14 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05" checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05"
dependencies = [ dependencies = [
"aes-gcm",
"base64",
"hkdf",
"hmac",
"percent-encoding", "percent-encoding",
"rand",
"sha2",
"subtle",
"time", "time",
"version_check", "version_check",
] ]
@ -596,6 +680,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.14.1" version = "0.14.1"
@ -768,6 +861,16 @@ dependencies = [
"wasi 0.10.2+wasi-snapshot-preview1", "wasi 0.10.2+wasi-snapshot-preview1",
] ]
[[package]]
name = "ghash"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.13" version = "0.3.13"
@ -808,6 +911,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437"
dependencies = [
"hmac",
]
[[package]] [[package]]
name = "hmac" name = "hmac"
version = "0.12.1" version = "0.12.1"
@ -1178,6 +1290,17 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "password-hash"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.7" version = "1.0.7"
@ -1192,7 +1315,7 @@ checksum = "271779f35b581956db91a3e55737327a03aa051e90b1c47aeb189508533adfd7"
dependencies = [ dependencies = [
"digest", "digest",
"hmac", "hmac",
"password-hash", "password-hash 0.3.2",
"sha2", "sha2",
] ]
@ -1220,6 +1343,18 @@ version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]]
name = "polyval"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.16" version = "0.2.16"
@ -1648,10 +1783,13 @@ dependencies = [
"actix", "actix",
"actix-files", "actix-files",
"actix-http", "actix-http",
"actix-session",
"actix-web", "actix-web",
"actix-web-actors", "actix-web-actors",
"argon2",
"askama", "askama",
"askama_actix", "askama_actix",
"base64",
"bytes", "bytes",
"bytesize", "bytesize",
"crc32fast", "crc32fast",
@ -1663,6 +1801,7 @@ dependencies = [
"log", "log",
"mime", "mime",
"mnemonic", "mnemonic",
"password-hash 0.4.2",
"pin-project-lite", "pin-project-lite",
"rand", "rand",
"sanitise-file-name", "sanitise-file-name",
@ -1712,6 +1851,16 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "universal-hash"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05"
dependencies = [
"generic-array",
"subtle",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.2.2" version = "2.2.2"

View file

@ -9,10 +9,13 @@ license = "MIT"
actix = "0.13" actix = "0.13"
actix-files = "0.6.0" actix-files = "0.6.0"
actix-http = "3.0.4" actix-http = "3.0.4"
actix-session = { version = "0.7.1", features = ["cookie-session"] }
actix-web = "4.0.1" actix-web = "4.0.1"
actix-web-actors = "4.1.0" actix-web-actors = "4.1.0"
argon2 = "0.4.1"
askama = "0.11.1" askama = "0.11.1"
askama_actix = "0.13" askama_actix = "0.13"
base64 = "0.13"
bytes = "1.1.0" bytes = "1.1.0"
bytesize = "1.1.0" bytesize = "1.1.0"
crc32fast = "1.3.2" crc32fast = "1.3.2"
@ -24,6 +27,7 @@ jsondb = "0.4.0"
log = "0.4" log = "0.4"
mime = "0.3.16" mime = "0.3.16"
mnemonic = "1.0.1" mnemonic = "1.0.1"
password-hash = { version = "0.4.2", features = ["alloc"] }
pin-project-lite = "0.2.9" pin-project-lite = "0.2.9"
rand = "0.8.5" rand = "0.8.5"
sanitise-file-name = "1.0.0" sanitise-file-name = "1.0.0"

View file

@ -24,6 +24,16 @@
## configuration ## configuration
transbeam is configured with the following environment variables: transbeam is configured with the following environment variables:
- `TRANSBEAM_ADMIN_PASSWORD_HASH`: Argon2 hash of a password for
accessing the admin interface. To generate:
```bash
echo -n 'Password: '; head -1 | tr -d '\n' | argon2 $(openssl rand -base64 32) -id -t 2 -m 14 -p 1 -e
```
- `TRANSBEAM_COOKIE_SECRET`: Base64-encoded cryptographic random data
for encrypting private session cookies. To generate:
```bash
openssl rand -base64 64 | tr -d '\n'
```
- `TRANSBEAM_STORAGE_DIR`: path where uploaded files should be stored - `TRANSBEAM_STORAGE_DIR`: path where uploaded files should be stored
(default: `./storage`) (default: `./storage`)
- `TRANSBEAM_STATIC_DIR`: path where the web app's static files live - `TRANSBEAM_STATIC_DIR`: path where the web app's static files live

View file

@ -4,20 +4,23 @@ mod store;
mod upload; mod upload;
mod zip; mod zip;
use std::{fmt::Debug, path::PathBuf, str::FromStr}; use std::{fmt::Debug, path::PathBuf, str::FromStr, ops::Deref};
use actix_http::StatusCode; use actix_http::StatusCode;
use actix_session::{SessionMiddleware, storage::CookieSessionStore, Session};
use actix_web::{ use actix_web::{
error::InternalError, get, middleware::Logger, post, web, App, HttpRequest, HttpResponse, error::InternalError, get, middleware::Logger, post, web, App, HttpRequest, HttpResponse,
HttpServer, Responder, HttpServer, Responder, cookie,
}; };
use actix_web_actors::ws; use actix_web_actors::ws;
use argon2::{Argon2, PasswordVerifier};
use askama_actix::{Template, TemplateToResponse}; use askama_actix::{Template, TemplateToResponse};
use bytesize::ByteSize; use bytesize::ByteSize;
use log::{error, warn}; use log::{error, warn};
use password_hash::PasswordHashString;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use state::StateDb; use state::{StateDb, prelude::SizedFile};
use store::StoredFile; use store::{StoredFile, StoredFiles};
use tokio::fs::File; use tokio::fs::File;
const APP_NAME: &str = "transbeam"; const APP_NAME: &str = "transbeam";
@ -40,6 +43,7 @@ struct Config {
reverse_proxy: bool, reverse_proxy: bool,
mnemonic_codes: bool, mnemonic_codes: bool,
cachebuster: String, cachebuster: String,
admin_password_hash: PasswordHashString,
} }
pub fn get_ip_addr(req: &HttpRequest, reverse_proxy: bool) -> String { pub fn get_ip_addr(req: &HttpRequest, reverse_proxy: bool) -> String {
@ -72,6 +76,67 @@ async fn index(data: web::Data<AppData>) -> impl Responder {
} }
} }
#[derive(Template)]
#[template(path = "admin/signed_out.html")]
struct SignedOutAdminPage {
cachebuster: String,
base_url: String,
incorrect_password: bool,
}
#[derive(Template)]
#[template(path = "admin/signed_in.html")]
struct AdminPage<'a> {
cachebuster: String,
base_url: String,
stored_files: &'a StoredFiles,
}
#[get("/admin")]
async fn admin_panel(data: web::Data<AppData>, session: Session) -> actix_web::Result<HttpResponse> {
if let Some(true) = session.get::<bool>("admin")? {
Ok(AdminPage {
cachebuster: data.config.cachebuster.clone(),
base_url: data.config.base_url.clone(),
stored_files: data.state.read().await.deref(),
}.to_response())
} else {
Ok(SignedOutAdminPage {
cachebuster: data.config.cachebuster.clone(),
base_url: data.config.base_url.clone(),
incorrect_password: false,
}.to_response())
}
}
#[derive(Deserialize)]
struct AdminPanelSignin {
password: String,
}
#[post("/admin")]
async fn admin_signin(req: HttpRequest, data: web::Data<AppData>, form: web::Form<AdminPanelSignin>, session: Session) -> actix_web::Result<HttpResponse> {
if Argon2::default().verify_password(form.password.as_bytes(), &data.config.admin_password_hash.password_hash()).is_ok() {
session.insert("admin", true)?;
Ok(AdminPage {
cachebuster: data.config.cachebuster.clone(),
base_url: data.config.base_url.clone(),
stored_files: data.state.read().await.deref(),
}.to_response())
} else {
let ip_addr = get_ip_addr(&req, data.config.reverse_proxy);
log_auth_failure(&ip_addr);
let mut resp = SignedOutAdminPage {
cachebuster: data.config.cachebuster.clone(),
base_url: data.config.base_url.clone(),
incorrect_password: true,
}.to_response();
*resp.status_mut() = StatusCode::FORBIDDEN;
Err(InternalError::from_response("Incorrect password", resp).into())
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct DownloadRequest { struct DownloadRequest {
code: String, code: String,
@ -307,6 +372,15 @@ async fn main() -> std::io::Result<()> {
env_or::<ByteSize>("TRANSBEAM_MAX_STORAGE_SIZE", ByteSize(64 * bytesize::GB)).as_u64(); env_or::<ByteSize>("TRANSBEAM_MAX_STORAGE_SIZE", ByteSize(64 * bytesize::GB)).as_u64();
let upload_password: String = env_or_panic("TRANSBEAM_UPLOAD_PASSWORD"); let upload_password: String = env_or_panic("TRANSBEAM_UPLOAD_PASSWORD");
let cachebuster: String = env_or_else("TRANSBEAM_CACHEBUSTER", String::new); let cachebuster: String = env_or_else("TRANSBEAM_CACHEBUSTER", String::new);
let admin_password_hash: PasswordHashString = env_or_panic("TRANSBEAM_ADMIN_PASSWORD_HASH");
let cookie_secret_base64: String = env_or_panic("TRANSBEAM_COOKIE_SECRET");
let cookie_key = cookie::Key::from(
&base64::decode(&cookie_secret_base64)
.unwrap_or_else(
|_| panic!("Value {} for TRANSBEAM_COOKIE_SECRET is not valid base64", cookie_secret_base64)
)
);
let state_file: PathBuf = match std::env::var("TRANSBEAM_STATE_FILE") { let state_file: PathBuf = match std::env::var("TRANSBEAM_STATE_FILE") {
Ok(v) => v Ok(v) => v
@ -336,6 +410,7 @@ async fn main() -> std::io::Result<()> {
reverse_proxy, reverse_proxy,
mnemonic_codes, mnemonic_codes,
cachebuster, cachebuster,
admin_password_hash,
}, },
}); });
data.cleanup().await?; data.cleanup().await?;
@ -349,12 +424,15 @@ async fn main() -> std::io::Result<()> {
} else { } else {
Logger::default() Logger::default()
}) })
.wrap(SessionMiddleware::new(CookieSessionStore::default(), cookie_key.clone()))
.service(index) .service(index)
.service(handle_download) .service(handle_download)
.service(download_info) .service(download_info)
.service(handle_upload) .service(handle_upload)
.service(check_upload_password) .service(check_upload_password)
.service(upload_limits) .service(upload_limits)
.service(admin_panel)
.service(admin_signin)
.service(actix_files::Files::new("/", static_dir.clone())) .service(actix_files::Files::new("/", static_dir.clone()))
}); });

View file

@ -1,6 +1,6 @@
use jsondb::JsonDb; use jsondb::JsonDb;
mod prelude { pub mod prelude {
pub use std::collections::HashMap; pub use std::collections::HashMap;
pub use jsondb::Schema; pub use jsondb::Schema;
@ -8,8 +8,17 @@ mod prelude {
pub use serde_with::serde_as; pub use serde_with::serde_as;
pub use serde_with::skip_serializing_none; pub use serde_with::skip_serializing_none;
pub use time::OffsetDateTime; pub use time::OffsetDateTime;
pub trait SizedFile {
fn size(&self) -> u64;
fn formatted_size(&self) -> String {
bytesize::to_string(self.size(), false).replace(" ", "")
}
}
} }
mod v0; mod v0;
pub mod v1 { pub mod v1 {
@ -42,6 +51,9 @@ pub mod v1 {
} }
} }
} }
impl SizedFile for UploadedFile {
fn size(&self) -> u64 { self.size }
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FileSet { pub struct FileSet {
@ -70,6 +82,9 @@ pub mod v1 {
pub expiry: OffsetDateTime, pub expiry: OffsetDateTime,
pub contents: Option<FileSet>, pub contents: Option<FileSet>,
} }
impl SizedFile for StoredFile {
fn size(&self) -> u64 { self.size }
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StoredFileWithPassword { pub struct StoredFileWithPassword {

View file

@ -234,7 +234,14 @@ summary {
td.file_size { td.file_size {
text-align: right; text-align: right;
width: 5.12rem; }
td.file_size, th.file_size {
width: 6rem;
}
td.file_expiry, th.file_expiry {
width: 10rem;
} }
td.file_name { td.file_name {

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block body %}
<div id="admin" class="section">
<h3 class="section_heading">Admin</h3>
<table id="stored_files"><tbody>
<tr>
<th class="file_name">Name</td>
<th class="file_size">Size</td>
<th class="file_expiry">Expiry</td>
</tr>
{% for (code, entry) in stored_files.0.iter() %}
<tr>
<td class="file_name"><a href="download?code={{ code }}">{{ entry.file.name }}</a></td>
<td class="file_size">{{ entry.file.formatted_size() }}</td>
<td class="file_expiry">{{ entry.file.expiry.format(DATE_DISPLAY_FORMAT).unwrap() }}</td>
</tr>
{% endfor %}
</tbody></table>
</div>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block body %}
<div id="admin_signin" class="section">
<h3 class="section_heading">Sign In</h3>
{% if incorrect_password %}
<h4>Incorrect Password</h4>
{% endif %}
<form id="admin_signin_form" action="admin" method="post">
<div>
<label>
<input type="password" id="admin_password" name="password" placeholder="Admin password"/>
</label>
</div>
<input id="admin_signin_button" type="submit" value="Sign In"/>
</form>
</div>
{% endblock %}

View file

@ -4,7 +4,7 @@
{% block og_title %}{{ info.file.name }}{% endblock %} {% block og_title %}{{ info.file.name }}{% endblock %}
{% block og_description -%} {% block og_description -%}
{% let formatted_total_size = bytesize::to_string(info.file.size.clone(), false).replace(" ", "") -%} {% let formatted_total_size = info.file.formatted_size() -%}
{% match info.file.contents -%} {% match info.file.contents -%}
{% when Some with (files) -%} {% when Some with (files) -%}
{{ files.files.len() }} files, {{ formatted_total_size }} total {{ files.files.len() }} files, {{ formatted_total_size }} total
@ -26,7 +26,7 @@
{% block body %} {% block body %}
<div id="download_toplevel" class="section"> <div id="download_toplevel" class="section">
<div class="file_name">{{ info.file.name }}</div> <div class="file_name">{{ info.file.name }}</div>
<div class="file_size">{{ bytesize::to_string(info.file.size.clone(), false).replace(" ", "") }}</div> <div class="file_size">{{ info.file.formatted_size() }}</div>
<div class="file_download"><a class="download_button" href="download?code={{ info.code }}&download=all"></a></div> <div class="file_download"><a class="download_button" href="download?code={{ info.code }}&download=all"></a></div>
<div class="file_expiry">Expires {{ info.file.expiry.format(DATE_DISPLAY_FORMAT).unwrap() }}</div> <div class="file_expiry">Expires {{ info.file.expiry.format(DATE_DISPLAY_FORMAT).unwrap() }}</div>
</div> </div>
@ -39,7 +39,7 @@
{% let offsets = info.offsets.as_ref().unwrap() %} {% let offsets = info.offsets.as_ref().unwrap() %}
{% for f in files.files %} {% for f in files.files %}
<tr class="{% if offsets.get(loop.index0.clone()).unwrap().clone() > info.available %}unavailable{% endif %}"> <tr class="{% if offsets.get(loop.index0.clone()).unwrap().clone() > info.available %}unavailable{% endif %}">
<td class="file_size">{{ bytesize::to_string(f.size.clone(), false).replace(" ", "") }}</td> <td class="file_size">{{ f.formatted_size() }}</td>
<td class="file_name">{{ f.name }}</td> <td class="file_name">{{ f.name }}</td>
<td class="file_download"><a class="download_button" href="download?code={{ info.code }}&download={{ loop.index0 }}"></a></td> <td class="file_download"><a class="download_button" href="download?code={{ info.code }}&download={{ loop.index0 }}"></a></td>
<td class="file_unavailable"></td> <td class="file_unavailable"></td>