Initial Code Commit

This commit is contained in:
2025-12-12 19:12:36 +00:00
commit e021191eb3
5 changed files with 1036 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
target/
media_config.json
.env
Cargo.lock
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "media-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dotenv = "0.15"
tower-http = { version = "0.5", features = ["compression-full", "timeout", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
View File
+497
View File
@@ -0,0 +1,497 @@
use axum::{
body::Body,
extract::{Path, State},
http::{header, HeaderMap, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
net::SocketAddr,
path::PathBuf,
sync::Arc,
time::Duration,
};
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio_util::io::ReaderStream;
use tower_http::{
compression::CompressionLayer,
timeout::TimeoutLayer,
trace::TraceLayer,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MediaFile {
name: String,
path: String,
description: Option<String>,
#[serde(default)]
content_type: Option<String>,
#[serde(default)]
size_bytes: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct MediaConfig {
files: HashMap<String, MediaFile>,
}
#[derive(Debug, Clone)]
struct Config {
port: u16,
host: String,
request_timeout: u64,
allowed_ips: Option<Vec<String>>,
chunk_size: usize,
domain: Option<String>,
}
#[derive(Clone)]
struct AppState {
files: Arc<HashMap<String, MediaFile>>,
config: Arc<Config>,
}
#[tokio::main]
async fn main() {
// Initialize tracing for logging
tracing_subscriber::fmt::init();
// Load environment configuration
dotenv::dotenv().ok();
let config = load_config();
// Load media files configuration
let media_config = load_media_config("media_config.json")
.expect("Failed to load media configuration");
let state = AppState {
files: Arc::new(media_config.files),
config: Arc::new(config.clone()),
};
let app = Router::new()
.route("/", get(list_files))
.route("/download/:file_id", get(download_file))
.route("/health", get(health_check))
.layer(middleware::from_fn_with_state(
state.clone(),
security_headers_middleware,
))
.layer(middleware::from_fn_with_state(
state.clone(),
ip_whitelist_middleware,
))
.layer(CompressionLayer::new())
.layer(TimeoutLayer::new(Duration::from_secs(config.request_timeout)))
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr = SocketAddr::from((
config.host.parse::<std::net::IpAddr>().unwrap(),
config.port,
));
println!("📥 Public media download server running on http://{}", addr);
println!("✅ No authentication required - public access enabled");
println!("💾 Optimized for large file streaming (100GB+)");
println!("📋 Configuration loaded from .env and media_config.json");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
fn load_config() -> Config {
Config {
port: std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("Invalid PORT"),
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
request_timeout: std::env::var("REQUEST_TIMEOUT_SECS")
.unwrap_or_else(|_| "3600".to_string()) // 1 hour default for large files
.parse()
.expect("Invalid REQUEST_TIMEOUT_SECS"),
allowed_ips: std::env::var("ALLOWED_IPS")
.ok()
.map(|s| s.split(',').map(|ip| ip.trim().to_string()).collect()),
chunk_size: std::env::var("CHUNK_SIZE_KB")
.unwrap_or_else(|_| "8192".to_string()) // 8MB chunks for large files
.parse::<usize>()
.expect("Invalid CHUNK_SIZE_KB")
* 1024,
domain: std::env::var("DOMAIN").ok().map(|d| d.trim_end_matches('/').to_string()),
}
}
fn load_media_config(path: &str) -> Result<MediaConfig, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let mut config: MediaConfig = serde_json::from_str(&content)?;
// Validate and get file sizes
for (id, file) in &mut config.files {
let path = PathBuf::from(&file.path);
if !path.exists() {
tracing::warn!("File '{}' at path '{}' does not exist", id, file.path);
} else if !path.is_file() {
tracing::warn!("Path '{}' for file '{}' is not a file", file.path, id);
} else {
// Get file size
if let Ok(metadata) = std::fs::metadata(&path) {
file.size_bytes = Some(metadata.len());
tracing::info!(
"Loaded file '{}': {} ({} bytes)",
id,
file.name,
metadata.len()
);
}
}
}
Ok(config)
}
// IP whitelist middleware (optional)
async fn ip_whitelist_middleware(
State(state): State<AppState>,
headers: HeaderMap,
request: axum::http::Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
if let Some(allowed_ips) = &state.config.allowed_ips {
// Try to get real IP from X-Forwarded-For or X-Real-IP headers
let client_ip = headers
.get("X-Forwarded-For")
.or_else(|| headers.get("X-Real-IP"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim());
if let Some(ip) = client_ip {
if !allowed_ips.iter().any(|allowed| allowed == ip || allowed == "*") {
tracing::warn!("Access denied for IP: {}", ip);
return Err(StatusCode::FORBIDDEN);
}
}
}
Ok(next.run(request).await)
}
// Security headers middleware
async fn security_headers_middleware(
State(_state): State<AppState>,
request: axum::http::Request<Body>,
next: Next,
) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
// Basic security headers
headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
headers.insert("X-Frame-Options", "SAMEORIGIN".parse().unwrap());
headers.insert("X-XSS-Protection", "1; mode=block".parse().unwrap());
headers.insert("Referrer-Policy", "no-referrer".parse().unwrap());
response
}
async fn health_check() -> impl IntoResponse {
(StatusCode::OK, "OK")
}
async fn list_files(State(state): State<AppState>) -> Html<String> {
// Read template from file
let template_path = "templates/index.html";
let template = match tokio::fs::read_to_string(template_path).await {
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to read template {}: {}", template_path, e);
return Html(format!(
"<h1>500 Internal Server Error</h1><p>Failed to load template: {}</p>",
e
));
}
};
let file_count = state.files.len();
let total_size: u64 = state.files.values().filter_map(|f| f.size_bytes).sum();
// Determine base URL to display in commands: prefer DOMAIN env var, else fall back to localhost:PORT
let base_url = state
.config
.domain
.clone()
.unwrap_or_else(|| format!("http://localhost:{}", state.config.port));
let mut file_list_items = String::new();
// Sort files by ID for consistent display order
let mut sorted_files: Vec<_> = state.files.iter().collect();
sorted_files.sort_by_key(|(id, _)| *id);
for (id, file) in sorted_files {
let size_str = file.size_bytes
.map(format_size)
.unwrap_or_else(|| "Unknown size".to_string());
// Build safe element id for per-file UI
let safe_elem_id = safe_html_id(id);
// Build example commands using base_url
let curl_cmd = format!("curl -O {}/download/{}", base_url, id);
file_list_items.push_str(&format!(
r#"
<li class="file-item">
<div class="file-name">{name}</div>
<div class="file-meta">
<span class="file-id">ID: {id}</span>
<span class="file-size">📦 {size}</span>
</div>
<div class="file-desc">{desc}</div>
<a href="/download/{id}" class="download-btn">⬇️ Download</a>
<div style="margin-top:12px; display:flex; gap:8px; align-items:center;">
<button class="show-cmd" data-target="cmd-{elem}">Show Command</button>
<button class="copy-cmd" data-target="cmd-{elem}">Copy</button>
</div>
<pre id="cmd-{elem}" class="usage-pre" style="display:none; margin-top:12px; background:#111827; color:#f9fafb; padding:12px; border-radius:6px; overflow:auto;">{curl}</pre>
</li>
"#,
name = html_escape(&file.name),
id = html_escape(id),
size = size_str,
desc = html_escape(file.description.as_deref().unwrap_or("No description")),
elem = html_escape(&safe_elem_id),
curl = html_escape(&curl_cmd),
));
}
let html = template
.replace("{FILE_COUNT}", &file_count.to_string())
.replace("{TOTAL_SIZE}", &format_size(total_size))
.replace("{FILE_LIST_ITEMS}", &file_list_items)
.replace("{BASE_URL}", &html_escape(&base_url));
Html(html)
}
async fn download_file(
State(state): State<AppState>,
Path(file_id): Path<String>,
headers: HeaderMap,
) -> Result<Response, StatusCode> {
// Sanitize file_id to prevent path traversal
if file_id.contains("..") || file_id.contains('/') || file_id.contains('\\') {
tracing::warn!("Path traversal attempt detected: {}", file_id);
return Err(StatusCode::BAD_REQUEST);
}
let file_info = state
.files
.get(&file_id)
.ok_or(StatusCode::NOT_FOUND)?;
// Canonicalize path to prevent symlink attacks
let file_path = PathBuf::from(&file_info.path);
let canonical_path = file_path
.canonicalize()
.map_err(|_| StatusCode::NOT_FOUND)?;
// Verify file still exists and is a file
if !canonical_path.is_file() {
tracing::error!("File not found or is not a file: {:?}", canonical_path);
return Err(StatusCode::NOT_FOUND);
}
// Get file metadata
let metadata = tokio::fs::metadata(&canonical_path)
.await
.map_err(|e| {
tracing::error!("Failed to get file metadata: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let file_size = metadata.len();
// Check for Range header to support resume
let range_header = headers.get(header::RANGE);
if let Some(range) = range_header {
// Handle range requests for resume support
return handle_range_request(&canonical_path, file_size, range, file_info).await;
}
// Open file for streaming
let file = File::open(&canonical_path)
.await
.map_err(|e| {
tracing::error!("Failed to open file: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// Create stream with optimized buffer size for large files
let stream = ReaderStream::with_capacity(file, state.config.chunk_size);
let body = Body::from_stream(stream);
let filename = canonical_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download");
// Sanitize filename
let safe_filename = filename.replace(&['/', '\\', '\0'][..], "_");
let content_type = file_info
.content_type
.as_deref()
.unwrap_or("application/octet-stream");
tracing::info!(
"File download started: {} ({}) - {} bytes",
file_id,
safe_filename,
file_size
);
Ok(Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", safe_filename),
)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, file_size)
.header(header::ACCEPT_RANGES, "bytes")
.header("X-Content-Type-Options", "nosniff")
.header("Cache-Control", "public, max-age=3600")
.body(body)
.unwrap())
}
async fn handle_range_request(
file_path: &PathBuf,
file_size: u64,
range_header: &header::HeaderValue,
file_info: &MediaFile,
) -> Result<Response, StatusCode> {
let range_str = range_header.to_str().map_err(|_| StatusCode::BAD_REQUEST)?;
// Parse range header (format: "bytes=start-end")
if !range_str.starts_with("bytes=") {
return Err(StatusCode::BAD_REQUEST);
}
let range_spec = &range_str[6..];
let parts: Vec<&str> = range_spec.split('-').collect();
if parts.len() != 2 {
return Err(StatusCode::BAD_REQUEST);
}
let start: u64 = if parts[0].is_empty() {
0
} else {
parts[0].parse().map_err(|_| StatusCode::BAD_REQUEST)?
};
let end: u64 = if parts[1].is_empty() {
file_size - 1
} else {
parts[1].parse::<u64>().map_err(|_| StatusCode::BAD_REQUEST)?
.min(file_size - 1)
};
if start > end || start >= file_size {
return Err(StatusCode::RANGE_NOT_SATISFIABLE);
}
let content_length = end - start + 1;
// Open file and seek to start position
let mut file = File::open(file_path)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
file.seek(std::io::SeekFrom::Start(start))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Create limited stream
let limited_file = file.take(content_length);
let stream = ReaderStream::new(limited_file);
let body = Body::from_stream(stream);
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download");
let safe_filename = filename.replace(&['/', '\\', '\0'][..], "_");
let content_type = file_info
.content_type
.as_deref()
.unwrap_or("application/octet-stream");
tracing::info!(
"Range request: {} bytes {}-{}/{} ({})",
safe_filename,
start,
end,
file_size,
content_length
);
Ok(Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", safe_filename),
)
.header(header::CONTENT_TYPE, content_type)
.header(header::CONTENT_LENGTH, content_length)
.header(
header::CONTENT_RANGE,
format!("bytes {}-{}/{}", start, end, file_size),
)
.header(header::ACCEPT_RANGES, "bytes")
.header("X-Content-Type-Options", "nosniff")
.body(body)
.unwrap())
}
// Format bytes to human-readable size
fn format_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"];
if bytes == 0 {
return "0 B".to_string();
}
let bytes_f = bytes as f64;
let i = (bytes_f.log10() / 1024_f64.log10()).floor() as usize;
let i = i.min(UNITS.len() - 1);
let size = bytes_f / 1024_f64.powi(i as i32);
format!("{:.2} {}", size, UNITS[i])
}
// HTML escape to prevent XSS
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
// Create a safe HTML element id from an arbitrary string
fn safe_html_id(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
+516
View File
@@ -0,0 +1,516 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GMMC WORLD ARCHIVES</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
<style>
:root {
--mc-bg: #111111;
--mc-ui-bg: #c6c6c6;
--mc-ui-dark: #373737;
--mc-ui-light: #ffffff;
--mc-ui-border: #000000;
--mc-text: #e0e0e0;
--mc-text-shadow: 2px 2px 0px #000000;
--mc-green: #55ff55;
--mc-gold: #ffaa00;
--mc-btn-bg: #8f8f8f;
--mc-btn-hover: #a0a0a0;
--mc-btn-shadow-light: #c6c6c6;
--mc-btn-shadow-dark: #505050;
}
* {
box-sizing: border-box;
}
body {
font-family: 'VT323', monospace;
background-color: var(--mc-bg);
background-image:
linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a),
linear-gradient(45deg, #1a1a1a 25%, transparent 25%, transparent 75%, #1a1a1a 75%, #1a1a1a);
background-size: 40px 40px;
background-position: 0 0, 20px 20px;
color: var(--mc-text);
margin: 0;
padding: 24px;
line-height: 1.2;
font-size: 20px;
image-rendering: pixelated;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 3rem;
font-weight: 400;
margin: 0 0 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
color: var(--mc-ui-light);
text-shadow: var(--mc-text-shadow);
text-transform: uppercase;
letter-spacing: 2px;
}
/* Stats Grid - Inventory Style */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2.5rem;
}
.stat-card {
background: #212121;
padding: 1rem;
border: 2px solid #555;
box-shadow: inset 2px 2px 0px #000, inset -2px -2px 0px #333;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stat-value {
font-size: 2.5rem;
color: var(--mc-green);
text-shadow: var(--mc-text-shadow);
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 1.2rem;
color: #aaaaaa;
text-shadow: 1px 1px 0 #000;
}
/* File List */
h2 {
font-size: 2rem;
font-weight: 400;
margin: 2rem 0 1rem;
color: var(--mc-ui-light);
text-shadow: var(--mc-text-shadow);
border-bottom: 2px solid #555;
padding-bottom: 0.5rem;
}
.file-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 1rem;
}
.file-item {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #555;
padding: 1rem;
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem 1rem;
align-items: start;
position: relative;
}
.file-item:hover {
background: rgba(0, 0, 0, 0.7);
border-color: #777;
}
/* 1. Name */
.file-name {
font-size: 1.5rem;
color: var(--mc-gold);
text-shadow: var(--mc-text-shadow);
grid-column: 1;
grid-row: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 2. Meta (ID, Size) */
.file-meta {
font-size: 1.1rem;
color: #aaaaaa;
display: flex;
gap: 1rem;
align-items: center;
grid-column: 1;
grid-row: 2;
text-shadow: 1px 1px 0 #000;
}
.file-id {
color: #aaaaaa;
}
/* 3. Description */
.file-desc {
font-size: 1.2rem;
color: #cccccc;
grid-column: 1 / -1;
grid-row: 3;
margin-top: 0.5rem;
line-height: 1.4;
max-height: 3.2em;
overflow: hidden;
text-shadow: 1px 1px 0 #000;
}
.file-desc.expanded {
max-height: none;
}
/* 4. Download Button - Minecraft Style */
.download-btn {
grid-column: 2;
grid-row: 1;
background-color: var(--mc-btn-bg);
color: #fff;
text-decoration: none;
padding: 8px 16px;
font-size: 1.2rem;
text-shadow: 1px 1px 0 #333;
border: 2px solid #000;
box-shadow:
inset 2px 2px 0px var(--mc-btn-shadow-light),
inset -2px -2px 0px var(--mc-btn-shadow-dark);
cursor: pointer;
justify-self: end;
display: inline-flex;
align-items: center;
gap: 0.5rem;
transition: none;
/* No smooth transition for pixel feel */
}
.download-btn:hover {
background-color: var(--mc-btn-hover);
color: #ffffa0;
}
.download-btn:active {
box-shadow:
inset 2px 2px 0px var(--mc-btn-shadow-dark),
inset -2px -2px 0px var(--mc-btn-shadow-light);
transform: translateY(2px);
}
/* Hide original button container generated by backend */
.file-item>div:nth-of-type(4) {
display: none !important;
}
/* Hide original pre block */
.usage-pre {
display: none !important;
}
/* New Command Row Styles */
.command-container {
grid-column: 1 / -1;
grid-row: 5;
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.command-row {
display: flex;
align-items: center;
background: #000;
border: 2px solid #555;
padding: 0.5rem;
gap: 0.75rem;
}
.command-text {
font-family: 'VT323', monospace;
font-size: 1.2rem;
color: #fff;
flex: 1;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
.icon-btn {
background-color: var(--mc-btn-bg);
border: 2px solid #000;
box-shadow:
inset 2px 2px 0px var(--mc-btn-shadow-light),
inset -2px -2px 0px var(--mc-btn-shadow-dark);
color: #fff;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
}
.icon-btn:hover {
background-color: var(--mc-btn-hover);
}
.icon-btn:active {
box-shadow:
inset 2px 2px 0px var(--mc-btn-shadow-dark),
inset -2px -2px 0px var(--mc-btn-shadow-light);
}
.icon-btn.success {
background-color: #55ff55;
color: #000;
}
/* Description Toggle */
.desc-toggle {
grid-column: 1 / -1;
grid-row: 4;
justify-self: start;
font-size: 1.1rem;
color: var(--mc-green);
cursor: pointer;
padding: 2px 0;
text-shadow: 1px 1px 0 #000;
}
.desc-toggle:hover {
text-decoration: underline;
color: #fff;
}
/* Usage Example Section */
.usage-example {
background: #000;
color: #fff;
padding: 1rem;
border: 2px solid #555;
font-family: 'VT323', monospace;
font-size: 1.1rem;
overflow-x: auto;
}
.usage-example pre {
margin: 0;
}
@media (max-width: 640px) {
body {
padding: 12px;
font-size: 18px;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.stats {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.stat-card {
padding: 0.75rem;
}
.stat-value {
font-size: 2rem;
}
.file-item {
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem;
}
.file-name {
grid-column: 1;
grid-row: 1;
font-size: 1.3rem;
}
.file-meta {
grid-column: 1;
grid-row: 2;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 1rem;
}
.download-btn {
grid-column: 1;
grid-row: 3;
justify-self: stretch;
width: 100%;
justify-content: center;
padding: 12px;
margin-top: 0.5rem;
}
.file-desc {
grid-row: 4;
font-size: 1.1rem;
}
.desc-toggle {
grid-row: 5;
padding: 8px 0;
}
.command-container {
grid-row: 6;
}
.command-row {
flex-direction: column;
align-items: stretch;
padding: 0.75rem;
}
.command-text {
margin-bottom: 0.5rem;
width: 100%;
}
.icon-btn {
width: 100%;
height: 40px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>GMMC WORLD ARCHIVES</h1>
<div class="stats">
<div class="stat-card">
<div class="stat-value">{FILE_COUNT}</div>
<div class="stat-label">Available Files</div>
</div>
<div class="stat-card">
<div class="stat-value">{TOTAL_SIZE}</div>
<div class="stat-label">Total Size</div>
</div>
</div>
<h2>Available Files</h2>
<ul class="file-list">
{FILE_LIST_ITEMS}
</ul>
<h2>Usage Examples</h2>
<div class="usage-example">
<pre># Download with cURL
curl -O {BASE_URL}/download/file_id</pre>
</div>
<p style="color: #777; font-size: 1rem; margin-top: 3rem; text-align: center; text-shadow: 1px 1px 0 #000;">
<a href="https://gmmc.sirblob.co/">Minecraft Club at George Mason University</a>
</p>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Add 'More' toggle links for long descriptions
document.querySelectorAll('.file-desc').forEach(function (desc) {
if (desc.scrollHeight > desc.clientHeight + 2) {
const btn = document.createElement('span');
btn.className = 'desc-toggle';
btn.textContent = '[Show more]';
btn.addEventListener('click', function () {
const expanded = desc.classList.toggle('expanded');
btn.textContent = expanded ? '[Show less]' : '[Show more]';
});
if (desc.parentNode) {
desc.parentNode.insertBefore(btn, desc.nextSibling);
}
}
});
// Transform usage-pre into interactive rows
document.querySelectorAll('.usage-pre').forEach(pre => {
const text = pre.textContent.trim();
if (!text) return;
const lines = text.split('\n').filter(line => line.trim());
const container = document.createElement('div');
container.className = 'command-container';
lines.forEach(line => {
const row = document.createElement('div');
row.className = 'command-row';
const code = document.createElement('div');
code.className = 'command-text';
code.textContent = line;
const btn = document.createElement('button');
btn.className = 'icon-btn';
btn.title = 'Copy to clipboard';
// Use simple SVG icons that fit the pixel theme
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><rect x="9" y="9" width="13" height="13"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2-2v1"></path></svg>`;
btn.addEventListener('click', () => {
const copyText = line;
const copyToClipboard = (str) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(str);
}
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea');
ta.value = str;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); resolve(); }
catch (err) { reject(err); }
document.body.removeChild(ta);
});
};
copyToClipboard(copyText).then(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
btn.classList.add('success');
setTimeout(() => {
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"><rect x="9" y="9" width="13" height="13"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2-2v1"></path></svg>`;
btn.classList.remove('success');
}, 1500);
});
});
row.appendChild(code);
row.appendChild(btn);
container.appendChild(row);
});
if (pre.parentNode) {
pre.parentNode.appendChild(container);
}
});
});
</script>
</body>
</html>