Compare commits
2 commits
61653794e1
...
d80180956f
Author | SHA1 | Date | |
---|---|---|---|
xenofem | d80180956f | ||
xenofem | 920b28f5f5 |
151
Cargo.lock
generated
151
Cargo.lock
generated
|
@ -166,6 +166,23 @@ dependencies = [
|
|||
"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]]
|
||||
name = "actix-utils"
|
||||
version = "3.0.0"
|
||||
|
@ -262,6 +279,15 @@ version = "1.0.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "aes"
|
||||
version = "0.7.5"
|
||||
|
@ -274,6 +300,20 @@ dependencies = [
|
|||
"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]]
|
||||
name = "ahash"
|
||||
version = "0.7.6"
|
||||
|
@ -309,6 +349,23 @@ dependencies = [
|
|||
"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]]
|
||||
name = "askama"
|
||||
version = "0.11.1"
|
||||
|
@ -368,6 +425,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
|
@ -403,6 +471,15 @@ version = "1.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.2"
|
||||
|
@ -543,7 +620,14 @@ version = "0.16.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"base64",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
@ -596,6 +680,15 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.14.1"
|
||||
|
@ -768,6 +861,16 @@ dependencies = [
|
|||
"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]]
|
||||
name = "h2"
|
||||
version = "0.3.13"
|
||||
|
@ -808,6 +911,15 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hkdf"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
|
@ -1178,6 +1290,17 @@ dependencies = [
|
|||
"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]]
|
||||
name = "paste"
|
||||
version = "1.0.7"
|
||||
|
@ -1192,7 +1315,7 @@ checksum = "271779f35b581956db91a3e55737327a03aa051e90b1c47aeb189508533adfd7"
|
|||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash",
|
||||
"password-hash 0.3.2",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
|
@ -1220,6 +1343,18 @@ version = "0.3.25"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.16"
|
||||
|
@ -1648,10 +1783,13 @@ dependencies = [
|
|||
"actix",
|
||||
"actix-files",
|
||||
"actix-http",
|
||||
"actix-session",
|
||||
"actix-web",
|
||||
"actix-web-actors",
|
||||
"argon2",
|
||||
"askama",
|
||||
"askama_actix",
|
||||
"base64",
|
||||
"bytes",
|
||||
"bytesize",
|
||||
"crc32fast",
|
||||
|
@ -1663,6 +1801,7 @@ dependencies = [
|
|||
"log",
|
||||
"mime",
|
||||
"mnemonic",
|
||||
"password-hash 0.4.2",
|
||||
"pin-project-lite",
|
||||
"rand",
|
||||
"sanitise-file-name",
|
||||
|
@ -1712,6 +1851,16 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "url"
|
||||
version = "2.2.2"
|
||||
|
|
|
@ -9,10 +9,13 @@ license = "MIT"
|
|||
actix = "0.13"
|
||||
actix-files = "0.6.0"
|
||||
actix-http = "3.0.4"
|
||||
actix-session = { version = "0.7.1", features = ["cookie-session"] }
|
||||
actix-web = "4.0.1"
|
||||
actix-web-actors = "4.1.0"
|
||||
argon2 = "0.4.1"
|
||||
askama = "0.11.1"
|
||||
askama_actix = "0.13"
|
||||
base64 = "0.13"
|
||||
bytes = "1.1.0"
|
||||
bytesize = "1.1.0"
|
||||
crc32fast = "1.3.2"
|
||||
|
@ -24,6 +27,7 @@ jsondb = "0.4.0"
|
|||
log = "0.4"
|
||||
mime = "0.3.16"
|
||||
mnemonic = "1.0.1"
|
||||
password-hash = { version = "0.4.2", features = ["alloc"] }
|
||||
pin-project-lite = "0.2.9"
|
||||
rand = "0.8.5"
|
||||
sanitise-file-name = "1.0.0"
|
||||
|
|
10
README.md
10
README.md
|
@ -24,6 +24,16 @@
|
|||
## configuration
|
||||
|
||||
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
|
||||
(default: `./storage`)
|
||||
- `TRANSBEAM_STATIC_DIR`: path where the web app's static files live
|
||||
|
|
86
src/main.rs
86
src/main.rs
|
@ -4,20 +4,23 @@ mod store;
|
|||
mod upload;
|
||||
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_session::{SessionMiddleware, storage::CookieSessionStore, Session};
|
||||
use actix_web::{
|
||||
error::InternalError, get, middleware::Logger, post, web, App, HttpRequest, HttpResponse,
|
||||
HttpServer, Responder,
|
||||
HttpServer, Responder, cookie,
|
||||
};
|
||||
use actix_web_actors::ws;
|
||||
use argon2::{Argon2, PasswordVerifier};
|
||||
use askama_actix::{Template, TemplateToResponse};
|
||||
use bytesize::ByteSize;
|
||||
use log::{error, warn};
|
||||
use password_hash::PasswordHashString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use state::StateDb;
|
||||
use store::StoredFile;
|
||||
use state::{StateDb, prelude::SizedFile};
|
||||
use store::{StoredFile, StoredFiles};
|
||||
use tokio::fs::File;
|
||||
|
||||
const APP_NAME: &str = "transbeam";
|
||||
|
@ -40,6 +43,7 @@ struct Config {
|
|||
reverse_proxy: bool,
|
||||
mnemonic_codes: bool,
|
||||
cachebuster: String,
|
||||
admin_password_hash: PasswordHashString,
|
||||
}
|
||||
|
||||
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)]
|
||||
struct DownloadRequest {
|
||||
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();
|
||||
let upload_password: String = env_or_panic("TRANSBEAM_UPLOAD_PASSWORD");
|
||||
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") {
|
||||
Ok(v) => v
|
||||
|
@ -336,6 +410,7 @@ async fn main() -> std::io::Result<()> {
|
|||
reverse_proxy,
|
||||
mnemonic_codes,
|
||||
cachebuster,
|
||||
admin_password_hash,
|
||||
},
|
||||
});
|
||||
data.cleanup().await?;
|
||||
|
@ -349,12 +424,15 @@ async fn main() -> std::io::Result<()> {
|
|||
} else {
|
||||
Logger::default()
|
||||
})
|
||||
.wrap(SessionMiddleware::new(CookieSessionStore::default(), cookie_key.clone()))
|
||||
.service(index)
|
||||
.service(handle_download)
|
||||
.service(download_info)
|
||||
.service(handle_upload)
|
||||
.service(check_upload_password)
|
||||
.service(upload_limits)
|
||||
.service(admin_panel)
|
||||
.service(admin_signin)
|
||||
.service(actix_files::Files::new("/", static_dir.clone()))
|
||||
});
|
||||
|
||||
|
|
17
src/state.rs
17
src/state.rs
|
@ -1,6 +1,6 @@
|
|||
use jsondb::JsonDb;
|
||||
|
||||
mod prelude {
|
||||
pub mod prelude {
|
||||
pub use std::collections::HashMap;
|
||||
|
||||
pub use jsondb::Schema;
|
||||
|
@ -8,8 +8,17 @@ mod prelude {
|
|||
pub use serde_with::serde_as;
|
||||
pub use serde_with::skip_serializing_none;
|
||||
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;
|
||||
|
||||
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)]
|
||||
pub struct FileSet {
|
||||
|
@ -70,6 +82,9 @@ pub mod v1 {
|
|||
pub expiry: OffsetDateTime,
|
||||
pub contents: Option<FileSet>,
|
||||
}
|
||||
impl SizedFile for StoredFile {
|
||||
fn size(&self) -> u64 { self.size }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct StoredFileWithPassword {
|
||||
|
|
|
@ -234,7 +234,14 @@ summary {
|
|||
|
||||
td.file_size {
|
||||
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 {
|
||||
|
|
21
templates/admin/signed_in.html
Normal file
21
templates/admin/signed_in.html
Normal 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 %}
|
18
templates/admin/signed_out.html
Normal file
18
templates/admin/signed_out.html
Normal 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 %}
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% block og_title %}{{ info.file.name }}{% endblock %}
|
||||
{% 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 -%}
|
||||
{% when Some with (files) -%}
|
||||
{{ files.files.len() }} files, {{ formatted_total_size }} total
|
||||
|
@ -26,7 +26,7 @@
|
|||
{% block body %}
|
||||
<div id="download_toplevel" class="section">
|
||||
<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_expiry">Expires {{ info.file.expiry.format(DATE_DISPLAY_FORMAT).unwrap() }}</div>
|
||||
</div>
|
||||
|
@ -39,7 +39,7 @@
|
|||
{% let offsets = info.offsets.as_ref().unwrap() %}
|
||||
{% for f in files.files %}
|
||||
<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_download"><a class="download_button" href="download?code={{ info.code }}&download={{ loop.index0 }}"></a></td>
|
||||
<td class="file_unavailable"></td>
|
||||
|
|
Loading…
Reference in a new issue