init
This commit is contained in:
commit
618d1c40d6
3
.env.example
Normal file
3
.env.example
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
.env
|
||||
3284
Cargo.lock
generated
Normal file
3284
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
Normal file
16
Cargo.toml
Normal 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
20
README.md
Normal 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
8
compose.dev.yml
Normal 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
18
compose.yml
Normal 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
1
rustfmt.toml
Normal file
@ -0,0 +1 @@
|
||||
tab_spaces = 2
|
||||
36
src/endpoints/auth/login.rs
Normal file
36
src/endpoints/auth/login.rs
Normal 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))
|
||||
}
|
||||
7
src/endpoints/auth/mod.rs
Normal file
7
src/endpoints/auth/mod.rs
Normal 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)
|
||||
}
|
||||
7
src/endpoints/misc/mod.rs
Normal file
7
src/endpoints/misc/mod.rs
Normal 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)
|
||||
}
|
||||
6
src/endpoints/misc/ping.rs
Normal file
6
src/endpoints/misc/ping.rs
Normal 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
4
src/endpoints/mod.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod misc;
|
||||
mod prelude;
|
||||
pub mod users;
|
||||
4
src/endpoints/prelude.rs
Normal file
4
src/endpoints/prelude.rs
Normal file
@ -0,0 +1,4 @@
|
||||
pub use actix_web::{
|
||||
HttpResponse, Responder, get, post,
|
||||
web::{Data, Json},
|
||||
};
|
||||
12
src/endpoints/users/list.rs
Normal file
12
src/endpoints/users/list.rs
Normal 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))
|
||||
}
|
||||
7
src/endpoints/users/mod.rs
Normal file
7
src/endpoints/users/mod.rs
Normal 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
52
src/entities/cat.rs
Normal 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 {}
|
||||
46
src/entities/cat_content_tags.rs
Normal file
46
src/entities/cat_content_tags.rs
Normal 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
46
src/entities/cat_tag.rs
Normal 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 {}
|
||||
17
src/entities/doctrine_migration_versions.rs
Normal file
17
src/entities/doctrine_migration_versions.rs
Normal 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
10
src/entities/mod.rs
Normal 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
8
src/entities/prelude.rs
Normal 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
42
src/entities/tag.rs
Normal 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
19
src/entities/user.rs
Normal 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
50
src/main.rs
Normal 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
20
src/models/error.rs
Normal 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
2
src/models/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod error;
|
||||
pub mod user;
|
||||
30
src/models/user.rs
Normal file
30
src/models/user.rs
Normal 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
6
src/state.rs
Normal 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
14
src/utils/hasher.rs
Normal 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
3
src/utils/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
mod hasher;
|
||||
|
||||
pub use hasher::PasswordHasher;
|
||||
Loading…
x
Reference in New Issue
Block a user