16 Commits

Author SHA1 Message Date
dependabot[bot]
7b2f9cb684 chore(deps): bump console-subscriber from 0.4.1 to 0.5.0
Bumps [console-subscriber](https://github.com/tokio-rs/console) from 0.4.1 to 0.5.0.
- [Release notes](https://github.com/tokio-rs/console/releases)
- [Changelog](https://github.com/tokio-rs/console/blob/main/release-plz.toml)
- [Commits](https://github.com/tokio-rs/console/compare/console-subscriber-v0.4.1...console-subscriber-v0.5.0)

---
updated-dependencies:
- dependency-name: console-subscriber
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 19:07:27 +00:00
2442641479 feat(ci): auto-deploy new version after building docker image 2025-10-29 02:31:13 +04:00
ac16c96e5e chore(schedule): add lesson type 'differentiated exam' 2025-10-29 02:16:25 +04:00
622464e4c3 feat(users): add endpoints for getting user by ids 2025-10-28 22:33:49 +04:00
39c60ef939 feat(middleware): add support of path patterns 2025-10-28 22:33:10 +04:00
d1ef5c032e feat: implement service users 2025-10-28 06:53:31 +04:00
b635750e28 feat(db): add service users table 2025-10-28 06:46:30 +04:00
a59fff927d chore(deps): update dependencies 2025-10-28 06:45:55 +04:00
cdc89b5bcd fix(parser): fix sentry error sending 2025-10-10 03:00:47 +04:00
ad86f6cd64 feat(parser): limit names regex to maximum 2 elements
This allows us to not worry about subgroups array index overflows, and we can make better non-standard case solving.
2025-10-10 01:39:54 +04:00
a3b4a501db feat(parser): improve names regex to exclude some non-standard cases
Like "Название ФАмилия. И.О.".
In that case regex will grab "Название ФА", instead of "Амилия. И. О." (we can't add 'Ф', bc it will make regex checks way more complex).

Now it will ignore "Название ФА" if after that lower or upper char is placed.
Previously only lower chars are excluded and check won't exclude "Название ФА" and grabs "Название Ф" bc after 'Ф' uppercase char is present.
2025-10-10 01:37:52 +04:00
df0e99a4d0 feat(parser): make lesson cell range less strict to support upcoming split-lessons 2025-10-10 01:31:55 +04:00
a8cf8fb0f5 feat(parser): improve street regex 2025-10-10 01:30:56 +04:00
7ed866138e feat(error): add error for unknown lesson type 2025-10-10 01:30:30 +04:00
7bac48f8fc feat(error): add more intuitive CellPos formatting and get rid of ErrorCell 2025-10-10 01:27:05 +04:00
191ec36fef chore: remove useless commented code 2025-10-10 01:25:12 +04:00
36 changed files with 1070 additions and 662 deletions

View File

@@ -140,3 +140,6 @@ jobs:
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
build-args: | build-args: |
"BINARY_NAME=${{ env.BINARY_NAME }}" "BINARY_NAME=${{ env.BINARY_NAME }}"
- name: Deploy
run: curl ${{ secrets.DEPLOY_URL }}

719
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,63 +18,63 @@ providers = { path = "providers" }
actix-macros = { path = "actix-macros" } actix-macros = { path = "actix-macros" }
# serve api # serve api
actix-web = "4.11.0" actix-web = "4"
# basic # basic
chrono = { version = "0.4.42", features = ["serde"] } chrono = { version = "0", features = ["serde"] }
derive_more = { version = "2.0.1", features = ["full"] } derive_more = { version = "2", features = ["full"] }
dotenvy = "0.15.7" dotenvy = "0"
# sql # sql
database = { path = "database" } database = { path = "database" }
# logging # logging
env_logger = "0.11.8" env_logger = "0"
# async # async
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0.7.16" tokio-util = "0"
futures-util = "0.3.31" futures-util = "0"
# authorization # authorization
bcrypt = "0.17.1" bcrypt = "0"
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] } jsonwebtoken = { version = "9", features = ["use_pem"] }
# creating users # creating users
objectid = "0.2.0" objectid = "0"
# schedule downloader # schedule downloader
reqwest = { version = "0.12.23", features = ["json"] } reqwest = { version = "0", features = ["json"] }
mime = "0.3.17" mime = "0"
# error handling # error handling
sentry = "0.43.0" sentry = "0"
sentry-actix = "0.43.0" sentry-actix = "0"
# [de]serializing # [de]serializing
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_with = "3.14" serde_with = "3"
sha1 = "0.11.0-rc.2" sha1 = "0.11.0-rc.2"
# documentation # documentation
utoipa = { version = "5.4.0", features = ["actix_extras", "chrono"] } utoipa = { version = "5", features = ["actix_extras", "chrono"] }
utoipa-rapidoc = { version = "6.0.0", features = ["actix-web"] } utoipa-rapidoc = { version = "6", features = ["actix-web"] }
utoipa-actix-web = "0.1.2" utoipa-actix-web = "0"
uuid = { version = "1.18.1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
hex-literal = "1" hex-literal = "1"
log = "0.4.28" log = "0"
# telegram webdata deciding and verify # telegram webdata deciding and verify
base64 = "0.22.1" base64 = "0"
percent-encoding = "2.3.2" percent-encoding = "2"
ed25519-dalek = "3.0.0-pre.1" ed25519-dalek = "3.0.0-pre.1"
# development tracing # development tracing
console-subscriber = { version = "0.4.1", optional = true } console-subscriber = { version = "0", optional = true }
tracing = { version = "0.1.41", optional = true } tracing = { version = "0", optional = true }
[dev-dependencies] [dev-dependencies]
providers = { path = "providers", features = ["test"] } providers = { path = "providers", features = ["test"] }

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
migration = { path = "migration" } migration = { path = "migration" }
entity = { path = "entity" } entity = { path = "entity" }
sea-orm = { version = "2.0.0-rc.6", features = ["sqlx-postgres", "runtime-tokio"] } sea-orm = { version = "2.0.0-rc.15", features = ["sqlx-postgres", "runtime-tokio"] }
paste = "1.0.15" paste = "1"
serde = { version = "1", features = ["derive"] }

View File

@@ -3,4 +3,5 @@
pub mod prelude; pub mod prelude;
pub mod sea_orm_active_enums; pub mod sea_orm_active_enums;
pub mod service_user;
pub mod user; pub mod user;

View File

@@ -1,3 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
pub use super::service_user::Entity as ServiceUser;
pub use super::user::Entity as User; pub use super::user::Entity as User;

View File

@@ -0,0 +1,16 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "service_user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -12,7 +12,7 @@ path = "src/lib.rs"
async-std = { version = "1", features = ["attributes", "tokio1"] } async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration] [dependencies.sea-orm-migration]
version = "2.0.0-rc.6" version = "2.0.0-rc.15"
features = [ features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.

View File

@@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::MigratorTrait;
use sea_orm_migration::prelude::*; use sea_orm_migration::prelude::*;
mod m20250904_024854_init; mod m20250904_024854_init;
mod m20251027_230335_add_service_users;
pub struct Migrator; pub struct Migrator;
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> { fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![ vec![
Box::new(m20250904_024854_init::Migration), Box::new(m20250904_024854_init::Migration),
Box::new(m20251027_230335_add_service_users::Migration),
] ]
} }
} }

View File

@@ -0,0 +1,33 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ServiceUser::Table)
.if_not_exists()
.col(string_uniq(ServiceUser::Id).primary_key().not_null())
.col(string(ServiceUser::Name))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ServiceUser::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ServiceUser {
Table,
Id,
Name,
}

