2 Commits

17 changed files with 829 additions and 178 deletions

View File

@@ -20,6 +20,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
run: cargo build
- name: Create .env.test
run: touch .env.test
- name: Run tests
run: cargo test --verbose -p schedule-parser-rusted -p schedule_parser
run: cargo test -p schedule-parser-rusted -p schedule_parser
env:
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
JWT_SECRET: "test-secret-at-least-256-bits-used"

190
Cargo.lock generated
View File

@@ -49,7 +49,7 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"rand",
"rand 0.9.0",
"sha1",
"smallvec",
"tokio",
@@ -168,7 +168,7 @@ dependencies = [
"serde_urlencoded",
"smallvec",
"socket2",
"time",
"time 0.3.40",
"tracing",
"url",
]
@@ -333,7 +333,7 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"byteorder 1.5.0",
"cipher",
]
@@ -364,6 +364,12 @@ version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "byteorder"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29b2aa490a8f546381308d68fc79e6bd753cd3ad839f7a7172897f1feedfa175"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -391,7 +397,7 @@ version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1"
dependencies = [
"byteorder",
"byteorder 1.5.0",
"codepage",
"encoding_rs",
"log",
@@ -516,7 +522,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
dependencies = [
"percent-encoding",
"time",
"time 0.3.40",
"version_check",
]
@@ -714,7 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470eb10efc8646313634c99bb1593f402a6434cbd86e266770c6e39219adb86a"
dependencies = [
"bitflags",
"byteorder",
"byteorder 1.5.0",
"diesel_derives",
"itoa",
"pq-sys",
@@ -878,6 +884,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fuchsia-cprng"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -926,6 +938,12 @@ dependencies = [
"thread_local",
]
[[package]]
name = "gcc"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f5f3913fa0bfe7ee1fd8248b6b9f42a5af4b9d65ec2dd2c3c26132b950ecfc2"
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1040,6 +1058,16 @@ dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21ceb46a83a85e824ef93669c8b390009623863b5c195d1ba747292c0c72f94e"
dependencies = [
"libc",
"winutil",
]
[[package]]
name = "http"
version = "0.2.12"
@@ -1434,6 +1462,12 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
[[package]]
name = "lazy_static"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76f033c7ad61445c5b347c7382dd1237847eb1bce590fe50365dcb33d546be73"
[[package]]
name = "libc"
version = "0.2.171"
@@ -1565,6 +1599,22 @@ dependencies = [
"memchr",
]
[[package]]
name = "objectid"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a605e0778d73324c897e7e4bd5903f64639884a99d1bad55bccfd986260063"
dependencies = [
"byteorder 0.3.13",
"hostname",
"lazy_static",
"libc",
"quick-error",
"rand 0.3.23",
"rust-crypto",
"rustc-serialize",
]
[[package]]
name = "once_cell"
version = "1.21.1"
@@ -1730,6 +1780,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-xml"
version = "0.31.0"
@@ -1755,6 +1811,29 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "rand"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c"
dependencies = [
"libc",
"rand 0.4.6",
]
[[package]]
name = "rand"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
dependencies = [
"fuchsia-cprng",
"libc",
"rand_core 0.3.1",
"rdrand",
"winapi",
]
[[package]]
name = "rand"
version = "0.9.0"
@@ -1762,7 +1841,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [
"rand_chacha",
"rand_core",
"rand_core 0.9.3",
"zerocopy",
]
@@ -1773,9 +1852,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
dependencies = [
"rand_core 0.4.2",
]
[[package]]
name = "rand_core"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
[[package]]
name = "rand_core"
version = "0.9.3"
@@ -1805,6 +1899,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rdrand"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
dependencies = [
"rand_core 0.3.1",
]
[[package]]
name = "redox_syscall"
version = "0.5.10"
@@ -1907,12 +2010,31 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust-crypto"
version = "0.2.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f76d05d3993fd5f4af9434e8e436db163a12a9d40e1a58a726f27a01dfd12a2a"
dependencies = [
"gcc",
"libc",
"rand 0.3.23",
"rustc-serialize",
"time 0.1.45",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-serialize"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe834bc780604f4674073badbad26d7219cadfb4a2275802db12cbae17498401"
[[package]]
name = "rustix"
version = "1.0.3"
@@ -1999,6 +2121,7 @@ dependencies = [
name = "schedule-parser-rusted"
version = "0.3.0"
dependencies = [
"actix-http",
"actix-web",
"bcrypt",
"chrono",
@@ -2007,9 +2130,12 @@ dependencies = [
"dotenvy",
"hmac",
"jwt",
"mime",
"objectid",
"reqwest",
"schedule_parser",
"serde",
"serde_json",
"sha2",
"tokio",
]
@@ -2292,6 +2418,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "time"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
dependencies = [
"libc",
"wasi 0.10.0+wasi-snapshot-preview1",
"winapi",
]
[[package]]
name = "time"
version = "0.3.40"
@@ -2548,6 +2685,12 @@ dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.10.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
@@ -2644,6 +2787,22 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.9"
@@ -2653,6 +2812,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.52.0"
@@ -2843,6 +3008,15 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winutil"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7daf138b6b14196e3830a588acf1e86966c694d3e8fb026fb105b8b5dca07e6e"
dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"

View File

@@ -8,16 +8,20 @@ edition = "2024"
publish = false
[dependencies]
actix-http = "3.10.0"
actix-web = "4.10.2"
bcrypt = "0.17.0"
jwt = "0.16.0"
hmac = "0.12.1"
sha2 = "0.10.8"
chrono = "0.4.40"
diesel = { version = "2.2.8", features = ["postgres"] }
diesel-derive-enum = { git = "https://github.com/Havunen/diesel-derive-enum.git", features = ["postgres"] }
dotenvy = "0.15.7"
serde = { version = "1.0.219", features = ["derive"] }
schedule_parser = { path = "./lib/schedule_parser" }
chrono = "0.4.40"
hmac = "0.12.1"
jwt = "0.16.0"
mime = "0.3.17"
objectid = "0.2.0"
reqwest = "0.12.15"
schedule_parser = { path = "./lib/schedule_parser" }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sha2 = "0.10.8"
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
actix-web = "4.10.2"

View File

@@ -1,10 +1,12 @@
use crate::schema::LessonType::Break;
use crate::schema::{Day, Lesson, LessonSubGroup, LessonTime, LessonType, ScheduleEntity};
use crate::LessonParseResult::{Lessons, Street};
use calamine::{open_workbook_from_rs, Reader, Xls};
use crate::schema::LessonType::Break;
use crate::schema::{
Day, Lesson, LessonSubGroup, LessonTime, LessonType, ParseResult, ScheduleEntry,
};
use calamine::{Reader, Xls, open_workbook_from_rs};
use chrono::{Duration, NaiveDateTime};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use regex::Regex;
use std::collections::HashMap;
use std::io::Cursor;
@@ -471,9 +473,9 @@ fn parse_name_and_subgroups(name: &String) -> (String, Vec<LessonSubGroup>) {
}
fn convert_groups_to_teachers(
groups: &HashMap<String, ScheduleEntity>,
) -> HashMap<String, ScheduleEntity> {
let mut teachers: HashMap<String, ScheduleEntity> = HashMap::new();
groups: &HashMap<String, ScheduleEntry>,
) -> HashMap<String, ScheduleEntry> {
let mut teachers: HashMap<String, ScheduleEntry> = HashMap::new();
let empty_days: Vec<Day> = groups
.values()
@@ -510,7 +512,7 @@ fn convert_groups_to_teachers(
if !teachers.contains_key(&subgroup.teacher) {
teachers.insert(
subgroup.teacher.clone(),
ScheduleEntity {
ScheduleEntry {
name: subgroup.teacher.clone(),
days: empty_days.to_vec(),
},
@@ -538,12 +540,7 @@ fn convert_groups_to_teachers(
teachers
}
pub fn parse_xls(
buffer: &Vec<u8>,
) -> (
HashMap<String, ScheduleEntity>,
HashMap<String, ScheduleEntity>,
) {
pub fn parse_xls(buffer: &Vec<u8>) -> ParseResult {
let cursor = Cursor::new(&buffer);
let mut workbook: Xls<_> = open_workbook_from_rs(cursor).expect("Can't open workbook");
@@ -556,13 +553,13 @@ pub fn parse_xls(
let (days_markup, groups_markup) = parse_skeleton(&worksheet);
let mut groups: HashMap<String, ScheduleEntity> = HashMap::new();
let mut groups: HashMap<String, ScheduleEntry> = HashMap::new();
let mut days_times: Vec<Vec<InternalTime>> = Vec::new();
let saturday_end_row = worksheet.end().unwrap().0;
for group_markup in groups_markup {
let mut group = ScheduleEntity {
let mut group = ScheduleEntry {
name: group_markup.name,
days: Vec::new(),
};
@@ -686,7 +683,10 @@ pub fn parse_xls(
groups.insert(group.name.clone(), group);
}
(convert_groups_to_teachers(&groups), groups)
ParseResult {
teachers: convert_groups_to_teachers(&groups),
groups,
}
}
#[cfg(test)]
@@ -698,7 +698,7 @@ mod tests {
let buffer: Vec<u8> = include_bytes!("../../../../schedule.xls").to_vec();
let result = parse_xls(&buffer);
assert_ne!(result.0.len(), 0);
assert_ne!(result.1.len(), 0);
assert_ne!(result.groups.len(), 0);
assert_ne!(result.teachers.len(), 0);
}
}

View File

@@ -24,84 +24,104 @@ pub enum LessonType {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LessonSubGroup {
/**
* Номер подгруппы.
*/
pub number: u8,
/**
* Кабинет, если присутствует.
*/
pub cabinet: Option<String>,
/**
* Фио преподавателя.
*/
pub teacher: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Lesson {
/**
* Тип занятия
* Тип занятия.
*/
#[serde(rename = "type")]
pub lesson_type: LessonType,
/**
* Индексы пар, если присутствуют
* Индексы пар, если присутствуют.
*/
#[serde(rename = "defaultRange")]
pub default_range: Option<[u8; 2]>,
/**
* Название занятия
* Название занятия.
*/
pub name: Option<String>,
/**
* Начало и конец занятия
* Начало и конец занятия.
*/
pub time: LessonTime,
/**
* Подгруппы
* Подгруппы.
*/
#[serde(rename = "subGroups")]
pub subgroups: Option<Vec<LessonSubGroup>>,
/**
* Группа (только для расписания преподавателей)
* Группа, если это расписание для преподавателей.
*/
pub group: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Day {
/**
* День недели.
*/
pub name: String,
/**
* Адрес другого корпуса.
*/
pub street: Option<String>,
/**
* Дата.
*/
pub date: DateTime<Utc>,
/**
* Список пар в этот день.
*/
pub lessons: Vec<Lesson>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ScheduleEntity {
pub struct ScheduleEntry {
/**
* Название группы или ФИО преподавателя.
*/
pub name: String,
/**
* Список из шести дней.
*/
pub days: Vec<Day>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Schedule {
#[serde(rename = "updatedAt")]
pub updated_at: DateTime<Utc>,
pub struct ParseResult {
/**
* Список групп.
* Ключом является название группы.
*/
pub groups: HashMap<String, ScheduleEntry>,
pub groups: HashMap<String, ScheduleEntity>,
#[serde(rename = "updatedGroups")]
pub updated_groups: Vec<Vec<usize>>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct TeacherSchedule {
#[serde(rename = "updatedAt")]
pub updated_at: DateTime<Utc>,
pub teacher: ScheduleEntity,
pub updated: Vec<usize>,
/**
* Список преподавателей.
* Ключом является ФИО преподавателя.
*/
pub teachers: HashMap<String, ScheduleEntry>,
}

39
src/app_state.rs Normal file
View File

@@ -0,0 +1,39 @@
use crate::xls_downloader::basic_impl::BasicXlsDownloader;
use actix_web::web;
use chrono::{DateTime, Utc};
use diesel::{Connection, PgConnection};
use schedule_parser::schema::ParseResult;
use std::env;
use std::sync::{Mutex, MutexGuard};
pub struct Schedule {
pub etag: String,
pub updated_at: DateTime<Utc>,
pub parsed_at: DateTime<Utc>,
pub data: ParseResult,
}
pub struct AppState {
pub downloader: Mutex<BasicXlsDownloader>,
pub schedule: Mutex<Option<Schedule>>,
pub database: Mutex<PgConnection>,
}
impl AppState {
pub fn connection(&self) -> MutexGuard<PgConnection> {
self.database.lock().unwrap()
}
}
pub fn app_state() -> web::Data<AppState> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
web::Data::new(AppState {
downloader: Mutex::new(BasicXlsDownloader::new()),
schedule: Mutex::new(None),
database: Mutex::new(
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)),
),
})
}

View File

@@ -1,9 +1,8 @@
pub mod users {
use crate::database::models::User;
use crate::database::schema::fcm::user_id;
use crate::database::schema::users::dsl::users;
use crate::database::schema::users::dsl::*;
use diesel::{ExpressionMethods, QueryResult};
use diesel::{insert_into, ExpressionMethods, QueryResult};
use diesel::{PgConnection, SelectableHelper};
use diesel::{QueryDsl, RunQueryDsl};
use std::ops::DerefMut;
@@ -31,4 +30,42 @@ pub mod users {
.select(User::as_select())
.first(con)
}
pub fn contains_by_username(connection: &Mutex<PgConnection>, _username: String) -> bool {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
match users
.filter(username.eq(_username))
.count()
.get_result::<i64>(con)
{
Ok(count) => count > 0,
Err(_) => false,
}
}
pub fn delete_by_username(connection: &Mutex<PgConnection>, _username: String) -> bool {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
match diesel::delete(users.filter(username.eq(_username))).execute(con) {
Ok(count) => count > 0,
Err(_) => false,
}
}
pub fn insert(connection: &Mutex<PgConnection>, user: &User) -> QueryResult<usize> {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
insert_into(users).values(user).execute(con)
}
pub fn insert_or_ignore(connection: &Mutex<PgConnection>, user: &User) -> QueryResult<usize> {
let mut lock = connection.lock().unwrap();
let con = lock.deref_mut();
insert_into(users).values(user).on_conflict_do_nothing().execute(con)
}
}

View File

@@ -1,7 +1,7 @@
use diesel::prelude::*;
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[derive(diesel_derive_enum::DbEnum, Serialize, Debug, Clone, Copy, PartialEq)]
#[derive(diesel_derive_enum::DbEnum, Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
#[ExistingTypePath = "crate::database::schema::sql_types::UserRole"]
#[DbValueStyle = "UPPERCASE"]
#[serde(rename_all = "UPPERCASE")]
@@ -11,7 +11,7 @@ pub enum UserRole {
Admin,
}
#[derive(Identifiable, AsChangeset, Queryable, Selectable, Serialize)]
#[derive(Identifiable, AsChangeset, Queryable, Selectable, Serialize, Insertable, Debug)]
#[diesel(table_name = crate::database::schema::users)]
#[diesel(treat_none_as_null = true)]
pub struct User {

View File

@@ -1,60 +1,27 @@
use crate::app_state::{AppState, app_state};
use crate::routes::auth::sign_in::sign_in;
use crate::xls_downloader::basic_impl::BasicXlsDownloader;
use actix_web::{App, HttpServer, web};
use chrono::{DateTime, Utc};
use diesel::{Connection, PgConnection};
use dotenvy::dotenv;
use schedule_parser::schema::ScheduleEntity;
use std::collections::HashMap;
use std::env;
use std::sync::{Mutex, MutexGuard};
mod app_state;
mod database;
mod routes;
#[cfg(test)]
mod test_env;
mod utility;
mod xls_downloader;
pub struct AppState {
downloader: Mutex<BasicXlsDownloader>,
schedule: Mutex<
Option<(
String,
DateTime<Utc>,
(
HashMap<String, ScheduleEntity>,
HashMap<String, ScheduleEntity>,
),
)>,
>,
database: Mutex<PgConnection>,
}
impl AppState {
pub fn connection(&self) -> MutexGuard<PgConnection> {
self.database.lock().unwrap()
}
}
#[actix_web::main]
async fn main() {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let data = web::Data::new(AppState {
downloader: Mutex::new(BasicXlsDownloader::new()),
schedule: Mutex::new(None),
database: Mutex::new(
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)),
),
});
HttpServer::new(move || {
let schedule_scope = web::scope("/auth").service(sign_in);
let api_scope = web::scope("/api/v1").service(schedule_scope);
App::new().app_data(data.clone()).service(api_scope)
App::new().app_data(move || app_state()).service(api_scope)
})
.bind(("127.0.0.1", 8080))
.unwrap()

View File

@@ -1,2 +1,3 @@
pub mod sign_in;
mod schema;
pub mod sign_in;
pub mod sign_up;

View File

@@ -1,56 +1,109 @@
pub mod sign_in {
use crate::database::models::User;
use serde::{Deserialize, Serialize, Serializer};
use crate::routes::schema::shared::{ErrorToHttpCode, IResponse};
use crate::routes::schema::user;
use actix_web::http::StatusCode;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct SignInDto {
#[derive(Deserialize, Serialize)]
pub struct Request {
pub username: String,
pub password: String,
}
pub struct SignInResult(Result<SignInOk, SignInErr>);
pub type Response = IResponse<user::ResponseOk, ResponseErr>;
#[derive(Serialize)]
pub struct ResponseErr {
code: ErrorCode,
}
#[derive(Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
IncorrectCredentials,
InvalidVkAccessToken,
}
pub trait ResponseExt {
fn ok(user: &User) -> Self;
fn err(code: ErrorCode) -> Response;
}
impl ResponseExt for Response {
fn ok(user: &User) -> Self {
IResponse(Ok(user::ResponseOk::from_user(&user)))
}
fn err(code: ErrorCode) -> Response {
IResponse(Err(ResponseErr { code }))
}
}
impl ErrorToHttpCode for ResponseErr {
fn to_http_status_code(&self) -> StatusCode {
StatusCode::NOT_ACCEPTABLE
}
}
}
pub mod sign_up {
use crate::database::models::{User, UserRole};
use crate::routes::schema::shared::{ErrorToHttpCode, IResponse};
use crate::routes::schema::user;
use actix_web::http::StatusCode;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Request {
pub username: String,
pub password: String,
pub group: String,
pub role: UserRole,
pub version: String,
}
pub type Response = IResponse<user::ResponseOk, ResponseErr>;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignInOk {
pub struct ResponseOk {
id: String,
access_token: String,
group: String,
}
#[derive(Serialize)]
pub struct SignInErr {
code: SignInErrCode,
pub struct ResponseErr {
code: ErrorCode,
}
#[derive(Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SignInErrCode {
IncorrectCredentials,
InvalidVkAccessToken,
pub enum ErrorCode {
DisallowedRole,
InvalidGroupName,
UsernameAlreadyExists,
}
impl SignInResult {
pub fn ok(user: &User) -> Self {
Self(Ok(SignInOk {
id: user.id.clone(),
access_token: user.access_token.clone(),
group: user.group.clone(),
}))
pub trait ResponseExt {
fn ok(user: &User) -> Self;
fn err(code: ErrorCode) -> Self;
}
pub fn err(code: SignInErrCode) -> SignInResult {
Self(Err(SignInErr { code }))
impl ResponseExt for Response {
fn ok(user: &User) -> Self {
IResponse(Ok(user::ResponseOk::from_user(&user)))
}
fn err(code: ErrorCode) -> Response {
Self(Err(ResponseErr { code }))
}
}
impl Serialize for SignInResult {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match &self.0 {
Ok(ok) => serializer.serialize_some(&ok),
Err(err) => serializer.serialize_some(&err),
impl ErrorToHttpCode for ResponseErr {
fn to_http_status_code(&self) -> StatusCode {
StatusCode::NOT_ACCEPTABLE
}
}
}

View File

@@ -1,7 +1,6 @@
use crate::database::driver;
use crate::database::models::User;
use crate::routes::auth::schema::SignInErrCode::IncorrectCredentials;
use crate::routes::auth::schema::{SignInDto, SignInResult};
use crate::routes::auth::schema;
use crate::{AppState, utility};
use actix_web::{post, web};
use diesel::SaveChangesDsl;
@@ -9,8 +8,13 @@ use std::ops::DerefMut;
use web::Json;
#[post("/sign-in")]
pub async fn sign_in(data: Json<SignInDto>, app_state: web::Data<AppState>) -> Json<SignInResult> {
let result = match driver::users::get_by_username(&app_state.database, data.username.clone()) {
pub async fn sign_in(
data: Json<schema::sign_in::Request>,
app_state: web::Data<AppState>,
) -> schema::sign_in::Response {
use schema::sign_in::*;
match driver::users::get_by_username(&app_state.database, data.username.clone()) {
Ok(mut user) => match bcrypt::verify(&data.password, &user.password) {
Ok(true) => {
let mut lock = app_state.connection();
@@ -21,13 +25,108 @@ pub async fn sign_in(data: Json<SignInDto>, app_state: web::Data<AppState>) -> J
user.save_changes::<User>(conn)
.expect("Failed to update user");
SignInResult::ok(&user)
Response::ok(&user)
}
Ok(false) | Err(_) => SignInResult::err(IncorrectCredentials),
Ok(false) | Err(_) => Response::err(ErrorCode::IncorrectCredentials),
},
Err(_) => SignInResult::err(IncorrectCredentials),
Err(_) => Response::err(ErrorCode::IncorrectCredentials),
}
}
#[cfg(test)]
mod tests {
use crate::app_state::app_state;
use crate::database::driver;
use crate::database::models::{User, UserRole};
use crate::routes::auth::schema;
use crate::routes::auth::sign_in::sign_in;
use crate::test_env::tests::{static_app_state, test_app, test_env};
use crate::utility;
use actix_http::StatusCode;
use actix_web::dev::ServiceResponse;
use actix_web::http::Method;
use actix_web::test;
use sha2::{Digest, Sha256};
use std::fmt::Write;
async fn sign_in_client(data: schema::sign_in::Request) -> ServiceResponse {
let app = test_app(app_state(), sign_in).await;
let req = test::TestRequest::with_uri("/sign-in")
.method(Method::POST)
.set_json(data)
.to_request();
test::call_service(&app, req).await
}
fn prepare(username: String) {
let id = {
let mut sha = Sha256::new();
sha.update(&username);
let result = sha.finalize();
let bytes = &result[..12];
let mut hex = String::new();
for byte in bytes {
write!(&mut hex, "{:02x}", byte).unwrap();
}
hex
};
Json(result)
test_env();
let app_state = static_app_state();
driver::users::insert_or_ignore(
&app_state.database,
&User {
id: id.clone(),
username,
password: bcrypt::hash("example".to_string(), bcrypt::DEFAULT_COST).unwrap(),
vk_id: None,
access_token: utility::jwt::encode(&id),
group: "ИС-214/23".to_string(),
role: UserRole::Student,
version: "1.0.0".to_string(),
},
)
.unwrap();
}
#[actix_web::test]
async fn sign_in_ok() {
prepare("test::sign_in_ok".to_string());
let resp = sign_in_client(schema::sign_in::Request {
username: "test::sign_in_ok".to_string(),
password: "example".to_string(),
})
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn sign_in_err() {
prepare("test::sign_in_err".to_string());
let invalid_username = sign_in_client(schema::sign_in::Request {
username: "test::sign_in_err::username".to_string(),
password: "example".to_string(),
})
.await;
assert_eq!(invalid_username.status(), StatusCode::NOT_ACCEPTABLE);
let invalid_password = sign_in_client(schema::sign_in::Request {
username: "test::sign_in_err".to_string(),
password: "bad_password".to_string(),
})
.await;
assert_eq!(invalid_password.status(), StatusCode::NOT_ACCEPTABLE);
}
}

153
src/routes/auth/sign_up.rs Normal file
View File

@@ -0,0 +1,153 @@
use crate::database::driver;
use crate::database::models::{User, UserRole};
use crate::routes::auth::schema;
use crate::{utility, AppState};
use actix_web::{post, web};
use objectid::ObjectId;
use web::Json;
#[post("/sign-up")]
pub async fn sign_up(
data: Json<schema::sign_up::Request>,
app_state: web::Data<AppState>,
) -> schema::sign_up::Response {
use schema::sign_up::*;
if data.role == UserRole::Admin {
return Response::err(ErrorCode::DisallowedRole);
}
let schedule_opt = app_state.schedule.lock().unwrap();
if let Some(schedule) = &*schedule_opt {
if !schedule.data.groups.contains_key(&data.group) {
return Response::err(ErrorCode::InvalidGroupName);
}
}
if driver::users::contains_by_username(&app_state.database, data.username.clone()) {
return Response::err(ErrorCode::UsernameAlreadyExists);
}
let id = ObjectId::new().unwrap().to_string();
let access_token = utility::jwt::encode(&id);
let user = User {
id,
username: data.username.clone(),
password: bcrypt::hash(data.password.as_str(), bcrypt::DEFAULT_COST).unwrap(),
vk_id: None,
access_token,
group: data.group.clone(),
role: data.role.clone(),
version: data.version.clone(),
};
driver::users::insert(&app_state.database, &user).unwrap();
Response::ok(&user)
}
#[cfg(test)]
mod tests {
use crate::app_state::app_state;
use crate::database::driver;
use crate::database::models::UserRole;
use crate::routes::auth::schema;
use crate::routes::auth::sign_up::sign_up;
use crate::test_env::tests::{static_app_state, test_app, test_env};
use actix_http::StatusCode;
use actix_web::dev::ServiceResponse;
use actix_web::http::Method;
use actix_web::test;
struct SignUpPartial {
username: String,
group: String,
role: UserRole,
}
async fn sign_up_client(data: SignUpPartial) -> ServiceResponse {
let app = test_app(app_state(), sign_up).await;
let req = test::TestRequest::with_uri("/sign-up")
.method(Method::POST)
.set_json(schema::sign_up::Request {
username: data.username.clone(),
password: "example".to_string(),
group: data.group.clone(),
role: data.role.clone(),
version: "1.0.0".to_string(),
})
.to_request();
test::call_service(&app, req).await
}
#[actix_web::test]
async fn sign_up_valid() {
// prepare
test_env();
let app_state = static_app_state();
driver::users::delete_by_username(&app_state.database, "test::sign_up_valid".to_string());
// test
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_valid".to_string(),
group: "ИС-214/23".to_string(),
role: UserRole::Student,
})
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn sign_up_multiple() {
// prepare
test_env();
let app_state = static_app_state();
driver::users::delete_by_username(
&app_state.database,
"test::sign_up_multiple".to_string(),
);
let create = sign_up_client(SignUpPartial {
username: "test::sign_up_multiple".to_string(),
group: "ИС-214/23".to_string(),
role: UserRole::Student,
})
.await;
assert_eq!(create.status(), StatusCode::OK);
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_multiple".to_string(),
group: "ИС-214/23".to_string(),
role: UserRole::Student,
})
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
#[actix_web::test]
async fn sign_up_invalid_role() {
test_env();
// test
let resp = sign_up_client(SignUpPartial {
username: "test::sign_up_invalid_role".to_string(),
group: "ИС-214/23".to_string(),
role: UserRole::Admin,
})
.await;
assert_eq!(resp.status(), StatusCode::NOT_ACCEPTABLE);
}
}

View File

@@ -1 +1,2 @@
pub mod auth;
mod schema;

69
src/routes/schema.rs Normal file
View File

@@ -0,0 +1,69 @@
pub mod shared {
use actix_web::body::EitherBody;
use actix_web::error::JsonPayloadError;
use actix_web::http::StatusCode;
use actix_web::{HttpRequest, HttpResponse, Responder};
use serde::Serialize;
pub struct IResponse<T: Serialize, E: Serialize>(pub Result<T, E>);
pub trait ErrorToHttpCode {
fn to_http_status_code(&self) -> StatusCode;
}
impl<T: Serialize, E: Serialize + ErrorToHttpCode> Responder for IResponse<T, E> {
type Body = EitherBody<String>;
fn respond_to(self, _: &HttpRequest) -> HttpResponse<Self::Body> {
match serde_json::to_string(&self.0) {
Ok(body) => {
let code = match &self.0 {
Ok(_) => StatusCode::OK,
Err(e) => e.to_http_status_code(),
};
match HttpResponse::build(code)
.content_type(mime::APPLICATION_JSON)
.message_body(body)
{
Ok(res) => res.map_into_left_body(),
Err(err) => HttpResponse::from_error(err).map_into_right_body(),
}
}
Err(err) => {
HttpResponse::from_error(JsonPayloadError::Serialize(err)).map_into_right_body()
}
}
}
}
}
pub mod user {
use crate::database::models::{User, UserRole};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResponseOk {
id: String,
username: String,
group: String,
role: UserRole,
vk_id: Option<i32>,
access_token: String,
}
impl ResponseOk {
pub fn from_user(user: &User) -> Self {
ResponseOk {
id: user.id.clone(),
username: user.username.clone(),
group: user.group.clone(),
role: user.role.clone(),
vk_id: user.vk_id.clone(),
access_token: user.access_token.clone(),
}
}
}
}

27
src/test_env.rs Normal file
View File

@@ -0,0 +1,27 @@
#[cfg(test)]
pub(crate) mod tests {
use crate::app_state::{AppState, app_state};
use actix_web::dev::{HttpServiceFactory, Service, ServiceResponse};
use actix_web::{App, test, web};
use std::sync::LazyLock;
pub fn test_env() {
dotenvy::from_path(".env.test").expect("Failed to load test environment file");
}
pub async fn test_app<F>(
app_state: web::Data<AppState>,
factory: F,
) -> impl Service<actix_http::Request, Response = ServiceResponse, Error = actix_web::Error>
where
F: HttpServiceFactory + 'static,
{
test::init_service(App::new().app_data(app_state).service(factory)).await
}
pub fn static_app_state() -> web::Data<AppState> {
static STATE: LazyLock<web::Data<AppState>> = LazyLock::new(|| app_state());
STATE.clone()
}
}

View File

@@ -48,7 +48,9 @@ pub fn verify_and_decode(token: &String) -> Result<String, VerifyError> {
Ok(claims.get("id").cloned().unwrap())
}
Err(err) => Err(match err {
jwt::Error::InvalidSignature => VerifyError::InvalidSignature,
jwt::Error::InvalidSignature | jwt::Error::RustCryptoMac(_) => {
VerifyError::InvalidSignature
}
jwt::Error::Format | jwt::Error::Base64(_) | jwt::Error::NoClaimsComponent => {
VerifyError::InvalidToken
}
@@ -86,18 +88,18 @@ pub fn encode(id: &String) -> String {
#[cfg(test)]
mod tests {
use super::*;
use dotenvy::dotenv;
use crate::test_env::tests::test_env;
#[test]
fn test_encode() {
dotenv().unwrap();
test_env();
assert_eq!(encode(&"test".to_string()).is_empty(), false);
}
#[test]
fn test_decode_invalid_token() {
dotenv().unwrap();
test_env();
let token = "".to_string();
let result = verify_and_decode(&token);
@@ -108,20 +110,20 @@ mod tests {
#[test]
fn test_decode_invalid_signature() {
dotenv().unwrap();
test_env();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxODY4ODEyOTI4IiwiaWF0IjoiMTc0MjY2ODkyOCIsImlkIjoiNjdkY2M5YTk1MDdiMDAwMDc3Mjc0NGEyIn0.DQYFYF-3DoJgCLOVdAWa47nUaCJAh16DXj-ChNSSmWz".to_string();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxNjE2NTI2Mzc2IiwiaWF0IjoiMTQ5MDM4MjM3NiIsImlkIjoiNjdkY2M5YTk1MDdiMDAwMDc3Mjc0NGEyIn0.Qc2LbMJTvl2hWzDM2XyQv4m9lIqR84COAESQAieUxz8".to_string();
let result = verify_and_decode(&token);
assert!(result.is_err());
assert_eq!(result.err().unwrap(), VerifyError::InvalidToken);
assert_eq!(result.err().unwrap(), VerifyError::InvalidSignature);
}
#[test]
fn test_decode_expired() {
dotenv().unwrap();
test_env();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxNjE2NTI2Mzc2IiwiaWF0IjoiMTQ5MDM4MjM3NiIsImlkIjoiNjdkY2M5YTk1MDdiMDAwMDc3Mjc0NGEyIn0.Qc2LbMJTvl2hWzDM2XyQv4m9lIqR84COAESQAieUxz8".to_string();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjAiLCJleHAiOiIwIn0.GBsVYvnZIfHXt00t-qmAdUMyHSyWOBtC0Mrxwg1HQOM".to_string();
let result = verify_and_decode(&token);
assert!(result.is_err());
@@ -130,9 +132,9 @@ mod tests {
#[test]
fn test_decode_ok() {
dotenv().unwrap();
test_env();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOiIxODY4ODEyOTI4IiwiaWF0IjoiMTc0MjY2ODkyOCIsImlkIjoiNjdkY2M5YTk1MDdiMDAwMDc3Mjc0NGEyIn0.DQYFYF-3DoJgCLOVdAWa47nUaCJAh16DXj-ChNSSmWw".to_string();
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6Ijk5OTk5OTk5OTkiLCJleHAiOiI5OTk5OTk5OTk5In0.o1vN-ze5iaJrnlHqe7WARXMBhhzjxTjTKkjlmTGEnOI".to_string();
let result = verify_and_decode(&token);
assert!(result.is_ok());