diff --git a/.example.env b/.example.env index ea3b6af..449f080 100644 --- a/.example.env +++ b/.example.env @@ -21,6 +21,13 @@ URL= POSTGRES_USER="mw" POSTGRES_PASSWORD= +# Auth +AUTH_UNTIS_URL="https://mese.webuntis.com/WebUntis/" +AUTH_UNTIS_CLIENT_NAME="mentorenwahl" +AUTH_UNTIS_SCHOOL= +AUTH_UNTIS_USERNAME= +AUTH_UNTIS_PASSWORD= + # Backend BACKEND_MINIMUM_TEACHER_SELECTION_COUNT=6 BACKEND_ASSIGNMENT_POSSIBILITY_COUNT=65536 diff --git a/.gitignore b/.gitignore index d826dd0..c2ae6eb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ # along with this program. If not, see . .env +data/ diff --git a/auth/.editorconfig b/auth/.editorconfig new file mode 100644 index 0000000..be76497 --- /dev/null +++ b/auth/.editorconfig @@ -0,0 +1,25 @@ +# Mentorenwahl: A fullstack application for assigning mentors to students based on their whishes. +# Copyright (C) 2022 Dominic Grimm +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +root = true + +[*.rs] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/auth/.gitignore b/auth/.gitignore new file mode 100644 index 0000000..a74235e --- /dev/null +++ b/auth/.gitignore @@ -0,0 +1,17 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +dist/ +vendor/ diff --git a/auth/Cargo.toml b/auth/Cargo.toml new file mode 100644 index 0000000..fbec38c --- /dev/null +++ b/auth/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "auth" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4.3.0" +anyhow = { version = "1.0.68", features = ["backtrace"] } +base64 = "0.21.0" +deunicode = "1.3.3" +envconfig = "0.10.0" +fancy-regex = "0.11.0" +futures = "0.3.25" +lazy_static = "1.4.0" +mime = "0.3.16" +serde = { version = "1.0.152", features = ["derive"] } +untis = { git = "https://git.dergrimm.net/dergrimm/untis.rs.git", branch = "main" } +url = "2.3.1" +uuid = { version = "1.2.2", features = ["v4", "fast-rng", "macro-diagnostics"] } +wkhtmltopdf = "0.4.0" + +[target.'cfg(not(target_env = "msvc"))'.dependencies] +tikv-jemallocator = "0.5" diff --git a/auth/Dockerfile b/auth/Dockerfile new file mode 100644 index 0000000..7c32424 --- /dev/null +++ b/auth/Dockerfile @@ -0,0 +1,42 @@ +FROM docker.io/lukemathwalker/cargo-chef:latest-rust-1.65.0 as chef + +FROM chef as planner +WORKDIR /usr/src/auth +RUN mkdir src && touch src/main.rs +COPY ./Cargo.toml ./Cargo.lock ./ +RUN cargo chef prepare --recipe-path recipe.json + +FROM alpine as wkhtmltopdf +WORKDIR /tmp +RUN wget -O wkhtmltopdf.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.bullseye_amd64.deb + +FROM chef as builder +WORKDIR /tmp +RUN apt update +RUN apt install -y xfonts-base xfonts-75dpi +COPY --from=wkhtmltopdf /tmp/wkhtmltopdf.deb . +RUN dpkg -i wkhtmltopdf.deb +RUN rm wkhtmltopdf.deb +RUN apt-get clean +RUN apt-get autoremove -y +WORKDIR /usr/src/auth +COPY --from=planner /usr/src/auth/recipe.json . +RUN cargo chef cook --release --recipe-path recipe.json +COPY ./src ./src +RUN cargo build --release + +FROM docker.io/debian:bullseye-slim as runner +WORKDIR /tmp +RUN apt update +RUN apt install -y ca-certificates +RUN apt install -y wget xfonts-base xfonts-75dpi fontconfig libjpeg62-turbo libx11-6 libxcb1 libxext6 libxrender1 +COPY --from=wkhtmltopdf /tmp/wkhtmltopdf.deb . +RUN dpkg -i wkhtmltopdf.deb +RUN rm wkhtmltopdf.deb +RUN apt-get clean +RUN apt-get autoremove -y +RUN rm -rf /var/lib/{apt,dpkg,cache,log}/ +WORKDIR /usr/src/auth +COPY --from=builder /usr/src/auth/target/release/auth ./bin/auth +EXPOSE 80 +ENTRYPOINT [ "./bin/auth" ] diff --git a/auth/src/config.rs b/auth/src/config.rs new file mode 100644 index 0000000..9010b7d --- /dev/null +++ b/auth/src/config.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use envconfig::Envconfig; +use lazy_static::lazy_static; +use url::Url; + +#[derive(Envconfig, Debug)] +pub struct Config { + #[envconfig(from = "AUTH_UNTIS_URL")] + pub untis_url: String, + + #[envconfig(from = "AUTH_UNTIS_CLIENT_NAME")] + pub untis_client_name: String, + + #[envconfig(from = "AUTH_UNTIS_SCHOOL")] + pub untis_school: String, + + #[envconfig(from = "AUTH_UNTIS_USERNAME")] + pub untis_username: String, + + #[envconfig(from = "AUTH_UNTIS_PASSWORD")] + pub untis_password: String, +} + +lazy_static! { + pub static ref CONFIG: Config = Config::init_from_env().unwrap(); +} + +pub fn untis_from_env() -> Result { + let webuntis_url = Url::parse(&CONFIG.untis_url)?; + + Ok(untis::Client { + rpc_url: untis::Client::gen_rpc_url(&webuntis_url, &CONFIG.untis_school)?, + webuntis_url, + client_name: CONFIG.untis_client_name.to_owned(), + user_agent: "".to_string(), + username: CONFIG.untis_username.to_owned(), + password: CONFIG.untis_password.to_owned(), + session: None, + authorization: None, + }) +} diff --git a/auth/src/lib.rs b/auth/src/lib.rs new file mode 100644 index 0000000..050c36f --- /dev/null +++ b/auth/src/lib.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod pdf; +pub mod users; diff --git a/auth/src/main.rs b/auth/src/main.rs new file mode 100644 index 0000000..52b0209 --- /dev/null +++ b/auth/src/main.rs @@ -0,0 +1,21 @@ +#[cfg(not(target_env = "msvc"))] +use tikv_jemallocator::Jemalloc; + +#[cfg(not(target_env = "msvc"))] +#[global_allocator] +static GLOBAL: Jemalloc = Jemalloc; + +use actix_web::{middleware, App, HttpServer}; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| { + App::new() + .wrap(middleware::Logger::default()) + .service(auth::users::get_users) + .service(auth::pdf::post_pdf) + }) + .bind(("0.0.0.0", 80))? + .run() + .await +} diff --git a/auth/src/pdf.rs b/auth/src/pdf.rs new file mode 100644 index 0000000..0e6528d --- /dev/null +++ b/auth/src/pdf.rs @@ -0,0 +1,59 @@ +use actix_web::{post, web::Json, HttpResponse}; +use serde::{Deserialize, Serialize}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +#[derive(Deserialize, Debug)] +pub struct PostPdfRequest { + pub html: String, +} + +#[derive(Serialize)] +pub struct PostPdfResponse { + pub error: Option, + pub filename: Option, +} + +#[post("/v1/pdf")] +async fn post_pdf(data: Json) -> HttpResponse { + let pdf_app = match wkhtmltopdf::PdfApplication::new() { + Ok(x) => x, + Err(x) => { + return HttpResponse::InternalServerError().json(PostPdfResponse { + error: Some(x.to_string()), + filename: None, + }) + } + }; + let mut pdfout = match pdf_app + .builder() + .orientation(wkhtmltopdf::Orientation::Portrait) + .margin(wkhtmltopdf::Size::Millimeters(10)) + .build_from_html(&data.html) + { + Ok(x) => x, + Err(x) => { + return HttpResponse::InternalServerError().json(PostPdfResponse { + error: Some(x.to_string()), + filename: None, + }) + } + }; + + let start = SystemTime::now(); + let since_epoch = start + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + let filename = format!("/static/{}_{}.pdf", since_epoch.as_secs(), Uuid::new_v4()); + if let Err(x) = pdfout.save(&filename) { + return HttpResponse::InternalServerError().json(PostPdfResponse { + error: Some(x.to_string()), + filename: None, + }); + } + + HttpResponse::Created().json(PostPdfResponse { + error: None, + filename: Some(filename), + }) +} diff --git a/auth/src/users.rs b/auth/src/users.rs new file mode 100644 index 0000000..d27037b --- /dev/null +++ b/auth/src/users.rs @@ -0,0 +1,194 @@ +use actix_web::{get, HttpResponse}; +use anyhow::Result; +use deunicode::deunicode; +use fancy_regex::Regex; +use lazy_static::lazy_static; +use serde::Serialize; +use std::collections::HashMap; + +use crate::config; + +lazy_static! { + static ref CLASS_REGEX: Regex = Regex::new(r"(9|10)\s?(?!R)[a-z]").unwrap(); +} + +#[derive(Serialize, Debug)] +pub struct Class { + pub name: String, + pub students: Vec, +} + +#[derive(Serialize, Debug)] +pub struct User { + pub username: String, + pub first_name: String, + pub last_name: String, +} + +#[derive(Serialize, Debug)] +pub struct GetStudentsResponse { + pub error: Option, + pub classes: Vec, + pub teachers: Vec, +} + +fn escape_username(s: &str) -> String { + deunicode( + &s.to_lowercase() + .chars() + .filter(|c| !c.is_whitespace()) + .collect::(), + ) +} + +async fn students( + client: &mut untis::Client, + usernames: &mut HashMap, +) -> Result> { + Ok(futures::future::try_join_all( + client + .classes() + .await? + .iter() + .filter(|c| CLASS_REGEX.is_match(&c.name).unwrap()) + .map(|c| async { + Ok::<_, anyhow::Error>(Class { + name: c.name.to_owned(), + students: client + .student_reports(c.id) + .await? + .into_iter() + .map(|s| User { + username: escape_username(&s.name), + first_name: s.fore_name, + last_name: s.long_name, + }) + .collect(), + }) + }), + ) + .await? + .into_iter() + .map(|c| Class { + name: c.name, + students: c + .students + .into_iter() + .map(|s| User { + username: { + let escaped = escape_username(&s.username); + match usernames.get_mut(&escaped) { + Some(x) => { + *x += 1; + format!("{}{}", escaped, x) + } + None => { + usernames.insert(escaped.to_owned(), 1); + escaped + } + } + }, + first_name: s.first_name, + last_name: s.last_name, + }) + .collect(), + }) + .collect()) +} + +async fn teachers( + client: &mut untis::Client, + usernames: &mut HashMap, +) -> Result> { + Ok(client + .teacher_reports() + .await? + .into_iter() + .filter_map(|t| { + if t.long_name.starts_with("Abi-Aufsicht") + || t.long_name == "SBBZ" + || t.long_name == "N.N." + || t.long_name == "Werkrealschule" + { + None + } else { + Some(User { + username: { + let escaped = escape_username(&t.name); + match usernames.get_mut(&escaped) { + Some(x) => { + *x += 1; + format!("{}{}", escaped, x) + } + None => { + usernames.insert(escaped.to_owned(), 1); + escaped + } + } + }, + first_name: t.fore_name, + last_name: t.long_name, + }) + } + }) + .collect()) +} + +#[get("/v1/users")] +pub async fn get_users() -> HttpResponse { + let mut client = match config::untis_from_env() { + Ok(x) => x, + Err(x) => { + return HttpResponse::InternalServerError().json(GetStudentsResponse { + error: Some(x.to_string()), + classes: vec![], + teachers: vec![], + }) + } + }; + if let Err(x) = client.login().await { + return HttpResponse::InternalServerError().json(GetStudentsResponse { + error: Some(x.to_string()), + classes: vec![], + teachers: vec![], + }); + } + + let mut usernames = HashMap::::new(); + let classes = match students(&mut client, &mut usernames).await { + Ok(x) => x, + Err(x) => { + return HttpResponse::InternalServerError().json(GetStudentsResponse { + error: Some(x.to_string()), + classes: vec![], + teachers: vec![], + }) + } + }; + let teachers = match teachers(&mut client, &mut usernames).await { + Ok(x) => x, + Err(x) => { + return HttpResponse::InternalServerError().json(GetStudentsResponse { + error: Some(x.to_string()), + classes: vec![], + teachers: vec![], + }) + } + }; + + if let Err(x) = client.logout().await { + return HttpResponse::InternalServerError().json(GetStudentsResponse { + error: Some(x.to_string()), + classes: vec![], + teachers: vec![], + }); + } + + HttpResponse::Ok() + .content_type(mime::APPLICATION_JSON) + .json(GetStudentsResponse { + error: None, + classes, + teachers, + }) +} diff --git a/backend/Dockerfile b/backend/Dockerfile index d93321c..3f3388b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,12 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -FROM crystallang/crystal:1.6.2-alpine as micrate-deps +FROM docker.io/crystallang/crystal:1.6.2-alpine as crystal + +FROM crystal as micrate-deps WORKDIR /usr/src/micrate COPY ./micrate/shard.yml ./micrate/shard.lock ./ RUN shards install --production -FROM crystallang/crystal:1.6.2-alpine as micrate-builder +FROM crystal as micrate-builder WORKDIR /usr/src/micrate COPY --from=micrate-deps /usr/src/micrate/shard.yml /usr/src/micrate/shard.lock ./ COPY --from=micrate-deps /usr/src/micrate/lib ./lib @@ -31,12 +33,12 @@ WORKDIR /usr/src/public COPY ./public ./src RUN minify -r -o ./dist ./src -FROM crystallang/crystal:1.6.2-alpine as deps +FROM crystal as deps WORKDIR /usr/src/mentorenwahl COPY ./shard.yml ./shard.lock ./ RUN shards install --production -FROM crystallang/crystal:1.6.2-alpine as builder +FROM crystal as builder WORKDIR /usr/src/mentorenwahl RUN apk add --no-cache pcre2-dev RUN mkdir deps diff --git a/backend/db/migrations/20220414171336_create_users.sql b/backend/db/migrations/20220414171336_create_users.sql index a3a7d31..d74a502 100644 --- a/backend/db/migrations/20220414171336_create_users.sql +++ b/backend/db/migrations/20220414171336_create_users.sql @@ -22,6 +22,9 @@ CREATE TYPE user_roles AS ENUM ('student', 'teacher'); CREATE TABLE users( id serial PRIMARY KEY, username text UNIQUE NOT NULL, + password_hash text NOT NULL, + first_name text NOT NULL, + last_name text NOT NULL, role user_roles NOT NULL, admin boolean NOT NULL ); @@ -40,9 +43,12 @@ CREATE TABLE teachers( max_students int NOT NULL ); +CREATE TABLE classes(id serial PRIMARY KEY, name text UNIQUE NOT NULL); + CREATE TABLE students( id serial PRIMARY KEY, - user_id int NOT NULL UNIQUE REFERENCES users(id) + user_id int NOT NULL UNIQUE REFERENCES users(id), + class_id int NOT NULL REFERENCES classes(id) ); CREATE TABLE votes( @@ -78,6 +84,8 @@ DROP TABLE teachers; DROP TABLE students; +DROP TABLE classes; + DROP TABLE tokens; DROP TABLE users; diff --git a/backend/micrate/shard.yml b/backend/micrate/shard.yml index 2772b0c..dff0666 100644 --- a/backend/micrate/shard.yml +++ b/backend/micrate/shard.yml @@ -8,7 +8,7 @@ targets: micrate: main: src/micrate.cr -crystal: 1.6.2 +crystal: 1.7.1 dependencies: micrate: diff --git a/backend/shard.lock b/backend/shard.lock index f2e10f9..b9f86bc 100644 --- a/backend/shard.lock +++ b/backend/shard.lock @@ -152,7 +152,15 @@ shards: git: https://github.com/maiha/shard.cr.git version: 1.0.0 + tallboy: + git: https://github.com/epoch/tallboy.git + version: 0.9.3 + version_from_shard: git: https://github.com/hugopl/version_from_shard.git version: 1.2.5 + wannabe_bool: + git: https://github.com/llamicron/wannabe_bool.git + version: 0.1.0+git.commit.a64a71a091094d0ba88cf6b81598aa268656ece3 + diff --git a/backend/shard.yml b/backend/shard.yml index 06f1463..090f548 100644 --- a/backend/shard.yml +++ b/backend/shard.yml @@ -69,3 +69,7 @@ dependencies: git: https://git.dergrimm.net/dergrimm/compiled_license.git docker: github: repomaa/docker.cr + tallboy: + github: epoch/tallboy + wannabe_bool: + github: llamicron/wannabe_bool diff --git a/backend/src/backend/api/context.cr b/backend/src/backend/api/context.cr index ccac7fe..09d37e5 100644 --- a/backend/src/backend/api/context.cr +++ b/backend/src/backend/api/context.cr @@ -24,9 +24,6 @@ module Backend module Api # GraphQL request context class class Context < GraphQL::Context - # Development mode - getter development - # Request status getter status @@ -46,7 +43,6 @@ module Backend getter jti def initialize( - @development : Bool, @status : Status, @user : Db::User?, @admin : Bool?, @@ -56,7 +52,7 @@ module Backend ) end - def initialize(headers : HTTP::Headers, @development : Bool, @status = Status::OK, *rest) + def initialize(headers : HTTP::Headers, @status = Status::OK, *rest) super(*rest) if (token = headers["authorization"]?) && token.starts_with?(Auth::BEARER) @@ -89,9 +85,7 @@ module Backend def on_development : Nil {% if !flag?(:release) %} - if @development - yield - end + yield {% end %} end @@ -191,21 +185,13 @@ module Backend ex.api_message when Errors::PrivateError {% if !flag?(:release) %} - if @development - ex.api_message - else - Errors::UNKNOWN_PRIVATE_ERROR - end + ex.api_message {% else %} Errors::UNKNOWN_PRIVATE_ERROR {% end %} else {% if !flag?(:release) %} - if @development - ex.message || Errors::UNKNOWN_PRIVATE_ERROR - else - Errors::UNKNOWN_PRIVATE_ERROR - end + ex.message || Errors::UNKNOWN_PRIVATE_ERROR {% else %} Errors::UNKNOWN_PRIVATE_ERROR {% end %} diff --git a/backend/src/backend/api/schema/mutation.cr b/backend/src/backend/api/schema/mutation.cr index a470fc3..eb2d92b 100644 --- a/backend/src/backend/api/schema/mutation.cr +++ b/backend/src/backend/api/schema/mutation.cr @@ -28,8 +28,8 @@ module Backend def login(username : String, password : String) : LoginPayload? raise Errors::Authentication.new if username.empty? || password.empty? - user = Db::User.query.find { var(:username) == username } - raise Errors::Authentication.new unless user && Ldap.authenticate?(Ldap::DN.uid(username), password) + user = Db::User.query.find { var(:username) == username.downcase } + raise Errors::Authentication.new unless user && user.password_hash.verify(password) token = Db::Token.create!( id: UUID.random(Random::Secure), @@ -73,24 +73,24 @@ module Backend Scalars::UUID.new(token.value) end - @[GraphQL::Field] - # Creates user - def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User? - context.admin! + # @[GraphQL::Field] + # # Creates user + # def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User? + # context.admin! - raise Errors::LdapUserDoesNotExist.new if check_ldap && begin - !Ldap::User.from_username(input.username) - rescue LDAP::Client::AuthError - true - end - user = Db::User.create!(username: input.username, role: input.role.to_db, admin: input.admin) - if input.role.student? - Db::Student.create!(user_id: user.id) - end - Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue + # raise Errors::LdapUserDoesNotExist.new if check_ldap && begin + # !Ldap::User.from_username(input.username) + # rescue LDAP::Client::AuthError + # true + # end + # user = Db::User.create!(username: input.username, role: input.role.to_db, admin: input.admin) + # if input.role.student? + # Db::Student.create!(user_id: user.id) + # end + # Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue - User.new(user) - end + # User.new(user) + # end @[GraphQL::Field] # Deletes user by ID @@ -122,14 +122,14 @@ module Backend # Teacher.new(teacher) # end - @[GraphQL::Field] - def register_teacher(context : Context, input : TeacherInput) : Teacher? - context.teacher!(false) - raise Errors::InvalidPermissions.new if context.user.not_nil!.teacher + # @[GraphQL::Field] + # def register_teacher(context : Context, input : TeacherInput) : Teacher? + # context.teacher!(false) + # raise Errors::InvalidPermissions.new if context.user.not_nil!.teacher - teacher = Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students) - Teacher.new(teacher) - end + # teacher = Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students) + # Teacher.new(teacher) + # end # @[GraphQL::Field] # # Deletes teacher by ID @@ -142,15 +142,15 @@ module Backend # id # end - # @[GraphQL::Field] - # # Self register as teacher - # def register_teacher(context : Context, input : TeacherInput) : Teacher - # context.teacher! external_check: false + @[GraphQL::Field] + # Self register as teacher + def register_teacher(context : Context, input : TeacherInput) : Teacher + context.teacher! external_check: false - # Teacher.new( - # Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students) - # ) - # end + Teacher.new( + Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students) + ) + end # @[GraphQL::Field] # # Creates student diff --git a/backend/src/backend/api/schema/user.cr b/backend/src/backend/api/schema/user.cr index 5ee8b84..f318cf1 100644 --- a/backend/src/backend/api/schema/user.cr +++ b/backend/src/backend/api/schema/user.cr @@ -46,34 +46,41 @@ module Backend db_object Db::User, Int32 - @ldap : Ldap::User? + # @ldap : Ldap::User? - # LDAP user data - def ldap : Ldap::User - if @ldap.nil? && (raw_cache = Redis::CLIENT.get("ldap:user:#{id}")).nil? - Worker::Jobs::CacheLdapUserJob.new(id).perform - raw_cache = Redis::CLIENT.get("ldap:user:#{id}").not_nil! - end + # # LDAP user data + # def ldap : Ldap::User + # if @ldap.nil? && (raw_cache = Redis::CLIENT.get("ldap:user:#{id}")).nil? + # Worker::Jobs::CacheLdapUserJob.new(id).perform + # raw_cache = Redis::CLIENT.get("ldap:user:#{id}").not_nil! + # end - (@ldap ||= Ldap::User.from_json(raw_cache.not_nil!)).not_nil! - end + # (@ldap ||= Ldap::User.from_json(raw_cache.not_nil!)).not_nil! + # end @[GraphQL::Field] # User's first name def first_name : String - ldap.first_name + # ldap.first_name + @model.first_name end @[GraphQL::Field] # User's last name def last_name : String - ldap.last_name + # ldap.last_name + @model.last_name end @[GraphQL::Field] # User's full name def name(formal : Bool = true) : String - ldap.name(formal) + # ldap.name(formal) + if formal + "#{@model.last_name}, #{@model.first_name}" + else + "#{@model.first_name} #{@model.last_name}" + end end @[GraphQL::Field] @@ -82,12 +89,6 @@ module Backend @model.username end - @[GraphQL::Field] - # User's email - def email : String - ldap.email - end - @[GraphQL::Field] # User is admin def admin : Bool diff --git a/backend/src/backend/auth.cr b/backend/src/backend/auth.cr new file mode 100644 index 0000000..27f0480 --- /dev/null +++ b/backend/src/backend/auth.cr @@ -0,0 +1,51 @@ +require "http/client" + +module Backend::Auth + extend self + + struct UsersResponse + include JSON::Serializable + + struct Class + include JSON::Serializable + + getter name : String + getter students : Array(User) + end + + struct User + include JSON::Serializable + + getter username : String + getter first_name : String + getter last_name : String + end + + getter error : String? + getter classes : Array(Class) + getter teachers : Array(User) + end + + struct GeneratePdfResponse + include JSON::Serializable + + getter error : String? + getter filename : String? + end + + def users : UsersResponse + resp = HTTP::Client.get(Path[Backend.config.auth.url, "users"].to_s) + data = UsersResponse.from_json(resp.body) + raise "Error in response (#{resp.status_code}): #{data.error}" if resp.status_code != 200 + + data + end + + def generate_pdf(html : String) : GeneratePdfResponse + resp = HTTP::Client.post(Path[Backend.config.auth.url, "pdf"].to_s, body: {:html => html}.to_json) + data = GeneratePdfResponse.from_json(resp.body) + raise "Error in response (#{resp.status_code}): #{data.error}" if resp.status_code != 201 + + data + end +end diff --git a/backend/src/backend/cli.cr b/backend/src/backend/cli.cr index 1aa6144..686a77f 100644 --- a/backend/src/backend/cli.cr +++ b/backend/src/backend/cli.cr @@ -1,4 +1,6 @@ require "commander" +require "tallboy" +require "wannabe_bool" module Backend CLI = Commander::Command.new do |cmd| @@ -60,41 +62,160 @@ module Backend end cmd.commands.add do |c| - c.use = "register " - c.short = "Seeds the database with required data" + c.use = "seed" + c.short = "Imports users from Untis" c.long = c.short - c.flags.add do |f| - f.name = "admin" - f.long = "--admin" - f.default = false - f.description = "Register as admin" - end + c.run do + users = Auth.users - c.flags.add do |f| - f.name = "yes" - f.short = "-y" - f.long = "--yes" - f.default = false - f.description = "Answer yes to all questions" - end - - c.run do |opts, args| - username = args[0] - role = Db::UserRole.from_string(args[1].underscore) - unless opts.bool["yes"] - print "Register '#{username}' as '#{role.to_api}'#{opts.bool["admin"] ? " with admin privileges" : nil}? [y/N] " - abort unless gets(chomp: true).not_nil!.strip.downcase == "y" + students = [] of Templates::Users::Student + users.classes.each do |cl| + c_db = Db::Class.create!({name: cl.name}) + cl.students.each do |s| + password = Password.generate(Password::DEFAULT_LEN) + students << Templates::Users::Student.new( + class_name: cl.name, + user: Templates::Users::User.new( + first_name: s.first_name, + last_name: s.last_name, + username: s.username, + password: password + ) + ) + user = Db::User.create!({ + username: s.username, + password: password, + first_name: s.first_name, + last_name: s.last_name, + role: Db::UserRole::Student, + admin: false, + }) + Db::Student.create!({ + user_id: user.id, + class_id: c_db.id, + }) + end end - user = Db::User.create!(username: username, role: role.to_s, admin: opts.bool["admin"]) - if role == Db::UserRole::Student - Db::Student.create!(user_id: user.id) + teachers = [] of Templates::Users::User + Db::User.import( + users.teachers.map do |t| + password = Password.generate(Password::DEFAULT_LEN) + teachers << Templates::Users::User.new( + first_name: t.first_name, + last_name: t.last_name, + username: t.username, + password: password + ) + + Db::User.new({ + username: t.username, + password: password, + first_name: t.first_name, + last_name: t.last_name, + role: Db::UserRole::Teacher, + admin: false, + }) + end + ) + + html = Templates::Users.new(students, teachers).to_s + puts "Filepath: #{Auth.generate_pdf(html).filename}" + end + end + + # cmd.commands.add do |c| + # c.use = "register " + # c.short = "Seeds the database with required data" + # c.long = c.short + + # c.flags.add do |f| + # f.name = "admin" + # f.long = "--admin" + # f.default = false + # f.description = "Register as admin" + # end + + # c.flags.add do |f| + # f.name = "yes" + # f.short = "-y" + # f.long = "--yes" + # f.default = false + # f.description = "Answer yes to all questions" + # end + + # c.run do |opts, args| + # username = args[0] + # role = Db::UserRole.from_string(args[1].underscore) + # unless opts.bool["yes"] + # print "Register '#{username}' as '#{role.to_api}'#{opts.bool["admin"] ? " with admin privileges" : nil}? [y/N] " + # abort unless gets(chomp: true).not_nil!.strip.downcase == "y" + # end + + # user = Db::User.create!(username: username, role: role.to_s, admin: opts.bool["admin"]) + # if role == Db::UserRole::Student + # Db::Student.create!(user_id: user.id) + # end + + # # Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue + + # puts "Done!" + # end + # end + + # ameba:disable Lint/ShadowingOuterLocalVar + cmd.commands.add do |cmd| + cmd.use = "user:list" + cmd.short = "Lists all users" + cmd.long = cmd.short + + cmd.run do + users = Db::User.query.to_a + table = Tallboy.table do + header ["id", "username", "first_name", "last_name", "role", "admin"] + + users.each do |user| + row [ + user.id, + user.username, + user.first_name, + user.last_name, + user.role, + user.admin, + ] + end end - Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue + puts table + end + end - puts "Done!" + # ameba:disable Lint/ShadowingOuterLocalVar + cmd.commands.add do |cmd| + cmd.use = "user:admin " + cmd.short = "Gives or removed admin rights" + cmd.long = cmd.short + + cmd.run do |_opts, args| + user = Db::User.find!(args[0].to_i) + user.admin = args[1].to_b + user.save! + + table = Tallboy.table do + header ["id", "username", "first_name", "last_name", "role", "admin"] + + row [ + user.id, + user.username, + user.first_name, + user.last_name, + user.role, + user.admin, + ] + end + + puts table end end end diff --git a/backend/src/backend/config.cr b/backend/src/backend/config.cr index c26f60b..96927a8 100644 --- a/backend/src/backend/config.cr +++ b/backend/src/backend/config.cr @@ -83,6 +83,10 @@ module Backend # Configuration for `Ldap` getter ldap : LdapConfig + @[EnvConfig::Setting(key: "auth")] + # Configuration for authorization provider + getter auth : AuthConfig + # Configuration for `Api` class ApiConfig include EnvConfig @@ -178,5 +182,13 @@ module Backend # Periodical cache refresh interval getter cache_refresh_interval : Int32 end + + # Configuration for authoriuation API + class AuthConfig + include EnvConfig + + # Auth API URL + getter url : String + end end end diff --git a/backend/src/backend/db/class.cr b/backend/src/backend/db/class.cr new file mode 100644 index 0000000..6fd265e --- /dev/null +++ b/backend/src/backend/db/class.cr @@ -0,0 +1,10 @@ +class Backend::Db::Class + include Clear::Model + self.table = :classes + + primary_key type: :serial + + column name : String + + has_many students : Student, foreign_key: :class_id +end diff --git a/backend/src/backend/db/student.cr b/backend/src/backend/db/student.cr index 8d797f3..db6b33b 100644 --- a/backend/src/backend/db/student.cr +++ b/backend/src/backend/db/student.cr @@ -6,6 +6,7 @@ module Backend::Db primary_key type: :serial belongs_to user : User + belongs_to class_model : Class, foreign_key: :class_id has_one vote : Vote?, foreign_key: :student_id has_one assignment : Assignment?, foreign_key: :student_id diff --git a/backend/src/backend/db/user.cr b/backend/src/backend/db/user.cr index 6888408..9793422 100644 --- a/backend/src/backend/db/user.cr +++ b/backend/src/backend/db/user.cr @@ -27,6 +27,9 @@ module Backend::Db primary_key type: :serial column username : String + column password_hash : Crypto::Bcrypt::Password + column first_name : String + column last_name : String column role : UserRole column admin : Bool = false @@ -34,5 +37,9 @@ module Backend::Db has_one teacher : Teacher?, foreign_key: :user_id has_many tokens : Token, foreign_key: :user_id + + def password=(x : String) + self.password_hash = Crypto::Bcrypt::Password.create(x) + end end end diff --git a/backend/src/backend/password.cr b/backend/src/backend/password.cr new file mode 100644 index 0000000..ab8ca88 --- /dev/null +++ b/backend/src/backend/password.cr @@ -0,0 +1,18 @@ +module Backend::Password + extend self + + DEFAULT_LEN = 10_u32 + DEFAULT_CHARSET = { + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + } + + def generate(len : UInt32) : String + String.build(len) do |s| + len.times do + c = DEFAULT_CHARSET.sample(Random::Secure) + s << (((Random::Secure.rand(UInt8) & 1) == 1) ? c.upcase : c) + end + end + end +end diff --git a/backend/src/backend/runner.cr b/backend/src/backend/runner.cr index 5446cfc..8978dbb 100644 --- a/backend/src/backend/runner.cr +++ b/backend/src/backend/runner.cr @@ -38,6 +38,7 @@ module Backend def run : self {% if !flag?(:release) %} Log.warn { "Backend is running in development mode! Do not use this in production!" } + pp Backend.config {% end %} Log.info { "Checking if DB schema is up to date..." } diff --git a/backend/src/backend/templates.cr b/backend/src/backend/templates.cr new file mode 100644 index 0000000..75873ac --- /dev/null +++ b/backend/src/backend/templates.cr @@ -0,0 +1,6 @@ +require "ecr" + +require "./templates/*" + +module Backend::Templates +end diff --git a/backend/src/backend/templates/users.cr b/backend/src/backend/templates/users.cr new file mode 100644 index 0000000..f0892c5 --- /dev/null +++ b/backend/src/backend/templates/users.cr @@ -0,0 +1,29 @@ +class Backend::Templates::Users + struct User + property first_name + property last_name + property username + property password + + def initialize( + @first_name : String, + @last_name : String, + @username : String, + @password : String + ) + end + end + + struct Student + property class_name + property user + + def initialize(@class_name : String, @user : User) + end + end + + def initialize(@students : Array(Student), @teachers : Array(User)) + end + + ECR.def_to_s "#{__DIR__}/users.ecr" +end diff --git a/backend/src/backend/templates/users.ecr b/backend/src/backend/templates/users.ecr new file mode 100644 index 0000000..f8eb1d0 --- /dev/null +++ b/backend/src/backend/templates/users.ecr @@ -0,0 +1,132 @@ + + + + + + + Benutzeraccounts | Mentorenwahl + + + + +
+