View File

@@ -4,7 +4,28 @@ pub use migration;
pub use sea_orm; pub use sea_orm;
pub mod entity { pub mod entity {
use serde::{Deserialize, Serialize};
pub use entity::*; pub use entity::*;
pub use entity::user::{ActiveModel as ActiveUser, Model as User, Entity as UserEntity, Column as UserColumn}; pub use entity::user::{
ActiveModel as ActiveUser, //
Column as UserColumn, //
Entity as UserEntity, //
Model as User, //
};
pub use entity::service_user::{
ActiveModel as ActiveServiceUser, //
Column as ServiceUserColumn, //
Entity as ServiceUserEntity, //
Model as ServiceUser, //
};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UserType {
Default,
Service,
}
} }

View File

@@ -51,6 +51,8 @@ macro_rules! define_find_by {
} }
impl Query { impl Query {
// User
define_find_by!(user, id, str, Id); define_find_by!(user, id, str, Id);
define_find_by!(user, telegram_id, i64, TelegramId); define_find_by!(user, telegram_id, i64, TelegramId);
define_find_by!(user, vk_id, i32, VkId); define_find_by!(user, vk_id, i32, VkId);
@@ -60,4 +62,12 @@ impl Query {
define_is_exists!(user, username, str, Username); define_is_exists!(user, username, str, Username);
define_is_exists!(user, telegram_id, i64, TelegramId); define_is_exists!(user, telegram_id, i64, TelegramId);
define_is_exists!(user, vk_id, i32, VkId); define_is_exists!(user, vk_id, i32, VkId);
// Service user
define_find_by!(service_user, id, str, Id);
define_find_by!(service_user, name, str, Name);
define_is_exists!(service_user, id, str, Id);
define_is_exists!(service_user, name, str, Name);
} }

View File

@@ -102,7 +102,10 @@ pub enum LessonType {
CourseProjectDefense, CourseProjectDefense,
/// Практическое занятие. /// Практическое занятие.
Practice Practice,
/// Дифференцированный зачёт.
DifferentiatedExam,
} }
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)] #[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
@@ -212,70 +215,6 @@ impl ScheduleSnapshot {
} }
} }
// #[derive(Clone, Debug, Display, Error, ToSchema)]
// #[display("row {row}, column {column}")]
// pub struct ErrorCellPos {
// pub row: u32,
// pub column: u32,
// }
//
// #[derive(Clone, Debug, Display, Error, ToSchema)]
// #[display("'{data}' at {pos}")]
// pub struct ErrorCell {
// pub pos: ErrorCellPos,
// pub data: String,
// }
//
// impl ErrorCell {
// pub fn new(row: u32, column: u32, data: String) -> Self {
// Self {
// pos: ErrorCellPos { row, column },
// data,
// }
// }
// }
// #[derive(Clone, Debug, Display, Error, ToSchema)]
// pub enum ParseError {
// /// Errors related to reading XLS file.
// #[display("{_0:?}: Failed to read XLS file.")]
// #[schema(value_type = String)]
// BadXLS(Arc<calamine::XlsError>),
//
// /// Not a single sheet was found.
// #[display("No work sheets found.")]
// NoWorkSheets,
//
// /// There are no data on the boundaries of the sheet.
// #[display("There is no data on work sheet boundaries.")]
// UnknownWorkSheetRange,
//
// /// Failed to read the beginning and end of the lesson from the cell
// #[display("Failed to read lesson start and end from {_0}.")]
// LessonBoundaries(ErrorCell),
//
// /// Not found the beginning and the end corresponding to the lesson.
// #[display("No start and end times matching the lesson (at {_0}) was found.")]
// LessonTimeNotFound(ErrorCellPos),
// }
//
// impl Serialize for ParseError {
// fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
// where
// S: Serializer,
// {
// match self {
// ParseError::BadXLS(_) => serializer.serialize_str("BAD_XLS"),
// ParseError::NoWorkSheets => serializer.serialize_str("NO_WORK_SHEETS"),
// ParseError::UnknownWorkSheetRange => {
// serializer.serialize_str("UNKNOWN_WORK_SHEET_RANGE")
// }
// ParseError::LessonBoundaries(_) => serializer.serialize_str("GLOBAL_TIME"),
// ParseError::LessonTimeNotFound(_) => serializer.serialize_str("LESSON_TIME_NOT_FOUND"),
// }
// }
// }
#[async_trait] #[async_trait]
pub trait ScheduleProvider pub trait ScheduleProvider
where where

View File

@@ -9,23 +9,23 @@ test = []
[dependencies] [dependencies]
base = { path = "../base" } base = { path = "../base" }
tokio = { version = "1.47.1", features = ["sync", "macros", "time"] } tokio = { version = "1", features = ["sync", "macros", "time"] }
tokio-util = "0.7.16" tokio-util = "0"
chrono = { version = "0.4.41", features = ["serde"] } chrono = { version = "0", features = ["serde"] }
derive_more = { version = "2.0.1", features = ["error", "display", "from"] } derive_more = { version = "2", features = ["error", "display", "from"] }
utoipa = { version = "5.4.0", features = ["macros", "chrono"] } utoipa = { version = "5", features = ["macros", "chrono"] }
calamine = "0.31" calamine = "0"
async-trait = "0.1.89" async-trait = "0"
reqwest = "0.12.23" reqwest = "0"
ua_generator = "0.5.22" ua_generator = "0"
regex = "1.11.2" regex = "1"
strsim = "0.11.1" strsim = "0"
log = "0.4.27" log = "0"
sentry = "0.43.0" sentry = "0"
fancy-regex = "0.16.2" fancy-regex = "0"

View File

