Compare commits
	
		
			No commits in common. "61b3a5fe801f79cb614f4343f205228e7fa2c4ff" and "9671be1ff349586ae0e50d6fecbf4c8f961281d6" have entirely different histories.
		
	
	
		
			61b3a5fe80
			...
			9671be1ff3
		
	
		
							
								
								
									
										3
									
								
								clippable-svc/.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								clippable-svc/.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
{
 | 
			
		||||
    "rust.all_features": true
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										9
									
								
								clippable-svc/.ycm_extra_conf.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								clippable-svc/.ycm_extra_conf.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
			
		||||
def Settings(**kwargs):
 | 
			
		||||
    return {
 | 
			
		||||
        'ls': {
 | 
			
		||||
            'cargo': {
 | 
			
		||||
                'features': ['admin'],
 | 
			
		||||
                'noDefaultFeatures': True
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
							
								
								
									
										2112
									
								
								clippable-svc/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2112
									
								
								clippable-svc/Cargo.lock
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										24
									
								
								clippable-svc/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								clippable-svc/Cargo.toml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "clippable-server"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
edition = "2018"
 | 
			
		||||
 | 
			
		||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
rocket = { version = "0.5.0-rc.1", features = [ "json" ] }
 | 
			
		||||
serde = { version = "1.0", features = [ "derive" ] }
 | 
			
		||||
lazy_static = { version = "1.4.0" }
 | 
			
		||||
 | 
			
		||||
# Required only for builds that contain admin features
 | 
			
		||||
serde_json = { version = "1.0", optional = true }
 | 
			
		||||
base64 = { version = "0.13.0", optional = true }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
[dependencies.rocket_dyn_templates]
 | 
			
		||||
version = "0.1.0-rc.1"
 | 
			
		||||
features = ["tera"]
 | 
			
		||||
 | 
			
		||||
[features]
 | 
			
		||||
default = []
 | 
			
		||||
admin = ["serde_json", "base64"]
 | 
			
		||||
							
								
								
									
										51
									
								
								clippable-svc/src/admin/apikey.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								clippable-svc/src/admin/apikey.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
			
		||||
use rocket::request::{Outcome, Request, FromRequest};
 | 
			
		||||
use rocket::async_trait;
 | 
			
		||||
use rocket::http::Status;
 | 
			
		||||
 | 
			
		||||
use crate::db::{self, DB_PATH};
 | 
			
		||||
 | 
			
		||||
pub struct ApiKey {
 | 
			
		||||
    // These are used by rocket's driver code/decl macros however cargo
 | 
			
		||||
    // is not able to check those as the code is generated at compile time.
 | 
			
		||||
    // The dead code thing is just to stifle pointless warnings
 | 
			
		||||
    #[allow(dead_code)]
 | 
			
		||||
    uid: String,
 | 
			
		||||
    #[allow(dead_code)]
 | 
			
		||||
    key: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Debug)]
 | 
			
		||||
pub enum ApiKeyError {
 | 
			
		||||
    Missing,
 | 
			
		||||
    Invalid,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[async_trait]
 | 
			
		||||
impl<'r> FromRequest<'r> for ApiKey {
 | 
			
		||||
    type Error = ApiKeyError;
 | 
			
		||||
    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
 | 
			
		||||
        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))
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										76
									
								
								clippable-svc/src/admin/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								clippable-svc/src/admin/mod.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
			
		||||
#[cfg(feature = "admin")]
 | 
			
		||||
 | 
			
		||||
mod apikey;
 | 
			
		||||
mod response;
 | 
			
		||||
mod util;
 | 
			
		||||
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::io::Result;
 | 
			
		||||
use std::fs;
 | 
			
		||||
use std::path::{Path, PathBuf};
 | 
			
		||||
 | 
			
		||||
use rocket::data::{Data, ToByteUnit};
 | 
			
		||||
use rocket::serde::json::Json;
 | 
			
		||||
use rocket_dyn_templates::Template;
 | 
			
		||||
use rocket::response::Redirect;
 | 
			
		||||
use response::{bad_request, ok};
 | 
			
		||||
 | 
			
		||||
use apikey::ApiKey;
 | 
			
		||||
use response::ActionResponse;
 | 
			
		||||
use crate::common::get_clips_dir;
 | 
			
		||||
 | 
			
		||||
#[get("/")]
 | 
			
		||||
pub async fn login_dashboard_redirect()  -> Redirect {
 | 
			
		||||
    Redirect::to("/admin/dashboard")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[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<i32,i32> = HashMap::new(); // does not allocate
 | 
			
		||||
    return Template::render("admin", &h);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[post("/dashboard")]
 | 
			
		||||
pub async fn dashboard(_key: ApiKey) -> Json<ActionResponse> {
 | 
			
		||||
    // Assuming the api key check doesn't fail we can reply with Ok
 | 
			
		||||
    // at the application level
 | 
			
		||||
    ok()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[post("/upload-video/<category>/<filename>", data = "<data>")]
 | 
			
		||||
pub async fn updload_video(_key: ApiKey, category: PathBuf, filename: PathBuf, data: Data<'_>) 
 | 
			
		||||
-> Result<Json<ActionResponse>> {
 | 
			
		||||
    /*
 | 
			
		||||
     * Uploads must have BOTH a valid filename and a category
 | 
			
		||||
     * Without the category the server will simply not find
 | 
			
		||||
     * the correct endpoint to reach and thus will 404
 | 
			
		||||
     */
 | 
			
		||||
    if util::valid_filename(&filename) == false {
 | 
			
		||||
        return Ok(bad_request(Some("Invalid filename(s)")));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let clips = get_clips_dir();
 | 
			
		||||
    fs::create_dir_all(Path::new(&clips).join(&category))?;
 | 
			
		||||
 | 
			
		||||
    /*
 | 
			
		||||
     * We allow up to 200 Megaytes per upload as most short
 | 
			
		||||
     * clips are not going to be very large anyway and this
 | 
			
		||||
     * should be a reasonably high limit for those that want
 | 
			
		||||
     * to upload "large" clips
 | 
			
		||||
     * */
 | 
			
		||||
    let filepath = Path::new(&clips).join(category).join(filename);
 | 
			
		||||
    data.open(250.megabytes()).into_file(filepath).await?;
 | 
			
		||||
    Ok(ok())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[delete("/remove-video/<category>/<filename>")]
 | 
			
		||||
pub async fn remove_video(_key: ApiKey, category: PathBuf, filename: PathBuf) 
 | 
			
		||||
-> Result<Json<ActionResponse>> {
 | 
			
		||||
    let clips = get_clips_dir();
 | 
			
		||||
    let path = Path::new(&clips).join(&category).join(&filename);
 | 
			
		||||
    fs::remove_file(path)?;
 | 
			
		||||
    Ok(ok())
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								clippable-svc/src/admin/response.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								clippable-svc/src/admin/response.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,35 @@
 | 
			
		||||
/*
 | 
			
		||||
 * Module handles general responses for the admin feature
 | 
			
		||||
 * Primarily these are responses for Admin related actions
 | 
			
		||||
 * like fetching video's, updating videos and deleting them
 | 
			
		||||
 * as well
 | 
			
		||||
 */
 | 
			
		||||
use serde::Serialize;
 | 
			
		||||
use rocket::serde::json::Json;
 | 
			
		||||
 | 
			
		||||
const FAIL: &'static str = "fail";
 | 
			
		||||
const OK: &'static str = "fail";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[derive(Serialize)]
 | 
			
		||||
pub struct ActionResponse {
 | 
			
		||||
    status: &'static str,
 | 
			
		||||
    code: i32,
 | 
			
		||||
    details: Option<&'static str>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub fn ok() -> Json<ActionResponse> {
 | 
			
		||||
    Json(ActionResponse {
 | 
			
		||||
        status: OK,
 | 
			
		||||
        code: 200,
 | 
			
		||||
        details: None
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pub fn bad_request(text: Option<&'static str>) -> Json<ActionResponse> {
 | 
			
		||||
    Json(ActionResponse {
 | 
			
		||||
        status: FAIL,
 | 
			
		||||
        code: 400,
 | 
			
		||||
        details: text
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								clippable-svc/src/admin/util.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								clippable-svc/src/admin/util.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
use std::path::PathBuf;
 | 
			
		||||
 | 
			
		||||
pub fn valid_filename(p: &PathBuf) -> bool {
 | 
			
		||||
    // Checks if a given filename is actually valid or not
 | 
			
		||||
    let mut valid = false;
 | 
			
		||||
    let s = p.file_name().unwrap_or_default().to_string_lossy();
 | 
			
		||||
    for e in [".mp4", ".webm", ".mkv"] {
 | 
			
		||||
        if s.ends_with(e) {
 | 
			
		||||
            valid = true;
 | 
			
		||||
            break;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    valid
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										82
									
								
								clippable-svc/src/category.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								clippable-svc/src/category.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,82 @@
 | 
			
		||||
use serde::Serialize;
 | 
			
		||||
use std::fs::DirEntry;
 | 
			
		||||
use std::path::Path;
 | 
			
		||||
use std::io;
 | 
			
		||||
use rocket::serde::json::Json;
 | 
			
		||||
use crate::common::{get_clips_dir, thumbs_dir};
 | 
			
		||||
 | 
			
		||||
/// Describes a category of videos as 
 | 
			
		||||
#[derive(Serialize)]
 | 
			
		||||
pub struct Category {
 | 
			
		||||
    name: String,
 | 
			
		||||
    /// NOTE: this is simply a URI pathname
 | 
			
		||||
    /// EXAMPLE: /thumbnail/<category>/.thumbnail.png
 | 
			
		||||
    thumbnail: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Returns a vector of category directories
 | 
			
		||||
pub fn get_category_dirs(path: &str) -> std::io::Result<Vec<DirEntry>> {
 | 
			
		||||
    let path = std::path::Path::new(path);
 | 
			
		||||
    // Trying to ignore non-directory entries
 | 
			
		||||
    if !path.is_dir() {
 | 
			
		||||
        let e = io::Error::new(io::ErrorKind::NotFound, "Unable to open");
 | 
			
		||||
        return Err(e);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let mut ret: Vec<DirEntry> = Vec::new();
 | 
			
		||||
    for entry in (std::fs::read_dir(path)?).flatten() {
 | 
			
		||||
        if entry.path().is_dir() {
 | 
			
		||||
            ret.push(entry)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    Ok(ret)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Will return the path to a category's thumb nail assuming it exists.
 | 
			
		||||
/// If nothing is found then it gives back the URI path to a not-found image
 | 
			
		||||
pub fn get_category_thumbnail(category: &str) -> std::io::Result<String> {
 | 
			
		||||
    let pathname = format!("{}/{}", thumbs_dir(), &category);
 | 
			
		||||
    let path = Path::new(&pathname);
 | 
			
		||||
    // Assume directory as we're only called from "safe" places
 | 
			
		||||
    let item = path.read_dir()?.find(|file| {
 | 
			
		||||
        if let Ok(file) = file {
 | 
			
		||||
            let name = file.file_name().into_string().unwrap();
 | 
			
		||||
            name == "category-thumbnail.jpg"
 | 
			
		||||
        } else {
 | 
			
		||||
            false
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return Ok(match item {
 | 
			
		||||
        Some(name) => {
 | 
			
		||||
            let name = name.unwrap().file_name().into_string().unwrap();
 | 
			
		||||
            format!("/thumbnail/{}/{}", category, name)
 | 
			
		||||
        },
 | 
			
		||||
        None => "/static/cantfindshit.jpg".to_string()
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Returns a List of categories
 | 
			
		||||
/// Primarily used on the main page
 | 
			
		||||
/// WARN: misconfigured servers are just going to get shafted and serve up
 | 
			
		||||
/// a tonne of 500's
 | 
			
		||||
#[get("/categories")]
 | 
			
		||||
pub fn list() -> Json<Vec<Category>> {
 | 
			
		||||
    let dir = get_clips_dir();
 | 
			
		||||
 | 
			
		||||
    let mut cats: Vec<Category> = Vec::new();
 | 
			
		||||
    if let Ok(dirs) = get_category_dirs(&dir) {
 | 
			
		||||
        // Let's just assume that each item in this directory is a folder
 | 
			
		||||
        // That way we can do this blindly without 9999 allocs
 | 
			
		||||
        for d in dirs {
 | 
			
		||||
            let name = d.file_name().to_string_lossy().to_string();
 | 
			
		||||
            let thumbnail = match get_category_thumbnail(&name) {
 | 
			
		||||
                Ok(s) => s,
 | 
			
		||||
                _ => "/static/cantfindshit.jpg".to_string()
 | 
			
		||||
            };
 | 
			
		||||
            cats.push(Category {name, thumbnail});
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    Json(cats)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								clippable-svc/src/common.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								clippable-svc/src/common.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
use std::env;
 | 
			
		||||
 | 
			
		||||
/// Returns the absolute path to the videos directory
 | 
			
		||||
pub fn get_clips_dir() -> String {
 | 
			
		||||
    match env::var("CLIPS_DIR") {
 | 
			
		||||
        Ok(val) => val,
 | 
			
		||||
        Err(_) => "/media/clips/".to_string()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Returns the absolute path to the thumbnails directory
 | 
			
		||||
pub fn thumbs_dir() -> String {
 | 
			
		||||
    match env::var("THUMBS_DIR") {
 | 
			
		||||
        Ok(val) => val,
 | 
			
		||||
        Err(_) => "/media/thumbnails/".to_string()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										117
									
								
								clippable-svc/src/db.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								clippable-svc/src/db.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,117 @@
 | 
			
		||||
#[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::env;
 | 
			
		||||
use std::fs::OpenOptions;
 | 
			
		||||
use std::io::{BufWriter, BufReader};
 | 
			
		||||
use std::path::PathBuf;
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use rocket::serde::{Serialize, Deserialize};
 | 
			
		||||
 | 
			
		||||
lazy_static! {
 | 
			
		||||
    pub static ref DB_PATH: String = {
 | 
			
		||||
        env::var("DB_PATH").unwrap_or("keys.db".into())
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[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<String,String>,
 | 
			
		||||
    #[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<Self, std::io::Error> {
 | 
			
		||||
        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<Self, std::io::Error> {
 | 
			
		||||
        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;
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								clippable-svc/src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								clippable-svc/src/main.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,55 @@
 | 
			
		||||
//#![feature(decl_macro)]
 | 
			
		||||
#[macro_use] extern crate rocket;
 | 
			
		||||
#[macro_use] extern crate lazy_static;
 | 
			
		||||
use std::env;
 | 
			
		||||
use rocket_dyn_templates::Template;
 | 
			
		||||
 | 
			
		||||
mod page;
 | 
			
		||||
mod category;
 | 
			
		||||
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() {
 | 
			
		||||
    // rid ourselves of random emoji's in logs
 | 
			
		||||
    env::set_var("ROCKET_CLI_COLORS", "false");
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    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_redirect, 
 | 
			
		||||
              admin::login_dashboard, 
 | 
			
		||||
              admin::dashboard,
 | 
			
		||||
              admin::updload_video,
 | 
			
		||||
              admin::remove_video
 | 
			
		||||
            ])
 | 
			
		||||
            .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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										121
									
								
								clippable-svc/src/page.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								clippable-svc/src/page.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,121 @@
 | 
			
		||||
/// This module takes care of returning bare webpage templates
 | 
			
		||||
/// Most of the work done here is simply to fill the templates
 | 
			
		||||
/// with the appropriate meta data that is required 
 | 
			
		||||
 | 
			
		||||
use rocket_dyn_templates::Template;
 | 
			
		||||
use rocket::fs::NamedFile;
 | 
			
		||||
use std::path::{Path, PathBuf};
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use crate::common::get_clips_dir;
 | 
			
		||||
use std::env;
 | 
			
		||||
 | 
			
		||||
// TODO: try... literally try to reduce the number of clones that happen here
 | 
			
		||||
 | 
			
		||||
lazy_static! {
 | 
			
		||||
    static ref SITENAME: String = {
 | 
			
		||||
        env::var("SITE_NAME").unwrap_or_else(|_| "Clippable".to_string())
 | 
			
		||||
    };
 | 
			
		||||
    static ref SITEDESC: String = {
 | 
			
		||||
        env::var("SITE_DESC").unwrap_or_else(|_| "Short clips".to_string())
 | 
			
		||||
    };
 | 
			
		||||
    static ref SITEURL: String = {
 | 
			
		||||
        env::var("SITE_URL").unwrap_or_else(|_| "#".to_string())
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Default metadata that all web pages must be filled with
 | 
			
		||||
pub fn default_map() -> HashMap<&'static str, String> {
 | 
			
		||||
    let mut h: HashMap<&'static str, String> = HashMap::new();
 | 
			
		||||
    h.insert("sitetitle", SITENAME.clone());
 | 
			
		||||
    h.insert("sitedesc", SITEDESC.clone());
 | 
			
		||||
    h.insert("siteurl", SITEURL.clone());
 | 
			
		||||
    h
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[get("/")]
 | 
			
		||||
pub async fn home() -> Template {
 | 
			
		||||
    let mut h = default_map();
 | 
			
		||||
    h.insert("page", String::from("home"));
 | 
			
		||||
    h.insert("title", SITENAME.clone());
 | 
			
		||||
 | 
			
		||||
    Template::render("list", &h)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[get("/category/<cat..>")]
 | 
			
		||||
pub async fn category(cat: PathBuf) -> Template {
 | 
			
		||||
    let mut h = default_map();
 | 
			
		||||
    h.insert("page", String::from("category"));
 | 
			
		||||
 | 
			
		||||
    // Opengraph meta tags bro
 | 
			
		||||
    let cat = cat.file_name().unwrap().to_string_lossy();
 | 
			
		||||
    h.insert("title", cat.to_string());
 | 
			
		||||
    h.insert("url", format!("/category/{}", cat));
 | 
			
		||||
    h.insert("desc", format!("{} clips", cat));
 | 
			
		||||
    h.insert("ogimg", format!("/thumbnail/{}/category-thumbnail.jpg", cat));
 | 
			
		||||
 | 
			
		||||
    Template::render("list", &h)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn map_base_vfile(category: &Path, base: &Path) -> String {
 | 
			
		||||
    // This takes a category and base filename and basically looks for the file
 | 
			
		||||
    // that maps to those parameters
 | 
			
		||||
    // This basically aims to get rid of the extension in the URL so that social
 | 
			
		||||
    // media sites will bother to fetch open graph tags
 | 
			
		||||
    let basename = base.to_string_lossy();
 | 
			
		||||
    let dirname = format!("{}/{}/", get_clips_dir(), category.to_string_lossy());
 | 
			
		||||
    let dir = Path::new(&dirname);
 | 
			
		||||
    let file = dir.read_dir().ok().unwrap().find(|item| {
 | 
			
		||||
        if let Ok(item) = item {
 | 
			
		||||
            let name = item.file_name().into_string().unwrap();
 | 
			
		||||
            name.starts_with(basename.as_ref())
 | 
			
		||||
        } else {
 | 
			
		||||
            false
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    match file {
 | 
			
		||||
        Some(ent) => ent.unwrap().file_name().into_string().unwrap(),
 | 
			
		||||
        None => "/static/cantfindshit.jpg".to_string()
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[get("/clip/<cat>/<file_base>")]
 | 
			
		||||
pub fn video(cat: PathBuf, file_base: PathBuf) -> Template {
 | 
			
		||||
    let mut h: HashMap<&'static str, &str> = HashMap::new();
 | 
			
		||||
 | 
			
		||||
    // Look for the file_base + [extension]
 | 
			
		||||
    let file = map_base_vfile(&cat, &file_base);
 | 
			
		||||
 | 
			
		||||
    let cat = cat.to_string_lossy();
 | 
			
		||||
 | 
			
		||||
    let mut file_pretty = file.to_string();
 | 
			
		||||
    for c in [".mp4", ".mkv", ".webm"] {
 | 
			
		||||
        file_pretty = file_pretty.replace(c, "");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    h.insert("title", &file_pretty);
 | 
			
		||||
 | 
			
		||||
    let url = format!("/clip/{}/{}", &cat, &file);
 | 
			
		||||
    h.insert("url", &url);
 | 
			
		||||
    h.insert("page", "video");
 | 
			
		||||
    h.insert("desc", &SITEDESC);
 | 
			
		||||
    h.insert("category", &cat);
 | 
			
		||||
    h.insert("filename", &file_pretty);
 | 
			
		||||
 | 
			
		||||
    let thumbnail = format!("/thumbnail/{}/{}", cat, file);
 | 
			
		||||
    h.insert("ogimg", &thumbnail);
 | 
			
		||||
 | 
			
		||||
    let clip_url = format!("/video/{}/{}", &cat, &file);
 | 
			
		||||
    h.insert("clip_url", &clip_url);
 | 
			
		||||
 | 
			
		||||
    let clip_thumbnail = format!("/thumbnail/{}/{}.jpg", &cat, &file);
 | 
			
		||||
    h.insert("clip_thumbnail", &clip_thumbnail);
 | 
			
		||||
 | 
			
		||||
    Template::render("video", &h)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// This route handler returns static files that are comprised
 | 
			
		||||
/// of JS, CSS, the favicon and other static items
 | 
			
		||||
#[get("/<file..>")]
 | 
			
		||||
pub async fn files(file: PathBuf) -> Option<NamedFile> {
 | 
			
		||||
    NamedFile::open(Path::new("static/").join(file)).await.ok()
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								clippable-svc/src/sec.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								clippable-svc/src/sec.rs
									
									
									
									
									
										Normal file
									
								
							@ -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<String, std::io::Error> {
 | 
			
		||||
    // 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);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								clippable-svc/src/thumbnail.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								clippable-svc/src/thumbnail.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,26 @@
 | 
			
		||||
use rocket::fs::NamedFile;
 | 
			
		||||
use std::path::{Path, PathBuf};
 | 
			
		||||
use crate::common::thumbs_dir;
 | 
			
		||||
 | 
			
		||||
/// Returns a thumbnail whose formatted name is <video-name>.<ext>.jpg
 | 
			
		||||
#[get("/<file..>")]
 | 
			
		||||
pub async fn get(file: PathBuf) -> Option<NamedFile> {
 | 
			
		||||
    let clips_dir = thumbs_dir();
 | 
			
		||||
 | 
			
		||||
    // Only serve jpg's and png's through this route
 | 
			
		||||
    let file_path = Path::new(&clips_dir).join(file);
 | 
			
		||||
    if file_path.is_file() {
 | 
			
		||||
        return match file_path.extension() {
 | 
			
		||||
            Some(ext) => {
 | 
			
		||||
                match ext == "jpg" {
 | 
			
		||||
                    true => NamedFile::open(file_path).await.ok(),
 | 
			
		||||
                    false => None
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            None => None
 | 
			
		||||
        }
 | 
			
		||||
    } else {
 | 
			
		||||
        let path = Path::new("static/cantfindshit.jpg");
 | 
			
		||||
        return NamedFile::open(path).await.ok();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										71
									
								
								clippable-svc/src/video.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								clippable-svc/src/video.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,71 @@
 | 
			
		||||
use std::path::{Path, PathBuf};
 | 
			
		||||
use std::fs::DirEntry;
 | 
			
		||||
use std::ffi::OsStr;
 | 
			
		||||
use serde::Serialize;
 | 
			
		||||
use rocket::serde::json::Json;
 | 
			
		||||
use rocket::fs::NamedFile;
 | 
			
		||||
use crate::common::get_clips_dir;
 | 
			
		||||
 | 
			
		||||
#[derive(Serialize)]
 | 
			
		||||
pub struct VideoPreview  {
 | 
			
		||||
    /// Real filename on disk
 | 
			
		||||
    name: String,
 | 
			
		||||
    /// URI path to what the browser can reasonably expect
 | 
			
		||||
    thumbnail: Option<String>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn vid_file_entries(path: &OsStr) -> std::io::Result<Vec<DirEntry>> {
 | 
			
		||||
    let mut dir = get_clips_dir();
 | 
			
		||||
    dir.push('/'); dir.push_str(&path.to_string_lossy());
 | 
			
		||||
    let path = std::path::Path::new(&dir);
 | 
			
		||||
 | 
			
		||||
    if !path.is_dir() {
 | 
			
		||||
        panic!("<{:?}> is not a valid directory", path);
 | 
			
		||||
    }
 | 
			
		||||
    let mut entries: Vec<DirEntry> = Vec::new();
 | 
			
		||||
    for ent in (path.read_dir()?).flatten() {
 | 
			
		||||
        let name = ent.file_name().into_string().unwrap();
 | 
			
		||||
        if name.ends_with("mkv") || name.ends_with("mp4") || name.ends_with("webm") {
 | 
			
		||||
            entries.push(ent);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    Ok(entries)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[get("/category/<cat..>")]
 | 
			
		||||
pub fn list(cat: PathBuf) -> Option<Json<Vec<VideoPreview>>> {
 | 
			
		||||
    /*
 | 
			
		||||
     * List out the videos to a given category
 | 
			
		||||
     */
 | 
			
		||||
    // First we have to make sure this given category is even registered with us
 | 
			
		||||
    let file_path = cat.file_name().unwrap_or_else(|| OsStr::new(""));
 | 
			
		||||
    if let Ok(entries) = vid_file_entries(file_path) {
 | 
			
		||||
        let mut previews: Vec<VideoPreview> = Vec::new();
 | 
			
		||||
        // Autismo but at least its bare-able
 | 
			
		||||
        for ent in entries {
 | 
			
		||||
            let name = ent.file_name(); 
 | 
			
		||||
            let name = name.to_string_lossy();
 | 
			
		||||
 | 
			
		||||
            let cat = cat.to_string_lossy();
 | 
			
		||||
            let thumbnail = format!("/thumbnail/{}/{}.jpg", cat, name);
 | 
			
		||||
 | 
			
		||||
            let item = VideoPreview {
 | 
			
		||||
                name: name.to_string(),
 | 
			
		||||
                thumbnail: Some(thumbnail)
 | 
			
		||||
            };
 | 
			
		||||
            previews.push(item);
 | 
			
		||||
        }
 | 
			
		||||
        return Some(Json(previews))
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Endpoint returns the video file itself that is saved on the server
 | 
			
		||||
#[get("/<cat>/<file>")]
 | 
			
		||||
pub async fn get_video(cat: PathBuf, file: PathBuf) -> Option<NamedFile> {
 | 
			
		||||
    let clips_dir = get_clips_dir();
 | 
			
		||||
    let path = Path::new(&clips_dir).join(cat).join(file);
 | 
			
		||||
    NamedFile::open(path).await.ok()
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								clippable-svc/static/cantfindshit.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								clippable-svc/static/cantfindshit.jpg
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
version https://git-lfs.github.com/spec/v1
 | 
			
		||||
oid sha256:bc7637a1b3f7e75322e3dddb3499931dc9cd57804afbf46c7941e6bc5211d03c
 | 
			
		||||
size 21076
 | 
			
		||||
							
								
								
									
										100
									
								
								clippable-svc/static/css/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								clippable-svc/static/css/style.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,100 @@
 | 
			
		||||
html, body, div, tag {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    font-size: 100%;
 | 
			
		||||
    vertical-align: baseline;
 | 
			
		||||
    background: #212121;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
* {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1 {
 | 
			
		||||
	text-transform: capitalize;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a {
 | 
			
		||||
	text-decoration: none;
 | 
			
		||||
	color: whitesmoke;
 | 
			
		||||
   text-shadow:
 | 
			
		||||
		3px 3px 0   #191818,
 | 
			
		||||
		-1px -1px 0 #191818,  
 | 
			
		||||
		1px -1px 0  #191818,
 | 
			
		||||
		-1px 1px 0  #191818,
 | 
			
		||||
		1px 1px 0   #191818;
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
a:hover {
 | 
			
		||||
	text-decoration: none;
 | 
			
		||||
	color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.content {
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    padding: 0 2em;
 | 
			
		||||
    line-height: 1.6em;
 | 
			
		||||
    color: whitesmoke;
 | 
			
		||||
	max-width: 80%;
 | 
			
		||||
}
 | 
			
		||||
.video-gallery {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.video-block {
 | 
			
		||||
	display: block;
 | 
			
		||||
    padding: 1em;
 | 
			
		||||
    width: 400px;
 | 
			
		||||
    line-height: 1em;
 | 
			
		||||
	margin-right: 1em;
 | 
			
		||||
	margin-bottom: 1em;
 | 
			
		||||
	border-radius: 1em;
 | 
			
		||||
 | 
			
		||||
	background: linear-gradient(#191818,#191818,50%,#00ffcc,50%,#00ffcc);
 | 
			
		||||
	background-size: 100% 200%;
 | 
			
		||||
	transition: all 0.2s ease;
 | 
			
		||||
	animation: 0.4s ease;
 | 
			
		||||
}
 | 
			
		||||
.video-block:hover {
 | 
			
		||||
	background-position: 100% 100%;
 | 
			
		||||
	animation: 0.4s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
video {
 | 
			
		||||
	max-width: 100%;
 | 
			
		||||
	max-height: 80vh;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.pure-form {
 | 
			
		||||
    text-align: center;
 | 
			
		||||
}
 | 
			
		||||
.pure-img {
 | 
			
		||||
	border-radius: 0.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.login-form {
 | 
			
		||||
	max-width: 500px;
 | 
			
		||||
	margin: 0 auto;
 | 
			
		||||
}
 | 
			
		||||
#video-meta {
 | 
			
		||||
	text-align: left;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.admin-video-li {
 | 
			
		||||
	color: black;
 | 
			
		||||
	text-shadow: none;
 | 
			
		||||
	padding-right: 1em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.admin-video-li:hover {
 | 
			
		||||
	color: #0a58ca;
 | 
			
		||||
	text-shadow: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.align-left {
 | 
			
		||||
	text-align: left;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								clippable-svc/static/favicon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								clippable-svc/static/favicon.png
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
version https://git-lfs.github.com/spec/v1
 | 
			
		||||
oid sha256:0cfdedb04d45802c1c008c9610bda2df8e3482f195bd254c11ebe07205e2bd5d
 | 
			
		||||
size 8560
 | 
			
		||||
							
								
								
									
										57
									
								
								clippable-svc/static/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								clippable-svc/static/index.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,57 @@
 | 
			
		||||
<html>
 | 
			
		||||
<head>
 | 
			
		||||
	<title>Clippable</title>
 | 
			
		||||
	<link rel="stylesheet" href="https://unpkg.com/purecss@2.0.6/build/pure-min.css" integrity="sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5" crossorigin="anonymous">
 | 
			
		||||
	<link rel="stylesheet" type="text/css" href="/style.css">
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
<div id="main">
 | 
			
		||||
	<div class="content">
 | 
			
		||||
		<form class="pure-form">
 | 
			
		||||
			<h1>Clippable</h1>
 | 
			
		||||
			<fieldset>
 | 
			
		||||
				<input type="text" placeholder="Search"/>
 | 
			
		||||
				<button class="pure-button pure-button-primary">Search</button>
 | 
			
		||||
			</fieldset>
 | 
			
		||||
		</form>
 | 
			
		||||
		<div class="video-gallery ">
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
			<div class="video-block">
 | 
			
		||||
				<h2>Video title</h2>
 | 
			
		||||
				<p>some random description that's actually really long for some reason</p>
 | 
			
		||||
				<img class="pure-img" src="/img/tmp.png">
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										84
									
								
								clippable-svc/templates/admin.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								clippable-svc/templates/admin.html.tera
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,84 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
	<meta charset="utf-8">
 | 
			
		||||
	<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
	<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
 | 
			
		||||
	<link rel="stylesheet" href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css" integrity="sha384-AYmEC3Yw5cVb3ZcuHtOA93w35dYTsvhLPVnYs9eStHfGJvOvKxVfELGroGkvsg+p" crossorigin="anonymous"/>
 | 
			
		||||
 | 
			
		||||
	<link rel="stylesheet" type="text/css" href="/static/css/style.css">
 | 
			
		||||
	<title>Clippable Admin Dashboard</title>
 | 
			
		||||
	<link rel="shortcut icon" type="image/png" href="/static/favicon.png"/>
 | 
			
		||||
 | 
			
		||||
	<script src="/static/dist/bundle.js"></script>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
<div class="content">
 | 
			
		||||
	<nav class="navbar navbar-expand-lg navbar-dark">
 | 
			
		||||
		<a style="bread-crumb-item text-transform: capitalize" class="navbar-brand" href="/">
 | 
			
		||||
			<img src="/static/favicon.png" class="rounded" width="50" height="50">
 | 
			
		||||
		</a>
 | 
			
		||||
		<ol class="breadcrumb">
 | 
			
		||||
			<li class="breadcrumb-item">
 | 
			
		||||
				<a href="/">Home</a>
 | 
			
		||||
			</li>
 | 
			
		||||
		</ol>
 | 
			
		||||
	</nav>
 | 
			
		||||
 | 
			
		||||
	<div class="content" id="login-display">
 | 
			
		||||
		<h2>Login to Instance</h2>
 | 
			
		||||
		<form class="form-inline login-form">
 | 
			
		||||
		  <div class="input-group mb-2">
 | 
			
		||||
			<input type="password" id="uid" class="form-control mx-sm-3" placeholder="UID code">
 | 
			
		||||
			<div class="form-check form-check-inline">
 | 
			
		||||
				<input class="form-check-input" type="checkbox" onclick="toggle_visible('uid')">
 | 
			
		||||
				<label class="form-check-label" for="showuid">Show uid</label>
 | 
			
		||||
			</div> </div>
 | 
			
		||||
		  <div class="input-group mb-2">
 | 
			
		||||
			  <input type="password" id="apikey" class="form-control mx-sm-3" placeholder="API Key">
 | 
			
		||||
			<div class="form-check form-check-inline">
 | 
			
		||||
				<input class="form-check-input" type="checkbox" onclick="toggle_visible('apikey')">
 | 
			
		||||
				<label class="form-check-label" for="showuid">Show key</label>
 | 
			
		||||
			</div>
 | 
			
		||||
		  </div>
 | 
			
		||||
		  <strong id='error' hidden></strong>
 | 
			
		||||
		  <button type="button" class="btn btn-info" id="verify-login-btn">Submit</button>
 | 
			
		||||
		</form>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div class="content" id="dashboard" hidden>
 | 
			
		||||
		<div class="container">
 | 
			
		||||
			<form>
 | 
			
		||||
				<div class="form-group">
 | 
			
		||||
					<label for="video-file">Pick out video file to upload</label>
 | 
			
		||||
					<input type="file" class="form-control" id="video-file">
 | 
			
		||||
					<label for="category">What category should this go in?</label>
 | 
			
		||||
					<input type="text" class="form-control" id="category">
 | 
			
		||||
				</div>
 | 
			
		||||
			</form>
 | 
			
		||||
			<div id="video-meta" hidden>
 | 
			
		||||
				<p>Video file name: <code id="vmn"></code></p>
 | 
			
		||||
				<p>Video file size: <code id="vms"></code></p>
 | 
			
		||||
				<p>Video file type: <code id="vmt"></code></p>
 | 
			
		||||
			    <button type="button" class="btn btn-primary" id="confirm-upload-btn">Upload</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div id="upload-response"></div>
 | 
			
		||||
			<div class="vids-meta-list">
 | 
			
		||||
				<h1>Videos</h1>
 | 
			
		||||
				<ul class="list-group" id="videos-list"></ul>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
<script type="text/javascript">
 | 
			
		||||
function toggle_visible(id) {
 | 
			
		||||
  var x = document.getElementById(id);
 | 
			
		||||
  if (x.type === "password") {
 | 
			
		||||
    x.type = "text";
 | 
			
		||||
  } else {
 | 
			
		||||
    x.type = "password";
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										41
									
								
								clippable-svc/templates/list.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								clippable-svc/templates/list.html.tera
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,41 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en"><head>
 | 
			
		||||
	<meta charset="utf-8">
 | 
			
		||||
	<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
	<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
 | 
			
		||||
	<link rel="stylesheet" type="text/css" href="/static/css/style.css">
 | 
			
		||||
	<title>{{title}}</title>
 | 
			
		||||
	<link rel="shortcut icon" type="image/png" href="/static/favicon.png"/>
 | 
			
		||||
	{% if page == "category"  %}
 | 
			
		||||
		<meta property="og:title" content="{{title}}">
 | 
			
		||||
		<meta property="og:site_name" content="Clippable">
 | 
			
		||||
		<meta property="og:url" content="{{url}}">
 | 
			
		||||
		<meta property="og:description" content="{{desc}}">
 | 
			
		||||
		<meta property="og:type" content="website">
 | 
			
		||||
		{% if ogimg %}
 | 
			
		||||
			<meta property="og:image" content="{{ogimg}}">
 | 
			
		||||
		{% else %}
 | 
			
		||||
			<meta property="og:image" content="/static/favicon.png">
 | 
			
		||||
		{% endif %}
 | 
			
		||||
	{# Otherwise we defautl to the home tags #}
 | 
			
		||||
	{% else %}
 | 
			
		||||
		<meta property="og:title" content="{{sitetitle}}">
 | 
			
		||||
		<meta property="og:site_name" content="Clippable">
 | 
			
		||||
		<meta property="og:url" content="{{siteurl}}">
 | 
			
		||||
		<meta property="og:description" content="{{sitedesc}}">
 | 
			
		||||
		<meta property="og:type" content="website">
 | 
			
		||||
		<meta property="og:image" content="/static/favicon.png">
 | 
			
		||||
	{% endif %}
 | 
			
		||||
	<script src="/static/dist/bundle.js"></script>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
	<div id="layout">
 | 
			
		||||
		<div class="content">
 | 
			
		||||
			{% include "navbar" %}
 | 
			
		||||
			<h1>{{title}}</h1>
 | 
			
		||||
			<div class="video-gallery" id="main-container">
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										32
									
								
								clippable-svc/templates/navbar.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								clippable-svc/templates/navbar.html.tera
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,32 @@
 | 
			
		||||
<nav class="navbar navbar-expand-lg navbar-dark">
 | 
			
		||||
		<a style="bread-crumb-item text-transform: capitalize" class="navbar-brand" href="/">
 | 
			
		||||
			<img src="/static/favicon.png" class="rounded" width="50" height="50">
 | 
			
		||||
		</a>
 | 
			
		||||
		<ol class="breadcrumb">
 | 
			
		||||
			{% if page == "category" %}
 | 
			
		||||
				<li class="breadcrumb-item">
 | 
			
		||||
					<a href="/">Home</a>
 | 
			
		||||
				</li>
 | 
			
		||||
				<li class="breadcrumb-item active" aria-current="page">
 | 
			
		||||
					<a href="{{url}}">{{title}}</a>
 | 
			
		||||
				</li>
 | 
			
		||||
 | 
			
		||||
			{% elif page == "video" %}
 | 
			
		||||
				<li class="breadcrumb-item">
 | 
			
		||||
					<a href="/">Home</a>
 | 
			
		||||
				</li>
 | 
			
		||||
				<li class="breadcrumb-item">
 | 
			
		||||
					<a href="/category/{{category}}">{{category}}</a>
 | 
			
		||||
				</li>
 | 
			
		||||
				<li class="breadcrumb-item active" aria-current="page">
 | 
			
		||||
					<a href="{{url}}">{{filename}}</a>
 | 
			
		||||
				</li>
 | 
			
		||||
 | 
			
		||||
			{% else %}
 | 
			
		||||
				<li class="breadcrumb-item active" aria-current="page">
 | 
			
		||||
					<a href="/">Home</a>
 | 
			
		||||
				</li>
 | 
			
		||||
			{% endif %}
 | 
			
		||||
		</ol>
 | 
			
		||||
</nav>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										42
									
								
								clippable-svc/templates/video.html.tera
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								clippable-svc/templates/video.html.tera
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en"><head>
 | 
			
		||||
	<meta name="generator" content="Hugo 0.88.1" />
 | 
			
		||||
	<meta charset="utf-8">
 | 
			
		||||
	<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
	<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
 | 
			
		||||
	<link rel="stylesheet" type="text/css" href="/static/css/style.css">
 | 
			
		||||
	<title>{{title}}</title>
 | 
			
		||||
	<link rel="shortcut icon" type="image/png" href="/static/favicon.png"/>
 | 
			
		||||
 | 
			
		||||
	{# Required for a better video player #}
 | 
			
		||||
	<link href="//vjs.zencdn.net/7.10.2/video-js.min.css" rel="stylesheet">
 | 
			
		||||
	<script src="//vjs.zencdn.net/7.10.2/video.min.js"></script>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	{# Opengraph tags #}
 | 
			
		||||
	<meta property="og:title" content="{{title}}">
 | 
			
		||||
	<meta property="og:site_name" content="Clippable">
 | 
			
		||||
	<meta property="og:url" content="{{url}}">
 | 
			
		||||
	<meta property="og:description" content="{{desc}}">
 | 
			
		||||
	<meta property="og:type" content="video.other">
 | 
			
		||||
	<meta property="og:image" content="{{clip_thumbnail}}">
 | 
			
		||||
	{# Image tag requires a default in case of disk memery #}
 | 
			
		||||
	<meta property="og:video" content="{{clip_url}}">
 | 
			
		||||
 | 
			
		||||
	{% if script %}
 | 
			
		||||
		<script src="/static/dist/bundle.js"></script>
 | 
			
		||||
	{% endif %}
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
<div id="layout">
 | 
			
		||||
	<div class="content">
 | 
			
		||||
		{% include "navbar" %}
 | 
			
		||||
		<h1>{{title}}</h1>
 | 
			
		||||
		<video width="1280" height="720" class="video-js" controls autoplay>
 | 
			
		||||
			<source src="{{clip_url}}">
 | 
			
		||||
			wtf get a better browser 1d10t
 | 
			
		||||
		</video>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										1
									
								
								service/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								service/.gitignore
									
									
									
									
										vendored
									
									
								
							@ -1 +0,0 @@
 | 
			
		||||
target/
 | 
			
		||||
							
								
								
									
										2487
									
								
								service/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2487
									
								
								service/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,9 +0,0 @@
 | 
			
		||||
[package]
 | 
			
		||||
name = "backend"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
edition = "2021"
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
rocket = { version = "0.5.1", features = ["json"] }
 | 
			
		||||
serde = {version = "1.0", features = ["derive"]}
 | 
			
		||||
rocket_dyn_templates = {version = "0.2.0", features = ["tera"] }
 | 
			
		||||
@ -1,33 +0,0 @@
 | 
			
		||||
use std::collections::HashMap;
 | 
			
		||||
use std::fs;
 | 
			
		||||
use rocket::serde::json;
 | 
			
		||||
use serde::Deserialize;
 | 
			
		||||
use serde::Serialize;
 | 
			
		||||
 | 
			
		||||
use crate::CONFIG;
 | 
			
		||||
 | 
			
		||||
#[derive(Serialize, Deserialize)]
 | 
			
		||||
pub struct Row {
 | 
			
		||||
    pub id: i64,
 | 
			
		||||
    pub file: String,
 | 
			
		||||
    pub thumbnail: String
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
pub fn get_video(id: i64) -> Option<Row> {
 | 
			
		||||
    // Used to fetch video information from the db for client's to request
 | 
			
		||||
    // video and thumbnail files from us
 | 
			
		||||
    let db_file = fs::read_to_string(CONFIG.db_file)
 | 
			
		||||
        .expect("Unable to read DB_FILE");
 | 
			
		||||
    let db_data: HashMap<i64, Row> = json::from_str(&db_file)
 | 
			
		||||
        .expect("Unable to parse DB_FILE content");
 | 
			
		||||
 | 
			
		||||
    match db_data.get(&id) {
 | 
			
		||||
        Some(video) => Some(Row {
 | 
			
		||||
            id,
 | 
			
		||||
            file: video.file.clone(),
 | 
			
		||||
            thumbnail: video.thumbnail.clone()
 | 
			
		||||
        }),
 | 
			
		||||
        None => None
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -1,27 +0,0 @@
 | 
			
		||||
#[macro_use] extern crate rocket;
 | 
			
		||||
use rocket_dyn_templates::Template;
 | 
			
		||||
use rocket::fs::FileServer;
 | 
			
		||||
 | 
			
		||||
mod video;
 | 
			
		||||
mod db;
 | 
			
		||||
 | 
			
		||||
pub struct BackendConfig {
 | 
			
		||||
    pub root: &'static str,
 | 
			
		||||
    pub db_file: &'static str,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const CONFIG: BackendConfig = BackendConfig {
 | 
			
		||||
    root: "/opt/clippable",
 | 
			
		||||
    db_file: "/opt/clippable/db.json"
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
#[rocket::main]
 | 
			
		||||
async fn main() {
 | 
			
		||||
 | 
			
		||||
    let _ = rocket::build()
 | 
			
		||||
        .mount("/static", FileServer::from("static"))
 | 
			
		||||
        .mount("/c", routes![video::index])
 | 
			
		||||
        .mount("/video", routes![video::file])
 | 
			
		||||
        .attach(Template::fairing())
 | 
			
		||||
        .launch().await;
 | 
			
		||||
}
 | 
			
		||||
@ -1,33 +0,0 @@
 | 
			
		||||
use crate::db;
 | 
			
		||||
use std::path::PathBuf;
 | 
			
		||||
use rocket::fs::NamedFile;
 | 
			
		||||
use rocket_dyn_templates::Template;
 | 
			
		||||
use rocket_dyn_templates::context;
 | 
			
		||||
 | 
			
		||||
#[get("/<video_id>")]
 | 
			
		||||
pub fn index(video_id: i64) -> Template {
 | 
			
		||||
    // Read the db file we have to get the ID
 | 
			
		||||
    // Now we can fetch the row from the DB content
 | 
			
		||||
    match db::get_video(video_id) {
 | 
			
		||||
        Some(video) => {
 | 
			
		||||
            let path = PathBuf::from(&video.file);
 | 
			
		||||
            Template::render("index", context! {
 | 
			
		||||
                title: path.file_name(),
 | 
			
		||||
                kind: path.extension(),
 | 
			
		||||
                video: video.file
 | 
			
		||||
            })
 | 
			
		||||
        },
 | 
			
		||||
        None => Template::render("index", context! {
 | 
			
		||||
            title: "Not found"
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[get("/<path>")]
 | 
			
		||||
pub async fn file(path: PathBuf) -> Option<NamedFile> {
 | 
			
		||||
    if path.is_file() {
 | 
			
		||||
        return NamedFile::open(path).await.ok();
 | 
			
		||||
    }
 | 
			
		||||
    None
 | 
			
		||||
}
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
.content {
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    margin: auto;
 | 
			
		||||
    width: 75%;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,16 +0,0 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<head lang="en-us">
 | 
			
		||||
    <link rel="stylesheet" href="/static/style.css">
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
    <div class="content">
 | 
			
		||||
        <h1 class="title">{{ title }}</h1>
 | 
			
		||||
        {% if video %}
 | 
			
		||||
            <video controls>
 | 
			
		||||
                <source src="/video/{{ video }}" type="{{ kind }}">
 | 
			
		||||
            </video>
 | 
			
		||||
        {% else %}
 | 
			
		||||
            <p>Nothing to see here</p>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
    </div>
 | 
			
		||||
</body>
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user