├── db ├── src │ ├── db.rs │ ├── lib.rs │ ├── database_args.rs │ ├── database.rs │ ├── entities │ │ └── pasta.rs │ ├── test_utils.rs │ └── db │ │ ├── json.rs │ │ └── sqlite.rs └── Cargo.toml ├── .github ├── index.png ├── logo.png ├── FUNDING.yml ├── workflows │ ├── rust.yml │ ├── pull_request.yml │ ├── rust-clippy.yml │ └── release.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── templates ├── assets │ ├── logo.png │ ├── favicon.ico │ ├── logo-square.png │ ├── utils.js │ └── highlight │ │ ├── LICENSE │ │ └── highlight.min.css ├── error.html ├── footer.html ├── qr.html ├── auth_admin.html ├── edit.html ├── header.html ├── guide.html ├── auth_upload.html ├── list.html └── upload.html ├── render.yaml ├── .cargo └── config.toml ├── .gitignore ├── SECURITY.md ├── src ├── util │ ├── hashids.rs │ ├── telemetry.rs │ ├── http_client.rs │ ├── version.rs │ ├── syntaxhighlighter.rs │ ├── animalnumbers.rs │ ├── auth.rs │ ├── cleanup.rs │ └── misc.rs ├── endpoints │ ├── errors.rs │ ├── guide.rs │ ├── list.rs │ ├── auth_admin.rs │ ├── static_resources.rs │ ├── qr.rs │ ├── admin.rs │ ├── file.rs │ ├── remove.rs │ ├── pasta.rs │ └── auth_upload.rs ├── error_handling.rs ├── main.rs ├── args.rs └── pasta.rs ├── Dockerfile ├── docker-setup.sh ├── LICENSE ├── compose.yaml ├── Cargo.toml ├── README.md └── .env /db/src/db.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod json; 2 | pub(crate) mod sqlite; 3 | -------------------------------------------------------------------------------- /.github/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yara-blue/microbin/HEAD/.github/index.png -------------------------------------------------------------------------------- /.github/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yara-blue/microbin/HEAD/.github/logo.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: szabodanika 4 | 5 | -------------------------------------------------------------------------------- /templates/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yara-blue/microbin/HEAD/templates/assets/logo.png -------------------------------------------------------------------------------- /templates/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yara-blue/microbin/HEAD/templates/assets/favicon.ico -------------------------------------------------------------------------------- /templates/assets/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yara-blue/microbin/HEAD/templates/assets/logo-square.png -------------------------------------------------------------------------------- /db/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod database; 2 | pub mod database_args; 3 | mod db; 4 | mod test_utils; 5 | pub mod entities { 6 | pub mod pasta; 7 | } 8 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 |
3 |

404

4 | Not Found 5 |
6 |
7 | Go Home 8 |
9 |
10 | {% include "footer.html" %} -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: microbin 4 | plan: free 5 | numInstances: 1 6 | env: rust 7 | repo: https://github.com/dvdsk/microbin.git 8 | buildCommand: cargo build --release 9 | startCommand: ./target/release/microbin --editable --highlightsyntax 10 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /db/src/database_args.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub enum DatabaseArgs { 3 | SqliteProperties(SqliteProperties), 4 | JSONDatabaseProperties(JSONDatabaseProperties), 5 | } 6 | 7 | #[derive(Debug)] 8 | pub struct SqliteProperties { 9 | pub db_path: String, 10 | pub in_memory: bool, 11 | } 12 | 13 | #[derive(Debug)] 14 | pub struct JSONDatabaseProperties { 15 | pub file_path: String, 16 | pub file_name: String, 17 | } 18 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # specifies the linker for compiling to these targets 2 | # this needs to be done to allow cross compiling 3 | 4 | # may need more entries for more architectures, if you run into 5 | # issues on something else then aarch64 musl open an issue and 6 | # point to this comment. This will no longer be necessary when 7 | # rust-lld is stabilizes. 8 | 9 | [target.aarch64-unknown-linux-musl] 10 | rustflags = ["-Clinker=rust-lld"] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # These are backup files generated by rustfmt 7 | **/*.rs.bk 8 | 9 | # MSVC Windows builds of rustc generate these, which store debugging information 10 | *.pdb 11 | 12 | pasta_data/* 13 | microbin_data/* 14 | *.env 15 | **/**/microbin-data 16 | 17 | 18 | # Ignore JetBrains IDEs project files 19 | .idea 20 | 21 | # Ignore test data 22 | test_data/ -------------------------------------------------------------------------------- /db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "db" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [lib] 7 | name = "db" 8 | path = "src/lib.rs" 9 | 10 | [dependencies] 11 | rusqlite = { version = "0.37.0", features = ["bundled"] } 12 | color-eyre = "0.6.5" 13 | r2d2_sqlite= "0.31.0" 14 | log = "0.4.27" 15 | serde = { version = "1.0.219", features = ["derive"] } 16 | serde_json = "1.0.142" 17 | r2d2 = "0.8" 18 | eyre = "0.6.12" 19 | 20 | 21 | [dev-dependencies] 22 | rand = "0.9.2" 23 | random-string="1.1.0" -------------------------------------------------------------------------------- /templates/footer.html: -------------------------------------------------------------------------------- 1 | {% if !args.hide_footer %} 2 | 3 |

4 | {% if args.footer_text.as_ref().is_none() %} 5 | MicroBin by Dániel Szabó and the FOSS 6 | Community. Let's keep the Web compact, accessible and 7 | humane! {%- else %} {{ args.footer_text.as_ref().unwrap()|safe }} {%- 8 | endif %} 9 |

10 | 11 | {%- endif %} 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Version Support 4 | 5 | Currently we only have capacity to support the latest version of MicroBin. We recommend that you always update to the newest one and check our pages regularly for announcements. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Security vulnerabilities can be reported directly to the developer/maintainer at d@szab.eu. 10 | 11 | Sensitive information may be GPG encrypted with my public key available at 12 | https://szab.eu/assets/files/daniel-szabo-pub.asc. 13 | -------------------------------------------------------------------------------- /src/util/hashids.rs: -------------------------------------------------------------------------------- 1 | use harsh::Harsh; 2 | use std::sync::LazyLock; 3 | 4 | pub static HARSH: LazyLock = LazyLock::new(|| { 5 | Harsh::builder() 6 | .length(6) 7 | .build() 8 | .expect("build with default alphabet and separator should succeed") 9 | }); 10 | 11 | pub fn to_hashids(number: u64) -> String { 12 | HARSH.encode(&[number]) 13 | } 14 | 15 | pub fn to_u64(hash_id: &str) -> Result { 16 | let ids = HARSH 17 | .decode(hash_id) 18 | .map_err(|_e| "Failed to decode hash ID")?; 19 | let id = ids.first().ok_or("No ID found in hash ID")?; 20 | Ok(*id) 21 | } 22 | -------------------------------------------------------------------------------- /templates/assets/utils.js: -------------------------------------------------------------------------------- 1 | function copyToClipboard(text) { 2 | if (navigator.clipboard && window.isSecureContext) { 3 | return navigator.clipboard.writeText(text); 4 | } else { 5 | const textArea = document.createElement('textarea'); 6 | textArea.value = text; 7 | textArea.style.position = 'fixed'; 8 | textArea.style.opacity = '0'; 9 | document.body.appendChild(textArea); 10 | textArea.focus(); 11 | textArea.select(); 12 | try { 13 | document.execCommand('copy'); 14 | } catch (err) { 15 | console.error('Failed to copy text: ', err); 16 | } 17 | document.body.removeChild(textArea); 18 | return Promise.resolve() 19 | } 20 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull_request.yml 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | - name: Set up cargo 13 | uses: actions-rs/toolchain@v1 14 | with: 15 | toolchain: stable 16 | override: true 17 | components: clippy, rustfmt 18 | - name: Run cargo fmt check 19 | run: cargo fmt --all -- --check 20 | - name: Run cargo clippy 21 | run: cargo clippy --all-targets --all-features -- -D warnings 22 | - name: Run cargo test 23 | run: cargo test --all 24 | -------------------------------------------------------------------------------- /src/endpoints/errors.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::error_handling::AppError; 4 | use askama::Template; 5 | use axum::extract::State; 6 | use axum::response::{IntoResponse, Response}; 7 | 8 | #[derive(Template)] 9 | #[template(path = "error.html")] 10 | pub struct ErrorTemplate<'a> { 11 | pub args: &'a Args, 12 | } 13 | 14 | pub async fn not_found( 15 | State(AppState { args, .. }): State, 16 | ) -> Result { 17 | let resp = Response::builder() 18 | .header("content-type", "text/html; charset=utf-8") 19 | .body(ErrorTemplate { args: &args }.render()?) 20 | .map_err(AppError::from)?; 21 | Ok(resp.into_response()) 22 | } 23 | -------------------------------------------------------------------------------- /src/endpoints/guide.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::error_handling::AppError; 4 | use askama::Template; 5 | use axum::Router; 6 | use axum::extract::State; 7 | use axum::response::{IntoResponse, Response}; 8 | 9 | #[derive(Template)] 10 | #[template(path = "guide.html")] 11 | struct Guide<'a> { 12 | args: &'a Args, 13 | } 14 | 15 | pub async fn guide( 16 | State(AppState { args, .. }): State, 17 | ) -> Result { 18 | Ok(Response::builder() 19 | .header("Content-Type", "text/html; charset=utf-8") 20 | .body(Guide { args: &args }.render()?)?) 21 | } 22 | 23 | pub fn guide_router() -> Router { 24 | Router::new().route("/guide", axum::routing::get(guide)) 25 | } 26 | -------------------------------------------------------------------------------- /templates/qr.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 | 3 |
4 | Back to Upload 5 |
6 | 7 | 8 |
9 | {% if pasta.pasta_type == "url" %} 10 | 11 | {{qr}} 12 | 13 | {% else %} 14 | 15 | {{qr}} 16 | 17 | {% endif %} 18 |
19 | 20 | 28 | 29 | {% include "footer.html" %} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:latest AS build 2 | 3 | WORKDIR /app 4 | 5 | RUN \ 6 | DEBIAN_FRONTEND=noninteractive \ 7 | apt-get update &&\ 8 | apt-get -y install ca-certificates tzdata 9 | 10 | COPY . . 11 | 12 | RUN \ 13 | CARGO_NET_GIT_FETCH_WITH_CLI=true \ 14 | cargo build --release 15 | 16 | # https://hub.docker.com/r/bitnami/minideb 17 | FROM bitnami/minideb:latest 18 | 19 | # microbin will be in /app 20 | WORKDIR /app 21 | 22 | RUN mkdir -p /usr/share/zoneinfo 23 | 24 | # copy time zone info 25 | COPY --from=build \ 26 | /usr/share/zoneinfo \ 27 | /usr/share/ 28 | 29 | COPY --from=build \ 30 | /etc/ssl/certs/ca-certificates.crt \ 31 | /etc/ssl/certs/ca-certificates.crt 32 | 33 | # copy built executable 34 | COPY --from=build \ 35 | /app/target/release/microbin \ 36 | /usr/bin/microbin 37 | 38 | # Expose webport used for the webserver to the docker runtime 39 | EXPOSE 8080 40 | 41 | ENTRYPOINT ["microbin"] 42 | -------------------------------------------------------------------------------- /templates/auth_admin.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | {% if status == "incorrect" %} 10 |

11 | Incorrect username or password. 12 |

13 | {% endif %} 14 |
15 | 16 | {% include "footer.html" %} {% if !args.pure_html %} 17 | 28 | {% endif %} -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /docker-setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if wget is installed; if not, try to use curl 4 | if ! command -v wget &> /dev/null 5 | then 6 | download_command="curl -O" 7 | else 8 | download_command="wget" 9 | fi 10 | 11 | # Get installation directory from user 12 | echo -e "\033[1mEnter installation directory (default is /usr/share/microbin):\033[0m" 13 | read install_dir 14 | install_dir=${install_dir:-/usr/share/microbin} 15 | 16 | # Create directory and download files 17 | mkdir -p $install_dir 18 | cd $install_dir 19 | $download_command https://raw.githubusercontent.com/dvdsk/microbin/master/.env 20 | $download_command https://raw.githubusercontent.com/dvdsk/microbin/master/compose.yaml 21 | 22 | # Get public path URL and port from user 23 | echo -e "\033[1mEnter public path URL (e.g. https://microbin.myserver.net or http://localhost:8080):\033[0m" 24 | read public_path 25 | 26 | echo -e "\033[1mEnter port number (default is 8080):\033[0m" 27 | read port 28 | port=${port:-8080} 29 | 30 | # Update environment variables in .env file 31 | sed -i "s|MICROBIN_PUBLIC_PATH=.*|MICROBIN_PUBLIC_PATH=${public_path}|" .env 32 | sed -i "s|MICROBIN_PORT=.*|MICROBIN_PORT=${port}|" .env 33 | 34 | # Start Microbin using Docker Compose 35 | docker compose --env-file .env up --detach 36 | -------------------------------------------------------------------------------- /src/util/telemetry.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | thread, 3 | time::{Duration, Instant}, 4 | }; 5 | 6 | use crate::args::Args; 7 | use serde_json::json; 8 | 9 | pub fn start_telemetry_thread(args: &Args) { 10 | // Start a new thread that calls the send_telemetry function every 24 hours 11 | let args = args.clone(); 12 | thread::spawn(move || { 13 | let mut last_run = Instant::now(); 14 | loop { 15 | let _ = send_telemetry(&args); 16 | 17 | // Wait for 24 hours since the last run 18 | let next_run = last_run + Duration::from_secs(60 * 60 * 24); 19 | let now = Instant::now(); 20 | if next_run > now { 21 | thread::sleep(next_run - now); 22 | } 23 | last_run = Instant::now(); 24 | } 25 | }); 26 | } 27 | 28 | fn send_telemetry(args: &Args) -> Result<(), reqwest::Error> { 29 | // Convert the telemetry object to JSON 30 | let json_body = json!(args.to_owned().without_secrets().to_owned()).to_string(); 31 | 32 | // Send the telemetry data to the configured API endpoint 33 | crate::util::http_client::new() 34 | .post(&args.telemetry_url) 35 | .header("Content-Type", "application/json") 36 | .body(json_body) 37 | .send()?; 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /src/util/http_client.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(any(feature = "default", feature = "__rustcrypto-tls")))] 2 | compile_error! {"You must either have the default feature enabled (remove 3 | the no-default-features rust argument) or the no-c-deps feature"} 4 | 5 | #[cfg(feature = "__rustcrypto-tls")] 6 | pub fn new() -> reqwest::blocking::Client { 7 | reqwest::blocking::Client::builder() 8 | .use_preconfigured_tls(tls_config()) 9 | .build() 10 | .expect("Could not create HTTP client.") 11 | } 12 | 13 | #[cfg(feature = "__rustcrypto-tls")] 14 | pub fn new_async() -> reqwest::Client { 15 | reqwest::Client::builder() 16 | .use_preconfigured_tls(tls_config()) 17 | .build() 18 | .expect("Could not create HTTP client.") 19 | } 20 | 21 | #[cfg(feature = "__rustcrypto-tls")] 22 | fn tls_config() -> rustls::ClientConfig { 23 | use std::sync::Arc; 24 | 25 | let root_store = rustls::RootCertStore { 26 | roots: webpki_roots::TLS_SERVER_ROOTS.into(), 27 | }; 28 | 29 | let provider = Arc::new(rustls_rustcrypto::provider()); 30 | rustls::ClientConfig::builder_with_provider(provider) 31 | .with_safe_default_protocol_versions() 32 | .expect("Should support safe default protocols") 33 | .with_root_certificates(root_store) 34 | .with_no_client_auth() 35 | } 36 | -------------------------------------------------------------------------------- /db/src/database.rs: -------------------------------------------------------------------------------- 1 | use crate::database_args::DatabaseArgs; 2 | use crate::db::json::JsonDatabase; 3 | use crate::db::sqlite::SqLite; 4 | use crate::entities::pasta::PastaEntity; 5 | use color_eyre::eyre; 6 | use eyre::Result; 7 | use std::sync::Arc; 8 | 9 | pub trait Database { 10 | fn insert_pasta(&self, pasta: PastaEntity) -> Result<()>; 11 | fn find_all_pastas(&self) -> Result>; 12 | fn get_pasta(&self, id: &u64) -> Result>; 13 | fn update_pasta(&self, id: &u64, pasta: PastaEntity) -> Result; 14 | fn find_all_public_pastas(&self) -> Result>; 15 | fn delete_pasta(&self, id: &u64) -> Result<()>; 16 | } 17 | 18 | pub type DatabaseType = Arc; 19 | 20 | pub fn get_database(args: DatabaseArgs) -> DatabaseType { 21 | match args { 22 | DatabaseArgs::SqliteProperties(sqlite_props) => { 23 | let db = SqLite::new(sqlite_props).expect("Error initializing SQLite database"); 24 | Arc::new(db) 25 | } 26 | DatabaseArgs::JSONDatabaseProperties(json_database_props) => { 27 | let db = JsonDatabase::new(json_database_props).expect( 28 | "Error initializing Json \ 29 | database ", 30 | ); 31 | Arc::new(db) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/endpoints/list.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::error_handling::AppError; 4 | use crate::pasta::Pasta; 5 | use askama::Template; 6 | use axum::extract::State; 7 | use axum::response::IntoResponse; 8 | use reqwest::{StatusCode, header}; 9 | 10 | #[derive(Template)] 11 | #[template(path = "list.html")] 12 | struct ListTemplate<'a> { 13 | pastas: &'a Vec, 14 | args: &'a Args, 15 | } 16 | 17 | pub async fn list( 18 | State(AppState { args, db }): State, 19 | ) -> Result { 20 | if args.no_listing { 21 | return Ok(( 22 | StatusCode::FOUND, 23 | [(header::LOCATION, format!("{}/", args.public_path_as_str()))], 24 | "".to_string(), 25 | )); 26 | } 27 | 28 | let mut pastas = db 29 | .find_all_public_pastas()? 30 | .iter() 31 | .map(Pasta::from) 32 | .collect::>(); 33 | // sort pastas in reverse-chronological order of creation time 34 | pastas.sort_by(|a, b| b.created.cmp(&a.created)); 35 | 36 | Ok(( 37 | StatusCode::OK, 38 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 39 | ListTemplate { 40 | pastas: &pastas, 41 | args: &args, 42 | } 43 | .render()?, 44 | )) 45 | } 46 | 47 | pub fn list_router() -> axum::Router { 48 | axum::Router::new().route("/list", axum::routing::get(list)) 49 | } 50 | -------------------------------------------------------------------------------- /src/endpoints/auth_admin.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::error_handling::AppError; 4 | use askama::Template; 5 | use axum::extract::{Path, State}; 6 | use axum::http::Response; 7 | use axum::response::IntoResponse; 8 | 9 | #[derive(Template)] 10 | #[template(path = "auth_admin.html")] 11 | struct AuthAdmin<'a> { 12 | args: &'a Args, 13 | status: String, 14 | } 15 | 16 | async fn auth_admin( 17 | State(AppState { args, .. }): State, 18 | ) -> Result { 19 | Ok(Response::builder() 20 | .header("Content-Type", "text/html; charset=utf-8") 21 | .body( 22 | AuthAdmin { 23 | args: &args, 24 | status: "".to_string(), 25 | } 26 | .render()?, 27 | )?) 28 | } 29 | 30 | async fn auth_admin_with_status( 31 | Path(status): Path, 32 | State(AppState { args, .. }): State, 33 | ) -> Result { 34 | Ok(Response::builder() 35 | .header("Content-Type", "text/html; charset=utf-8") 36 | .body( 37 | AuthAdmin { 38 | args: &args, 39 | status: status.to_string(), 40 | } 41 | .render()?, 42 | )?) 43 | } 44 | 45 | pub fn auth_admin_router() -> axum::Router { 46 | axum::Router::new() 47 | .route("/auth_admin", axum::routing::get(auth_admin)) 48 | .route( 49 | "/auth_admin/{status}", 50 | axum::routing::get(auth_admin_with_status), 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/endpoints/static_resources.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::error_handling::AppError; 3 | use axum::Router; 4 | use axum::extract::Path; 5 | use axum::http::Response; 6 | use axum::response::IntoResponse; 7 | use reqwest::StatusCode; 8 | use rust_embed::RustEmbed; 9 | 10 | #[derive(RustEmbed)] 11 | #[folder = "templates/assets/"] 12 | struct Asset; 13 | 14 | async fn static_resources(Path(path): Path) -> Result { 15 | if let Ok(response) = try_embedded(&path) { 16 | Ok(response) 17 | } else { 18 | log::warn!("Resource not found: {path}"); 19 | Response::builder() 20 | .status(StatusCode::NOT_FOUND) 21 | .header("Content-Type", "text/plain") 22 | .body(axum::body::Body::from("Resource not found")) 23 | .map_err(AppError::from) 24 | } 25 | } 26 | 27 | // Hilfsfunktion für rust-embed 28 | fn try_embedded(path: &str) -> Result, AppError> { 29 | match Asset::get(path).map(|content| { 30 | axum::http::Response::builder() 31 | .header( 32 | "Content-Type", 33 | mime_guess::from_path(path).first_or_octet_stream().as_ref(), 34 | ) 35 | .body(axum::body::Body::from(content.data.into_owned())) 36 | }) { 37 | Some(response) => Ok(response?), 38 | None => Err(AppError::bad_request(format!("Bad request: {path}"))), 39 | } 40 | } 41 | 42 | pub fn static_resource_router() -> Router { 43 | Router::new().route("/static/{*path}", axum::routing::get(static_resources)) 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022-2023, Dániel Szabó 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /templates/assets/highlight/LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2006, Ivan Sagalaev. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/util/version.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Deserialize, Serialize, Clone)] 6 | pub struct Version { 7 | pub major: u32, 8 | pub minor: u32, 9 | pub patch: u32, 10 | pub title: Cow<'static, str>, 11 | pub long_title: Cow<'static, str>, 12 | pub description: Cow<'static, str>, 13 | pub date: Cow<'static, str>, 14 | pub update_type: Cow<'static, str>, 15 | } 16 | 17 | pub static CURRENT_VERSION: Version = Version { 18 | major: 2, 19 | minor: 0, 20 | patch: 4, 21 | title: Cow::Borrowed("2.0.4"), 22 | long_title: Cow::Borrowed("Version 2.0.4, Build 20230711"), 23 | description: Cow::Borrowed("This version includes bug fixes and performance improvements."), 24 | date: Cow::Borrowed("2023-07-11"), 25 | update_type: Cow::Borrowed("beta"), 26 | }; 27 | 28 | impl Version { 29 | pub fn newer_than(&self, other: &Version) -> bool { 30 | if self.major != other.major { 31 | self.major > other.major 32 | } else if self.minor != other.minor { 33 | self.minor > other.minor 34 | } else { 35 | self.patch > other.patch 36 | } 37 | } 38 | 39 | pub fn newer_than_current(&self) -> bool { 40 | self.newer_than(&CURRENT_VERSION) 41 | } 42 | } 43 | 44 | pub async fn fetch_latest_version() -> Result { 45 | let url = "https://api.microbin.eu/version/"; 46 | let http_client = crate::util::http_client::new_async(); 47 | let response = http_client.get(url).send().await?; 48 | let version = response.json::().await?; 49 | 50 | Ok(version) 51 | } 52 | -------------------------------------------------------------------------------- /templates/edit.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 |
4 |

5 | Editing upload '{{ pasta.id_as_animals(args.hash_ids) }}' 6 |