Schüler:

+ + + + + + + + + <%- @students.in_groups_of(4).each do |group| -%> + + <%- group.each do |student| %> + + <%- end -%> + + <%- end -%> +
+ <%- if student -%> + + + + + + + + + + +
+ <%= student.user.last_name %>, <%= student.user.first_name %> (<%= student.class_name %>) +
+
+ <%= student.user.username %> +
+
+ <%= student.user.password %> +
+ <%- end -%> +
+
+
+ +
+

Lehrer:

+ + + + + + + + + <%- @teachers.in_groups_of(4).each do |group| -%> + + <%- group.each do |teacher| %> + + <%- end -%> + + <%- end -%> +
+ <%- if teacher -%> + + + + + + + + + + +
+ <%= teacher.last_name %>, <%= teacher.first_name %> +
+
+ <%= teacher.username %> +
+
+ <%= teacher.password %> +
+ <%- end -%> +
+
+ + diff --git a/backend/src/backend/web/controllers/api_controller.cr b/backend/src/backend/web/controllers/api_controller.cr index 3277558..1de0ef4 100644 --- a/backend/src/backend/web/controllers/api_controller.cr +++ b/backend/src/backend/web/controllers/api_controller.cr @@ -59,21 +59,14 @@ module Backend end @[ARTA::Post("")] - {% if !flag?(:release) %} - @[ATHA::QueryParam("development", description: "Enables development mode")] - {% end %} - def endpoint(request : ATH::Request, development : Bool = false) : ATH::Exceptions::BadRequest | ATH::Response - {% if !flag?(:release) %} - Log.notice { "Development request icoming" } if development - {% end %} - + def endpoint(request : ATH::Request) : ATH::Exceptions::BadRequest | ATH::Response return ATH::Exceptions::BadRequest.new("No request body given") unless request.body query = GraphQLQuery.from_json(request.body.not_nil!) ATH::StreamedResponse.new( headers: HTTP::Headers{ "content-type" => "application/json", - "cache-control" => ["no-cache", "no-store", "max-age=0", "must-revalidate"], + "cache-control" => {"no-cache", "no-store", "max-age=0", "must-revalidate"}, } ) do |io| Api::Schema::SCHEMA.execute( @@ -81,7 +74,7 @@ module Backend query.query, query.variables, query.operation_name, - Api::Context.new(request.headers, development) + Api::Context.new(request.headers) ) end end diff --git a/docker-compose.yml b/docker-compose.yml index c7caced..f1170c3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,8 +32,6 @@ services: db: image: docker.io/postgres:alpine restart: always - networks: - - db environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} @@ -43,20 +41,29 @@ services: adminer: image: docker.io/adminer:standalone restart: always - networks: - - default - - db depends_on: - db redis: image: redis:alpine restart: always - networks: - - redis volumes: - redis:/data + auth: + image: git.dergrimm.net/mentorenwahl/auth:latest + build: + context: ./auth + restart: always + environment: + AUTH_UNTIS_URL: ${AUTH_UNTIS_URL} + AUTH_UNTIS_CLIENT_NAME: ${AUTH_UNTIS_CLIENT_NAME} + AUTH_UNTIS_SCHOOL: ${AUTH_UNTIS_SCHOOL} + AUTH_UNTIS_USERNAME: ${AUTH_UNTIS_USERNAME} + AUTH_UNTIS_PASSWORD: ${AUTH_UNTIS_PASSWORD} + volumes: + - ./data/static:/static + backend: image: git.dergrimm.net/mentorenwahl/backend:latest build: @@ -64,13 +71,10 @@ services: args: BUILD_ENV: production restart: always - networks: - - default - - db - - redis depends_on: - db - redis + - auth environment: BACKEND_URL: ${URL} BACKEND_MINIMUM_TEACHER_SELECTION_COUNT: ${BACKEND_MINIMUM_TEACHER_SELECTION_COUNT} @@ -94,22 +98,16 @@ services: BACKEND_LDAP_BIND_DN: ${BACKEND_LDAP_BIND_DN} BACKEND_LDAP_BIND_PASSWORD: ${BACKEND_LDAP_BIND_PASSWORD} BACKEND_LDAP_CACHE_REFRESH_INTERVAL: ${BACKEND_LDAP_CACHE_REFRESH_INTERVAL} + BACKEND_AUTH_URL: "http://auth/v1" frontend: image: git.dergrimm.net/mentorenwahl/frontend:latest build: context: ./frontend restart: always - networks: - - default depends_on: - backend -networks: - db: - redis: - - volumes: db: redis: diff --git a/frontend/graphql/queries/users_by_role_students.graphql b/frontend/graphql/queries/users_by_role_students.graphql index bc55a7d..846e50c 100644 --- a/frontend/graphql/queries/users_by_role_students.graphql +++ b/frontend/graphql/queries/users_by_role_students.graphql @@ -6,7 +6,6 @@ query Students { firstName lastName username - email role admin } diff --git a/frontend/graphql/queries/users_by_role_teachers.graphql b/frontend/graphql/queries/users_by_role_teachers.graphql index 58e0c11..c78a33b 100644 --- a/frontend/graphql/queries/users_by_role_teachers.graphql +++ b/frontend/graphql/queries/users_by_role_teachers.graphql @@ -6,7 +6,6 @@ query Teachers { firstName lastName username - email role admin } diff --git a/frontend/graphql/schema.graphql b/frontend/graphql/schema.graphql index d358093..bcb74b3 100644 --- a/frontend/graphql/schema.graphql +++ b/frontend/graphql/schema.graphql @@ -71,7 +71,6 @@ scalar UUID type User { admin: Boolean! - email: String! externalId: Int! firstName: String! id: Int! @@ -162,12 +161,11 @@ type LoginPayload { } type Mutation { - createUser(checkLdap: Boolean! = true, input: UserCreateInput!): User createVote(input: VoteCreateInput!): Vote deleteUser(id: Int!): Int login(password: String!, username: String!): LoginPayload logout: UUID - registerTeacher(input: TeacherInput!): Teacher + registerTeacher(input: TeacherInput!): Teacher! revokeToken(token: UUID!): UUID! startAssignment: Boolean } @@ -176,12 +174,6 @@ input TeacherInput { maxStudents: Int! } -input UserCreateInput { - username: String! - role: UserRole! - admin: Boolean! = false -} - input VoteCreateInput { teacherIds: [Int!]! -} \ No newline at end of file +} diff --git a/frontend/src/routes/settings/new_user_modal.rs b/frontend/src/routes/settings/new_user_modal.rs index e28be4d..77a712c 100644 --- a/frontend/src/routes/settings/new_user_modal.rs +++ b/frontend/src/routes/settings/new_user_modal.rs @@ -20,7 +20,7 @@ impl Component for NewUserModal { type Message = Msg; type Properties = NewUserModalProps; - fn create(ctx: &Context) -> Self { + fn create(_ctx: &Context) -> Self { Self { username: NodeRef::default(), admin: NodeRef::default(), diff --git a/frontend/src/routes/settings/users.rs b/frontend/src/routes/settings/users.rs index cd1e7f5..3c0fba9 100644 --- a/frontend/src/routes/settings/users.rs +++ b/frontend/src/routes/settings/users.rs @@ -153,8 +153,7 @@ impl Component for Users { { "ID" } { "Nachname" } { "Vorname" } - { "Benutzername" } - { "Email" } + { "Benutzername" } { "Rolle" } { "Externe Rollen-ID" } { "Admin" } @@ -169,11 +168,7 @@ impl Component for Users { { &s.user.last_name } { &s.user.first_name } { &s.user.username } - - - { &s.user.email } - - + { @@ -198,11 +193,6 @@ impl Component for Users { { &t.user.last_name } { &t.user.first_name } { &t.user.username } - - - { &t.user.email } - - { @@ -230,8 +220,7 @@ impl Component for Users { { "ID" } { "Nachname" } { "Vorname" } - { "Benutzername" } - { "Email" } + { "Benutzername" } { "Benutzer-ID" } { "Admin" } { "Gewählt" } @@ -245,11 +234,6 @@ impl Component for Users { { &s.user.last_name } { &s.user.first_name } { &s.user.username } - - - { &s.user.email } - - { &s.user.id } { if s.user.admin { 1 } else { 0 } } { if s.vote.is_some() { 1 } else { 0 } } @@ -266,7 +250,7 @@ impl Component for Users { { "ID" } { "Nachname" } { "Vorname" } - { "Benutzername" } + { "Benutzername" } { "Email" } { "Benutzer-ID" } { "Admin" } @@ -280,11 +264,6 @@ impl Component for Users { { &t.user.last_name } { &t.user.first_name } { &t.user.username } - - - { &t.user.email } - - {