@@ -1,21 +1,5 @@
use derive_more::{Display, Error, From};
use crate::parser::worksheet::CellPos; use crate::parser::worksheet::CellPos;
use derive_more::{Display, Error, From};
#[derive(Clone, Debug, Display, Error)]
#[display("'{data}' at {pos}")]
pub struct ErrorCell {
pub pos: CellPos,
pub data: String,
}
impl ErrorCell {
pub fn new(row: u32, column: u32, data: &str) -> Self {
Self {
pos: CellPos { row, column },
data: data.to_string(),
}
}
}
#[derive(Debug, Display, Error, From)] #[derive(Debug, Display, Error, From)]
pub enum Error { pub enum Error {
@@ -28,11 +12,14 @@ pub enum Error {
#[display("There is no data on work sheet boundaries.")] #[display("There is no data on work sheet boundaries.")]
UnknownWorkSheetRange, UnknownWorkSheetRange,
#[display("Failed to read lesson start and end from {_0}.")] #[display("Failed to read lesson start and end of lesson at {_0}.")]
NoLessonBoundaries(ErrorCell), NoLessonBoundaries(CellPos),
#[display("No start and end times matching the lesson (at {_0}) was found.")] #[display("No start and end times matching the lesson (at {_0}) was found.")]
LessonTimeNotFound(CellPos), LessonTimeNotFound(CellPos),
#[display("Unknown lesson type `{type}` at {pos}")]
UnknownLessonType { pos: CellPos, r#type: String },
} }
pub type Result<T> = core::result::Result<T, Error>; pub type Result<T> = core::result::Result<T, Error>;

View File

@@ -1,6 +1,5 @@
pub use self::error::{Error, Result}; pub use self::error::{Error, Result};
use crate::or_continue; use crate::or_continue;
use crate::parser::error::ErrorCell;
use crate::parser::worksheet::{CellPos, CellRange, WorkSheet}; use crate::parser::worksheet::{CellPos, CellRange, WorkSheet};
use crate::parser::LessonParseResult::{Lessons, Street}; use crate::parser::LessonParseResult::{Lessons, Street};
use base::LessonType::Break; use base::LessonType::Break;
@@ -188,6 +187,7 @@ fn guess_lesson_type(text: &str) -> Option<LessonType> {
("курсовой проект", LessonType::CourseProject), ("курсовой проект", LessonType::CourseProject),
("защита курсового проекта", LessonType::CourseProjectDefense), ("защита курсового проекта", LessonType::CourseProjectDefense),
("практическое занятие", LessonType::Practice), ("практическое занятие", LessonType::Practice),
("дифференцированный зачет", LessonType::DifferentiatedExam),
]) ])
}); });
@@ -217,7 +217,7 @@ fn parse_lesson(
}; };
static OTHER_STREET_RE: LazyLock<Regex> = static OTHER_STREET_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+[,\s]\d+$").unwrap()); LazyLock::new(|| Regex::new(r"^[А-Я][а-я]+[,\s]+д\.\s\d+$").unwrap());
if OTHER_STREET_RE.is_match(&cell_data) { if OTHER_STREET_RE.is_match(&cell_data) {
return Ok(Street(cell_data)); return Ok(Street(cell_data));
@@ -226,12 +226,17 @@ fn parse_lesson(
cell_data cell_data
}; };
let cell_range = worksheet.get_merge_from_start(row, group_column); let lesson_cell_range = worksheet.get_merge_from_start(row, group_column);
let (default_range, lesson_time) = { let (default_range, lesson_time) = {
let end_time_arr = day_boundaries let end_time_arr = day_boundaries
.iter() .iter()
.filter(|time| time.range.end.row == cell_range.end.row) .filter(
|BoundariesData {
range: CellRange { end, .. },
..
}| { lesson_cell_range.end.row <= end.row },
)
.collect::<Vec<&BoundariesData>>(); .collect::<Vec<&BoundariesData>>();
let end_time = end_time_arr let end_time = end_time_arr
@@ -257,12 +262,12 @@ fn parse_lesson(
name, name,
mut subgroups, mut subgroups,
r#type: lesson_type, r#type: lesson_type,
} = parse_name_and_subgroups(&name)?; } = parse_name_and_subgroups(&name, row, group_column)?;
{ {
let cabinets: Vec<String> = parse_cabinets( let cabinets: Vec<String> = parse_cabinets(
worksheet, worksheet,
(cell_range.start.row, cell_range.end.row), (lesson_cell_range.start.row, lesson_cell_range.end.row),
group_column + 1, group_column + 1,
); );
@@ -364,7 +369,7 @@ struct ParsedLessonName {
//noinspection GrazieInspection //noinspection GrazieInspection
/// Getting the "pure" name of the lesson and list of teachers from the text of the lesson cell. /// Getting the "pure" name of the lesson and list of teachers from the text of the lesson cell.
fn parse_name_and_subgroups(text: &str) -> Result<ParsedLessonName> { fn parse_name_and_subgroups(text: &str, row: u32, column: u32) -> Result<ParsedLessonName> {
// Части названия пары: // Части названия пары:
// 1. Само название. // 1. Само название.
// 2. Список преподавателей и подгрупп. // 2. Список преподавателей и подгрупп.
@@ -373,7 +378,7 @@ fn parse_name_and_subgroups(text: &str) -> Result<ParsedLessonName> {
// Регулярное выражение для получения ФИО преподавателей и номеров подгрупп (aka. второй части). // Регулярное выражение для получения ФИО преподавателей и номеров подгрупп (aka. второй части).
static NAME_RE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| { static NAME_RE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
fancy_regex::Regex::new( fancy_regex::Regex::new(
r"([А-Я][а-я]+(?:[\s.]*[А-Я]){1,2})(?=[^а-я])[.\s]*(?:\(?(\d)[\sа-я]*\)?)?", r"([А-Я][а-я]+(?:[\s.]*[А-Я]){1,2})(?=[^Аа-я])[.\s]*(?:\(?(\d)[\sа-я]*\)?)?",
) )
.unwrap() .unwrap()
}); });
@@ -394,10 +399,10 @@ fn parse_name_and_subgroups(text: &str) -> Result<ParsedLessonName> {
let mut lesson_name: Option<&str> = None; let mut lesson_name: Option<&str> = None;
let mut extra: Option<&str> = None; let mut extra: Option<&str> = None;
let mut shared_subgroup = false; let mut shared_subgroup = true;
let mut subgroups: [Option<LessonSubGroup>; 2] = [None, None]; let mut subgroups: [Option<LessonSubGroup>; 2] = [None, None];
for capture in NAME_RE.captures_iter(&text) { for capture in NAME_RE.captures_iter(&text).take(2) {
let capture = capture.unwrap(); let capture = capture.unwrap();
if lesson_name.is_none() { if lesson_name.is_none() {
@@ -438,17 +443,23 @@ fn parse_name_and_subgroups(text: &str) -> Result<ParsedLessonName> {
match subgroup_index { match subgroup_index {
None => { None => {
subgroups[0] = subgroup; // we have only 2 matches max so more than 2 subgroups we cant have 100%
subgroups[1] = None; *subgroups.iter_mut().find(|x| x.is_none()).unwrap() = subgroup;
shared_subgroup = true;
break;
} }
Some(num) => { Some(num) => {
// bc we have indexed subgroup
shared_subgroup = false;
// 1 - 1 = 0 | 2 - 1 = 1 | 3 - 1 = 2 (schedule index to array index) // 1 - 1 = 0 | 2 - 1 = 1 | 3 - 1 = 2 (schedule index to array index)
// 0 % 2 = 0 | 1 % 2 = 1 | 2 % 2 = 0 (clamp) // 0 % 2 = 0 | 1 % 2 = 1 | 2 % 2 = 0 (clamp)
let normalised = (num - 1) % 2; let subgroup_index = ((num - 1) % 2) as usize;
subgroups[normalised as usize] = subgroup; // if we have subgroup in that index (probably non-indexed, we change it index to free)
if subgroups[subgroup_index].is_some() {
subgroups.swap(0, 1);
}
subgroups[subgroup_index] = subgroup;
} }
} }
} }
@@ -456,7 +467,7 @@ fn parse_name_and_subgroups(text: &str) -> Result<ParsedLessonName> {
let subgroups = if lesson_name.is_none() { let subgroups = if lesson_name.is_none() {
Vec::new() Vec::new()
} else if shared_subgroup { } else if shared_subgroup {
Vec::from([subgroups[0].take()]) Vec::from([subgroups.into_iter().next().unwrap()])
} else { } else {
Vec::from(subgroups) Vec::from(subgroups)
}; };
@@ -475,13 +486,19 @@ fn parse_name_and_subgroups(text: &str) -> Result<ParsedLessonName> {
if result.is_none() { if result.is_none() {
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
sentry::capture_message( sentry::capture_error(&Error::UnknownLessonType {
&format!("Не удалось угадать тип пары '{}'!", extra), r#type: extra.to_string(),
sentry::Level::Warning, pos: CellPos::new(row, column),
); });
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
log::warn!("Не удалось угадать тип пары '{}'!", extra); log::warn!(
"{}",
Error::UnknownLessonType {
r#type: extra.to_string(),
pos: CellPos::new(row, column),
}
);
} }
result result
@@ -548,9 +565,8 @@ fn parse_day_boundaries(
continue; continue;
}; };
let lesson_time = parse_lesson_boundaries_cell(&time_cell, date).ok_or( let lesson_time = parse_lesson_boundaries_cell(&time_cell, date)
Error::NoLessonBoundaries(ErrorCell::new(row, column, &time_cell)), .ok_or(Error::NoLessonBoundaries(CellPos::new(row, column)))?;
)?;
// type // type
let lesson_type = if time_cell.contains("пара") { let lesson_type = if time_cell.contains("пара") {

View File

@@ -1,5 +1,5 @@
use derive_more::Display;
use regex::Regex; use regex::Regex;
use std::fmt::{Display, Formatter};
use std::ops::Deref; use std::ops::Deref;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -9,13 +9,35 @@ pub struct WorkSheet {
pub merges: Vec<calamine::Dimensions>, pub merges: Vec<calamine::Dimensions>,
} }
#[derive(Clone, Debug, Display, derive_more::Error)] #[derive(Clone, Debug, derive_more::Error)]
#[display("row {row}, column {column}")]
pub struct CellPos { pub struct CellPos {
pub row: u32, pub row: u32,
pub column: u32, pub column: u32,
} }
fn format_column_index(index: u32) -> String {
// https://stackoverflow.com/a/297214
let quotient = index / 26;
let char = char::from((65 + (index % 26)) as u8);
if quotient > 0 {
return format!("{}{}", format_column_index(quotient - 1), char);
}
char.to_string()
}
impl Display for CellPos {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"column {}, row {}",
format_column_index(self.column),
self.row + 1,
))
}
}
pub struct CellRange { pub struct CellRange {
pub start: CellPos, pub start: CellPos,
pub end: CellPos, pub end: CellPos,

View File

@@ -1,12 +1,12 @@
use crate::extractors::base::FromRequestAsync; use crate::extractors::base::FromRequestAsync;
use crate::state::AppState; use crate::state::AppState;
use crate::utility::jwt; use crate::utility::req_auth;
use crate::utility::req_auth::get_claims_from_req;
use actix_macros::MiddlewareError; use actix_macros::MiddlewareError;
use actix_web::body::BoxBody; use actix_web::body::BoxBody;
use actix_web::dev::Payload; use actix_web::dev::Payload;
use actix_web::http::header;
use actix_web::{web, HttpRequest}; use actix_web::{web, HttpRequest};
use database::entity::User; use database::entity::{User, UserType};
use database::query::Query; use database::query::Query;
use derive_more::Display; use derive_more::Display;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -28,80 +28,53 @@ pub enum Error {
#[display("Invalid or expired access token")] #[display("Invalid or expired access token")]
InvalidAccessToken, InvalidAccessToken,
/// Default user is required.
#[display("Non-default user type is owning this access token")]
#[status_code = "actix_web::http::StatusCode::FORBIDDEN"]
NonDefaultUserType,
/// The user bound to the token is not found in the database. /// The user bound to the token is not found in the database.
#[display("No user associated with access token")] #[display("No user associated with access token")]
NoUser, NoUser,
/// User doesn't have required role.
#[display("You don't have sufficient rights")]
#[status_code = "actix_web::http::StatusCode::FORBIDDEN"]
InsufficientRights,
} }
impl Error { impl From<req_auth::Error> for Error {
pub fn into_err(self) -> actix_web::Error { fn from(value: req_auth::Error) -> Self {
actix_web::Error::from(self) match value {
req_auth::Error::NoHeaderOrCookieFound => Error::NoHeaderOrCookieFound,
req_auth::Error::UnknownAuthorizationType => Error::UnknownAuthorizationType,
req_auth::Error::InvalidAccessToken => Error::InvalidAccessToken,
} }
} }
fn get_access_token_from_header(req: &HttpRequest) -> Result<String, Error> {
let header_value = req
.headers()
.get(header::AUTHORIZATION)
.ok_or(Error::NoHeaderOrCookieFound)?
.to_str()
.map_err(|_| Error::NoHeaderOrCookieFound)?
.to_string();
let parts = header_value
.split_once(' ')
.ok_or(Error::UnknownAuthorizationType)?;
if parts.0 != "Bearer" {
Err(Error::UnknownAuthorizationType)
} else {
Ok(parts.1.to_string())
}
}
fn get_access_token_from_cookies(req: &HttpRequest) -> Result<String, Error> {
let cookie = req
.cookie("access_token")
.ok_or(Error::NoHeaderOrCookieFound)?;
Ok(cookie.value().to_string())
} }
/// User extractor from request with Bearer access token. /// User extractor from request with Bearer access token.
impl FromRequestAsync for User { impl FromRequestAsync for User {
type Error = actix_web::Error; type Error = Error;
async fn from_request_async( async fn from_request_async(
req: &HttpRequest, req: &HttpRequest,
_payload: &mut Payload, _payload: &mut Payload,
) -> Result<Self, Self::Error> { ) -> Result<Self, Self::Error> {
let access_token = match get_access_token_from_header(req) { let claims = get_claims_from_req(req).map_err(Error::from)?;
Err(Error::NoHeaderOrCookieFound) => {
get_access_token_from_cookies(req).map_err(|error| error.into_err())?
}
Err(error) => {
return Err(error.into_err());
}
Ok(access_token) => access_token,
};
let user_id = jwt::verify_and_decode(&access_token) if claims.user_type.unwrap_or(UserType::Default) != UserType::Default {
.map_err(|_| Error::InvalidAccessToken.into_err())?; return Err(Error::NonDefaultUserType);
}
let db = req let db = req
.app_data::<web::Data<AppState>>() .app_data::<web::Data<AppState>>()
.unwrap() .unwrap()
.get_database(); .get_database();
Query::find_user_by_id(db, &user_id) match Query::find_user_by_id(db, &claims.id).await {
.await Ok(Some(user)) => Ok(user),
.map_err(|_| Error::NoUser.into()) _ => Err(Error::NoUser),
.and_then(|user| { }
if let Some(user) = user {
Ok(user)
} else {
Err(actix_web::Error::from(Error::NoUser))
}
})
} }
} }

View File

@@ -1,8 +1,9 @@
use crate::middlewares::authorization::JWTAuthorization; use crate::middlewares::authorization::{JWTAuthorizationBuilder, ServiceConfig};
use crate::middlewares::content_type::ContentTypeBootstrap; use crate::middlewares::content_type::ContentTypeBootstrap;
use crate::state::{new_app_state, AppState}; use crate::state::{new_app_state, AppState};
use actix_web::dev::{ServiceFactory, ServiceRequest}; use actix_web::dev::{ServiceFactory, ServiceRequest};
use actix_web::{App, Error, HttpServer}; use actix_web::{App, Error, HttpServer};
use database::entity::sea_orm_active_enums::UserRole;
use dotenvy::dotenv; use dotenvy::dotenv;
use log::info; use log::info;
use std::io; use std::io;
@@ -26,6 +27,22 @@ pub fn get_api_scope<
>( >(
scope: I, scope: I,
) -> Scope<T> { ) -> Scope<T> {
let admin_scope = {
let service_user_scope =
utoipa_actix_web::scope("/service-users").service(routes::admin::service_users::create);
utoipa_actix_web::scope("/admin")
.wrap(
JWTAuthorizationBuilder::new()
.with_default(Some(ServiceConfig {
allow_service: false,
user_roles: Some(&[UserRole::Admin]),
}))
.build(),
)
.service(service_user_scope)
};
let auth_scope = utoipa_actix_web::scope("/auth") let auth_scope = utoipa_actix_web::scope("/auth")
.service(routes::auth::sign_in) .service(routes::auth::sign_in)
.service(routes::auth::sign_in_vk) .service(routes::auth::sign_in_vk)
@@ -33,26 +50,64 @@ pub fn get_api_scope<
.service(routes::auth::sign_up_vk); .service(routes::auth::sign_up_vk);
let users_scope = utoipa_actix_web::scope("/users") let users_scope = utoipa_actix_web::scope("/users")
.wrap(JWTAuthorization::default()) .wrap(
JWTAuthorizationBuilder::new()
.add_paths(
["/by/id/{id}", "/by/telegram-id/{id}"],
Some(ServiceConfig {
allow_service: true,
user_roles: Some(&[UserRole::Admin]),
}),
)
.build(),
)
.service(
utoipa_actix_web::scope("/by")
.service(routes::users::by::by_id)
.service(routes::users::by::by_telegram_id),
)
.service(routes::users::change_group) .service(routes::users::change_group)
.service(routes::users::change_username) .service(routes::users::change_username)
.service(routes::users::me); .service(routes::users::me);
let schedule_scope = utoipa_actix_web::scope("/schedule") let schedule_scope = utoipa_actix_web::scope("/schedule")
.wrap(JWTAuthorization { .wrap(
ignore: &["/group-names", "/teacher-names"], JWTAuthorizationBuilder::new()
}) .with_default(Some(ServiceConfig {
.service(routes::schedule::schedule) allow_service: true,
user_roles: None,
}))
.add_paths(["/group-names", "/teacher-names"], None)
.add_paths(
["/"],
Some(ServiceConfig {
allow_service: true,
user_roles: Some(&[UserRole::Admin]),
}),
)
.add_paths(
["/group"],
Some(ServiceConfig {
allow_service: false,
user_roles: None,
}),
)
.build(),
)
.service(routes::schedule::cache_status) .service(routes::schedule::cache_status)
.service(routes::schedule::schedule)
.service(routes::schedule::group) .service(routes::schedule::group)
.service(routes::schedule::group_by_name)
.service(routes::schedule::group_names) .service(routes::schedule::group_names)
.service(routes::schedule::teacher) .service(routes::schedule::teacher)
.service(routes::schedule::teacher_names); .service(routes::schedule::teacher_names);
let flow_scope = utoipa_actix_web::scope("/flow") let flow_scope = utoipa_actix_web::scope("/flow")
.wrap(JWTAuthorization { .wrap(
ignore: &["/telegram-auth"], JWTAuthorizationBuilder::new()
}) .add_paths(["/telegram-auth"], None)
.build(),
)
.service(routes::flow::telegram_auth) .service(routes::flow::telegram_auth)
.service(routes::flow::telegram_complete); .service(routes::flow::telegram_complete);
@@ -60,6 +115,7 @@ pub fn get_api_scope<
.service(routes::vk_id::oauth); .service(routes::vk_id::oauth);
utoipa_actix_web::scope(scope) utoipa_actix_web::scope(scope)
.service(admin_scope)
.service(auth_scope) .service(auth_scope)
.service(users_scope) .service(users_scope)
.service(schedule_scope) .service(schedule_scope)

View File

@@ -1,18 +1,68 @@
use crate::extractors::authorized_user; use crate::extractors::authorized_user;
use crate::extractors::base::FromRequestAsync; use crate::state::AppState;
use crate::utility::req_auth::get_claims_from_req;
use actix_web::body::{BoxBody, EitherBody}; use actix_web::body::{BoxBody, EitherBody};
use actix_web::dev::{forward_ready, Payload, Service, ServiceRequest, ServiceResponse, Transform}; use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{Error, HttpRequest, ResponseError}; use actix_web::{web, Error, HttpRequest, ResponseError};
use database::entity::User; use database::entity::sea_orm_active_enums::UserRole;
use database::entity::UserType;
use database::query::Query;
use futures_util::future::LocalBoxFuture; use futures_util::future::LocalBoxFuture;
use std::future::{ready, Ready}; use std::future::{ready, Ready};
use std::ops::Deref;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
#[derive(Default, Clone)]
pub struct ServiceConfig {
/// Allow service users to access endpoints.
pub allow_service: bool,
/// List of required roles to access endpoints.
pub user_roles: Option<&'static [UserRole]>,
}
type ServiceKV = (Arc<[&'static str]>, Option<ServiceConfig>);
pub struct JWTAuthorizationBuilder {
pub default_config: Option<ServiceConfig>,
pub path_configs: Vec<ServiceKV>,
}
impl JWTAuthorizationBuilder {
pub fn new() -> Self {
JWTAuthorizationBuilder {
default_config: Some(ServiceConfig::default()),
path_configs: vec![],
}
}
pub fn with_default(mut self, default: Option<ServiceConfig>) -> Self {
self.default_config = default;
self
}
pub fn add_paths(
mut self,
paths: impl AsRef<[&'static str]>,
config: Option<ServiceConfig>,
) -> Self {
self.path_configs.push((Arc::from(paths.as_ref()), config));
self
}
pub fn build(self) -> JWTAuthorization {
JWTAuthorization {
default_config: Arc::new(self.default_config),
path_configs: Arc::from(self.path_configs),
}
}
}
/// Middleware guard working with JWT tokens. /// Middleware guard working with JWT tokens.
#[derive(Default)]
pub struct JWTAuthorization { pub struct JWTAuthorization {
/// List of ignored endpoints. pub default_config: Arc<Option<ServiceConfig>>,
pub ignore: &'static [&'static str], pub path_configs: Arc<[ServiceKV]>,
} }
impl<S, B> Transform<S, ServiceRequest> for JWTAuthorization impl<S, B> Transform<S, ServiceRequest> for JWTAuthorization
@@ -30,15 +80,17 @@ where
fn new_transform(&self, service: S) -> Self::Future { fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JWTAuthorizationMiddleware { ready(Ok(JWTAuthorizationMiddleware {
service: Rc::new(service), service: Rc::new(service),
ignore: self.ignore, default_config: self.default_config.clone(),
path_configs: self.path_configs.clone(),
})) }))
} }
} }
pub struct JWTAuthorizationMiddleware<S> { pub struct JWTAuthorizationMiddleware<S> {
service: Rc<S>, service: Rc<S>,
/// List of ignored endpoints.
ignore: &'static [&'static str], default_config: Arc<Option<ServiceConfig>>,
path_configs: Arc<[ServiceKV]>,
} }
impl<S, B> JWTAuthorizationMiddleware<S> impl<S, B> JWTAuthorizationMiddleware<S>
@@ -48,29 +100,68 @@ where
B: 'static, B: 'static,
{ {
/// Checking the validity of the token. /// Checking the validity of the token.
async fn check_authorization(req: &HttpRequest) -> Result<(), authorized_user::Error> { async fn check_authorization(
let mut payload = Payload::None; req: &HttpRequest,
allow_service_user: bool,
required_user_roles: Option<&'static [UserRole]>,
) -> Result<(), authorized_user::Error> {
let claims = get_claims_from_req(req).map_err(authorized_user::Error::from)?;
User::from_request_async(req, &mut payload) let db = req
.await .app_data::<web::Data<AppState>>()
.map(|_| ()) .unwrap()
.map_err(|e| e.as_error::<authorized_user::Error>().unwrap().clone()) .get_database();
let user_type = claims.user_type.unwrap_or(UserType::Default);
match user_type {
UserType::Default => {
if let Some(required_user_roles) = required_user_roles {
let Ok(Some(user)) = Query::find_user_by_id(db, &claims.id).await else {
return Err(authorized_user::Error::NoUser);
};
if !required_user_roles.contains(&user.role) {
return Err(authorized_user::Error::InsufficientRights);
} }
fn should_skip(&self, req: &ServiceRequest) -> bool { return Ok(());
let path = req.match_info().unprocessed();
self.ignore.iter().any(|ignore| {
if !path.starts_with(ignore) {
return false;
} }
if let Some(other) = path.as_bytes().get(ignore.len()) { match Query::is_user_exists_by_id(db, &claims.id).await {
return [b'?', b'/'].contains(other); Ok(true) => Ok(()),
_ => Err(authorized_user::Error::NoUser),
}
}
UserType::Service => {
if !allow_service_user {
return Err(authorized_user::Error::NonDefaultUserType);
} }
true match Query::is_service_user_exists_by_id(db, &claims.id).await {
}) Ok(true) => Ok(()),
_ => Err(authorized_user::Error::NoUser),
}
}
}
}
fn find_config(
current_path: &str,
per_route: &[ServiceKV],
default: &Option<ServiceConfig>,
) -> Option<ServiceConfig> {
for (service_paths, config) in per_route {
for service_path in service_paths.deref() {
if !service_path.eq(&current_path) {
continue;
}
return config.clone();
}
}
default.clone()
} }
} }
@@ -87,15 +178,33 @@ where
forward_ready!(service); forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future { fn call(&self, req: ServiceRequest) -> Self::Future {
if self.should_skip(&req) {
let fut = self.service.call(req);
return Box::pin(async move { Ok(fut.await?.map_into_left_body()) });
}
let service = Rc::clone(&self.service); let service = Rc::clone(&self.service);
let match_info = req.match_info();
let path = if let Some(pattern) = req.match_pattern() {
let scope_start_idx = match_info
.as_str()
.find(match_info.unprocessed())
.unwrap_or(0);
pattern.as_str().split_at(scope_start_idx).1.to_owned()
} else {
match_info.unprocessed().to_owned()
};
let Some(config) = Self::find_config(&path, &self.path_configs, &self.default_config)
else {
let fut = self.service.call(req);
return Box::pin(async move { Ok(fut.await?.map_into_left_body()) });
};
let allow_service_user = config.allow_service;
let required_user_roles = config.user_roles;
Box::pin(async move { Box::pin(async move {
match Self::check_authorization(req.request()).await { match Self::check_authorization(req.request(), allow_service_user, required_user_roles)
.await
{
Ok(_) => { Ok(_) => {
let fut = service.call(req).await?; let fut = service.call(req).await?;
Ok(fut.map_into_left_body()) Ok(fut.map_into_left_body())

1
src/routes/admin/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod service_users;

View File

@@ -0,0 +1,75 @@
use self::schema::*;
use crate::{utility, AppState};
use actix_web::{post, web};
use database::entity::{ActiveServiceUser, UserType};
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, Set};
use objectid::ObjectId;
use web::Json;
#[utoipa::path(responses(
(status = OK, body = Response),
))]
#[post("/create")]
pub async fn create(data_json: Json<Request>, app_state: web::Data<AppState>) -> ServiceResponse {
let service_user =
match Query::find_service_user_by_id(app_state.get_database(), &data_json.name)
.await
.expect("Failed to find service user by name")
{
Some(_) => return Err(ErrorCode::AlreadyExists).into(),
None => {
let new_user = ActiveServiceUser {
id: Set(ObjectId::new().unwrap().to_string()),
name: Set(data_json.name.clone()),
};
new_user
.insert(app_state.get_database())
.await
.expect("Failed to insert service user")
}
};
let access_token = utility::jwt::encode(UserType::Service, &service_user.id);
Ok(Response::new(access_token)).into()
}
mod schema {
use actix_macros::{ErrResponse, OkResponse};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Debug, Deserialize, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
#[schema(as = ServiceUser::Create::Request)]
pub struct Request {
/// Service username.
pub name: String,
}
#[derive(Serialize, ToSchema, OkResponse)]
#[serde(rename_all = "camelCase")]
#[schema(as = ServiceUser::Create::Response)]
pub struct Response {
access_token: String,
}
impl Response {
pub fn new(access_token: String) -> Self {
Self { access_token }
}
}
pub type ServiceResponse = crate::routes::schema::Response<Response, ErrorCode>;
#[derive(Clone, ToSchema, Display, ErrResponse, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[status_code = "actix_web::http::StatusCode::UNAUTHORIZED"]
#[schema(as = ServiceUser::Create::ErrorCode)]
pub enum ErrorCode {
#[display("Service user with that name already exists.")]
AlreadyExists,
}
}

View File

@@ -0,0 +1,3 @@
mod create;
pub use create::*;

View File

@@ -7,6 +7,7 @@ use crate::{utility, AppState};
use actix_web::{post, web}; use actix_web::{post, web};
use database::query::Query; use database::query::Query;
use web::Json; use web::Json;
use database::entity::UserType;
async fn sign_in_combined( async fn sign_in_combined(
data: SignInData, data: SignInData,
@@ -40,7 +41,7 @@ async fn sign_in_combined(
} }
} }
let access_token = utility::jwt::encode(&user.id); let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(UserResponse::from_user_with_token(user, access_token)) Ok(UserResponse::from_user_with_token(user, access_token))
} }
@@ -184,9 +185,7 @@ mod tests {
let active_user = ActiveUser { let active_user = ActiveUser {
id: Set(id.clone()), id: Set(id.clone()),
username: Set(username), username: Set(username),
password: Set(Some( password: Set(Some(bcrypt::hash("example", bcrypt::DEFAULT_COST).unwrap())),
bcrypt::hash("example", bcrypt::DEFAULT_COST).unwrap(),
)),
vk_id: Set(None), vk_id: Set(None),
telegram_id: Set(None), telegram_id: Set(None),
group: Set(Some("ИС-214/23".to_string())), group: Set(Some("ИС-214/23".to_string())),

View File

@@ -5,7 +5,7 @@ use crate::routes::schema::ResponseError;
use crate::{utility, AppState}; use crate::{utility, AppState};
use actix_web::{post, web}; use actix_web::{post, web};
use database::entity::sea_orm_active_enums::UserRole; use database::entity::sea_orm_active_enums::UserRole;
use database::entity::ActiveUser; use database::entity::{ActiveUser, UserType};
use database::query::Query; use database::query::Query;
use database::sea_orm::ActiveModelTrait; use database::sea_orm::ActiveModelTrait;
use web::Json; use web::Json;
@@ -51,7 +51,7 @@ async fn sign_up_combined(
let active_user: ActiveUser = data.into(); let active_user: ActiveUser = data.into();
let user = active_user.insert(db).await.unwrap(); let user = active_user.insert(db).await.unwrap();
let access_token = utility::jwt::encode(&user.id); let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(UserResponse::from_user_with_token(user, access_token)) Ok(UserResponse::from_user_with_token(user, access_token))
} }

View File

@@ -5,7 +5,7 @@ use crate::{utility, AppState};
use actix_web::{post, web}; use actix_web::{post, web};
use chrono::{DateTime, Duration, Utc}; use chrono::{DateTime, Duration, Utc};
use database::entity::sea_orm_active_enums::UserRole; use database::entity::sea_orm_active_enums::UserRole;
use database::entity::ActiveUser; use database::entity::{ActiveUser, UserType};
use database::query::Query; use database::query::Query;
use database::sea_orm::{ActiveModelTrait, Set}; use database::sea_orm::{ActiveModelTrait, Set};
use objectid::ObjectId; use objectid::ObjectId;
@@ -73,7 +73,7 @@ pub async fn telegram_auth(
} }
}; };
let access_token = utility::jwt::encode(&user.id); let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(Response::new(&access_token, user.group.is_some())).into() Ok(Response::new(&access_token, user.group.is_some())).into()
} }

View File

@@ -58,10 +58,7 @@ pub async fn telegram_complete(
active_user.group = Set(Some(data.group)); active_user.group = Set(Some(data.group));
active_user active_user.update(db).await.expect("Failed to update user");
.update(db)
.await
.expect("Failed to update user");
Ok(()).into() Ok(()).into()
} }

View File

@@ -1,3 +1,4 @@
pub mod admin;
pub mod auth; pub mod auth;
pub mod flow; pub mod flow;
pub mod schedule; pub mod schedule;

View File

@@ -0,0 +1,65 @@
use self::schema::*;
use crate::routes::schedule::schema::ScheduleEntryResponse;
use crate::routes::schema::ResponseError;
use crate::AppState;
use actix_web::{get, web};
#[utoipa::path(responses(
(status = OK, body = ScheduleEntryResponse),
(
status = SERVICE_UNAVAILABLE,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NO_SCHEDULE",
"message": "Schedule not parsed yet."
})
),
(
status = NOT_FOUND,
body = ResponseError<ErrorCode>,
example = json!({
"code": "NOT_FOUND",
"message": "Required group not found."
})
),
))]
#[get("/group/{group_name}")]
pub async fn group_by_name(
path: web::Path<String>,
app_state: web::Data<AppState>,
) -> ServiceResponse {
let group_name = path.into_inner();
match app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.data
.groups
.get(&group_name)
{
None => Err(ErrorCode::NotFound),
Some(entry) => Ok(entry.clone().into()),
}
.into()
}
mod schema {
use crate::routes::schedule::schema::ScheduleEntryResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<ScheduleEntryResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = GroupByNameSchedule::ErrorCode)]
pub enum ErrorCode {
/// Group not found.
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required group not found.")]
NotFound,
}
}