7 | 8 |
9 | 11 |
12 |
13 | {% if pasta.readonly || pasta.encrypt_server %} 14 |
15 |
16 | 18 | {% if status == "incorrect" %} 19 |

20 | Incorrect password. 21 |

22 | {% endif %} 23 |
24 | {% endif %} 25 | 26 | 27 |
28 |
29 | 31 | 32 |
33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 | {% include "footer.html" %} -------------------------------------------------------------------------------- /src/util/syntaxhighlighter.rs: -------------------------------------------------------------------------------- 1 | use syntect::easy::HighlightLines; 2 | use syntect::highlighting::{Style, ThemeSet}; 3 | use syntect::html::IncludeBackground::No; 4 | use syntect::html::append_highlighted_html_for_styled_line; 5 | use syntect::parsing::SyntaxSet; 6 | use syntect::util::LinesWithEndings; 7 | 8 | pub fn html_highlight(text: &str, extension: &str) -> String { 9 | let ps = SyntaxSet::load_defaults_newlines(); 10 | let ts = ThemeSet::load_defaults(); 11 | 12 | let syntax = ps 13 | .find_syntax_by_extension(extension) 14 | .or_else(|| Option::from(ps.find_syntax_plain_text())) 15 | .expect("syntax not found"); 16 | let mut h = HighlightLines::new(syntax, &ts.themes["InspiredGitHub"]); 17 | 18 | let mut highlighted_content: String = String::from(""); 19 | 20 | for line in LinesWithEndings::from(text) { 21 | let ranges: Vec<(Style, &str)> = h 22 | .highlight_line(line, &ps) 23 | .expect("error highlighting line"); 24 | append_highlighted_html_for_styled_line(&ranges[..], No, &mut highlighted_content) 25 | .expect("Failed to append highlighted line!"); 26 | } 27 | 28 | let mut highlighted_content2: String = String::from(""); 29 | for line in highlighted_content.lines() { 30 | highlighted_content2 += &*format!("{line}\n"); 31 | } 32 | 33 | // Rewrite colours to ones that are compatible with water.css and both light/dark modes 34 | highlighted_content2 = highlighted_content2.replace("style=\"color:#323232;\"", ""); 35 | highlighted_content2 = 36 | highlighted_content2.replace("style=\"color:#183691;\"", "style=\"color:blue;\""); 37 | 38 | highlighted_content2 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/rust-clippy.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # rust-clippy is a tool that runs a bunch of lints to catch common 6 | # mistakes in your Rust code and help improve your Rust code. 7 | # More details at https://github.com/rust-lang/rust-clippy 8 | # and https://rust-lang.github.io/rust-clippy/ 9 | 10 | name: rust-clippy analyze 11 | 12 | on: 13 | push: 14 | branches: [ master ] 15 | pull_request: 16 | # The branches below must be a subset of the branches above 17 | branches: [ master ] 18 | schedule: 19 | - cron: '35 12 * * 5' 20 | 21 | jobs: 22 | rust-clippy-analyze: 23 | name: Run rust-clippy analyzing 24 | runs-on: ubuntu-latest 25 | permissions: 26 | contents: read 27 | security-events: write 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v2 31 | 32 | - name: Install Rust toolchain 33 | uses: dtolnay/rust-toolchain@stable 34 | with: 35 | components: clippy, rustfmt 36 | 37 | - name: Install required cargo 38 | run: cargo install clippy-sarif sarif-fmt 39 | 40 | - name: Run rust-clippy 41 | run: 42 | cargo clippy 43 | --all-features 44 | --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt 45 | continue-on-error: true 46 | 47 | - name: Upload analysis results to GitHub 48 | uses: github/codeql-action/upload-sarif@v1 49 | with: 50 | sarif_file: rust-clippy-results.sarif 51 | wait-for-processing: true 52 | -------------------------------------------------------------------------------- /db/src/entities/pasta.rs: -------------------------------------------------------------------------------- 1 | use rusqlite::Row; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Serialize, Deserialize, Clone)] 5 | pub struct PastaEntity { 6 | pub id: u64, 7 | pub content: String, 8 | pub file_name: Option, 9 | pub file_size: Option, 10 | pub extension: String, 11 | pub read_only: bool, 12 | pub private: bool, 13 | pub editable: i32, 14 | pub encrypt_server: i32, 15 | pub encrypt_client: i32, 16 | pub encrypted_key: Option, 17 | pub created: i64, 18 | pub expiration: i64, 19 | pub last_read: i64, 20 | pub read_count: i64, 21 | pub burn_after_reads: i64, 22 | pub pasta_type: String, 23 | pub hide_read_count: bool, 24 | } 25 | 26 | impl From<&Row<'_>> for PastaEntity { 27 | fn from(row: &Row<'_>) -> Self { 28 | PastaEntity { 29 | id: row.get(0).unwrap(), 30 | content: row.get(1).unwrap(), 31 | file_name: row.get(2).unwrap(), 32 | file_size: row.get(3).unwrap(), 33 | extension: row.get(4).unwrap(), 34 | read_only: row.get(5).unwrap(), 35 | private: row.get(6).unwrap(), 36 | editable: row.get(7).unwrap(), 37 | encrypt_server: row.get(8).unwrap(), 38 | encrypt_client: row.get(9).unwrap(), 39 | encrypted_key: row.get(10).unwrap(), 40 | created: row.get(11).unwrap(), 41 | expiration: row.get(12).unwrap(), 42 | last_read: row.get(13).unwrap(), 43 | read_count: row.get(14).unwrap(), 44 | burn_after_reads: row.get(15).unwrap(), 45 | pasta_type: row.get(16).unwrap(), 46 | hide_read_count: row.get(17).unwrap(), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /templates/assets/highlight/highlight.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Theme: Default Description: Original highlight.js style Author: (c) Ivan 3 | Sagalaev Maintainer: @highlightjs/core-team 4 | Website: https://highlightjs.org/ License: see project LICENSE Touched: 2021 5 | */ 6 | pre code.hljs { 7 | display: block; 8 | overflow-x: auto; 9 | padding: 1em 10 | } 11 | 12 | code.hljs { 13 | padding: 3px 5px 14 | } 15 | 16 | .hljs { 17 | background: #f3f3f3; 18 | color: #444 19 | } 20 | 21 | .hljs-comment { 22 | color: #697070 23 | } 24 | 25 | .hljs-punctuation, 26 | .hljs-tag { 27 | color: #444a 28 | } 29 | 30 | .hljs-tag .hljs-attr, 31 | .hljs-tag .hljs-name { 32 | color: #444 33 | } 34 | 35 | .hljs-attribute, 36 | .hljs-doctag, 37 | .hljs-keyword, 38 | .hljs-meta .hljs-keyword, 39 | .hljs-name, 40 | .hljs-selector-tag { 41 | font-weight: 700 42 | } 43 | 44 | .hljs-deletion, 45 | .hljs-number, 46 | .hljs-quote, 47 | .hljs-selector-class, 48 | .hljs-selector-id, 49 | .hljs-string, 50 | .hljs-template-tag, 51 | .hljs-type { 52 | color: #800 53 | } 54 | 55 | .hljs-section, 56 | .hljs-title { 57 | color: #800; 58 | font-weight: 700 59 | } 60 | 61 | .hljs-link, 62 | .hljs-operator, 63 | .hljs-regexp, 64 | .hljs-selector-attr, 65 | .hljs-selector-pseudo, 66 | .hljs-symbol, 67 | .hljs-template-variable, 68 | .hljs-variable { 69 | color: #ab5656 70 | } 71 | 72 | .hljs-literal { 73 | color: #695 74 | } 75 | 76 | .hljs-addition, 77 | .hljs-built_in, 78 | .hljs-bullet, 79 | .hljs-code { 80 | color: #397300 81 | } 82 | 83 | .hljs-meta { 84 | color: #1f7199 85 | } 86 | 87 | .hljs-meta .hljs-string { 88 | color: #38a 89 | } 90 | 91 | .hljs-emphasis { 92 | font-style: italic 93 | } 94 | 95 | .hljs-strong { 96 | font-weight: 700 97 | } -------------------------------------------------------------------------------- /db/src/test_utils.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod test_util { 3 | 4 | const CHARSET: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 5 | use random_string::generate; 6 | 7 | pub fn create_test_sqlite_properties() -> super::super::database_args::SqliteProperties { 8 | super::super::database_args::SqliteProperties { 9 | db_path: ":memory:".to_string(), 10 | in_memory: true, 11 | } 12 | } 13 | 14 | pub fn create_test_json_db_properties() -> super::super::database_args::JSONDatabaseProperties { 15 | super::super::database_args::JSONDatabaseProperties { 16 | file_path: "./test_data".to_string(), 17 | file_name: format!("pasta_{}.json", generate(20, CHARSET)), 18 | } 19 | } 20 | 21 | pub fn create_random_pasta_entity() -> super::super::entities::pasta::PastaEntity { 22 | use rand::Rng; 23 | let mut rng = rand::rng(); 24 | 25 | super::super::entities::pasta::PastaEntity { 26 | id: rng.random_range(0..100), 27 | content: generate(6, CHARSET), 28 | file_name: Option::from(generate(6, CHARSET)), 29 | file_size: Some(1024), 30 | extension: generate(6, CHARSET), 31 | private: false, 32 | read_only: false, 33 | editable: i32::from(true), 34 | encrypt_server: i32::from(false), 35 | encrypt_client: i32::from(false), 36 | encrypted_key: None, 37 | created: rng.random_range(0..1000000), 38 | expiration: 0, 39 | last_read: 0, 40 | read_count: 0, 41 | burn_after_reads: 0, 42 | pasta_type: "url".to_string(), 43 | hide_read_count: false, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/util/animalnumbers.rs: -------------------------------------------------------------------------------- 1 | const ANIMAL_NAMES: &[&str] = &[ 2 | "ant", "eel", "mole", "sloth", "ape", "emu", "monkey", "snail", "bat", "falcon", "mouse", 3 | "snake", "bear", "fish", "otter", "spider", "bee", "fly", "parrot", "squid", "bird", "fox", 4 | "panda", "swan", "bison", "frog", "pig", "tiger", "camel", "gecko", "pigeon", "toad", "cat", 5 | "goat", "pony", "turkey", "cobra", "goose", "pug", "turtle", "crow", "hawk", "rabbit", "viper", 6 | "deer", "horse", "rat", "wasp", "dog", "jaguar", "raven", "whale", "dove", "koala", "seal", 7 | "wolf", "duck", "lion", "shark", "worm", "eagle", "lizard", "sheep", "zebra", 8 | ]; 9 | const ANIMAL_COUNT: u64 = ANIMAL_NAMES.len() as u64; 10 | 11 | pub fn to_animal_names(number: u64) -> String { 12 | let mut result: Vec<&str> = Vec::new(); 13 | 14 | if number == 0 { 15 | return ANIMAL_NAMES[0] 16 | .parse() 17 | .expect("could not parse animal number"); 18 | } 19 | 20 | let mut value = number; 21 | while value != 0 { 22 | let digit = (value % ANIMAL_COUNT) as usize; 23 | value /= ANIMAL_COUNT; 24 | result.push(ANIMAL_NAMES[digit]); 25 | } 26 | 27 | // We calculated the numbers in Little-Endian, 28 | // now convert to Big-Endian for backwards compatibility with old data. 29 | result.reverse(); 30 | 31 | result.join("-") 32 | } 33 | 34 | #[test] 35 | fn test_to_animal_names() { 36 | assert_eq!(to_animal_names(0), "ant"); 37 | assert_eq!(to_animal_names(1), "eel"); 38 | assert_eq!(to_animal_names(64), "eel-ant"); 39 | assert_eq!(to_animal_names(12345), "sloth-ant-lion"); 40 | } 41 | 42 | pub fn to_u64(animal_names: &str) -> Result { 43 | let mut result: u64 = 0; 44 | 45 | for animal in animal_names.split('-') { 46 | let animal_index = ANIMAL_NAMES.iter().position(|&r| r == animal); 47 | match animal_index { 48 | None => return Err("Failed to convert animal name to u64!"), 49 | Some(idx) => { 50 | result = result * ANIMAL_COUNT + (idx as u64); 51 | } 52 | } 53 | } 54 | 55 | Ok(result) 56 | } 57 | 58 | #[test] 59 | fn test_animal_name_to_u64() { 60 | assert_eq!(to_u64("ant"), Ok(0)); 61 | assert_eq!(to_u64("eel"), Ok(1)); 62 | assert_eq!(to_u64("eel-ant"), Ok(64)); 63 | assert_eq!(to_u64("sloth-ant-lion"), Ok(12345)); 64 | } 65 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | microbin: 3 | image: ghcr.io/dvdsk/microbin:latest 4 | restart: always 5 | ports: 6 | - "${MICROBIN_PORT}:8080" 7 | volumes: 8 | - ./microbin-data:/app/microbin_data 9 | environment: 10 | MICROBIN_BASIC_AUTH_USERNAME: ${MICROBIN_BASIC_AUTH_USERNAME} 11 | MICROBIN_BASIC_AUTH_PASSWORD: ${MICROBIN_BASIC_AUTH_PASSWORD} 12 | MICROBIN_ADMIN_USERNAME: ${MICROBIN_ADMIN_USERNAME} 13 | MICROBIN_ADMIN_PASSWORD: ${MICROBIN_ADMIN_PASSWORD} 14 | MICROBIN_EDITABLE: ${MICROBIN_EDITABLE} 15 | MICROBIN_FOOTER_TEXT: ${MICROBIN_FOOTER_TEXT} 16 | MICROBIN_HIDE_FOOTER: ${MICROBIN_HIDE_FOOTER} 17 | MICROBIN_HIDE_HEADER: ${MICROBIN_HIDE_HEADER} 18 | MICROBIN_HIDE_LOGO: ${MICROBIN_HIDE_LOGO} 19 | MICROBIN_NO_LISTING: ${MICROBIN_NO_LISTING} 20 | MICROBIN_HIGHLIGHTSYNTAX: ${MICROBIN_HIGHLIGHTSYNTAX} 21 | MICROBIN_BIND: ${MICROBIN_BIND} 22 | MICROBIN_PRIVATE: ${MICROBIN_PRIVATE} 23 | MICROBIN_PURE_HTML: ${MICROBIN_PURE_HTML} 24 | MICROBIN_DATA_DIR: ${MICROBIN_DATA_DIR} 25 | MICROBIN_JSON_DB: ${MICROBIN_JSON_DB} 26 | MICROBIN_PUBLIC_PATH: ${MICROBIN_PUBLIC_PATH} 27 | MICROBIN_SHORT_PATH: ${MICROBIN_SHORT_PATH} 28 | MICROBIN_READONLY: ${MICROBIN_READONLY} 29 | MICROBIN_SHOW_READ_STATS: ${MICROBIN_SHOW_READ_STATS} 30 | MICROBIN_TITLE: ${MICROBIN_TITLE} 31 | MICROBIN_THREADS: ${MICROBIN_THREADS} 32 | MICROBIN_GC_DAYS: ${MICROBIN_GC_DAYS} 33 | MICROBIN_ENABLE_BURN_AFTER: ${MICROBIN_ENABLE_BURN_AFTER} 34 | MICROBIN_DEFAULT_BURN_AFTER: ${MICROBIN_DEFAULT_BURN_AFTER} 35 | MICROBIN_WIDE: ${MICROBIN_WIDE} 36 | MICROBIN_QR: ${MICROBIN_QR} 37 | MICROBIN_ETERNAL_PASTA: ${MICROBIN_ETERNAL_PASTA} 38 | MICROBIN_ENABLE_READONLY: ${MICROBIN_ENABLE_READONLY} 39 | MICROBIN_DEFAULT_EXPIRY: ${MICROBIN_DEFAULT_EXPIRY} 40 | MICROBIN_NO_FILE_UPLOAD: ${MICROBIN_NO_FILE_UPLOAD} 41 | MICROBIN_CUSTOM_CSS: ${MICROBIN_CUSTOM_CSS} 42 | MICROBIN_HASH_IDS: ${MICROBIN_HASH_IDS} 43 | MICROBIN_ENCRYPTION_CLIENT_SIDE: ${MICROBIN_ENCRYPTION_CLIENT_SIDE} 44 | MICROBIN_ENCRYPTION_SERVER_SIDE: ${MICROBIN_ENCRYPTION_SERVER_SIDE} 45 | MICROBIN_MAX_FILE_SIZE_ENCRYPTED_MB: ${MICROBIN_MAX_FILE_SIZE_ENCRYPTED_MB} 46 | MICROBIN_MAX_FILE_SIZE_UNENCRYPTED_MB: ${MICROBIN_MAX_FILE_SIZE_UNENCRYPTED_MB} 47 | MICROBIN_ENABLE_TELEMETRY: ${MICROBIN_ENABLE_TELEMETRY} 48 | MICROBIN_TELEMETRY_URL: ${MICROBIN_TELEMETRY_URL} 49 | MICROBIN_LOG: ${MICROBIN_LOG} 50 | -------------------------------------------------------------------------------- /src/endpoints/qr.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::endpoints::errors::ErrorTemplate; 4 | use crate::error_handling::AppError; 5 | use crate::pasta::Pasta; 6 | use crate::util::animalnumbers::to_u64; 7 | use crate::util::hashids::to_u64 as hashid_to_u64; 8 | use crate::util::misc::{self}; 9 | use askama::Template; 10 | use axum::Router; 11 | use axum::extract::{Path, State}; 12 | use axum::http::StatusCode; 13 | use axum::response::IntoResponse; 14 | use axum::routing::get; 15 | use reqwest::header; 16 | 17 | #[derive(Template)] 18 | #[template(path = "qr.html", escape = "none")] 19 | struct QRTemplate<'a> { 20 | args: &'a Args, 21 | qr: &'a String, 22 | pasta: &'a Pasta, 23 | } 24 | 25 | pub async fn getqr( 26 | State(AppState { args, db }): State, 27 | Path(id): Path, 28 | ) -> Result { 29 | let u64_id = if args.hash_ids { 30 | hashid_to_u64(&id).unwrap_or(0) 31 | } else { 32 | to_u64(&id).unwrap_or(0) 33 | }; 34 | 35 | let opt_pasta = db.get_pasta(&u64_id)?; 36 | 37 | let pasta: Pasta = match opt_pasta { 38 | Some(pasta) => Ok::(pasta.into()), 39 | None => { 40 | // otherwise, send pasta not found error 41 | return Ok(( 42 | StatusCode::OK, 43 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 44 | ErrorTemplate { args: &args }.render()?, 45 | ) 46 | .into_response()); 47 | } 48 | }?; 49 | 50 | // generate the QR code as an SVG - if its a file or text pastas, this will point to the /upload endpoint, otherwise to the /url endpoint, essentially directly taking the user to the url stored in the pasta 51 | let svg: String = match pasta.pasta_type.as_str() { 52 | "url" => { 53 | misc::string_to_qr_svg(format!("{}/url/{}", &args.public_path_as_str(), &id).as_str()) 54 | } 55 | _ => misc::string_to_qr_svg( 56 | format!("{}/upload/{}", &args.public_path_as_str(), &id).as_str(), 57 | ), 58 | }; 59 | 60 | let qr_template = QRTemplate { 61 | qr: &svg, 62 | pasta: &pasta, 63 | args: &args, 64 | } 65 | .render()?; 66 | 67 | // serve qr code in template 68 | Ok([(header::CONTENT_TYPE, "text/html; charset=utf-8")] 69 | .into_response() 70 | .map(|_| qr_template) 71 | .into_response()) 72 | } 73 | 74 | pub fn qr_router() -> Router { 75 | Router::new().route("/qr/{id}", get(getqr)) 76 | } 77 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "microbin" 3 | version = "2.1.0" 4 | edition = "2024" 5 | rust-version = "1.88.0" 6 | authors = ["Daniel Szabo ", "David Kleingeld", "SamTV1998"] 7 | license = "BSD-3-Clause" 8 | description = "Simple, performant, configurable, entirely self-contained Pastebin and URL shortener." 9 | readme = "README.md" 10 | homepage = "https://microbin.eu" 11 | repository = "https://github.com/dvdsk/microbin" 12 | keywords = ["pastebin", "filesharing", "microbin", "axum", "selfhosted"] 13 | categories = ["multimedia"] 14 | 15 | 16 | [workspace] 17 | members = [".", "db"] 18 | 19 | [dependencies] 20 | axum = { version = "0.8.4", features = ["multipart", "macros"] } 21 | tokio-util={version = "0.7.15"} 22 | axum-extra = {version = "0.10.1"} 23 | tower-http = { version = "0.6.6", features = ["fs", "normalize-path", "limit"] } 24 | tokio = {version = "1.46.1", features = ["full"]} 25 | askama = "0.14.0" 26 | askama-filters = { version = "0.1.3", features = ["chrono"] } 27 | bytesize = { version = "2.0.1", features = ["serde"] } 28 | chrono = "0.4.19" 29 | clap = { version = "4.5.41", features = ["derive", "env"] } 30 | env_logger = "0.11.8" 31 | futures = "0.3" 32 | harsh = "0.2" 33 | html-escape = "0.2.13" 34 | linkify = "0.10.0" 35 | log = "0.4.21" 36 | magic-crypt = "4.0.1" 37 | mime_guess = "2.0.4" 38 | qrcode-generator = "5.0.0" 39 | rand = "0.9.1" 40 | reqwest = { version = "0.12", default-features = false, features = ["charset", 41 | "http2", "macos-system-configuration", "json", "blocking"] } 42 | rust-embed = "8.7.2" 43 | db = {path = "./db"} 44 | color-eyre = "0.6.5" 45 | 46 | # The rustls-rustcrypto version must support the rustls version, and the 47 | # rustls version must match the one expected by reqwest; 48 | rustls = { version = "0.23", default-features = false, features = ["custom-provider"], optional = true } 49 | rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } 50 | 51 | serde_json = "1.0.114" 52 | serde = { version = "1.0.197", features = ["derive"] } 53 | syntect = { version = "5.2.0", default-features = false } 54 | webpki-roots = { version = "1.0.2", optional = true } 55 | tower = "0.5.2" 56 | bytes = "1.8.0" 57 | base64 = "0.22.1" 58 | 59 | [features] 60 | default = ["__rustcrypto-tls", "__syntect-fast"] 61 | no-c-deps = ["__rustcrypto-tls", "__syntect-rust"] 62 | 63 | __rustcrypto-tls = ["reqwest/rustls-tls-manual-roots-no-provider", "dep:rustls", "dep:rustls-rustcrypto", "webpki-roots"] 64 | __syntect-fast = ["syntect/default-onig"] 65 | __syntect-rust = ["syntect/default-fancy"] 66 | 67 | [profile.release] 68 | lto = true 69 | strip = true 70 | 71 | [dev-dependencies] 72 | tempfile = "3.13.0" 73 | -------------------------------------------------------------------------------- /templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% if args.title.as_ref().is_none() %} 6 | MicroBin 7 | {%- else %} 8 | {{ args.title.as_ref().unwrap() }} 9 | {%- endif %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% if !args.pure_html %} {% if args.custom_css.as_ref().is_none() || 17 | args.custom_css.as_ref().unwrap() == "" %} 18 | 19 | {%- else %} 20 | 21 | {%- endif %} {%- endif %} 22 | 23 | 24 | {% if args.wide %} 25 | 26 | 28 | {%- else %} 29 | 30 | 32 | {%- endif %} 33 |
34 | {% if !args.hide_header %} 35 | 36 | 59 | 60 | 61 | 62 | {%- endif %} -------------------------------------------------------------------------------- /src/util/auth.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::error_handling::AppError; 3 | use axum::extract::{Multipart, Request, State}; 4 | use axum::middleware::Next; 5 | use axum::response::Response; 6 | use base64::Engine; 7 | use base64::engine::general_purpose; 8 | use reqwest::StatusCode; 9 | 10 | pub async fn auth_validator( 11 | State(AppState { args, .. }): State, 12 | req: Request, 13 | next: Next, 14 | ) -> Result { 15 | let (username, password) = req 16 | .headers() 17 | .get("authorization") 18 | .and_then(|auth| auth.to_str().ok()) 19 | .and_then(|auth_str| { 20 | let parts: Vec<&str> = auth_str.splitn(2, ' ').collect(); 21 | if parts.len() == 2 && parts[0] == "Basic" { 22 | let decoded = general_purpose::STANDARD.decode(parts[1]).ok()?; 23 | let credentials = String::from_utf8(decoded).ok()?; 24 | let creds: Vec<&str> = credentials.splitn(2, ':').collect(); 25 | if creds.len() == 2 { 26 | Some((creds[0].to_string(), creds[1].to_string())) 27 | } else { 28 | None 29 | } 30 | } else { 31 | None 32 | } 33 | }) 34 | .map(|(user, password)| (Some(user), Some(password))) 35 | .unwrap_or((None, None)); 36 | if username.is_none() || password.is_none() { 37 | return Ok(Response::builder() 38 | .status(StatusCode::UNAUTHORIZED) 39 | .header("WWW-Authenticate", "Basic realm=\"Restricted Area\"") 40 | .body("Unauthorized".into())?); 41 | } 42 | 43 | if let Some(username) = username 44 | && username != args.auth_admin_username 45 | { 46 | return Ok(Response::builder() 47 | .status(StatusCode::FORBIDDEN) 48 | .body("Forbidden".into())?); 49 | } 50 | 51 | if let Some(password) = password 52 | && password != args.auth_admin_password 53 | { 54 | return Ok(Response::builder() 55 | .status(StatusCode::FORBIDDEN) 56 | .body("Forbidden".into())?); 57 | } 58 | 59 | Ok(next.run(req).await) 60 | } 61 | 62 | pub async fn password_from_multipart(mut payload: Multipart) -> Result { 63 | let mut password = String::new(); 64 | 65 | while let Some(field) = payload.next_field().await? { 66 | if field.name() == Some("password") { 67 | let password_bytes = field.bytes().await.unwrap_or(bytes::Bytes::new()); 68 | password = String::from_utf8_lossy(&password_bytes).to_string(); 69 | } 70 | } 71 | Ok(password) 72 | } 73 | -------------------------------------------------------------------------------- /templates/guide.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 | 3 |

Options

4 |
5 | 6 | 7 |

Expiration

8 |
9 |

10 | Use the expiration dropdown to choose how long you want your upload to exist. 11 | When the selected time has expired, it will be removed from the server. 12 |

13 | 14 | {% if args.enable_burn_after %} 15 | 16 |

Burn After

17 |
18 |

19 | Use the burn after dropdown to set a limit on how many times your data can be 20 | accessed before it will be removed from the server. 21 |

22 | {%- endif %} 23 | 24 | {% if args.highlightsyntax %} 25 | 26 |

Syntax Highlighting

27 |
28 |

29 | Use the syntax highlighting dropdown to enable syntax highlighting for your upload, making it easier to read. 30 | You may choose to have the syntax highlighting done by your browser, which 31 | will also recognise the language automatically. You can select server-side 32 | highlighting, where you need to select the language yourself, but the code 33 | will get highlighting without javascript. 34 |

35 | {%- endif %} 36 | 37 | 38 |

Password

39 |
40 |

41 | Use the password field to set a password for your upload. This will encrypt 42 | your data while stored on our server with your password, and you will need to 43 | enter the password to access (in case of private and secret uploads) or to 44 | modify (in case of read-only uploads). Your password is encrypted, and in case 45 | of secret uploads, we never even see it. 46 |

47 | 48 | 49 |

Privacy

50 |
51 |

52 | Use this dropdown to select the level of protection your upload needs. Use 53 | lower privacy levels if you or your organisation host MicroBin, and higher 54 | privacy levels if you are using a public MicroBin service. 55 |

56 |

Level 1: Public

57 |

This privacy level allows everyone to find, see, modify and remove your upload.

58 |

Level 2: Unlisted (recommended)

59 |

Unlisted uploads cannot be found unless someone knows its unique, random 60 | identifier. If someone knows this identifier, they can see, modify and remove 61 | the upload.

62 |

Level 3: Read-only

63 |

With this privacy setting, the upload cannot be found unless someone knows 64 | its unique, random identifier. If someone knows this identifier, they can see 65 | the contents but cannot modify or remove it without entering the password of 66 | the upload. 67 |

68 |

Level 4: Private

69 |

With this privacy setting, the upload cannot be found unless someone knows 70 | its unique, random identifier. If someone knows this identifier, they cannot 71 | see, modify or remove it without entering the password of the upload. Your 72 | upload and its attachments are encrypted, so they are stored safely.

73 |

Level 5: Secret

74 |

With this privacy setting, the upload cannot be found unless someone knows 75 | its unique, random identifier. If someone knows this identifier, they cannot 76 | see, modify or remove it without entering the password of the upload. Your 77 | browser sends us an already encrypted version, so the unencrypted data and 78 | password never even leave your device. This option requires you to enter your 79 | password many times when accessing your data, but is extremely safe.

80 | 81 | 82 | {% include "footer.html" %} -------------------------------------------------------------------------------- /templates/auth_upload.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 | 3 | {% if encrypt_client %} 4 | 5 |
6 | {% if status == "success" %} 7 | 8 | Success! 9 |
10 | {% endif %} 11 | 14 | 15 | 16 | 17 | 18 | {% if status == "incorrect" %} 19 | 20 | Incorrect password. 21 | 22 | {% endif %} 23 |
24 | 25 | 60 | 61 | {% else %} 62 | 63 |
64 | {% if status == "success" %} 65 | 66 | Success! 67 |
68 | {% endif %} 69 | 72 | 73 | 74 | 75 | {% if status == "incorrect" %} 76 | 77 | Incorrect password. 78 | 79 | {% endif %} 80 |
81 | 82 | 83 | 104 | 105 | {% endif %} 106 | 107 | {% include "footer.html" %} {% if !args.pure_html %} 108 | 119 | {% endif %} -------------------------------------------------------------------------------- /src/util/cleanup.rs: -------------------------------------------------------------------------------- 1 | use crate::error_handling::AppError; 2 | use crate::{args::Args, pasta::Pasta, util::misc::clean_up_expired_pastes}; 3 | use db::database::DatabaseType; 4 | use std::{fs, sync::Arc, thread, time::Duration}; 5 | 6 | pub fn start_cleanup_thread(db: &DatabaseType, args: Args) { 7 | let db = Arc::clone(db); 8 | thread::spawn(move || { 9 | log::info!("Started background cleanup thread - running immediately and then every hour"); 10 | 11 | loop { 12 | let pastas = match db.find_all_pastas() { 13 | Ok(pastas) => pastas.iter().map(Pasta::from).collect::>(), 14 | Err(e) => { 15 | log::error!("Failed to fetch pastas for cleanup: {}", e); 16 | thread::sleep(Duration::from_secs(60 * 60)); // 1 hour 17 | continue; 18 | } 19 | }; 20 | let count_before = pastas.len(); 21 | clean_up_expired_pastes(&args, &db).unwrap_or_else(|e| { 22 | log::error!("Failed to clean up expired pastas: {}", e); 23 | }); 24 | let count_after = pastas.len(); 25 | let removed_count = count_before - count_after; 26 | 27 | if removed_count > 0 { 28 | log::info!( 29 | "Background cleanup: removed {} expired paste(s)", 30 | removed_count 31 | ); 32 | } else { 33 | log::debug!("Background cleanup: no expired pastes found"); 34 | } 35 | 36 | if let Err(e) = cleanup_orphaned_files(&pastas, &args) { 37 | log::error!("Failed to cleanup paste: {}", e); 38 | } 39 | 40 | thread::sleep(Duration::from_secs(60 * 60)); // 1 hour 41 | } 42 | }); 43 | } 44 | 45 | // Clean up orphaned files (files without pasta references) 46 | pub fn cleanup_orphaned_files(pastas: &[Pasta], args: &Args) -> Result<(), AppError> { 47 | log::debug!("Starting orphaned files cleanup"); 48 | 49 | let attachments_dir = format!("{}/attachments", args.data_dir); 50 | let dirs = match fs::read_dir(&attachments_dir) { 51 | Ok(dirs) => dirs, 52 | Err(e) => { 53 | if e.kind() == std::io::ErrorKind::NotFound { 54 | fs::create_dir_all(&attachments_dir)?; 55 | return cleanup_orphaned_files(pastas, args); 56 | } 57 | log::debug!("No attachments directory found or unable to read: {}", e); 58 | return Err(e.into()); 59 | } 60 | }; 61 | 62 | let mut orphaned_count = 0; 63 | for dir_entry in dirs.flatten() { 64 | if !dir_entry 65 | .file_type() 66 | .map(|ft| ft.is_dir()) 67 | .map_err(AppError::from)? 68 | { 69 | continue; 70 | } 71 | 72 | let dir_name = dir_entry.file_name().to_string_lossy().to_string(); 73 | 74 | // Check if any pasta references this directory 75 | let is_referenced = pastas 76 | .iter() 77 | .any(|pasta| pasta.file.is_some() && pasta.id_as_animals(&args.hash_ids) == dir_name); 78 | 79 | if !is_referenced { 80 | log::debug!("Found orphaned directory: {}", dir_name); 81 | let full_path = dir_entry.path(); 82 | 83 | // Remove the entire directory and its contents 84 | if let Err(e) = fs::remove_dir_all(&full_path) { 85 | log::error!("Failed to remove orphaned directory {:?}: {}", full_path, e); 86 | } else { 87 | log::debug!("Removed orphaned directory: {:?}", full_path); 88 | orphaned_count += 1; 89 | } 90 | } 91 | } 92 | 93 | if orphaned_count > 0 { 94 | log::info!( 95 | "Orphaned files cleanup: removed {} orphaned directories", 96 | orphaned_count 97 | ); 98 | } else { 99 | log::debug!("Orphaned files cleanup: no orphaned files found"); 100 | } 101 | Ok(()) 102 | } 103 | -------------------------------------------------------------------------------- /src/endpoints/admin.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::error_handling::AppError; 4 | use crate::pasta::Pasta; 5 | use crate::util::version::{CURRENT_VERSION, Version, fetch_latest_version}; 6 | use askama::Template; 7 | use axum::Router; 8 | use axum::extract::{Multipart, State}; 9 | use axum::response::{IntoResponse, Response}; 10 | use axum::routing::{get, post}; 11 | use futures::TryStreamExt; 12 | use reqwest::{StatusCode, header}; 13 | 14 | #[derive(Template)] 15 | #[template(path = "admin.html")] 16 | struct AdminTemplate<'a> { 17 | pastas: &'a Vec, 18 | args: &'a Args, 19 | status: &'a String, 20 | version_string: &'a String, 21 | message: &'a String, 22 | update: &'a Option, 23 | } 24 | 25 | pub async fn get_admin( 26 | State(AppState { args, .. }): State, 27 | ) -> Result { 28 | Ok(( 29 | StatusCode::FOUND, 30 | [( 31 | header::LOCATION, 32 | format!("{}/auth_admin", &args.public_path_as_str()), 33 | )], 34 | "".to_string(), 35 | )) 36 | } 37 | 38 | pub async fn post_admin( 39 | State(AppState { args, db }): State, 40 | mut payload: Multipart, 41 | ) -> Result { 42 | let mut username = String::from(""); 43 | let mut password = String::from(""); 44 | 45 | while let Some(mut field) = payload.next_field().await? { 46 | if field.name() == Some("username") { 47 | while let Some(chunk) = field.try_next().await? { 48 | username.push_str( 49 | std::str::from_utf8(&chunk) 50 | .map_err(AppError::from)? 51 | .to_string() 52 | .as_str(), 53 | ); 54 | } 55 | } else if field.name() == Some("password") { 56 | while let Some(chunk) = field.try_next().await? { 57 | password.push_str( 58 | std::str::from_utf8(&chunk) 59 | .map_err(AppError::from)? 60 | .to_string() 61 | .as_str(), 62 | ); 63 | } 64 | } 65 | } 66 | 67 | if username != args.auth_admin_username || password != args.auth_admin_password { 68 | return Ok(( 69 | StatusCode::FOUND, 70 | [( 71 | header::LOCATION, 72 | format!("{}/auth_admin/incorrect", args.public_path_as_str()), 73 | )], 74 | "".to_string(), 75 | ) 76 | .into_response()); 77 | } 78 | 79 | let mut pastas = db 80 | .find_all_pastas()? 81 | .iter() 82 | .map(Pasta::from) 83 | .collect::>(); 84 | pastas.sort_by(|a, b| b.created.cmp(&a.created)); 85 | 86 | // todo status report more sophisticated 87 | let mut status = "OK"; 88 | let mut message = ""; 89 | 90 | if args.public_path.is_none() { 91 | status = "WARNING"; 92 | message = "Warning: No public URL set with --public-path parameter. QR code and URL Copying functions have been disabled" 93 | } 94 | 95 | if args.auth_admin_username == "admin" && args.auth_admin_password == "m1cr0b1n" { 96 | status = "WARNING"; 97 | message = "Warning: You are using the default admin login details. This is a security risk, please change them." 98 | } 99 | 100 | let update; 101 | 102 | if !args.disable_update_checking { 103 | let latest_version_res = fetch_latest_version().await; 104 | if let Ok(latest_version) = latest_version_res { 105 | if latest_version.newer_than_current() { 106 | update = Some(latest_version); 107 | } else { 108 | update = None; 109 | } 110 | } else { 111 | update = None; 112 | } 113 | } else { 114 | update = None; 115 | } 116 | 117 | Ok(( 118 | StatusCode::OK, 119 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 120 | AdminTemplate { 121 | pastas: &pastas, 122 | args: &args, 123 | status: &String::from(status), 124 | version_string: &format!("{}", CURRENT_VERSION.long_title), 125 | message: &String::from(message), 126 | update: &update, 127 | } 128 | .render()?, 129 | ) 130 | .into_response()) 131 | } 132 | 133 | pub fn admin_router() -> Router { 134 | Router::new() 135 | .route("/admin/", post(post_admin)) 136 | .route("/admin", get(get_admin)) 137 | } 138 | -------------------------------------------------------------------------------- /src/error_handling.rs: -------------------------------------------------------------------------------- 1 | use crate::pasta::Pasta; 2 | use axum::extract::multipart::MultipartError; 3 | use axum::http; 4 | use axum::response::{IntoResponse, Response}; 5 | use color_eyre::Report; 6 | use magic_crypt::MagicCryptError; 7 | use reqwest::StatusCode; 8 | use reqwest::header::InvalidHeaderValue; 9 | use std::fmt::Display; 10 | use std::str::Utf8Error; 11 | use std::sync::{MutexGuard, PoisonError}; 12 | 13 | #[derive(Debug)] 14 | pub struct AppError { 15 | pub message: String, 16 | pub code: StatusCode, 17 | } 18 | 19 | impl Display for AppError { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | write!(f, "AppError: {} (code: {})", self.message, self.code) 22 | } 23 | } 24 | 25 | impl AppError { 26 | pub fn bad_request(message: impl Into) -> AppError { 27 | AppError { 28 | message: message.into(), 29 | code: StatusCode::BAD_REQUEST, 30 | } 31 | } 32 | } 33 | 34 | impl From for AppError { 35 | fn from(error: Report) -> Self { 36 | log::warn!("Database error: {error}"); 37 | AppError { 38 | message: 39 | "An error occurred while accessing the database. Please check the server logs \ 40 | if you are the server admin" 41 | .to_string(), 42 | code: StatusCode::INTERNAL_SERVER_ERROR, 43 | } 44 | } 45 | } 46 | 47 | impl From for AppError { 48 | fn from(error: MagicCryptError) -> Self { 49 | log::warn!("MagicCrypt error: {error}"); 50 | AppError { 51 | message: "Encryption/Decryption error. Please check the server logs if you are the \ 52 | server admin" 53 | .to_string(), 54 | code: StatusCode::BAD_REQUEST, 55 | } 56 | } 57 | } 58 | 59 | impl From for AppError { 60 | fn from(error: reqwest::Error) -> Self { 61 | log::warn!("Request error during fetch: {error}"); 62 | AppError { 63 | message: "An error occurred while processing your request. Please check the server \ 64 | logs if you are the server admin" 65 | .to_string(), 66 | code: StatusCode::INTERNAL_SERVER_ERROR, 67 | } 68 | } 69 | } 70 | 71 | impl From for AppError { 72 | fn from(error: Utf8Error) -> Self { 73 | log::warn!("UTF-8 error: {error}"); 74 | AppError { 75 | message: "Invalid UTF-8 sequence. Please check the server logs if you are the \ 76 | server admin" 77 | .to_string(), 78 | code: StatusCode::BAD_REQUEST, 79 | } 80 | } 81 | } 82 | 83 | impl From for AppError { 84 | fn from(error: InvalidHeaderValue) -> Self { 85 | log::warn!("Invalid header value: {error}"); 86 | AppError { 87 | message: "Invalid header value. Please check the server logs if you are the \ 88 | server admin" 89 | .to_string(), 90 | code: StatusCode::BAD_REQUEST, 91 | } 92 | } 93 | } 94 | 95 | impl From for AppError { 96 | fn from(error: askama::Error) -> Self { 97 | AppError { 98 | message: format!("Template rendering error: {error}"), 99 | code: StatusCode::INTERNAL_SERVER_ERROR, 100 | } 101 | } 102 | } 103 | 104 | impl From>>> for AppError { 105 | fn from(error: PoisonError>>) -> Self { 106 | AppError { 107 | message: format!("Mutex error: {error}"), 108 | code: StatusCode::INTERNAL_SERVER_ERROR, 109 | } 110 | } 111 | } 112 | 113 | impl IntoResponse for AppError { 114 | fn into_response(self) -> Response { 115 | let body = self.message; 116 | (self.code, body).into_response() 117 | } 118 | } 119 | 120 | impl From for AppError { 121 | fn from(error: MultipartError) -> Self { 122 | AppError { 123 | message: format!("Multipart error: {error}"), 124 | code: StatusCode::BAD_REQUEST, 125 | } 126 | } 127 | } 128 | 129 | impl From for AppError { 130 | fn from(error: http::Error) -> Self { 131 | AppError { 132 | message: format!("HTTP error: {error}"), 133 | code: StatusCode::INTERNAL_SERVER_ERROR, 134 | } 135 | } 136 | } 137 | 138 | impl From for AppError { 139 | fn from(error: std::io::Error) -> Self { 140 | AppError { 141 | message: format!("IO error: {error}"), 142 | code: StatusCode::INTERNAL_SERVER_ERROR, 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/endpoints/file.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::error_handling::AppError; 3 | use crate::pasta::{Pasta, PastaFile}; 4 | use crate::util::auth; 5 | use crate::util::hashids::to_u64 as hashid_to_u64; 6 | use crate::util::{animalnumbers::to_u64, misc::decrypt_file}; 7 | use axum::extract::{Multipart, Path, State}; 8 | use axum::response::{IntoResponse, Response}; 9 | use db::entities::pasta::PastaEntity; 10 | use reqwest::StatusCode; 11 | use reqwest::header; 12 | use std::fs::File; 13 | use std::path::PathBuf; 14 | use tokio_util::io::ReaderStream; 15 | 16 | pub async fn post_secure_file( 17 | State(AppState { args, db }): State, 18 | Path(id): Path, 19 | payload: Multipart, 20 | ) -> Result { 21 | // get access to the pasta collection 22 | 23 | let password = auth::password_from_multipart(payload).await?; 24 | 25 | let id = if args.hash_ids { 26 | hashid_to_u64(&id).unwrap_or(0) 27 | } else { 28 | to_u64(&id).unwrap_or(0) 29 | }; 30 | 31 | let pasta_entity = match db.get_pasta(&id)? { 32 | Some(pasta) => Ok::(pasta), 33 | None => { 34 | return Ok(StatusCode::NOT_FOUND.into_response()); 35 | } 36 | }?; 37 | 38 | let pasta: Pasta = pasta_entity.into(); 39 | 40 | let pasta_file = match pasta.file { 41 | Some(ref pasta_file) => Ok::<&PastaFile, AppError>(pasta_file), 42 | None => { 43 | return Ok((StatusCode::NOT_FOUND).into_response()); 44 | } 45 | }?; 46 | 47 | let file = File::open(format!( 48 | "{}/attachments/{}/enc", 49 | &args.data_dir, 50 | pasta.id_as_animals(&args.hash_ids) 51 | ))?; 52 | 53 | // Not compatible with NamedFile from actix_files (it needs a File 54 | // to work therefore secure files do not support streaming 55 | let decrypted_data: Vec = decrypt_file(&password, &file)?; 56 | 57 | // Set the content type based on the file extension 58 | let content_type = mime_guess::from_path(&pasta_file.name) 59 | .first_or_octet_stream() 60 | .to_string(); 61 | 62 | // Create a response with the decrypted data 63 | let response = Response::builder() 64 | .status(StatusCode::OK) 65 | .header("Content-Type", content_type) 66 | .header( 67 | "Content-Disposition", 68 | format!("attachment; filename=\"{}\"", pasta_file.name()), 69 | ) 70 | .body(decrypted_data.into())?; 71 | Ok(response) 72 | } 73 | 74 | pub async fn get_file( 75 | Path(id): Path, 76 | State(AppState { args, db }): State, 77 | ) -> Result { 78 | let id_intern = if args.hash_ids { 79 | hashid_to_u64(&id).unwrap_or(0) 80 | } else { 81 | to_u64(&id).unwrap_or(0) 82 | }; 83 | 84 | let pasta_entity = match db.get_pasta(&id_intern)? { 85 | Some(pasta) => Ok::(pasta), 86 | None => { 87 | return Ok((StatusCode::NOT_FOUND).into_response()); 88 | } 89 | }?; 90 | 91 | let pasta: Pasta = pasta_entity.into(); 92 | 93 | let pasta_file = match pasta.file { 94 | Some(ref pasta_file) => Ok::<&PastaFile, AppError>(pasta_file), 95 | None => { 96 | return Ok(StatusCode::NOT_FOUND.into_response()); 97 | } 98 | }?; 99 | 100 | if pasta.encrypt_server { 101 | return Ok(( 102 | StatusCode::FOUND, 103 | [( 104 | header::LOCATION, 105 | format!("/auth_file/{}", pasta.id_as_animals(&args.hash_ids)), 106 | )], 107 | ) 108 | .into_response()); 109 | } 110 | 111 | // Construct the path to the file 112 | let file_path = format!( 113 | "{}/attachments/{}/{}", 114 | &args.data_dir, 115 | pasta.id_as_animals(&args.hash_ids), 116 | pasta_file.name() 117 | ); 118 | let file_path = PathBuf::from(file_path); 119 | 120 | // This will stream the file and set the content type based on the 121 | // file path 122 | let file = tokio::fs::File::open(&file_path).await?; 123 | let stream = ReaderStream::new(file); 124 | let body = axum::body::Body::from_stream(stream); 125 | let content_disposition = format!("attachment; filename=\"{}\"", pasta_file.name()); 126 | let response = Response::builder() 127 | .status(StatusCode::OK) 128 | .header( 129 | header::CONTENT_TYPE, 130 | mime_guess::from_path(&file_path) 131 | .first_or_octet_stream() 132 | .as_ref(), 133 | ) 134 | .header(header::CONTENT_DISPOSITION, &content_disposition) 135 | .body(body)?; 136 | // This takes care of streaming/seeking using the Range 137 | // header in the request. 138 | Ok(response.into_response()) 139 | } 140 | 141 | pub fn files_router() -> axum::Router { 142 | axum::Router::new() 143 | .route("/secure_file/{id}", axum::routing::post(post_secure_file)) 144 | .route("/file/{id}", axum::routing::get(get_file)) 145 | } 146 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: GitHub and Docker Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+* 7 | 8 | jobs: 9 | release: 10 | name: Publish to Github Relases 11 | outputs: 12 | rc: ${{ steps.check-tag.outputs.rc }} 13 | 14 | strategy: 15 | matrix: 16 | include: 17 | - target: aarch64-unknown-linux-musl 18 | os: ubuntu-latest 19 | use-cross: true 20 | cargo-flags: "" 21 | - target: aarch64-apple-darwin 22 | os: macos-latest 23 | use-cross: true 24 | cargo-flags: "" 25 | - target: x86_64-apple-darwin 26 | os: macos-latest 27 | cargo-flags: "" 28 | - target: x86_64-pc-windows-msvc 29 | os: windows-latest 30 | cargo-flags: "" 31 | - target: x86_64-unknown-linux-musl 32 | os: ubuntu-latest 33 | use-cross: true 34 | cargo-flags: "" 35 | - target: armv7-unknown-linux-musleabihf 36 | os: ubuntu-latest 37 | use-cross: true 38 | cargo-flags: "" 39 | - target: arm-unknown-linux-musleabihf 40 | os: ubuntu-latest 41 | use-cross: true 42 | cargo-flags: "" 43 | runs-on: ${{matrix.os}} 44 | 45 | steps: 46 | - uses: actions/checkout@v2 47 | 48 | - name: Check Tag 49 | id: check-tag 50 | shell: bash 51 | run: | 52 | tag=${GITHUB_REF##*/} 53 | echo "::set-output name=version::$tag" 54 | if [[ "$tag" =~ [0-9]+.[0-9]+.[0-9]+$ ]]; then 55 | echo "::set-output name=rc::false" 56 | else 57 | echo "::set-output name=rc::true" 58 | fi 59 | 60 | 61 | - name: Install Rust toolchain 62 | uses: dtolnay/rust-toolchain@stable 63 | with: 64 | components: clippy, rustfmt 65 | targets: ${{ matrix.target }} 66 | 67 | - name: Install OpenSSL 68 | if: runner.os == 'Linux' 69 | run: sudo apt-get install -y libssl-dev 70 | 71 | - name: Show Version Information (Rust, cargo, GCC) 72 | shell: bash 73 | run: | 74 | gcc --version || true 75 | rustup -V 76 | rustup toolchain list 77 | rustup default 78 | cargo -V 79 | rustc -V 80 | 81 | - name: Build 82 | uses: actions-rs/cargo@v1 83 | with: 84 | use-cross: ${{ matrix.use-cross }} 85 | command: build 86 | args: --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }} 87 | 88 | - name: Build Archive 89 | shell: bash 90 | id: package 91 | env: 92 | target: ${{ matrix.target }} 93 | version: ${{ steps.check-tag.outputs.version }} 94 | run: | 95 | set -euxo pipefail 96 | 97 | bin=${GITHUB_REPOSITORY##*/} 98 | src=`pwd` 99 | dist=$src/dist 100 | name=$bin-$version-$target 101 | executable=target/$target/release/$bin 102 | 103 | if [[ "$RUNNER_OS" == "Windows" ]]; then 104 | executable=$executable.exe 105 | fi 106 | 107 | mkdir $dist 108 | cp $executable $dist 109 | cd $dist 110 | 111 | if [[ "$RUNNER_OS" == "Windows" ]]; then 112 | archive=$dist/$name.zip 113 | 7z a $archive * 114 | echo "::set-output name=archive::`pwd -W`/$name.zip" 115 | else 116 | archive=$dist/$name.tar.gz 117 | tar czf $archive * 118 | echo "::set-output name=archive::$archive" 119 | fi 120 | 121 | - name: Publish Archive 122 | uses: softprops/action-gh-release@v0.1.15 123 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 124 | with: 125 | draft: false 126 | files: ${{ steps.package.outputs.archive }} 127 | prerelease: ${{ steps.check-tag.outputs.rc == 'true' }} 128 | env: 129 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 130 | 131 | docker: 132 | name: Publish to Docker Hub 133 | if: startsWith(github.ref, 'refs/tags/') 134 | runs-on: ubuntu-latest 135 | needs: release 136 | steps: 137 | - name: Docker meta 138 | id: meta 139 | uses: docker/metadata-action@v4 140 | with: 141 | images: ghcr.io/${{ github.repository }} 142 | tags: | 143 | type=semver,pattern={{version}} 144 | type=semver,pattern={{major}}.{{minor}} 145 | type=semver,pattern={{major}} 146 | - name: Set up QEMU 147 | uses: docker/setup-qemu-action@v1 148 | - name: Set up Docker Buildx 149 | uses: docker/setup-buildx-action@v1 150 | - name: Login to GitHub Container Registry 151 | uses: docker/login-action@v3 152 | with: 153 | registry: ghcr.io 154 | username: ${{ github.actor }} 155 | password: ${{ secrets.GITHUB_TOKEN }} 156 | - name: Build and push 157 | uses: docker/build-push-action@v2 158 | with: 159 | build-args: | 160 | REPO=${{ github.repository }} 161 | VER=${{ github.ref_name }} 162 | platforms: | 163 | linux/amd64 164 | linux/arm64 165 | push: ${{ github.ref_type == 'tag' }} 166 | tags: ${{ steps.meta.outputs.tags }} 167 | labels: ${{ steps.meta.outputs.labels }} 168 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | extern crate core; 2 | 3 | use crate::args::Args; 4 | use crate::endpoints::admin::admin_router; 5 | use crate::endpoints::auth_admin::auth_admin_router; 6 | use crate::endpoints::create::create_routes; 7 | use crate::endpoints::edit::edit_router; 8 | use crate::endpoints::errors::not_found; 9 | use crate::endpoints::file::files_router; 10 | use crate::endpoints::guide::guide_router; 11 | use crate::endpoints::list::list_router; 12 | use crate::endpoints::pasta::pasta_routes; 13 | use crate::endpoints::qr::qr_router; 14 | use crate::endpoints::remove::remove_router; 15 | use crate::endpoints::static_resources; 16 | use crate::pasta::Pasta; 17 | use crate::static_resources::static_resource_router; 18 | use crate::util::auth::auth_validator; 19 | use crate::util::cleanup::start_cleanup_thread; 20 | use crate::util::telemetry::start_telemetry_thread; 21 | use ::db::database::{Database, get_database}; 22 | use axum::extract::DefaultBodyLimit; 23 | use axum::{Router, middleware}; 24 | use chrono::Local; 25 | use clap::Parser; 26 | use env_logger::{Builder, Env}; 27 | use std::io::Write; 28 | use std::sync::Arc; 29 | use std::{env, fs}; 30 | use tower_http::normalize_path::NormalizePathLayer; 31 | 32 | pub mod args; 33 | mod error_handling; 34 | pub mod pasta; 35 | 36 | pub mod util { 37 | pub mod animalnumbers; 38 | pub mod auth; 39 | pub mod cleanup; 40 | pub mod hashids; 41 | pub mod http_client; 42 | pub mod misc; 43 | pub mod syntaxhighlighter; 44 | pub mod telemetry; 45 | pub mod version; 46 | } 47 | 48 | pub mod endpoints { 49 | pub mod admin; 50 | pub mod auth_admin; 51 | pub mod auth_upload; 52 | pub mod create; 53 | pub mod edit; 54 | pub mod errors; 55 | pub mod file; 56 | pub mod guide; 57 | pub mod list; 58 | pub mod pasta; 59 | pub mod qr; 60 | pub mod remove; 61 | pub mod static_resources; 62 | } 63 | 64 | #[derive(Clone)] 65 | pub struct AppState { 66 | pub args: Args, 67 | pub db: Arc, 68 | } 69 | 70 | #[tokio::main] 71 | async fn main() -> std::io::Result<()> { 72 | let args = Args::parse(); 73 | 74 | if env::var("MICROBIN_LOG").is_err() { 75 | unsafe { 76 | env::set_var("MICROBIN_LOG", "info"); 77 | } 78 | } 79 | 80 | let default_log_env = Env::new().filter_or("MICROBIN_LOG", "info"); 81 | 82 | Builder::from_env(default_log_env) 83 | .format(|buf, record| { 84 | writeln!( 85 | buf, 86 | "{} [{}] - {}", 87 | Local::now().format("%Y-%m-%dT%H:%M:%S"), 88 | record.level(), 89 | record.args() 90 | ) 91 | }) 92 | .init(); 93 | 94 | match fs::create_dir_all(format!("{}/public", args.data_dir)) { 95 | Ok(dir) => dir, 96 | Err(error) => { 97 | log::error!( 98 | "Couldn't create data directory {}/attachments/: {:?}", 99 | args.data_dir, 100 | error 101 | ); 102 | panic!( 103 | "Couldn't create data directory {}/attachments/: {:?}", 104 | args.data_dir, error 105 | ); 106 | } 107 | }; 108 | 109 | let db_args = args.clone(); 110 | let db = get_database(db_args.into()); 111 | let app_state = AppState { 112 | db, 113 | args: args.clone(), 114 | }; 115 | 116 | start_cleanup_thread(&app_state.db, args.clone()); 117 | 118 | let mut router = Router::new() 119 | .merge(create_routes()) 120 | .merge(admin_router()) 121 | .merge(edit_router()) 122 | .merge(files_router()) 123 | .merge(guide_router()) 124 | .merge(list_router()) 125 | .merge(pasta_routes()) 126 | .merge(qr_router()) 127 | .merge(remove_router()) 128 | .merge(static_resource_router()) 129 | .merge(auth_admin_router()) 130 | .fallback(not_found) 131 | .with_state(app_state.clone()); 132 | 133 | if args.enable_telemetry { 134 | start_telemetry_thread(&args); 135 | } 136 | 137 | if let Some(username) = args.auth_basic_username.as_ref() 138 | && username.trim() != "" 139 | { 140 | log::info!("Basic authentication is enabled."); 141 | router = router.layer(middleware::from_fn_with_state(app_state, auth_validator)); 142 | } 143 | 144 | let max_size = std::cmp::max( 145 | args.max_file_size_encrypted_mb, 146 | args.max_file_size_unencrypted_mb, 147 | ); 148 | let body_limit = (max_size + 10) * 1024 * 1024; // Add 10MB overhead for multipart encoding 149 | 150 | log::info!( 151 | "Configured file size limits - encrypted: {}MB, unencrypted: {}MB", 152 | args.max_file_size_encrypted_mb, 153 | args.max_file_size_unencrypted_mb 154 | ); 155 | log::info!( 156 | "Setting HTTP body limit to: {}MB ({} bytes)", 157 | (max_size + 10), 158 | body_limit 159 | ); 160 | log::info!("MicroBin starting on http://{}:{}", args.bind, args.port); 161 | 162 | let app = router 163 | .layer(DefaultBodyLimit::max(body_limit)) 164 | .layer(NormalizePathLayer::trim_trailing_slash()); 165 | let tcp = tokio::net::TcpListener::bind((args.bind, args.port)).await?; 166 | axum::serve(tcp, app).await?; 167 | Ok(()) 168 | } 169 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Note, this is a maintained fork of MicroBin. 2 | This simply provides a branch (dev) with many fixes from microbin main repository applied. 3 | ### list of patches/PR's applied 4 | - Fix JSON db losing already saved pasta's on crash/power failure #281 5 | - Updates dependencies #280 6 | - Adds a feature no-c-deps which makes microbin easy to crosscompile #279 7 | - Answer Range requests and stream files downloads #277 8 | - Fix default value comments for some environment variables #268 9 | - Fix privacyDropdown is null issue #267 10 | - Fix never expire condition #260 11 | - Attachments compatible with absolute path #255 12 | - Updates dependencies #254 13 | - Set charset=utf-8 for /raw/{id} response #246 14 | - Specify charset for non english words #244 15 | - fix: division by zero on 32-bit platform #239 16 | - Fix raw pastes returning 404 #218 17 | - Minor fixups #211 18 | - Prefix some URLs with args.public_path_as_str() #194 19 | - Checkbox to hide read counter per pasta #145 20 | - Moving from actix web to axum web framework for better integration with tokio and async/await 21 | - Fixed non-static status codes 22 | - Improved reliability of the server code. 23 | - Copying text to clipboard also in insecure contexts works 24 | 25 | 26 | ![Screenshot](.github/index.png) 27 | 28 | # MicroBin 29 | 30 | ![Build](https://github.com/szabodanika/microbin/actions/workflows/rust.yml/badge.svg) 31 | [![crates.io](https://img.shields.io/crates/v/microbin.svg)](https://crates.io/crates/microbin) 32 | [![Docker Image](https://github.com/szabodanika/microbin/actions/workflows/release.yml/badge.svg)](https://hub.docker.com/r/danielszabo99/microbin) 33 | [![Docker Pulls](https://img.shields.io/docker/pulls/danielszabo99/microbin?label=Docker%20pulls)](https://img.shields.io/docker/pulls/danielszabo99/microbin?label=Docker%20pulls) 34 | [![Support Server](https://img.shields.io/discord/662017309162078267.svg?color=7289da&label=Discord&logo=discord&style=flat-square)](https://discord.gg/3DsyTN7T) 35 | 36 | MicroBin is a super tiny, feature rich, configurable, self-contained and self-hosted paste bin web application. It is very easy to set up and use, and will only require a few megabytes of memory and disk storage. It takes only a couple minutes to set it up, why not give it a try now? 37 | 38 | ### Check out the Public Test Server at [pub.microbin.eu](https://pub.microbin.eu)! 39 | 40 | ### Or host MicroBin yourself 41 | 42 | Run our quick docker setup script ([DockerHub](https://hub.docker.com/r/uniquepwd/microbin)): 43 | ```bash 44 | bash <(curl -s https://microbin.eu/docker.sh) 45 | ``` 46 | 47 | Or install it manually from [Cargo](https://crates.io/crates/microbin): 48 | 49 | ```bash 50 | cargo install microbin; 51 | curl -L -O https://raw.githubusercontent.com/szabodanika/microbin/master/.env; 52 | source .env; 53 | microbin 54 | ``` 55 | 56 | On our website [microbin.eu](https://microbin.eu) you will find the following: 57 | 58 | - [Screenshots](https://microbin.eu/screenshots/) 59 | - [Guide and Documentation](https://microbin.eu/docs/intro) 60 | - [Donations and Sponsorships](https://microbin.eu/sponsorship) 61 | - [Roadmap](https://microbin.eu/roadmap) 62 | 63 | ## Features 64 | 65 | - Entirely self-contained executable, MicroBin is a single file! 66 | - Server-side and client-side encryption 67 | - File uploads (eg. `server.com/file/pig-dog-cat`) 68 | - Raw text serving (eg. `server.com/raw/pig-dog-cat`) 69 | - QR code support 70 | - URL shortening and redirection 71 | - Animal names instead of random numbers for upload identifiers (64 animals) 72 | - SQLite and JSON database support 73 | - Private and public, editable and uneditable, automatically and never expiring uploads 74 | - Automatic dark mode and custom styling support with very little CSS and only vanilla JS (see [`water.css`](https://github.com/kognise/water.css)) 75 | - And much more! 76 | 77 | ## What is an upload? 78 | 79 | In MicroBin, an upload can be: 80 | 81 | - A text that you want to paste from one machine to another, eg. some code, 82 | - A file that you want to share, eg. a video that is too large for Discord, a zip with a code project in it or an image, 83 | - A URL redirection. 84 | 85 | ## When is MicroBin useful? 86 | 87 | You can use MicroBin: 88 | 89 | - To send long texts to other people, 90 | - To send large files to other people, 91 | - To share secrets or sensitive documents securely, 92 | - As a URL shortener/redirect service, 93 | - To serve content on the web, eg. configuration files for testing, images, or any other file content using the Raw functionality, 94 | - To move files between your desktop and a server you access from the console, 95 | - As a "postbox" service where people can upload their files or texts, but they cannot see or remove what others sent you, 96 | - Or even to take quick notes. 97 | 98 | ...and many other things, why not get creative? 99 | 100 | ## Logging and Debugging 101 | 102 | MicroBin supports configurable logging levels for debugging and troubleshooting using the `MICROBIN_LOG` environment variable. 103 | 104 | ### Available Log Levels 105 | 106 | - `error` - Only error messages 107 | - `warn` - Warnings and errors 108 | - `info` - General information (default) 109 | - `debug` - Detailed debugging information 110 | - `trace` - Very verbose tracing (includes file upload progress) 111 | 112 | ### Examples 113 | 114 | ```bash 115 | # Show debug level logs 116 | export MICROBIN_LOG=debug 117 | 118 | # Show trace level logs (most verbose) 119 | export MICROBIN_LOG=trace 120 | 121 | # Show only info level logs from microbin modules 122 | export MICROBIN_LOG=microbin=info 123 | 124 | # Multiple modules with different levels 125 | export MICROBIN_LOG=microbin=debug,tower_http=info 126 | ``` 127 | 128 | ### Docker Usage 129 | 130 | Set the `MICROBIN_LOG` environment variable in your `compose.yaml` or docker run command: 131 | 132 | ```yaml 133 | environment: 134 | MICROBIN_LOG: debug 135 | ``` 136 | 137 | This is particularly useful for: 138 | - Troubleshooting file upload issues 139 | - Monitoring request handling 140 | - Debugging authentication problems 141 | - Analyzing performance issues 142 | 143 | MicroBin and MicroBin.eu are available under the [BSD 3-Clause License](LICENSE). 144 | 145 | © Dániel Szabó 2022-2023 146 | -------------------------------------------------------------------------------- /src/util/misc.rs: -------------------------------------------------------------------------------- 1 | use crate::Pasta; 2 | use crate::args::Args; 3 | use crate::error_handling::AppError; 4 | use db::database::DatabaseType; 5 | use linkify::{LinkFinder, LinkKind}; 6 | use magic_crypt::{MagicCryptTrait, new_magic_crypt}; 7 | use qrcode_generator::QrCodeEcc; 8 | use std::fs::{self, File}; 9 | use std::io::{BufReader, Read, Write}; 10 | use std::path::Path; 11 | use std::time::{SystemTime, UNIX_EPOCH}; 12 | 13 | pub fn clean_up_expired_pastes(args: &Args, db: &DatabaseType) -> Result<(), AppError> { 14 | // get current time - this will be needed to check which pastas have expired 15 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 16 | Ok(n) => n.as_secs(), 17 | Err(_) => { 18 | log::error!("SystemTime before UNIX EPOCH!"); 19 | 0 20 | } 21 | } as i64; 22 | 23 | let pastas = db 24 | .find_all_pastas()? 25 | .iter() 26 | .map(Pasta::from) 27 | .collect::>(); 28 | 29 | for p in pastas { 30 | // keep if: 31 | // expiration is `never` or not reached 32 | // AND 33 | // read count is less than burn limit, or no limit set 34 | // AND 35 | // has been read in the last N days where N is the arg --gc-days OR N is 0 (no GC) 36 | if (p.expiration == 0 || p.expiration > timenow) 37 | && (p.read_count < p.burn_after_reads || p.burn_after_reads == 0) 38 | && (p.last_read_days_ago() < args.gc_days || args.gc_days == 0) 39 | { 40 | // keep 41 | continue; 42 | } else { 43 | // remove from database 44 | db.delete_pasta(&p.id)?; 45 | log::debug!( 46 | "Removing expired pasta ID: {} ({})", 47 | p.id, 48 | p.id_as_animals(&args.hash_ids) 49 | ); 50 | if p.expiration > 0 && p.expiration <= timenow { 51 | log::debug!( 52 | " Reason: Expired (expiration: {}, now: {})", 53 | p.expiration, 54 | timenow 55 | ); 56 | } 57 | if p.burn_after_reads > 0 && p.read_count >= p.burn_after_reads { 58 | log::debug!( 59 | " Reason: Burn after reads limit reached ({}/{})", 60 | p.read_count, 61 | p.burn_after_reads 62 | ); 63 | } 64 | if args.gc_days > 0 && p.last_read_days_ago() >= args.gc_days { 65 | log::debug!( 66 | " Reason: GC limit reached (last read {} days ago, limit: {})", 67 | p.last_read_days_ago(), 68 | args.gc_days 69 | ); 70 | } 71 | 72 | db.delete_pasta(&p.id)?; 73 | 74 | // remove the file itself 75 | if let Some(file) = &p.file { 76 | let file_path = format!( 77 | "{}/attachments/{}/{}", 78 | args.data_dir, 79 | p.id_as_animals(&args.hash_ids), 80 | file.name() 81 | ); 82 | if let Err(e) = fs::remove_file(&file_path) { 83 | log::error!("Failed to delete file {}: {}", file_path, e); 84 | } else { 85 | log::debug!("Successfully deleted file: {}", file_path); 86 | } 87 | 88 | // and remove the containing directory 89 | let dir_path = format!( 90 | "{}/attachments/{}/", 91 | args.data_dir, 92 | p.id_as_animals(&args.hash_ids) 93 | ); 94 | if let Err(e) = fs::remove_dir(&dir_path) { 95 | log::error!("Failed to delete directory {}: {}", dir_path, e); 96 | } else { 97 | log::debug!("Successfully deleted directory: {}", dir_path); 98 | } 99 | } 100 | } 101 | } 102 | Ok(()) 103 | } 104 | 105 | pub fn string_to_qr_svg(str: &str) -> String { 106 | qrcode_generator::to_svg_to_string(str, QrCodeEcc::Low, 256, None::<&str>) 107 | .expect("should be able to generate QR code SVG if the string is not empty") 108 | } 109 | 110 | pub fn is_valid_url(url: &str) -> bool { 111 | let finder = LinkFinder::new(); 112 | let spans: Vec<_> = finder.spans(url).collect(); 113 | spans[0].as_str() == url && Some(&LinkKind::Url) == spans[0].kind() 114 | } 115 | 116 | pub fn encrypt(text_str: &str, key_str: &str) -> String { 117 | if text_str.is_empty() { 118 | return String::from(""); 119 | } 120 | 121 | let mc = new_magic_crypt!(key_str, 256); 122 | 123 | mc.encrypt_str_to_base64(text_str) 124 | } 125 | 126 | pub fn decrypt(text_str: &str, key_str: &str) -> Result { 127 | if text_str.is_empty() { 128 | return Ok(String::from("")); 129 | } 130 | 131 | let mc = new_magic_crypt!(key_str, 256); 132 | 133 | mc.decrypt_base64_to_string(text_str) 134 | } 135 | 136 | pub fn encrypt_file( 137 | passphrase: &str, 138 | input_file_path: &str, 139 | ) -> Result<(), Box> { 140 | // Read the input file into memory 141 | let file = File::open(input_file_path).expect("Tried to encrypt non-existent file"); 142 | let mut reader = BufReader::new(file); 143 | let mut input_data = Vec::new(); 144 | reader.read_to_end(&mut input_data)?; 145 | 146 | // Create a MagicCrypt instance with the given passphrase 147 | let mc = new_magic_crypt!(passphrase, 256); 148 | 149 | // Encrypt the input data 150 | let ciphertext = mc.encrypt_bytes_to_bytes(&input_data[..]); 151 | 152 | // Write the encrypted data to a new file with the .enc extension 153 | let mut f = File::create( 154 | Path::new(input_file_path) 155 | .with_file_name("data") 156 | .with_extension("enc"), 157 | )?; 158 | f.write_all(ciphertext.as_slice())?; 159 | 160 | // Delete the original input file 161 | // input_file.seek(SeekFrom::Start(0))?; 162 | fs::remove_file(input_file_path)?; 163 | 164 | Ok(()) 165 | } 166 | 167 | pub fn decrypt_file(passphrase: &str, input_file: &File) -> Result, AppError> { 168 | // Read the input file into memory 169 | let mut reader = BufReader::new(input_file); 170 | let mut ciphertext = Vec::new(); 171 | reader.read_to_end(&mut ciphertext)?; 172 | 173 | // Create a MagicCrypt instance with the given passphrase 174 | let mc = new_magic_crypt!(passphrase, 256); 175 | // Encrypt the input data 176 | let res = mc.decrypt_bytes_to_bytes(&ciphertext[..]); 177 | 178 | match res { 179 | Ok(data) => Ok(data), 180 | Err(e) => Err(AppError::from(e)), 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/endpoints/remove.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::endpoints::errors::ErrorTemplate; 3 | use crate::error_handling::AppError; 4 | use crate::pasta::{Pasta, PastaFile}; 5 | use crate::util::animalnumbers::to_u64; 6 | use crate::util::auth; 7 | use crate::util::hashids::to_u64 as hashid_to_u64; 8 | use crate::util::misc::decrypt; 9 | use askama::Template; 10 | use axum::Router; 11 | use axum::extract::{Multipart, Path, State}; 12 | use axum::http::StatusCode; 13 | use axum::response::IntoResponse; 14 | use axum::routing::{get, post}; 15 | use reqwest::header; 16 | use std::fs; 17 | 18 | pub async fn remove( 19 | State(AppState { args, db }): State, 20 | Path(id): Path, 21 | ) -> Result { 22 | let id = if args.hash_ids { 23 | hashid_to_u64(&id).unwrap_or(0) 24 | } else { 25 | to_u64(&id).unwrap_or(0) 26 | }; 27 | 28 | let opt_pasta = db.get_pasta(&id)?; 29 | 30 | let pasta: Pasta = match opt_pasta { 31 | Some(pasta) => Ok::(pasta.into()), 32 | None => { 33 | // otherwise, send pasta not found error 34 | return Ok(( 35 | StatusCode::OK, 36 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 37 | ErrorTemplate { args: &args }.render()?, 38 | ) 39 | .into_response()); 40 | } 41 | }?; 42 | 43 | // if it's encrypted or read-only, it needs a password to be deleted 44 | if pasta.encrypt_server || pasta.readonly { 45 | return Ok(( 46 | StatusCode::FOUND, 47 | [( 48 | header::LOCATION, 49 | format!( 50 | "{}/auth_remove_private/{}", 51 | args.public_path_as_str(), 52 | pasta.id_as_animals(&args.hash_ids) 53 | ), 54 | )], 55 | "".to_string(), 56 | ) 57 | .into_response()); 58 | } 59 | 60 | // remove the file itself 61 | if let Some(PastaFile { name, .. }) = &pasta.file { 62 | if fs::remove_file(format!( 63 | "{}/attachments/{}/{}", 64 | args.data_dir, 65 | pasta.id_as_animals(&args.hash_ids), 66 | name 67 | )) 68 | .is_err() 69 | { 70 | log::error!("Failed to delete file {}!", name) 71 | } 72 | 73 | // and remove the containing directory 74 | if fs::remove_dir(format!( 75 | "{}/attachments/{}/", 76 | args.data_dir, 77 | pasta.id_as_animals(&args.hash_ids) 78 | )) 79 | .is_err() 80 | { 81 | log::error!("Failed to delete directory {}!", name) 82 | } 83 | } 84 | 85 | db.delete_pasta(&id)?; 86 | 87 | Ok(( 88 | StatusCode::FOUND, 89 | [( 90 | header::LOCATION, 91 | format!("{}/list", args.public_path_as_str()), 92 | )], 93 | "".to_string(), 94 | ) 95 | .into_response()) 96 | } 97 | 98 | pub async fn post_remove( 99 | State(AppState { args, db }): State, 100 | Path(id): Path, 101 | payload: Multipart, 102 | ) -> Result { 103 | let id = if args.hash_ids { 104 | hashid_to_u64(&id).unwrap_or(0) 105 | } else { 106 | to_u64(&id).unwrap_or(0) 107 | }; 108 | 109 | let password = auth::password_from_multipart(payload).await; 110 | if password.is_err() { 111 | return Ok(( 112 | StatusCode::OK, 113 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 114 | ErrorTemplate { args: &args }.render()?, 115 | ) 116 | .into_response()); 117 | } 118 | 119 | let password_unwrapped = password?; 120 | 121 | let opt_pasta = db.get_pasta(&id)?; 122 | 123 | let pasta: Pasta = match opt_pasta { 124 | Some(pasta) => Ok::(pasta.into()), 125 | None => { 126 | // otherwise, send pasta not found error 127 | return Ok(( 128 | StatusCode::OK, 129 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 130 | ErrorTemplate { args: &args }.render()?, 131 | ) 132 | .into_response()); 133 | } 134 | }?; 135 | if pasta.readonly || pasta.encrypt_server { 136 | if password_unwrapped != *"" { 137 | let res = decrypt(pasta.content.to_owned().as_str(), &password_unwrapped); 138 | if res.is_ok() { 139 | // remove the file itself 140 | if let Some(PastaFile { name, .. }) = &pasta.file { 141 | if fs::remove_file(format!( 142 | "{}/attachments/{}/{}", 143 | args.data_dir, 144 | pasta.id_as_animals(&args.hash_ids), 145 | name 146 | )) 147 | .is_err() 148 | { 149 | log::error!("Failed to delete file {}!", name) 150 | } 151 | 152 | // and remove the containing directory 153 | if fs::remove_dir(format!( 154 | "{}/attachments/{}/", 155 | args.data_dir, 156 | pasta.id_as_animals(&args.hash_ids) 157 | )) 158 | .is_err() 159 | { 160 | log::error!("Failed to delete directory {}!", name) 161 | } 162 | } 163 | 164 | db.delete_pasta(&pasta.id)?; 165 | 166 | let res = ( 167 | StatusCode::FOUND, 168 | [( 169 | header::LOCATION, 170 | format!("{}/list", args.public_path_as_str()), 171 | )], 172 | "".to_string(), 173 | ) 174 | .into_response(); 175 | return Ok(res); 176 | } else { 177 | let res = ( 178 | StatusCode::FOUND, 179 | [( 180 | header::LOCATION, 181 | format!( 182 | "{}/auth_remove_private/{}/incorrect", 183 | args.public_path_as_str(), 184 | pasta.id_as_animals(&args.hash_ids) 185 | ), 186 | )], 187 | "".to_string(), 188 | ) 189 | .into_response(); 190 | return Ok(res); 191 | } 192 | } else { 193 | let res = ( 194 | StatusCode::FOUND, 195 | [( 196 | header::LOCATION, 197 | format!( 198 | "{}/auth_remove_private/{}", 199 | args.public_path_as_str(), 200 | pasta.id_as_animals(&args.hash_ids) 201 | ), 202 | )], 203 | "".to_string(), 204 | ) 205 | .into_response(); 206 | return Ok(res); 207 | } 208 | } 209 | 210 | let res = ( 211 | StatusCode::FOUND, 212 | [( 213 | header::LOCATION, 214 | format!( 215 | "{}/upload/{}", 216 | args.public_path_as_str(), 217 | pasta.id_as_animals(&args.hash_ids) 218 | ), 219 | )], 220 | "".to_string(), 221 | ) 222 | .into_response(); 223 | Ok(res) 224 | } 225 | 226 | pub fn remove_router() -> Router { 227 | Router::new() 228 | .route("/remove/{id}", post(post_remove)) 229 | .route("/remove/{id}", get(remove)) 230 | } 231 | -------------------------------------------------------------------------------- /db/src/db/json.rs: -------------------------------------------------------------------------------- 1 | use crate::database::Database; 2 | use crate::database_args::JSONDatabaseProperties; 3 | use crate::entities::pasta::PastaEntity; 4 | use eyre::{Context, Result}; 5 | use std::path::PathBuf; 6 | 7 | pub struct JsonDatabase { 8 | pub path: String, 9 | } 10 | 11 | impl JsonDatabase { 12 | pub fn new(args: JSONDatabaseProperties) -> Result { 13 | let path_to_use = PathBuf::from(args.file_path) 14 | .join(args.file_name) 15 | .to_str() 16 | .unwrap() 17 | .to_owned(); 18 | 19 | if !PathBuf::from(&path_to_use).exists() { 20 | // Create the directory if it does not exist 21 | if let Some(parent) = PathBuf::from(&path_to_use).parent() { 22 | std::fs::create_dir_all(parent)?; 23 | } 24 | // Create the file if it does not exist 25 | std::fs::File::create(&path_to_use)?; 26 | std::fs::write(&path_to_use, "[]")?; 27 | } 28 | 29 | std::fs::read_to_string(&path_to_use)?; 30 | Ok(Self { path: path_to_use }) 31 | } 32 | pub fn read_json(&self) -> Result> { 33 | let data = std::fs::read_to_string(&self.path)?; 34 | serde_json::from_str::>(&data) 35 | .wrap_err("Unable to deserialize json while reading pastas") 36 | } 37 | 38 | pub fn write_json(&self, pastas: &Vec) -> Result<()> { 39 | let data = serde_json::to_string(pastas)?; 40 | std::fs::write(&self.path, &data)?; 41 | Ok(()) 42 | } 43 | } 44 | 45 | impl Database for JsonDatabase { 46 | fn insert_pasta(&self, pasta: PastaEntity) -> Result<()> { 47 | let mut current_pastas = self.read_json()?; 48 | current_pastas.push(pasta); 49 | self.write_json(¤t_pastas)?; 50 | Ok(()) 51 | } 52 | 53 | fn find_all_pastas(&self) -> Result> { 54 | let pastas = self.read_json()?; 55 | Ok(pastas) 56 | } 57 | 58 | fn get_pasta(&self, id: &u64) -> Result> { 59 | let pastas = self.read_json()?; 60 | for pasta in pastas { 61 | if pasta.id == *id { 62 | return Ok(Some(pasta)); 63 | } 64 | } 65 | Ok(None) 66 | } 67 | 68 | fn update_pasta(&self, id: &u64, pasta_updated: PastaEntity) -> Result { 69 | let mut pastas = self.read_json()?; 70 | for pasta in pastas.iter_mut() { 71 | if pasta.id == *id { 72 | let old_id = pasta.id; 73 | *pasta = pasta_updated; 74 | pasta.id = old_id; // Ensure the ID remains the same 75 | let cloned_pasta = pasta.clone(); 76 | self.write_json(&pastas)?; 77 | return Ok(cloned_pasta); 78 | } 79 | } 80 | Err(eyre::eyre!("Pasta with ID {} not found", id)) 81 | } 82 | 83 | fn find_all_public_pastas(&self) -> Result> { 84 | let all_pastas = self.find_all_pastas()?; 85 | Ok(all_pastas 86 | .into_iter() 87 | .filter(|pasta| !pasta.private) 88 | .collect()) 89 | } 90 | 91 | fn delete_pasta(&self, id: &u64) -> Result<()> { 92 | let mut pastas = self.read_json()?; 93 | pastas.retain(|pasta| pasta.id != *id); 94 | self.write_json(&pastas)?; 95 | Ok(()) 96 | } 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | 103 | #[test] 104 | fn test_create_and_insert_pasta() { 105 | let db = JsonDatabase::new( 106 | super::super::super::test_utils::test_util::create_test_json_db_properties(), 107 | ) 108 | .expect( 109 | "Failed to \ 110 | create \ 111 | Json database", 112 | ); 113 | 114 | let pasta = super::super::super::test_utils::test_util::create_random_pasta_entity(); 115 | 116 | db.insert_pasta(pasta.clone()) 117 | .expect("Failed to insert pasta"); 118 | let pastas = db.find_all_pastas().expect("Failed to find all pastas"); 119 | assert_eq!(pastas.len(), 1); 120 | 121 | let retrieved_pasta = db.get_pasta(&pasta.id).expect("Failed to get pasta"); 122 | assert!(retrieved_pasta.is_some()); 123 | } 124 | 125 | #[test] 126 | fn test_create_and_insert_update_pasta() { 127 | let db = JsonDatabase::new( 128 | super::super::super::test_utils::test_util::create_test_json_db_properties(), 129 | ) 130 | .expect( 131 | "Failed to \ 132 | create \ 133 | Json database", 134 | ); 135 | let pasta = super::super::super::test_utils::test_util::create_random_pasta_entity(); 136 | 137 | db.insert_pasta(pasta.clone()) 138 | .expect("Failed to insert pasta"); 139 | let pastas = db.find_all_pastas().expect("Failed to find all pastas"); 140 | assert_eq!(pastas.len(), 1); 141 | 142 | let retrieved_pasta = db.get_pasta(&pasta.id).expect("Failed to get pasta"); 143 | let retrieved_pasta = retrieved_pasta.expect("Pasta should exist"); 144 | assert_eq!(retrieved_pasta.id, pasta.id); 145 | 146 | let updated_pasta = 147 | super::super::super::test_utils::test_util::create_random_pasta_entity(); 148 | db.update_pasta(&pasta.id, updated_pasta.clone()) 149 | .expect("Failed to update pasta"); 150 | let updated_retrieved_pasta = db 151 | .get_pasta(&pasta.id) 152 | .expect("Failed to get pasta after update"); 153 | let updated_retrieved_pasta = 154 | updated_retrieved_pasta.expect("Pasta should exist after update"); 155 | assert_eq!(updated_retrieved_pasta.id, retrieved_pasta.id); 156 | assert_ne!(retrieved_pasta.content, updated_retrieved_pasta.content); 157 | } 158 | 159 | #[test] 160 | fn test_find_all_public_pastas() { 161 | let db = JsonDatabase::new( 162 | super::super::super::test_utils::test_util::create_test_json_db_properties(), 163 | ) 164 | .expect( 165 | "Failed to \ 166 | create \ 167 | Json database", 168 | ); 169 | let mut pasta1 = super::super::super::test_utils::test_util::create_random_pasta_entity(); 170 | pasta1.private = true; 171 | let pasta2 = super::super::super::test_utils::test_util::create_random_pasta_entity(); 172 | let pasta3 = super::super::super::test_utils::test_util::create_random_pasta_entity(); 173 | 174 | db.insert_pasta(pasta1.clone()) 175 | .expect("Failed to insert pasta1"); 176 | db.insert_pasta(pasta2.clone()) 177 | .expect("Failed to insert pasta2"); 178 | db.insert_pasta(pasta3.clone()) 179 | .expect("Failed to insert pasta3"); 180 | 181 | let public_pastas = db 182 | .find_all_public_pastas() 183 | .expect("Failed to find all public pastas"); 184 | let all_pastas = db.find_all_pastas().expect("Failed to find all pastas"); 185 | assert_eq!(public_pastas.len(), 2); 186 | assert!(public_pastas.iter().any(|p| p.id == pasta2.id)); 187 | assert!(public_pastas.iter().any(|p| p.id == pasta3.id)); 188 | assert_eq!(all_pastas.len(), 3); 189 | assert!(all_pastas.iter().any(|p| p.id == pasta1.id)); 190 | assert!(all_pastas.iter().any(|p| p.id == pasta2.id)); 191 | assert!(all_pastas.iter().any(|p| p.id == pasta3.id)); 192 | } 193 | 194 | #[test] 195 | fn test_delete_pasta() { 196 | let db = JsonDatabase::new( 197 | super::super::super::test_utils::test_util::create_test_json_db_properties(), 198 | ) 199 | .expect( 200 | "Failed to \ 201 | create \ 202 | Json database", 203 | ); 204 | let pasta = super::super::super::test_utils::test_util::create_random_pasta_entity(); 205 | 206 | db.insert_pasta(pasta.clone()) 207 | .expect("Failed to insert pasta"); 208 | let pastas = db.find_all_pastas().expect("Failed to find all pastas"); 209 | assert_eq!(pastas.len(), 1); 210 | 211 | db.delete_pasta(&pasta.id).expect("Failed to delete pasta"); 212 | let pastas_after_delete = db 213 | .find_all_pastas() 214 | .expect("Failed to find all pastas after delete"); 215 | assert_eq!(pastas_after_delete.len(), 0); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /templates/list.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 | 3 | 4 | {% if pastas.is_empty() %} 5 |
6 |

7 | No uploads yet. 😔 Create one here. 8 |

9 |
10 | {%- else %} 11 |

Uploads

12 |
13 | {% if args.pure_html %} 14 | 15 | {% else %} 16 |
17 | {% endif %} 18 | 19 | 22 | 24 | 27 | 30 | 33 | 34 | 36 | 38 | 39 | 40 | {% for pasta in pastas %} 41 | {% if pasta.pasta_type == "text" && !pasta.private %} 42 | 43 | 48 | 59 | 62 | 65 | 83 | 84 | 90 | 93 | 94 | {%- endif %} 95 | {% endfor %} 96 | 97 |
20 | Key 21 | 23 | 25 | Created 26 | 28 | Expiration 29 | 31 | Contents 32 | 35 | 37 |
44 | {{pasta 46 | .id_as_animals(args.hash_ids)}} 47 | 49 | {% if args.public_path_as_str() != "" %} 50 | {% if args.short_path_as_str() == "" %} 51 | Copy 53 | {% else %} 54 | Copy 56 | {% endif %} 57 | {%- endif %} 58 | 60 | {{pasta.created_as_string()}} 61 | 63 | {{pasta.expiration_as_string()}} 64 | 66 | {% if pasta.content != "" %} 67 | Text 69 | {%- endif %} 70 | {% if pasta.file.is_some() %} 71 | 73 | {% if pasta.file.as_ref().unwrap().is_image() %} 74 | Image 75 | {%- else if pasta.file.as_ref().unwrap().is_video() %} 76 | Video 77 | {%- else %} 78 | File 79 | {%- endif %} 80 | 81 | {%- endif %} 82 | 85 | {% if pasta.editable %} 86 | Edit 88 | {%- endif %} 89 | 91 | Remove 92 |
98 |
99 |

URL Redirects

100 | {% if args.pure_html %} 101 | 102 | {% else %} 103 |
104 | {% endif %} 105 | 106 | 109 | 111 | 114 | 117 | 119 | 121 | 123 | 124 | {% for pasta in pastas %} 125 | {% if pasta.pasta_type == "url" && !pasta.private %} 126 | 127 | 132 | 141 | 144 | 147 | 151 | 157 | 160 | 161 | {%- endif %} 162 | {% endfor %} 163 | 164 |
107 | Key 108 | 110 | 112 | Created 113 | 115 | Expiration 116 | 118 | 120 | 122 |
128 | {{pasta 130 | .id_as_animals(args.hash_ids)}} 131 | 133 | {% if args.short_path_as_str() == "" %} 134 | Copy 136 | {% else %} 137 | Copy 139 | {% endif %} 140 | 142 | {{pasta.created_as_string()}} 143 | 145 | {{pasta.expiration_as_string()}} 146 | 148 | Redirect 150 | 152 | {% if pasta.editable %} 153 | Edit 155 | {%- endif %} 156 | 158 | Remove 159 |
165 |
166 | {%- endif %} 167 |
168 | 169 | 185 | 199 | 200 | {% include "footer.html" %} -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use db::database_args::DatabaseArgs; 3 | use serde::Serialize; 4 | use std::convert::Infallible; 5 | use std::fmt; 6 | use std::net::IpAddr; 7 | use std::str::FromStr; 8 | 9 | #[derive(Parser, Debug, Clone, Serialize)] 10 | #[clap(author, version, about, long_about = None)] 11 | pub struct Args { 12 | #[clap(long, env = "MICROBIN_BASIC_AUTH_USERNAME")] 13 | pub auth_basic_username: Option, 14 | 15 | #[clap(long, env = "MICROBIN_BASIC_AUTH_PASSWORD")] 16 | pub auth_basic_password: Option, 17 | 18 | #[clap(long, env = "MICROBIN_ADMIN_USERNAME", default_value = "admin")] 19 | pub auth_admin_username: String, 20 | 21 | #[clap(long, env = "MICROBIN_ADMIN_PASSWORD", default_value = "m1cr0b1n")] 22 | pub auth_admin_password: String, 23 | 24 | #[clap(long, env = "MICROBIN_EDITABLE")] 25 | pub editable: bool, 26 | 27 | #[clap(long, env = "MICROBIN_FOOTER_TEXT")] 28 | pub footer_text: Option, 29 | 30 | #[clap(long, env = "MICROBIN_HIDE_FOOTER")] 31 | pub hide_footer: bool, 32 | 33 | #[clap(long, env = "MICROBIN_HIDE_HEADER")] 34 | pub hide_header: bool, 35 | 36 | #[clap(long, env = "MICROBIN_HIDE_LOGO")] 37 | pub hide_logo: bool, 38 | 39 | #[clap(long, env = "MICROBIN_NO_LISTING")] 40 | pub no_listing: bool, 41 | 42 | #[clap(long, env = "MICROBIN_HIGHLIGHTSYNTAX")] 43 | pub highlightsyntax: bool, 44 | 45 | #[clap(short, long, env = "MICROBIN_PORT", default_value_t = 8080)] 46 | pub port: u16, 47 | 48 | #[clap(short, long, env="MICROBIN_BIND", default_value_t = IpAddr::from([0, 0, 0, 0]))] 49 | pub bind: IpAddr, 50 | 51 | #[clap(long, env = "MICROBIN_PRIVATE")] 52 | pub private: bool, 53 | 54 | #[clap(long, env = "MICROBIN_PURE_HTML")] 55 | pub pure_html: bool, 56 | 57 | #[clap(long, env = "MICROBIN_JSON_DB")] 58 | pub json_db: bool, 59 | 60 | #[clap(long, env = "MICROBIN_IN_MEMORY_DB")] 61 | pub in_memory_db: bool, 62 | 63 | #[clap(long, env = "MICROBIN_PUBLIC_PATH")] 64 | pub public_path: Option, 65 | 66 | #[clap(long, env = "MICROBIN_SHORT_PATH")] 67 | pub short_path: Option, 68 | 69 | #[clap(long, env = "MICROBIN_UPLOADER_PASSWORD")] 70 | pub uploader_password: Option, 71 | 72 | #[clap(long, env = "MICROBIN_READONLY")] 73 | pub readonly: bool, 74 | 75 | #[clap(long, env = "MICROBIN_SHOW_READ_STATS")] 76 | pub show_read_stats: bool, 77 | 78 | #[clap(long, env = "MICROBIN_TITLE")] 79 | pub title: Option, 80 | 81 | #[clap(short, long, env = "MICROBIN_THREADS", default_value_t = 1)] 82 | pub threads: u8, 83 | 84 | #[clap(short, long, env = "MICROBIN_GC_DAYS", default_value_t = 90)] 85 | pub gc_days: u16, 86 | 87 | #[clap(long, env = "MICROBIN_ENABLE_BURN_AFTER")] 88 | pub enable_burn_after: bool, 89 | 90 | #[clap(short, long, env = "MICROBIN_DEFAULT_BURN_AFTER", default_value_t = 0)] 91 | pub default_burn_after: u16, 92 | 93 | #[clap(long, env = "MICROBIN_WIDE")] 94 | pub wide: bool, 95 | 96 | #[clap(long, env = "MICROBIN_QR")] 97 | pub qr: bool, 98 | 99 | #[clap(long, env = "MICROBIN_ETERNAL_PASTA")] 100 | pub eternal_pasta: bool, 101 | 102 | #[clap(long, env = "MICROBIN_ENABLE_READONLY")] 103 | pub enable_readonly: bool, 104 | 105 | #[clap(long, env = "MICROBIN_DEFAULT_EXPIRY", default_value = "24hour")] 106 | pub default_expiry: String, 107 | 108 | #[clap(long, env = "MICROBIN_DATA_DIR", default_value = "microbin_data")] 109 | pub data_dir: String, 110 | 111 | #[clap(short, long, env = "MICROBIN_NO_FILE_UPLOAD")] 112 | pub no_file_upload: bool, 113 | 114 | #[clap(long, env = "MICROBIN_CUSTOM_CSS")] 115 | pub custom_css: Option, 116 | 117 | #[clap(long, env = "MICROBIN_HASH_IDS")] 118 | pub hash_ids: bool, 119 | 120 | #[clap(long, env = "MICROBIN_LIST_SERVER")] 121 | pub list_server: bool, 122 | 123 | #[clap(long, env = "MICROBIN_ENABLE_TELEMETRY")] 124 | pub enable_telemetry: bool, 125 | 126 | #[clap( 127 | long, 128 | env = "MICROBIN_TELEMETRY_URL", 129 | default_value = "https://api.microbin.eu/telemetry/" 130 | )] 131 | pub telemetry_url: String, 132 | 133 | #[clap(long, env = "MICROBIN_DISABLE_UPDATE_CHECKING")] 134 | pub disable_update_checking: bool, 135 | 136 | #[clap(long, env = "MICROBIN_ENCRYPTION_CLIENT_SIDE")] 137 | pub encryption_client_side: bool, 138 | 139 | #[clap(long, env = "MICROBIN_ENCRYPTION_SERVER_SIDE")] 140 | pub encryption_server_side: bool, 141 | 142 | #[clap( 143 | long, 144 | env = "MICROBIN_MAX_FILE_SIZE_ENCRYPTED_MB", 145 | default_value_t = 256 146 | )] 147 | pub max_file_size_encrypted_mb: usize, 148 | 149 | #[clap( 150 | long, 151 | env = "MICROBIN_MAX_FILE_SIZE_UNENCRYPTED_MB", 152 | default_value_t = 2048 153 | )] 154 | pub max_file_size_unencrypted_mb: usize, 155 | } 156 | 157 | impl From for DatabaseArgs { 158 | fn from(val: Args) -> Self { 159 | if val.json_db { 160 | DatabaseArgs::JSONDatabaseProperties(db::database_args::JSONDatabaseProperties { 161 | file_path: val.data_dir.clone(), 162 | file_name: String::from("pasta.json"), 163 | }) 164 | } else { 165 | DatabaseArgs::SqliteProperties(db::database_args::SqliteProperties { 166 | db_path: format!("{}/database.sqlite", val.data_dir), 167 | in_memory: false, 168 | }) 169 | } 170 | } 171 | } 172 | 173 | impl Args { 174 | pub fn public_path_as_str(&self) -> String { 175 | if let Some(public_path) = self.public_path.as_ref() { 176 | public_path.to_string() 177 | } else { 178 | String::from("") 179 | } 180 | } 181 | 182 | pub fn short_path_as_str(&self) -> String { 183 | if let Some(short_path) = self.short_path.as_ref() { 184 | short_path.to_string() 185 | } else if let Some(public_path) = self.public_path.as_ref() { 186 | public_path.to_string() 187 | } else { 188 | String::from("") 189 | } 190 | } 191 | 192 | pub fn without_secrets(self) -> Args { 193 | Args { 194 | auth_basic_username: None, 195 | auth_basic_password: None, 196 | auth_admin_username: String::from(""), 197 | auth_admin_password: String::from(""), 198 | editable: self.editable, 199 | footer_text: self.footer_text, 200 | hide_footer: self.hide_footer, 201 | hide_header: self.hide_header, 202 | hide_logo: self.hide_logo, 203 | no_listing: self.no_listing, 204 | highlightsyntax: self.highlightsyntax, 205 | port: self.port, 206 | bind: self.bind, 207 | private: self.private, 208 | pure_html: self.pure_html, 209 | json_db: self.json_db, 210 | public_path: self.public_path, 211 | short_path: self.short_path, 212 | uploader_password: None, 213 | readonly: self.readonly, 214 | show_read_stats: self.show_read_stats, 215 | title: self.title, 216 | list_server: self.list_server, 217 | threads: self.threads, 218 | gc_days: self.gc_days, 219 | enable_burn_after: self.enable_burn_after, 220 | default_burn_after: self.default_burn_after, 221 | wide: self.wide, 222 | qr: self.qr, 223 | eternal_pasta: self.eternal_pasta, 224 | enable_readonly: self.enable_readonly, 225 | default_expiry: self.default_expiry, 226 | data_dir: String::from(""), 227 | no_file_upload: self.no_file_upload, 228 | custom_css: self.custom_css, 229 | hash_ids: self.hash_ids, 230 | enable_telemetry: self.enable_telemetry, 231 | telemetry_url: self.telemetry_url, 232 | encryption_client_side: self.encryption_client_side, 233 | encryption_server_side: self.encryption_server_side, 234 | max_file_size_encrypted_mb: self.max_file_size_encrypted_mb, 235 | max_file_size_unencrypted_mb: self.max_file_size_unencrypted_mb, 236 | disable_update_checking: self.disable_update_checking, 237 | in_memory_db: self.in_memory_db, 238 | } 239 | } 240 | } 241 | 242 | #[derive(Debug, Clone, Serialize)] 243 | pub struct PublicUrl(pub String); 244 | 245 | impl fmt::Display for PublicUrl { 246 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 247 | write!(f, "{}", self.0) 248 | } 249 | } 250 | 251 | impl FromStr for PublicUrl { 252 | type Err = Infallible; 253 | 254 | fn from_str(s: &str) -> Result { 255 | let uri = s.strip_suffix('/').unwrap_or(s).to_owned(); 256 | Ok(PublicUrl(uri)) 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Require username for HTTP Basic Authentication when 2 | # visiting the service. If basic auth username is set but 3 | # basic auth password is not, just leave the password field 4 | # empty when logging in. You can also just go to 5 | # https://username:password@yourserver.net or 6 | # https://username@yourserver.net if password is not set 7 | # instead of typing into the password 8 | # Default value: unset 9 | # export MICROBIN_BASIC_AUTH_USERNAME= 10 | 11 | # Require password for HTTP Basic Authentication when 12 | # visiting the service. Will not have any affect unless 13 | # basic auth username is also set. If basic auth username is 14 | # set but basic auth password is not, just leave the 15 | # password field empty when logging in. You can also just go 16 | # to https://username:password@yourserver.net or 17 | # https://username@yourserver.net if password is not set 18 | # instead of typing into the password prompt. 19 | # Default value: unset 20 | # export MICROBIN_BASIC_AUTH_PASSWORD= 21 | 22 | # Enables administrator interface at yourserver.com/admin/ 23 | # if set, disables it if unset. If admin username is set but 24 | # admin password is not, just leave the password field empty 25 | # when logging in. 26 | # Default value: admin 27 | export MICROBIN_ADMIN_USERNAME=admin 28 | 29 | # Enables administrator interface at yourserver.com/admin/ 30 | # if set, disables it if unset. Will not have any affect 31 | # unless admin username is also set. If admin username is 32 | # set but admin password is not, just leave the password 33 | # field empty when logging in. 34 | # Default value: m1cr0b1n 35 | export MICROBIN_ADMIN_PASSWORD=m1cr0b1n 36 | 37 | # Enables editable pastas. You will still be able to make 38 | # finalised pastas but there will be an extra checkbox to 39 | # make your new pasta editable from the pasta list or the 40 | # pasta view page. 41 | # Default value: true 42 | export MICROBIN_EDITABLE=true 43 | 44 | # Replaces the default footer text with your own. If you 45 | # want to hide the footer, use the hide footer option instead. 46 | # Note that you can also embed HTML here, so you may want to escape 47 | # '<', '>' and so on. 48 | # export MICROBIN_FOOTER_TEXT= 49 | 50 | # Hides the navigation bar on every page. 51 | # Default value: false 52 | export MICROBIN_HIDE_HEADER=false 53 | 54 | # Hides the footer on every page. 55 | # Default value: false 56 | export MICROBIN_HIDE_FOOTER=false 57 | 58 | # Hides the MicroBin logo from the navigation bar on every 59 | # page. 60 | # Default value: false 61 | export MICROBIN_HIDE_LOGO=false 62 | 63 | # Disables the /pastalist endpoint, essentially making all 64 | # pastas private. 65 | # Default value: false 66 | export MICROBIN_NO_LISTING=false 67 | 68 | # Enables syntax highlighting support. When creating a new 69 | # pasta, a new dropdown selector will be added where you can 70 | # select your pasta's syntax, or just leave it empty for no 71 | # highlighting. 72 | export MICROBIN_HIGHLIGHTSYNTAX=true 73 | 74 | # Sets the port for the server will be listening on. 75 | # Default value: 8080 76 | export MICROBIN_PORT=8080 77 | 78 | # Sets the bind address for the server will be listening on. 79 | # Both ipv4 and ipv6 are supported. Default value: "0.0.0.0". 80 | # Example value: "myserver.net", "127.0.0.1". 81 | export MICROBIN_BIND="0.0.0.0" 82 | 83 | # Enables private pastas. Adds a new checkbox to make your 84 | # pasta private, which then won't show up on the pastalist 85 | # page. With the URL to your pasta, it will still be 86 | # accessible. 87 | # Default value: true 88 | export MICROBIN_PRIVATE=true 89 | 90 | # DEPRECATED: Will be removed soon. If you want to change styling (incl. removal), use custom CSS variable instead. 91 | # Disables main CSS styling, just uses a few in-line 92 | # stylings for the layout. With this option you will lose 93 | # dark-mode support. 94 | export MICROBIN_PURE_HTML=false 95 | 96 | # Sets the name of the directory where MicroBin creates 97 | # its database and stores attachments. 98 | # Default value: microbin_data 99 | export MICROBIN_DATA_DIR="microbin_data" 100 | 101 | # Enables storing pasta data (not attachments and files) in 102 | # a JSON file instead of the SQLite database. 103 | # Default value: false 104 | export MICROBIN_JSON_DB=false 105 | 106 | # Add the given public path prefix to all urls. This allows 107 | # you to host MicroBin behind a reverse proxy on a subpath. 108 | # Note that MicroBin itself still expects all routes to be 109 | # as without this option, and thus is unsuited if you are 110 | # running MicroBin directly. Default value: unset. Example 111 | # values: https://myserver.com/ or https://192.168.0.10:8080/ 112 | # export MICROBIN_PUBLIC_PATH= 113 | 114 | # Sets a shortened path to use when the user copies URL from 115 | # the application. This will also use shorter endpoints, 116 | # such as /p/ instead if /pasta/. Default value: 117 | # unset.Example value: https://b.in/ export 118 | # MICROBIN_SHORT_PATH= 119 | 120 | # The password required for uploading, if read-only mode is enabled 121 | # Default value: unset 122 | # export MICROBIN_UPLOADER_PASSWORD= 123 | 124 | # If set to true, authentication required for uploading 125 | # Default value: false 126 | export MICROBIN_READONLY=false 127 | 128 | # Enables showing read count on pasta pages. 129 | # Default value: false 130 | export MICROBIN_SHOW_READ_STATS=true 131 | 132 | # Adds your title of choice to the 133 | # navigation bar. 134 | # Default value: unset 135 | # export MICROBIN_TITLE= 136 | 137 | # Number of workers MicroBin is allowed to have. Increase 138 | # this to the number of CPU cores you have if you want to go 139 | # beast mode, but for personal use one worker is enough. 140 | # Default value: 1. 141 | export MICROBIN_THREADS=1 142 | 143 | # Sets the garbage collector time limit. Pastas not accessed 144 | # for N days are removed even if they are set to never 145 | # expire. 146 | # Default value: 90. 147 | # To turn off GC: 0. 148 | export MICROBIN_GC_DAYS=90 149 | 150 | # Enables or disables the "Burn after" function 151 | # Default value: false 152 | export MICROBIN_ENABLE_BURN_AFTER=true 153 | 154 | # Sets the default burn after setting on the main screen. 155 | # Default value: 0. Available expiration options: 1, 10, 156 | # 100, 1000, 10000, 0 (= no limit) 157 | export MICROBIN_DEFAULT_BURN_AFTER=0 158 | 159 | # Changes the maximum width of the UI from 720 pixels to 160 | # 1080 pixels. 161 | # Default value: false 162 | export MICROBIN_WIDE=false 163 | 164 | # Enables generating QR codes for pastas. Requires 165 | # the public path to also be set. 166 | # Default value: false 167 | export MICROBIN_QR=true 168 | 169 | # Toggles "Never" expiry settings for pastas. Default 170 | # value: false 171 | export MICROBIN_ETERNAL_PASTA=false 172 | 173 | # Enables "Read-only" uploads. These are unlisted and 174 | # unencrypted, but can be viewed without password if you 175 | # have the URL. Editing and removing requires password. 176 | # Default value: true 177 | export MICROBIN_ENABLE_READONLY=true 178 | 179 | # Sets the default expiry time setting on the main screen. 180 | # Default value: 24hour Available expiration options: 1min, 181 | # 10min, 1hour, 24hour, 1week, never 182 | export MICROBIN_DEFAULT_EXPIRY=24hour 183 | 184 | # Disables and hides the file upload option in the UI. 185 | # Default value: false 186 | export MICROBIN_NO_FILE_UPLOAD=false 187 | 188 | # Replaced the built-in water.css stylesheet with the URL 189 | # you provide. Default value: unset. Example value: 190 | # https://myserver.net/public/mystyle.css 191 | # export MICROBIN_CUSTOM_CSS= 192 | 193 | # Use short hash strings in the URLs instead of animal names 194 | # to make URLs shorter. Does not change the underlying data 195 | # stored, just how pastas are recalled. 196 | # Default value: false 197 | export MICROBIN_HASH_IDS=false 198 | 199 | # Enables server-side encryption. This will add private 200 | # privacy level, where the user sends plain unencrypted data 201 | # (still secure, because you use HTTPS, right?), but the 202 | # server sees everything that the user submits, therefore 203 | # the user does not have complete and absolute protection. 204 | # Default value: false 205 | export MICROBIN_ENCRYPTION_CLIENT_SIDE=true 206 | 207 | # Enables client-side encryption. This will add the secret 208 | # privacy level where the user's browser encrypts all data 209 | # with JavaScript before sending it over to MicroBin, which 210 | # encrypt the data once again on server side. 211 | # Default value: false 212 | export MICROBIN_ENCRYPTION_SERVER_SIDE=true 213 | 214 | # Limit the maximum file size users can upload without 215 | # encryption. Default value: 256. 216 | export MICROBIN_MAX_FILE_SIZE_ENCRYPTED_MB=256 217 | 218 | # Limit the maximum file size users can upload with 219 | # encryption (more strain on your server than without 220 | # encryption, so the limit should be lower. Secrets tend to 221 | # be tiny files usually anyways.) Default value: 2048. 222 | export MICROBIN_MAX_FILE_SIZE_UNENCRYPTED_MB=2048 223 | 224 | # Disables the feature that checks for available updates 225 | # when opening the admin screen. 226 | # Default value: false 227 | export MICROBIN_DISABLE_UPDATE_CHECKING=false 228 | 229 | # Enables telemetry if set to true. Telemetry is now OPT-IN for privacy. 230 | # Telemetry includes your configuration and helps development. 231 | # It does not include any sensitive data. 232 | # Default value: false (disabled by default) 233 | # export MICROBIN_ENABLE_TELEMETRY=false 234 | 235 | # Custom telemetry server URL. Only used if telemetry is enabled. 236 | # Set this to your own telemetry server to avoid sending data to microbin.eu 237 | # Default value: https://api.microbin.eu/telemetry/ 238 | # Examples: 239 | # MICROBIN_TELEMETRY_URL=https://your-server.com/telemetry/ 240 | # MICROBIN_TELEMETRY_URL=http://localhost:8090/telemetry/ 241 | # export MICROBIN_TELEMETRY_URL=https://api.microbin.eu/telemetry/ 242 | 243 | # Enables listing your server in the public MicroBin server list. 244 | # Default value: false 245 | export MICROBIN_LIST_SERVER=false 246 | 247 | 248 | # Controls the log level for debugging and troubleshooting. 249 | # Useful levels: error, warn, info, debug, trace 250 | # You can also target specific modules, e.g., microbin=debug 251 | # Examples: 252 | # MICROBIN_LOG=info - Show info level logs and above 253 | # MICROBIN_LOG=debug - Show debug level logs and above 254 | # MICROBIN_LOG=trace - Show all logs (most verbose) 255 | # MICROBIN_LOG=microbin=debug - Only debug logs from microbin modules 256 | # Default value: info (set in the application) 257 | # export MICROBIN_LOG=info 258 | -------------------------------------------------------------------------------- /src/pasta.rs: -------------------------------------------------------------------------------- 1 | use crate::util::animalnumbers::to_animal_names; 2 | use crate::util::hashids::to_hashids; 3 | use crate::util::syntaxhighlighter::html_highlight; 4 | use bytesize::ByteSize; 5 | use chrono::{Datelike, Local, TimeZone, Timelike}; 6 | use db::entities::pasta::PastaEntity; 7 | use serde::{Deserialize, Serialize}; 8 | use std::fmt; 9 | use std::path::Path; 10 | use std::time::{SystemTime, UNIX_EPOCH}; 11 | 12 | #[derive(Serialize, Deserialize, PartialEq, Debug, Eq, Clone)] 13 | pub struct PastaFile { 14 | pub name: String, 15 | pub size: ByteSize, 16 | } 17 | 18 | impl PastaFile { 19 | pub fn from_unsanitized(path: &str) -> Result { 20 | let path = Path::new(path); 21 | let name = path.file_name().ok_or("Path did not contain a file name")?; 22 | let name = name.to_string_lossy().replace(' ', "_"); 23 | Ok(Self { 24 | name, 25 | size: ByteSize::b(0), 26 | }) 27 | } 28 | 29 | pub fn name(&self) -> &str { 30 | &self.name 31 | } 32 | 33 | pub fn is_image(&self) -> bool { 34 | let lowercase_name = self.name.to_lowercase(); 35 | let extensions = [ 36 | ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico", ".svg", ".tiff", ".tif", 37 | ".jfif", ".pjpeg", ".pjp", ".avif", ".jxl", ".heif", 38 | ]; 39 | extensions.iter().any(|&ext| lowercase_name.ends_with(ext)) 40 | } 41 | 42 | pub fn is_video(&self) -> bool { 43 | let lowercase_name = self.name.to_lowercase(); 44 | let extensions = [ 45 | ".mp4", ".mov", ".wmv", ".webm", ".avi", ".flv", ".mkv", ".mts", 46 | ]; 47 | extensions.iter().any(|&ext| lowercase_name.ends_with(ext)) 48 | } 49 | 50 | pub fn embeddable(&self) -> bool { 51 | self.is_image() || self.is_video() 52 | } 53 | } 54 | 55 | #[derive(Serialize, Deserialize, Debug, Clone)] 56 | pub struct Pasta { 57 | pub id: u64, 58 | pub content: String, 59 | pub file: Option, 60 | pub extension: String, 61 | pub private: bool, 62 | pub readonly: bool, 63 | pub editable: bool, 64 | pub hide_read_count: bool, 65 | pub encrypt_server: bool, 66 | pub encrypt_client: bool, 67 | pub encrypted_key: Option, 68 | pub created: i64, 69 | pub expiration: i64, 70 | pub last_read: i64, 71 | pub read_count: u64, 72 | pub burn_after_reads: u64, 73 | pub pasta_type: String, 74 | } 75 | 76 | impl From for PastaEntity { 77 | fn from(pasta: Pasta) -> Self { 78 | PastaEntity { 79 | id: pasta.id, 80 | content: pasta.content, 81 | file_name: pasta.file.clone().map(|f| f.name), 82 | file_size: pasta.file.map(|f| f.size.as_u64()).map(|u| u as i64), 83 | extension: pasta.extension, 84 | read_only: pasta.readonly, 85 | private: pasta.private, 86 | editable: i32::from(pasta.editable), 87 | encrypt_server: i32::from(pasta.encrypt_server), 88 | encrypt_client: i32::from(pasta.encrypt_client), 89 | encrypted_key: pasta.encrypted_key, 90 | created: pasta.created, 91 | expiration: pasta.expiration, 92 | last_read: pasta.last_read, 93 | read_count: pasta.read_count as i64, 94 | burn_after_reads: pasta.burn_after_reads as i64, 95 | pasta_type: pasta.pasta_type, 96 | hide_read_count: pasta.hide_read_count, 97 | } 98 | } 99 | } 100 | 101 | impl From<&PastaEntity> for Pasta { 102 | fn from(value: &PastaEntity) -> Self { 103 | let cloned_pasta_entity = value.clone(); 104 | Pasta::from(cloned_pasta_entity) 105 | } 106 | } 107 | 108 | impl From for Pasta { 109 | fn from(pasta: PastaEntity) -> Self { 110 | Pasta { 111 | id: pasta.id, 112 | content: pasta.content, 113 | file: pasta.file_name.map(|name| PastaFile { 114 | name, 115 | size: ByteSize::b(pasta.file_size.unwrap_or(0) as u64), 116 | }), 117 | extension: pasta.extension, 118 | private: pasta.private, 119 | readonly: pasta.read_only, 120 | editable: pasta.editable != 0, 121 | hide_read_count: pasta.hide_read_count, 122 | encrypt_server: pasta.encrypt_server != 0, 123 | encrypt_client: pasta.encrypt_client != 0, 124 | encrypted_key: pasta.encrypted_key, 125 | created: pasta.created, 126 | expiration: pasta.expiration, 127 | last_read: pasta.last_read, 128 | read_count: pasta.read_count as u64, 129 | burn_after_reads: pasta.burn_after_reads as u64, 130 | pasta_type: pasta.pasta_type, 131 | } 132 | } 133 | } 134 | 135 | impl Pasta { 136 | pub fn id_as_animals(&self, hash_ids: &bool) -> String { 137 | if *hash_ids { 138 | to_hashids(self.id) 139 | } else { 140 | to_animal_names(self.id) 141 | } 142 | } 143 | 144 | pub fn has_file(&self) -> bool { 145 | self.file.is_some() 146 | } 147 | 148 | pub fn total_size_as_string(&self) -> String { 149 | let total_size_bytes = if let Some(file) = &self.file { 150 | file.size.as_u64() as usize + self.content.len() 151 | } else { 152 | self.content.len() 153 | }; 154 | 155 | if total_size_bytes < 1024 { 156 | format!("{total_size_bytes} B") 157 | } else if total_size_bytes < 1024 * 1024 { 158 | format!("{} KB", total_size_bytes / 1024) 159 | } else if total_size_bytes < 1024 * 1024 * 1024 { 160 | format!("{} MB", total_size_bytes / (1024 * 1024)) 161 | } else { 162 | format!("{} GB", total_size_bytes / (1024 * 1024 * 1024)) 163 | } 164 | } 165 | 166 | pub fn file_embeddable(&self) -> bool { 167 | let first_file_result = match self.file { 168 | Some(ref file) => file.embeddable(), 169 | None => false, 170 | }; 171 | 172 | first_file_result && !(self.encrypt_server || self.encrypt_client) 173 | } 174 | 175 | pub fn created_as_string(&self) -> String { 176 | Local 177 | .timestamp_opt(self.created, 0) 178 | .map(|date| { 179 | format!( 180 | "{:02}-{:02} {:02}:{:02}", 181 | date.month(), 182 | date.day(), 183 | date.hour(), 184 | date.minute(), 185 | ) 186 | }) 187 | .earliest() 188 | .unwrap_or_else(|| { 189 | log::error!("Failed to process created date"); 190 | String::from("Unknow") 191 | }) 192 | } 193 | 194 | pub fn expiration_as_string(&self) -> String { 195 | if self.expiration == 0 { 196 | String::from("Never") 197 | } else { 198 | Local 199 | .timestamp_opt(self.expiration, 0) 200 | .map(|date| { 201 | format!( 202 | "{:02}-{:02} {:02}:{:02}", 203 | date.month(), 204 | date.day(), 205 | date.hour(), 206 | date.minute(), 207 | ) 208 | }) 209 | .earliest() 210 | .unwrap_or_else(|| { 211 | log::error!("Failed to process expiration"); 212 | String::from("Never") 213 | }) 214 | } 215 | } 216 | 217 | pub fn last_read_time_ago_as_string(&self) -> String { 218 | // get current unix time in seconds 219 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 220 | Ok(n) => n.as_secs(), 221 | Err(_) => { 222 | log::error!("SystemTime before UNIX EPOCH!"); 223 | 0 224 | } 225 | } as i64; 226 | 227 | // get seconds since last read and convert it to days 228 | let days = ((timenow - self.last_read) / 86400) as u16; 229 | if days > 1 { 230 | return format!("{days} days ago"); 231 | }; 232 | 233 | // it's less than 1 day, let's do hours then 234 | let hours = ((timenow - self.last_read) / 3600) as u16; 235 | if hours > 1 { 236 | return format!("{hours} hours ago"); 237 | }; 238 | 239 | // it's less than 1 hour, let's do minutes then 240 | let minutes = ((timenow - self.last_read) / 60) as u16; 241 | if minutes > 1 { 242 | return format!("{minutes} minutes ago"); 243 | }; 244 | 245 | // it's less than 1 minute, let's do seconds then 246 | let seconds = (timenow - self.last_read) as u16; 247 | if seconds > 1 { 248 | return format!("{seconds} seconds ago"); 249 | }; 250 | 251 | // it's less than 1 second????? 252 | String::from("just now") 253 | } 254 | 255 | pub fn short_last_read_time_ago_as_string(&self) -> String { 256 | // get current unix time in seconds 257 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 258 | Ok(n) => n.as_secs(), 259 | Err(_) => { 260 | log::error!("SystemTime before UNIX EPOCH!"); 261 | 0 262 | } 263 | } as i64; 264 | 265 | // get seconds since last read and convert it to days 266 | let days = ((timenow - self.last_read) / 86400) as u16; 267 | if days > 1 { 268 | return format!("{days} d ago"); 269 | }; 270 | 271 | // it's less than 1 day, let's do hours then 272 | let hours = ((timenow - self.last_read) / 3600) as u16; 273 | if hours > 1 { 274 | return format!("{hours} h ago"); 275 | }; 276 | 277 | // it's less than 1 hour, let's do minutes then 278 | let minutes = ((timenow - self.last_read) / 60) as u16; 279 | if minutes > 1 { 280 | return format!("{minutes} m ago"); 281 | }; 282 | 283 | // it's less than 1 minute, let's do seconds then 284 | let seconds = (timenow - self.last_read) as u16; 285 | if seconds > 1 { 286 | return format!("{seconds} s ago"); 287 | }; 288 | 289 | // it's less than 1 second????? 290 | String::from("just now") 291 | } 292 | 293 | pub fn last_read_days_ago(&self) -> u16 { 294 | // get current unix time in seconds 295 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 296 | Ok(n) => n.as_secs(), 297 | Err(_) => { 298 | log::error!("SystemTime before UNIX EPOCH!"); 299 | 0 300 | } 301 | } as i64; 302 | 303 | // get seconds since last read and convert it to days 304 | ((timenow - self.last_read) / 86400) as u16 305 | } 306 | 307 | pub fn content_syntax_highlighted(&self) -> String { 308 | html_highlight(&self.content, &self.extension) 309 | } 310 | 311 | pub fn content_not_highlighted(&self) -> String { 312 | html_highlight(&self.content, "txt") 313 | } 314 | 315 | pub fn content_escaped(&self) -> String { 316 | html_escape::encode_text( 317 | &self 318 | .content 319 | .replace('\\', "\\\\") 320 | .replace('`', "\\`") 321 | .replace('$', "\\$"), 322 | ) 323 | .to_string() 324 | } 325 | } 326 | 327 | impl fmt::Display for Pasta { 328 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 329 | write!(f, "{}", self.content) 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /db/src/db/sqlite.rs: -------------------------------------------------------------------------------- 1 | pub use crate::database::Database; 2 | use crate::database_args::SqliteProperties; 3 | use crate::entities::pasta::PastaEntity; 4 | use eyre::Result; 5 | use r2d2_sqlite::SqliteConnectionManager; 6 | use rusqlite::params; 7 | 8 | pub struct SqLite { 9 | pub pool: r2d2::Pool, 10 | } 11 | 12 | pub static INSERT_QUERY: &str = r#"INSERT INTO pasta ( 13 | id, 14 | content, 15 | file_name, 16 | file_size, 17 | extension, 18 | private, 19 | read_only, 20 | editable, 21 | encrypt_server, 22 | encrypt_client, 23 | encrypted_key, 24 | created, 25 | expiration, 26 | last_read, 27 | read_count, 28 | burn_after_reads, 29 | pasta_type, 30 | hide_read_count 31 | ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18 32 | )"#; 33 | 34 | pub static CREATE_TABLE_QUERY: &str = r#" 35 | CREATE TABLE IF NOT EXISTS pasta ( 36 | id INTEGER PRIMARY KEY, 37 | content TEXT NOT NULL, 38 | file_name TEXT, 39 | file_size INTEGER, 40 | extension TEXT NOT NULL, 41 | read_only INTEGER NOT NULL, 42 | private INTEGER NOT NULL, 43 | editable INTEGER NOT NULL, 44 | encrypt_server INTEGER NOT NULL, 45 | encrypt_client INTEGER NOT NULL, 46 | encrypted_key TEXT, 47 | created INTEGER NOT NULL, 48 | expiration INTEGER NOT NULL, 49 | last_read INTEGER NOT NULL, 50 | read_count INTEGER NOT NULL, 51 | burn_after_reads INTEGER NOT NULL, 52 | pasta_type TEXT NOT NULL, 53 | hide_read_count INTEGER NOT NULL 54 | );"#; 55 | 56 | pub static SELECT_ALL_QUERY: &str = r#"SELECT * FROM pasta ORDER BY created ASC"#; 57 | 58 | impl SqLite { 59 | pub fn new(args: SqliteProperties) -> Result { 60 | let manager = if args.in_memory { 61 | SqliteConnectionManager::memory() 62 | } else { 63 | SqliteConnectionManager::file(args.db_path) 64 | }; 65 | 66 | let pool = r2d2::Pool::new(manager).expect("db pool"); 67 | let conn = pool.get().expect("should get connection from pool"); 68 | conn.execute(CREATE_TABLE_QUERY, [])?; 69 | Ok(SqLite { pool }) 70 | } 71 | } 72 | 73 | impl Database for SqLite { 74 | fn insert_pasta(&self, pasta: PastaEntity) -> Result<()> { 75 | self.pool 76 | .get() 77 | .expect("should get connection from pool") 78 | .execute( 79 | INSERT_QUERY, 80 | params![ 81 | pasta.id, 82 | pasta.content, 83 | pasta.file_name, 84 | pasta.file_size, 85 | pasta.extension, 86 | pasta.private, 87 | pasta.read_only, 88 | pasta.editable, 89 | pasta.encrypt_server, 90 | pasta.encrypt_client, 91 | pasta.encrypted_key, 92 | pasta.created, 93 | pasta.expiration, 94 | pasta.last_read, 95 | pasta.read_count, 96 | pasta.burn_after_reads, 97 | pasta.pasta_type, 98 | pasta.hide_read_count 99 | ], 100 | )?; 101 | 102 | Ok(()) 103 | } 104 | 105 | fn find_all_pastas(&self) -> Result> { 106 | let pool = self.pool.get().expect("should get connection from pool"); 107 | 108 | let mut stmt = pool.prepare(SELECT_ALL_QUERY)?; 109 | let pasta_iter = stmt 110 | .query_map([], |row| Ok(PastaEntity::from(row))) 111 | .expect("Failed to query pastas"); 112 | 113 | Ok(pasta_iter 114 | .map(|r| r.expect("Failed to get pasta")) 115 | .collect::>()) 116 | } 117 | 118 | fn get_pasta(&self, id: &u64) -> Result> { 119 | let pool = self.pool.get().expect("should get connection from pool"); 120 | let mut stmt = pool.prepare( 121 | "SELECT\ 122 | * FROM pasta WHERE id = ?1", 123 | )?; 124 | let result_from_query = stmt.query_one(params![id], |row| Ok(PastaEntity::from(row))); 125 | match result_from_query { 126 | Ok(pasta) => Ok(Some(pasta)), 127 | Err(e) => match e { 128 | rusqlite::Error::QueryReturnedNoRows => Ok(None), 129 | e => Err(e.into()), 130 | }, 131 | } 132 | } 133 | 134 | fn update_pasta(&self, id: &u64, pasta: PastaEntity) -> Result { 135 | let pool = self.pool.get().expect("should get connection from pool"); 136 | let mut stmt = pool.prepare( 137 | "UPDATE pasta SET content = ?1, file_name = ?2, file_size = ?3, extension = ?4, private = ?5, read_only = ?6, editable = ?7, encrypt_server = ?8, encrypt_client = ?9, encrypted_key = ?10, created = ?11, expiration = ?12, last_read = ?13, read_count = ?14, burn_after_reads = ?15, pasta_type = ?16 WHERE id = ?17" 138 | )?; 139 | 140 | stmt.execute(params![ 141 | pasta.content, 142 | pasta.file_name, 143 | pasta.file_size, 144 | pasta.extension, 145 | pasta.private, 146 | pasta.read_only, 147 | pasta.editable, 148 | pasta.encrypt_server, 149 | pasta.encrypt_client, 150 | pasta.encrypted_key, 151 | pasta.created, 152 | pasta.expiration, 153 | pasta.last_read, 154 | pasta.read_count, 155 | pasta.burn_after_reads, 156 | pasta.pasta_type, 157 | id 158 | ])?; 159 | 160 | Ok(pasta) 161 | } 162 | 163 | fn find_all_public_pastas(&self) -> Result> { 164 | let pool = self.pool.get().expect("should get connection from pool"); 165 | let mut stmt = pool.prepare( 166 | "SELECT * FROM pasta WHERE private = 0 ORDER BY created \ 167 | ASC", 168 | )?; 169 | let pasta_iter = stmt 170 | .query_map([], |row| Ok(PastaEntity::from(row))) 171 | .expect("Failed to query public pastas"); 172 | 173 | Ok(pasta_iter 174 | .map(|r| r.expect("Failed to get public pasta")) 175 | .collect::>()) 176 | } 177 | 178 | fn delete_pasta(&self, id: &u64) -> Result<()> { 179 | let pool = self.pool.get().expect("should get connection from pool"); 180 | let mut stmt = pool.prepare("DELETE FROM pasta WHERE id = ?1")?; 181 | stmt.execute(params![id])?; 182 | Ok(()) 183 | } 184 | } 185 | 186 | #[cfg(test)] 187 | mod tests { 188 | use super::*; 189 | 190 | #[test] 191 | fn test_create_and_insert_pasta() { 192 | let db = SqLite::new( 193 | super::super::super::test_utils::test_util::create_test_sqlite_properties(), 194 | ) 195 | .expect( 196 | "Failed to \ 197 | create \ 198 | SQLite database", 199 | ); 200 | 201 | let pasta = super::super::super::test_utils::test_util::create_random_pasta_entity(); 202 | 203 | db.insert_pasta(pasta.clone()) 204 | .expect("Failed to insert pasta"); 205 | let pastas = db.find_all_pastas().expect("Failed to find all pastas"); 206 | assert_eq!(pastas.len(), 1); 207 | 208 | let retrieved_pasta = db.get_pasta(&pasta.id).expect("Failed to get pasta"); 209 | assert!(retrieved_pasta.is_some()); 210 | } 211 | 212 | #[test] 213 | fn test_create_and_insert_update_pasta() { 214 | let db = SqLite::new( 215 | super::super::super::test_utils::test_util::create_test_sqlite_properties(), 216 | ) 217 | .expect("Failed to create SQLite database"); 218 | 219 | let pasta = super::super::super::test_utils::test_util::create_random_pasta_entity(); 220 | 221 | db.insert_pasta(pasta.clone()) 222 | .expect("Failed to insert pasta"); 223 | let pastas = db.find_all_pastas().expect("Failed to find all pastas"); 224 | assert_eq!(pastas.len(), 1); 225 | 226 | let retrieved_pasta = db.get_pasta(&pasta.id).expect("Failed to get pasta"); 227 | let retrieved_pasta = retrieved_pasta.expect("Pasta should exist"); 228 | assert_eq!(retrieved_pasta.id, pasta.id); 229 | 230 | let updated_pasta = 231 | super::super::super::test_utils::test_util::create_random_pasta_entity(); 232 | db.update_pasta(&pasta.id, updated_pasta.clone()) 233 | .expect("Failed to update pasta"); 234 | let updated_retrieved_pasta = db 235 | .get_pasta(&pasta.id) 236 | .expect("Failed to get pasta after update"); 237 | let updated_retrieved_pasta = 238 | updated_retrieved_pasta.expect("Pasta should exist after update"); 239 | assert_eq!(updated_retrieved_pasta.id, retrieved_pasta.id); 240 | assert_ne!(retrieved_pasta.content, updated_retrieved_pasta.content); 241 | } 242 | 243 | #[test] 244 | fn test_find_all_public_pastas() { 245 | let db = SqLite::new( 246 | super::super::super::test_utils::test_util::create_test_sqlite_properties(), 247 | ) 248 | .expect("Failed to create SQLite database"); 249 | 250 | let mut pasta1 = super::super::super::test_utils::test_util::create_random_pasta_entity(); 251 | pasta1.private = true; 252 | let pasta2 = super::super::super::test_utils::test_util::create_random_pasta_entity(); 253 | let pasta3 = super::super::super::test_utils::test_util::create_random_pasta_entity(); 254 | 255 | db.insert_pasta(pasta1.clone()) 256 | .expect("Failed to insert pasta1"); 257 | db.insert_pasta(pasta2.clone()) 258 | .expect("Failed to insert pasta2"); 259 | db.insert_pasta(pasta3.clone()) 260 | .expect("Failed to insert pasta3"); 261 | 262 | let public_pastas = db 263 | .find_all_public_pastas() 264 | .expect("Failed to find all public pastas"); 265 | let all_pastas = db.find_all_pastas().expect("Failed to find all pastas"); 266 | assert_eq!(public_pastas.len(), 2); 267 | assert!(public_pastas.iter().any(|p| p.id == pasta2.id)); 268 | assert!(public_pastas.iter().any(|p| p.id == pasta3.id)); 269 | assert_eq!(all_pastas.len(), 3); 270 | assert!(all_pastas.iter().any(|p| p.id == pasta1.id)); 271 | assert!(all_pastas.iter().any(|p| p.id == pasta2.id)); 272 | assert!(all_pastas.iter().any(|p| p.id == pasta3.id)); 273 | } 274 | 275 | #[test] 276 | fn test_delete_pasta() { 277 | let db = SqLite::new( 278 | super::super::super::test_utils::test_util::create_test_sqlite_properties(), 279 | ) 280 | .expect("Failed to create SQLite database"); 281 | 282 | let pasta = super::super::super::test_utils::test_util::create_random_pasta_entity(); 283 | 284 | db.insert_pasta(pasta.clone()) 285 | .expect("Failed to insert pasta"); 286 | let pastas = db.find_all_pastas().expect("Failed to find all pastas"); 287 | assert_eq!(pastas.len(), 1); 288 | 289 | db.delete_pasta(&pasta.id).expect("Failed to delete pasta"); 290 | let pastas_after_delete = db 291 | .find_all_pastas() 292 | .expect("Failed to find all pastas after delete"); 293 | assert_eq!(pastas_after_delete.len(), 0); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/endpoints/pasta.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::endpoints::errors::ErrorTemplate; 4 | use crate::error_handling::AppError; 5 | use crate::pasta::Pasta; 6 | use crate::util::animalnumbers::to_u64; 7 | use crate::util::auth; 8 | use crate::util::hashids::to_u64 as hashid_to_u64; 9 | use askama::Template; 10 | use axum::Router; 11 | use axum::extract::{Multipart, Path, State}; 12 | use axum::http::{HeaderMap, StatusCode, header}; 13 | use axum::response::IntoResponse; 14 | use axum::routing::{get, post}; 15 | use magic_crypt::{MagicCryptTrait, new_magic_crypt}; 16 | use std::time::{SystemTime, UNIX_EPOCH}; 17 | 18 | #[derive(Template)] 19 | #[template(path = "upload.html", escape = "none")] 20 | struct PastaTemplate<'a> { 21 | pasta: &'a Pasta, 22 | args: &'a Args, 23 | } 24 | 25 | fn pastaresponse( 26 | AppState { args, db }: AppState, 27 | id: String, 28 | password: String, 29 | ) -> Result { 30 | // get access to the pasta collection 31 | 32 | let id = if args.hash_ids { 33 | hashid_to_u64(&id).unwrap_or(0) 34 | } else { 35 | to_u64(&id).unwrap_or(0) 36 | }; 37 | 38 | let opt_pasta = db.get_pasta(&id)?; 39 | 40 | let mut pasta: Pasta = match opt_pasta { 41 | Some(pasta) => Ok::(pasta.into()), 42 | None => { 43 | // otherwise, send pasta not found error 44 | return Ok(( 45 | StatusCode::OK, 46 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 47 | ErrorTemplate { args: &args }.render()?, 48 | )); 49 | } 50 | }?; 51 | 52 | if pasta.encrypt_server && password == *"" { 53 | return Ok(( 54 | StatusCode::FOUND, 55 | [( 56 | header::LOCATION, 57 | format!( 58 | "{}/auth/{}", 59 | args.public_path_as_str(), 60 | pasta.id_as_animals(&args.hash_ids) 61 | ), 62 | )], 63 | "".to_string(), 64 | )); 65 | } 66 | 67 | // increment read count 68 | pasta.read_count += 1; 69 | 70 | // save the updated read count 71 | db.update_pasta(&id, pasta.clone().into())?; 72 | let original_content = pasta.content.to_owned(); 73 | 74 | // decrypt content temporarily 75 | if password != *"" && !original_content.is_empty() { 76 | let res = decrypt(&original_content, &password); 77 | if let Ok(rs) = res { 78 | pasta.content.replace_range(.., rs.as_str()); 79 | } else { 80 | return Ok(( 81 | StatusCode::FOUND, 82 | [( 83 | header::LOCATION, 84 | format!( 85 | "{}/auth/{}/incorrect", 86 | args.public_path_as_str(), 87 | pasta.id_as_animals(&args.hash_ids) 88 | ), 89 | )], 90 | "".to_string(), 91 | )); 92 | } 93 | } 94 | 95 | // serve pasta in template 96 | let pasta_template = PastaTemplate { 97 | pasta: &pasta, 98 | args: &args, 99 | } 100 | .render()?; 101 | let response = ( 102 | StatusCode::OK, 103 | [(header::CONTENT_TYPE, "text/html charset=utf-8".to_string())], 104 | pasta_template, 105 | ); 106 | 107 | if pasta.content != original_content { 108 | pasta.content = original_content; 109 | } 110 | 111 | // get current unix time in seconds 112 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 113 | Ok(n) => n.as_secs(), 114 | Err(_) => { 115 | log::error!("SystemTime before UNIX EPOCH!"); 116 | 0 117 | } 118 | } as i64; 119 | 120 | // update last read time 121 | pasta.last_read = timenow; 122 | 123 | // save the updated read count 124 | db.update_pasta(&id, pasta.clone().into())?; 125 | Ok(response) 126 | } 127 | 128 | pub async fn postpasta( 129 | State(data): State, 130 | Path(id): Path, 131 | payload: Multipart, 132 | ) -> Result { 133 | let password = auth::password_from_multipart(payload).await?; 134 | Ok(pastaresponse(data, id, password)) 135 | } 136 | 137 | pub async fn postshortpasta( 138 | State(data): State, 139 | Path(id): Path, 140 | payload: Multipart, 141 | ) -> Result { 142 | let password = auth::password_from_multipart(payload).await?; 143 | Ok(pastaresponse(data, id, password)) 144 | } 145 | 146 | pub async fn getpasta(State(data): State, Path(id): Path) -> impl IntoResponse { 147 | pastaresponse(data, id, String::from("")) 148 | } 149 | 150 | pub async fn getshortpasta( 151 | State(data): State, 152 | Path(id): Path, 153 | ) -> impl IntoResponse { 154 | pastaresponse(data, id, String::from("")) 155 | } 156 | 157 | fn urlresponse(AppState { args, db }: AppState, id: String) -> Result { 158 | // get access to the pasta collection 159 | 160 | let id = if args.hash_ids { 161 | hashid_to_u64(&id).unwrap_or(0) 162 | } else { 163 | to_u64(&id).unwrap_or(0) 164 | }; 165 | 166 | let opt_pasta = db.get_pasta(&id)?; 167 | 168 | let mut pasta: Pasta = match opt_pasta { 169 | Some(pasta) => Ok::(pasta.into()), 170 | None => { 171 | // otherwise, send pasta not found error 172 | return Ok(( 173 | StatusCode::OK, 174 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 175 | ErrorTemplate { args: &args }.render()?, 176 | )); 177 | } 178 | }?; 179 | // increment read count 180 | pasta.read_count += 1; 181 | 182 | // save the updated read count 183 | db.update_pasta(&id, pasta.clone().into())?; 184 | // send redirect if it's a url pasta 185 | if pasta.pasta_type == "url" { 186 | let response = ( 187 | StatusCode::FOUND, 188 | [(header::LOCATION, pasta.content.to_string())], 189 | "".to_string(), 190 | ); 191 | 192 | // get current unix time in seconds 193 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 194 | Ok(n) => n.as_secs(), 195 | Err(_) => { 196 | log::error!("SystemTime before UNIX EPOCH!"); 197 | 0 198 | } 199 | } as i64; 200 | 201 | // update last read time 202 | pasta.last_read = timenow; 203 | 204 | // save the updated read count 205 | db.update_pasta(&id, pasta.clone().into())?; 206 | Ok(response) 207 | // send error if we're trying to open a non-url pasta as a redirect 208 | } else { 209 | let response = ( 210 | StatusCode::OK, 211 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 212 | ErrorTemplate { args: &args }.render()?, 213 | ); 214 | Ok(response) 215 | } 216 | } 217 | 218 | pub async fn redirecturl( 219 | State(data): State, 220 | Path(id): Path, 221 | ) -> impl IntoResponse { 222 | urlresponse(data, id) 223 | } 224 | 225 | pub async fn shortredirecturl( 226 | State(data): State, 227 | Path(id): Path, 228 | ) -> impl IntoResponse { 229 | urlresponse(data, id) 230 | } 231 | 232 | pub async fn getrawpasta( 233 | State(AppState { args, db }): State, 234 | Path(id): Path, 235 | ) -> Result { 236 | // get access to the pasta collection 237 | 238 | let id = if args.hash_ids { 239 | hashid_to_u64(&id).unwrap_or(0) 240 | } else { 241 | to_u64(&id).unwrap_or(0) 242 | }; 243 | 244 | let opt_pasta = db.get_pasta(&id)?; 245 | 246 | let mut pasta: Pasta = match opt_pasta { 247 | Some(pasta) => Ok::(pasta.into()), 248 | None => { 249 | // otherwise, send pasta not found error 250 | return Ok(( 251 | StatusCode::OK, 252 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 253 | ErrorTemplate { args: &args }.render()?, 254 | )); 255 | } 256 | }?; 257 | if pasta.encrypt_server { 258 | return Ok(( 259 | StatusCode::FOUND, 260 | [( 261 | header::LOCATION, 262 | format!( 263 | "{}/auth_raw/{}", 264 | args.public_path_as_str(), 265 | pasta.id_as_animals(&args.hash_ids) 266 | ), 267 | )], 268 | "".to_string(), 269 | )); 270 | } 271 | 272 | // increment read count 273 | pasta.read_count += 1; 274 | 275 | // save the updated read count 276 | db.update_pasta(&id, pasta.clone().into())?; 277 | 278 | // get current unix time in seconds 279 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 280 | Ok(n) => n.as_secs(), 281 | Err(_) => { 282 | log::error!("SystemTime before UNIX EPOCH!"); 283 | 0 284 | } 285 | } as i64; 286 | 287 | // update last read time 288 | pasta.last_read = timenow; 289 | 290 | // send raw content of pasta 291 | let selected_pasta = pasta.content.to_owned(); 292 | 293 | let response = ( 294 | StatusCode::OK, 295 | [( 296 | header::CONTENT_TYPE, 297 | "text/plain; \ 298 | charset=utf-8" 299 | .to_string(), 300 | )], 301 | selected_pasta, 302 | ); 303 | 304 | Ok(response) 305 | } 306 | 307 | pub async fn postrawpasta( 308 | State(AppState { args, db }): State, 309 | Path(id): Path, 310 | payload: Multipart, 311 | ) -> Result { 312 | let password = auth::password_from_multipart(payload).await?; 313 | 314 | let id = if args.hash_ids { 315 | hashid_to_u64(&id).unwrap_or(0) 316 | } else { 317 | to_u64(&id).unwrap_or(0) 318 | }; 319 | 320 | let opt_pasta = db.get_pasta(&id)?; 321 | 322 | let mut pasta: Pasta = match opt_pasta { 323 | Some(pasta) => Ok::(pasta.into()), 324 | None => { 325 | // otherwise, send pasta not found error 326 | return Ok(( 327 | StatusCode::OK, 328 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 329 | ErrorTemplate { args: &args }.render()?, 330 | )); 331 | } 332 | }?; 333 | 334 | if pasta.encrypt_server && password == *"" { 335 | let mut headers = HeaderMap::new(); 336 | headers.insert( 337 | "Location", 338 | format!( 339 | "{}/auth/{}", 340 | args.public_path_as_str(), 341 | pasta.id_as_animals(&args.hash_ids) 342 | ) 343 | .parse()?, 344 | ); 345 | return Ok(( 346 | StatusCode::FOUND, 347 | [( 348 | header::LOCATION, 349 | format!( 350 | "{}/auth/{}", 351 | args.public_path_as_str(), 352 | pasta.id_as_animals(&args.hash_ids) 353 | ), 354 | )], 355 | "".to_string(), 356 | )); 357 | } 358 | 359 | // increment read count 360 | pasta.read_count += 1; 361 | 362 | // save the updated read count 363 | db.update_pasta(&id, pasta.clone().into())?; 364 | 365 | let original_content = pasta.content.to_owned(); 366 | 367 | // decrypt content temporarily 368 | if password != *"" { 369 | let res = decrypt(&original_content, &password); 370 | if let Ok(rs) = res { 371 | pasta.content.replace_range(.., rs.as_str()); 372 | } else { 373 | return Ok(( 374 | StatusCode::FOUND, 375 | [( 376 | header::LOCATION, 377 | format!( 378 | "{}/auth/{}/incorrect", 379 | args.public_path_as_str(), 380 | pasta.id_as_animals(&args.hash_ids) 381 | ), 382 | )], 383 | "".to_string(), 384 | )); 385 | } 386 | } 387 | 388 | // get current unix time in seconds 389 | let timenow: i64 = match SystemTime::now().duration_since(UNIX_EPOCH) { 390 | Ok(n) => n.as_secs(), 391 | Err(_) => { 392 | log::error!("SystemTime before UNIX EPOCH!"); 393 | 0 394 | } 395 | } as i64; 396 | 397 | // update last read time 398 | pasta.last_read = timenow; 399 | 400 | // save the updated read count 401 | db.update_pasta(&id, pasta.clone().into())?; 402 | 403 | // send raw content of pasta 404 | 405 | let mut headers = HeaderMap::new(); 406 | headers.insert("content-type", "text/html; charset=utf-8".parse()?); 407 | let response = ( 408 | StatusCode::NOT_FOUND, 409 | [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], 410 | pasta.content.to_owned(), 411 | ); 412 | 413 | if pasta.content != original_content { 414 | pasta.content = original_content; 415 | } 416 | Ok(response) 417 | } 418 | 419 | fn decrypt(text_str: &str, key_str: &str) -> Result { 420 | let mc = new_magic_crypt!(key_str, 256); 421 | 422 | mc.decrypt_base64_to_string(text_str) 423 | } 424 | 425 | pub fn pasta_routes() -> Router { 426 | Router::new() 427 | .route("/raw/{id}", post(postrawpasta)) 428 | .route("/raw/{id}", get(getrawpasta)) 429 | .route("/u/{id}", get(shortredirecturl)) 430 | .route("/url/{id}", get(redirecturl)) 431 | .route("/p/{id}", get(getshortpasta)) 432 | .route("/p/{id}", post(postshortpasta)) 433 | .route("/upload/{id}", get(getpasta)) 434 | .route("/upload/{id}", post(postpasta)) 435 | } 436 | -------------------------------------------------------------------------------- /src/endpoints/auth_upload.rs: -------------------------------------------------------------------------------- 1 | use crate::AppState; 2 | use crate::args::Args; 3 | use crate::endpoints::errors::ErrorTemplate; 4 | use crate::error_handling::AppError; 5 | use crate::pasta::Pasta; 6 | use crate::util::animalnumbers::to_u64; 7 | use crate::util::hashids::to_u64 as hashid_to_u64; 8 | use askama::Template; 9 | use axum::Router; 10 | use axum::extract::{Path, State}; 11 | use axum::http::HeaderMap; 12 | use axum::response::IntoResponse; 13 | use axum::routing::get; 14 | use db::entities::pasta::PastaEntity; 15 | 16 | #[derive(Template)] 17 | #[template(path = "auth_upload.html")] 18 | struct AuthPasta<'a> { 19 | args: &'a Args, 20 | id: String, 21 | status: String, 22 | encrypted_key: String, 23 | encrypt_client: bool, 24 | path: String, 25 | } 26 | 27 | pub async fn auth_upload( 28 | State(AppState { args, db }): State, 29 | Path(id): Path, 30 | ) -> Result { 31 | // get access to the pasta collection 32 | 33 | let intern_id = if args.hash_ids { 34 | hashid_to_u64(&id).unwrap_or(0) 35 | } else { 36 | to_u64(&id).unwrap_or(0) 37 | }; 38 | 39 | let pasta = db.get_pasta(&intern_id)?; 40 | 41 | let pasta: Pasta = match pasta { 42 | Some(pasta) => Ok::(pasta.into()), 43 | None => { 44 | let mut headers = HeaderMap::new(); 45 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 46 | let body = ErrorTemplate { args: &args }.render()?; 47 | return Ok((headers, body)); 48 | } 49 | }?; 50 | 51 | let mut headers = HeaderMap::new(); 52 | headers.insert( 53 | "Content-Type", 54 | "text/html; charset=utf-8".parse().map_err(AppError::from)?, 55 | ); 56 | let body = AuthPasta { 57 | args: &args, 58 | id, 59 | status: String::from(""), 60 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 61 | encrypt_client: pasta.encrypt_client, 62 | path: String::from("upload"), 63 | } 64 | .render()?; 65 | Ok((headers, body)) 66 | } 67 | 68 | pub async fn auth_upload_with_status( 69 | State(AppState { args, db }): State, 70 | Path((id, status)): Path<(String, String)>, 71 | ) -> Result { 72 | let intern_id = if args.hash_ids { 73 | hashid_to_u64(&id).unwrap_or(0) 74 | } else { 75 | to_u64(&id).unwrap_or(0) 76 | }; 77 | 78 | let pasta = db.get_pasta(&intern_id)?; 79 | 80 | let pasta: Pasta = match pasta { 81 | Some(pasta) => Ok::(pasta.into()), 82 | None => { 83 | let mut headers = HeaderMap::new(); 84 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 85 | let body = ErrorTemplate { args: &args }.render()?; 86 | return Ok((headers, body)); 87 | } 88 | }?; 89 | 90 | let mut headers = HeaderMap::new(); 91 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 92 | let body = AuthPasta { 93 | args: &args, 94 | id, 95 | status, 96 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 97 | encrypt_client: pasta.encrypt_client, 98 | path: String::from("upload"), 99 | } 100 | .render()?; 101 | Ok((headers, body)) 102 | } 103 | 104 | pub async fn auth_raw_pasta( 105 | State(AppState { args, db }): State, 106 | Path(id): Path, 107 | ) -> Result { 108 | // get access to the pasta collection 109 | 110 | let intern_id = if args.hash_ids { 111 | hashid_to_u64(&id).unwrap_or(0) 112 | } else { 113 | to_u64(&id).unwrap_or(0) 114 | }; 115 | 116 | let pasta_entity = match db.get_pasta(&intern_id)? { 117 | Some(pasta) => Ok::(pasta), 118 | None => { 119 | let mut headers = HeaderMap::new(); 120 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 121 | let body = ErrorTemplate { args: &args }.render()?; 122 | return Ok((headers, body)); 123 | } 124 | }?; 125 | 126 | let pasta: Pasta = pasta_entity.into(); 127 | let mut headers = HeaderMap::new(); 128 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 129 | let body = AuthPasta { 130 | args: &args, 131 | id, 132 | status: String::from(""), 133 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 134 | encrypt_client: pasta.encrypt_client, 135 | path: String::from("raw"), 136 | } 137 | .render()?; 138 | Ok((headers, body)) 139 | } 140 | 141 | pub async fn auth_raw_pasta_with_status( 142 | State(AppState { args, db }): State, 143 | Path((id, status)): Path<(String, String)>, 144 | ) -> Result { 145 | // get access to the pasta collection 146 | 147 | let intern_id = if args.hash_ids { 148 | hashid_to_u64(&id).unwrap_or(0) 149 | } else { 150 | to_u64(&id).unwrap_or(0) 151 | }; 152 | 153 | let pasta_entity = match db.get_pasta(&intern_id)? { 154 | Some(pasta) => Ok::(pasta), 155 | None => { 156 | let mut headers = HeaderMap::new(); 157 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 158 | let body = ErrorTemplate { args: &args }.render()?; 159 | return Ok((headers, body)); 160 | } 161 | }?; 162 | 163 | let pasta: Pasta = pasta_entity.into(); 164 | let mut headers = HeaderMap::new(); 165 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 166 | let body = AuthPasta { 167 | args: &args, 168 | id, 169 | status, 170 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 171 | encrypt_client: pasta.encrypt_client, 172 | path: String::from("raw"), 173 | } 174 | .render()?; 175 | Ok((headers, body)) 176 | } 177 | 178 | pub async fn auth_edit_private( 179 | State(AppState { args, db }): State, 180 | Path(id): Path, 181 | ) -> Result { 182 | // get access to the pasta collection 183 | 184 | let intern_id = if args.hash_ids { 185 | hashid_to_u64(&id).unwrap_or(0) 186 | } else { 187 | to_u64(&id).unwrap_or(0) 188 | }; 189 | 190 | let pasta = match db.get_pasta(&intern_id)? { 191 | Some(pasta) => Ok::(pasta.into()), 192 | None => { 193 | let mut headers = HeaderMap::new(); 194 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 195 | let body = ErrorTemplate { args: &args }.render()?; 196 | return Ok((headers, body)); 197 | } 198 | }?; 199 | 200 | let mut headers = HeaderMap::new(); 201 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 202 | let body = AuthPasta { 203 | args: &args, 204 | id, 205 | status: String::from(""), 206 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 207 | encrypt_client: pasta.encrypt_client, 208 | path: String::from("edit_private"), 209 | } 210 | .render()?; 211 | Ok((headers, body)) 212 | } 213 | 214 | pub async fn auth_edit_private_with_status( 215 | State(AppState { args, db }): State, 216 | Path((id, status)): Path<(String, String)>, 217 | ) -> Result { 218 | // get access to the pasta collection 219 | 220 | let intern_id = if args.hash_ids { 221 | hashid_to_u64(&id).unwrap_or(0) 222 | } else { 223 | to_u64(&id).unwrap_or(0) 224 | }; 225 | 226 | let pasta = match db.get_pasta(&intern_id)? { 227 | Some(pasta) => Ok::(pasta.into()), 228 | None => { 229 | let mut headers = HeaderMap::new(); 230 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 231 | let body = ErrorTemplate { args: &args }.render()?; 232 | return Ok((headers, body)); 233 | } 234 | }?; 235 | 236 | let mut headers = HeaderMap::new(); 237 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 238 | let body = AuthPasta { 239 | args: &args, 240 | id, 241 | status, 242 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 243 | encrypt_client: pasta.encrypt_client, 244 | path: String::from("edit_private"), 245 | } 246 | .render()?; 247 | Ok((headers, body)) 248 | } 249 | 250 | pub async fn auth_file( 251 | State(AppState { args, db }): State, 252 | Path(id): Path, 253 | ) -> Result { 254 | let intern_id = if args.hash_ids { 255 | hashid_to_u64(&id).unwrap_or(0) 256 | } else { 257 | to_u64(&id).unwrap_or(0) 258 | }; 259 | let pasta = match db.get_pasta(&intern_id)? { 260 | Some(pasta) => Ok::(pasta.into()), 261 | None => { 262 | let mut headers = HeaderMap::new(); 263 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 264 | let body = ErrorTemplate { args: &args }.render()?; 265 | return Ok((headers, body)); 266 | } 267 | }?; 268 | 269 | let mut headers = HeaderMap::new(); 270 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 271 | let body = AuthPasta { 272 | args: &args, 273 | id, 274 | status: String::from(""), 275 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 276 | encrypt_client: pasta.encrypt_client, 277 | path: String::from("secure_file"), 278 | } 279 | .render()?; 280 | Ok((headers, body)) 281 | } 282 | 283 | pub async fn auth_file_with_status( 284 | State(AppState { args, db }): State, 285 | Path((id, status)): Path<(String, String)>, 286 | ) -> Result { 287 | let intern_id = if args.hash_ids { 288 | hashid_to_u64(&id).unwrap_or(0) 289 | } else { 290 | to_u64(&id).unwrap_or(0) 291 | }; 292 | 293 | let opt_pasta = db.get_pasta(&intern_id)?; 294 | 295 | let pasta: Pasta = match opt_pasta { 296 | Some(pasta) => Ok::(pasta.into()), 297 | None => { 298 | let mut headers = HeaderMap::new(); 299 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 300 | let body = ErrorTemplate { args: &args }.render()?; 301 | return Ok((headers, body)); 302 | } 303 | }?; 304 | 305 | let mut headers = HeaderMap::new(); 306 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 307 | let body = AuthPasta { 308 | args: &args, 309 | id, 310 | status, 311 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 312 | encrypt_client: pasta.encrypt_client, 313 | path: String::from("secure_file"), 314 | } 315 | .render()?; 316 | Ok((headers, body)) 317 | } 318 | 319 | pub async fn auth_remove_private( 320 | State(AppState { args, db }): State, 321 | Path(id): Path, 322 | ) -> Result { 323 | let intern_id = if args.hash_ids { 324 | hashid_to_u64(&id).unwrap_or(0) 325 | } else { 326 | to_u64(&id).unwrap_or(0) 327 | }; 328 | 329 | let pasta = match db.get_pasta(&intern_id)? { 330 | Some(pasta) => Ok::(pasta.into()), 331 | None => { 332 | let mut headers = HeaderMap::new(); 333 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 334 | let body = ErrorTemplate { args: &args }.render()?; 335 | return Ok((headers, body)); 336 | } 337 | }?; 338 | let mut headers = HeaderMap::new(); 339 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 340 | let body = AuthPasta { 341 | args: &args, 342 | id, 343 | status: String::from(""), 344 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 345 | encrypt_client: pasta.encrypt_client, 346 | path: String::from("remove"), 347 | } 348 | .render()?; 349 | Ok((headers, body)) 350 | } 351 | 352 | pub async fn auth_remove_private_with_status( 353 | State(AppState { args, db }): State, 354 | Path((id, status)): Path<(String, String)>, 355 | ) -> Result { 356 | let intern_id = if args.hash_ids { 357 | hashid_to_u64(&id).unwrap_or(0) 358 | } else { 359 | to_u64(&id).unwrap_or(0) 360 | }; 361 | 362 | let pasta = match db.get_pasta(&intern_id)? { 363 | Some(pasta) => Ok::(pasta.into()), 364 | None => { 365 | let mut headers = HeaderMap::new(); 366 | let body = ErrorTemplate { args: &args }.render()?; 367 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 368 | return Ok((headers, body)); 369 | } 370 | }?; 371 | let mut headers = HeaderMap::new(); 372 | let body = AuthPasta { 373 | args: &args, 374 | id: id.clone(), 375 | status: status.clone(), 376 | encrypted_key: pasta.encrypted_key.to_owned().unwrap_or_default(), 377 | encrypt_client: pasta.encrypt_client, 378 | path: String::from("remove"), 379 | } 380 | .render()?; 381 | headers.insert("Content-Type", "text/html; charset=utf-8".parse()?); 382 | Ok((headers, body)) 383 | } 384 | 385 | pub fn auth_upload_router() -> Router { 386 | Router::new() 387 | .route( 388 | "/auth_remove_private/{id}/{status}", 389 | get(auth_remove_private_with_status), 390 | ) 391 | .route("/auth_remove_private/{id}", get(auth_remove_private)) 392 | .route("/auth_file/{id}/{status}", get(auth_file_with_status)) 393 | .route("/auth_file/{id}", get(auth_file)) 394 | .route( 395 | "/auth_edit_private/{id}/{status}", 396 | get(auth_edit_private_with_status), 397 | ) 398 | .route("/auth_edit_private/{id}", get(auth_edit_private)) 399 | .route("/auth_raw/{id}/{status}", get(auth_raw_pasta_with_status)) 400 | .route("/auth_raw/{id}", get(auth_raw_pasta)) 401 | .route("/auth/{id}/{status}", get(auth_upload_with_status)) 402 | .route("/auth/{id}", get(auth_upload)) 403 | } 404 | -------------------------------------------------------------------------------- /templates/upload.html: -------------------------------------------------------------------------------- 1 | {% include "header.html" %} 2 |
3 | {% if pasta.content != "" %} 4 | 8 | 9 | {% if args.public_path_as_str() != "" && pasta.pasta_type == "url" %} 10 | 14 | {%- endif %} 15 | Raw Text 16 | Content 17 | {%- endif %} {% if args.qr && args.public_path_as_str() != "" %} 18 | QR 19 | {%- endif %} {% if pasta.editable && !pasta.encrypt_client %} 20 | Edit 21 | {%- endif %} 22 | Remove 23 |
24 |
25 | {{pasta.id_as_animals(args 27 | .hash_ids)}} 28 | {% if args.public_path_as_str() != "" %} 29 | 32 | {%- endif %} 33 |
34 | 35 |
36 |
37 | {% if pasta.encrypt_client %} 38 | 40 |
41 | {% if pasta.encrypt_client %} 42 | 45 | 47 | {% if pasta.content != "" %} 48 | 54 | {%- endif %} 55 | {%- endif %} 56 | {% if pasta.file.is_some() && !pasta.file_embeddable() %} 57 | 64 | {%- endif %} 65 |
66 |
67 | {%- endif %} 68 | 69 |
70 | 71 | {% if pasta.content != "" %} 72 |
73 |
74 | {% if pasta.extension == "auto" || pasta.encrypt_client %} 75 |
{{pasta.content_escaped()}}
76 | {% else if args.highlightsyntax %} 77 |
{{pasta.content_syntax_highlighted()}}
78 | {% else %} 79 |
{{pasta.content_not_highlighted()}}
80 | {%- endif %} 81 |
82 |
83 | {%- endif %} 84 | 85 | {% if pasta.file.is_some() && !pasta.file_embeddable() && !pasta.encrypt_client %} 86 | 88 |

{{pasta.file.as_ref().unwrap().name()}} 89 | [{{pasta.file.as_ref().unwrap().size}}]

90 | 91 | 94 | 95 |
96 | {%- endif %} 97 | 98 | 99 | {% if pasta.file.is_some() && pasta.file.as_ref().unwrap().is_image() && 100 | pasta.file_embeddable() && !pasta.encrypt_client %} 101 | 102 | 104 |

{{pasta.file.as_ref().unwrap().name()}} 105 | [{{pasta.file.as_ref().unwrap().size}}]

106 | 107 | 110 | 111 |
112 | {%- endif %} 113 | 114 | 115 | {% if pasta.file.is_some() && pasta.file.as_ref().unwrap().is_video() && 116 | pasta.file_embeddable() && !pasta.encrypt_client %} 117 | 118 | 120 |

{{pasta.file.as_ref().unwrap().name()}} 121 | [{{pasta.file.as_ref().unwrap().size}}]

122 | 123 | 126 | 127 |
128 | {%- endif %} 129 | 130 |
131 | {% if args.show_read_stats && !pasta.hide_read_count %} 132 | {% if pasta.read_count == 1 %} 133 |

Read one time, last 134 | {{pasta.last_read_time_ago_as_string()}}

135 | {%- else %} 136 |

Read {{pasta.read_count}} times, last 137 | {{pasta.last_read_time_ago_as_string()}}

138 | {%- endif %} {%- endif %} 139 | 140 |
141 | 142 |
143 | 144 | 145 | 146 | 147 | 321 | 322 | 389 | 390 | {% if !args.pure_html %} 391 | 399 | {% endif %} 400 | 401 | {% include "footer.html" %} 402 | --------------------------------------------------------------------------------