working v1

This commit is contained in:
Oliver Atkinson 2024-05-08 13:56:59 -06:00
parent cc21efdea9
commit ca8c12f56e
20 changed files with 566 additions and 365 deletions

View File

@ -8,3 +8,7 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Videos & audio downloaded from testing
*.mp3
*.mp4

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "site/themes/lugo"]
path = site/themes/lugo
url = https://github.com/Rushmore75/lugo.git

View File

@ -1,21 +1,15 @@
[package]
name = "poem_website_template"
name = "server"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
argon2 = "0.5.3"
futures-util = "0.3"
password-hash = "0.5.0"
poem = { version="3", features = ["static-files", "websocket", "session"]}
rand = "0.8.5"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = "1.8.0"
clap = { version = "4.5.4", features = ["derive"] }
serde_json = "1.0.116"
csv = "1.3.0"
url = "2.5.0"

View File

@ -1,2 +0,0 @@
username,hashed_password,salt
admin,MMEq3XimAWJ3OKTDHl5cPFEC45ghVW4c2G3/fE9mfwY,k8omIbCJkNyoLa3QihqyQw
1 username hashed_password salt
2 admin MMEq3XimAWJ3OKTDHl5cPFEC45ghVW4c2G3/fE9mfwY k8omIbCJkNyoLa3QihqyQw

View File

@ -1,240 +0,0 @@
use std::collections::HashSet;
use std::fmt::Display;
use std::sync::Arc;
use argon2::Argon2;
use password_hash::rand_core::OsRng;
use password_hash::{PasswordHash, SaltString};
use poem::http::StatusCode;
use poem::{
web::cookie::SameSite,
middleware::CookieJarManager,
session::CookieConfig,
IntoResponse, Request, Endpoint, Middleware
};
use serde::Deserialize;
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::FILESYSTEM_ROOT;
pub static COOKIE_JAR_NAME: &str = "clocks";
pub mod simple_storage {
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
use tracing::{debug, error, warn};
use super::{Account, AccountDB};
#[derive(Clone)]
pub struct FilesystemDB {
data: Arc<RwLock<HashMap<String, Account>>>,
}
impl AccountDB for FilesystemDB {
async fn account_with_name(&self, name: &str) -> Option<Account> {
let data = self.data.read().await;
data.get(name).cloned()
}
async fn init() -> Self {
let path = "./import.csv";
let db: Arc<RwLock<HashMap<String, Account>>> = Default::default();
let csv = csv::Reader::from_path(path);
let mut lock = db.write().await;
match csv {
Ok(mut r) => {
for i in r.deserialize::<Account>() {
match i {
Ok(acct) => {
debug!("Importing: {:?}", acct.username);
lock.insert(acct.username.clone(), acct);
},
Err(e) => {
error!("Error whilst importing account. {}", e);
},
}
}
},
Err(e) => {
// TODO, if the file isn't found create random admin credentials and print them out.
error!("Could not read csv: \"{:?}\". {}", path, e);
warn!("Continuing without loading any accounts!");
}
}
drop(lock);
Self { data: db }
}
type Output = Self;
}
}
/// Methods for interacting with a database
pub trait AccountDB {
type Output;
fn account_with_name(&self, name: &str) -> impl std::future::Future<Output = Option<Account>> + Send;
async fn init() -> Self::Output;
}
#[derive(Clone, Deserialize)]
pub struct Account {
username: String,
hashed_password: String,
salt: String,
}
impl Display for Account {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{},{},{}",self.username,self.hashed_password,self.salt)
}
}
impl Account {
pub fn try_new(username: &str, password: &str) -> Result<Self, password_hash::Error> {
let salt = SaltString::generate(OsRng);
match PasswordHash::generate(Argon2::default(), password, &salt) {
Ok(hash) => {
return Ok(Self {
username: username.to_owned(),
hashed_password: hash.to_string(),
salt: salt.as_str().to_string(),
});
},
Err(e) => {
Err(e)
},
}
}
async fn is_valid_creds<A>(
accounts: &A,
username: &str,
password: &str,
) -> bool where A: AccountDB,
{
// find account with same username
if let Some(account) = accounts.account_with_name(username).await {
// retrieve the salt
if let Ok(salt) = SaltString::from_b64(&account.salt) {
// hash the incoming password to see if it is the same as the saved one
if let Ok(hash) = PasswordHash::generate(Argon2::default(), password, &salt) {
if hash.to_string() == account.hashed_password {
return true;
}
}
}
}
false
}
}
type SessionDB = Arc<RwLock<HashSet<String>>>;
#[derive(Clone)]
pub struct AppState<A>
where A: AccountDB
{
active_sessions: SessionDB,
cookie_config: Arc<CookieConfig>,
accounts: A,
}
impl<A> AppState<A>
where A: AccountDB<Output = A>
{
pub async fn new() -> Self {
let cc = CookieConfig::default()
.name(COOKIE_JAR_NAME)
.same_site(SameSite::Strict)
.max_age(None);
Self {
cookie_config: Arc::new(cc),
active_sessions: SessionDB::default(),
accounts: A::init().await,
}
}
}
impl<A: AccountDB + Clone + Send + Sync, E: Endpoint> Middleware<E> for AppState<A> {
type Output = poem::middleware::CookieJarManagerEndpoint<ServerSessionEndpoint<E, A>>;
fn transform(&self, ep: E) -> Self::Output {
CookieJarManager::new().transform(ServerSessionEndpoint {
// These are both Arc, thus cloning is cheap
config: self.cookie_config.clone(),
sessions: self.active_sessions.clone(),
endpoint: ep,
accounts: self.accounts.clone(),
})
}
}
pub struct ServerSessionEndpoint<E, A> {
endpoint: E,
config: Arc<CookieConfig>,
sessions: SessionDB,
accounts: A
}
impl<E, A> Endpoint for ServerSessionEndpoint<E, A>
where E: Endpoint,
A: AccountDB + Clone + Send+ Sync,
{
type Output = poem::Response;
async fn call(&self, req: Request) -> Result<Self::Output, poem::Error> {
let cookie_jar = req.cookie().clone();
// the config holds the keys to the cookie jar if it's locked. That's why we need self.config
let client_session_id = self.config.get_cookie_value(&cookie_jar);
// Up next:
// check if the client's session id is valid
// if it doesn't exist try to sign in
// if the session is invalid and signing in doesn't work - reject request
if let Some(id) = client_session_id {
let server_sessions = self.sessions.read().await;
if server_sessions.get(&id).is_some() {
// Client's session is found on the server.
// Go ahead and call the endpoint.
let res = self.endpoint.call(req).await?;
return Ok(res.into_response());
}
}
// couldn't validate req via the cookie
// try to login the user
// Try to log the user in
if let Some(username) = req.header("username") { // These are case-insensitive keys
if let Some(password) = req.header("password") {
if Account::is_valid_creds(&self.accounts, username, password).await {
// set session id
let uuid = Uuid::default().to_string();
/* server */ self.sessions.write().await.insert(uuid.clone());
/* client */ self.config.set_cookie_value(&cookie_jar, &uuid);
let res = self.endpoint.call(req).await?;
return Ok(res.into_response());
}
}
}
// this request couldn't be validated in any way.
// tell them to login
let x = poem::endpoint::StaticFileEndpoint::new(FILESYSTEM_ROOT.to_owned() + "/login.html");
let mut res = x.call(req).await?;
res.set_status(StatusCode::UNAUTHORIZED);
Ok(res)
}
}