View File

@@ -1,5 +1,6 @@
mod cache_status; mod cache_status;
mod group; mod group;
mod group_by_name;
mod group_names; mod group_names;
mod get; mod get;
mod schema; mod schema;
@@ -8,6 +9,7 @@ mod teacher_names;
pub use cache_status::*; pub use cache_status::*;
pub use group::*; pub use group::*;
pub use group_by_name::*;
pub use group_names::*; pub use group_names::*;
pub use get::*; pub use get::*;
pub use teacher::*; pub use teacher::*;

View File

@@ -163,6 +163,7 @@ pub mod user {
#[schema(examples( #[schema(examples(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY" "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY"
))] ))]
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>, pub access_token: Option<String>,
} }

56
src/routes/users/by.rs Normal file
View File

@@ -0,0 +1,56 @@
use crate::routes::schema::user::UserResponse;
use crate::routes::users::by::schema::{ErrorCode, ServiceResponse};
use crate::state::AppState;
use actix_web::{get, web};
use database::query::Query;
#[utoipa::path(responses((status = OK, body = UserResponse)))]
#[get("/id/{id}")]
pub async fn by_id(app_state: web::Data<AppState>, path: web::Path<String>) -> ServiceResponse {
let user_id = path.into_inner();
let db = app_state.get_database();
match Query::find_user_by_id(db, &user_id).await {
Ok(Some(user)) => Ok(UserResponse::from(user)),
_ => Err(ErrorCode::NotFound),
}
.into()
}
#[utoipa::path(responses((status = OK, body = UserResponse)))]
#[get("/telegram-id/{id}")]
pub async fn by_telegram_id(
app_state: web::Data<AppState>,
path: web::Path<i64>,
) -> ServiceResponse {
let telegram_id = path.into_inner();
let db = app_state.get_database();
match Query::find_user_by_telegram_id(db, telegram_id).await {
Ok(Some(user)) => Ok(UserResponse::from(user)),
_ => Err(ErrorCode::NotFound),
}
.into()
}
mod schema {
use crate::routes::schema::user::UserResponse;
use actix_macros::ErrResponse;
use derive_more::Display;
use serde::Serialize;
use utoipa::ToSchema;
pub type ServiceResponse = crate::routes::schema::Response<UserResponse, ErrorCode>;
#[derive(Clone, Serialize, Display, ToSchema, ErrResponse)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = Users::By::ErrorCode)]
pub enum ErrorCode {
/// User not found.
#[status_code = "actix_web::http::StatusCode::NOT_FOUND"]
#[display("Required user not found.")]
NotFound,
}
}

