From ff73995ff30eb0c4d269523e895c2f61b784ed35 Mon Sep 17 00:00:00 2001 From: shockrah Date: Wed, 2 Feb 2022 23:02:39 -0800 Subject: [PATCH] !!+ Optional backend code for admin feature Features implemented thus far are proven to be working however they will be left as optional features until they are more properly tested in some kind of staging environment --- api/src/admin.rs | 95 +++++++++++++++++++++++++++++++++++++++ api/src/db.rs | 113 +++++++++++++++++++++++++++++++++++++++++++++++ api/src/main.rs | 50 +++++++++++++++------ api/src/sec.rs | 26 +++++++++++ 4 files changed, 270 insertions(+), 14 deletions(-) create mode 100644 api/src/admin.rs create mode 100644 api/src/db.rs create mode 100644 api/src/sec.rs 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); + } +}