User creation now possible!

Signed-off-by: Louis Hollingworth <louis@hollingworth.nl>
This commit is contained in:
Louis Hollingworth 2024-01-14 16:49:36 +00:00
parent 215cf639bd
commit f671a5ba2f
Signed by: lucxjo
GPG key ID: A11415CB3DC7809B
12 changed files with 2185 additions and 44 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
/target /target
.envrc .envrc
/data
.blogrss.toml

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO users (id, name, epost, xmpp, site, password, active_token)\n VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "4b300d7e44f7b6c95cfb9a2791e81abb21ecf887cb7cf46e5dcb45567818cab4"
}

View file

@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "\n SELECT * FROM users\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Int64"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "site",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "xmpp",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "epost",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "password",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "active_token",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true,
false,
true
]
},
"hash": "a2c624a66b1999281d3b63e85b77785d0aae8d5ebc4865b7d42d7afd81e6f58c"
}

1835
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,11 @@
name = "blogrss" name = "blogrss"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
authors = ["Louis Hollingworth <louis@hollingworth.nl>"]
readme = "README.md"
repository = "https://git.ludoviko.ch/lucxjo/blogrss"
license = "AGPL-3.0-or-later"
description = "A simple blogging system using RSS/Atom"
[lib] [lib]
@ -14,9 +19,18 @@ name = "blogrss-server"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
argon2 = "0.5.2"
atom_syndication = { version = "0.12.2", features = ["with-serde"] } atom_syndication = { version = "0.12.2", features = ["with-serde"] }
axum = "0.7.3" axum = "0.7.3"
axum-xml = "0.2.0" axum-xml = "0.2.0"
chrono = "0.4.31"
clap = { version = "4.4.16", features = ["derive"] }
jsonwebtoken = "9.2.0"
rand_core = { version = "0.6.4", features = ["getrandom"] }
reqwest = { version = "0.11.23", features = ["json", "native-tls-vendored"] }
rss = { version = "2.0.6", features = ["serde", "atom", "atom_syndication", "chrono", "with-serde"] } rss = { version = "2.0.6", features = ["serde", "atom", "atom_syndication", "chrono", "with-serde"] }
rustcrypto = "0.0.0"
serde = { version = "1.0.195", features = ["derive"] } serde = { version = "1.0.195", features = ["derive"] }
sqlx = { version = "0.7.3", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1.35.1", features = ["full"] } tokio = { version = "1.35.1", features = ["full"] }
toml = "0.8.8"

View file

@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE "users";

View file

@ -0,0 +1,9 @@
-- Add up migration script here
CREATE TABLE "users" (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
site TEXT NOT NULL,
xmpp TEXT,
epost TEXT,
password TEXT NOT NULL
);

View file

@ -0,0 +1,2 @@
-- Add down migration script here
ALTER TABLE users DROP COLUMN active_token;

View file

@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE users
ADD COLUMN active_token TEXT;

View file

@ -1,15 +1,92 @@
use argon2::PasswordHasher;
use blogrss::{User, RegisterUser};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let app = axum::Router::new() let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
.route("/", axum::routing::get(test_feed)); let pool = sqlx::SqlitePool::connect(&database_url).await.unwrap();
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") let app = axum::Router::new()
.await .route("/test", axum::routing::get(test_feed))
.unwrap(); .route("/user/register", axum::routing::post(register_user))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
#[derive(serde::Deserialize, serde::Serialize)]
struct JWTClaims {
sub: String,
exp: usize,
}
async fn register_user(
axum::extract::State(pool): axum::extract::State<sqlx::SqlitePool>,
axum::Json(user): axum::Json<RegisterUser>,
) -> Result<String, (axum::http::StatusCode, String)> {
let recs = sqlx::query_as!(
User,
r#"
SELECT * FROM users
"#
)
.fetch_all(&pool)
.await;
match recs {
Err(_) => {
return Err((
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Error getting users".to_string(),
))
}
Ok(r) => {
if r.len() == 0 {
if user.epost.is_none() && user.xmpp.is_none() {
return Err((
axum::http::StatusCode::BAD_REQUEST,
"E-post and/or XMPP must be supplied!".to_string(),
));
}
let claims = JWTClaims {
sub: user.xmpp.clone().unwrap_or(user.epost.clone().unwrap()),
exp: chrono::Utc::now().checked_add_signed(chrono::Duration::days(2)).expect("Valid timestamp").timestamp() as usize,
};
let token = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &jsonwebtoken::EncodingKey::from_secret(std::env::var("JWT_SECRET").unwrap().as_bytes())).unwrap();
let salt = argon2::password_hash::SaltString::generate(&mut rand_core::OsRng);
let argon2 = argon2::Argon2::default();
let hash = argon2
.hash_password(user.password.as_bytes(), &salt)
.unwrap()
.to_string();
sqlx::query_as!(
User,
r#"
INSERT INTO users (id, name, epost, xmpp, site, password, active_token)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
"#,
1,
user.name,
user.epost,
user.xmpp,
user.site,
hash,
token
)
.execute(&pool)
.await
.unwrap();
return Ok(token);
}
return Err((axum::http::StatusCode::CONFLICT, "There is already a user in the database. We currently don't support more than one user.".to_string()));
}
}
}
async fn test_feed() -> axum::response::Response { async fn test_feed() -> axum::response::Response {
let mut entries = Vec::<atom_syndication::Entry>::new(); let mut entries = Vec::<atom_syndication::Entry>::new();
@ -17,22 +94,20 @@ async fn test_feed() -> axum::response::Response {
atom_syndication::EntryBuilder::default() atom_syndication::EntryBuilder::default()
.title("First Test Post") .title("First Test Post")
.id("first-test-post".to_string()) .id("first-test-post".to_string())
.authors(vec![ .authors(vec![atom_syndication::Person {
atom_syndication::Person { name: "Test Person".into(),
name: "Test Person".into(), email: Some("test@example.com".into()),
email: Some("test@example.com".into()), uri: Some("http://localhost:3000".into()),
uri: Some("http://localhost:3000".into()), ..Default::default()
..Default::default() }])
}
])
.content(Some( .content(Some(
atom_syndication::ContentBuilder::default() atom_syndication::ContentBuilder::default()
.lang(Some("en".into())) .lang(Some("en".into()))
.value(Some("<p>This is a test post</p>".into())) .value(Some("<p>This is a test post</p>".into()))
.content_type(Some("html".into())) .content_type(Some("html".into()))
.build() .build(),
)) ))
.build() .build(),
); );
let atom = atom_syndication::Feed { let atom = atom_syndication::Feed {

View file

@ -1,2 +1,159 @@
#![feature(fs_try_exists)]
use clap::{Parser, Subcommand};
fn main() {} #[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Manipulate users (requires an initialised site)
User {
#[command(subcommand)]
command: UserCommands,
},
Site {
#[command(subcommand)]
command: SiteCommands,
},
}
#[derive(Subcommand)]
enum UserCommands {
/// Register a user with the site
Register {
/// The name to show on blog posts by this user
#[arg(short, long)]
name: String,
/// The e-post/e-mail of this user, optional if XMPP address is supplied
/// shows in the feed.
#[arg(short, long)]
epost: Option<String>,
/// The XMPP address of the user, optional if e-post address is supplied
/// does not show in the feed, but shows in the body as a contact.
#[arg(short, long)]
xmpp: Option<String>,
/// The site URL for the user (should be the same as where the main blog shows)
#[arg(short, long)]
site: String,
/// The password for the user
#[arg(short, long)]
password: String,
},
}
#[derive(Subcommand)]
enum SiteCommands {
/// Initialises a site in the current directory
Init {
/// The name for the site
#[arg(short, long)]
name: String,
/// The URL for the main site
#[arg(short, long)]
url: String,
/// The URL for the API
#[arg(short, long)]
api: String,
},
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Config {
site: SiteConfig,
user: Option<UserConfig>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct SiteConfig {
name: String,
url: String,
api_url: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserConfig {
contact: String,
jwt: String,
}
fn deref_string_opt(val: &Option<String>) -> Option<String> {
let v = match val {
Some(s) => Some(s.to_string()),
None => None
};
return v;
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match &cli.command {
Commands::User { command } => match command {
UserCommands::Register { name, epost, xmpp, site, password } => {
let cfg_file = std::fs::read_to_string("./blogrss.toml").unwrap();
let cfg: Config = toml::from_str(&cfg_file).unwrap();
if epost.is_none() && xmpp.is_none() {
println!("E-post and/or xmpp must be supplied!");
return;
}
let new_user = blogrss::RegisterUser {
name: name.to_string(),
epost: deref_string_opt(epost),
xmpp: deref_string_opt(xmpp),
site: site.to_string(),
password: password.to_string(),
};
let client = reqwest::Client::new();
let res = client.post(format!("{}/user/register", cfg.site.api_url))
.json(&new_user)
.send()
.await.unwrap();
if res.status().is_success() {
let new_cfg = Config {
site: cfg.site,
user: Some(UserConfig {
jwt: res.text().await.unwrap(),
contact: match deref_string_opt(xmpp) {
Some(s) => format!("xmpp:{}", s),
None => format!("epost:{}", deref_string_opt(epost).unwrap())
}
})
};
std::fs::write("./blogrss.toml", toml::to_string(&new_cfg).unwrap()).expect("Unable to write data");
println!("Success! You are now registered and logged in");
return;
} else {
eprintln!("There was an error creating your account");
return;
}
}
}
Commands::Site { command } => match command {
SiteCommands::Init { name, url, api } => {
if std::fs::try_exists("./blogrss.toml").unwrap() {
println!("Site already initialised!")
} else {
let cfg = Config {
site: SiteConfig {
api_url: api.to_string(),
name: name.to_string(),
url: url.to_string(),
},
user: None,
};
std::fs::write("./blogrss.toml", toml::to_string(&cfg).unwrap())
.expect("Unable to write data");
println!("Site initialised!")
}
}
},
}
}

View file

@ -1,14 +1,20 @@
pub fn add(left: usize, right: usize) -> usize {
left + right #[derive(serde::Deserialize, serde::Serialize, sqlx::FromRow)]
pub struct RegisterUser {
pub name: String,
pub epost: Option<String>,
pub xmpp: Option<String>,
pub site: String,
pub password: String,
} }
#[cfg(test)] #[derive(serde::Serialize, serde::Deserialize, sqlx::FromRow)]
mod tests { pub struct User {
use super::*; pub id: i64,
pub name: String,
#[test] pub epost: Option<String>,
fn it_works() { pub xmpp: Option<String>,
let result = add(2, 2); pub site: String,
assert_eq!(result, 4); pub password: String,
} pub active_token: Option<String>
} }