init
This commit is contained in:
commit
0e5a6de323
10 changed files with 2166 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
|
||||
uploads
|
||||
1727
Cargo.lock
generated
Normal file
1727
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
11
Cargo.toml
Normal file
11
Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "paste"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
content_inspector = "0.2.4"
|
||||
nanoid = "0.4.0"
|
||||
rocket = "0.5.1"
|
||||
tempfile = "3.27.0"
|
||||
tokio = "1.50.0"
|
||||
55
Dockerfile
Normal file
55
Dockerfile
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Make sure RUST_VERSION matches the Rust version
|
||||
ARG RUST_VERSION=1.92
|
||||
ARG APP_NAME=docker-rust-hello
|
||||
|
||||
################################################################################
|
||||
# Create a stage for building the application.
|
||||
################################################################################
|
||||
|
||||
FROM dhi.io/rust:${RUST_VERSION}-alpine3.22-dev AS build
|
||||
ARG APP_NAME
|
||||
WORKDIR /app
|
||||
|
||||
# Install host build dependencies.
|
||||
RUN apk add --no-cache clang lld musl-dev git
|
||||
|
||||
# Build the application.
|
||||
RUN --mount=type=bind,source=src,target=src \
|
||||
--mount=type=bind,source=Cargo.toml,target=Cargo.toml \
|
||||
--mount=type=bind,source=Cargo.lock,target=Cargo.lock \
|
||||
--mount=type=cache,target=/app/target/ \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry/ \
|
||||
cargo build --locked --release && \
|
||||
cp ./target/release/$APP_NAME /bin/server
|
||||
|
||||
################################################################################
|
||||
# Create a new stage for running the application that contains the minimal
|
||||
# We use dhi.io/static for the final stage because itâs a minimal Docker Hardened Image runtime (basically âjust # enough OS to run the binaryâ), which helps keep the image small and with a lower attack surface compared to a # # full Alpine/Debian runtime.
|
||||
################################################################################
|
||||
|
||||
FROM dhi.io/static:20250419 AS final
|
||||
|
||||
# Create a non-privileged user that the app will run under.
|
||||
ARG UID=10001
|
||||
RUN adduser \
|
||||
--disabled-password \
|
||||
--gecos "" \
|
||||
--home "/nonexistent" \
|
||||
--shell "/sbin/nologin" \
|
||||
--no-create-home \
|
||||
--uid "${UID}" \
|
||||
appuser
|
||||
USER appuser
|
||||
|
||||
# Copy the executable from the "build" stage.
|
||||
COPY --from=build /bin/server /bin/
|
||||
|
||||
# Configure rocket to listen on all interfaces.
|
||||
ENV ROCKET_ADDRESS=0.0.0.0
|
||||
|
||||
# Expose the port that the application listens on.
|
||||
EXPOSE 8000
|
||||
|
||||
# What the container should run when it is started.
|
||||
CMD ["/bin/server"]
|
||||
15
config.toml
Normal file
15
config.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
upload_dir = "./uploads"
|
||||
base_url = "https://paste.mizuki.guru"
|
||||
id_length = 10
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8000
|
||||
|
||||
[file]
|
||||
# u can setup this
|
||||
# b, kb, mb, gb, tb
|
||||
# kib, mib, gib, tib
|
||||
max_size = "10MB"
|
||||
extension_whitelist = [".docx", ".md", ".json", ".rs"]
|
||||
block_binary = true
|
||||
130
src/assets/editor.html
Normal file
130
src/assets/editor.html
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap");
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-optical-sizing: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
header button {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<ul>
|
||||
<li><button>[ new ]</button></li>
|
||||
<li><button>[ save ]</button></li>
|
||||
</ul>
|
||||
</header>
|
||||
<div id="editor"></div>
|
||||
<footer>
|
||||
<p id="command-display"></p>
|
||||
<p id="vim-mode"></p>
|
||||
</footer>
|
||||
</div>
|
||||
<script id="initial-data" type="text/plain">$%{defaultText}%$</script>
|
||||
<script type="module">
|
||||
import { basicSetup, EditorView } from "https://esm.sh/codemirror";
|
||||
import {
|
||||
CodeMirror,
|
||||
vim,
|
||||
Vim,
|
||||
getCM,
|
||||
} from "https://esm.sh/@replit/codemirror-vim";
|
||||
|
||||
let initialDoc =
|
||||
document.getElementById("initial-data").textContent;
|
||||
|
||||
let editor = new EditorView({
|
||||
doc: initialDoc,
|
||||
extensions: [
|
||||
vim({ status: true }),
|
||||
basicSetup,
|
||||
],
|
||||
parent: document.querySelector("#editor"),
|
||||
});
|
||||
|
||||
async function saveFile() {
|
||||
const currentContent = editor.state.doc.toString();
|
||||
|
||||
if (currentContent === initialDoc) {
|
||||
console.log("No changes detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/paste.txt", {
|
||||
method: "PUT",
|
||||
body: currentContent,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const resultText = await response.text();
|
||||
console.log("Server returned path:", resultText);
|
||||
window.history.pushState({}, "", resultText);
|
||||
initialDoc = currentContent;
|
||||
} else {
|
||||
alert("Failed to save! Status: " + response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Save error:", error);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll("button").forEach((btn) => {
|
||||
if (btn.textContent.includes("save")) {
|
||||
btn.onclick = saveFile;
|
||||
}
|
||||
if (btn.textContent.includes("new")) {
|
||||
btn.onclick = () => (window.location.href = "/");
|
||||
}
|
||||
});
|
||||
|
||||
Vim.defineEx("write", "w", () => {
|
||||
saveFile();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
102
src/config.rs
Normal file
102
src/config.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use rocket::serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct AppConfig {
|
||||
pub upload_dir: String,
|
||||
pub base_url: String,
|
||||
pub id_length: usize,
|
||||
#[serde(default)]
|
||||
pub server: ServerConfig,
|
||||
#[serde(default)]
|
||||
pub file: FileConfig,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct ServerConfig {
|
||||
#[serde(default = "default_server_host")]
|
||||
pub host: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub struct FileConfig {
|
||||
pub max_size: Option<String>,
|
||||
pub extension_whitelist: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub block_binary: bool,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn max_upload_size(&self) -> Option<u64> {
|
||||
self.file.max_size.as_deref().and_then(parse_size)
|
||||
}
|
||||
|
||||
pub fn should_block_binary(&self) -> bool {
|
||||
self.file.block_binary
|
||||
}
|
||||
|
||||
pub fn is_extension_allowed(&self, extension: &str) -> bool {
|
||||
match self.file.extension_whitelist.as_ref() {
|
||||
Some(whitelist) if !whitelist.is_empty() => {
|
||||
let normalized = normalize_extension(extension);
|
||||
whitelist.iter().any(|allowed| normalize_extension(allowed) == normalized)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_extension(extension: &str) -> String {
|
||||
if extension.starts_with('.') {
|
||||
extension.to_ascii_lowercase()
|
||||
} else {
|
||||
format!(".{extension}").to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_server_host() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
fn default_server_port() -> u16 {
|
||||
8000
|
||||
}
|
||||
|
||||
fn parse_size(input: &str) -> Option<u64> {
|
||||
let trimmed = input.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let unit_start = trimmed
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
|
||||
.unwrap_or(trimmed.len());
|
||||
|
||||
let (number, unit) = trimmed.split_at(unit_start);
|
||||
let value: f64 = number.trim().parse().ok()?;
|
||||
let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "b" => 1f64,
|
||||
"kb" => 1_000f64,
|
||||
"mb" => 1_000_000f64,
|
||||
"gb" => 1_000_000_000f64,
|
||||
"tb" => 1_000_000_000_000f64,
|
||||
"kib" => 1_024f64,
|
||||
"mib" => 1_048_576f64,
|
||||
"gib" => 1_073_741_824f64,
|
||||
"tib" => 1_099_511_627_776f64,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let bytes = value * multiplier;
|
||||
|
||||
if !bytes.is_finite() || bytes < 0f64 || bytes > u64::MAX as f64 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(bytes.ceil() as u64)
|
||||
}
|
||||
24
src/main.rs
Normal file
24
src/main.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#[macro_use] extern crate rocket;
|
||||
mod config;
|
||||
mod routes;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::routes::{get_file, index, upload};
|
||||
use rocket::figment::{Figment, providers::{Format, Toml}};
|
||||
|
||||
#[launch]
|
||||
fn rocket() -> _ {
|
||||
let config: AppConfig = Figment::new()
|
||||
.merge(Toml::file("config.toml"))
|
||||
.extract()
|
||||
.expect("failed to load config.toml");
|
||||
|
||||
rocket::build()
|
||||
.configure(rocket::Config {
|
||||
address: config.server.host.parse().unwrap_or("0.0.0.0".parse().unwrap()),
|
||||
port: if config.server.port != 0 { config.server.port } else { 8000 },
|
||||
..Default::default()
|
||||
})
|
||||
.manage(config)
|
||||
.mount("/", routes![index, get_file, upload])
|
||||
}
|
||||
89
src/routes.rs
Normal file
89
src/routes.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use crate::config::{AppConfig, normalize_extension};
|
||||
use content_inspector::ContentType;
|
||||
use rocket::data::{Data, ToByteUnit};
|
||||
use rocket::{State, http::Status, response::content::RawHtml};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::fs as async_fs;
|
||||
|
||||
#[get("/")]
|
||||
pub fn index() -> Option<RawHtml<String>> {
|
||||
let raw: &str = include_str!("assets/editor.html");
|
||||
Some(RawHtml(raw.replace("$%{defaultText}%$", "")))
|
||||
}
|
||||
|
||||
#[get("/<id>?<raw>")]
|
||||
pub async fn get_file(id: &str, raw: Option<bool>, config: &State<AppConfig>) -> Result<String, Status> {
|
||||
let file_path = Path::new(&config.upload_dir).join(id);
|
||||
|
||||
if file_path.exists() {
|
||||
if let Ok(content) = async_fs::read_to_string(file_path).await {
|
||||
let is_mozilla = std::env::var("HTTP_USER_AGENT")
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.contains("Mozilla");
|
||||
|
||||
if raw.unwrap_or(false) || !is_mozilla {
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
let html: &str = include_str!("assets/editor.html");
|
||||
return Ok(html.replace("$%{defaultText}%$", &content));
|
||||
}
|
||||
}
|
||||
|
||||
Err(Status::NotFound)
|
||||
}
|
||||
|
||||
#[put("/<file_name>", data = "<data>")]
|
||||
pub async fn upload(file_name: &str, data: Data<'_>, config: &State<AppConfig>) -> Result<String, Status> {
|
||||
let extension = Path::new(file_name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(normalize_extension)
|
||||
.unwrap_or_else(|| ".txt".to_string());
|
||||
|
||||
if !config.is_extension_allowed(&extension) {
|
||||
return Err(Status::UnsupportedMediaType);
|
||||
}
|
||||
|
||||
let upload_dir = Path::new(&config.upload_dir);
|
||||
let limit = config.max_upload_size().unwrap_or(u64::MAX);
|
||||
let id = loop {
|
||||
let candidate = nanoid::format(nanoid::rngs::default, &nanoid::alphabet::SAFE[..], config.id_length);
|
||||
let save_name = format!("{}{}", candidate, extension);
|
||||
|
||||
if !upload_dir.join(save_name).exists() {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
|
||||
if !upload_dir.exists() {
|
||||
fs::create_dir_all(upload_dir).map_err(|_| Status::InternalServerError)?;
|
||||
}
|
||||
|
||||
let mut temp = NamedTempFile::new_in(&config.upload_dir)
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
let stream = data.open(limit.bytes());
|
||||
let bytes = stream.into_bytes().await
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
if !bytes.is_complete() {
|
||||
return Err(Status::PayloadTooLarge);
|
||||
}
|
||||
|
||||
if config.should_block_binary() && matches!(content_inspector::inspect(bytes.as_ref()), ContentType::BINARY) {
|
||||
return Err(Status::UnsupportedMediaType);
|
||||
}
|
||||
|
||||
temp.write_all(&bytes)
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
temp.persist(Path::new(&config.upload_dir).join(format!("{}{}", id, extension)))
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
Ok(format!("{}/{}{}", config.base_url.trim_end_matches('/'), id, extension))
|
||||
}
|
||||
10
todo.md
Normal file
10
todo.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
- [ ] i want font custom (delegate)
|
||||
- [ ] i want language custom (delegate)
|
||||
- [ ] i want theme custom (delegate)
|
||||
- [x] if user agent doesn't have Mozilla/5.0, get raw value
|
||||
- [x] if POST request at root upload file
|
||||
- [x] https://codemirror.net
|
||||
- [ ] improve frontend (language select, file extension custom, etc...)
|
||||
- [x] file max size (but... why? i can edit that in reverse proxy server)
|
||||
- [x] file extension whitelist
|
||||
- [x] block binary file (content inspector)
|
||||
Loading…
Add table
Add a link
Reference in a new issue