View File

@ -1,95 +0,0 @@
#![deny(clippy::unwrap_used)]
#![deny(clippy::todo)]
#![deny(clippy::unimplemented)]
#![feature(ascii_char)]
#![feature(addr_parse_ascii)]
#![feature(async_closure)]
use std::future::IntoFuture;
use account::simple_storage::FilesystemDB;
use clap::Parser;
use poem::{
EndpointExt, endpoint::StaticFilesEndpoint, listener::TcpListener, Route, Server
};
use tracing::{Level, instrument};
use tracing_subscriber::EnvFilter;
use crate::account::AppState;
mod account;
#[derive(Debug, Parser)]
#[command(version)]
struct Args {
#[arg(short, long)]
new: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
#[cfg(debug_assertions)]
let filter = EnvFilter::builder()
.parse("room_clocks=trace,poem=debug,tokio=warn")
.expect("Could not create env filter.")
;
#[cfg(debug_assertions)]
tracing_subscriber::fmt::fmt()
.with_max_level(Level::TRACE)
.with_target(true)
.with_env_filter(filter)
.with_thread_ids(false)
.with_file(false)
.without_time()
.init();
let args = Args::parse();
match args.new {
Some(a) => {
let mut x = a.split_ascii_whitespace();
let username = x.next().expect("You passed an empty string as credentials...");
let password = x.next().expect("You need to pass a password along with that username.");
// let acct = Account::new(&AccountLogin::new(password, username)).expect("Internal error whilst building account. Most likely during the password hashing process.");
if let Ok(act) = account::Account::try_new(username, password) {
println!("{}", act);
}
return Ok(())
},
None => {/*continue*/},
}
// Tick thru messages
run_server().into_future().await
}
pub static FILESYSTEM_ROOT: &str = "./www/";
#[instrument(level="trace", name="web" skip_all)]
async fn run_server() -> Result<(), std::io::Error> {
let appstate = AppState::<FilesystemDB>::new().await;
// Wow, poem routing gets a bit messy
let app = Route::new()
.nest("/", StaticFilesEndpoint::new(FILESYSTEM_ROOT)
.index_file("index.html")
.fallback_to_index()
)
.nest("/protected", Route::new()
.nest("/", StaticFilesEndpoint::new(FILESYSTEM_ROOT.to_owned()+"protected/")
.index_file("account.html")
.fallback_to_index()
)
.with(appstate)
)
.with(poem::middleware::Tracing)
;
Server::new(TcpListener::bind("0.0.0.0:3000"))
.run(app)
.await
}

