This commit is contained in:
암냥 2026-03-11 23:16:53 +09:00
commit 0e5a6de323
No known key found for this signature in database
10 changed files with 2166 additions and 0 deletions

130
src/assets/editor.html Normal file
View 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
View 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
View 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
View 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))
}