diff --git a/api/src/admin.rs b/api/src/admin.rs new file mode 100644 index 0000000..f92a373 --- /dev/null +++ b/api/src/admin.rs @@ -0,0 +1,95 @@ +#[cfg(feature = "admin")] +// This module deals with all the routes which are protected by an api key +// Without a proper api key sent to the server these routes will +// respond with a 401 UNAUTHORIZED response + +// Below is the implementation required to have custom api keys which +// allow people to actually use the service +use std::env; +use std::collections::HashMap; +use rocket::serde::Serialize; +use rocket::serde::json::Json; +use rocket_dyn_templates::Template; +use rocket::request::{self, Outcome, Request, FromRequest}; +use rocket::http::Status; +use crate::db; +use crate::page; + +lazy_static! { + static ref DB_PATH: String = { + env::var("DB_PATH").unwrap_or("keys.db".into()) + }; +} + +#[derive(Serialize)] +struct ActionResponse(&'static str); + +pub struct ApiKey { + uid: String, + key: String +} + +#[derive(Debug)] +pub enum ApiKeyError { + Missing, + Invalid, +} + + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for ApiKey { + type Error = ApiKeyError; + async fn from_request(req: &'r Request<'_>) -> Outcome { + let key = req.headers().get_one("ADMIN-API-KEY"); + let uid = req.headers().get_one("ADMIN-API-UID"); + + if key.is_none() || uid.is_none() { + return Outcome::Failure((Status::Forbidden, ApiKeyError::Missing)); + } + + let (key, uid) = (key.unwrap(), uid.unwrap()); + + println!("Path to use for db file {:?}", DB_PATH.to_string()); + let db = db::Database::load(DB_PATH.as_str().into()).unwrap(); + if let Some(stored) = db.get(uid) { + if stored == key { + return Outcome::Success(ApiKey { + key: key.into(), + uid: uid.into() + }) + } + return Outcome::Failure((Status::Forbidden, ApiKeyError::Invalid)) + } + + return Outcome::Failure((Status::Forbidden, ApiKeyError::Invalid)) + } +} + + +#[get("/dashboard")] +pub async fn login_dashboard() -> Template { + // This page is basically just a login form + // However the rest of the form is present on this page, just hidden + let h: HashMap = HashMap::new(); // does not allocate + return Template::render("admin", &h); +} + +#[post("/dashboard")] +pub async fn dashboard(_key: ApiKey) -> Json { + // API Key auth'd for us so we don't need to bother checking, + // this just serves to confirm the credentials are correct + Json(ActionResponse("ok")) +} + + +#[post("/upload-video?&")] +async fn updload_video(_key: ApiKey, category: String, filename: String) -> &'static str { + todo!() +} + +#[delete("/remove-video?&")] +async fn remove_video(_key: ApiKey, category: String, filename: String) -> &'static str { + todo!() +} + + diff --git a/api/src/db.rs b/api/src/db.rs new file mode 100644 index 0000000..3b81037 --- /dev/null +++ b/api/src/db.rs @@ -0,0 +1,113 @@ +#[cfg(feature = "admin")] +// This module defines a tiny async interface for the "database" that this +// project uses for interfacing with the key store + +// WARN: at the moment there are no guarantees as far as data integrity is +// concerned. This means there are no real transactions +use std::fs::OpenOptions; +use std::io::prelude::Write; +use std::io::{BufWriter, BufReader}; +use std::path::PathBuf; +use std::collections::HashMap; +use rocket::serde::{Serialize, Deserialize}; + + +#[derive(Debug, Serialize, Deserialize)] +pub struct Database { + // uid's while random are fine to release as public as the key is more + // important however ideally neither should be release. Furthermore + // the frontend assists in keeping these secret by treating both as + // password fields as they are both randomly generated via a script + + // uid -> key + users: HashMap, + #[serde(skip)] + path: PathBuf +} + +impl Database { + // Opens a handle to a database file + // if none is found then one is created with the new path + // if there is one then the existing database is used + // any thing else is invalid and causes this to return Err + pub fn new(path: PathBuf) -> Result { + let file = OpenOptions::new() + .write(true) + .create(true) + .open(&path)?; + let writer = BufWriter::new(&file); + + // Dummy value to write in place + let empty = Database { users: HashMap::new(), path: "".into() }; + serde_json::to_writer(writer, &empty)?; + + Ok(empty) + } + + pub fn load(path: PathBuf) -> Result { + let file = OpenOptions::new() + .read(true) + .open(&path)?; + let reader = BufReader::new(&file); + let mut data: Database = serde_json::from_reader(reader)?; + + data.path = path; + return Ok(data); + } + + pub fn get(&self, uid: &str) -> Option<&String> { + return self.users.get(uid); + } + + fn write(&self) -> Result<(), std::io::Error> { + let file = OpenOptions::new() + .write(true) + .open(&self.path)?; + let writer = BufWriter::new(file); + serde_json::to_writer(writer, &self.path)?; + return Ok(()) + } + + pub fn remove(&mut self, uid: &str) -> Result<(), std::io::Error> { + self.users.remove_entry(uid); + self.write() + } + pub fn add(&mut self, key: &str, value: &str) -> Result<(), std::io::Error> { + println!("{:?}", self.path); + self.users.insert(key.into(), value.into()); + self.write() + } +} + + +#[cfg(test)] +mod db_tests { + use super::Database; + use rocket::tokio; + + const DB: &'static str = "new.db"; + + #[test] + fn load_db() { + match Database::new(DB.into()) { + Ok(db) => println!("Loaded new sample.db: {:?}", db), + Err(e) => panic!("Error fetching database: {}", e) + } + } + + #[test] + fn add_simple_entries() { + match Database::load(DB.into()) { + Ok(mut db) => db.add("key", "value").unwrap(), + Err(e) => println!("Error adding entries: {}", e) + } + } + + #[test] + fn remove_simple_entries() { + match Database::load(DB.into()) { + Ok(mut db) => db.remove("key").unwrap(), + Err(e) => println!("Error removing simple entries: {}", e) + } + } +} diff --git a/api/src/main.rs b/api/src/main.rs index 750ae96..9c2ee8b 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -10,21 +10,43 @@ mod video; mod thumbnail; mod common; +#[cfg(feature = "admin")] +mod admin; + +#[cfg(feature = "admin")] +mod sec; + +#[cfg(feature = "admin")] +mod db; + #[rocket::main] async fn main() { - // emoji's crash my terminal + // rid ourselves of random emoji's in logs env::set_var("ROCKET_CLI_COLORS", "false"); - /* - * Some of the target vars that we look for - * CLIP_DIR - */ - // Comments denote what kind of data each sub uri actually returns - let _ = rocket::build() - .mount("/", routes![page::home, page::category, page::video]) // html - .mount("/static", routes![page::files]) // files - .mount("/api", routes![category::list, video::list]) // json - .mount("/thumbnail", routes![thumbnail::get]) // images - .mount("/video", routes![video::get_video]) // videos - .attach(Template::fairing()) - .launch().await; + + + if cfg!(feature = "admin") { + #[cfg(feature = "admin")] + let _ = rocket::build() + .mount("/", routes![page::home, page::category, page::video]) // html + .mount("/static", routes![page::files]) // files + .mount("/api", routes![category::list, video::list]) // json + .mount("/thumbnail", routes![thumbnail::get]) // images + .mount("/video", routes![video::get_video]) // videos + .mount("/admin?", routes![ + admin::login_dashboard, + admin::dashboard + ]) + .attach(Template::fairing()) + .launch().await; + } else { + let _ = rocket::build() + .mount("/", routes![page::home, page::category, page::video]) // html + .mount("/static", routes![page::files]) // files + .mount("/api", routes![category::list, video::list]) // json + .mount("/thumbnail", routes![thumbnail::get]) // images + .mount("/video", routes![video::get_video]) // videos + .attach(Template::fairing()) + .launch().await; + } } diff --git a/api/src/sec.rs b/api/src/sec.rs new file mode 100644 index 0000000..9fbd375 --- /dev/null +++ b/api/src/sec.rs @@ -0,0 +1,26 @@ +#[cfg(feature = "admin")] +// This module concerns itself with the encrypting/decrypting of passwords +// as well as the storage of those items +use rocket::tokio::io::AsyncReadExt; +use rocket::tokio::fs; + +async fn random_string() -> Result { + // First we read in some bytes from /dev/urandom + let mut handle = fs::File::open("/dev/urandom").await?; + let mut buffer = [0;32]; + handle.read(&mut buffer[..]).await?; + + Ok(base64::encode_config(buffer, base64::URL_SAFE_NO_PAD)) +} + + +#[cfg(test)] +mod sec_api_tests { + use rocket::tokio; + use super::random_string; + + #[tokio::test] + async fn generate_string() { + println!("{:?}", random_string().await); + } +}