Compare commits

..

10 Commits

11 changed files with 1857 additions and 14 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
/target
database.db
.env
/scripts

1254
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,5 +5,15 @@ edition = "2021"
[dependencies]
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
axum = "0.7.5"
chrono = "0.4.38"
dotenvy = "0.15.7"
http = "1.1.0"
jsonwebtoken = "9.3.0"
rand = "0.8.5"
rusqlite = "0.32.1"
serde = { version = "1.0.206", features = ["derive"] }
serde_json = "1.0.122"
tokio = { version = "1.39.2", features = ["full"] }
tower-http = { version = "0.5.2", features = ["fs"] }
validator = { version = "0.18.1", features = ["derive"] }

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM rust:1.80 AS builder
WORKDIR /code
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
COPY src src
RUN cargo install --path .
FROM debian:bookworm-slim AS runtime
RUN apt-get update && \
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /usr/local/cargo/bin/script_hoster .
EXPOSE 3000
CMD ["./script_hoster"]

12
docker-compose.yaml Normal file
View File

@ -0,0 +1,12 @@
services:
web:
restart: unless-stopped
build: .
ports:
- "3000:3000"
volumes:
- ./.env:/app/.env:ro
- data:/app
volumes:
data:

463
src/api/mod.rs Normal file
View File

@ -0,0 +1,463 @@
use axum::{routing::post, Json, Router};
use jsonwebtoken::{encode, DecodingKey, EncodingKey, Header};
use rusqlite::Connection;
use serde::Deserialize;
use serde::Serialize;
use serde_json::{json, Value};
use std::fs::File;
use std::io::Write;
use validator::Validate;
use crate::db;
pub fn get_routes() -> Router {
Router::new().nest(
"/api",
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/change_username", post(change_username))
.route("/change_email", post(change_email))
.route("/change_password", post(change_password))
.route("/delete_account", post(delete_account))
.route("/get_scripts", post(get_scripts))
.route("/add_script", post(add_script))
.route("/delete_script", post(delete_script))
.route("/edit_script", post(edit_script))
.route("/rename_script", post(rename_script)),
)
}
#[derive(Serialize, Deserialize)]
struct Claims {
account_id: usize,
exp: usize,
}
#[derive(Deserialize, Validate)]
pub struct RegisterPayload {
username: String,
#[validate(email)]
email: String,
password: String,
}
pub async fn register(Json(payload): Json<RegisterPayload>) -> Json<Value> {
if payload.password.len() < 8 {
Json(json!({ "error": "Password is too short" }))
} else if payload.password.len() > 64 {
Json(json!({ "error": "Password is too long" }))
} else if payload.username.len() < 3 {
Json(json!({ "error": "Username is too short" }))
} else if payload.username.len() > 32 {
Json(json!({ "error": "Username is too long" }))
} else if payload.validate().is_err() {
Json(json!({ "error": "Invalid email" }))
} else {
let connection = Connection::open("database.db").expect("Failed to open database");
let result = db::add_account(
&connection,
&payload.username,
&payload.email,
&payload.password,
);
connection.close().expect("Failed to close database");
match result {
Ok(_) => Json(json!({ "success": "User created with success" })),
Err(rusqlite::Error::SqliteFailure(_, _)) => {
Json(json!({ "error": "Username or email already taken" }))
}
Err(_) => Json(json!({ "error": "Failed to create user" })),
}
}
}
#[derive(Deserialize)]
pub struct LoginPayload {
username: Option<String>,
email: Option<String>,
password: String,
}
pub async fn login(Json(payload): Json<LoginPayload>) -> Json<Value> {
let connection = Connection::open("database.db").expect("Failed to open database");
let account_id = if let Some(username) = payload.username {
db::get_account_id_from_username(&connection, &username)
} else if let Some(email) = payload.email {
db::get_account_id_from_email(&connection, &email)
} else {
connection.close().expect("Failed to close database");
return Json(json!({ "error": "Username or email must be provided" }));
};
let account_id = match account_id {
Ok(Some(account_id)) => account_id,
Ok(None) => {
connection.close().expect("Failed to close database");
return Json(json!({ "error": "User not found" }));
}
Err(_) => {
connection.close().expect("Failed to close database");
return Json(json!({ "error": "Failed to get user" }));
}
};
let account = db::get_account(&connection, account_id, &payload.password);
match account {
Ok(Some(_)) => {
connection.close().expect("Failed to close database");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let claims = Claims {
account_id,
exp: (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as usize,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(jwt_secret.as_ref()),
)
.expect("Failed to encode token");
Json(json!({ "success": "User logged in with success", "token": token}))
}
Ok(None) => {
connection.close().expect("Failed to close database");
Json(json!({ "error": "Wrong password" }))
}
Err(_) => {
connection.close().expect("Failed to close database");
Json(json!({ "error": "Failed to get user" }))
}
}
}
#[derive(Deserialize)]
pub struct ChangeUsernamePayload {
username: String,
token: String,
}
fn get_account_id_from_jwt(token: &str) -> Result<usize, jsonwebtoken::errors::Error> {
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let token_data = jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&jsonwebtoken::Validation::default(),
)?;
Ok(token_data.claims.account_id)
}
pub async fn change_username(Json(payload): Json<ChangeUsernamePayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
match db::change_username(&connection, account_id, &payload.username) {
Ok(_) => {}
Err(rusqlite::Error::SqliteFailure(_, _)) => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Username already taken" }));
}
Err(_) => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to change username" }));
}
}
connection.close().expect("Failed to close");
Json(json!({ "success": "Username changed with success" }))
}
#[derive(Deserialize)]
pub struct ChangeEmailPayload {
email: String,
token: String,
}
pub async fn change_email(Json(payload): Json<ChangeEmailPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
match db::change_email(&connection, account_id, &payload.email) {
Ok(_) => {}
Err(rusqlite::Error::SqliteFailure(_, _)) => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Email already taken" }));
}
Err(_) => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to change email" }));
}
}
connection.close().expect("Failed to close");
Json(json!({ "success": "Email changed with success" }))
}
#[derive(Deserialize)]
pub struct ChangePasswordPayload {
password: String,
token: String,
}
pub async fn change_password(Json(payload): Json<ChangePasswordPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
match db::change_password(&connection, account_id, &payload.password) {
Ok(_) => {}
Err(_) => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to change password" }));
}
}
connection.close().expect("Failed to close");
Json(json!({ "success": "Password changed with success" }))
}
#[derive(Deserialize)]
pub struct GenericTokenPayload {
token: String,
}
pub async fn delete_account(Json(payload): Json<GenericTokenPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
match db::delete_account(&connection, account_id) {
Ok(_) => {}
Err(_) => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to delete account" }));
}
}
connection.close().expect("Failed to close");
Json(json!({ "success": "Account deleted with success" }))
}
pub async fn get_scripts(Json(payload): Json<GenericTokenPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
let scripts = db::get_scripts(&connection, account_id);
connection.close().expect("Failed to close database");
match scripts {
Ok(scripts) => {
let mut result = json!({});
for (id, name, last_edit) in scripts {
result[id.to_string()] = json!({ "name": name, "last_edit": last_edit });
}
Json(result)
}
Err(_) => Json(json!({ "error": "Failed to get scripts" })),
}
}
#[derive(Deserialize)]
pub struct AddScriptPayload {
name: String,
token: String,
}
pub async fn add_script(Json(payload): Json<AddScriptPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
match db::add_script(&connection, &payload.name, account_id) {
Ok(_) => {
let username = match db::get_account_username(&connection, account_id)
.expect("Failed to get account username")
{
Some(username) => username,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get account username" }));
}
};
let filename = format!("scripts/{}/{}", username, &payload.name);
std::fs::create_dir_all(format!("scripts/{}", username))
.expect("Failed to create directory");
File::create(&filename).expect("Failed to create file");
connection.close().expect("Failed to close");
Json(json!({ "success": "Script added with success" }))
}
Err(_) => {
connection.close().expect("Failed to close");
Json(json!({ "error": "Failed to add script" }))
}
}
}
#[derive(Deserialize)]
pub struct DeleteScriptPayload {
script_id: usize,
token: String,
}
pub async fn delete_script(Json(payload): Json<DeleteScriptPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
if !db::script_is_owned_by(&connection, payload.script_id, account_id)
.expect("Failed to check if script is owned by user")
{
connection.close().expect("Failed to close");
return Json(json!({ "error": "Script is not owned by user" }));
}
let script_name = match db::get_script_name(&connection, payload.script_id, account_id)
.expect("Failed to get script name")
{
Some(script_name) => script_name,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get script name" }));
}
};
match db::remove_script(&connection, payload.script_id) {
Ok(_) => {
let username = match db::get_account_username(&connection, account_id)
.expect("Failed to get account username")
{
Some(username) => username,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get account username" }));
}
};
let filename = format!("scripts/{}/{}", username, script_name);
std::fs::remove_file(&filename).expect("Failed to remove file");
connection.close().expect("Failed to close");
Json(json!({ "success": "Script deleted with success" }))
}
Err(_) => {
connection.close().expect("Failed to close");
Json(json!({ "error": "Failed to delete script" }))
}
}
}
#[derive(Deserialize)]
pub struct EditScriptPayload {
script_id: usize,
content: String,
token: String,
}
pub async fn edit_script(Json(payload): Json<EditScriptPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
if !db::script_is_owned_by(&connection, payload.script_id, account_id)
.expect("Failed to check if script is owned by user")
{
connection.close().expect("Failed to close");
return Json(json!({ "error": "Script is not owned by user" }));
}
match db::update_script_last_edit(&connection, payload.script_id) {
Ok(_) => {
let username = match db::get_account_username(&connection, account_id)
.expect("Failed to get account username")
{
Some(username) => username,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get account username" }));
}
};
let script_name = match db::get_script_name(&connection, payload.script_id, account_id)
.expect("Failed to get script name")
{
Some(script_name) => script_name,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get script name" }));
}
};
let filename = format!("scripts/{}/{}", username, script_name);
let mut file = File::create(&filename).expect("Failed to open file");
file.write_all(payload.content.as_bytes())
.expect("Failed to write to file");
connection.close().expect("Failed to close");
Json(json!({ "success": "Script edited with success" }))
}
Err(_) => {
connection.close().expect("Failed to close");
Json(json!({ "error": "Failed to edit script" }))
}
}
}
#[derive(Deserialize)]
pub struct RenameScriptPayload {
script_id: usize,
new_script_name: String,
token: String,
}
pub async fn rename_script(Json(payload): Json<RenameScriptPayload>) -> Json<Value> {
let account_id = match get_account_id_from_jwt(&payload.token) {
Ok(account_id) => account_id,
Err(_) => return Json(json!({ "error": "Invalid token" })),
};
let connection = Connection::open("database.db").expect("Failed to open database");
if !db::script_is_owned_by(&connection, payload.script_id, account_id)
.expect("Failed to check if script is owned by user")
{
connection.close().expect("Failed to close");
return Json(json!({ "error": "Script is not owned by user" }));
}
let old_script_name = match db::get_script_name(&connection, payload.script_id, account_id)
.expect("Failed to get script name")
{
Some(script_name) => script_name,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get script name" }));
}
};
match db::modify_script_name(&connection, payload.script_id, &payload.new_script_name) {
Ok(_) => {
let username = match db::get_account_username(&connection, account_id)
.expect("Failed to get account username")
{
Some(username) => username,
None => {
connection.close().expect("Failed to close");
return Json(json!({ "error": "Failed to get account username" }));
}
};
let old_filename = format!("scripts/{}/{}", username, old_script_name);
let new_filename = format!("scripts/{}/{}", username, &payload.new_script_name);
std::fs::rename(&old_filename, &new_filename).expect("Failed to rename file");
connection.close().expect("Failed to close");
Json(json!({ "success": "Script renamed with success" }))
}
Err(_) => {
connection.close().expect("Failed to close");
Json(json!({ "error": "Failed to rename script" }))
}
}
}

5
src/assets/style.css Normal file
View File

@ -0,0 +1,5 @@
@import url('https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css');
.center {
text-align: center;
}

View File

@ -122,7 +122,17 @@ pub fn get_account_id_from_email(conn: &Connection, email: &str) -> Result<Optio
}
}
pub fn get_scripts(conn: &Connection, account_id: usize) -> Result<Vec<(usize, String, usize)>> {
pub fn get_account_username(conn: &Connection, id: usize) -> Result<Option<String>> {
let mut stmt = conn.prepare("SELECT username FROM accounts WHERE id = ?1")?;
let mut rows = stmt.query([id])?;
if let Some(row) = rows.next()? {
Ok(Some(row.get(0)?))
} else {
Ok(None)
}
}
pub fn get_scripts(conn: &Connection, account_id: usize) -> Result<Vec<(usize, String, String)>> {
let mut stmt = conn.prepare("SELECT id, name, last_edit FROM scripts WHERE account_id = ?1")?;
let mut rows = stmt.query([account_id])?;
let mut scripts = Vec::new();
@ -138,12 +148,7 @@ pub fn get_scripts(conn: &Connection, account_id: usize) -> Result<Vec<(usize, S
Ok(scripts)
}
pub fn add_script(
conn: &Connection,
name: &str,
last_edit: usize,
account_id: usize,
) -> Result<()> {
pub fn add_script(conn: &Connection, name: &str, account_id: usize) -> Result<()> {
let mut stmt = conn.prepare("SELECT id FROM scripts WHERE name = ?1 AND account_id = ?2")?;
let mut rows = stmt.query([&name as &dyn ToSql, &account_id as &dyn ToSql])?;
if rows.next()?.is_some() {
@ -151,8 +156,8 @@ pub fn add_script(
}
conn.execute(
"INSERT INTO scripts (name, last_edit, account_id) VALUES (?1, ?2, ?3)",
(name, last_edit, account_id),
"INSERT INTO scripts (name, account_id) VALUES (?1, ?2)",
(name, account_id),
)?;
Ok(())
@ -169,10 +174,15 @@ pub fn script_is_owned_by(conn: &Connection, script_id: usize, account_id: usize
}
}
pub fn get_script_id(conn: &Connection, name: &str, account_id: usize) -> Result<Option<usize>> {
let mut stmt = conn.prepare("SELECT id FROM scripts WHERE name = ?1 AND account_id = ?2")?;
let mut rows = stmt.query([&name as &dyn ToSql, &account_id as &dyn ToSql])?;
pub fn get_script_name(
conn: &Connection,
script_id: usize,
account_id: usize,
) -> Result<Option<String>> {
let mut stmt = conn.prepare("SELECT name FROM scripts WHERE id = ?1 AND account_id = ?2")?;
let mut rows = stmt.query([&script_id as &dyn ToSql, &account_id as &dyn ToSql])?;
if let Some(row) = rows.next()? {
println!("row: {:?}", row);
Ok(Some(row.get(0)?))
} else {
Ok(None)

19
src/error_pages/404.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404 Not found</title>
<link rel="stylesheet" href="/assets/style.css">
</head>
<body class="center">
<header>
<h1>404</h1>
<h2>Page not found</h2>
</header>
<main>
<p>Sorry, the page you are looking for does not exist.</p>
<p>If you arrived here via a link, please open an issue on the repository.</p>
</main>
</body>
</html>

23
src/frontend/mod.rs Normal file
View File

@ -0,0 +1,23 @@
use axum::handler::HandlerWithoutStateExt;
use axum::response::{Html, IntoResponse};
use axum::Router;
use http::StatusCode;
use tower_http::services::ServeDir;
pub fn get_routes() -> Router {
Router::new()
.nest_service(
"/",
ServeDir::new("src/static_pages").not_found_service(handler.into_service()),
)
.nest_service("/assets", ServeDir::new("src/assets"))
}
pub async fn handler() -> impl IntoResponse {
let path = "src/error_pages/404.html";
if let Ok(content) = tokio::fs::read_to_string(path).await {
(StatusCode::NOT_FOUND, Html(content))
} else {
(StatusCode::NOT_FOUND, Html("404 Not Found".to_string()))
}
}

View File

@ -1,8 +1,32 @@
use std::net::SocketAddr;
use axum::Router;
use rusqlite::Connection;
use tower_http::services::ServeDir;
mod api;
mod db;
mod frontend;
fn main() {
#[tokio::main]
async fn main() {
let connection = Connection::open("database.db").expect("Failed to open database");
db::init(&connection).expect("Failed to create database");
connection.close().expect("Failed to close database");
dotenvy::dotenv().ok();
let app = Router::new()
.merge(api::get_routes())
.merge(frontend::get_routes())
.nest_service("/scripts", ServeDir::new("scripts"));
let port = std::env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be an unsigned number");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}