init
This commit is contained in:
commit
0e5a6de323
10 changed files with 2166 additions and 0 deletions
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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue