Compare commits

...

23 Commits

Author SHA1 Message Date
8135f1d530 Updating default logo and background images 2025-08-26 08:22:23 -07:00
81509ceaa8 Merging remote changes to loonix 2025-08-26 07:44:04 -07:00
690158ff1c Fixing vertical alignment in videos pages 2025-08-18 14:46:10 -07:00
050160c326 Hugo template rip commit. When in doubut steal it out 2025-08-18 10:19:49 -07:00
d709e5fcc5 Skeleton for basic homepage and other generic pages like 404's and things 2025-08-18 10:19:10 -07:00
f6d2fcd3f6 updating templates to the ones from the dimension theme, no video support yet with this theming 2025-08-18 10:18:31 -07:00
eb99d97e7b Path & PathBuf .join() methods are more headache than they're worth
Here's two hard coded variables
2025-08-17 15:30:09 -07:00
7d813391bd Stablizing backend files search 2025-08-17 15:22:21 -07:00
1309ab2324 Backend now responsed to all correct API calls for video file pages 2025-08-15 20:45:04 -07:00
150d34c38e Opting for string keys in json db 2025-08-15 20:24:09 -07:00
61b3a5fe80 Main video pages now slowly coming online from the backend 2025-08-15 16:20:56 -07:00
ac200c0c0d Removing mega old code that doesn't work anymore 2025-08-13 16:14:49 -07:00
9ca54103e5 - Removing admin features from core service binary API
Because this feature is going to be optional we're going to provide this as
a seperate service as the two have entirely different goals anyway
2022-11-13 12:38:09 -08:00
9671be1ff3 Updating references to old api/ to clippable-svc/ 2022-10-28 12:12:21 -07:00
348410853a Renamed api to clippable-svc to be more clear 2022-10-28 12:09:29 -07:00
54af3628e4 - Removing out of scope code 2022-05-04 17:46:22 -07:00
dc98feef5f + Required job dependencies for cargo builders
'needs' keyword also added for meme graph lines
2022-03-27 00:49:36 -07:00
ba0d75d383 + New jobs for building both admin and normal docker images
+ New jobs for building both admin and normal binary packages

! NOTE: This commit is likely broken so do not use it to checkout
2022-03-27 00:45:07 -07:00
9f78f316c5 +* Ansible role meta data 2022-03-27 00:43:14 -07:00
988d598a19 + Base job for deploy docker images to gitlab repos 2022-03-27 00:42:47 -07:00
1fd47481c8 + Base job for building cargo binaries
script field is the only that "userland" code needs to
override
2022-03-27 00:41:47 -07:00
4416d08994 + admin::populate_video_list now populates t he video list
The ul here is meant to serve as a quick
way to check what videos are being served
as well as removing videos

+ category::VideoMeta::as_li built for admin video list
This is basically how we spit this out onto
the DOM
2022-03-27 00:00:50 -07:00
d1e2d80eae + Font awesome images
+ Styling for admin video list
2022-03-26 23:58:53 -07:00
73 changed files with 5494 additions and 3623 deletions

15
.gitignore vendored
View File

