Skip to content

Commit

Permalink
Initial commit: rktpb.
Browse files Browse the repository at this point in the history
  • Loading branch information
SergioBenitez committed Nov 28, 2023
0 parents commit a828614
Show file tree
Hide file tree
Showing 26 changed files with 3,020 additions and 0 deletions.
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
target
.cargo
*.sh
*.tar.gz
Paste.toml
upload/**/*
!upload/README
26 changes: 26 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "rktpb"
version = "1.0.0"
authors = ["Sergio Benitez <[email protected]>"]
edition = "2021"
description = "A pastebin that does just enough to be really useful."
repository = "https://github.com/SergioBenitez/rktpb"
readme = "README.md"
keywords = ["pastebin", "rocket", "server", "markdown", "highlight"]
license = "AGPL-3.0-only"
categories = ["web-programming::http-server"]

[lints.clippy]
large_enum_variant = "allow"
mutable_key_type = "allow"

[dependencies]
rocket = "0.5"
rocket_dyn_templates = { version = "0.1", features = ["tera"] }
yansi = "1.0.0-rc"
rand = "0.8"
syntect = "5"
comrak = "0.19"
futures = "0.3"
humantime = "2.1"
humantime-serde = "1.1"
619 changes: 619 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions Paste.toml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[default] # default configuration irrespective of compilation mode
id_length = 3 # paste ID length
upload_dir = "upload" # directory to save uploads in
paste_limit = "384KiB" # maximum paste upload file size
max_age = "30 days" # how long a paste is considered fresh
reap_interval = "5 minutes" # how often to reap stale uploads
server_url = "http://127.0.0.1:8000" # URL server is reachable at
address = "127.0.0.1" # address to listen on
port = 8000 # port to listen on
keep_alive = 5 # HTTP keep-alive in seconds
ident = "Rocket" # server `Ident` header
ip_header = "X-Real-IP" # header to inspect for client IP
log_level = "normal" # console log level
cli_colors = true # enable (detect TTY) or disable CLI colors

[default.cors] # CORS config - one key/value for each allowed host
"https://example.com" = ["options", "get", "post", "delete"] # methods to allow

[default.shutdown] # settings for graceful shutdown
ctrlc = true # whether `<ctrl-c>` initiates a shutdown
signals = ["term", "hup"] # signals that initiate a shutdown
grace = 5 # grace period length in seconds
mercy = 5 # mercy period length in seconds

[debug] # overrides for when application is compiled in debug mode
# key/values are identical to `default`

[release] # overrides for when application is compiled in release mode
# key/values are identical to `default`
141 changes: 141 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Rocket Powered Pastebin (`rktpb` | [`paste.rs`])

A pastebin that does just enough to be _really_ useful.

- [x] Really fast, really lightweight.
- [x] Renders _markdown_ like GitHub.
- [x] Highlights `source code`.
- [x] Returns plain text, too.
- [x] Has a simple API usable via CLI.
- [x] Has support for CORS.
- [x] Limits paste upload sizes.
- [x] No database: uses the file system.
- [x] Automatically deletes stale pastes.

This pastebin powers [`paste.rs`], a public instance. Further usage details can
be found there.

[`paste.rs`]: https://paste.rs

## Usage