View File

@ -1,3 +0,0 @@
this is some html text
or - would be, if it was more than text

View File

@ -1 +0,0 @@
login

View File

@ -1 +0,0 @@
account

View File

@ -1 +0,0 @@
this or that

View File

@ -1,6 +0,0 @@
---
title: "{{ replace .Name "-" " " | title }}"
date: {{ .Date }}
draft: true
---

View File

@ -1,4 +0,0 @@
baseURL = 'http://example.org/'
languageCode = 'en-us'
title = 'My New Hugo Site'
theme = 'lugo'

@ -1 +0,0 @@
Subproject commit ca92c2dddeee70d4627931d2c214a299143b6735

256
src/main.rs Normal file
View File

@ -0,0 +1,256 @@
#![deny(clippy::unwrap_used)]
#![deny(clippy::todo)]
#![deny(clippy::unimplemented)]
#![feature(iter_collect_into)]
use std::{collections::HashMap, process::Command, sync::Arc};
use poem::{
endpoint::StaticFilesEndpoint, get, handler, http::StatusCode, listener::TcpListener, middleware::AddData, post, web::{Data, Form, Path, StaticFileRequest}, Body, EndpointExt, FromRequest, IntoResponse, Request, Route, Server
};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::{debug, error, info, instrument, trace, Level};
use tracing_subscriber::EnvFilter;
use url::Url;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
#[cfg(debug_assertions)]
let filter = EnvFilter::builder()
.parse("server=trace,poem=debug,tokio=warn")
.expect("Could not create env filter.")
;
#[cfg(debug_assertions)]
tracing_subscriber::fmt::fmt()
.with_max_level(Level::TRACE)
.with_target(true)
.with_env_filter(filter)
.with_thread_ids(false)
.with_file(false)
.without_time()
.init();
if !(check_cmd("ffmpeg", "-version") && check_cmd("yt-dlp", "--version")) {
error!("One or more commands errored out, continuing is pointless.");
return Ok(());
}
trace!("Loading up server...");
run_server().await
}
fn check_cmd(cmd: &str, ver_arg: &str) -> bool {
match Command::new(cmd).arg(ver_arg).output()
{
Ok(e) => {
let stderr = e.stderr.iter().map(|f| *f as char).collect::<String>();
let stdout = e.stdout.iter().map(|f| *f as char).collect::<String>();
if !e.status.success() {
error!("Failed with to run {}!", cmd);
error!("{}", stderr);
false
} else {
info!("{} version: {}", cmd, stdout);
true
}
},
Err(_) => {
error!("Couldn't run commands at all!");
false
},
}
}
#[derive(Default)]
struct AppData {
mapping: RwLock<HashMap<String, std::path::PathBuf>>,
}
pub static FILESYSTEM_ROOT: &str = "./www/";
#[instrument(level="trace", name="web" skip_all)]
async fn run_server() -> Result<(), std::io::Error> {
// Wow, poem routing gets a bit messy
let app = Route::new()
.nest("/", StaticFilesEndpoint::new(FILESYSTEM_ROOT)
.index_file("index.html")
.fallback_to_index()
)
.at("/download", post(download_url))
.at("/download/:path", get(get_file))
.with(AddData::new(Arc::new(AppData::default())))
.with(poem::middleware::Tracing)
;
Server::new(TcpListener::bind("0.0.0.0:3000"))
.run(app)
.await
}
#[derive(Deserialize)]
struct Input {
url: String,
output: OutputFormat,
}
#[derive(Deserialize, Debug)]
enum OutputFormat {
Video,
Audio,
}
impl OutputFormat {
fn to_format(&self) -> &str {
match self {
OutputFormat::Video => "mp4",
OutputFormat::Audio => "mp3",
}
}
}
struct YtDlpOutput {
filename: String,
key: String,
}
impl YtDlpOutput {
fn from(value: String) -> Self {
let x = value.lines();
let mut v = Vec::new();
x.collect_into(&mut v);
Self { filename: v[0].to_owned(), key: v[1].to_owned() }
}
}
#[handler]
#[instrument(level="trace", skip_all)]
async fn download_url(Form(data): Form<Input>, state: Data<&Arc<AppData>>) -> poem::Response {
trace!("Download attempt...");
// validate url
match Url::parse(&data.url) {
Ok(url) => {
match url.domain() {
Some(domain) => {
let mut domain: String = domain.to_string();
let splits: Vec<&str> = domain.split('.').collect();
if splits.len() > 2 {
domain = [
splits[splits.len()-2],
".",
splits[splits.len()-1]
]
.iter()
.map(|f| *f)
.collect();
}
if domain == "youtube.com" || domain == "yout.be" {
debug!("Downloading {} to {} format.", data.url, data.output.to_format());
match Command::new("yt-dlp")
.arg("--quiet") // shut up
.arg("--no-warnings") // shut up again
.arg("--windows-filenames") // windows compatible file name
.arg("--no-playlist") // if video or playlist, choose video
.args(["--print","after_move:filename,id"]) // output filename
.args(["-f", OutputFormat::Video.to_format()]) // format selector
.arg(data.url.clone())
.output()
{
Ok(o) => {
if let Some(code) = o.status.code() {
let stderr = o.stderr.iter().map(|f| *f as char).collect::<String>();
let stdout = o.stdout.iter().map(|f| *f as char).collect::<String>();
match code {
0 => {
info!("Download done.");
let mut out = YtDlpOutput::from(stdout);
// You can potentially do post-processing directly in yt-dlp
match data.output {
OutputFormat::Video => out.key = format!("video_{}", out.key),
OutputFormat::Audio => {
trace!("Converting to audio file.");
let audio_file = format!("{}.mp3", out.filename);
match Command::new("ffmpeg")
.args(["-i", &out.filename]) // ffmpeg input
.arg(audio_file.clone()) // ffmpeg output
.output()
{
Ok(_) => {
out = YtDlpOutput {
filename: audio_file,
key: format!("audio_{}", out.key),
};
},
Err(_) => error!("Couldn't create shell for ffmpeg."),
}
},
}
trace!("Setting state...");
state.mapping.write().await.insert(out.key.clone(), out.filename.into());
let response = poem::Response::builder()
.content_type("text/html")
.status(StatusCode::UNAUTHORIZED)
.body(Body::from_string(format!("<script>window.location = '/download/{}'</script>", out.key)))
;
return response;
},
2 => {
error!("{} STDERR: {}", o.status, stderr);
}
_ => {
error!("{}", o.status);
error!("{}", stderr);
}
}
}
},
Err(_e) => {
error!("Failed to create shell for yt-dlp.");
},
}
}
},
None => error!("Url didn't have a domain."),
}
},
Err(_) => error!("Couldn't parse url from string."),
}
poem::Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal error")
}
#[handler]
#[instrument(level="trace", skip(state, r))]
async fn get_file(Path(request_key): Path<String>,r: &Request, state: Data<&Arc<AppData>>) -> Result<poem::Response, poem::Error> {
trace!("Getting: {}", request_key);
match state.mapping.read().await.get(&request_key) {
Some(real_path) => {
trace!("Found real path: {:?}", real_path);
Ok(StaticFileRequest::from_request_without_body(r)
.await?
.create_response(real_path, true)?
.into_response())
},
None => {
error!("Tried to get {} from the state, but failed.", request_key);
Err(poem::Error::from_status(StatusCode::NOT_FOUND))
},
}
}

49
www/form.css Normal file
View File

@ -0,0 +1,49 @@
input {
color: #ffffff;
margin: 0.2rem;
}
input, label, button {
font-size: 1.5rem;
}
input[type="submit"]:focus {
outline-color: #344a70;
}
input[type="submit"] {
background-color: #344a70;
text-align: center;
}
input[type=radio] {
width: 1em;
height: 0.75em;
}
.line {
width: 100%;
text-align: center;
}
input[type="password"], [type="text"] {
background-color: #ffffff;
color: #222222;
width: 75%;
}
input[type="text"]::placeholder {
text-align: center;
color: #dddddd;
}
textarea, input {
border-radius: 0.5rem;
border-style: hidden;
padding: 0.5rem;
}
form {
display: flex;
flex-direction: column;
align-items: center;
}

47
www/index.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Download Youtube Videos</title>
<link rel="canonical" href="http://localhost:1313/" />
<link rel="stylesheet" type="text/css" media="screen" href="./style.css" />
<link rel="stylesheet" type="text/css" href="./lugo.css"/>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="index, follow" />
</head>
<body>
<header>
<h1>
<a href="./">Download Youtube Videos</a>
</h1>
</header>
<main>
<section>
<div class="article-header">
<h3 class="spaced"></h3>
</div>
<article>
<link rel="stylesheet" type="text/css" media="screen" href="./form.css" />
<form action="/download" enctype="application/x-www-form-urlencoded" method="post">
<input type="text" name="url" placeholder="youtube url" />
<br/>
<div class="line">
<input id="audio" type="radio" name="output" value="Audio" />
<label for="audio">Audio</label>
</div>
<div class="line">
<input id="video" type="radio" name="output" value="Video" />
<label for="video">Video</label>
</div>
<br/>
<button type="submit">Submit</button>
</form>
</article>
</section>
</main>
</body>
</html>

67
www/lugo.css Normal file
View File

@ -0,0 +1,67 @@
/* Shortcode css*/
/* For NEXTPREV.HTML */
#nextprev {
/* The container for both the previous and next articles. */
color: black;
}
#nextprev a:any-link {
color: black;
}
#nextart {
float: left ;
text-align: left ;
}
#prevart {
float: right ;
text-align: right ;
}
#nextart,#prevart {
max-width: 33% ;
}
/* For TAGLIST.HTML */
.taglist {
text-align: center;
clear: both;
color: var(--bg-text);
}
/* For putting right / left boxes next to each other */
.left {
width: 45%;
padding-right: 5%;
float: left;
clear: both;
}
.right {
width: 45%;
padding-left: 5%;
float: right;
}
figure {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-content: center;
align-items: center;
}
figcaption {
color: var(--bg-text);
}
img {
max-width: 100%;
max-height: 50vh;
border-radius: var(--radius);
}
.rss {
border-radius: 0.25rem;
}

141
www/style.css Normal file
View File

@ -0,0 +1,141 @@
:root {
--bg: #222222;
--bg-text: #606060;
--text: #ffffff;
--header: #454545;
--header-text: #707070;
--content-bg: #303030;
--accent: #344a70;
--link: #3e765d;
--radius: 0.75rem;
font-family: Arial, Helvetica, sans-serif;
font-size: x-large;
}
blockquote {
background-color: var(--header);
/* border-radius: var(--radius); */
padding-left: 1rem;
padding-right: 1rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
margin-top: 0;
margin-bottom: 0;
}
/* blockquote~blockquote {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
blockquote+blockquote {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
} */
pre {
background-color: var(--bg);
padding: 1rem;
border-radius: var(--radius);
}
.copy:hover {
color: var(--accent);
cursor: copy;
}
::-webkit-scrollbar {
width: 0;
}
body {
background-color: var(--bg);
color: var(--text);
margin: 0;
}
header {
background-color: var(--accent);
text-align: center;
padding: 0.5rem;
}
.article-header {
background-color: var(--header);
color: var(--header-text);
}
section {
border-radius: var(--radius);
margin: 2rem;
overflow: hidden;
}
.spaced {
margin: 0;
padding: 1rem;
}
article {
background-color: var(--content-bg);
padding: 1.5rem;
text-align: left;
}
hr {
margin: 2rem;
height: 1px;
border: 0;
border-top: 1px solid var(--header);
height: 1px;
}
footer {
color: var(--bg-text);
text-align: center;
}
h1,h2,h3,h4,h5,h6 {
margin: 1rem;
text-align: center;
}
header h1 {
margin: 0;
font-size: 2.5rem;
}
article h1 {
margin-bottom: 1rem;
}
a {
color: var(--link);
}
a:hover {
color: var(--accent);
text-decoration-line: none;
}
header a {
color: inherit;
text-decoration-line: none;
font-weight: bold;
}
header a:hover {
font-weight: bold;
color: inherit
}
main {
max-width: 80%;
margin: auto;
}
@media screen and (max-width: 1500px) {
main {
max-width: 100%;
margin: auto;
}
}