Compare commits

..
49 Commits
Author SHA1 Message Date
n08i40k 91193944ab chore(ci): disable sentry debug-symbols upload 2026-09-04 23:33:04 +04:00
n08i40k 384149ae38 fix(tests): use local sqlite for tests and adapt test after jwt library update 2026-09-04 23:23:35 +04:00
n08i40k 50a580b07c feat(downloader): download schedule from different source 2026-09-04 22:54:18 +04:00
n08i40k 3204c41fd5 chore: bump version of dependencies 2026-09-04 22:53:04 +04:00
n08i40k 2442641479 feat(ci): auto-deploy new version after building docker image 2025-10-29 02:31:13 +04:00
n08i40k ac16c96e5e chore(schedule): add lesson type 'differentiated exam' 2025-10-29 02:16:25 +04:00
n08i40k 622464e4c3 feat(users): add endpoints for getting user by ids 2025-10-28 22:33:49 +04:00
n08i40k 39c60ef939 feat(middleware): add support of path patterns 2025-10-28 22:33:10 +04:00
n08i40k d1ef5c032e feat: implement service users 2025-10-28 06:53:31 +04:00
n08i40k b635750e28 feat(db): add service users table 2025-10-28 06:46:30 +04:00
n08i40k a59fff927d chore(deps): update dependencies 2025-10-28 06:45:55 +04:00
n08i40k cdc89b5bcd fix(parser): fix sentry error sending 2025-10-10 03:00:47 +04:00
n08i40k 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
n08i40k 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
n08i40k df0e99a4d0 feat(parser): make lesson cell range less strict to support upcoming split-lessons 2025-10-10 01:31:55 +04:00
n08i40k a8cf8fb0f5 feat(parser): improve street regex 2025-10-10 01:30:56 +04:00
n08i40k 7ed866138e feat(error): add error for unknown lesson type 2025-10-10 01:30:30 +04:00
n08i40k 7bac48f8fc feat(error): add more intuitive CellPos formatting and get rid of ErrorCell 2025-10-10 01:27:05 +04:00
n08i40k 191ec36fef chore: remove useless commented code 2025-10-10 01:25:12 +04:00
n08i40k f121a04f1b refactor: refactor providers code 2025-10-02 07:55:07 +04:00
n08i40k df74ab03a1 chore(ci): make building non-verbose 2025-10-02 07:54:09 +04:00
n08i40k 1b79d1cf1e chore: bump provider version to 0.2.2 2025-10-02 07:44:35 +04:00
n08i40k 2b9b1ea66b chore(deps): update dependencies 2025-10-02 07:43:37 +04:00
n08i40k ca713d8d51 refactor: prevent updater stop because of errors 2025-10-02 07:40:44 +04:00
n08i40k 69df538467 refactor(updater): don't cancel token when uri fetch error occurred 2025-09-29 08:27:25 +04:00
n08i40k aa019f8fcf fix(parser): rework teacher name parsing 2025-09-29 08:27:24 +04:00
n08i40k b664ba578d chore(clippy): fix all clippy warnings 2025-09-25 03:42:34 +04:00
n08i40k 983967f8b0 chore(downloader): suppress unused_assignments warning 2025-09-25 03:27:55 +04:00
n08i40k e5760120e2 chore(release): bump version to 1.3.0 2025-09-25 03:17:14 +04:00
n08i40k a28fb66dd4 feat(downloader): add retry-mechanism for querying uri from yandex-cloud api (#18) 2025-09-25 03:15:36 +04:00
n08i40k 3780fb3136 feat(downloader): implement etag-based difference check for schedule 2025-09-25 03:14:39 +04:00
n08i40k 6c71bc19f5 chore(parser): fix crash caused by another mistype in schedule 2025-09-25 02:50:43 +04:00
n08i40k 2d0041dc8b feat(schedule): add practice lesson type 2025-09-25 02:49:23 +04:00
n08i40k b5d372e109 feat(ci): build and push image to docker registry on every push to master 2025-09-10 20:05:11 +04:00
n08i40k 84dca02c34 fix(database): use migrator and change connection options 2025-09-10 20:04:19 +04:00
n08i40k 6c9d3b3b31 chore(release): bump version to 1.2.2 2025-09-08 07:16:53 +04:00
n08i40k a348b1b99b refactor(auth): interpret failure to find user as an error 2025-09-08 07:15:22 +04:00
n08i40k ff12ee5da2 chore(release): bump version to 1.2.1 2025-09-06 21:25:26 +04:00
n08i40k 35f707901f chore(clippy): fix all clippy warnings 2025-09-06 21:24:52 +04:00
n08i40k edea6c5424 chore(release): bump version to 1.2.0 2025-09-06 20:33:17 +04:00
n08i40k fdbb872fc3 refactor(dev): move tracing to feature 2025-09-06 20:17:14 +04:00
n08i40k dbc800fef1 feat(database)!: switch from diesel to sea-orm 2025-09-06 20:09:04 +04:00
n08i40k e729d84c93 fix: fix typo in error description 2025-09-06 18:42:23 +04:00
n08i40k cc7adf10ed fix(env): make dotenv file optional 2025-09-06 18:40:04 +04:00
n08i40k 57c1699c9a chore(release): bump version to 1.1.1 2025-09-03 07:13:37 +04:00
n08i40k 298c4f4dd3 feat(auth): send raw token on telegram auth 2025-09-03 07:08:51 +04:00
n08i40k e3904a255b chore(release): bump version to 1.1.0 2025-09-02 09:23:29 +04:00
n08i40k 829c1cf68d chore(deps): bump calamine version 2025-09-02 09:20:02 +04:00
n08i40k 6a535f8d73 chore(ci): remove creating .env.test 2025-09-02 09:20:02 +04:00
98 changed files with 5405 additions and 2726 deletions
+1 -5
View File
@@ -1,5 +1,5 @@
# Schedule
# SCHEDULE_INIT_URL=
SCHEDULE_YANDEX_DISK_URL="https://disk.yandex.ru/d/xxxxxxxxxxxxxx"
SCHEDULE_DISABLE_AUTO_UPDATE=1
# Basic authorization
@@ -14,10 +14,6 @@ TELEGRAM_BOT_ID=0
TELEGRAM_MINI_APP_HOST=example.com
TELEGRAM_TEST_DC=false
# Yandex Cloud
YANDEX_CLOUD_API_KEY=""
YANDEX_CLOUD_FUNC_ID=""
# Firebase
# GOOGLE_APPLICATION_CREDENTIALS=
+124
View File
@@ -0,0 +1,124 @@
name: build
on:
push:
branches: [ "master" ]
tags-ignore: [ "release/v*" ]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
BINARY_NAME: schedule-parser-rusted
DOCKER_IMAGE_NAME: ${{ github.repository }}
DOCKER_REGISTRY_HOST: registry.n08i40k.ru
DOCKER_REGISTRY_USERNAME: ${{ github.repository_owner }}
DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
- name: Test
run: |
cargo test
env:
SCHEDULE_DISABLE_AUTO_UPDATE: 1
JWT_SECRET: "test-secret-at-least-256-bits-used"
VK_ID_CLIENT_ID: 0
VK_ID_REDIRECT_URI: "vk0://vk.com/blank.html"
TELEGRAM_BOT_ID: 0
TELEGRAM_MINI_APP_HOST: example.com
TELEGRAM_TEST_DC: false
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Rust
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
toolchain: stable
- name: Build
run: cargo build --release
- name: Extract debug symbols
run: |
objcopy --only-keep-debug target/release/${{ env.BINARY_NAME }}{,.d}
objcopy --strip-debug --strip-unneeded target/release/${{ env.BINARY_NAME }}
objcopy --add-gnu-debuglink target/release/${{ env.BINARY_NAME }}{.d,}
- name: Upload build binary artifact
uses: actions/upload-artifact@v4
with:
name: release-binary
path: target/release/${{ env.BINARY_NAME }}
- name: Upload build debug symbols artifact
uses: actions/upload-artifact@v4
with:
name: release-symbols
path: target/release/${{ env.BINARY_NAME }}.d
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: release-binary
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3.10.0
- name: Login to Registry
uses: docker/login-action@v3.4.0
with:
registry: ${{ env.DOCKER_REGISTRY_HOST }}
username: ${{ env.DOCKER_REGISTRY_USERNAME }}
password: ${{ env.DOCKER_REGISTRY_PASSWORD }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5.7.0
with:
images: ${{ env.DOCKER_REGISTRY_HOST }}/${{ env.DOCKER_IMAGE_NAME }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6.15.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
"BINARY_NAME=${{ env.BINARY_NAME }}"
- name: Deploy
run: curl ${{ secrets.DEPLOY_URL }}
-18
View File
@@ -14,10 +14,6 @@ env:
TEST_DB: ${{ secrets.TEST_DATABASE_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
DOCKER_IMAGE_NAME: ${{ github.repository }}
DOCKER_REGISTRY_HOST: registry.n08i40k.ru
@@ -50,8 +46,6 @@ jobs:
TELEGRAM_BOT_ID: 0
TELEGRAM_MINI_APP_HOST: example.com
TELEGRAM_TEST_DC: false
YANDEX_CLOUD_API_KEY: ""
YANDEX_CLOUD_FUNC_ID: ""
build:
name: Build
runs-on: ubuntu-latest
@@ -75,18 +69,6 @@ jobs:
objcopy --strip-debug --strip-unneeded target/release/${{ env.BINARY_NAME }}
objcopy --add-gnu-debuglink target/release/${{ env.BINARY_NAME }}{.d,}
- name: Setup sentry-cli
uses: matbour/setup-sentry-cli@v2.0.0
with:
version: latest
token: ${{ env.SENTRY_AUTH_TOKEN }}
organization: ${{ env.SENTRY_ORG }}
project: ${{ env.SENTRY_PROJECT }}
- name: Upload debug symbols to Sentry
run: |
sentry-cli debug-files upload --include-sources .
- name: Upload build binary artifact
uses: actions/upload-artifact@v4
with:
+3 -5
View File
@@ -2,7 +2,7 @@ name: cargo test
on:
push:
branches: [ "master" ]
branches: [ "development" ]
tags-ignore: [ "release/v*" ]
permissions:
@@ -18,10 +18,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build
- name: Create .env.test
run: touch .env.test
- name: Run tests
run: cargo test
env:
@@ -33,5 +33,3 @@ jobs:
TELEGRAM_BOT_ID: 0
TELEGRAM_MINI_APP_HOST: example.com
TELEGRAM_TEST_DC: false
YANDEX_CLOUD_API_KEY: ""
YANDEX_CLOUD_FUNC_ID: ""
Generated
+3242 -1005
View File
File diff suppressed because it is too large Load Diff
+42 -25
View File
@@ -3,13 +3,16 @@ members = ["actix-macros", "actix-test", "providers"]
[package]
name = "schedule-parser-rusted"
version = "1.0.5"
version = "1.3.1"
edition = "2024"
publish = false
[profile.release]
debug = true
[features]
trace = ["tracing", "console-subscriber"]
[dependencies]
providers = { path = "providers" }
actix-macros = { path = "actix-macros" }
@@ -18,58 +21,72 @@ actix-macros = { path = "actix-macros" }
actix-web = "4"
# basic
chrono = { version = "0.4.40", features = ["serde"] }
chrono = { version = "0", features = ["serde"] }
derive_more = { version = "2", features = ["full"] }
dotenvy = "0.15.7"
dotenvy = "0"
# sql
diesel = { version = "2.2.8", features = ["postgres"] }
diesel-derive-enum = { git = "https://github.com/Havunen/diesel-derive-enum.git", features = ["postgres"] }
database = { path = "database" }
# logging
env_logger = "0.11.7"
env_logger = "0"
# async
tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0.7.16"
futures-util = "0.3.31"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio-util = "0"
futures-util = "0"
# authorization
bcrypt = "0.17.0"
jsonwebtoken = { version = "9.3.1", features = ["use_pem"] }
bcrypt = "0"
jsonwebtoken = { version = "11", features = ["use_pem", "aws_lc_rs"] }
# creating users
objectid = "0.2.0"
objectid = "0"
# schedule downloader
reqwest = { version = "0.12.15", features = ["json"] }
mime = "0.3.17"
reqwest = { version = "0", features = ["json", "form"] }
mime = "0"
# error handling
sentry = "0.42.0"
sentry-actix = "0.42.0"
sentry = { version = "0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"logs",
"metrics",
"panic",
"release-health",
"reqwest",
"rustls",
] }
sentry-actix = "0"
# [de]serializing
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_with = "3.12.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_with = "3"
sha1 = "0.11.0-rc.0"
sha1 = "0.11.0-rc.2"
# documentation
utoipa = { version = "5", features = ["actix_extras", "chrono"] }
utoipa-rapidoc = { version = "6", features = ["actix-web"] }
utoipa-actix-web = "0.1"
utoipa-actix-web = "0"
uuid = { version = "1", features = ["v4"] }
hex-literal = "1"
log = "0.4"
log = "0"
# telegram webdata deciding and verify
base64 = "0.22"
percent-encoding = "2.3"
ed25519-dalek = "2"
base64 = "0"
percent-encoding = "2"
aws-lc-rs = "1"
# development tracing
console-subscriber = { version = "0", optional = true }
tracing = { version = "0", optional = true }
[dev-dependencies]
providers = { path = "providers", features = ["test"] }
database = { path = "database", features = ["sqlite"] }
actix-test = { path = "actix-test" }
+1 -1
View File
@@ -6,7 +6,7 @@ ARG BINARY_NAME
WORKDIR /app/
RUN apt update && \
apt install -y libpq5 ca-certificates openssl
apt install -y ca-certificates
COPY ./${BINARY_NAME} /bin/main
RUN chmod +x /bin/main
+3 -3
View File
@@ -4,9 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
syn = "2"
quote = "1"
proc-macro2 = "1"
syn = "2.0.106"
quote = "1.0.40"
proc-macro2 = "1.0.101"
[lib]
proc-macro = true
+2 -4
View File
@@ -6,7 +6,7 @@ mod shared {
use quote::{ToTokens, quote};
use syn::{Attribute, DeriveInput};
pub fn find_status_code(attrs: &Vec<Attribute>) -> Option<proc_macro2::TokenStream> {
pub fn find_status_code(attrs: &[Attribute]) -> Option<proc_macro2::TokenStream> {
attrs
.iter()
.find_map(|attr| -> Option<proc_macro2::TokenStream> {
@@ -41,14 +41,12 @@ mod shared {
let mut status_code_arms: Vec<proc_macro2::TokenStream> = variants
.iter()
.map(|v| -> Option<proc_macro2::TokenStream> {
.filter_map(|v| -> Option<proc_macro2::TokenStream> {
let status_code = find_status_code(&v.attrs)?;
let variant_name = &v.ident;
Some(quote! { #name::#variant_name => #status_code, })
})
.filter(|v| v.is_some())
.map(|v| v.unwrap())
.collect();
if status_code_arms.len() < variants.len() {
+2 -2
View File
@@ -4,5 +4,5 @@ version = "0.1.0"
edition = "2024"
[dependencies]
actix-http = "3"
actix-web = "4"
actix-http = "3.11.1"
actix-web = "4.11.0"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "database"
version = "0.1.0"
edition = "2024"
[features]
sqlite = ["sea-orm/sqlx-sqlite", "migration/sqlite"]
[dependencies]
migration = { path = "migration" }
entity = { path = "entity" }
sea-orm = { version = "2.0.0-rc.15", features = ["sqlx-postgres", "runtime-tokio"] }
paste = "1"
serde = { version = "1", features = ["derive"] }
+1
View File
@@ -0,0 +1 @@
/target
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2024"
[dependencies]
sea-orm = "2.0.0-rc.6"
serde = { version = "1.0.219", features = ["derive"] }
utoipa = "5.4.0"
+7
View File
@@ -0,0 +1,7 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
pub mod prelude;
pub mod sea_orm_active_enums;
pub mod service_user;
pub mod user;
+4
View File
@@ -0,0 +1,4 @@
//! `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;
@@ -0,0 +1,25 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
use sea_orm::entity::prelude::*;
#[derive(
Debug,
Clone,
PartialEq,
Eq,
EnumIter,
DeriveActiveEnum,
:: serde :: Serialize,
:: serde :: Deserialize,
:: utoipa :: ToSchema,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "user_role")]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum UserRole {
#[sea_orm(string_value = "student")]
Student,
#[sea_orm(string_value = "teacher")]
Teacher,
#[sea_orm(string_value = "admin")]
Admin,
}
+16
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 {}
+25
View File
@@ -0,0 +1,25 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.12
use super::sea_orm_active_enums::UserRole;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
#[sea_orm(unique)]
pub username: String,
pub password: Option<String>,
pub vk_id: Option<i32>,
pub group: Option<String>,
pub role: UserRole,
pub android_version: Option<String>,
#[sea_orm(unique)]
pub telegram_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
+1
View File
@@ -0,0 +1 @@
/target
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[features]
sqlite = ["sea-orm-migration/sqlx-sqlite"]
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "2.0.0-rc.15"
features = [
# 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.
# e.g.
"runtime-tokio", # `ASYNC_RUNTIME` feature
"sqlx-postgres", # `DATABASE_DRIVER` feature
]
+18
View File
@@ -0,0 +1,18 @@
pub use sea_orm_migration::prelude::MigratorTrait;
use sea_orm_migration::prelude::*;
mod m20250904_024854_init;
mod m20251027_230335_add_service_users;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20250904_024854_init::Migration),
Box::new(m20251027_230335_add_service_users::Migration),
]
}
}
@@ -0,0 +1,76 @@
use sea_orm_migration::prelude::extension::postgres::Type;
use sea_orm_migration::sea_orm::{DatabaseBackend, EnumIter, Iterable};
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> {
if manager.get_database_backend() == DatabaseBackend::Postgres {
manager
.create_type(
Type::create()
.as_enum(UserRole)
.values(UserRoleVariants::iter())
.to_owned(),
)
.await?;
}
manager
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(string_uniq(User::Id).primary_key().not_null())
.col(string_uniq(User::Username).not_null())
.col(string_null(User::Password))
.col(integer_null(User::VkId))
.col(string_null(User::Group))
.col(enumeration(User::Role, UserRole, UserRoleVariants::iter()))
.col(string_null(User::AndroidVersion))
.col(big_integer_null(User::TelegramId).unique_key())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await?;
if manager.get_database_backend() == DatabaseBackend::Postgres {
manager
.drop_type(Type::drop().name(UserRole).to_owned())
.await?;
}
Ok(())
}
}
#[derive(DeriveIden)]
struct UserRole;
#[derive(DeriveIden, EnumIter)]
enum UserRoleVariants {
Student,
Teacher,
Admin,
}
#[derive(DeriveIden)]
enum User {
Table,
Id,
Username,
Password,
VkId,
Group,
Role,
AndroidVersion,
TelegramId,
}
@@ -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,
}
+6
View File
@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}
+31
View File
@@ -0,0 +1,31 @@
pub mod query;
pub use migration;
pub use sea_orm;
pub mod entity {
use serde::{Deserialize, Serialize};
pub use entity::*;
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,
}
}
+73
View File
@@ -0,0 +1,73 @@
use paste::paste;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::QueryFilter;
pub struct Query;
macro_rules! ref_type {
(String) => {
&String
};
(str) => {
&str
};
($other:ty) => {
$other
};
}
macro_rules! define_is_exists {
($entity: ident, $by: ident, $by_type: ident, $by_column: ident) => {
paste! {
pub async fn [<is_ $entity _exists_by_ $by>](
db: &::sea_orm::DbConn,
$by: ref_type!($by_type)
) -> Result<bool, ::sea_orm::DbErr> {
::entity::$entity::Entity::find()
.filter(::entity::$entity::Column::$by_column.eq($by))
.one(db)
.await
.map(|x| x.is_some())
}
}
};
}
macro_rules! define_find_by {
($entity: ident, $by: ident, $by_type: ident, $by_column: ident) => {
paste! {
pub async fn [<find_ $entity _by_ $by>](
db: &::sea_orm::DbConn,
$by: ref_type!($by_type)
) -> Result<Option<::entity::$entity::Model>, ::sea_orm::DbErr> {
::entity::$entity::Entity::find()
.filter(::entity::$entity::Column::$by_column.eq($by))
.one(db)
.await
}
}
};
}
impl Query {
// User
define_find_by!(user, id, str, Id);
define_find_by!(user, telegram_id, i64, TelegramId);
define_find_by!(user, vk_id, i32, VkId);
define_find_by!(user, username, str, Username);
define_is_exists!(user, id, str, Id);
define_is_exists!(user, username, str, Username);
define_is_exists!(user, telegram_id, i64, TelegramId);
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);
}
-9
View File
@@ -1,9 +0,0 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/database/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId", "Clone"]
[migrations_directory]
dir = "./migrations"
View File
@@ -1,6 +0,0 @@
-- This file was automatically created by Diesel to set up helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
DROP FUNCTION IF EXISTS diesel_set_updated_at();
@@ -1,36 +0,0 @@
-- This file was automatically created by Diesel to set up helper functions
-- and other internal bookkeeping. This file is safe to edit, any future
-- changes will be added to existing projects as new migrations.
-- Sets up a trigger for the given table to automatically set a column called
-- `updated_at` whenever the row is modified (unless `updated_at` was included
-- in the modified columns)
--
-- # Example
--
-- ```sql
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
--
-- SELECT diesel_manage_updated_at('users');
-- ```
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
BEGIN
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
BEGIN
IF (
NEW IS DISTINCT FROM OLD AND
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
) THEN
NEW.updated_at := current_timestamp;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
@@ -1 +0,0 @@
DROP TYPE user_role;
@@ -1,4 +0,0 @@
CREATE TYPE user_role AS ENUM (
'STUDENT',
'TEACHER',
'ADMIN');
@@ -1 +0,0 @@
DROP TABLE users;
@@ -1,11 +0,0 @@
CREATE TABLE users
(
id text PRIMARY KEY NOT NULL,
username text UNIQUE NOT NULL,
password text NOT NULL,
vk_id int4 NULL,
access_token text UNIQUE NOT NULL,
"group" text NOT NULL,
role user_role NOT NULL,
version text NOT NULL
);
@@ -1 +0,0 @@
DROP TABLE fcm;
@@ -1,6 +0,0 @@
CREATE TABLE fcm
(
user_id text PRIMARY KEY NOT NULL REFERENCES users (id),
token text NOT NULL,
topics text[] NOT NULL CHECK ( array_position(topics, null) is null )
);
@@ -1,2 +0,0 @@
ALTER TABLE users DROP CONSTRAINT users_telegram_id_key;
ALTER TABLE users DROP COLUMN telegram_id;
@@ -1,2 +0,0 @@
ALTER TABLE users ADD telegram_id int8 NULL;
ALTER TABLE users ADD CONSTRAINT users_telegram_id_key UNIQUE (telegram_id);
@@ -1,2 +0,0 @@
UPDATE users SET "password" = '' WHERE "password" IS NULL;
ALTER TABLE users ALTER COLUMN "password" SET NOT NULL;
@@ -1 +0,0 @@
ALTER TABLE users ALTER COLUMN "password" DROP NOT NULL;
@@ -1,3 +0,0 @@
UPDATE users SET "android_version" = '' WHERE "android_version" IS NULL;
ALTER TABLE users ALTER COLUMN "android_version" SET NOT NULL;
ALTER TABLE users RENAME COLUMN android_version TO "version";
@@ -1,2 +0,0 @@
ALTER TABLE users RENAME COLUMN "version" TO android_version;
ALTER TABLE users ALTER COLUMN android_version DROP NOT NULL;
@@ -1,2 +0,0 @@
UPDATE users SET "group" = '' WHERE "group" IS NULL;
ALTER TABLE users ALTER COLUMN "group" SET NOT NULL;
@@ -1 +0,0 @@
ALTER TABLE users ALTER COLUMN "group" DROP NOT NULL;
@@ -1,2 +0,0 @@
UPDATE users SET "access_token" = '' WHERE "access_token" IS NULL;
ALTER TABLE users ALTER COLUMN "access_token" SET NOT NULL;
@@ -1 +0,0 @@
ALTER TABLE users ALTER COLUMN "access_token" DROP NOT NULL;
+1 -1
View File
@@ -14,4 +14,4 @@ serde_repr = "0.1.20"
utoipa = { version = "5.4.0", features = ["macros", "chrono"] }
sha1 = "0.11.0-rc.0"
sha1 = "0.11.0-rc.2"
+6 -64
View File
@@ -100,6 +100,12 @@ pub enum LessonType {
/// Защита курсового проекта.
CourseProjectDefense,
/// Практическое занятие.
Practice,
/// Дифференцированный зачёт.
DifferentiatedExam,
}
#[derive(Clone, Hash, Debug, Serialize, Deserialize, ToSchema)]
@@ -209,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]
pub trait ScheduleProvider
where
@@ -1,6 +1,6 @@
[package]
name = "provider-engels-polytechnic"
version = "0.1.0"
version = "0.2.3"
edition = "2024"
[features]
@@ -9,24 +9,35 @@ test = []
[dependencies]
base = { path = "../base" }
tokio = { version = "1.47.1", features = ["sync", "macros", "time"] }
tokio-util = "0.7.16"
tokio = { version = "1", features = ["sync", "macros", "time"] }
tokio-util = "0"
chrono = { version = "0.4.41", features = ["serde"] }
chrono = { version = "0", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] }
derive_more = { version = "2", features = ["error", "display", "from"] }
derive_more = { version = "2.0.1", features = ["error", "display"] }
utoipa = { version = "5", features = ["macros", "chrono"] }
utoipa = { version = "5.4.0", features = ["macros", "chrono"] }
calamine = "0"
async-trait = "0"
calamine = { git = "https://github.com/prophittcorey/calamine.git", branch = "fix/zip-3.0" }
async-trait = "0.1.89"
reqwest = "0.12.23"
ua_generator = "0.5.22"
regex = "1.11.1"
strsim = "0.11.1"
log = "0.4.27"
sentry = "0.42.0"
reqwest = { version = "0", features = ["json"] }
serde = { version = "1", features = ["derive"] }
percent-encoding = "2"
ua_generator = "0"
regex = "1"
strsim = "0"
log = "0"
sentry = { version = "0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"logs",
"metrics",
"panic",
"release-health",
"reqwest",
"rustls",
] }
fancy-regex = "0"
@@ -1,4 +1,4 @@
use crate::updater::Updater;
pub use crate::updater::{UpdateSource, Updater};
use async_trait::async_trait;
use base::{ScheduleProvider, ScheduleSnapshot};
use std::ops::DerefMut;
@@ -8,8 +8,6 @@ use tokio::sync::RwLock;
use tokio::time::interval;
use tokio_util::sync::CancellationToken;
pub use crate::updater::UpdateSource;
mod parser;
mod updater;
mod xls_downloader;
@@ -25,9 +23,9 @@ pub struct EngelsPolytechnicProvider {
}
impl EngelsPolytechnicProvider {
pub async fn new(
pub async fn get(
update_source: UpdateSource,
) -> Result<Arc<dyn ScheduleProvider>, crate::updater::error::Error> {
) -> Result<Arc<dyn ScheduleProvider>, crate::updater::Error> {
let (updater, snapshot) = Updater::new(update_source).await?;
Ok(Arc::new(Wrapper {
@@ -60,14 +58,13 @@ impl ScheduleProvider for Wrapper {
log::info!("Updating schedule...");
match this.updater.update(&mut this.snapshot).await {
match this.updater.update(&this.snapshot).await {
Ok(snapshot) => {
this.snapshot = Arc::new(snapshot);
},
Err(err) => {
cancellation_token.cancel();
return Err(err.into());
sentry::capture_error(&err);
}
}
}
@@ -0,0 +1,25 @@
use crate::parser::worksheet::CellPos;
use derive_more::{Display, Error, From};
#[derive(Debug, Display, Error, From)]
pub enum Error {
#[from]
BadXls(calamine::XlsError),
#[display("No work sheets found.")]
NoWorkSheets,
#[display("There is no data on work sheet boundaries.")]
UnknownWorkSheetRange,
#[display("Failed to read lesson start and end of lesson at {_0}.")]
NoLessonBoundaries(CellPos),
#[display("No start and end times matching the lesson (at {_0}) was found.")]
LessonTimeNotFound(CellPos),
#[display("Unknown lesson type `{type}` at {pos}")]
UnknownLessonType { pos: CellPos, r#type: String },
}
pub type Result<T> = core::result::Result<T, Error>;
@@ -1,94 +1,24 @@
pub use self::error::{Error, Result};
use crate::or_continue;
use crate::parser::error::{ErrorCell, ErrorCellPos};
use crate::parser::worksheet::WorkSheet;
use crate::parser::LessonParseResult::{Lessons, Street};
use crate::parser::worksheet::{CellPos, CellRange, WorkSheet};
use base::LessonType::Break;
use base::{
Day, Lesson, LessonBoundaries, LessonSubGroup, LessonType, ParsedSchedule, ScheduleEntry,
};
use calamine::{open_workbook_from_rs, Reader, Xls};
use calamine::{Reader, Xls, open_workbook_from_rs};
use chrono::{DateTime, Duration, NaiveDate, NaiveTime, Utc};
use regex::Regex;
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::LazyLock;
mod error;
mod macros;
mod worksheet;
pub mod error {
use derive_more::{Display, Error};
use serde::{Serialize, Serializer};
use std::sync::Arc;
use utoipa::ToSchema;
#[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 Error {
/// 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 Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Error::BadXLS(_) => serializer.serialize_str("BAD_XLS"),
Error::NoWorkSheets => serializer.serialize_str("NO_WORK_SHEETS"),
Error::UnknownWorkSheetRange => {
serializer.serialize_str("UNKNOWN_WORK_SHEET_RANGE")
}
Error::LessonBoundaries(_) => serializer.serialize_str("GLOBAL_TIME"),
Error::LessonTimeNotFound(_) => serializer.serialize_str("LESSON_TIME_NOT_FOUND"),
}
}
}
}
/// Data cell storing the group name.
pub struct GroupCellInfo {
pub struct GroupMarkup {
/// Column index.
pub column: u32,
@@ -97,7 +27,7 @@ pub struct GroupCellInfo {
}
/// Data cell storing the line.
pub struct DayCellInfo {
pub struct DayMarkup {
/// Line index.
pub row: u32,
@@ -111,8 +41,13 @@ pub struct DayCellInfo {
pub date: DateTime<Utc>,
}
pub struct WorkSheetMarkup {
days: Box<[DayMarkup]>,
groups: Box<[GroupMarkup]>,
}
/// Data on the time of lessons from the second column of the schedule.
pub struct BoundariesCellInfo {
pub struct BoundariesData {
/// Temporary segment of the lesson.
pub time_range: LessonBoundaries,
@@ -123,23 +58,26 @@ pub struct BoundariesCellInfo {
pub default_index: Option<u32>,
/// The frame of the cell.
pub xls_range: ((u32, u32), (u32, u32)),
pub range: CellRange,
}
/// Obtaining a "skeleton" schedule from the working sheet.
fn parse_skeleton(
worksheet: &WorkSheet,
) -> Result<(Vec<DayCellInfo>, Vec<GroupCellInfo>), crate::parser::error::Error> {
let mut groups: Vec<GroupCellInfo> = Vec::new();
let mut days: Vec<(u32, String, Option<DateTime<Utc>>)> = Vec::new();
fn parse_markup(worksheet: &WorkSheet) -> Result<WorkSheetMarkup> {
struct PartialDayMarkup {
row: u32,
name: String,
date: Option<DateTime<Utc>>,
}
let worksheet_start = worksheet
.start()
.ok_or(error::Error::UnknownWorkSheetRange)?;
let worksheet_end = worksheet.end().ok_or(error::Error::UnknownWorkSheetRange)?;
let mut groups: Vec<GroupMarkup> = Vec::new();
let mut days: Vec<PartialDayMarkup> = Vec::new();
let mut row = worksheet_start.0;
let (start_row, start_col) = worksheet.start().ok_or(Error::UnknownWorkSheetRange)?;
let (end_row, end_col) = worksheet.end().ok_or(Error::UnknownWorkSheetRange)?;
while row < worksheet_end.0 {
let mut row = start_row;
while row < end_row {
row += 1;
let day_full_name = or_continue!(worksheet.get_string_from_cell(row, 0));
@@ -149,8 +87,8 @@ fn parse_skeleton(
// переход на предыдущую строку
row -= 1;
for column in (worksheet_start.1 + 2)..=worksheet_end.1 {
groups.push(GroupCellInfo {
for column in (start_col + 2)..=end_col {
groups.push(GroupMarkup {
column,
name: or_continue!(worksheet.get_string_from_cell(row, column))
.replace(" ", ""),
@@ -183,37 +121,44 @@ fn parse_skeleton(
(name, date)
};
days.push((row, day_name, day_date));
days.push(PartialDayMarkup {
row,
name: day_name,
date: day_date,
});
}
// fix unparsable day dates
let days_max = days.len().min(5);
for i in 0..days_max {
if days[i].2.is_none() && days[i + 1].2.is_some() {
days[i].2 = Some(days[i + 1].2.unwrap() - Duration::days(1));
if days[i].date.is_none() && days[i + 1].date.is_some() {
days[i].date = Some(days[i + 1].date.unwrap() - Duration::days(1));
}
}
for i in 0..days_max {
let i = days_max - i;
if days[i - 1].2.is_none() && days[i].2.is_some() {
days[i - 1].2 = Some(days[i].2.unwrap() - Duration::days(1));
if days[i - 1].date.is_none() && days[i].date.is_some() {
days[i - 1].date = Some(days[i].date.unwrap() - Duration::days(1));
}
}
let days = days
.into_iter()
.map(|day| DayCellInfo {
row: day.0,
.map(|day| DayMarkup {
row: day.row,
column: 0,
name: day.1,
date: day.2.unwrap(),
name: day.name,
date: day.date.unwrap(),
})
.collect();
Ok((days, groups))
Ok(WorkSheetMarkup {
days,
groups: groups.into_boxed_slice(),
})
}
/// The result of obtaining a lesson from the cell.
@@ -230,9 +175,10 @@ enum LessonParseResult {
// noinspection GrazieInspection
/// Obtaining a non-standard type of lesson by name.
fn guess_lesson_type(text: &String) -> Option<LessonType> {
fn guess_lesson_type(text: &str) -> Option<LessonType> {
static MAP: LazyLock<HashMap<&str, LessonType>> = LazyLock::new(|| {
HashMap::from([
("о важном", LessonType::Additional),
("консультация", LessonType::Consultation),
("самостоятельная работа", LessonType::IndependentWork),
("зачет", LessonType::Exam),
@@ -240,31 +186,29 @@ fn guess_lesson_type(text: &String) -> Option<LessonType> {
("экзамен", LessonType::ExamDefault),
("курсовой проект", LessonType::CourseProject),
("защита курсового проекта", LessonType::CourseProjectDefense),
("практическое занятие", LessonType::Practice),
("дифференцированный зачет", LessonType::DifferentiatedExam),
])
});
let name_lower = text.to_lowercase();
match MAP
.iter()
.map(|(text, lesson_type)| (lesson_type, strsim::levenshtein(text, &*name_lower)))
MAP.iter()
.map(|(text, lesson_type)| (lesson_type, strsim::levenshtein(text, &name_lower)))
.filter(|x| x.1 <= 4)
.min_by_key(|(_, score)| *score)
{
None => None,
Some(v) => Some(v.0.clone()),
}
.map(|v| v.0.clone())
}
/// Getting a pair or street from a cell.
fn parse_lesson(
worksheet: &WorkSheet,
day: &Day,
day_boundaries: &Vec<BoundariesCellInfo>,
lesson_boundaries: &BoundariesCellInfo,
day_boundaries: &[BoundariesData],
lesson_boundaries: &BoundariesData,
group_column: u32,
) -> Result<LessonParseResult, crate::parser::error::Error> {
let row = lesson_boundaries.xls_range.0.0;
) -> Result<LessonParseResult> {
let row = lesson_boundaries.range.start.row;
let name = {
let cell_data = match worksheet.get_string_from_cell(row, group_column) {
@@ -273,7 +217,7 @@ fn parse_lesson(
};
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) {
return Ok(Street(cell_data));
@@ -282,42 +226,45 @@ fn parse_lesson(
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 end_time_arr = day_boundaries
.iter()
.filter(|time| time.xls_range.1.0 == cell_range.1.0)
.collect::<Vec<&BoundariesCellInfo>>();
.filter(
|BoundariesData {
range: CellRange { end, .. },
..
}| { lesson_cell_range.end.row <= end.row },
)
.collect::<Vec<&BoundariesData>>();
let end_time = end_time_arr
.first()
.ok_or(error::Error::LessonTimeNotFound(ErrorCellPos {
row,
column: group_column,
}))?;
.ok_or(Error::LessonTimeNotFound(CellPos::new(row, group_column)))?;
let range: Option<[u8; 2]> = if lesson_boundaries.default_index != None {
let default = lesson_boundaries.default_index.unwrap() as u8;
Some([default, end_time.default_index.unwrap() as u8])
} else {
None
};
let range: Option<[u8; 2]> = lesson_boundaries
.default_index
.map(|default_index| [default_index as u8, end_time.default_index.unwrap() as u8]);
let time = LessonBoundaries {
start: lesson_boundaries.time_range.start,
end: end_time.time_range.end,
};
Ok((range, time))
}?;
(range, time)
};
let (name, mut subgroups, lesson_type) = parse_name_and_subgroups(&name)?;
let ParsedLessonName {
name,
mut subgroups,
r#type: lesson_type,
} = parse_name_and_subgroups(&name, row, group_column)?;
{
let cabinets: Vec<String> = parse_cabinets(
worksheet,
(cell_range.0.0, cell_range.1.0),
(lesson_cell_range.start.row, lesson_cell_range.end.row),
group_column + 1,
);
@@ -325,13 +272,11 @@ fn parse_lesson(
if cab_count == 1 {
// Назначаем этот кабинет всем подгруппам
let cab = Some(cabinets.get(0).unwrap().clone());
let cab = Some(cabinets.first().unwrap().clone());
for subgroup in &mut subgroups {
if let Some(subgroup) = subgroup {
for subgroup in subgroups.iter_mut().flatten() {
subgroup.cabinet = cab.clone()
}
}
} else if cab_count == 2 {
while subgroups.len() < cab_count {
subgroups.push(subgroups.last().unwrap_or(&None).clone());
@@ -361,10 +306,7 @@ fn parse_lesson(
range: default_range,
name: Some(name),
time: lesson_time,
subgroups: if subgroups.len() == 2
&& subgroups.get(0).unwrap().is_none()
&& subgroups.get(1).unwrap().is_none()
{
subgroups: if subgroups.len() == 2 && subgroups.iter().all(|x| x.is_none()) {
None
} else {
Some(subgroups)
@@ -416,123 +358,144 @@ fn parse_cabinets(worksheet: &WorkSheet, row_range: (u32, u32), column: u32) ->
cabinets
}
struct ParsedLessonName {
name: String,
subgroups: Vec<Option<LessonSubGroup>>,
r#type: Option<LessonType>,
}
//noinspection GrazieInspection
/// Getting the "pure" name of the lesson and list of teachers from the text of the lesson cell.
fn parse_name_and_subgroups(
text: &String,
) -> Result<(String, Vec<Option<LessonSubGroup>>, Option<LessonType>), crate::parser::error::Error>
{
fn parse_name_and_subgroups(text: &str, row: u32, column: u32) -> Result<ParsedLessonName> {
// Части названия пары:
// 1. Само название.
// 2. Список преподавателей и подгрупп.
// 3. "Модификатор" (чаще всего).
//
// Регулярное выражение для получения ФИО преподавателей и номеров подгрупп (aka. второй части).
// (?:[А-Я][а-я]+\s?(?:[А-Я][\s.]*){2}(?:\(\d\s?[а-я]+\))?(?:, )?)+[\s.]*
//
// Подробнее:
// (?:
// [А-Я][а-я]+ - Фамилия.
// \s? - Кто знает, будет ли там пробел.
// (?:[А-Я][\s.]*){2} - Имя и отчество с учётом случайных пробелов и точек.
// (?:
// \( - Открытие подгруппы.
// \s? - Кто знает, будет ли там пробел.
// \d - Номер подгруппы.
// \s? - Кто знает, будет ли там пробел.
// [а-я\s]+ - Слово "подгруппа" с учётов ошибок.
// \) - Закрытие подгруппы.
// )? - Явное указание подгруппы может отсутствовать по понятным причинам.
// (?:, )? - Разделители между отдельными частями.
// )+
// [\s.]* - Забираем с собой всякий мусор, что бы не передать его в третью часть.
static NAMES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:[А-Я][а-я]+\s?(?:[А-Я][\s.]*){2}(?:\(\s*\d\s*[а-я\s]+\))?(?:[\s,]+)?){1,2}+[\s.,]*",
static NAME_RE: LazyLock<fancy_regex::Regex> = LazyLock::new(|| {
fancy_regex::Regex::new(
r"([А-Я][а-я]+(?:[\s.]*[А-Я]){1,2})(?=[^А-Яа-я])[.\s]*(?:\(?(\d)[\sа-я]*\)?)?",
)
.unwrap()
});
// Отчистка
static CLEAN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\s\n\t]+").unwrap());
let text = text
.chars()
.filter(|c: &char| {
c.is_whitespace()
|| c.is_ascii_digit()
|| (*c >= 'а' && *c <= 'я')
|| (*c >= 'А' && *c <= 'Я')
|| *c == '.'
|| *c == '-'
})
.collect::<String>()
.replace(r"\s+", " ");
let text = CLEAN_RE
.replace(&text.replace(&[' ', '\t', '\n'], " "), " ")
.to_string();
let mut lesson_name: Option<&str> = None;
let mut extra: Option<&str> = None;
let (lesson_name, subgroups, lesson_type) = match NAMES_REGEX.captures(&text) {
Some(captures) => {
let capture = captures.get(0).unwrap();
let subgroups: Vec<Option<LessonSubGroup>> = {
let src = capture.as_str().replace(&[' ', '.'], "");
let mut shared_subgroup = false;
let mut shared_subgroup = true;
let mut subgroups: [Option<LessonSubGroup>; 2] = [None, None];
for name in src.split(',') {
let open_bracket_index = name.find('(');
for capture in NAME_RE.captures_iter(&text).take(2) {
let capture = capture.unwrap();
let number: u8 = open_bracket_index
.map_or(0, |index| name[(index + 1)..(index + 2)].parse().unwrap());
if lesson_name.is_none() {
lesson_name = Some(&text[..capture.get(0).unwrap().start()]);
}
extra = Some(&text[capture.get(0).unwrap().end()..]);
let teacher_name = {
let name_end = open_bracket_index.unwrap_or_else(|| name.len());
let clean = capture
.get(1)
.unwrap()
.as_str()
.chars()
.filter(|c| c.is_alphabetic())
.collect::<Vec<char>>();
// Я ебал. Как же я долго до этого доходил.
if clean.get(clean.len() - 2).is_some_and(|c| c.is_uppercase()) {
let (name, remaining) = clean.split_at(clean.len() - 2);
format!(
"{} {}.{}.",
name.get(..name_end - 4).unwrap(),
name.get(name_end - 4..name_end - 2).unwrap(),
name.get(name_end - 2..name_end).unwrap(),
name.iter().collect::<String>(),
remaining[0],
remaining[1]
)
} else {
let (remaining, name) = clean.split_last().unwrap();
format!("{} {}.", name.iter().collect::<String>(), remaining)
}
};
let lesson = Some(LessonSubGroup {
let subgroup_index = capture.get(2).map(|m| m.as_str().parse::<u32>().unwrap());
let subgroup = Some(LessonSubGroup {
cabinet: None,
teacher: Some(teacher_name),
});
match number {
0 => {
subgroups[0] = lesson;
subgroups[1] = None;
shared_subgroup = true;
break;
match subgroup_index {
None => {
// we have only 2 matches max so more than 2 subgroups we cant have 100%
*subgroups.iter_mut().find(|x| x.is_none()).unwrap() = subgroup;
}
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)
// 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] = lesson;
// 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;
}
}
}
if shared_subgroup {
Vec::from([subgroups[0].take()])
let subgroups = if lesson_name.is_none() {
Vec::new()
} else if shared_subgroup {
Vec::from([subgroups.into_iter().next().unwrap()])
} else {
Vec::from(subgroups)
}
};
let name = text[..capture.start()].trim().to_string();
let extra = text[capture.end()..].trim().to_string();
if extra.is_none() {
extra = text
.rfind(" ")
.and_then(|i| text[..i].rfind(" "))
.map(|i| &text[i + 1..]);
}
let lesson_type = if extra.len() > 4 {
let result = guess_lesson_type(&extra);
let lesson_type = if let Some(extra) = extra
&& extra.len() > 4
{
let result = guess_lesson_type(extra);
if result.is_none() {
#[cfg(not(debug_assertions))]
sentry::capture_message(
&*format!("Не удалось угадать тип пары '{}'!", extra),
sentry::Level::Warning,
);
sentry::capture_error(&Error::UnknownLessonType {
r#type: extra.to_string(),
pos: CellPos::new(row, column),
});
#[cfg(debug_assertions)]
log::warn!("Не удалось угадать тип пары '{}'!", extra);
log::warn!(
"{}",
Error::UnknownLessonType {
r#type: extra.to_string(),
pos: CellPos::new(row, column),
}
);
}
result
@@ -540,12 +503,11 @@ fn parse_name_and_subgroups(
None
};
(name, subgroups, lesson_type)
}
None => (text, Vec::new(), None),
};
Ok((lesson_name, subgroups, lesson_type))
Ok(ParsedLessonName {
name: lesson_name.unwrap_or(&text).to_string(),
subgroups,
r#type: lesson_type,
})
}
/// Getting the start and end of a pair from a cell in the first column of a document.
@@ -554,18 +516,11 @@ fn parse_name_and_subgroups(
///
/// * `cell_data`: text in cell.
/// * `date`: date of the current day.
fn parse_lesson_boundaries_cell(
cell_data: &String,
date: DateTime<Utc>,
) -> Option<LessonBoundaries> {
fn parse_lesson_boundaries_cell(cell_data: &str, date: DateTime<Utc>) -> Option<LessonBoundaries> {
static TIME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(\d+\.\d+)-(\d+\.\d+)").unwrap());
let parse_res = if let Some(captures) = TIME_RE.captures(cell_data) {
captures
} else {
return None;
};
let parse_res = TIME_RE.captures(cell_data)?;
let start_match = parse_res.get(1).unwrap().as_str();
let start_parts: Vec<&str> = start_match.split(".").collect();
@@ -579,7 +534,7 @@ fn parse_lesson_boundaries_cell(
};
Some(LessonBoundaries {
start: GET_TIME(date.clone(), &start_parts),
start: GET_TIME(date, &start_parts),
end: GET_TIME(date, &end_parts),
})
}
@@ -597,8 +552,8 @@ fn parse_day_boundaries(
date: DateTime<Utc>,
row_range: (u32, u32),
column: u32,
) -> Result<Vec<BoundariesCellInfo>, crate::parser::error::Error> {
let mut day_times: Vec<BoundariesCellInfo> = Vec::new();
) -> Result<Vec<BoundariesData>> {
let mut day_times: Vec<BoundariesData> = Vec::new();
for row in row_range.0..row_range.1 {
let time_cell = if let Some(str) = worksheet.get_string_from_cell(row, column) {
@@ -607,9 +562,8 @@ fn parse_day_boundaries(
continue;
};
let lesson_time = parse_lesson_boundaries_cell(&time_cell, date.clone()).ok_or(
error::Error::LessonBoundaries(ErrorCell::new(row, column, time_cell.clone())),
)?;
let lesson_time = parse_lesson_boundaries_cell(&time_cell, date)
.ok_or(Error::NoLessonBoundaries(CellPos::new(row, column)))?;
// type
let lesson_type = if time_cell.contains("пара") {
@@ -633,11 +587,11 @@ fn parse_day_boundaries(
None
};
day_times.push(BoundariesCellInfo {
day_times.push(BoundariesData {
time_range: lesson_time,
lesson_type,
default_index,
xls_range: worksheet.get_merge_from_start(row, column),
range: worksheet.get_merge_from_start(row, column),
});
}
@@ -652,9 +606,9 @@ fn parse_day_boundaries(
/// * `week_markup`: markup of the current week.
fn parse_week_boundaries(
worksheet: &WorkSheet,
week_markup: &Vec<DayCellInfo>,
) -> Result<Vec<Vec<BoundariesCellInfo>>, crate::parser::error::Error> {
let mut result: Vec<Vec<BoundariesCellInfo>> = Vec::new();
week_markup: &[DayMarkup],
) -> Result<Vec<Vec<BoundariesData>>> {
let mut result: Vec<Vec<BoundariesData>> = Vec::new();
let worksheet_end_row = worksheet.end().unwrap().0;
let lesson_time_column = week_markup[0].column + 1;
@@ -671,8 +625,8 @@ fn parse_week_boundaries(
};
let day_boundaries = parse_day_boundaries(
&worksheet,
day_markup.date.clone(),
worksheet,
day_markup.date,
(day_markup.row, end_row),
lesson_time_column,
)?;
@@ -698,7 +652,7 @@ fn convert_groups_to_teachers(
.map(|day| Day {
name: day.name.clone(),
street: day.street.clone(),
date: day.date.clone(),
date: day.date,
lessons: vec![],
})
.collect();
@@ -773,35 +727,21 @@ fn convert_groups_to_teachers(
///
/// * `buffer`: XLS data containing schedule.
///
/// returns: Result<ParseResult, crate::parser::error::Error>
///
/// # Examples
///
/// ```
/// use schedule_parser::parse_xls;
///
/// let result = parse_xls(&include_bytes!("../../schedule.xls").to_vec());
///
/// assert!(result.is_ok(), "{}", result.err().unwrap());
///
/// assert_ne!(result.as_ref().unwrap().groups.len(), 0);
/// assert_ne!(result.as_ref().unwrap().teachers.len(), 0);
/// ```
pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParsedSchedule, crate::parser::error::Error> {
/// returns: Result<ParseResult, Error>
pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParsedSchedule> {
let cursor = Cursor::new(&buffer);
let mut workbook: Xls<_> =
open_workbook_from_rs(cursor).map_err(|e| error::Error::BadXLS(std::sync::Arc::new(e)))?;
let mut workbook: Xls<_> = open_workbook_from_rs(cursor)?;
let worksheet = {
let (worksheet_name, worksheet) = workbook
.worksheets()
.first()
.ok_or(error::Error::NoWorkSheets)?
.ok_or(Error::NoWorkSheets)?
.clone();
let worksheet_merges = workbook
.worksheet_merge_cells(&*worksheet_name)
.ok_or(error::Error::NoWorkSheets)?;
.merge_cells_by_sheet_name(&worksheet_name)
.map_err(|_| Error::NoWorkSheets)?;
WorkSheet {
data: worksheet,
@@ -809,7 +749,11 @@ pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParsedSchedule, crate::parser::erro
}
};
let (week_markup, groups_markup) = parse_skeleton(&worksheet)?;
let WorkSheetMarkup {
days: week_markup,
groups: groups_markup,
} = parse_markup(&worksheet)?;
let week_boundaries = parse_week_boundaries(&worksheet, &week_markup)?;
let mut groups: HashMap<String, ScheduleEntry> = HashMap::new();
@@ -820,7 +764,7 @@ pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParsedSchedule, crate::parser::erro
days: Vec::new(),
};
for day_index in 0..(&week_markup).len() {
for day_index in 0..week_markup.len() {
let day_markup = &week_markup[day_index];
let mut day = Day {
@@ -836,8 +780,8 @@ pub fn parse_xls(buffer: &Vec<u8>) -> Result<ParsedSchedule, crate::parser::erro
match &mut parse_lesson(
&worksheet,
&day,
&day_boundaries,
&lesson_boundaries,
day_boundaries,
lesson_boundaries,
group_markup.column,
)? {
Lessons(lesson) => day.lessons.append(lesson),
@@ -862,7 +806,7 @@ pub mod test_utils {
use super::*;
use base::ParsedSchedule;
pub fn test_result() -> Result<ParsedSchedule, crate::parser::error::Error> {
pub fn test_result() -> Result<ParsedSchedule> {
parse_xls(&include_bytes!("../../../../test-data/engels-polytechnic.xls").to_vec())
}
}
@@ -1,4 +1,5 @@
use regex::Regex;
use std::fmt::{Display, Formatter};
use std::ops::Deref;
use std::sync::LazyLock;
@@ -8,6 +9,40 @@ pub struct WorkSheet {
pub merges: Vec<calamine::Dimensions>,
}
#[derive(Clone, Debug, derive_more::Error)]
pub struct CellPos {
pub row: 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 start: CellPos,
pub end: CellPos,
}
impl Deref for WorkSheet {
type Target = calamine::Range<calamine::Data>;
@@ -45,14 +80,26 @@ impl WorkSheet {
}
/// Obtaining the boundaries of the cell along its upper left coordinate.
pub fn get_merge_from_start(&self, row: u32, column: u32) -> ((u32, u32), (u32, u32)) {
pub fn get_merge_from_start(&self, row: u32, column: u32) -> CellRange {
match self
.merges
.iter()
.find(|merge| merge.start.0 == row && merge.start.1 == column)
{
Some(merge) => (merge.start, (merge.end.0 + 1, merge.end.1 + 1)),
None => ((row, column), (row + 1, column + 1)),
Some(merge) => CellRange {
start: CellPos::new(merge.start.0, merge.start.1),
end: CellPos::new(merge.end.0 + 1, merge.end.1 + 1),
},
None => CellRange {
start: CellPos::new(row, column),
end: CellPos::new(row + 1, column + 1),
},
}
}
}
impl CellPos {
pub fn new(row: u32, column: u32) -> Self {
Self { row, column }
}
}
@@ -1,263 +0,0 @@
use crate::parser::parse_xls;
use crate::updater::error::{Error, QueryUrlError, SnapshotCreationError};
use crate::xls_downloader::{FetchError, XlsDownloader};
use base::ScheduleSnapshot;
pub enum UpdateSource {
Prepared(ScheduleSnapshot),
Url(String),
GrabFromSite {
yandex_api_key: String,
yandex_func_id: String,
},
}
pub struct Updater {
downloader: XlsDownloader,
update_source: UpdateSource,
}
pub mod error {
use crate::xls_downloader::FetchError;
use derive_more::{Display, Error};
#[derive(Debug, Display, Error)]
pub enum Error {
/// An error occurred while querying the Yandex Cloud API for a URL.
///
/// This may result from network failures, invalid API credentials, or issues with the Yandex Cloud Function invocation.
/// See [`QueryUrlError`] for more details about specific causes.
QueryUrlFailed(QueryUrlError),
/// The schedule snapshot creation process failed.
///
/// This can happen due to URL conflicts (same URL already in use), failed network requests,
/// download errors, or invalid XLS file content. See [`SnapshotCreationError`] for details.
SnapshotCreationFailed(SnapshotCreationError),
}
/// Errors that may occur when querying the Yandex Cloud API to retrieve a URL.
#[derive(Debug, Display, Error)]
pub enum QueryUrlError {
/// Occurs when the request to the Yandex Cloud API fails.
///
/// This may be due to network issues, invalid API key, incorrect function ID, or other
/// problems with the Yandex Cloud Function invocation.
#[display("An error occurred during the request to the Yandex Cloud API: {_0}")]
RequestFailed(reqwest::Error),
}
/// Errors that may occur during the creation of a schedule snapshot.
#[derive(Debug, Display, Error)]
pub enum SnapshotCreationError {
/// The URL is the same as the one already being used (no update needed).
#[display("The URL is the same as the one already being used.")]
SameUrl,
/// The URL query for the XLS file failed to execute, either due to network issues or invalid API parameters.
#[display("Failed to fetch URL: {_0}")]
FetchFailed(FetchError),
/// Downloading the XLS file content failed after successfully obtaining the URL.
#[display("Download failed: {_0}")]
DownloadFailed(FetchError),
/// The XLS file could not be parsed into a valid schedule format.
#[display("Schedule data is invalid: {_0}")]
InvalidSchedule(crate::parser::error::Error),
}
}
impl Updater {
/// Constructs a new `ScheduleSnapshot` by downloading and parsing schedule data from the specified URL.
///
/// This method first checks if the provided URL is the same as the one already configured in the downloader.
/// If different, it updates the downloader's URL, fetches the XLS content, parses it, and creates a snapshot.
/// Errors are returned for URL conflicts, network issues, download failures, or invalid data.
///
/// # Arguments
///
/// * `downloader`: A mutable reference to an `XLSDownloader` implementation used to fetch and parse the schedule data.
/// * `url`: The source URL pointing to the XLS file containing schedule data.
///
/// returns: Result<ScheduleSnapshot, SnapshotCreationError>
pub async fn new_snapshot(
downloader: &mut XlsDownloader,
url: String,
) -> Result<ScheduleSnapshot, SnapshotCreationError> {
if downloader.url.as_ref().is_some_and(|_url| _url.eq(&url)) {
return Err(SnapshotCreationError::SameUrl);
}
let head_result = downloader.set_url(&*url).await.map_err(|error| {
if let FetchError::Unknown(error) = &error {
sentry::capture_error(&error);
}
SnapshotCreationError::FetchFailed(error)
})?;
let xls_data = downloader
.fetch(false)
.await
.map_err(|error| {
if let FetchError::Unknown(error) = &error {
sentry::capture_error(&error);
}
SnapshotCreationError::DownloadFailed(error)
})?
.data
.unwrap();
let parse_result = parse_xls(&xls_data).map_err(|error| {
sentry::capture_error(&error);
SnapshotCreationError::InvalidSchedule(error)
})?;
Ok(ScheduleSnapshot {
fetched_at: head_result.requested_at,
updated_at: head_result.uploaded_at,
url,
data: parse_result,
})
}
/// Queries the Yandex Cloud Function (FaaS) to obtain a URL for the schedule file.
///
/// This sends a POST request to the specified Yandex Cloud Function endpoint,
/// using the provided API key for authentication. The returned URI is combined
/// with the "https://politehnikum-eng.ru" base domain to form the complete URL.
///
/// # Arguments
///
/// * `api_key` - Authentication token for Yandex Cloud API
/// * `func_id` - ID of the target Yandex Cloud Function to invoke
///
/// # Returns
///
/// Result containing:
/// - `Ok(String)` - Complete URL constructed from the Function's response
/// - `Err(QueryUrlError)` - If the request or response processing fails
async fn query_url(api_key: &str, func_id: &str) -> Result<String, QueryUrlError> {
let client = reqwest::Client::new();
let uri = client
.post(format!(
"https://functions.yandexcloud.net/{}?integration=raw",
func_id
))
.header("Authorization", format!("Api-Key {}", api_key))
.send()
.await
.map_err(|error| QueryUrlError::RequestFailed(error))?
.text()
.await
.map_err(|error| QueryUrlError::RequestFailed(error))?;
Ok(format!("https://politehnikum-eng.ru{}", uri.trim()))
}
/// Initializes the schedule by fetching the URL from the environment or Yandex Cloud Function (FaaS)
/// and creating a [`ScheduleSnapshot`] with the downloaded data.
///
/// # Arguments
///
/// * `downloader`: Mutable reference to an `XLSDownloader` implementation used to fetch and parse the schedule
/// * `app_env`: Reference to the application environment containing either a predefined URL or Yandex Cloud credentials
///
/// # Returns
///
/// Returns `Ok(())` if the snapshot was successfully initialized, or an `Error` if:
/// - URL query to Yandex Cloud failed ([`QueryUrlError`])
/// - Schedule snapshot creation failed ([`SnapshotCreationError`])
pub async fn new(update_source: UpdateSource) -> Result<(Self, ScheduleSnapshot), Error> {
let mut this = Updater {
downloader: XlsDownloader::new(),
update_source,
};
if let UpdateSource::Prepared(snapshot) = &this.update_source {
let snapshot = snapshot.clone();
return Ok((this, snapshot));
}
let url = match &this.update_source {
UpdateSource::Url(url) => {
log::info!("The default link {} will be used", url);
url.clone()
}
UpdateSource::GrabFromSite {
yandex_api_key,
yandex_func_id,
} => {
log::info!("Obtaining a link using FaaS...");
Self::query_url(yandex_api_key, yandex_func_id)
.await
.map_err(|error| Error::QueryUrlFailed(error))?
}
_ => unreachable!(),
};
log::info!("For the initial setup, a link {} will be used", url);
let snapshot = Self::new_snapshot(&mut this.downloader, url)
.await
.map_err(|error| Error::SnapshotCreationFailed(error))?;
log::info!("Schedule snapshot successfully created!");
Ok((this, snapshot))
}
/// Updates the schedule snapshot by querying the latest URL from FaaS and checking for changes.
/// If the URL hasn't changed, only updates the [`fetched_at`] timestamp. If changed, downloads
/// and parses the new schedule data.
///
/// # Arguments
///
/// * `downloader`: XLS file downloader used to fetch and parse the schedule data
/// * `app_env`: Application environment containing Yandex Cloud configuration and auto-update settings
///
/// returns: `Result<(), Error>` - Returns error if URL query fails or schedule parsing encounters issues
///
/// # Safety
///
/// Use `unsafe` to access the initialized snapshot, guaranteed valid by prior `init()` call
pub async fn update(
&mut self,
current_snapshot: &ScheduleSnapshot,
) -> Result<ScheduleSnapshot, Error> {
if let UpdateSource::Prepared(snapshot) = &self.update_source {
let mut snapshot = snapshot.clone();
snapshot.update();
return Ok(snapshot);
}
let url = match &self.update_source {
UpdateSource::Url(url) => url.clone(),
UpdateSource::GrabFromSite {
yandex_api_key,
yandex_func_id,
} => Self::query_url(yandex_api_key.as_str(), yandex_func_id.as_str())
.await
.map_err(|error| Error::QueryUrlFailed(error))?,
_ => unreachable!(),
};
let snapshot = match Self::new_snapshot(&mut self.downloader, url).await {
Ok(snapshot) => snapshot,
Err(SnapshotCreationError::SameUrl) => {
let mut clone = current_snapshot.clone();
clone.update();
clone
}
Err(error) => return Err(Error::SnapshotCreationFailed(error)),
};
Ok(snapshot)
}
}
@@ -0,0 +1,24 @@
use crate::xls_downloader::FetchError;
use derive_more::{Display, Error, From};
#[derive(Debug, Display, Error, From)]
pub enum Error {
/// The remote file has not changed since the last update.
#[display("The schedule file has not changed.")]
NotModified,
/// The lookup of the current schedule file failed, either due to network issues or an
/// unexpected response from the storage.
#[display("Failed to fetch URL: {_0}")]
ScheduleFetchFailed(FetchError),
/// Downloading the XLS file content failed after successfully locating the file.
#[display("Download failed: {_0}")]
ScheduleDownloadFailed(FetchError),
/// The XLS file could not be parsed into a valid schedule format.
#[from]
InvalidSchedule(crate::parser::Error),
}
pub type Result<T> = core::result::Result<T, Error>;
@@ -0,0 +1,139 @@
pub use self::error::{Error, Result};
use crate::parser::parse_xls;
use crate::xls_downloader::{FetchError, Source};
use base::ScheduleSnapshot;
use chrono::Utc;
mod error;
pub enum UpdateSource {
Prepared(ScheduleSnapshot),
/// Public Yandex Disk folder the college uploads the schedule to.
YandexDisk {
public_url: String,
},
}
pub struct Updater {
update_source: UpdateSource,
/// Version of the file the current snapshot was built from.
version: Option<String>,
}
impl Updater {
/// Place the schedule is downloaded from, or [`None`] for a prepared snapshot.
fn source(&self) -> Option<Source> {
match &self.update_source {
UpdateSource::Prepared(_) => None,
UpdateSource::YandexDisk { public_url } => Some(Source::new(public_url.clone())),
}
}
/// Constructs a new [`ScheduleSnapshot`] by downloading and parsing the current schedule file.
///
/// The file is looked up first, and its content is downloaded only when the version marker
/// differs from the one the current snapshot was built from.
///
/// # Returns
///
/// Returns [`Error::NotModified`] when the remote file has not changed since the last update,
/// or an error describing the failed download or parsing.
async fn new_snapshot(&mut self) -> Result<ScheduleSnapshot> {
let source = self.source().expect("a prepared snapshot has no source");
let file = source.probe().await.map_err(|error| {
if let FetchError::Reqwest(error) = &error {
sentry::capture_error(&error);
}
Error::ScheduleFetchFailed(error)
})?;
if self.version.as_deref() == Some(file.version.as_str()) {
return Err(Error::NotModified);
}
let xls_data = source.download(&file).await.map_err(|error| {
if let FetchError::Reqwest(error) = &error {
sentry::capture_error(&error);
}
Error::ScheduleDownloadFailed(error)
})?;
let parse_result = parse_xls(&xls_data)?;
self.version = Some(file.version);
Ok(ScheduleSnapshot {
fetched_at: Utc::now(),
updated_at: file.modified_at,
url: file.url,
data: parse_result,
})
}
/// Initializes the schedule by downloading the current file from the configured source.
///
/// # Arguments
///
/// * `update_source`: Place the schedule is taken from.
///
/// # Returns
///
/// Returns the updater together with the initial [`ScheduleSnapshot`], or an error if the
/// schedule could not be downloaded or parsed.
pub async fn new(update_source: UpdateSource) -> Result<(Self, ScheduleSnapshot)> {
let mut this = Updater {
update_source,
version: None,
};
if let UpdateSource::Prepared(snapshot) = &this.update_source {
let snapshot = snapshot.clone();
return Ok((this, snapshot));
}
log::info!("Creating the initial schedule snapshot...");
let snapshot = this.new_snapshot().await?;
log::info!("Schedule snapshot successfully created!");
Ok((this, snapshot))
}
/// Rebuilds the schedule snapshot from the current remote file.
///
/// When the remote file has not changed, the current snapshot is reused with a refreshed
/// fetch timestamp.
///
/// # Arguments
///
/// * `current_snapshot`: Snapshot the provider currently serves.
///
/// returns: `Result<ScheduleSnapshot, Error>`
pub async fn update(
&mut self,
current_snapshot: &ScheduleSnapshot,
) -> Result<ScheduleSnapshot> {
if let UpdateSource::Prepared(snapshot) = &self.update_source {
let mut snapshot = snapshot.clone();
snapshot.update();
return Ok(snapshot);
}
let snapshot = match self.new_snapshot().await {
Ok(snapshot) => snapshot,
Err(Error::NotModified) => {
let mut clone = current_snapshot.clone();
clone.update();
clone
}
Err(error) => return Err(error),
};
Ok(snapshot)
}
}
@@ -1,237 +0,0 @@
use chrono::{DateTime, Utc};
use derive_more::{Display, Error};
use std::mem::discriminant;
use std::sync::Arc;
use utoipa::ToSchema;
/// XLS data retrieval errors.
#[derive(Clone, Debug, ToSchema, Display, Error)]
pub enum FetchError {
/// File url is not set.
#[display("The link to the timetable was not provided earlier.")]
NoUrlProvided,
/// Unknown error.
#[display("An unknown error occurred while downloading the file.")]
#[schema(value_type = String)]
Unknown(Arc<reqwest::Error>),
/// Server returned a status code different from 200.
#[display("Server returned a status code {status_code}.")]
BadStatusCode { status_code: u16 },
/// The url leads to a file of a different type.
#[display("The link leads to a file of type '{content_type}'.")]
BadContentType { content_type: String },
/// Server doesn't return expected headers.
#[display("Server doesn't return expected header(s) '{expected_header}'.")]
BadHeaders { expected_header: String },
}
impl FetchError {
pub fn unknown(error: Arc<reqwest::Error>) -> Self {
Self::Unknown(error)
}
pub fn bad_status_code(status_code: u16) -> Self {
Self::BadStatusCode { status_code }
}
pub fn bad_content_type(content_type: &str) -> Self {
Self::BadContentType {
content_type: content_type.to_string(),
}
}
pub fn bad_headers(expected_header: &str) -> Self {
Self::BadHeaders {
expected_header: expected_header.to_string(),
}
}
}
impl PartialEq for FetchError {
fn eq(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other)
}
}
/// Result of XLS data retrieval.
#[derive(Debug, PartialEq)]
pub struct FetchOk {
/// File upload date.
pub uploaded_at: DateTime<Utc>,
/// Date data received.
pub requested_at: DateTime<Utc>,
/// File data.
pub data: Option<Vec<u8>>,
}
impl FetchOk {
/// Result without file content.
pub fn head(uploaded_at: DateTime<Utc>) -> Self {
FetchOk {
uploaded_at,
requested_at: Utc::now(),
data: None,
}
}
/// Full result.
pub fn get(uploaded_at: DateTime<Utc>, data: Vec<u8>) -> Self {
FetchOk {
uploaded_at,
requested_at: Utc::now(),
data: Some(data),
}
}
}
pub type FetchResult = Result<FetchOk, FetchError>;
pub struct XlsDownloader {
pub url: Option<String>,
}
impl XlsDownloader {
pub fn new() -> Self {
XlsDownloader { url: None }
}
async fn fetch_specified(url: &str, head: bool) -> FetchResult {
let client = reqwest::Client::new();
let response = if head {
client.head(url)
} else {
client.get(url)
}
.header("User-Agent", ua_generator::ua::spoof_chrome_ua())
.send()
.await
.map_err(|e| FetchError::unknown(Arc::new(e)))?;
if response.status().as_u16() != 200 {
return Err(FetchError::bad_status_code(response.status().as_u16()));
}
let headers = response.headers();
let content_type = headers
.get("Content-Type")
.ok_or(FetchError::bad_headers("Content-Type"))?;
if !headers.contains_key("etag") {
return Err(FetchError::bad_headers("etag"));
}
let last_modified = headers
.get("last-modified")
.ok_or(FetchError::bad_headers("last-modified"))?;
if content_type != "application/vnd.ms-excel" {
return Err(FetchError::bad_content_type(content_type.to_str().unwrap()));
}
let last_modified = DateTime::parse_from_rfc2822(&last_modified.to_str().unwrap())
.unwrap()
.with_timezone(&Utc);
Ok(if head {
FetchOk::head(last_modified)
} else {
FetchOk::get(last_modified, response.bytes().await.unwrap().to_vec())
})
}
pub async fn fetch(&self, head: bool) -> FetchResult {
if self.url.is_none() {
Err(FetchError::NoUrlProvided)
} else {
Self::fetch_specified(&*self.url.as_ref().unwrap(), head).await
}
}
pub async fn set_url(&mut self, url: &str) -> FetchResult {
let result = Self::fetch_specified(url, true).await;
if let Ok(_) = result {
self.url = Some(url.to_string());
}
result
}
}
#[cfg(test)]
mod tests {
use crate::xls_downloader::{FetchError, XlsDownloader};
#[tokio::test]
async fn bad_url() {
let url = "bad_url";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_err());
}
#[tokio::test]
async fn bad_status_code() {
let url = "https://www.google.com/not-found";
let mut downloader = XlsDownloader::new();
assert_eq!(
downloader.set_url(url).await,
Err(FetchError::bad_status_code(404))
);
}
#[tokio::test]
async fn bad_headers() {
let url = "https://www.google.com/favicon.ico";
let mut downloader = XlsDownloader::new();
assert_eq!(
downloader.set_url(url).await,
Err(FetchError::BadHeaders {
expected_header: "ETag".to_string(),
})
);
}
#[tokio::test]
async fn bad_content_type() {
let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb59fd46303008396ac96%2Fexample.txt";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_err());
}
#[tokio::test]
async fn ok() {
let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_ok());
}
#[tokio::test]
async fn downloader_ok() {
let url = "https://s3.aero-storage.ldragol.ru/679e5d1145a6ad00843ad3f1/67ddb5fad46303008396ac97%2Fschedule.xls";
let mut downloader = XlsDownloader::new();
assert!(downloader.set_url(url).await.is_ok());
assert!(downloader.fetch(false).await.is_ok());
}
#[tokio::test]
async fn downloader_no_url_provided() {
let downloader = XlsDownloader::new();
let result = downloader.fetch(false).await;
assert_eq!(result, Err(FetchError::NoUrlProvided));
}
}
@@ -0,0 +1,99 @@
use chrono::{DateTime, Utc};
use derive_more::{Display, Error};
use std::mem::discriminant;
use std::sync::Arc;
mod yandex_disk;
/// XLS data retrieval errors.
#[derive(Clone, Debug, Display, Error)]
pub enum FetchError {
/// Unknown error.
#[display("An unknown error occurred while downloading the file.")]
Reqwest(Arc<reqwest::Error>),
/// Server returned a status code different from 200.
#[display("Server returned a status code {status_code}.")]
BadStatusCode { status_code: u16 },
/// The folder contains no file matching the schedule name pattern.
#[display("No schedule file was found in the shared folder.")]
NoScheduleFile,
}
impl FetchError {
pub fn unknown(error: Arc<reqwest::Error>) -> Self {
Self::Reqwest(error)
}
pub fn bad_status_code(status_code: u16) -> Self {
Self::BadStatusCode { status_code }
}
}
impl PartialEq for FetchError {
fn eq(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other)
}
}
pub type FetchResult<T> = Result<T, FetchError>;
/// Description of the remote schedule file, obtained without downloading its content.
#[derive(Clone, Debug, PartialEq)]
pub struct RemoteFile {
/// Permanent link to the file, shown to API clients.
pub url: String,
/// Link the content is actually downloaded from.
pub download_url: String,
/// Content hash, changing whenever the file content changes.
pub version: String,
/// Time of the last file modification reported by the remote side.
pub modified_at: DateTime<Utc>,
}
/// Public Yandex Disk folder the schedule is downloaded from.
#[derive(Clone, Debug)]
pub struct Source {
pub public_url: String,
}
impl Source {
pub fn new(public_url: String) -> Self {
Self { public_url }
}
/// Looks up the current schedule file without downloading its content.
pub async fn probe(&self) -> FetchResult<RemoteFile> {
yandex_disk::probe(&self.public_url).await
}
/// Downloads the content of a previously probed file.
pub async fn download(&self, file: &RemoteFile) -> FetchResult<Vec<u8>> {
get(&file.download_url)
.await?
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|error| FetchError::unknown(Arc::new(error)))
}
}
/// Performs a GET request with a spoofed browser User-Agent and checks the status code.
async fn get(url: &str) -> FetchResult<reqwest::Response> {
let response = reqwest::Client::new()
.get(url)
.header("User-Agent", ua_generator::ua::spoof_chrome_ua())
.send()
.await
.map_err(|error| FetchError::unknown(Arc::new(error)))?;
if response.status().as_u16() != 200 {
return Err(FetchError::bad_status_code(response.status().as_u16()));
}
Ok(response)
}
@@ -0,0 +1,135 @@
use super::{FetchError, FetchResult, RemoteFile};
use chrono::{DateTime, Utc};
use percent_encoding::{AsciiSet, CONTROLS, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Deserialize;
/// Prefix of the schedule file names in the shared folder.
const NAME_PREFIX: &str = "poltavskaja_";
/// Marker of the corrections file, which holds a separate schedule.
const NAME_EXCLUDED_MARKER: &str = "korr";
/// Extension of the schedule files.
const NAME_SUFFIX: &str = ".xls";
/// Maximum amount of entries requested from the folder listing.
const LISTING_LIMIT: u32 = 200;
/// Characters not allowed inside a single path segment of the public file link.
const PATH_SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}');
#[derive(Deserialize)]
struct Listing {
#[serde(rename = "_embedded")]
embedded: Embedded,
}
#[derive(Deserialize)]
struct Embedded {
items: Vec<Item>,
}
#[derive(Deserialize)]
struct Item {
#[serde(rename = "type")]
resource_type: String,
name: String,
modified: DateTime<Utc>,
md5: Option<String>,
revision: Option<u64>,
file: Option<String>,
}
impl Item {
/// Whether the entry is the schedule the provider is interested in.
fn is_schedule(&self) -> bool {
if self.resource_type != "file" || self.file.is_none() {
return false;
}
let name = self.name.to_lowercase();
name.starts_with(NAME_PREFIX)
&& name.ends_with(NAME_SUFFIX)
&& !name.contains(NAME_EXCLUDED_MARKER)
}
/// Marker changing whenever the file content changes.
fn version(&self) -> String {
self.md5
.clone()
.or_else(|| self.revision.map(|revision| revision.to_string()))
.unwrap_or_else(|| self.modified.to_rfc3339())
}
}
/// Finds the freshest schedule file in the public folder.
///
/// The files inside the folder are replaced independently of the folder link,
/// so the whole listing is re-read on every probe.
pub async fn probe(public_url: &str) -> FetchResult<RemoteFile> {
let listing = super::get(&format!(
"https://cloud-api.yandex.net/v1/disk/public/resources?public_key={}&limit={}",
utf8_percent_encode(public_url, NON_ALPHANUMERIC),
LISTING_LIMIT
))
.await?
.json::<Listing>()
.await
.map_err(|error| FetchError::unknown(std::sync::Arc::new(error)))?;
let item = listing
.embedded
.items
.into_iter()
.filter(Item::is_schedule)
.max_by_key(|item| (item.modified, item.revision))
.ok_or(FetchError::NoScheduleFile)?;
Ok(RemoteFile {
url: format!(
"{}/{}",
public_url.trim_end_matches('/'),
utf8_percent_encode(&item.name, PATH_SEGMENT)
),
version: item.version(),
modified_at: item.modified,
download_url: item.file.unwrap(),
})
}
#[cfg(test)]
mod tests {
use super::probe;
const PUBLIC_URL: &str = "https://disk.yandex.ru/d/e8HJpMgDq7msyg";
#[tokio::test]
async fn probe_ok() {
let file = probe(PUBLIC_URL).await.unwrap();
assert!(file.url.starts_with(PUBLIC_URL));
assert!(!file.version.is_empty());
assert!(file.download_url.starts_with("https://"));
}
#[tokio::test]
async fn probe_unknown_folder() {
assert!(
probe("https://disk.yandex.ru/d/000000000000000")
.await
.is_err()
);
}
}
-148
View File
@@ -1,148 +0,0 @@
pub mod users {
use crate::database::models::User;
use crate::database::schema::users::dsl::users;
use crate::database::schema::users::dsl::*;
use crate::state::AppState;
use actix_web::web;
use diesel::{insert_into, ExpressionMethods, QueryResult};
use diesel::{QueryDsl, RunQueryDsl};
use diesel::{SaveChangesDsl, SelectableHelper};
use std::ops::DerefMut;
pub async fn get(state: &web::Data<AppState>, _id: &String) -> QueryResult<User> {
users
.filter(id.eq(_id))
.select(User::as_select())
.first(state.get_database().await.deref_mut())
}
pub async fn get_by_username(
state: &web::Data<AppState>,
_username: &String,
) -> QueryResult<User> {
users
.filter(username.eq(_username))
.select(User::as_select())
.first(state.get_database().await.deref_mut())
}
//noinspection RsTraitObligations
pub async fn get_by_vk_id(state: &web::Data<AppState>, _vk_id: i32) -> QueryResult<User> {
users
.filter(vk_id.eq(_vk_id))
.select(User::as_select())
.first(state.get_database().await.deref_mut())
}
//noinspection RsTraitObligations
pub async fn get_by_telegram_id(
state: &web::Data<AppState>,
_telegram_id: i64,
) -> QueryResult<User> {
users
.filter(telegram_id.eq(_telegram_id))
.select(User::as_select())
.first(state.get_database().await.deref_mut())
}
//noinspection DuplicatedCode
pub async fn contains_by_username(state: &web::Data<AppState>, _username: &String) -> bool {
// и как это нахуй сократить блять примеров нихуя нет, нихуя не работает
// как меня этот раст заебал уже
match users
.filter(username.eq(_username))
.count()
.get_result::<i64>(state.get_database().await.deref_mut())
{
Ok(count) => count > 0,
Err(_) => false,
}
}
//noinspection DuplicatedCode
//noinspection RsTraitObligations
pub async fn contains_by_vk_id(state: &web::Data<AppState>, _vk_id: i32) -> bool {
match users
.filter(vk_id.eq(_vk_id))
.count()
.get_result::<i64>(state.get_database().await.deref_mut())
{
Ok(count) => count > 0,
Err(_) => false,
}
}
pub async fn insert(state: &web::Data<AppState>, user: &User) -> QueryResult<usize> {
insert_into(users)
.values(user)
.execute(state.get_database().await.deref_mut())
}
/// Function declaration [User::save][UserSave::save].
pub trait UserSave {
/// Saves the user's changes to the database.
///
/// # Arguments
///
/// * `state`: The state of the actix-web application that stores the mutex of the [connection][diesel::PgConnection].
///
/// returns: `QueryResult<User>`
///
/// # Examples
///
/// ```
/// use crate::database::driver::users;
///
/// #[derive(Deserialize)]
/// struct Params {
/// pub username: String,
/// }
///
/// #[patch("/")]
/// async fn patch_user(
/// app_state: web::Data<AppState>,
/// user: SyncExtractor<User>,
/// web::Query(params): web::Query<Params>,
/// ) -> web::Json<User> {
/// let mut user = user.into_inner();
///
/// user.username = params.username;
///
/// match user.save(&app_state) {
/// Ok(user) => web::Json(user),
/// Err(e) => {
/// eprintln!("Failed to save user: {e}");
/// panic!();
/// }
/// }
/// }
/// ```
async fn save(&self, state: &web::Data<AppState>) -> QueryResult<User>;
}
/// Implementation of [UserSave][UserSave] trait.
impl UserSave for User {
async fn save(&self, state: &web::Data<AppState>) -> QueryResult<User> {
self.save_changes::<Self>(state.get_database().await.deref_mut())
}
}
#[cfg(test)]
pub async fn delete_by_username(state: &web::Data<AppState>, _username: &String) -> bool {
match diesel::delete(users.filter(username.eq(_username)))
.execute(state.get_database().await.deref_mut())
{
Ok(count) => count > 0,
Err(_) => false,
}
}
#[cfg(test)]
pub async fn insert_or_ignore(state: &web::Data<AppState>, user: &User) -> QueryResult<usize> {
insert_into(users)
.values(user)
.on_conflict_do_nothing()
.execute(state.get_database().await.deref_mut())
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod driver;
pub mod models;
pub mod schema;
-87
View File
@@ -1,87 +0,0 @@
use actix_macros::ResponderJson;
use diesel::QueryId;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(
Copy, Clone, PartialEq, Debug, Serialize, Deserialize, diesel_derive_enum::DbEnum, ToSchema,
)]
#[ExistingTypePath = "crate::database::schema::sql_types::UserRole"]
#[DbValueStyle = "UPPERCASE"]
#[serde(rename_all = "UPPERCASE")]
pub enum UserRole {
Student,
Teacher,
Admin,
}
#[derive(
Identifiable,
AsChangeset,
Queryable,
QueryId,
Selectable,
Serialize,
Insertable,
Debug,
ToSchema,
ResponderJson,
)]
#[diesel(table_name = crate::database::schema::users)]
#[diesel(treat_none_as_null = true)]
pub struct User {
/// Account UUID.
pub id: String,
/// User name.
pub username: String,
/// BCrypt password hash.
pub password: Option<String>,
/// ID of the linked VK account.
pub vk_id: Option<i32>,
/// JWT access token.
pub access_token: Option<String>,
/// Group.
pub group: Option<String>,
/// Role.
pub role: UserRole,
/// Version of the installed Polytechnic+ application.
pub android_version: Option<String>,
/// ID of the linked Telegram account.
pub telegram_id: Option<i64>,
}
#[derive(
Debug,
Clone,
Serialize,
Identifiable,
Queryable,
Selectable,
Insertable,
AsChangeset,
Associations,
ToSchema,
ResponderJson,
)]
#[diesel(belongs_to(User))]
#[diesel(table_name = crate::database::schema::fcm)]
#[diesel(primary_key(user_id))]
pub struct FCM {
/// Account UUID.
pub user_id: String,
/// FCM token.
pub token: String,
/// List of topics subscribed to by the user.
pub topics: Vec<Option<String>>,
}
-39
View File
@@ -1,39 +0,0 @@
// @generated automatically by Diesel CLI.
pub mod sql_types {
#[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)]
#[diesel(postgres_type(name = "user_role"))]
pub struct UserRole;
}
diesel::table! {
fcm (user_id) {
user_id -> Text,
token -> Text,
topics -> Array<Nullable<Text>>,
}
}
diesel::table! {
use diesel::sql_types::*;
use super::sql_types::UserRole;
users (id) {
id -> Text,
username -> Text,
password -> Nullable<Text>,
vk_id -> Nullable<Int4>,
access_token -> Nullable<Text>,
group -> Nullable<Text>,
role -> UserRole,
android_version -> Nullable<Text>,
telegram_id -> Nullable<Int8>,
}
}
diesel::joinable!(fcm -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
fcm,
users,
);
+34 -51
View File
@@ -1,13 +1,13 @@
use crate::database::driver;
use crate::database::models::User;
use crate::extractors::base::FromRequestAsync;
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_web::body::BoxBody;
use actix_web::dev::Payload;
use actix_web::http::header;
use actix_web::{web, HttpRequest};
use database::entity::{User, UserType};
use database::query::Query;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
@@ -28,70 +28,53 @@ pub enum Error {
#[display("Invalid or expired access token")]
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.
#[display("No user associated with access token")]
NoUser,
/// User doesn't have required role.
#[display("You don't have sufficient rights")]
#[status_code = "actix_web::http::StatusCode::FORBIDDEN"]
InsufficientRights,
}
impl Error {
pub fn into_err(self) -> actix_web::Error {
actix_web::Error::from(self)
impl From<req_auth::Error> for Error {
fn from(value: req_auth::Error) -> 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.
impl FromRequestAsync for User {
type Error = actix_web::Error;
type Error = Error;
async fn from_request_async(
req: &HttpRequest,
_payload: &mut Payload,
) -> Result<Self, Self::Error> {
let access_token = match get_access_token_from_header(req) {
Err(Error::NoHeaderOrCookieFound) => {
get_access_token_from_cookies(req).map_err(|error| error.into_err())?
let claims = get_claims_from_req(req).map_err(Error::from)?;
if claims.user_type.unwrap_or(UserType::Default) != UserType::Default {
return Err(Error::NonDefaultUserType);
}
Err(error) => {
return Err(error.into_err());
let db = req
.app_data::<web::Data<AppState>>()
.unwrap()
.get_database();
match Query::find_user_by_id(db, &claims.id).await {
Ok(Some(user)) => Ok(user),
_ => Err(Error::NoUser),
}
Ok(access_token) => access_token,
};
let user_id = jwt::verify_and_decode(&access_token)
.map_err(|_| Error::InvalidAccessToken.into_err())?;
let app_state = req.app_data::<web::Data<AppState>>().unwrap();
driver::users::get(app_state, &user_id)
.await
.map_err(|_| Error::NoUser.into())
}
}
-2
View File
@@ -5,7 +5,6 @@ use std::future::{Ready, ready};
use std::ops;
/// # Async extractor.
/// Asynchronous object extractor from a query.
pub struct AsyncExtractor<T>(T);
@@ -80,7 +79,6 @@ impl<T: FromRequestAsync> FromRequest for AsyncExtractor<T> {
}
/// # Sync extractor.
/// Synchronous object extractor from a query.
pub struct SyncExtractor<T>(T);
+72 -20
View File
@@ -1,19 +1,18 @@
use crate::middlewares::authorization::JWTAuthorization;
use crate::middlewares::authorization::{JWTAuthorizationBuilder, ServiceConfig};
use crate::middlewares::content_type::ContentTypeBootstrap;
use crate::state::{new_app_state, AppState};
use crate::state::{AppState, new_app_state};
use actix_web::dev::{ServiceFactory, ServiceRequest};
use actix_web::{App, Error, HttpServer};
use database::entity::sea_orm_active_enums::UserRole;
use dotenvy::dotenv;
use log::info;
use std::io;
use utoipa_actix_web::scope::Scope;
use utoipa_actix_web::AppExt;
use utoipa_actix_web::scope::Scope;
use utoipa_rapidoc::RapiDoc;
mod state;
mod database;
mod extractors;
mod middlewares;
mod routes;
@@ -28,6 +27,22 @@ pub fn get_api_scope<
>(
scope: I,
) -> 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")
.service(routes::auth::sign_in)
.service(routes::auth::sign_in_vk)
@@ -35,26 +50,64 @@ pub fn get_api_scope<
.service(routes::auth::sign_up_vk);
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_username)
.service(routes::users::me);
let schedule_scope = utoipa_actix_web::scope("/schedule")
.wrap(JWTAuthorization {
ignore: &["/group-names", "/teacher-names"],
})
.service(routes::schedule::schedule)
.wrap(
JWTAuthorizationBuilder::new()
.with_default(Some(ServiceConfig {
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::schedule)
.service(routes::schedule::group)
.service(routes::schedule::group_by_name)
.service(routes::schedule::group_names)
.service(routes::schedule::teacher)
.service(routes::schedule::teacher_names);
let flow_scope = utoipa_actix_web::scope("/flow")
.wrap(JWTAuthorization {
ignore: &["/telegram-auth"],
})
.wrap(
JWTAuthorizationBuilder::new()
.add_paths(["/telegram-auth"], None)
.build(),
)
.service(routes::flow::telegram_auth)
.service(routes::flow::telegram_complete);
@@ -62,6 +115,7 @@ pub fn get_api_scope<
.service(routes::vk_id::oauth);
utoipa_actix_web::scope(scope)
.service(admin_scope)
.service(auth_scope)
.service(users_scope)
.service(schedule_scope)
@@ -72,7 +126,7 @@ pub fn get_api_scope<
async fn async_main() -> io::Result<()> {
info!("Запуск сервера...");
let app_state = new_app_state().await.unwrap();
let app_state = new_app_state(None).await.unwrap();
HttpServer::new(move || {
let (app, api) = App::new()
@@ -104,14 +158,12 @@ async fn async_main() -> io::Result<()> {
fn main() -> io::Result<()> {
let _guard = sentry::init((
"https://9c33db76e89984b3f009b28a9f4b5954@sentry.n08i40k.ru/8",
sentry::ClientOptions {
release: sentry::release_name!(),
send_default_pii: true,
..Default::default()
},
sentry::ClientOptions::new()
.maybe_release(sentry::release_name!())
.send_default_pii(true),
));
dotenv().unwrap();
let _ = dotenv();
env_logger::init();
+142 -38
View File
@@ -1,23 +1,68 @@
use crate::database::models::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::dev::{Payload, Service, ServiceRequest, ServiceResponse, Transform, forward_ready};
use actix_web::{Error, HttpRequest, ResponseError};
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::{web, Error, HttpRequest, ResponseError};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::UserType;
use database::query::Query;
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::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.
pub struct JWTAuthorization {
/// List of ignored endpoints.
pub ignore: &'static [&'static str],
}
impl Default for JWTAuthorization {
fn default() -> Self {
Self { ignore: &[] }
}
pub default_config: Arc<Option<ServiceConfig>>,
pub path_configs: Arc<[ServiceKV]>,
}
impl<S, B> Transform<S, ServiceRequest> for JWTAuthorization
@@ -35,15 +80,17 @@ where
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(JWTAuthorizationMiddleware {
service: Rc::new(service),
ignore: self.ignore,
default_config: self.default_config.clone(),
path_configs: self.path_configs.clone(),
}))
}
}
pub struct JWTAuthorizationMiddleware<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>
@@ -53,29 +100,68 @@ where
B: 'static,
{
/// Checking the validity of the token.
async fn check_authorization(req: &HttpRequest) -> Result<(), authorized_user::Error> {
let mut payload = Payload::None;
async fn check_authorization(
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)
.await
.map(|_| ())
.map_err(|e| e.as_error::<authorized_user::Error>().unwrap().clone())
let db = req
.app_data::<web::Data<AppState>>()
.unwrap()
.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 {
let path = req.match_info().unprocessed();
self.ignore.iter().any(|ignore| {
if !path.starts_with(ignore) {
return false;
return Ok(());
}
if let Some(other) = path.as_bytes().iter().nth(ignore.len()) {
return ['?' as u8, '/' as u8].contains(other);
match Query::is_user_exists_by_id(db, &claims.id).await {
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()
}
}
@@ -92,15 +178,33 @@ where
forward_ready!(service);
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 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 {
match Self::check_authorization(req.request()).await {
match Self::check_authorization(req.request(), allow_service_user, required_user_roles)
.await
{
Ok(_) => {
let fut = service.call(req).await?;
Ok(fut.map_into_left_body())
+8 -7
View File
@@ -1,10 +1,10 @@
use actix_web::Error;
use actix_web::body::{BoxBody, EitherBody};
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready};
use actix_web::dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform};
use actix_web::http::header;
use actix_web::http::header::HeaderValue;
use actix_web::Error;
use futures_util::future::LocalBoxFuture;
use std::future::{Ready, ready};
use std::future::{ready, Ready};
/// Middleware to specify the encoding in the Content-Type header.
pub struct ContentTypeBootstrap;
@@ -30,7 +30,7 @@ pub struct ContentTypeMiddleware<S> {
service: S,
}
impl<'a, S, B> Service<ServiceRequest> for ContentTypeMiddleware<S>
impl<S, B> Service<ServiceRequest> for ContentTypeMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
@@ -49,14 +49,15 @@ where
let mut response = fut.await?;
let headers = response.response_mut().headers_mut();
if let Some(content_type) = headers.get("Content-Type") {
if content_type == "application/json" {
if let Some(content_type) = headers.get("Content-Type")
&& content_type == "application/json"
{
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf8"),
);
}
}
Ok(response.map_into_left_body())
})
+1
View File
@@ -0,0 +1 @@
pub mod service_users;
+75
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,
}
}
+3
View File
@@ -0,0 +1,3 @@
mod create;
pub use create::*;
+4 -14
View File
@@ -2,16 +2,6 @@ use jsonwebtoken::errors::ErrorKind;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
struct TokenData {
iis: String,
sub: i32,
app: i32,
exp: i32,
iat: i32,
jti: i32,
}
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: i32,
@@ -22,7 +12,7 @@ struct Claims {
#[derive(Debug, PartialEq)]
pub enum Error {
JwtError(ErrorKind),
Jwt(ErrorKind),
InvalidSignature,
InvalidToken,
Expired,
@@ -49,10 +39,10 @@ const VK_PUBLIC_KEY: &str = concat!(
"-----END PUBLIC KEY-----"
);
pub fn parse_vk_id(token_str: &String, client_id: i32) -> Result<i32, Error> {
pub fn parse_vk_id(token_str: &str, client_id: i32) -> Result<i32, Error> {
let dkey = DecodingKey::from_rsa_pem(VK_PUBLIC_KEY.as_bytes()).unwrap();
match decode::<Claims>(&token_str, &dkey, &Validation::new(Algorithm::RS256)) {
match decode::<Claims>(token_str, &dkey, &Validation::new(Algorithm::RS256)) {
Ok(token_data) => {
let claims = token_data.claims;
@@ -77,7 +67,7 @@ pub fn parse_vk_id(token_str: &String, client_id: i32) -> Result<i32, Error> {
ErrorKind::Base64(_) => Error::InvalidToken,
ErrorKind::Json(_) => Error::InvalidToken,
ErrorKind::Utf8(_) => Error::InvalidToken,
kind => Error::JwtError(kind),
kind => Error::Jwt(kind),
}),
}
}
+39 -35
View File
@@ -1,31 +1,35 @@
use self::schema::*;
use crate::database::driver;
use crate::database::driver::users::UserSave;
use crate::routes::auth::shared::parse_vk_id;
use crate::routes::auth::sign_in::schema::SignInData::{Default, VkOAuth};
use crate::routes::schema::ResponseError;
use crate::routes::schema::user::UserResponse;
use crate::{AppState, utility};
use crate::routes::schema::ResponseError;
use crate::{utility, AppState};
use actix_web::{post, web};
use database::query::Query;
use web::Json;
use database::entity::UserType;
async fn sign_in_combined(
data: SignInData,
app_state: &web::Data<AppState>,
) -> Result<UserResponse, ErrorCode> {
let db = app_state.get_database();
let user = match &data {
Default(data) => driver::users::get_by_username(&app_state, &data.username).await,
VkOAuth(id) => driver::users::get_by_vk_id(&app_state, *id).await,
};
Default(data) => Query::find_user_by_username(db, &data.username).await,
VkOAuth(id) => Query::find_user_by_vk_id(db, *id).await,
}
.ok()
.flatten();
match user {
Ok(mut user) => {
Some(user) => {
if let Default(data) = data {
if user.password.is_none() {
return Err(ErrorCode::IncorrectCredentials);
}
match bcrypt::verify(&data.password, &user.password.as_ref().unwrap()) {
match bcrypt::verify(&data.password, user.password.as_ref().unwrap()) {
Ok(result) => {
if !result {
return Err(ErrorCode::IncorrectCredentials);
@@ -37,14 +41,11 @@ async fn sign_in_combined(
}
}
user.access_token = Some(utility::jwt::encode(&user.id));
user.save(&app_state).await.expect("Failed to update user");
Ok(user.into())
let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(UserResponse::from_user_with_token(user, access_token))
}
Err(_) => Err(ErrorCode::IncorrectCredentials),
None => Err(ErrorCode::IncorrectCredentials),
}
}
@@ -124,8 +125,6 @@ mod schema {
InvalidVkAccessToken,
}
/// Internal
/// Type of authorization.
pub enum SignInData {
/// User and password name and password.
@@ -139,16 +138,16 @@ mod schema {
#[cfg(test)]
mod tests {
use super::schema::*;
use crate::database::driver;
use crate::database::models::{User, UserRole};
use crate::routes::auth::sign_in::sign_in;
use crate::test_env::tests::{static_app_state, test_app_state, test_env};
use crate::utility;
use actix_test::test_app;
use actix_web::dev::ServiceResponse;
use actix_web::http::Method;
use actix_web::http::StatusCode;
use actix_web::test;
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{ActiveUser, UserEntity};
use database::sea_orm::{ActiveModelTrait, EntityTrait, Set};
use sha1::{Digest, Sha1};
use std::fmt::Write;
@@ -182,22 +181,27 @@ mod tests {
test_env();
let app_state = static_app_state().await;
driver::users::insert_or_ignore(
&app_state,
&User {
id: id.clone(),
username,
password: Some(bcrypt::hash("example".to_string(), bcrypt::DEFAULT_COST).unwrap()),
vk_id: None,
telegram_id: None,
access_token: Some(utility::jwt::encode(&id)),
group: Some("ИС-214/23".to_string()),
role: UserRole::Student,
android_version: None,
},
)
let active_user = ActiveUser {
id: Set(id.clone()),
username: Set(username),
password: Set(Some(bcrypt::hash("example", bcrypt::DEFAULT_COST).unwrap())),
vk_id: Set(None),
telegram_id: Set(None),
group: Set(Some("ИС-214/23".to_string())),
role: Set(UserRole::Student),
android_version: Set(None),
};
UserEntity::delete_by_id(&id)
.exec(app_state.get_database())
.await
.unwrap();
.expect("Failed to delete user");
active_user
.insert(app_state.get_database())
.await
.expect("Failed to save user");
}
#[actix_web::test]
+55 -38
View File
@@ -1,11 +1,13 @@
use self::schema::*;
use crate::AppState;
use crate::database::driver;
use crate::database::models::UserRole;
use crate::routes::auth::shared::parse_vk_id;
use crate::routes::schema::ResponseError;
use crate::routes::schema::user::UserResponse;
use crate::routes::schema::ResponseError;
use crate::{utility, AppState};
use actix_web::{post, web};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{ActiveUser, UserType};
use database::query::Query;
use database::sea_orm::ActiveModelTrait;
use web::Json;
async fn sign_up_combined(
@@ -28,22 +30,30 @@ async fn sign_up_combined(
return Err(ErrorCode::InvalidGroupName);
}
// If user with specified username already exists.
if driver::users::contains_by_username(&app_state, &data.username).await {
let db = app_state.get_database();
// If user with specified username already exists.O
if Query::find_user_by_username(db, &data.username)
.await
.is_ok_and(|user| user.is_some())
{
return Err(ErrorCode::UsernameAlreadyExists);
}
// If user with specified VKID already exists.
if let Some(id) = data.vk_id {
if driver::users::contains_by_vk_id(&app_state, id).await {
if let Some(id) = data.vk_id
&& Query::is_user_exists_by_vk_id(db, id)
.await
.expect("Failed to check user existence")
{
return Err(ErrorCode::VkAlreadyExists);
}
}
let user = data.into();
driver::users::insert(&app_state, &user).await.unwrap();
let active_user: ActiveUser = data.into();
let user = active_user.insert(db).await.unwrap();
let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(UserResponse::from(&user)).into()
Ok(UserResponse::from_user_with_token(user, access_token))
}
#[utoipa::path(responses(
@@ -101,10 +111,11 @@ pub async fn sign_up_vk(
}
mod schema {
use crate::database::models::{User, UserRole};
use crate::routes::schema::user::UserResponse;
use crate::utility;
use actix_macros::ErrResponse;
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::ActiveUser;
use database::sea_orm::Set;
use derive_more::Display;
use objectid::ObjectId;
use serde::{Deserialize, Serialize};
@@ -134,7 +145,7 @@ mod schema {
}
pub mod vk {
use crate::database::models::UserRole;
use database::entity::sea_orm_active_enums::UserRole;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, utoipa::ToSchema)]
@@ -189,8 +200,6 @@ mod schema {
VkAlreadyExists,
}
/// Internal
/// Data for registration.
pub struct SignUpData {
// TODO: сделать ограничение на минимальную и максимальную длину при регистрации и смене.
@@ -215,25 +224,21 @@ mod schema {
pub version: String,
}
impl Into<User> for SignUpData {
fn into(self) -> User {
assert_ne!(self.password.is_some(), self.vk_id.is_some());
impl From<SignUpData> for ActiveUser {
fn from(value: SignUpData) -> Self {
assert_ne!(value.password.is_some(), value.vk_id.is_some());
let id = ObjectId::new().unwrap().to_string();
let access_token = Some(utility::jwt::encode(&id));
User {
id,
username: self.username,
password: self
ActiveUser {
id: Set(ObjectId::new().unwrap().to_string()),
username: Set(value.username),
password: Set(value
.password
.map(|x| bcrypt::hash(x, bcrypt::DEFAULT_COST).unwrap()),
vk_id: self.vk_id,
telegram_id: None,
access_token,
group: Some(self.group),
role: self.role,
android_version: Some(self.version),
.map(|x| bcrypt::hash(x, bcrypt::DEFAULT_COST).unwrap())),
vk_id: Set(value.vk_id),
telegram_id: Set(None),
group: Set(Some(value.group)),
role: Set(value.role),
android_version: Set(Some(value.version)),
}
}
}
@@ -241,8 +246,6 @@ mod schema {
#[cfg(test)]
mod tests {
use crate::database::driver;
use crate::database::models::UserRole;
use crate::routes::auth::sign_up::schema::Request;
use crate::routes::auth::sign_up::sign_up;
use crate::test_env::tests::{static_app_state, test_app_state, test_env};
@@ -251,6 +254,10 @@ mod tests {
use actix_web::http::Method;
use actix_web::http::StatusCode;
use actix_web::test;
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{UserColumn, UserEntity};
use database::sea_orm::ColumnTrait;
use database::sea_orm::{EntityTrait, QueryFilter};
struct SignUpPartial<'a> {
username: &'a str,
@@ -282,7 +289,12 @@ mod tests {
test_env();
let app_state = static_app_state().await;
driver::users::delete_by_username(&app_state, &"test::sign_up_valid".to_string()).await;
UserEntity::delete_many()
.filter(UserColumn::Username.eq("test::sign_up_valid"))
.exec(app_state.get_database())
.await
.expect("Failed to delete user");
// test
@@ -303,7 +315,12 @@ mod tests {
test_env();
let app_state = static_app_state().await;
driver::users::delete_by_username(&app_state, &"test::sign_up_multiple".to_string()).await;
UserEntity::delete_many()
.filter(UserColumn::Username.eq("test::sign_up_multiple"))
.exec(app_state.get_database())
.await
.expect("Failed to delete user");
let create = sign_up_client(SignUpPartial {
username: "test::sign_up_multiple",
+32 -42
View File
@@ -1,12 +1,13 @@
use self::schema::*;
use crate::database::driver;
use crate::database::driver::users::UserSave;
use crate::database::models::{User, UserRole};
use crate::routes::schema::ResponseError;
use crate::utility::telegram::{WebAppInitDataMap, WebAppUser};
use crate::{AppState, utility};
use crate::{utility, AppState};
use actix_web::{post, web};
use chrono::{DateTime, Duration, Utc};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::{ActiveUser, UserType};
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, Set};
use objectid::ObjectId;
use std::sync::Arc;
use web::Json;
@@ -22,10 +23,6 @@ pub async fn telegram_auth(
) -> ServiceResponse {
let init_data = WebAppInitDataMap::from_str(data_json.into_inner().init_data);
// for (key, value) in &init_data.data_map {
// println!("key: {} | value: {}", key, value);
// }
{
let env = &app_state.get_env().telegram;
@@ -52,39 +49,32 @@ pub async fn telegram_auth(
let web_app_user =
serde_json::from_str::<WebAppUser>(init_data.data_map.get("user").unwrap()).unwrap();
let mut user = {
match driver::users::get_by_telegram_id(&app_state, web_app_user.id).await {
Ok(value) => Ok(value),
Err(_) => {
let new_user = User {
id: ObjectId::new().unwrap().to_string(),
username: format!("telegram_{}", web_app_user.id), // можно оставить, а можно поменять
password: None, // ибо нехуй
vk_id: None,
telegram_id: Some(web_app_user.id),
access_token: None, // установится ниже
group: None,
role: UserRole::Student, // TODO: при реге проверять данные
android_version: None,
};
driver::users::insert(&app_state, &new_user)
let user = match Query::find_user_by_telegram_id(app_state.get_database(), web_app_user.id)
.await
.map(|_| new_user)
}
}
.expect("Failed to get or add user")
.expect("Failed to find user by telegram id")
{
Some(value) => value,
None => {
let new_user = ActiveUser {
id: Set(ObjectId::new().unwrap().to_string()),
username: Set(format!("telegram_{}", web_app_user.id)), // можно оставить, а можно поменять
password: Set(None), // ибо нехуй
vk_id: Set(None),
telegram_id: Set(Some(web_app_user.id)),
group: Set(None),
role: Set(UserRole::Student), // TODO: при реге проверять данные
android_version: Set(None),
};
user.access_token = Some(utility::jwt::encode(&user.id));
new_user
.insert(app_state.get_database())
.await
.expect("Failed to insert user")
}
};
user.save(&app_state).await.expect("Failed to update user");
Ok(Response::new(
&*user.access_token.unwrap(),
user.group.is_some(),
))
.into()
let access_token = utility::jwt::encode(UserType::Default, &user.id);
Ok(Response::new(&access_token, user.group.is_some())).into()
}
mod schema {
@@ -93,9 +83,9 @@ mod schema {
use crate::utility::telegram::VerifyError;
use actix_macros::ErrResponse;
use actix_web::body::EitherBody;
use actix_web::cookie::CookieBuilder;
use actix_web::cookie::time::OffsetDateTime;
use actix_web::{HttpRequest, HttpResponse, web};
use actix_web::cookie::CookieBuilder;
use actix_web::{web, HttpRequest, HttpResponse};
use derive_more::Display;
use serde::{Deserialize, Serialize, Serializer};
use std::ops::Add;
@@ -114,8 +104,8 @@ mod schema {
#[serde(rename_all = "camelCase")]
#[schema(as = Flow::TelegramAuth::Response)]
pub struct Response {
#[serde(skip)]
#[schema(ignore)]
// #[serde(skip)] // TODO: я пока не придумал как не отдавать сырой токен в ответе
// #[schema(ignore)]
access_token: String,
pub completed: bool,
@@ -135,7 +125,7 @@ mod schema {
&mut self,
request: &HttpRequest,
response: &mut HttpResponse<EitherBody<String>>,
) -> () {
) {
let access_token = &self.access_token;
let app_state = request.app_data::<web::Data<AppState>>().unwrap();
+17 -11
View File
@@ -1,11 +1,11 @@
use self::schema::*;
use crate::AppState;
use crate::database::driver;
use crate::database::driver::users::UserSave;
use crate::database::models::User;
use crate::extractors::base::AsyncExtractor;
use crate::routes::schema::ResponseError;
use crate::AppState;
use actix_web::{post, web};
use database::entity::User;
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
use web::Json;
#[utoipa::path(responses(
@@ -20,7 +20,7 @@ pub async fn telegram_complete(
app_state: web::Data<AppState>,
user: AsyncExtractor<User>,
) -> ServiceResponse {
let mut user = user.into_inner();
let user = user.into_inner();
// проверка на перезапись уже имеющихся данных
if user.group.is_some() {
@@ -29,13 +29,19 @@ pub async fn telegram_complete(
let data = data.into_inner();
let db = app_state.get_database();
let mut active_user = user.clone().into_active_model();
// замена существующего имени, если оно отличается
if user.username != data.username {
if driver::users::contains_by_username(&app_state, &data.username).await {
if Query::is_user_exists_by_username(db, &data.username)
.await
.unwrap()
{
return Err(ErrorCode::UsernameAlreadyExists).into();
}
user.username = data.username;
active_user.username = Set(data.username);
}
// проверка на существование группы
@@ -50,9 +56,9 @@ pub async fn telegram_complete(
return Err(ErrorCode::InvalidGroupName).into();
}
user.group = Some(data.group);
active_user.group = Set(Some(data.group));
user.save(&app_state).await.expect("Failed to update user");
active_user.update(db).await.expect("Failed to update user");
Ok(()).into()
}
@@ -80,11 +86,11 @@ mod schema {
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[schema(as = Flow::TelegramFill::ErrorCode)]
pub enum ErrorCode {
#[display("This flow already completed.")]
#[display("This flow is already completed.")]
#[status_code = "actix_web::http::StatusCode::CONFLICT"]
AlreadyCompleted,
#[display("Username is already exists.")]
#[display("User with that name already exists.")]
#[status_code = "actix_web::http::StatusCode::BAD_REQUEST"]
UsernameAlreadyExists,
+1
View File
@@ -1,3 +1,4 @@
pub mod admin;
pub mod auth;
pub mod flow;
pub mod schedule;
+8 -2
View File
@@ -1,11 +1,17 @@
use crate::AppState;
use crate::routes::schedule::schema::CacheStatus;
use crate::AppState;
use actix_web::{get, web};
use std::ops::Deref;
#[utoipa::path(responses(
(status = OK, body = CacheStatus),
))]
#[get("/cache-status")]
pub async fn cache_status(app_state: web::Data<AppState>) -> CacheStatus {
CacheStatus::from(&app_state).await.into()
app_state
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.deref()
.into()
}
+1 -1
View File
@@ -1,10 +1,10 @@
use self::schema::*;
use crate::AppState;
use crate::database::models::User;
use crate::extractors::base::AsyncExtractor;
use crate::routes::schedule::schema::ScheduleEntryResponse;
use crate::routes::schema::ResponseError;
use actix_web::{get, web};
use database::entity::User;
#[utoipa::path(responses(
(status = OK, body = ScheduleEntryResponse),
+65
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,
}
}
+4 -2
View File
@@ -1,14 +1,16 @@
mod cache_status;
mod group;
mod group_by_name;
mod group_names;
mod schedule;
mod get;
mod schema;
mod teacher;
mod teacher_names;
pub use cache_status::*;
pub use group::*;
pub use group_by_name::*;
pub use group_names::*;
pub use schedule::*;
pub use get::*;
pub use teacher::*;
pub use teacher_names::*;
-12
View File
@@ -63,18 +63,6 @@ pub struct CacheStatus {
pub updated_at: i64,
}
impl CacheStatus {
pub async fn from(value: &web::Data<AppState>) -> Self {
From::<&ScheduleSnapshot>::from(
value
.get_schedule_snapshot("eng_polytechnic")
.await
.unwrap()
.deref(),
)
}
}
impl From<&ScheduleSnapshot> for CacheStatus {
fn from(value: &ScheduleSnapshot) -> Self {
Self {
+28 -12
View File
@@ -13,13 +13,13 @@ where
E: Serialize + PartialSchema + Display + PartialErrResponse;
/// Transform Response<T, E> into Result<T, E>
impl<T, E> Into<Result<T, E>> for Response<T, E>
impl<T, E> From<Response<T, E>> for Result<T, E>
where
T: Serialize + PartialSchema + PartialOkResponse,
E: Serialize + PartialSchema + Display + PartialErrResponse,
{
fn into(self) -> Result<T, E> {
self.0
fn from(value: Response<T, E>) -> Self {
value.0
}
}
@@ -46,7 +46,7 @@ where
{
match &self.0 {
Ok(ok) => serializer.serialize_some(&ok),
Err(err) => serializer.serialize_some(&ResponseError::<E>::from(err.clone().into())),
Err(err) => serializer.serialize_some(&err.clone().into()),
}
}
}
@@ -95,7 +95,7 @@ pub trait PartialOkResponse {
&mut self,
_request: &HttpRequest,
_response: &mut HttpResponse<EitherBody<String>>,
) -> () {
) {
}
}
@@ -126,8 +126,9 @@ where
}
pub mod user {
use crate::database::models::{User, UserRole};
use actix_macros::{OkResponse, ResponderJson};
use database::entity::sea_orm_active_enums::UserRole;
use database::entity::User;
use serde::Serialize;
//noinspection SpellCheckingInspection
@@ -162,20 +163,35 @@ pub mod user {
#[schema(examples(
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjY3ZGNjOWE5NTA3YjAwMDA3NzI3NDRhMiIsImlhdCI6IjE3NDMxMDgwOTkiLCJleHAiOiIxODY5MjUyMDk5In0.rMgXRb3JbT9AvLK4eiY9HMB5LxgUudkpQyoWKOypZFY"
))]
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
}
impl UserResponse {
pub fn from_user_with_token(user: User, access_token: String) -> Self {
Self {
id: user.id.clone(),
username: user.username.clone(),
group: user.group.clone(),
role: user.role.clone(),
vk_id: user.vk_id,
telegram_id: user.telegram_id,
access_token: Some(access_token),
}
}
}
/// Create UserResponse from User ref.
impl From<&User> for UserResponse {
fn from(user: &User) -> Self {
UserResponse {
Self {
id: user.id.clone(),
username: user.username.clone(),
group: user.group.clone(),
role: user.role.clone(),
vk_id: user.vk_id.clone(),
telegram_id: user.telegram_id.clone(),
access_token: user.access_token.clone(),
vk_id: user.vk_id,
telegram_id: user.telegram_id,
access_token: None,
}
}
}
@@ -183,14 +199,14 @@ pub mod user {
/// Transform User to UserResponse.
impl From<User> for UserResponse {
fn from(user: User) -> Self {
UserResponse {
Self {
id: user.id,
username: user.username,
group: user.group,
role: user.role,
vk_id: user.vk_id,
telegram_id: user.telegram_id,
access_token: user.access_token,
access_token: None,
}
}
}
+56
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,
}
}
+12 -6
View File
@@ -1,9 +1,9 @@
use self::schema::*;
use crate::database::driver::users::UserSave;
use crate::database::models::User;
use crate::extractors::base::AsyncExtractor;
use crate::state::AppState;
use actix_web::{post, web};
use database::entity::User;
use database::sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
#[utoipa::path(responses((status = OK)))]
#[post("/change-group")]
@@ -12,9 +12,13 @@ pub async fn change_group(
user: AsyncExtractor<User>,
data: web::Json<Request>,
) -> ServiceResponse {
let mut user = user.into_inner();
let user = user.into_inner();
if user.group.is_some_and(|group| group == data.group) {
if user
.group
.as_ref()
.is_some_and(|group| group.eq(&data.group))
{
return Ok(()).into();
}
@@ -29,8 +33,10 @@ pub async fn change_group(
return Err(ErrorCode::NotFound).into();
}
user.group = Some(data.into_inner().group);
user.save(&app_state).await.unwrap();
let mut active_user = user.clone().into_active_model();
active_user.group = Set(Some(data.into_inner().group));
active_user.update(app_state.get_database()).await.unwrap();
Ok(()).into()
}
+11 -8
View File
@@ -1,10 +1,10 @@
use self::schema::*;
use crate::database::driver;
use crate::database::driver::users::UserSave;
use crate::database::models::User;
use crate::extractors::base::AsyncExtractor;
use crate::state::AppState;
use actix_web::{post, web};
use database::entity::User;
use database::query::Query;
use database::sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
#[utoipa::path(responses((status = OK)))]
#[post("/change-username")]
@@ -13,21 +13,24 @@ pub async fn change_username(
user: AsyncExtractor<User>,
data: web::Json<Request>,
) -> ServiceResponse {
let mut user = user.into_inner();
let user = user.into_inner();
if user.username == data.username {
return Ok(()).into();
}
if driver::users::get_by_username(&app_state, &data.username)
let db = app_state.get_database();
if Query::is_user_exists_by_username(db, &data.username)
.await
.is_ok()
.unwrap()
{
return Err(ErrorCode::AlreadyExists).into();
}
user.username = data.into_inner().username;
user.save(&app_state).await.unwrap();
let mut active_user = user.into_active_model();
active_user.username = Set(data.into_inner().username);
active_user.update(db).await.unwrap();
Ok(()).into()
}
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::database::models::User;
use crate::extractors::base::AsyncExtractor;
use crate::routes::schema::user::UserResponse;
use actix_web::get;
use database::entity::User;
#[utoipa::path(responses((status = OK, body = UserResponse)))]
#[get("/me")]
+1
View File
@@ -1,3 +1,4 @@
pub mod by;
mod change_group;
mod change_username;
mod me;
-9
View File
@@ -2,22 +2,13 @@ pub mod schedule;
pub mod telegram;
pub mod vk_id;
#[cfg(not(test))]
pub mod yandex_cloud;
pub use self::schedule::ScheduleEnvData;
pub use self::telegram::TelegramEnvData;
pub use self::vk_id::VkIdEnvData;
#[cfg(not(test))]
pub use self::yandex_cloud::YandexCloudEnvData;
#[derive(Default)]
pub struct AppEnv {
pub schedule: ScheduleEnvData,
pub telegram: TelegramEnvData,
pub vk_id: VkIdEnvData,
#[cfg(not(test))]
pub yandex_cloud: YandexCloudEnvData,
}
+5 -2
View File
@@ -2,8 +2,10 @@ use std::env;
#[derive(Clone)]
pub struct ScheduleEnvData {
/// Public link to the Yandex Disk folder the schedule files are uploaded to.
#[cfg(not(test))]
pub url: Option<String>,
pub yandex_disk_url: String,
pub auto_update: bool,
}
@@ -11,7 +13,8 @@ impl Default for ScheduleEnvData {
fn default() -> Self {
Self {
#[cfg(not(test))]
url: env::var("SCHEDULE_INIT_URL").ok(),
yandex_disk_url: env::var("SCHEDULE_YANDEX_DISK_URL")
.expect("SCHEDULE_YANDEX_DISK_URL must be set"),
auto_update: !env::var("SCHEDULE_DISABLE_AUTO_UPDATE")
.is_ok_and(|v| v.eq("1") || v.eq("true")),
}
-16
View File
@@ -1,16 +0,0 @@
use std::env;
#[derive(Clone)]
pub struct YandexCloudEnvData {
pub api_key: String,
pub func_id: String,
}
impl Default for YandexCloudEnvData {
fn default() -> Self {
Self {
api_key: env::var("YANDEX_CLOUD_API_KEY").expect("YANDEX_CLOUD_API_KEY must be set"),
func_id: env::var("YANDEX_CLOUD_FUNC_ID").expect("YANDEX_CLOUD_FUNC_ID must be set"),
}
}
}
+70 -23
View File
@@ -2,29 +2,30 @@ mod env;
pub use crate::state::env::AppEnv;
use actix_web::web;
use diesel::{Connection, PgConnection};
use database::migration::{Migrator, MigratorTrait};
use database::sea_orm::{ConnectOptions, Database, DatabaseConnection};
use providers::base::{ScheduleProvider, ScheduleSnapshot};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, MutexGuard};
use std::time::Duration;
use tokio_util::sync::CancellationToken;
/// Common data provided to endpoints.
pub struct AppState {
cancel_token: CancellationToken,
database: Mutex<PgConnection>,
database: DatabaseConnection,
providers: HashMap<String, Arc<dyn ScheduleProvider>>,
env: AppEnv,
}
impl AppState {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
pub async fn new(
database: Option<DatabaseConnection>,
) -> Result<Self, Box<dyn std::error::Error>> {
let env = AppEnv::default();
let providers: HashMap<String, Arc<dyn ScheduleProvider>> = HashMap::from([(
"eng_polytechnic".to_string(),
providers::EngelsPolytechnicProvider::new({
providers::EngelsPolytechnicProvider::get({
#[cfg(test)]
{
providers::EngelsPolytechnicUpdateSource::Prepared(ScheduleSnapshot {
@@ -37,13 +38,8 @@ impl AppState {
#[cfg(not(test))]
{
if let Some(url) = &env.schedule.url {
providers::EngelsPolytechnicUpdateSource::Url(url.clone())
} else {
providers::EngelsPolytechnicUpdateSource::GrabFromSite {
yandex_api_key: env.yandex_cloud.api_key.clone(),
yandex_func_id: env.yandex_cloud.func_id.clone(),
}
providers::EngelsPolytechnicUpdateSource::YandexDisk {
public_url: env.schedule.yandex_disk_url.clone(),
}
}
})
@@ -52,16 +48,28 @@ impl AppState {
let this = Self {
cancel_token: CancellationToken::new(),
database: Mutex::new(
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url)),
),
database: if let Some(database) = database {
database
} else {
let opt = database_connect_options();
let database_url = opt.get_url().to_string();
let database = Database::connect(opt)
.await
.unwrap_or_else(|_| panic!("Error connecting to {}", database_url));
Migrator::up(&database, None)
.await
.expect("Failed to run database migrations");
database
},
env,
providers,
};
if this.env.schedule.auto_update {
for (_, provider) in &this.providers {
for provider in this.providers.values() {
let provider = provider.clone();
let cancel_token = this.cancel_token.clone();
@@ -80,8 +88,8 @@ impl AppState {
None
}
pub async fn get_database(&'_ self) -> MutexGuard<'_, PgConnection> {
self.database.lock().await
pub fn get_database(&'_ self) -> &DatabaseConnection {
&self.database
}
pub fn get_env(&self) -> &AppEnv {
@@ -89,7 +97,46 @@ impl AppState {
}
}
/// Параметры подключения к базе данных.
#[cfg(not(test))]
fn database_connect_options() -> ConnectOptions {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let mut opt = ConnectOptions::new(database_url);
opt.max_connections(4)
.min_connections(2)
.connect_timeout(Duration::from_secs(10))
.idle_timeout(Duration::from_secs(8))
.sqlx_logging(true);
opt
}
/// SQLite во временном файле: база в памяти умрёт вместе с соединением.
#[cfg(test)]
fn database_connect_options() -> ConnectOptions {
let path = std::env::temp_dir().join(format!(
"{}-test-{}.sqlite",
env!("CARGO_PKG_NAME"),
std::process::id()
));
let _ = std::fs::remove_file(&path);
let mut opt = ConnectOptions::new(format!("sqlite://{}?mode=rwc", path.display()));
opt.max_connections(1)
.min_connections(1)
.connect_timeout(Duration::from_secs(10))
.sqlx_logging(true);
opt
}
/// Create a new object web::Data<AppState>.
pub async fn new_app_state() -> Result<web::Data<AppState>, Box<dyn std::error::Error>> {
Ok(web::Data::new(AppState::new().await?))
pub async fn new_app_state(
database: Option<DatabaseConnection>,
) -> Result<web::Data<AppState>, Box<dyn std::error::Error>> {
Ok(web::Data::new(AppState::new(database).await?))
}
+13 -2
View File
@@ -7,13 +7,16 @@ pub(crate) mod tests {
pub fn test_env() {
info!("Loading test environment file...");
dotenvy::from_filename(".env.test.local")
.or_else(|_| dotenvy::from_filename(".env.test"))
.expect("Failed to load test environment file");
}
pub async fn test_app_state() -> web::Data<AppState> {
let state = new_app_state().await.unwrap();
let state = new_app_state(Some(static_app_state().await.get_database().clone()))
.await
.unwrap();
state.clone()
}
@@ -21,6 +24,14 @@ pub(crate) mod tests {
pub async fn static_app_state() -> web::Data<AppState> {
static STATE: OnceCell<web::Data<AppState>> = OnceCell::const_new();
STATE.get_or_init(|| test_app_state()).await.clone()
STATE
.get_or_init(async || -> web::Data<AppState> {
#[cfg(feature = "trace")]
console_subscriber::init();
new_app_state(None).await.unwrap()
})
.await
.clone()
}
}
+22 -20
View File
@@ -1,13 +1,14 @@
use chrono::Duration;
use chrono::Utc;
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_with::DisplayFromStr;
use serde_with::serde_as;
use serde_with::DisplayFromStr;
use std::env;
use std::mem::discriminant;
use std::sync::LazyLock;
use database::entity::UserType;
/// Key for token verification.
static DECODING_KEY: LazyLock<DecodingKey> = LazyLock::new(|| {
@@ -24,14 +25,13 @@ static ENCODING_KEY: LazyLock<EncodingKey> = LazyLock::new(|| {
});
/// Token verification errors.
#[allow(dead_code)]
#[derive(Debug)]
pub enum Error {
/// The token has a different signature.
InvalidSignature,
/// Token reading error.
InvalidToken(ErrorKind),
InvalidToken,
/// Token expired.
Expired,
@@ -43,52 +43,56 @@ impl PartialEq for Error {
}
}
/// The data the token holds.
#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
pub struct Claims {
/// User account UUID.
id: String,
pub id: String,
/// User type.
pub user_type: Option<UserType>,
/// Token creation date.
#[serde_as(as = "DisplayFromStr")]
iat: u64,
pub iat: u64,
/// Token expiry date.
#[serde_as(as = "DisplayFromStr")]
exp: u64,
pub exp: u64,
}
/// Token signing algorithm.
pub(crate) const DEFAULT_ALGORITHM: Algorithm = Algorithm::HS256;
/// Checking the token and extracting the UUID of the user account from it.
pub fn verify_and_decode(token: &String) -> Result<String, Error> {
pub fn verify_and_decode(token: &str) -> Result<Claims, Error> {
let mut validation = Validation::new(DEFAULT_ALGORITHM);
validation.required_spec_claims.remove("exp");
validation.validate_exp = false;
let result = decode::<Claims>(&token, &*DECODING_KEY, &validation);
let result = decode::<Claims>(token, &DECODING_KEY, &validation);
match result {
Ok(token_data) => {
if token_data.claims.exp < Utc::now().timestamp().unsigned_abs() {
Err(Error::Expired)
} else {
Ok(token_data.claims.id)
Ok(token_data.claims)
}
}
Err(err) => Err(match err.into_kind() {
ErrorKind::InvalidSignature => Error::InvalidSignature,
ErrorKind::ExpiredSignature => Error::Expired,
kind => Error::InvalidToken(kind),
_ => Error::InvalidToken,
}),
}
}
/// Creating a user token.
pub fn encode(id: &String) -> String {
pub fn encode(user_type: UserType, id: &str) -> String {
let header = Header {
typ: Some(String::from("JWT")),
..Default::default()
@@ -98,12 +102,13 @@ pub fn encode(id: &String) -> String {
let exp = iat + Duration::days(365 * 4);
let claims = Claims {
id: id.clone(),
id: id.to_string(),
user_type: Some(user_type),
iat: iat.timestamp().unsigned_abs(),
exp: exp.timestamp().unsigned_abs(),
};
jsonwebtoken::encode(&header, &claims, &*ENCODING_KEY).unwrap()
jsonwebtoken::encode(&header, &claims, &ENCODING_KEY).unwrap()
}
#[cfg(test)]
@@ -115,7 +120,7 @@ mod tests {
fn test_encode() {
test_env();
assert_eq!(encode(&"test".to_string()).is_empty(), false);
assert!(!encode(UserType::Default, "test").is_empty());
}
#[test]
@@ -126,10 +131,7 @@ mod tests {
let result = verify_and_decode(&token);
assert!(result.is_err());
assert_eq!(
result.err().unwrap(),
Error::InvalidToken(ErrorKind::InvalidToken)
);
assert_eq!(result.err().unwrap(), Error::InvalidToken);
}
//noinspection SpellCheckingInspection
+1
View File
@@ -1,2 +1,3 @@
pub mod jwt;
pub mod telegram;
pub mod req_auth;
+56
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)
}
+15 -9
View File
@@ -1,10 +1,13 @@
use aws_lc_rs::signature::{ED25519, UnparsedPublicKey};
use base64::Engine;
use derive_more::{Display, Error};
use ed25519_dalek::Verifier;
use hex_literal::hex;
use serde::Deserialize;
use std::collections::HashMap;
/// Длина подписи Ed25519 в байтах.
const ED25519_SIGNATURE_LENGTH: usize = 64;
pub struct WebAppInitDataMap {
pub data_map: HashMap<String, String>,
}
@@ -33,7 +36,7 @@ impl WebAppInitDataMap {
};
data.split('&')
.map(|kv| kv.split_once('=').unwrap_or_else(|| (kv, "")))
.map(|kv| kv.split_once('=').unwrap_or((kv, "")))
.for_each(|(key, value)| {
this.data_map.insert(key.to_string(), value.to_string());
});
@@ -54,10 +57,10 @@ impl WebAppInitDataMap {
hex!("40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec"),
];
let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(
&TELEGRAM_PUBLIC_KEY[if test_dc { 1 } else { 0 }],
)
.unwrap();
let verifying_key = UnparsedPublicKey::new(
&ED25519,
TELEGRAM_PUBLIC_KEY[if test_dc { 1 } else { 0 }],
);
let signature = {
let raw = self
@@ -69,8 +72,11 @@ impl WebAppInitDataMap {
.decode(raw)
.map_err(|_| VerifyError::BadSignature)?;
ed25519_dalek::Signature::from_slice(bytes.as_slice())
.map_err(|_| VerifyError::BadSignature)?
if bytes.len() != ED25519_SIGNATURE_LENGTH {
return Err(VerifyError::BadSignature);
}
bytes
};
let data_check_string = format!("{}:WebAppData\n{}", bot_id, {
@@ -85,7 +91,7 @@ impl WebAppInitDataMap {
});
verifying_key
.verify(data_check_string.as_bytes(), &signature)
.verify(data_check_string.as_bytes(), signature.as_slice())
.map_err(|_| VerifyError::IntegrityCheckFailed)
}
}