Switch to Untis user import
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Dominic Grimm 2023-01-29 11:49:13 +01:00
parent 8055a5e4db
commit 735c91f7b4
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
37 changed files with 960 additions and 168 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -15,3 +15,4 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
.env
data/

25
auth/.editorconfig Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
root = true
[*.rs]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

17
auth/.gitignore vendored Normal file
View File

@ -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/

25
auth/Cargo.toml Normal file
View File

@ -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"

42
auth/Dockerfile Normal file
View File

@ -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" ]

41
auth/src/config.rs Normal file
View File

@ -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<untis::Client> {
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,
})
}

3
auth/src/lib.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod config;
pub mod pdf;
pub mod users;

21
auth/src/main.rs Normal file
View File

@ -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
}

59
auth/src/pdf.rs Normal file
View File

@ -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<String>,
pub filename: Option<String>,
}
#[post("/v1/pdf")]
async fn post_pdf(data: Json<PostPdfRequest>) -> 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),
})
}

194
auth/src/users.rs Normal file
View File

@ -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<User>,
}
#[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<String>,
pub classes: Vec<Class>,
pub teachers: Vec<User>,
}
fn escape_username(s: &str) -> String {
deunicode(
&s.to_lowercase()
.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>(),
)
}
async fn students(
client: &mut untis::Client,
usernames: &mut HashMap<String, usize>,
) -> Result<Vec<Class>> {
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<String, usize>,
) -> Result<Vec<User>> {
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::<String, usize>::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,
})
}

View File

@ -14,12 +14,14 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
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

View File

@ -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;

View File

@ -8,7 +8,7 @@ targets:
micrate:
main: src/micrate.cr
crystal: 1.6.2
crystal: 1.7.1
dependencies:
micrate:

View File

@ -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

View File

@ -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

View File

@ -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 %}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 <username> <role>"
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 <username> <role>"
# 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 <id> <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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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..." }

View File

@ -0,0 +1,6 @@
require "ecr"
require "./templates/*"
module Backend::Templates
end

View File

@ -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

View File

@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Benutzeraccounts | Mentorenwahl</title>
<style>
html,
body {
font-family: monospace;
}
.column {
width: 25%;
}
.border {
border: 1px solid;
}
.padded {
padding: 1%;
}
.left,
.right {
float: left;
}
hr {
border-top: 1px solid;
}
code {
font-family: monospace;
background: #f4f4f4;
word-wrap: break-word;
box-decoration-break: clone;
padding: 0.1rem 0.3rem 0.2rem;
border-radius: 0.2rem;
}
</style>
</head>
<body>
<div>
<p>Schüler:</p>
<table width="100%" cellspacing="0" border="0">
<colgroup>
<col class="column" />
<col class="column" />
<col class="column" />
<col class="column" />
</colgroup>
<%- @students.in_groups_of(4).each do |group| -%>
<tr>
<%- group.each do |student| %>
<td class="border padded">
<%- if student -%>
<table width="100%">
<tr>
<td>
<%= student.user.last_name %>, <%= student.user.first_name %> (<%= student.class_name %>)
<hr />
</td>
</tr>
<tr>
<td>
<code><%= student.user.username %></code>
<hr />
</td>
</tr>
<tr>
<td>
<code><%= student.user.password %></code>
</td>
</tr>
</table>
<%- end -%>
</td>
<%- end -%>
</tr>
<%- end -%>
</table>
<footer></footer>
</div>
<div>
<p>Lehrer:</p>
<table width="100%" cellspacing="0" border="0">
<colgroup>
<col class="column" />
<col class="column" />
<col class="column" />
<col class="column" />
</colgroup>
<%- @teachers.in_groups_of(4).each do |group| -%>
<tr>
<%- group.each do |teacher| %>
<td class="border padded">
<%- if teacher -%>
<table width="100%">
<tr>
<td>
<%= teacher.last_name %>, <%= teacher.first_name %>
<hr />
</td>
</tr>
<tr>
<td>
<code><%= teacher.username %></code>
<hr />
</td>
</tr>
<tr>
<td>
<code><%= teacher.password %></code>
</td>
</tr>
</table>
<%- end -%>
</td>
<%- end -%>
</tr>
<%- end -%>
</table>
</div>
</body>
</html>