@@ -3,13 +3,13 @@ msg
tmp/
keys/
api/target/
api/dev/
api/vids/
api/static/js/
api/static/dist/
api/thumbs/
api/*.db
clippable-svc/target/
clippable-svc/dev/
clippable-svc/vids/
clippable-svc/static/js/
clippable-svc/static/dist/
clippable-svc/thumbs/
clippable-svc/*.db
build/
gitpage/public/
@@ -29,3 +29,4 @@ ts/dist/
ts/node_modules/
.vscode/settings.json
api/.vscode/settings.json
api/.ycm_extra_conf.py

View File

@@ -4,6 +4,10 @@ stages:
- build-backend
- deploy
include:
- local: 'ci/cargo.yml'
- local: 'ci/docker.yml'
pages:
image: shockrah/website:latest
stage: pages
@@ -22,62 +26,67 @@ pages:
paths:
- public/
# Webpack bundles everything anyway so both admin/non-admin builds have
# the same frontend code.
build-frontend-js:
image: codesignal/typescript:v9.6.0
stage: build-frontend
stage: pages
only:
refs:
- master
script:
- cd ts/
- npm run setup
- npm i
- npm run build
artifacts:
paths:
- api/static/
- clippable-svc/static/
# Literally both of these fail 99% of the time so I'm forgoing them completely
# for now until I find something doesn't suck
# Builds out the intended zip package
build-server-binaries:
image: rustlang/rust:nightly
build-server-no-admin:
extends: .cargo-builder
stage: build-backend
stage: pages
only:
refs:
- master
needs:
- build-frontend-js
dependencies:
- build-frontend-js
script:
- mkdir -p build
- cp api/templates/ api/static/ build/ -r
- cd api/
- cd clippable-svc/
- cargo build --release
- cd ../
- cp api/target/release/clippable-server build/server
- cp ./scripts/ build/ -r
- cp readme.md build/
- sh ./scripts/default-rocket-toml.sh
artifacts:
paths:
- build/
# Upload built docker image to the local registry
deploy-docker-image:
stage: deploy
image: docker:stable
only:
refs:
- master
build-server-admin-enabled:
extends: .cargo-builder
stage: build-backend
needs:
- build-server-binaries
- build-frontend-js
dependencies:
- build-server-binaries
services:
- docker:dind
- build-frontend-js
script:
- cd clippable-svc/
- cargo build --release --features admin
- cd ../
deploy-no-admin-docker:
stage: deploy
extends: .docker-deploy
needs:
- build-server-no-admin
dependencies:
- build-server-no-admin
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t registry.gitlab.com/shockrah/clippable .
- docker push registry.gitlab.com/shockrah/clippable
deploy-admin-docker:
stage: deploy
extends: .docker-deploy
needs:
- build-server-admin-enabled
dependencies:
- build-server-admin-enabled
script:
- docker build -t registry.gitlab.com/shockrah/clippable:admin .
- docker push registry.gitlab.com/shockrah/clippable:admin

3
.gitmodules vendored
View File

@@ -1,3 +1,6 @@
[submodule "gitpage/themes/dimension"]
path = gitpage/themes/dimension
url = https://gitlab.com/dspechnikov/dimension-hugo
[submodule "service/themes/dimension"]
path = service/themes/dimension
url = https://github.com/your-identity/hugo-theme-dimension.git

2112
api/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
[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"]

View File

@@ -1,51 +0,0 @@
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))
}
}

View File

@@ -1,76 +0,0 @@
#[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())
}

View File

@@ -1,35 +0,0 @@
/*
* 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
})
}

View File

@@ -1,14 +0,0 @@
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
}

View File

@@ -1,80 +0,0 @@
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()
})
}
/// 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)
}

View File

@@ -1,17 +0,0 @@
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()
}
}

View File

@@ -1,117 +0,0 @@
#[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)
}
}
}

View File

@@ -1,55 +0,0 @@
//#![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;
}
}

View File

@@ -1,121 +0,0 @@
/// 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()
}

View File

@@ -1,26 +0,0 @@
#[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);
}
}

View File

@@ -1,26 +0,0 @@
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();
}
}

View File

@@ -1,71 +0,0 @@
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()
}

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bc7637a1b3f7e75322e3dddb3499931dc9cd57804afbf46c7941e6bc5211d03c
size 21076

View File

@@ -1,85 +0,0 @@
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;
}

View File

@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0cfdedb04d45802c1c008c9610bda2df8e3482f195bd254c11ebe07205e2bd5d
size 8560

View File

@@ -1,57 +0,0 @@
<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>

View File

@@ -1,78 +0,0 @@
<!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>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>
</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>

View File

@@ -1,41 +0,0 @@
<!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>

View File

@@ -1,32 +0,0 @@
<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>

View File

@@ -1,42 +0,0 @@
<!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>

View File

@@ -1,14 +0,0 @@
resource "aws_ebs_volume" "app_volume" {
availability_zone = var.availability_zone
size = 20
type = "standard"
tags = {
Name = "APP Video block storage"
}
}
resource "aws_volume_attachment" "ebs_att" {
device_name = "/dev/sdf"
volume_id = aws_ebs_volume.app_volume.id
instance_id = aws_instance.app_instance.id
}

View File

@@ -1,35 +0,0 @@
# This here module takes care of setting up the ec2 instances that our
# containers will bind to later on
variable "aws_key" {}
variable "aws_secret" {}
variable "aws_region" {}
variable "ami_id" {}
variable "instance_type" {}
variable "ssh_key_name" {}
variable "public_key_path" {}
variable "availability_zone" {}
provider "aws" {
access_key = var.aws_key
secret_key = var.aws_secret
region = var.aws_region
max_retries = 1
}
resource "aws_key_pair" "sshkey" {
key_name = var.ssh_key_name
public_key = file(var.public_key_path)
}
resource "aws_instance" "app_instance" {
ami = var.ami_id
instance_type = var.instance_type
key_name = var.ssh_key_name
security_groups = [ aws_security_group.app_security_group.id ]
subnet_id = aws_subnet.app_public_subnet.id
tags = {
Name = "Clippable App Instance"
}
}

View File

@@ -1,7 +0,0 @@
resource "aws_eip" "app_eip" {
instance = aws_instance.app_instance.id
vpc = true
tags = {
Name = "Clippable EIP"
}
}

View File

@@ -1,6 +0,0 @@
resource "aws_internet_gateway" "app_gateway" {
vpc_id = aws_vpc.app_vpc.id
tags = {
Name = "Clippable app internet gateway"
}
}

View File

@@ -1,12 +0,0 @@
resource "aws_route_table" "app_route_table" {
vpc_id = aws_vpc.app_vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.app_gateway.id
}
}
resource "aws_route_table_association" "app_subnet_assoc" {
subnet_id = aws_subnet.app_public_subnet.id
route_table_id = aws_route_table.app_route_table.id
}

View File

@@ -1,32 +0,0 @@
#!/bin/sh
# This script runs in order to to set things up for so that we don't have to do
# much else by ourselves
# No harm in using sudo even as root its just a little pointless
# Doing this with our ami however means we don't have to check if we're root
# for privileged operations at provision-time
apt="sudo apt"
server_name=$1
if [ -z "$server_name" ];then
echo A servername must be given as an argument
fi
$apt update
$apt upgrade
$apt install -y nginx certbot
sudo mkdir -p /var/www/clippable
# Creating the reverse proxy configuration for nginx
# WARN: Also we're assuming that the webserver has the default port
# Only this because certbot does the rest
cat << EOF > /etc/nginx/sites-available/clippable
server {
server_name $server_name;
location / {
proxy_pass http://0.0.0.0:8482;
};
}
EOF

View File

@@ -1,39 +0,0 @@
resource "aws_security_group" "app_security_group" {
name = "App sec group"
description = "Allowing SSH and web traffic"
vpc_id = aws_vpc.app_vpc.id
ingress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 443
to_port = 443
protocol = "tcp"
}
ingress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 80
to_port = 80
protocol = "tcp"
}
ingress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 22
to_port = 22
protocol = "tcp"
}
# These are so that we can update the system regularly using apt and sometimes
# with tarballs if we're updating something from source
egress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 443
to_port = 443
protocol = "tcp"
}
egress {
cidr_blocks = ["0.0.0.0/0"]
from_port = 80
to_port = 80
protocol = "tcp"
}
}

View File

@@ -1,5 +0,0 @@
resource "aws_subnet" "app_public_subnet" {
vpc_id = aws_vpc.app_vpc.id
cidr_block = "10.0.0.128/26"
availability_zone = var.availability_zone
}

View File

@@ -1,10 +0,0 @@
resource "aws_vpc" "app_vpc" {
cidr_block = "10.0.0.128/26"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "Clippable APP VPC"
}
}

View File

@@ -1,57 +0,0 @@
Role Name
=========
This role is dedicated to making the setup and administration of a clippable
server a little bit easier for those intending on running their own instance.
There are playbooks for maintaining this service both as a container and as a
service running on System D.
Role Variables
--------------
Vars in: `defaults/main.yml`
* `remote_user`: Default username to use for regular tasks
Set to `admin` by default.
* `remote_app_dir`: Directory to install application files into
Set to `/home/{{remote_user}}/app` by default
This includes things like the server binary and HTML template files.
You only need to worry about this if you're not going to run this
in a container.
* `main_host`
Set to `main` by default.
Host that you intend on targeting.
Dependencies
------------
* community.docker
This is only required if you are planning on using any of the docker playbooks.
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
License
-------
GPL V3
Author Information
------------------
Author: Shockrah
Email: dev@shockrah.xyz

View File

@@ -1,4 +0,0 @@
---
remote_user: admin
remote_app_dir: "/home/{{remote_user}}/app"
main_host: main

View File

@@ -1,2 +0,0 @@
---
# handlers file for playbooks

View File

@@ -1,52 +0,0 @@
galaxy_info:
author: your name
description: your role description
company: your company (optional)
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
# Choose a valid license ID from https://spdx.org - some suggested licenses:
# - BSD-3-Clause (default)
# - MIT
# - GPL-2.0-or-later
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: license (GPL-2.0-or-later, MIT, etc)
min_ansible_version: 2.1
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
#
# Provide a list of supported platforms, and for each platform a list of versions.
# If you don't wish to enumerate all versions for a particular platform, use 'all'.
# To view available platforms and versions (or releases), visit:
# https://galaxy.ansible.com/api/v1/platforms/
#
# platforms:
# - name: Fedora
# versions:
# - all
# - 25
# - name: SomePlatform
# versions:
# - all
# - 1.0
# - 7
# - 99.99
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View File

@@ -1,41 +0,0 @@
---
tasks:
- name: Install docker dependencies
become: yes
become_method: sudo
apt:
name: "{{item}}"
update_cache: yes
loop:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- software-properties-common
- lsb-release
- name: Install docker GPG key
become: yes
become_method: sudo
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker Apt Repo
become: yes
become_method: sudo
apt_repository:
repo: deb https://download.docker.com/linux/ubuntu impish stable
state: present
- name: Install Docker components
become: yes
become_method: sudo
apt:
name: "{{item}}"
update_cache: yes
loop:
- docker-ce
- docker-ce-cli
- containerd.io

View File

@@ -1,9 +0,0 @@
# This playbook is setup to install docker on debian based systems
---
- hosts: main
tasks:
- include_tasks: 'debian.yml'
when:
ansible_distribution: Debian

View File

@@ -1,59 +0,0 @@
# WHEN TO USE THIS PLAYBOOK:
# Use this if you're running clippable under systemd
# WHAT THIS PLAYBOOK DOES:
# This playbooks basically takes a build/ directory similar to what the Gitlab
# pipelines generate and uploads those files to the desired directory
---
- hosts: {{ main_host }}
remote_user: {{ remote_user }}
tasks:
- name: Build skeleton root directory
file:
path: '{{remote_app_dir}}'
state: directory
- name: Build skeleton static directory
file:
path: '{{remote_app_dir}}/static'
state: directory
- name: Build skeleton css directory
file:
path: '{{remote_app_dir}}/static/css'
state: directory
- name: Build skeleton js directory
file:
path: '{{remote_app_dir}}/static/js'
state: directory
- name: Build skeleton templates directory
file:
path: '{{remote_app_dir}}/templates'
state: directory
- name: Update Binary installation
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
with_items:
- { src: ../../build/static/css/style.css, dest: '{{remote_app_dir}}/static/css/'}
- { src: ../../build/static/js/index.js, dest: '{{remote_app_dir}}/static/js/'}
- { src: ../../build/static/js/category.js, dest: '{{remote_app_dir}}/static//js/'}
- { src: ../../build/static/cantfindshit.jpg, dest: '{{remote_app_dir}}/static/'}
- { src: ../../build/static/favicon.png, dest: '{{remote_app_dir}}/static/'}
- { src: ../../build/templates/list.html.tera, dest: '{{remote_app_dir}}/templates/list.html.tera'}
- { src: ../../build/templates/video.html.tera, dest: '{{remote_app_dir}}/templates/video.html.tera'}
- { src: ../../build/Rocket.toml, dest: '{{remote_app_dir}}/'}
- { src: ../../build/server, dest: '{{remote_app_dir}}/'}
- name: Restart web service
become: yes
become_method: sudo
service:
name: app
state: restarted

View File

@@ -1,2 +0,0 @@
localhost

View File

@@ -1,5 +0,0 @@
---
- hosts: localhost
remote_user: root
roles:
- playbooks

View File

@@ -1,2 +0,0 @@
---
# vars file for playbooks

View File

@@ -1,8 +0,0 @@
# AWS Configuration
For those that would like to deploy a minimal instance to AWS via terraform
this directory will basically have everything you need to get a working instance
up and running for very cheap with an EC2 instance.
There is still the question of preparing the EC2 instance itself however the
amount of configuration is very light.

View File

@@ -1,5 +0,0 @@
# NOTE: sample inventory either use your own inventory file or just
# replace the hostname/ip below
[main]
1.1.1.1

16
ci/cargo.yml Normal file
View File

@@ -0,0 +1,16 @@
.cargo-builder:
image: rustlang/rust:nightly
only:
refs:
- master
before_script:
- mkdir -p build
- cp clippable-svc/templates/ clippable-svc/static/ build/ -r
after_script:
- cp clippable-svc/target/release/clippable-server build/server
- cp ./scripts/ build/ -r
- cp readme.md build/
- sh ./scripts/default-rocket-toml.sh
artifacts:
paths:
- build/

9
ci/docker.yml Normal file
View File

@@ -0,0 +1,9 @@
.docker-deploy:
image: docker:stable
services:
- docker:dind
only:
refs:
- master
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"

View File

@@ -10,9 +10,9 @@ cargo build --release
mkdir -p build/
cp target/release/api build/server
cp api/templates/ build/ -r
cp api/static/ build -r
cp target/release/clippable-svc build/server
cp clippable-svc/templates/ build/ -r
cp clippable-svc/static/ build -r
bash ./scripts/default-rocket-toml.sh
docker build -t registry.gitlab.com/shockrah/clippable .

1
service/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

2487
service/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
service/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[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"] }

33
service/src/db.rs Normal file
View File

@@ -0,0 +1,33 @@
use std::collections::HashMap;
use std::fs;
use rocket::serde::json;
use serde::Deserialize;
use serde::Serialize;
use crate::DB_FILE;
#[derive(Serialize, Deserialize)]
pub struct Row {
pub file: String,
pub thumbnail: String
}
type Data = HashMap<String, Row>;
pub fn get_video(id: &str) -> 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(DB_FILE)
.expect("Unable to read DB_FILE");
let db_data: Data = json::from_str(&db_file)
.expect("Unable to parse DB_FILE content");
match db_data.get(id) {
Some(video) => Some(Row {
file: video.file.clone(),
thumbnail: video.thumbnail.clone()
}),
None => None
}
}

6
service/src/generic.rs Normal file
View File

@@ -0,0 +1,6 @@
use rocket_dyn_templates::{context, Template};
#[get("/")]
pub fn homepage() -> Template {
Template::render("index", context!{ })
}

24
service/src/main.rs Normal file
View File

@@ -0,0 +1,24 @@
#[macro_use] extern crate rocket;
use rocket_dyn_templates::Template;
use rocket::fs::FileServer;
mod video;
mod db;
mod generic;
const FILES_VIDEO: &'static str = "/opt/clippable/file/video";
const DB_FILE: &'static str = "/opt/clippable/db.json";
const VIDEO_BASE_URI: &'static str = "/file/video";
#[rocket::main]
async fn main() {
let _ = rocket::build()
.mount("/", routes![generic::homepage])
.mount("/static", FileServer::from("static"))
.mount("/c", routes![video::index])
.mount(VIDEO_BASE_URI, routes![video::file])
.attach(Template::fairing())
.launch().await;
}

32
service/src/video.rs Normal file
View File

@@ -0,0 +1,32 @@
use crate::{FILES_VIDEO, VIDEO_BASE_URI, db};
use std::path::PathBuf;
use rocket::fs::NamedFile;
use rocket_dyn_templates::{Template, context};
#[get("/<video_id>")]
pub fn index(video_id: &str) -> 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("video", context! {
title: path.to_str().unwrap(),
kind: path.extension().expect("No extension found").to_str().unwrap(),
video: format!("{}/{}", VIDEO_BASE_URI, video.file)
})
},
None => Template::render("video", context! {
title: "Not found"
})
}
}
#[get("/<path>")]
pub async fn file(path: PathBuf) -> Option<NamedFile> {
let disk = PathBuf::from(FILES_VIDEO).join(path);
println!("disk path: {disk:?}");
NamedFile::open(disk).await.ok()
}

BIN
service/static/bg.png LFS Normal file

Binary file not shown.

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4582377054b2a53e18c5f212a7f28aaf6915a6765d8f2f2b77b041152e8068ef
size 19080

2
service/static/js/breakpoints.min.js vendored Normal file
View File

@@ -0,0 +1,2 @@
/* breakpoints.js v1.0 | @ajlkn | MIT licensed */
var breakpoints=function(){"use strict";function e(e){t.init(e)}var t={list:null,media:{},events:[],init:function(e){t.list=e,window.addEventListener("resize",t.poll),window.addEventListener("orientationchange",t.poll),window.addEventListener("load",t.poll),window.addEventListener("fullscreenchange",t.poll)},active:function(e){var n,a,s,i,r,d,c;if(!(e in t.media)){if(">="==e.substr(0,2)?(a="gte",n=e.substr(2)):"<="==e.substr(0,2)?(a="lte",n=e.substr(2)):">"==e.substr(0,1)?(a="gt",n=e.substr(1)):"<"==e.substr(0,1)?(a="lt",n=e.substr(1)):"!"==e.substr(0,1)?(a="not",n=e.substr(1)):(a="eq",n=e),n&&n in t.list)if(i=t.list[n],Array.isArray(i)){if(r=parseInt(i[0]),d=parseInt(i[1]),isNaN(r)){if(isNaN(d))return;c=i[1].substr(String(d).length)}else c=i[0].substr(String(r).length);if(isNaN(r))switch(a){case"gte":s="screen";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: -1px)";break;case"not":s="screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (max-width: "+d+c+")"}else if(isNaN(d))switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen";break;case"gt":s="screen and (max-width: -1px)";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+")";break;default:s="screen and (min-width: "+r+c+")"}else switch(a){case"gte":s="screen and (min-width: "+r+c+")";break;case"lte":s="screen and (max-width: "+d+c+")";break;case"gt":s="screen and (min-width: "+(d+1)+c+")";break;case"lt":s="screen and (max-width: "+(r-1)+c+")";break;case"not":s="screen and (max-width: "+(r-1)+c+"), screen and (min-width: "+(d+1)+c+")";break;default:s="screen and (min-width: "+r+c+") and (max-width: "+d+c+")"}}else s="("==i.charAt(0)?"screen and "+i:i;t.media[e]=!!s&&s}return t.media[e]!==!1&&window.matchMedia(t.media[e]).matches},on:function(e,n){t.events.push({query:e,handler:n,state:!1}),t.active(e)&&n()},poll:function(){var e,n;for(e=0;e<t.events.length;e++)n=t.events[e],t.active(n.query)?n.state||(n.state=!0,n.handler()):n.state&&(n.state=!1)}};return e._=t,e.on=function(e,n){t.on(e,n)},e.active=function(e){return t.active(e)},e}();!function(e,t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?module.exports=t():e.breakpoints=t()}(this,function(){return breakpoints});

2
service/static/js/browser.min.js vendored Normal file
View File

@@ -0,0 +1,2 @@
/* browser.js v1.0 | @ajlkn | MIT licensed */
var browser=function(){"use strict";var e={name:null,version:null,os:null,osVersion:null,touch:null,mobile:null,_canUse:null,canUse:function(n){e._canUse||(e._canUse=document.createElement("div"));var o=e._canUse.style,r=n.charAt(0).toUpperCase()+n.slice(1);return n in o||"Moz"+r in o||"Webkit"+r in o||"O"+r in o||"ms"+r in o},init:function(){var n,o,r,i,t=navigator.userAgent;for(n="other",o=0,r=[["firefox",/Firefox\/([0-9\.]+)/],["bb",/BlackBerry.+Version\/([0-9\.]+)/],["bb",/BB[0-9]+.+Version\/([0-9\.]+)/],["opera",/OPR\/([0-9\.]+)/],["opera",/Opera\/([0-9\.]+)/],["edge",/Edge\/([0-9\.]+)/],["safari",/Version\/([0-9\.]+).+Safari/],["chrome",/Chrome\/([0-9\.]+)/],["ie",/MSIE ([0-9]+)/],["ie",/Trident\/.+rv:([0-9]+)/]],i=0;i<r.length;i++)if(t.match(r[i][1])){n=r[i][0],o=parseFloat(RegExp.$1);break}for(e.name=n,e.version=o,n="other",o=0,r=[["ios",/([0-9_]+) like Mac OS X/,function(e){return e.replace("_",".").replace("_","")}],["ios",/CPU like Mac OS X/,function(e){return 0}],["wp",/Windows Phone ([0-9\.]+)/,null],["android",/Android ([0-9\.]+)/,null],["mac",/Macintosh.+Mac OS X ([0-9_]+)/,function(e){return e.replace("_",".").replace("_","")}],["windows",/Windows NT ([0-9\.]+)/,null],["bb",/BlackBerry.+Version\/([0-9\.]+)/,null],["bb",/BB[0-9]+.+Version\/([0-9\.]+)/,null],["linux",/Linux/,null],["bsd",/BSD/,null],["unix",/X11/,null]],i=0;i<r.length;i++)if(t.match(r[i][1])){n=r[i][0],o=parseFloat(r[i][2]?r[i][2](RegExp.$1):RegExp.$1);break}e.os=n,e.osVersion=o,e.touch="wp"==e.os?navigator.msMaxTouchPoints>0:!!("ontouchstart"in window),e.mobile="wp"==e.os||"android"==e.os||"ios"==e.os||"bb"==e.os}};return e.init(),e}();!function(e,n){"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?module.exports=n():e.browser=n()}(this,function(){return browser});

2
service/static/js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

417
service/static/js/main.js Normal file
View File

@@ -0,0 +1,417 @@
/*
Dimension by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*/
(function($) {
var $window = $(window),
$body = $('body'),
$wrapper = $('#wrapper'),
$header = $('#header'),
$footer = $('#footer'),
$main = $('#main'),
$main_articles = $main.children('article');
// Breakpoints.
breakpoints({
xlarge: [ '1281px', '1680px' ],
large: [ '981px', '1280px' ],
medium: [ '737px', '980px' ],
small: [ '481px', '736px' ],
xsmall: [ '361px', '480px' ],
xxsmall: [ null, '360px' ]
});
// Play initial animations on page load.
$window.on('load', function() {
window.setTimeout(function() {
$body.removeClass('is-preload');
}, 100);
});
// Fix: Flexbox min-height bug on IE.
if (browser.name == 'ie') {
var flexboxFixTimeoutId;
$window.on('resize.flexbox-fix', function() {
clearTimeout(flexboxFixTimeoutId);
flexboxFixTimeoutId = setTimeout(function() {
if ($wrapper.prop('scrollHeight') > $window.height())
$wrapper.css('height', 'auto');
else
$wrapper.css('height', '100vh');
}, 250);
}).triggerHandler('resize.flexbox-fix');
}
// Nav.
var $nav = $header.children('nav'),
$nav_li = $nav.find('li');
// Add "middle" alignment classes if we're dealing with an even number of items.
if ($nav_li.length % 2 == 0) {
$nav.addClass('use-middle');
$nav_li.eq( ($nav_li.length / 2) ).addClass('is-middle');
}
// Main.
var delay = 325,
locked = false,
nohash = false;
// Set nohash.
if (location.hash == '' || location.hash == '#')
nohash = true;
// Methods.
$main._show = function(id, initial) {
var $article = $main_articles.filter('#' + id);
// No such article? Bail.
if ($article.length == 0)
return;
// Handle lock.
// Already locked? Speed through "show" steps w/o delays.
if (locked || (typeof initial != 'undefined' && initial === true)) {
// Mark as switching.
$body.addClass('is-switching');
// Mark as visible.
$body.addClass('is-article-visible');
// Deactivate all articles (just in case one's already active).
$main_articles.removeClass('active');
// Hide header, footer.
$header.hide();
$footer.hide();
// Show main, article.
$main.show();
$article.show();
// Activate article.
$article.addClass('active');
// Unlock.
locked = false;
// Unmark as switching.
setTimeout(function() {
$body.removeClass('is-switching');
}, (initial ? 1000 : 0));
return;
}
// Lock.
locked = true;
// Article already visible? Just swap articles.
if ($body.hasClass('is-article-visible')) {
// Deactivate current article.
var $currentArticle = $main_articles.filter('.active');
$currentArticle.removeClass('active');
// Show article.
setTimeout(function() {
// Hide current article.
$currentArticle.hide();
// Show article.
$article.show();
// Activate article.
setTimeout(function() {
$article.addClass('active');
// Window stuff.
$window
.scrollTop(0)
.triggerHandler('resize.flexbox-fix');
// Unlock.
setTimeout(function() {
locked = false;
}, delay);
}, 25);
}, delay);
}
// Otherwise, handle as normal.
else {
// Mark as visible.
$body
.addClass('is-article-visible');
// Show article.
setTimeout(function() {
// Hide header, footer.
$header.hide();
$footer.hide();
// Show main, article.
$main.show();
$article.show();
// Activate article.
setTimeout(function() {
$article.addClass('active');
// Window stuff.
$window
.scrollTop(0)
.triggerHandler('resize.flexbox-fix');
// Unlock.
setTimeout(function() {
locked = false;
}, delay);
}, 25);
}, delay);
}
};
$main._hide = function(addState) {
var $article = $main_articles.filter('.active');
// Article not visible? Bail.
if (!$body.hasClass('is-article-visible'))
return;
// Add state?
if (typeof addState != 'undefined'
&& addState === true)
history.replaceState(null, null, '/');
// Handle lock.
// Already locked? Speed through "hide" steps w/o delays.
if (locked) {
// Mark as switching.
$body.addClass('is-switching');
// Deactivate article.
$article.removeClass('active');
// Hide article, main.
$article.hide();
$main.hide();
// Show footer, header.
$footer.show();
$header.show();
// Unmark as visible.
$body.removeClass('is-article-visible');
// Unlock.
locked = false;
// Unmark as switching.
$body.removeClass('is-switching');
// Window stuff.
$window
.scrollTop(0)
.triggerHandler('resize.flexbox-fix');
return;
}
// Lock.
locked = true;
// Deactivate article.
$article.removeClass('active');
// Hide article.
setTimeout(function() {
// Hide article, main.
$article.hide();
$main.hide();
// Show footer, header.
$footer.show();
$header.show();
// Unmark as visible.
setTimeout(function() {
$body.removeClass('is-article-visible');
// Window stuff.
$window
.scrollTop(0)
.triggerHandler('resize.flexbox-fix');
// Unlock.
setTimeout(function() {
locked = false;
}, delay);
}, 25);
}, delay);
};
// Articles.
$main_articles.each(function() {
var $this = $(this);
// Close.
$('<div class="close">Close</div>')
.appendTo($this)
.on('click', function() {
if (nohash) {
history.go(-1);
} else {
$main._hide(true);
nohash = true;
}
});
// Prevent clicks from inside article from bubbling.
$this.on('click', function(event) {
event.stopPropagation();
});
});
// Events.
$body.on('click', function(event) {
// Article visible? Go back. Hide when article is accessed first.
if ($body.hasClass('is-article-visible')) {
if (nohash) {
history.go(-1);
} else {
$main._hide(true);
nohash = true;
}
}
});
$window.on('keyup', function(event) {
switch (event.keyCode) {
case 27:
// Article visible? Hide.
if ($body.hasClass('is-article-visible'))
$main._hide(true);
break;
default:
break;
}
});
$window.on('hashchange', function(event) {
// Empty hash?
if (location.hash == ''
|| location.hash == '#') {
// Prevent default.
event.preventDefault();
event.stopPropagation();
// Hide.
$main._hide();
}
// Otherwise, check for a matching article.
else if ($main_articles.filter(location.hash).length > 0) {
// Prevent default.
event.preventDefault();
event.stopPropagation();
// Show article.
$main._show(location.hash.substr(1));
}
});
// Scroll restoration.
// This prevents the page from scrolling back to the top on a hashchange.
if ('scrollRestoration' in history)
history.scrollRestoration = 'manual';
else {
var oldScrollPos = 0,
scrollPos = 0,
$htmlbody = $('html,body');
$window
.on('scroll', function() {
oldScrollPos = scrollPos;
scrollPos = $htmlbody.scrollTop();
})
.on('hashchange', function() {
$window.scrollTop(oldScrollPos);
});
}
// Initialize.
// Hide main, articles.
$main.hide();
$main_articles.hide();
// Initial article.
if (location.hash != ''
&& location.hash != '#')
$window.on('load', function() {
$main._show(location.hash.substr(1), true);
});
})(jQuery);

587
service/static/js/util.js Normal file
View File

@@ -0,0 +1,587 @@
(function($) {
/**
* Generate an indented list of links from a nav. Meant for use with panel().
* @return {jQuery} jQuery object.
*/
$.fn.navList = function() {
var $this = $(this);
$a = $this.find('a'),
b = [];
$a.each(function() {
var $this = $(this),
indent = Math.max(0, $this.parents('li').length - 1),
href = $this.attr('href'),
target = $this.attr('target');
b.push(
'<a ' +
'class="link depth-' + indent + '"' +
( (typeof target !== 'undefined' && target != '') ? ' target="' + target + '"' : '') +
( (typeof href !== 'undefined' && href != '') ? ' href="' + href + '"' : '') +
'>' +
'<span class="indent-' + indent + '"></span>' +
$this.text() +
'</a>'
);
});
return b.join('');
};
/**
* Panel-ify an element.
* @param {object} userConfig User config.
* @return {jQuery} jQuery object.
*/
$.fn.panel = function(userConfig) {
// No elements?
if (this.length == 0)
return $this;
// Multiple elements?
if (this.length > 1) {
for (var i=0; i < this.length; i++)
$(this[i]).panel(userConfig);
return $this;
}
// Vars.
var $this = $(this),
$body = $('body'),
$window = $(window),
id = $this.attr('id'),
config;
// Config.
config = $.extend({
// Delay.
delay: 0,
// Hide panel on link click.
hideOnClick: false,
// Hide panel on escape keypress.
hideOnEscape: false,
// Hide panel on swipe.
hideOnSwipe: false,
// Reset scroll position on hide.
resetScroll: false,
// Reset forms on hide.
resetForms: false,
// Side of viewport the panel will appear.
side: null,
// Target element for "class".
target: $this,
// Class to toggle.
visibleClass: 'visible'
}, userConfig);
// Expand "target" if it's not a jQuery object already.
if (typeof config.target != 'jQuery')
config.target = $(config.target);
// Panel.
// Methods.
$this._hide = function(event) {
// Already hidden? Bail.
if (!config.target.hasClass(config.visibleClass))
return;
// If an event was provided, cancel it.
if (event) {
event.preventDefault();
event.stopPropagation();
}
// Hide.
config.target.removeClass(config.visibleClass);
// Post-hide stuff.
window.setTimeout(function() {
// Reset scroll position.
if (config.resetScroll)
$this.scrollTop(0);
// Reset forms.
if (config.resetForms)
$this.find('form').each(function() {
this.reset();
});
}, config.delay);
};
// Vendor fixes.
$this
.css('-ms-overflow-style', '-ms-autohiding-scrollbar')
.css('-webkit-overflow-scrolling', 'touch');
// Hide on click.
if (config.hideOnClick) {
$this.find('a')
.css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)');
$this
.on('click', 'a', function(event) {
var $a = $(this),
href = $a.attr('href'),
target = $a.attr('target');
if (!href || href == '#' || href == '' || href == '#' + id)
return;
// Cancel original event.
event.preventDefault();
event.stopPropagation();
// Hide panel.
$this._hide();
// Redirect to href.
window.setTimeout(function() {
if (target == '_blank')
window.open(href);
else
window.location.href = href;
}, config.delay + 10);
});
}
// Event: Touch stuff.
$this.on('touchstart', function(event) {
$this.touchPosX = event.originalEvent.touches[0].pageX;
$this.touchPosY = event.originalEvent.touches[0].pageY;
})
$this.on('touchmove', function(event) {
if ($this.touchPosX === null
|| $this.touchPosY === null)
return;
var diffX = $this.touchPosX - event.originalEvent.touches[0].pageX,
diffY = $this.touchPosY - event.originalEvent.touches[0].pageY,
th = $this.outerHeight(),
ts = ($this.get(0).scrollHeight - $this.scrollTop());
// Hide on swipe?
if (config.hideOnSwipe) {
var result = false,
boundary = 20,
delta = 50;
switch (config.side) {
case 'left':
result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX > delta);
break;
case 'right':
result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX < (-1 * delta));
break;
case 'top':
result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY > delta);
break;
case 'bottom':
result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY < (-1 * delta));
break;
default:
break;
}
if (result) {
$this.touchPosX = null;
$this.touchPosY = null;
$this._hide();
return false;
}
}
// Prevent vertical scrolling past the top or bottom.
if (($this.scrollTop() < 0 && diffY < 0)
|| (ts > (th - 2) && ts < (th + 2) && diffY > 0)) {
event.preventDefault();
event.stopPropagation();
}
});
// Event: Prevent certain events inside the panel from bubbling.
$this.on('click touchend touchstart touchmove', function(event) {
event.stopPropagation();
});
// Event: Hide panel if a child anchor tag pointing to its ID is clicked.
$this.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.removeClass(config.visibleClass);
});
// Body.
// Event: Hide panel on body click/tap.
$body.on('click touchend', function(event) {
$this._hide(event);
});
// Event: Toggle.
$body.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.toggleClass(config.visibleClass);
});
// Window.
// Event: Hide on ESC.
if (config.hideOnEscape)
$window.on('keydown', function(event) {
if (event.keyCode == 27)
$this._hide(event);
});
return $this;
};
/**
* Apply "placeholder" attribute polyfill to one or more forms.
* @return {jQuery} jQuery object.
*/
$.fn.placeholder = function() {
// Browser natively supports placeholders? Bail.
if (typeof (document.createElement('input')).placeholder != 'undefined')
return $(this);
// No elements?
if (this.length == 0)
return $this;
// Multiple elements?
if (this.length > 1) {
for (var i=0; i < this.length; i++)
$(this[i]).placeholder();
return $this;
}
// Vars.
var $this = $(this);
// Text, TextArea.
$this.find('input[type=text],textarea')
.each(function() {
var i = $(this);
if (i.val() == ''
|| i.val() == i.attr('placeholder'))
i
.addClass('polyfill-placeholder')
.val(i.attr('placeholder'));
})
.on('blur', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/))
return;
if (i.val() == '')
i
.addClass('polyfill-placeholder')
.val(i.attr('placeholder'));
})
.on('focus', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/))
return;
if (i.val() == i.attr('placeholder'))
i
.removeClass('polyfill-placeholder')
.val('');
});
// Password.
$this.find('input[type=password]')
.each(function() {
var i = $(this);
var x = $(
$('<div>')
.append(i.clone())
.remove()
.html()
.replace(/type="password"/i, 'type="text"')
.replace(/type=password/i, 'type=text')
);
if (i.attr('id') != '')
x.attr('id', i.attr('id') + '-polyfill-field');
if (i.attr('name') != '')
x.attr('name', i.attr('name') + '-polyfill-field');
x.addClass('polyfill-placeholder')
.val(x.attr('placeholder')).insertAfter(i);
if (i.val() == '')
i.hide();
else
x.hide();
i
.on('blur', function(event) {
event.preventDefault();
var x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') {
i.hide();
x.show();
}
});
x
.on('focus', function(event) {
event.preventDefault();
var i = x.parent().find('input[name=' + x.attr('name').replace('-polyfill-field', '') + ']');
x.hide();
i
.show()
.focus();
})
.on('keypress', function(event) {
event.preventDefault();
x.val('');
});
});
// Events.
$this
.on('submit', function() {
$this.find('input[type=text],input[type=password],textarea')
.each(function(event) {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/))
i.attr('name', '');
if (i.val() == i.attr('placeholder')) {
i.removeClass('polyfill-placeholder');
i.val('');
}
});
})
.on('reset', function(event) {
event.preventDefault();
$this.find('select')
.val($('option:first').val());
$this.find('input,textarea')
.each(function() {
var i = $(this),
x;
i.removeClass('polyfill-placeholder');
switch (this.type) {
case 'submit':
case 'reset':
break;
case 'password':
i.val(i.attr('defaultValue'));
x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') {
i.hide();
x.show();
}
else {
i.show();
x.hide();
}
break;
case 'checkbox':
case 'radio':
i.attr('checked', i.attr('defaultValue'));
break;
case 'text':
case 'textarea':
i.val(i.attr('defaultValue'));
if (i.val() == '') {
i.addClass('polyfill-placeholder');
i.val(i.attr('placeholder'));
}
break;
default:
i.val(i.attr('defaultValue'));
break;
}
});
});
return $this;
};
/**
* Moves elements to/from the first positions of their respective parents.
* @param {jQuery} $elements Elements (or selector) to move.
* @param {bool} condition If true, moves elements to the top. Otherwise, moves elements back to their original locations.
*/
$.prioritize = function($elements, condition) {
var key = '__prioritize';
// Expand $elements if it's not already a jQuery object.
if (typeof $elements != 'jQuery')
$elements = $($elements);
// Step through elements.
$elements.each(function() {
var $e = $(this), $p,
$parent = $e.parent();
// No parent? Bail.
if ($parent.length == 0)
return;
// Not moved? Move it.
if (!$e.data(key)) {
// Condition is false? Bail.
if (!condition)
return;
// Get placeholder (which will serve as our point of reference for when this element needs to move back).
$p = $e.prev();
// Couldn't find anything? Means this element's already at the top, so bail.
if ($p.length == 0)
return;
// Move element to top of parent.
$e.prependTo($parent);
// Mark element as moved.
$e.data(key, $p);
}
// Moved already?
else {
// Condition is true? Bail.
if (condition)
return;
$p = $e.data(key);
// Move element back to its original location (using our placeholder).
$e.insertAfter($p);
// Unmark element as moved.
$e.removeData(key);
}
});
};
})(jQuery);

3
service/static/logo.png Normal file
View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4582377054b2a53e18c5f212a7f28aaf6915a6765d8f2f2b77b041152e8068ef
size 19080

1672
service/static/main.css Normal file

File diff suppressed because it is too large Load Diff

BIN
service/static/overlay.png LFS Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
<script src="/static/js/jquery.min.js"></script>
<script src="/static/js/browser.min.js"></script>
<script src="/static/js/breakpoints.min.js"></script>
<script src="/static/js/util.js"></script>
<script src="/static/js/main.js"></script>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>Clippable</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel="stylesheet" href="/static/main.css"/>
<link rel="icon" href="/static/favicon.png" type="image/png"
<noscript><link rel="stylesheet" href="/static/noscript.css"/></noscript>
<style>
:root{--site-background : url("/static/bg.png")};
</style>
</head>

View File

@@ -0,0 +1,31 @@
{% include "header" %}
<body class="is-preload">
<div id="wrapper">
<header id="header">
<div class="logo">
<span class="icon"> <img src="/static/logo.png"> </span>
</div>
<div class="content">
<div class="inner">
<h1>some title</h1>
<p> some description </p>
</div>
</div>
<nav>
<ul>
<li> <a href="#about-section">about section</a> </li>
</ul>
</nav>
</header>
<div id="main">
<article id="about-section">
<h2 class="major">about section</h2>
<span class="image main"><img src="" alt="" /></span>
<p>asdfadf</p>
</article>
</div>
</div>
<div id="bg"></div>
</body>
{% include "footer" %}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
{% include "header" %}
<body class="is-preload">
<div id="wrapper">
<header id="header">
<div class="content">
<div class="inner">
<h1>{{ title }}</h1>
<video controls>
<source src="{{ video }}" type="video/{{ kind }}">
</video>
</div>
</div>
</header>
</div>
<div id="bg"></div>
</body>
{% include "footer" %}