**R**ocket **P**owered Paste**b**in (`rktpb`) is written in
[Rust](https://rust-lang.org) with [Rocket](https://rocket.rs). To start the
server, use `cargo`:

```sh
# clone the repository
git clone https://github.com/SergioBenitez/rktpb

# change into directory: the `static` folder needs to be in CWD before running
cd rktpb

# compile and start the server with the default config
cargo run
```

## Configuration

Configuration is provided via [environment variables](#environment-variables) or
a [TOML file](#toml-file). A set of defaults is always provided.

The complete list of configurable parameters is below:

| Name | Default Value | Description |
|--------------------|-----------------------------|-------------------------------------------|
| `id_length` | `3` | paste ID length
| `upload_dir` | `"upload"` | directory to save uploads in |
| `paste_limit` | `"384KiB"` | maximum paste upload file size |
| `max_age` | `"30 days"` | how long a paste is considered fresh |
| `reap_interval` | `"5 minutes"` | how often to reap stale uploads |
| `server_url` | `"http://{address}:{port}"` | URL server is reachable at |
| | | |
| `cors.{host}` | `["{HTTP method}"..]` | allow CORS {HTTP methods} for {host} |
| | | |
| `address` | `"127.0.0.1"` | address to listen on |
| `port` | `8000` | port to listen on |
| `keep_alive` | `5` | HTTP keep-alive in seconds |
| `ident` | `"Rocket"` | server `Ident` header |
| `ip_header` | `"X-Real-IP"` | header to inspect for client IP |
| `log_level` | `"normal"` | console log level |
| `cli_colors` | `true` | enable (detect TTY) or disable CLI colors |
| | | |
| `shutdown.ctrlc` | `true` | whether `<ctrl-c>` initiates a shutdown |
| `shutdown.signals` | `["term", "hup"]` | signals that initiate a shutdown |
| `shutdown.grace` | `5` | grace period length in seconds |
| `shutdown.mercy` | `5` | mercy period length in seconds |

You'll definitely want to configure the values in the first two categories, from
`id_length` to `cors`.

You should likely use the defaults for the rest.

### Environment Variables

Use an environment variable name equivalent to the parameter name prefixed with
`PASTE_`:

```sh
PASTE_ID_LENGTH=10 PASTE_MAX_AGE="1 year" ./rktpb
```

To set structured data via environment variables, such as CORS, use [TOML-like
syntax](https://docs.rs/figment/latest/figment/providers/struct.Env.html):

```sh
PASTE_CORS='{"http://example.com"=["get", "post"]}' ./rktpb
```

### TOML File

See [`Paste.toml.template`](Paste.toml.template) for a template with all of the
defaults set as well as a dummy `cors` configuration for `http://example.com`
that allows the `options`, `get`, `post`, and `delete` HTTP methods.

```sh
mv Paste.toml.template Paste.toml
```

By default, the application searches for a file called `Paste.toml` in the CWD.
The path to the file can be overridden by setting `PASTE_CONFIG`. For example,
to use a file named `rktpb.toml`, use `PASTE_CONFIG="rktpb.toml" ./rktpb`.

## Deploying

To deploy, build in release mode and ship/run the resulting binary along with
`static/`, `templates/`, and any config:

```sh
# build in release mode for `${TARGET}`
cargo build --release --target ${TARGET}

# create a tarball of everything that's needed
tar -cvzf "rktpb.tar.gz" \
Paste.toml static templates \
-C target/${TARGET}/release rktpb
```

However you choose to deploy, you'll need to ensure that the CWD at the time the
server is started contains the `static` and `templates` directories as well as
the config file, if one is used.

Note that when the server is compiled in `release` mode, the `[release]` section
of a TOML config file can be used to override config values; the same is true
when compiled in `debug` mode with `[debug]`.

## License

Rocket Powered Pastebin (`rktpb` | [`paste.rs`])
Copyright © 2020 Sergio Benitez

This program is free software: you can redistribute it and/or modify it under
the terms of the [GNU Affero General Public License version 3 (GNU AGPLv3) as
published by the Free Software
Foundation](https://www.gnu.org/licenses/agpl-3.0.en.html#license-text). This
program is distributed in the hope that it will be useful, but **WITHOUT ANY
WARRANTY**; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU AGPLv3 [LICENSE](LICENSE) for more details.

Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this project shall be licensed under the GNU AGPLv3 License,
without any additional terms or conditions.
106 changes: 106 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
use rocket::figment::{Figment, Profile};
use rocket::figment::providers::{Format, Toml, Serialized, Env};
use rocket::figment::value::magic::RelativePathBuf;
use rocket::serde::{de, Deserialize, Serialize};

use rocket::{Sentinel, Rocket, Ignite, Request};
use rocket::data::{ByteUnit, ToByteUnit, Limits};
use rocket::http::{Status, uri::Absolute};
use rocket::request::{FromRequest, Outcome};
use rocket::outcome::IntoOutcome;

use yansi::Paint;

#[derive(Debug, Deserialize, Serialize)]
#[serde(crate = "rocket::serde")]
pub struct Config {
pub id_length: usize,
pub paste_limit: ByteUnit,
pub server_url: Absolute<'static>,
#[serde(deserialize_with = "directory")]
#[serde(serialize_with = "RelativePathBuf::serialize_original")]
pub upload_dir: RelativePathBuf,
}

impl Config {
pub fn figment() -> Figment {
#[cfg(debug_assertions)] const DEFAULT_PROFILE: &str = "debug";
#[cfg(not(debug_assertions))] const DEFAULT_PROFILE: &str = "release";

// This the base figment, without our `Config` defaults.
let mut figment = Figment::new()
.join(rocket::Config::default())
.merge(Toml::file(Env::var_or("PASTE_CONFIG", "Paste.toml")).nested())
.merge(Env::prefixed("PASTE_").profile(Profile::Global))
.select(Profile::from_env_or("PASTE_PROFILE", DEFAULT_PROFILE));

// Dynamically determine `server_url` default based on address/port.
let default_server_url = match figment.extract::<rocket::Config>() {
Ok(config) => {
let proto = if config.tls_enabled() { "https" } else { "http" };
let url = format!("{}://{}:{}", proto, config.address, config.port);
Absolute::parse_owned(url).expect("default URL is Absolute")
},
Err(_) => uri!("http://127.0.0.1:8017"),
};

// Now set the `Config` defaults.
figment = figment
.join(Serialized::defaults(Config {
id_length: 3,
paste_limit: 384.kibibytes(),
server_url: default_server_url,
upload_dir: "upload".into(),
}));

// Configure Rocket based on `Config` settings. If this fails now, it's
// fine - it'll fail when attached too, so we won't miss out.
if let Ok(config) = figment.extract::<Self>() {
figment = figment
.merge((rocket::Config::TEMP_DIR, config.upload_dir))
.merge((rocket::Config::LIMITS, Limits::default()
.limit("form", config.paste_limit)
.limit("data-form", config.paste_limit)
.limit("file", config.paste_limit)
.limit("string", config.paste_limit)
.limit("bytes", config.paste_limit)
.limit("json", config.paste_limit)
.limit("msgpack", config.paste_limit)
.limit("paste", config.paste_limit),
));
}

figment
}
}

impl Sentinel for Config {
fn abort(rocket: &Rocket<Ignite>) -> bool {
rocket.state::<Self>().is_none()
}
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for &'r Config {
type Error = ();

async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
req.rocket().state::<Config>().or_error((Status::InternalServerError, ()))
}
}

fn directory<'de, D: de::Deserializer<'de>>(de: D) -> Result<RelativePathBuf, D::Error> {
let path = RelativePathBuf::deserialize(de)?;
let resolved = path.relative();
if !resolved.exists() {
let path = resolved.display();
return Err(de::Error::custom(format!("Path {} does not exist.", path.primary())));
}

if !resolved.is_dir() {
let path = resolved.display();
return Err(de::Error::custom(format!("Path {} is not a directory.", path.primary())));
}

Ok(path)
}
78 changes: 78 additions & 0 deletions src/cors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use std::io::Cursor;
use std::collections::HashMap;

use rocket::{Rocket, Request, Response, Orbit};
use rocket::fairing::{AdHoc, Fairing, Info, Kind};
use rocket::http::{Status, Header, Method, uri::Absolute};
use rocket::serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct Cors {
#[serde(default)]
cors: HashMap<Absolute<'static>, Vec<Method>>,
}

impl Cors {
pub fn fairing() -> impl Fairing {
AdHoc::try_on_ignite("CORS Configuration", |rocket| async {
match rocket.figment().extract::<Cors>() {
Ok(cors) => Ok(rocket.attach(cors)),
Err(e) => {
let kind = rocket::error::ErrorKind::Config(e);
rocket::Error::from(kind).pretty_print();
Err(rocket)
},
}
})
}
}

#[rocket::async_trait]
impl Fairing for Cors {
fn info(&self) -> Info {
Info { name: "CORS", kind: Kind::Liftoff | Kind::Response }
}

async fn on_liftoff(&self, _rocket: &Rocket<Orbit>) {
use yansi::Paint;

info!("{}{}", "📫 ".mask(), "CORS:".magenta());
if self.cors.is_empty() {
return info_!("status: {}", "disabled".red());
}

info_!("status: {}", "enabled".green());
for (host, methods) in &self.cors {
info_!("{}: {:?}", host.magenta(), methods.primary());
}
}

async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) {
let allowed_host_methods = req.headers().get_one("Origin")
.and_then(|origin| Absolute::parse(origin).ok())
.and_then(|host| self.cors.get_key_value(&host))
.filter(|(_, methods)| methods.contains(&req.method()));

if let Some((host, methods)) = allowed_host_methods {
const ALLOW_ORIGIN: &str = "Access-Control-Allow-Origin";
const ALLOW_METHODS: &str = "Access-Control-Allow-Methods";
const ALLOW_HEADERS: &str = "Access-Control-Allow-Headers";

let mut allow_methods = String::with_capacity(methods.len() * 8);
for (i, method) in methods.iter().enumerate() {
if i != 0 { allow_methods.push(','); }
allow_methods.push_str(method.as_str());
}

res.set_header(Header::new(ALLOW_ORIGIN, host.to_string()));
res.set_header(Header::new(ALLOW_METHODS, allow_methods));
res.set_header(Header::new(ALLOW_HEADERS, "Content-Type"));

if req.method() == Method::Options && res.status() == Status::NotFound {
res.set_status(Status::Ok);
res.set_sized_body(0, Cursor::new(""));
}
}
}
}
Loading

0 comments on commit a828614

Please sign in to comment.