View File

@@ -1,3 +1,4 @@
pub mod by;
mod change_group; mod change_group;
mod change_username; mod change_username;
mod me; mod me;

View File

@@ -1,13 +1,14 @@
use chrono::Duration; use chrono::Duration;
use chrono::Utc; use chrono::Utc;
use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode}; use jsonwebtoken::{decode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as; use serde_with::serde_as;
use serde_with::DisplayFromStr;
use std::env; use std::env;
use std::mem::discriminant; use std::mem::discriminant;
use std::sync::LazyLock; use std::sync::LazyLock;
use database::entity::UserType;
/// Key for token verification. /// Key for token verification.
static DECODING_KEY: LazyLock<DecodingKey> = LazyLock::new(|| { static DECODING_KEY: LazyLock<DecodingKey> = LazyLock::new(|| {
@@ -42,27 +43,31 @@ impl PartialEq for Error {
} }
} }
/// The data the token holds. /// The data the token holds.
#[serde_as] #[serde_as]
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct Claims { pub struct Claims {
/// User account UUID. /// User account UUID.
id: String, pub id: String,
/// User type.
pub user_type: Option<UserType>,
/// Token creation date. /// Token creation date.
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
iat: u64, pub iat: u64,
/// Token expiry date. /// Token expiry date.
#[serde_as(as = "DisplayFromStr")] #[serde_as(as = "DisplayFromStr")]
exp: u64, pub exp: u64,
} }
/// Token signing algorithm. /// Token signing algorithm.
pub(crate) const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256; pub(crate) const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256;
/// Checking the token and extracting the UUID of the user account from it. /// Checking the token and extracting the UUID of the user account from it.
pub fn verify_and_decode(token: &str) -> Result<String, Error> { pub fn verify_and_decode(token: &str) -> Result<Claims, Error> {
let mut validation = Validation::new(DEFAULT_ALGORITHM); let mut validation = Validation::new(DEFAULT_ALGORITHM);
validation.required_spec_claims.remove("exp"); validation.required_spec_claims.remove("exp");
@@ -75,7 +80,7 @@ pub fn verify_and_decode(token: &str) -> Result<String, Error> {
if token_data.claims.exp < Utc::now().timestamp().unsigned_abs() { if token_data.claims.exp < Utc::now().timestamp().unsigned_abs() {
Err(Error::Expired) Err(Error::Expired)
} else { } else {
Ok(token_data.claims.id) Ok(token_data.claims)
} }
} }
Err(err) => Err(match err.into_kind() { Err(err) => Err(match err.into_kind() {
@@ -87,7 +92,7 @@ pub fn verify_and_decode(token: &str) -> Result<String, Error> {
} }
/// Creating a user token. /// Creating a user token.
pub fn encode(id: &str) -> String { pub fn encode(user_type: UserType, id: &str) -> String {
let header = Header { let header = Header {
typ: Some(String::from("JWT")), typ: Some(String::from("JWT")),
..Default::default() ..Default::default()
@@ -98,6 +103,7 @@ pub fn encode(id: &str) -> String {
let claims = Claims { let claims = Claims {
id: id.to_string(), id: id.to_string(),
user_type: Some(user_type),
iat: iat.timestamp().unsigned_abs(), iat: iat.timestamp().unsigned_abs(),
exp: exp.timestamp().unsigned_abs(), exp: exp.timestamp().unsigned_abs(),
}; };
@@ -114,7 +120,7 @@ mod tests {
fn test_encode() { fn test_encode() {
test_env(); test_env();
assert!(!encode("test").is_empty()); assert!(!encode(UserType::Default, "test").is_empty());
} }
#[test] #[test]
@@ -125,10 +131,7 @@ mod tests {
let result = verify_and_decode(&token); let result = verify_and_decode(&token);
assert!(result.is_err()); assert!(result.is_err());
assert_eq!( assert_eq!(result.err().unwrap(), Error::InvalidToken);
result.err().unwrap(),
Error::InvalidToken
);
} }
//noinspection SpellCheckingInspection //noinspection SpellCheckingInspection

View File

@@ -1,2 +1,3 @@
pub mod jwt; pub mod jwt;
pub mod telegram; pub mod telegram;
pub mod req_auth;

56
src/utility/req_auth.rs Normal file
View File

@@ -0,0 +1,56 @@
use crate::utility::jwt;
use crate::utility::jwt::Claims;
use actix_web::http::header;
use actix_web::HttpRequest;
#[derive(Debug, PartialEq)]
pub enum Error {
/// There is no Authorization header or cookie in the request.
NoHeaderOrCookieFound,
/// Unknown authorization type other than Bearer.
UnknownAuthorizationType,
/// Invalid or expired access token.
InvalidAccessToken,
}
pub fn get_access_token_from_header(req: &HttpRequest) -> Result<String, Error> {
let header_value = req
.headers()
.get(header::AUTHORIZATION)
.ok_or(Error::NoHeaderOrCookieFound)?
.to_str()
.map_err(|_| Error::NoHeaderOrCookieFound)?
.to_string();
let parts = header_value
.split_once(' ')
.ok_or(Error::UnknownAuthorizationType)?;
if parts.0 != "Bearer" {
Err(Error::UnknownAuthorizationType)
} else {
Ok(parts.1.to_string())
}
}
pub fn get_access_token_from_cookies(req: &HttpRequest) -> Result<String, Error> {
let cookie = req
.cookie("access_token")
.ok_or(Error::NoHeaderOrCookieFound)?;
Ok(cookie.value().to_string())
}
pub fn get_claims_from_req(req: &HttpRequest) -> Result<Claims, Error> {
let access_token = match get_access_token_from_header(req) {
Err(Error::NoHeaderOrCookieFound) => get_access_token_from_cookies(req)?,
Err(error) => {
return Err(error);
}
Ok(access_token) => access_token,
};
jwt::verify_and_decode(&access_token).map_err(|_| Error::InvalidAccessToken)
}