initial commit

This commit is contained in:
kittentm 2026-07-24 01:36:58 +02:00
commit b61931d8a1
Signed by: kitten
GPG key ID: 394B4EABE4405A83
17 changed files with 5200 additions and 0 deletions

7
.env.example Normal file
View 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
View file

@ -0,0 +1,5 @@
/target
.env
private_key.pem
public_key.pem
cdn_keys.db

4069
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

22
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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(),
},
),
}
}

View 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)))
}

View file

@ -0,0 +1,2 @@
pub mod create;
pub mod revoke;

View 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
View file

@ -0,0 +1,2 @@
pub mod index;
pub mod keys;

40
src/routes/i.rs Normal file
View 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
View file

@ -0,0 +1,3 @@
pub mod admin;
pub mod i;
pub mod upload;

118
src/routes/upload.rs Normal file
View 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
View 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">&laquo; Previous</a>
{% else %}
<span class="page-btn disabled">&laquo; Previous</span>
{% endif %}
{% if has_next %}
<a href="/admin?key={{ key }}&page={{ next_page }}&per_page={{ per_page }}" class="page-btn">Next &raquo;</a>
{% else %}
<span class="page-btn disabled">Next &raquo;</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>