View File

@ -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

View File

@ -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:

View File

@ -6,7 +6,6 @@ query Students {
firstName
lastName
username
email
role
admin
}

View File

@ -6,7 +6,6 @@ query Teachers {
firstName
lastName
username
email
role
admin
}

View File

@ -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!]!
}
}

View File

@ -20,7 +20,7 @@ impl Component for NewUserModal {
type Message = Msg;
type Properties = NewUserModalProps;
fn create(ctx: &Context<Self>) -> Self {
fn create(_ctx: &Context<Self>) -> Self {
Self {
username: NodeRef::default(),
admin: NodeRef::default(),

View File

@ -153,8 +153,7 @@ impl Component for Users {
<th><abbr title="ID des Benutzers in der Datenbank / API">{ "ID" }</abbr></th>
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th><abbr title="LDAP Benutzername">{ "Benutzername" }</abbr></th>
<th>{ "Email" }</th>
<th>{ "Benutzername" }</th>
<th>{ "Rolle" }</th>
<th><abbr title="ID des externen Benutzerobjekts">{ "Externe Rollen-ID" }</abbr></th>
<th>{ "Admin" }</th>
@ -169,11 +168,7 @@ impl Component for Users {
<td>{ &s.user.last_name }</td>
<td>{ &s.user.first_name }</td>
<td><code>{ &s.user.username }</code></td>
<td>
<a href={format!("mailto:{}", s.user.email)}>
<code>{ &s.user.email }</code>
</a>
</td>
<td>
<code>
{
@ -198,11 +193,6 @@ impl Component for Users {
<td>{ &t.user.last_name }</td>
<td>{ &t.user.first_name }</td>
<td><code>{ &t.user.username }</code></td>
<td>
<a href={format!("mailto:{}", t.user.email)}>
<code>{ &t.user.email }</code>
</a>
</td>
<td>
<code>
{
@ -230,8 +220,7 @@ impl Component for Users {
<th><abbr title="ID des Schülers in der Datenbank / API">{ "ID" }</abbr></th>
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th><abbr title="LDAP Benutzername">{ "Benutzername" }</abbr></th>
<th>{ "Email" }</th>
<th>{ "Benutzername" }</th>
<th>{ "Benutzer-ID" }</th>
<th>{ "Admin" }</th>
<th>{ "Gewählt" }</th>
@ -245,11 +234,6 @@ impl Component for Users {
<td>{ &s.user.last_name }</td>
<td>{ &s.user.first_name }</td>
<td><code>{ &s.user.username }</code></td>
<td>
<a href={format!("mailto:{}", s.user.email)}>
<code>{ &s.user.email }</code>
</a>
</td>
<td><code>{ &s.user.id }</code></td>
<td><code>{ if s.user.admin { 1 } else { 0 } }</code></td>
<td><code>{ if s.vote.is_some() { 1 } else { 0 } }</code></td>
@ -266,7 +250,7 @@ impl Component for Users {
<th><abbr title="ID des Lehrers in der Datenbank / API">{ "ID" }</abbr></th>
<th>{ "Nachname" }</th>
<th>{ "Vorname" }</th>
<th><abbr title="LDAP Benutzername">{ "Benutzername" }</abbr></th>
<th>{ "Benutzername" }</th>
<th>{ "Email" }</th>
<th>{ "Benutzer-ID" }</th>
<th>{ "Admin" }</th>
@ -280,11 +264,6 @@ impl Component for Users {
<td>{ &t.user.last_name }</td>
<td>{ &t.user.first_name }</td>
<td><code>{ &t.user.username }</code></td>
<td>
<a href={format!("mailto:{}", t.user.email)}>
<code>{ &t.user.email }</code>
</a>
</td>
<td>
<code>
{