View File

@@ -1,8 +1,8 @@
// This module serves as convenience for admin users to upload/remove videos
// from their clippable instance. There are no fancy tricks as this is meant
// purely to be a UX thing.
//import { fetch_category_videos } from './category'
//import { fetch_categories } from './index'
import { fetch_category_videos, VideoMeta } from './category'
import { fetch_categories } from './index'
let UID: null|string = null
@@ -79,7 +79,6 @@ export function populate_meta_form() {
let file = document.getElementById('video-file') as HTMLInputElement
// When we remove the file this array becomes 0 so the check is required
console.log('files found', file.files.length)
if(file.files.length == 0) {
document.getElementById('video-meta').hidden = true
} else {
@@ -93,6 +92,24 @@ export function populate_meta_form() {
}
}
async function populate_video_list() {
const categories = await fetch_categories()
let videos: Array<VideoMeta> = []
for(const cat of categories) {
const vids = await fetch_category_videos(cat.name)
for(const v of vids) {
videos.push(v)
}
}
const list_ref = document.getElementById("videos-list")
for(const video of videos) {
list_ref.appendChild(video.as_li())
}
}
document.addEventListener('DOMContentLoaded', () => {
/*
* Setting up hooks required for functionality
@@ -100,4 +117,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('video-file').onchange = populate_meta_form
document.getElementById('verify-login-btn').onclick = confirm_auth
document.getElementById('confirm-upload-btn').onclick = upload_video
populate_video_list()
.then(value => console.log('succesful list population: ', value))
.catch(reason => console.log('Failure in populate_video_list', reason))
})

View File

@@ -1,8 +1,9 @@
class VideoMeta {
export class VideoMeta {
name: string|null
thumbnail: string|null
category: string
basename: string|null
href: string
constructor(raw: any) {
this.name = raw['name']
@@ -14,6 +15,8 @@ class VideoMeta {
this.basename = this.name ?
this.name.slice(0, this.name.lastIndexOf('.')) :
null
this.href = `/clip/${this.category}/${this.basename}`
}
private clean_link(link: string) : string {
@@ -28,7 +31,7 @@ class VideoMeta {
let container = document.createElement('h2')
let link = document.createElement('a')
if(this.name) {
link.href = `/clip/${this.category}/${this.basename}`
link.href = this.href
link.text = this.clean_link(this.name)
} else {
link.href = '#'
@@ -77,10 +80,38 @@ class VideoMeta {
return container
}
public as_li() : HTMLElement {
const li = document.createElement('li')
li.className = 'align-left list-group-item'
const link = document.createElement('a')
link.href = `/clip/${this.category}/${this.name}`
link.target = '_blank'
link.textContent = this.name
link.className = 'admin-video-li btn'
li.appendChild(link)
const thumbnail_link = document.createElement('a')
thumbnail_link.href = `/thumbnail/${this.category}/${this.name}.jpg`
link.target = '_blank'
thumbnail_link.innerHTML = '<i class="fa-solid fa-image"></i>'
thumbnail_link.className = 'admin-video-li btn'
li.appendChild(thumbnail_link)
const delete_btn = document.createElement('button')
delete_btn.type = 'button'
delete_btn.className = 'btn btn-danger'
delete_btn.innerHTML = '<i class="fa-solid fa-trash"></i>'
li.appendChild(delete_btn)
return li
}
}
export async function fetch_category_videos() : Promise<Array<VideoMeta>> {
const endpoint = window.location.origin + '/api' + window.location.pathname
export async function fetch_category_videos(name?: string) : Promise<Array<VideoMeta>> {
const category = name ? name : window.location.pathname
const endpoint = window.location.origin + `/api/category/${category}`
let videos: Array<VideoMeta> = []
const response = await fetch(endpoint)
if(response.headers.get('Content-Type') == 'application/json') {