initial commit
This commit is contained in:
commit
b61931d8a1
17 changed files with 5200 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
MINIO_ENDPOINT=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_REGION=
|
||||
ALLOWED_BUCKET=
|
||||
DB_PASSPHRASE=
|
||||
ALLOWED_EXTENSIONS=jpg,jpeg,png,gif,webp
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
.env
|
||||
private_key.pem
|
||||
public_key.pem
|
||||
cdn_keys.db
|
||||
4069
Cargo.lock
generated
Normal file
4069
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "kanzashi-cdn"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
aws-config = "1.10.0"
|
||||
aws-credential-types = "1.3.0"
|
||||
aws-sdk-s3 = "1.139.0"
|
||||
dotenvy = "0.15.7"
|
||||
ed25519-dalek = "3.0.0"
|
||||
hex = "0.4.3"
|
||||
hmac = "0.13.0"
|
||||
jsonwebtoken = "10.4.0"
|
||||
rand = "0.10.2"
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
rocket_dyn_templates = { version = "0.2.0", features = ["tera"] }
|
||||
rusqlite = { version = "0.40.1", features = ["bundled-sqlcipher"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
sha2 = "0.11.0"
|
||||
tokio = { version = "1.53.1", features = ["full"] }
|
||||
uuid = { version = "1.24.0", features = ["v4"] }
|
||||
40
src/db.rs
Normal file
40
src/db.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use crate::models::DbPool;
|
||||
|
||||
pub fn init_db() -> DbPool {
|
||||
let conn = Connection::open("cdn_keys.db").expect("Failed to open SQLite database");
|
||||
let db_pass = std::env::var("DB_PASSPHRASE").expect("DB_PASSPHRASE missing");
|
||||
conn.pragma_update(None, "key", &db_pass).expect("Failed to set encryption key");
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
public_key_hex TEXT NOT NULL,
|
||||
active INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
created_by_ip TEXT NOT NULL DEFAULT '127.0.0.1'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.expect("Failed to initialize keys table");
|
||||
|
||||
conn.execute(
|
||||
"ALTER TABLE keys ADD COLUMN created_by_ip TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||||
[],
|
||||
)
|
||||
.ok();
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS panel_access_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
accessed_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.expect("Failed to initialize logs table");
|
||||
|
||||
Arc::new(Mutex::new(conn))
|
||||
}
|
||||
94
src/guards.rs
Normal file
94
src/guards.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
use rocket::http::Status;
|
||||
use rocket::request::{FromRequest, Outcome, Request};
|
||||
use rocket::State;
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use crate::models::DbPool;
|
||||
|
||||
pub struct ClientIp(pub String);
|
||||
pub struct AsymmetricAuth;
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for ClientIp {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
if let Some(cf_ip) = req.headers().get_one("CF-Connecting-IP") {
|
||||
return Outcome::Success(ClientIp(cf_ip.to_string()));
|
||||
}
|
||||
if let Some(forwarded) = req.headers().get_one("X-Forwarded-For") {
|
||||
if let Some(first_ip) = forwarded.split(',').next() {
|
||||
return Outcome::Success(ClientIp(first_ip.trim().to_string()));
|
||||
}
|
||||
}
|
||||
let ip = req
|
||||
.remote()
|
||||
.map(|addr| addr.ip().to_string())
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
|
||||
Outcome::Success(ClientIp(ip))
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r> FromRequest<'r> for AsymmetricAuth {
|
||||
type Error = &'static str;
|
||||
|
||||
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
|
||||
let signature_hex = req.headers().get_one("X-Signature");
|
||||
let timestamp = req.headers().get_one("X-Timestamp");
|
||||
|
||||
let (sig_hex, ts) = match (signature_hex, timestamp) {
|
||||
(Some(s), Some(t)) => (s, t),
|
||||
_ => return Outcome::Error((Status::Unauthorized, "Missing signature or timestamp header")),
|
||||
};
|
||||
|
||||
let request_time: u64 = match ts.parse() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return Outcome::Error((Status::BadRequest, "Invalid timestamp")),
|
||||
};
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
|
||||
if now.saturating_sub(request_time) > 300 {
|
||||
return Outcome::Error((Status::Unauthorized, "Request expired"));
|
||||
}
|
||||
|
||||
let sig_bytes = match hex::decode(sig_hex) {
|
||||
Ok(b) if b.len() == 64 => b,
|
||||
_ => return Outcome::Error((Status::BadRequest, "Malformed hex signature")),
|
||||
};
|
||||
|
||||
let signature = match Signature::from_slice(&sig_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Outcome::Error((Status::BadRequest, "Invalid signature format")),
|
||||
};
|
||||
|
||||
let db_state = match req.guard::<&State<DbPool>>().await {
|
||||
Outcome::Success(s) => s.inner(),
|
||||
_ => return Outcome::Error((Status::InternalServerError, "Database connection missing")),
|
||||
};
|
||||
|
||||
let message = format!("{}:{}:{}", req.method(), req.uri().path(), ts);
|
||||
let conn = db_state.lock().unwrap();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT public_key_hex FROM keys WHERE active = 1")
|
||||
.unwrap();
|
||||
|
||||
let active_pub_keys = stmt.query_map([], |row| row.get::<_, String>(0)).unwrap();
|
||||
|
||||
for pub_hex in active_pub_keys.flatten() {
|
||||
if let Ok(bytes) = hex::decode(&pub_hex) {
|
||||
if let Ok(array) = <[u8; 32]>::try_from(bytes.as_slice()) {
|
||||
if let Ok(pub_key) = VerifyingKey::from_bytes(&array) {
|
||||
if pub_key.verify(message.as_bytes(), &signature).is_ok() {
|
||||
return Outcome::Success(AsymmetricAuth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Outcome::Error((Status::Unauthorized, "Invalid or revoked Ed25519 signature"))
|
||||
}
|
||||
}
|
||||
81
src/main.rs
Normal file
81
src/main.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#[macro_use]
|
||||
extern crate rocket;
|
||||
|
||||
mod db;
|
||||
mod guards;
|
||||
mod models;
|
||||
mod routes;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_credential_types::Credentials;
|
||||
use aws_sdk_s3::config::RequestChecksumCalculation;
|
||||
use aws_sdk_s3::Client;
|
||||
use rand::RngExt;
|
||||
use rocket_dyn_templates::Template;
|
||||
|
||||
use models::AdminConsoleKey;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AllowedExtensions(pub HashSet<String>);
|
||||
|
||||
#[launch]
|
||||
async fn rocket() -> _ {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let endpoint = std::env::var("MINIO_ENDPOINT").expect("MINIO_ENDPOINT missing");
|
||||
let access_key = std::env::var("MINIO_ACCESS_KEY").expect("MINIO_ACCESS_KEY missing");
|
||||
let secret_key = std::env::var("MINIO_SECRET_KEY").expect("MINIO_SECRET_KEY missing");
|
||||
let region = std::env::var("MINIO_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let allowed_bucket = std::env::var("ALLOWED_BUCKET").expect("ALLOWED_BUCKET missing");
|
||||
|
||||
let allowed_ext_str = std::env::var("ALLOWED_EXTENSIONS")
|
||||
.unwrap_or_else(|_| "jpg,jpeg,png,gif,webp".to_string());
|
||||
|
||||
let allowed_extensions: HashSet<String> = allowed_ext_str
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let db_pool = db::init_db();
|
||||
|
||||
let mut random_bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut random_bytes);
|
||||
let console_admin_key = hex::encode(random_bytes);
|
||||
|
||||
println!("\n==================================================================");
|
||||
println!("key: {}", console_admin_key);
|
||||
println!("Panel located at /admin?key={}", console_admin_key);
|
||||
println!("==================================================================\n");
|
||||
|
||||
let credentials = Credentials::new(access_key, secret_key, None, None, "static");
|
||||
let s3_config = aws_sdk_s3::Config::builder()
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(credentials)
|
||||
.region(aws_sdk_s3::config::Region::new(region))
|
||||
.force_path_style(true)
|
||||
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
|
||||
.build();
|
||||
|
||||
let client = Client::from_conf(s3_config);
|
||||
|
||||
rocket::build()
|
||||
.manage(client)
|
||||
.manage(allowed_bucket)
|
||||
.manage(AllowedExtensions(allowed_extensions))
|
||||
.manage(db_pool)
|
||||
.manage(AdminConsoleKey(console_admin_key))
|
||||
.attach(Template::fairing())
|
||||
.mount(
|
||||
"/",
|
||||
routes![
|
||||
routes::i::get_asset,
|
||||
routes::upload::upload_asset,
|
||||
routes::admin::index::admin_dashboard,
|
||||
routes::admin::keys::create::create_key,
|
||||
routes::admin::keys::revoke::revoke_key,
|
||||
],
|
||||
)
|
||||
}
|
||||
38
src/models.rs
Normal file
38
src/models.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
use serde::Serialize;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use rocket::form::FromForm;
|
||||
|
||||
pub type DbPool = Arc<Mutex<Connection>>;
|
||||
|
||||
pub struct AdminConsoleKey(pub String);
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct KeyMetaData {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub public_key_hex: String,
|
||||
pub active: bool,
|
||||
pub created_at: i64,
|
||||
pub created_by_ip: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct AccessLog {
|
||||
pub id: i64,
|
||||
pub ip: String,
|
||||
pub endpoint: String,
|
||||
pub accessed_at: i64,
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct CreateKeyForm {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct RevokeKeyForm {
|
||||
pub key: String,
|
||||
pub key_id: String,
|
||||
}
|
||||
139
src/routes/admin/index.rs
Normal file
139
src/routes/admin/index.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use aws_sdk_s3::Client;
|
||||
use rocket::State;
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
use rusqlite::params;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::guards::ClientIp;
|
||||
use crate::models::{AccessLog, AdminConsoleKey, DbPool, KeyMetaData};
|
||||
|
||||
#[get("/admin?<key>&<new_private_key>&<page>&<per_page>")]
|
||||
pub async fn admin_dashboard(
|
||||
key: Option<String>,
|
||||
new_private_key: Option<String>,
|
||||
page: Option<usize>,
|
||||
per_page: Option<usize>,
|
||||
console_key: &State<AdminConsoleKey>,
|
||||
allowed_bucket: &State<String>,
|
||||
s3_client: &State<Client>,
|
||||
db: &State<DbPool>,
|
||||
client_ip: ClientIp,
|
||||
) -> Template {
|
||||
match key {
|
||||
Some(ref k) if k == &console_key.0 => {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
|
||||
let (api_keys_list, logs, total_visits) = {
|
||||
let conn = db.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO panel_access_logs (ip, endpoint, accessed_at) VALUES (?1, '/admin', ?2)",
|
||||
params![client_ip.0, now],
|
||||
).ok();
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, label, public_key_hex, active, created_at, created_by_ip FROM keys ORDER BY created_at DESC")
|
||||
.unwrap();
|
||||
|
||||
let keys: Vec<KeyMetaData> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(KeyMetaData {
|
||||
id: row.get(0)?,
|
||||
label: row.get(1)?,
|
||||
public_key_hex: row.get(2)?,
|
||||
active: row.get::<_, i32>(3)? == 1,
|
||||
created_at: row.get(4)?,
|
||||
created_by_ip: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
let mut log_stmt = conn
|
||||
.prepare("SELECT id, ip, endpoint, accessed_at FROM panel_access_logs ORDER BY accessed_at DESC LIMIT 20")
|
||||
.unwrap();
|
||||
|
||||
let logs_list: Vec<AccessLog> = log_stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AccessLog {
|
||||
id: row.get(0)?,
|
||||
ip: row.get(1)?,
|
||||
endpoint: row.get(2)?,
|
||||
accessed_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
let visits: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM panel_access_logs", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
(keys, logs_list, visits)
|
||||
};
|
||||
|
||||
let mut all_s3_keys = Vec::new();
|
||||
if let Ok(resp) = s3_client.list_objects_v2().bucket(allowed_bucket.inner()).send().await {
|
||||
if let Some(objects) = resp.contents {
|
||||
for obj in objects {
|
||||
if let Some(k) = obj.key {
|
||||
all_s3_keys.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_s3_keys.sort();
|
||||
|
||||
let total_items = all_s3_keys.len();
|
||||
let limit = per_page.unwrap_or(50).max(1);
|
||||
let current_page = page.unwrap_or(1).max(1);
|
||||
|
||||
let total_pages = if total_items == 0 {
|
||||
1
|
||||
} else {
|
||||
(total_items + limit - 1) / limit
|
||||
};
|
||||
|
||||
let start = ((current_page - 1) * limit).min(total_items);
|
||||
let end = (start + limit).min(total_items);
|
||||
let paginated_s3_keys = all_s3_keys[start..end].to_vec();
|
||||
|
||||
let has_prev = current_page > 1;
|
||||
let has_next = current_page < total_pages;
|
||||
let prev_page = if has_prev { current_page - 1 } else { 1 };
|
||||
let next_page = if has_next { current_page + 1 } else { total_pages };
|
||||
|
||||
Template::render(
|
||||
"admin",
|
||||
context! {
|
||||
bucket: allowed_bucket.inner(),
|
||||
key: console_key.0.clone(),
|
||||
authenticated: true,
|
||||
api_keys: api_keys_list,
|
||||
logs: logs,
|
||||
total_visits: total_visits,
|
||||
generated_private_key: new_private_key,
|
||||
keys: paginated_s3_keys,
|
||||
total_items: total_items,
|
||||
current_page: current_page,
|
||||
total_pages: total_pages,
|
||||
per_page: limit,
|
||||
has_prev: has_prev,
|
||||
has_next: has_next,
|
||||
prev_page: prev_page,
|
||||
next_page: next_page,
|
||||
},
|
||||
)
|
||||
}
|
||||
_ => Template::render(
|
||||
"admin",
|
||||
context! {
|
||||
authenticated: false,
|
||||
error: key.is_some(),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
44
src/routes/admin/keys/create.rs
Normal file
44
src/routes/admin/keys/create.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
use ed25519_dalek::SigningKey;
|
||||
use rand::RngExt;
|
||||
use rocket::form::Form;
|
||||
use rocket::http::Status;
|
||||
use rocket::response::Redirect;
|
||||
use rocket::State;
|
||||
use rusqlite::params;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::guards::ClientIp;
|
||||
use crate::models::{AdminConsoleKey, CreateKeyForm, DbPool};
|
||||
|
||||
#[post("/admin/keys/create", data = "<form>")]
|
||||
pub async fn create_key(
|
||||
form: Form<CreateKeyForm>,
|
||||
console_key: &State<AdminConsoleKey>,
|
||||
db: &State<DbPool>,
|
||||
client_ip: ClientIp,
|
||||
) -> Result<Redirect, Status> {
|
||||
if form.key != console_key.0 {
|
||||
return Ok(Redirect::to(format!("/admin?key={}&error=1", form.key)));
|
||||
}
|
||||
|
||||
let mut secret_bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut secret_bytes);
|
||||
|
||||
let signing_key = SigningKey::from_bytes(&secret_bytes);
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
|
||||
let pub_hex = hex::encode(verifying_key.as_bytes());
|
||||
let priv_hex = hex::encode(secret_bytes);
|
||||
|
||||
let key_id = format!("key_{}", &pub_hex[..8]);
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO keys (id, label, public_key_hex, active, created_at, created_by_ip) VALUES (?1, ?2, ?3, 1, ?4, ?5)",
|
||||
params![key_id, form.label, pub_hex, now, client_ip.0],
|
||||
)
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
Ok(Redirect::to(format!("/admin?key={}&new_private_key={}", console_key.0, priv_hex)))
|
||||
}
|
||||
2
src/routes/admin/keys/mod.rs
Normal file
2
src/routes/admin/keys/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod create;
|
||||
pub mod revoke;
|
||||
27
src/routes/admin/keys/revoke.rs
Normal file
27
src/routes/admin/keys/revoke.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
use rocket::form::Form;
|
||||
use rocket::http::Status;
|
||||
use rocket::response::Redirect;
|
||||
use rocket::State;
|
||||
use rusqlite::params;
|
||||
|
||||
use crate::models::{AdminConsoleKey, DbPool, RevokeKeyForm};
|
||||
|
||||
#[post("/admin/keys/revoke", data = "<form>")]
|
||||
pub async fn revoke_key(
|
||||
form: Form<RevokeKeyForm>,
|
||||
console_key: &State<AdminConsoleKey>,
|
||||
db: &State<DbPool>,
|
||||
) -> Result<Redirect, Status> {
|
||||
if form.key != console_key.0 {
|
||||
return Ok(Redirect::to(format!("/admin?key={}&error=1", form.key)));
|
||||
}
|
||||
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE keys SET active = 0 WHERE id = ?1",
|
||||
params![form.key_id],
|
||||
)
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
|
||||
Ok(Redirect::to(format!("/admin?key={}", console_key.0)))
|
||||
}
|
||||
2
src/routes/admin/mod.rs
Normal file
2
src/routes/admin/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod index;
|
||||
pub mod keys;
|
||||
40
src/routes/i.rs
Normal file
40
src/routes/i.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use aws_sdk_s3::Client;
|
||||
use rocket::http::{ContentType, Status};
|
||||
use rocket::State;
|
||||
use std::path::Path;
|
||||
|
||||
#[get("/i/<filename>")]
|
||||
pub async fn get_asset(
|
||||
filename: String,
|
||||
allowed_bucket: &State<String>,
|
||||
s3_client: &State<Client>,
|
||||
) -> Result<(ContentType, Vec<u8>), Status> {
|
||||
let resp = s3_client
|
||||
.get_object()
|
||||
.bucket(allowed_bucket.inner())
|
||||
.key(&filename)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| Status::NotFound)?;
|
||||
|
||||
let content_type = resp
|
||||
.content_type()
|
||||
.and_then(|ct| ContentType::parse_flexible(ct))
|
||||
.or_else(|| {
|
||||
Path::new(&filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.and_then(ContentType::from_extension)
|
||||
})
|
||||
.unwrap_or(ContentType::Binary);
|
||||
|
||||
let data = resp
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|_| Status::InternalServerError)?
|
||||
.into_bytes()
|
||||
.to_vec();
|
||||
|
||||
Ok((content_type, data))
|
||||
}
|
||||
3
src/routes/mod.rs
Normal file
3
src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod admin;
|
||||
pub mod i;
|
||||
pub mod upload;
|
||||
118
src/routes/upload.rs
Normal file
118
src/routes/upload.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use aws_sdk_s3::Client;
|
||||
use rocket::form::{Form, FromForm, Lenient};
|
||||
use rocket::fs::TempFile;
|
||||
use rocket::http::Status;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::State;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::guards::AsymmetricAuth;
|
||||
use crate::AllowedExtensions;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UploadResponse {
|
||||
pub message: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
pub struct UploadForm<'r> {
|
||||
pub file: TempFile<'r>,
|
||||
}
|
||||
|
||||
#[post("/upload", data = "<form>")]
|
||||
pub async fn upload_asset(
|
||||
form: Form<Lenient<UploadForm<'_>>>,
|
||||
_auth: AsymmetricAuth,
|
||||
allowed_bucket: &State<String>,
|
||||
allowed_exts: &State<AllowedExtensions>,
|
||||
s3_client: &State<Client>,
|
||||
) -> Result<Json<UploadResponse>, Status> {
|
||||
let mut upload = form.into_inner();
|
||||
let file = &mut upload.file;
|
||||
|
||||
let raw_filename = file
|
||||
.raw_name()
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
let mut ext = Path::new(&raw_filename)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
if ext.is_empty() {
|
||||
if let Some(ct) = file.content_type() {
|
||||
ext = match (ct.top().as_str(), ct.sub().as_str()) {
|
||||
("image", "jpeg") => "jpg".to_string(),
|
||||
("image", "png") => "png".to_string(),
|
||||
("image", "gif") => "gif".to_string(),
|
||||
("image", "webp") => "webp".to_string(),
|
||||
_ => ct.sub().as_str().to_lowercase(),
|
||||
};
|
||||
} else {
|
||||
eprintln!("missing file extension and Content-Type header");
|
||||
return Err(Status::BadRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed_exts.0.contains(&ext) {
|
||||
eprintln!(
|
||||
"Rejected upload '{}' with forbidden extension: '.{}'",
|
||||
raw_filename, ext
|
||||
);
|
||||
return Err(Status::UnprocessableEntity);
|
||||
}
|
||||
|
||||
let final_key = if Path::new(&raw_filename).extension().is_none() {
|
||||
format!("{}.{}", raw_filename, ext)
|
||||
} else {
|
||||
raw_filename
|
||||
};
|
||||
|
||||
let content_type_str = file
|
||||
.content_type()
|
||||
.map(|ct| ct.to_string())
|
||||
.unwrap_or_else(|| format!("image/{}", if ext == "jpg" { "jpeg" } else { &ext }));
|
||||
|
||||
let body_bytes = match file.path() {
|
||||
Some(path) => tokio::fs::read(path)
|
||||
.await
|
||||
.map_err(|_| Status::InternalServerError)?,
|
||||
None => {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buf = Vec::new();
|
||||
file.open()
|
||||
.await
|
||||
.map_err(|_| Status::InternalServerError)?
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(|_| Status::InternalServerError)?;
|
||||
buf
|
||||
}
|
||||
};
|
||||
|
||||
let body = aws_sdk_s3::primitives::ByteStream::from(body_bytes);
|
||||
|
||||
s3_client
|
||||
.put_object()
|
||||
.bucket(allowed_bucket.inner())
|
||||
.key(&final_key)
|
||||
.content_type(content_type_str)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
eprintln!("S3 Upload Error: {:?}", e);
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
|
||||
Ok(Json(UploadResponse {
|
||||
message: "Upload successful".to_string(),
|
||||
key: final_key,
|
||||
}))
|
||||
}
|
||||
469
templates/admin.html.tera
Normal file
469
templates/admin.html.tera
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CDN Manager Control Panel</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&display=swap');
|
||||
|
||||
.archivo-black { font-family: "Archivo Black", sans-serif; font-weight: 400; }
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: #1d1e1f;
|
||||
color: #c9d1d9;
|
||||
padding: 2rem 1rem;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
background-color: #1a1a1f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0.85rem 1rem;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar-menu { display: flex; flex-direction: column; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: #8b949e;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.active-nav {
|
||||
background: rgba(0, 174, 239, 0.1);
|
||||
border-left: 3px solid #00aeef;
|
||||
color: #00aeef;
|
||||
font-weight: 600;
|
||||
padding-left: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.settings-main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
background-color: #1a1a1f;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 0.85rem 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-body { padding: 1.5rem; }
|
||||
|
||||
.form-group { display: flex; gap: 10px; margin-bottom: 1.5rem; }
|
||||
|
||||
input[type="text"] {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="text"]:focus { border-color: #00aeef; }
|
||||
|
||||
button, .page-btn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: #00aeef;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
button:hover, .page-btn:hover { opacity: 0.9; }
|
||||
|
||||
.page-btn.disabled {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: rgba(248, 81, 73, 0.2);
|
||||
color: #f85149;
|
||||
border: 1px solid rgba(248, 81, 73, 0.4);
|
||||
}
|
||||
|
||||
button.danger:hover { background: rgba(248, 81, 73, 0.3); }
|
||||
|
||||
.alert-box {
|
||||
background-color: rgba(0, 174, 239, 0.1);
|
||||
border-left: 4px solid #00aeef;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.alert-box h3 { color: #ffffff; font-size: 1rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.alert-box code {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #00aeef;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
th { background-color: rgba(0, 0, 0, 0.2); color: #8b949e; font-weight: 600; }
|
||||
|
||||
td code { font-family: monospace; color: #00aeef; }
|
||||
|
||||
.version-tag {
|
||||
font-size: 0.85rem;
|
||||
background-color: rgba(0, 174, 239, 0.15);
|
||||
color: #00aeef;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 174, 239, 0.3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background-color: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.revoked {
|
||||
background-color: rgba(248, 81, 73, 0.15);
|
||||
color: #f85149;
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
}
|
||||
|
||||
.geo-tag {
|
||||
font-size: 0.85rem;
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.88rem;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.pagination-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-wrapper { max-width: 420px; margin: 4rem auto; width: 100%; }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.settings-container { flex-direction: row; align-items: flex-start; }
|
||||
.settings-sidebar { width: 280px; flex-shrink: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if key %}
|
||||
<div class="settings-container">
|
||||
|
||||
<aside class="settings-sidebar">
|
||||
<div class="sidebar-header archivo-black">
|
||||
CONTROL PANEL
|
||||
</div>
|
||||
<nav class="sidebar-menu">
|
||||
<div class="nav-item active-nav"><span>Key Management</span></div>
|
||||
<div class="nav-item"><span>Stats</span></div>
|
||||
<div class="nav-item"><span>Bucket Objects</span></div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="settings-main-content">
|
||||
|
||||
{% if generated_private_key %}
|
||||
<div class="alert-box">
|
||||
<h3>Key Pair Generated!</h3>
|
||||
<p>Copy this private key now. It will not be shown again:</p>
|
||||
<code>ED25519_PRIVATE_KEY={{ generated_private_key }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header archivo-black">
|
||||
<span>API KEY MANAGEMENT</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<form action="/admin/keys/create" method="POST" class="form-group">
|
||||
<input type="hidden" name="key" value="{{ key }}">
|
||||
<input type="text" name="label" placeholder="App / Client Label" required>
|
||||
<button type="submit">Generate Key Pair</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label / ID</th>
|
||||
<th>Public Key</th>
|
||||
<th>Created By IP</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for k in api_keys %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ k.label }}</strong><br>
|
||||
<small style="color: #8b949e;">{{ k.id }}</small>
|
||||
</td>
|
||||
<td><code>{{ k.public_key_hex }}</code></td>
|
||||
<td>
|
||||
<span class="ip-address">{{ k.created_by_ip }}</span><br>
|
||||
<span class="geo-location geo-tag" data-ip="{{ k.created_by_ip }}">Resolving location...</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if k.active %}
|
||||
<span class="status-badge active">Active</span>
|
||||
{% else %}
|
||||
<span class="status-badge revoked">Revoked</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if k.active %}
|
||||
<form action="/admin/keys/revoke" method="POST" style="margin: 0;">
|
||||
<input type="hidden" name="key" value="{{ key }}">
|
||||
<input type="hidden" name="key_id" value="{{ k.id }}">
|
||||
<button type="submit" class="danger">Revoke</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="color: #64748b; font-size: 0.85rem;">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" style="color: #8b949e;">No API keys registered yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header archivo-black">
|
||||
<span>STATS</span>
|
||||
<span class="version-tag">Total Visits: {{ total_visits }}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Geolocation</th>
|
||||
<th>Accessed Endpoint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td><code>{{ log.ip }}</code></td>
|
||||
<td><span class="geo-location geo-tag" data-ip="{{ log.ip }}">Resolving location...</span></td>
|
||||
<td><span style="color: #4ade80;">{{ log.endpoint }}</span></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" style="color: #8b949e;">No access logs recorded yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header archivo-black">
|
||||
<span>BUCKET OBJECTS</span>
|
||||
<span class="version-tag">{{ total_items }} Total Objects</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Object Key</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for file_key in keys %}
|
||||
<tr>
|
||||
<td><code>{{ file_key }}</code></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td style="color: #8b949e;">No assets uploaded yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<div>
|
||||
Page <strong>{{ current_page }}</strong> of <strong>{{ total_pages }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="pagination-nav">
|
||||
{% if has_prev %}
|
||||
<a href="/admin?key={{ key }}&page={{ prev_page }}&per_page={{ per_page }}" class="page-btn">« Previous</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled">« Previous</span>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next %}
|
||||
<a href="/admin?key={{ key }}&page={{ next_page }}&per_page={{ per_page }}" class="page-btn">Next »</a>
|
||||
{% else %}
|
||||
<span class="page-btn disabled">Next »</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const geoElements = document.querySelectorAll(".geo-location");
|
||||
const cache = {};
|
||||
|
||||
for (const el of geoElements) {
|
||||
const ip = el.getAttribute("data-ip");
|
||||
if (!ip || ip === "127.0.0.1" || ip === "::1" || ip.startsWith("192.168.") || ip.startsWith("10.")) {
|
||||
el.innerText = "On server";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cache[ip]) {
|
||||
el.innerText = cache[ip];
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://ip-api.com/json/${ip}?fields=status,country,city`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
const locationStr = `${data.city}, ${data.country}`;
|
||||
cache[ip] = locationStr;
|
||||
el.innerText = locationStr;
|
||||
} else {
|
||||
el.innerText = "Unknown Location";
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerText = "Location N/A";
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% else %}
|
||||
<div class="login-wrapper">
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header archivo-black">
|
||||
ADMIN LOGIN
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if error %}
|
||||
<p style="color: #f85149; margin-bottom: 1rem; font-size: 0.9rem;">Invalid Key</p>
|
||||
{% endif %}
|
||||
<p style="color: #8b949e; font-size: 0.9rem; margin-bottom: 1rem;">
|
||||
Check Console for key
|
||||
</p>
|
||||
<form action="/admin" method="GET">
|
||||
<input type="text" name="key" placeholder="Enter console access key..." required style="width: 100%; margin-bottom: 1rem;">
|
||||
<button type="submit" style="width: 100%;">Access Dashboard</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue