This commit is contained in:
Mia Pilchová 2025-02-24 12:05:27 +01:00
commit 618d1c40d6
31 changed files with 3800 additions and 0 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
DATABASE_URL=mysql://root:rooter@localhost:16306/catter
HOST=127.0.0.1
PORT=8000

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.env

3284
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "catter"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-web = "4"
sea-orm = { version = "1.1.5", features = [
"sqlx-mysql",
"runtime-tokio-native-tls",
"macros",
] }
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] }
bcrypt = "0.17"
jwt = "0.16"

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# Rusty Catter
Re-implementation of [Catter](https://github.com/lilianalillyy/lilianaa.dev/tree/main/catter), a small project originally written in PHP for serving pictures of my cats.
Code is very WIP. The entities are generated by SeaORM CLI based on the current database schema. May (probably will) change.
## Environment variables
| Variable | Description |
| ------------ | -------------------------------- |
| HOST | Hostname of the server |
| PORT | Port of the server |
| DATABASE_URL | Database URL to the MySQL server |
### Docker env vars
Docker setup is essentially the same as in the original PHP project, just without php-fpm & nginx. Environment variables have changed:
- `MYSQL_*` -> `DOCKER_MYSQL_*`
- `MYSQL_PORT` -> `DOCKER_EXPOSED_MYSQL_PORT`
- `ADMINER_PORT` -> `DOCKER_ADMINER_PORT`

8
compose.dev.yml Normal file
View File

@ -0,0 +1,8 @@
services:
adminer:
depends_on:
- db
image: dockette/adminer:latest
restart: always
ports:
- "${DOCKER_ADMINER_PORT:-16003}:80"

18
compose.yml Normal file
View File

@ -0,0 +1,18 @@
services:
db:
image: mysql:8.0
volumes:
- "db-data:/var/lib/mysql"
restart: always
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
hostname: "${MYSQL_HOST:-db}"
environment:
MYSQL_ROOT_PASSWORD: "${DOCKER_MYSQL_PASSWORD:-rooter}"
MYSQL_DATABASE: "${DOCKER_MYSQL_NAME:-catter}"
ports:
- "${DOCKER_EXPOSED_MYSQL_PORT:-16306}:3306"
volumes:
db-data:
external: true
name: catter-db-data

1
rustfmt.toml Normal file
View File

@ -0,0 +1 @@
tab_spaces = 2

View File

@ -0,0 +1,36 @@
use crate::{
endpoints::prelude::*,
entities::{prelude::User, user},
models::{error::ErrorResponse, user::UserModel},
state::AppState,
utils::PasswordHasher,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[post("/login")]
pub async fn handler(body: Json<LoginRequest>, state: Data<AppState>) -> impl Responder {
let user = User::find()
.filter(user::Column::Email.eq(body.email.clone()))
.one(&state.db)
.await
.expect("database error");
if user.is_none() {
return HttpResponse::Unauthorized().json(ErrorResponse::new(401, "invalid email"));
}
let user = user.unwrap();
if !PasswordHasher::verify(&body.password, &user.password) {
return HttpResponse::Unauthorized().json(ErrorResponse::new(401, "invalid password"));
}
HttpResponse::Ok().json(UserModel::from(user))
}

View File

@ -0,0 +1,7 @@
use actix_web::dev::HttpServiceFactory;
mod login;
pub fn scope() -> impl HttpServiceFactory + 'static {
actix_web::web::scope("/auth").service(login::handler)
}

View File

@ -0,0 +1,7 @@
use actix_web::dev::HttpServiceFactory;
mod ping;
pub fn scope() -> impl HttpServiceFactory + 'static {
actix_web::web::scope("/misc").service(ping::handler)
}

View File

@ -0,0 +1,6 @@
use actix_web::{HttpResponse, Responder, get};
#[get("/ping")]
pub async fn handler() -> impl Responder {
HttpResponse::Ok().body("pong")
}

4
src/endpoints/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod auth;
pub mod misc;
mod prelude;
pub mod users;

4
src/endpoints/prelude.rs Normal file
View File

@ -0,0 +1,4 @@
pub use actix_web::{
HttpResponse, Responder, get, post,
web::{Data, Json},
};

View File

@ -0,0 +1,12 @@
use sea_orm::EntityTrait;
use crate::{
endpoints::prelude::*, entities::prelude::User, models::user::UserModel, state::AppState,
};
#[get("/")]
pub async fn handler(state: Data<AppState>) -> impl Responder {
let users = User::find().all(&state.db).await.expect("database error");
HttpResponse::Ok().json(UserModel::from_many(users))
}

View File

@ -0,0 +1,7 @@
use actix_web::dev::HttpServiceFactory;
mod list;
pub fn scope() -> impl HttpServiceFactory + 'static {
actix_web::web::scope("/users").service(list::handler)
}

52
src/entities/cat.rs Normal file
View File

@ -0,0 +1,52 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "cat")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub cat_camera_tag: Option<i32>,
pub image: String,
pub content: Option<String>,
pub date: DateTime,
pub thumbnail: Option<String>,
pub hidden: i8,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::cat_content_tags::Entity")]
CatContentTags,
#[sea_orm(has_many = "super::cat_tag::Entity")]
CatTag,
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::CatCameraTag",
to = "super::tag::Column::Id",
on_update = "NoAction",
on_delete = "NoAction"
)]
Tag,
}
impl Related<super::cat_content_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::CatContentTags.def()
}
}
impl Related<super::cat_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::CatTag.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,46 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "cat_content_tags")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub cat_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::cat::Entity",
from = "Column::CatId",
to = "super::cat::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Cat,
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Tag,
}
impl Related<super::cat::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cat.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

46
src/entities/cat_tag.rs Normal file
View File

@ -0,0 +1,46 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "cat_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub cat_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::cat::Entity",
from = "Column::CatId",
to = "super::cat::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Cat,
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Tag,
}
impl Related<super::cat::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cat.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@ -0,0 +1,17 @@
// //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
// use sea_orm::entity::prelude::*;
// #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
// #[sea_orm(table_name = "doctrine_migration_versions")]
// pub struct Model {
// #[sea_orm(primary_key, auto_increment = false)]
// pub version: String,
// pub executed_at: Option<DateTime>,
// pub execution_time: Option<i32>,
// }
// #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
// pub enum Relation {}
// impl ActiveModelBehavior for ActiveModel {}

10
src/entities/mod.rs Normal file
View File

@ -0,0 +1,10 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
pub mod prelude;
pub mod cat;
pub mod cat_content_tags;
pub mod cat_tag;
// pub mod doctrine_migration_versions;
pub mod tag;
pub mod user;

8
src/entities/prelude.rs Normal file
View File

@ -0,0 +1,8 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
pub use super::cat::Entity as Cat;
pub use super::cat_content_tags::Entity as CatContentTags;
pub use super::cat_tag::Entity as CatTag;
// pub use super::doctrine_migration_versions::Entity as DoctrineMigrationVersions;
pub use super::tag::Entity as Tag;
pub use super::user::Entity as User;

42
src/entities/tag.rs Normal file
View File

@ -0,0 +1,42 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "tag")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub r#type: String,
pub content: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::cat::Entity")]
Cat,
#[sea_orm(has_many = "super::cat_content_tags::Entity")]
CatContentTags,
#[sea_orm(has_many = "super::cat_tag::Entity")]
CatTag,
}
impl Related<super::cat::Entity> for Entity {
fn to() -> RelationDef {
Relation::Cat.def()
}
}
impl Related<super::cat_content_tags::Entity> for Entity {
fn to() -> RelationDef {
Relation::CatContentTags.def()
}
}
impl Related<super::cat_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::CatTag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

19
src/entities/user.rs Normal file
View File

@ -0,0 +1,19 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.6
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub email: String,
pub roles: Json,
pub password: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

50
src/main.rs Normal file
View File

@ -0,0 +1,50 @@
pub mod endpoints;
pub mod entities;
pub mod models;
pub mod state;
pub mod utils;
use std::env;
use actix_web::{App, HttpServer, web};
use sea_orm::Database;
use state::AppState;
#[actix_web::main]
async fn main() -> Result<(), std::io::Error> {
dotenvy::dotenv().expect("Failed to load env vars");
let host = env::var("HOST").expect("HOST is required");
let port = env::var("PORT")
.expect("PORT is required")
.parse::<u16>()
.expect("PORT is invalid");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL is required");
println!("connecting to db ...");
let db = Database::connect(database_url)
.await
.expect("Failed to establish database connection");
// TODO: migrations
let state = AppState { db };
println!("starting a server at {}:{}", host, port);
let app_factory = move || {
App::new()
.app_data(web::Data::new(state.clone()))
.configure(|cfg| {
cfg.service(endpoints::auth::scope());
cfg.service(endpoints::misc::scope());
cfg.service(endpoints::users::scope());
})
};
HttpServer::new(app_factory)
.bind((host.as_str(), port))?
.run()
.await
}

20
src/models/error.rs Normal file
View File

@ -0,0 +1,20 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct ErrorResponse {
pub status_code: i16,
pub message: String,
}
impl ErrorResponse {
pub fn server_error(message: &str) -> Self {
Self::new(500, message)
}
pub fn new(status_code: i16, message: &str) -> Self {
Self {
status_code,
message: String::from(message),
}
}
}

2
src/models/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod error;
pub mod user;

30
src/models/user.rs Normal file
View File

@ -0,0 +1,30 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct UserModel {
pub id: i32,
pub email: String,
pub roles: Vec<String>,
}
impl From<crate::entities::user::Model> for UserModel {
fn from(value: crate::entities::user::Model) -> Self {
Self {
id: value.id,
email: value.email,
roles: match value.roles {
sea_orm::JsonValue::Array(roles) => roles
.into_iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect(),
_ => vec![],
},
}
}
}
impl UserModel {
pub fn from_many(values: Vec<crate::entities::user::Model>) -> Vec<Self> {
values.into_iter().map(Into::into).collect()
}
}

6
src/state.rs Normal file
View File

@ -0,0 +1,6 @@
use sea_orm::DatabaseConnection;
#[derive(Debug, Clone)]
pub struct AppState {
pub db: DatabaseConnection,
}

14
src/utils/hasher.rs Normal file
View File

@ -0,0 +1,14 @@
pub struct PasswordHasher {}
impl PasswordHasher {
// DO NOT CHANGE THIS VALUE OR HELL WILL RISE
pub const BCRYPT_PASSWORD_COST: u32 = 13;
pub fn hash(password: &str) -> Option<String> {
bcrypt::hash(password, Self::BCRYPT_PASSWORD_COST).ok()
}
pub fn verify(password: &str, hash: &str) -> bool {
bcrypt::verify(password, hash).is_ok_and(|v| v)
}
}

3
src/utils/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod hasher;
pub use hasher::PasswordHasher;