Update codebase
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Dominic Grimm 2022-10-31 09:47:26 +01:00
parent 1c72c81b85
commit 50379148bc
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
93 changed files with 486 additions and 630 deletions

View File

@ -43,25 +43,25 @@ steps:
- name: ameba
image: veelenga/ameba
commands:
- cd docker/backend
- cd backend
- ameba micrate/src src
- name: deps
image: crystallang/crystal:1.3-alpine
image: crystallang/crystal:1.6-alpine
volumes:
- name: lib
path: /drone/src/docker/backend/lib
path: /drone/src/backend/lib
- name: cache
path: /cache
commands:
- cd docker/backend
- cd backend
- shards install
- name: docs
image: crystallang/crystal:1.3-alpine
image: crystallang/crystal:1.6-alpine
volumes:
- name: lib
path: /drone/src/docker/backend/lib
path: /drone/src/backend/lib
commands:
- cd docker/backend
- cd backend
- make docs
depends_on:
- ameba
@ -74,7 +74,7 @@ steps:
settings:
rebuild: true
mount:
- ./docker/backend/docs
- ./backend/docs
depends_on:
- docs
- name: build
@ -83,20 +83,10 @@ steps:
- name: dockersock
path: /var/run/docker.sock
commands:
- docker-compose build --build-arg BUILD_ENV=development backend
- docker-compose build backend
depends_on:
- ameba
volumes:
- name: lib
temp: {}
- name: cache
host:
path: /tmp/cache
- name: dockersock
host:
path: /var/run/docker.sock
---
kind: pipeline
type: docker
@ -109,7 +99,7 @@ steps:
- name: dockersock
path: /var/run/docker.sock
commands:
- docker-compose build --build-arg BUILD_ENV=development frontend
- docker-compose build frontend
volumes:
- name: dockersock
@ -131,7 +121,7 @@ steps:
restore: true
mount:
- ./docs/book
- ./docker/backend/docs
- ./backend/docs
- name: prepare-pages
image: bitnami/git
volumes:
@ -144,7 +134,7 @@ steps:
- rm -rf /tmp/pages/*
- cp -r ./docs/book/* /tmp/pages
- mkdir -p /tmp/pages/_api/backend
- cp -r ./docker/backend/docs/* /tmp/pages/_api/backend
- cp -r ./backend/docs/* /tmp/pages/_api/backend
depends_on:
- restore-cache
- name: deploy-pages

View File

@ -26,7 +26,6 @@ BACKEND_MINIMUM_TEACHER_SELECTION_COUNT=6
BACKEND_ASSIGNMENT_POSSIBILITY_COUNT=500
BACKEND_URL=URL
# Backend - API
BACKEND_API_GRAPHQL_PLAYGROUND=false
BACKEND_API_JWT_SECRET=
BACKEND_API_JWT_EXPIRATION=360
# Backend - Worker

View File

@ -14,50 +14,66 @@
# 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.5-alpine as micrate-deps
WORKDIR /src
FROM veelenga/ameba:latest
WORKDIR /usr/src/micrate
COPY ./micrate/src ./src
RUN ameba src/ || true
FROM crystallang/crystal:1.6-alpine as micrate-deps
WORKDIR /usr/src/micrate
COPY ./micrate/shard.yml ./micrate/shard.lock ./
RUN shards install --production
FROM crystallang/crystal:1.5-alpine as micrate-builder
WORKDIR /src
# RUN apk add --no-cache sqlite-static
COPY --from=micrate-deps /src/shard.yml /src/shard.lock ./
COPY --from=micrate-deps /src/lib ./lib
FROM crystallang/crystal:1.6-alpine 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
COPY ./micrate/src ./src
RUN shards build --release --static --verbose -s -p -t
FROM tdewolff/minify as public
WORKDIR /src
WORKDIR /usr/src/public
COPY ./public ./src
RUN minify -r -o ./dist ./src
FROM crystallang/crystal:1.5-alpine as deps
WORKDIR /src
FROM veelenga/ameba:latest
WORKDIR /usr/src/mentorenwahl
COPY ./src ./src
RUN ameba src/ || true
FROM crystallang/crystal:1.6-alpine as deps
WORKDIR /usr/src/mentorenwahl
COPY ./shard.yml ./shard.lock ./
RUN shards install --production
FROM crystallang/crystal:1.5-alpine as builder
ARG BUILD_ENV
WORKDIR /src/mentorenwahl
FROM crystallang/crystal:1.6-alpine as builder
WORKDIR /usr/src/mentorenwahl
RUN apk add --no-cache pcre2-dev
COPY --from=deps /src/shard.yml /src/shard.lock ./
COPY --from=deps /src/lib ./lib
COPY --from=deps /usr/src/mentorenwahl/shard.yml /usr/src/mentorenwahl/shard.lock ./
COPY --from=deps /usr/src/mentorenwahl/lib ./lib
COPY ./LICENSE .
COPY ./Makefile .
COPY ./src ./src
COPY ./db ./db
COPY --from=public /src/dist ./public
COPY ./src ./src
ARG BUILD_ENV
COPY --from=public /usr/src/public/dist ./public
RUN if [ "${BUILD_ENV}" = "development" ]; then \
make dev; \
else \
make; \
fi
RUN mkdir deps
RUN if [ "${BUILD_ENV}" = "development" ]; then \
ldd bin/backend | tr -s '[:blank:]' '\n' | grep '^/' | \
xargs -I % sh -c 'mkdir -p $(dirname deps%); cp % deps%;'; \
fi
FROM scratch as runner
COPY --from=micrate-builder /src/bin/micrate /bin/micrate
COPY --from=builder /src/mentorenwahl/bin /bin
COPY --from=builder /src/mentorenwahl/db ./db
WORKDIR /
COPY --from=micrate-builder /usr/src/micrate/bin/micrate ./bin/micrate
COPY --from=builder /usr/src/mentorenwahl/deps /
COPY --from=builder /usr/src/mentorenwahl/bin/backend ./bin/backend
COPY --from=builder /usr/src/mentorenwahl/db ./db
EXPOSE 80
ENTRYPOINT [ "backend" ]
ENTRYPOINT [ "./bin/backend" ]
CMD [ "run" ]

View File

@ -19,10 +19,10 @@
all: prod
dev:
shards build -Ddevelopment --static --verbose -s -p -t
shards build -Dplayground --verbose -s -p -t
prod:
shards build --static --release --verbose -s -p -t
shards build --production --static --release --verbose -s -p -t
docs:
crystal docs --project-name "Mentorenwahl Backend"
crystal docs --project-name "Mentorenwahl"

View File

@ -17,14 +17,14 @@
*/
-- +micrate Up
-- SQL in section ' Up ' is executed when this migration is applied
CREATE TYPE user_roles AS ENUM ('teacher', 'student');
CREATE TYPE user_roles AS ENUM ('student', 'teacher');
CREATE TABLE users(
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
role user_roles NOT NULL,
skif BOOLEAN NOT NULL,
admin BOOLEAN NOT NULL
admin BOOLEAN NOT NULL,
jti uuid UNIQUE
);
CREATE TABLE teachers(

View File

@ -6,7 +6,7 @@ shards:
athena:
git: https://github.com/athena-framework/framework.git
version: 0.17.0
version: 0.17.1
athena-config:
git: https://github.com/athena-framework/config.git
@ -30,15 +30,15 @@ shards:
athena-routing:
git: https://github.com/athena-framework/routing.git
version: 0.1.2
version: 0.1.3
athena-serializer:
git: https://github.com/athena-framework/serializer.git
version: 0.3.0
version: 0.3.1
athena-validator:
git: https://github.com/athena-framework/validator.git
version: 0.2.0
version: 0.2.1
baked_file_system:
git: https://github.com/schovi/baked_file_system.git
@ -46,7 +46,7 @@ shards:
bindata:
git: https://github.com/spider-gazelle/bindata.git
version: 1.10.0
version: 1.11.0
clear:
git: https://github.com/vici37/clear.git
@ -82,7 +82,7 @@ shards:
graphql:
git: https://github.com/graphql-crystal/graphql.git
version: 0.4.0
version: 0.4.0+git.commit.e3281bb0ef0ca301ccea176e6839422ac766465b
habitat:
git: https://github.com/luckyframework/habitat.git
@ -126,7 +126,7 @@ shards:
pretty:
git: https://github.com/maiha/pretty.cr.git
version: 1.1.1
version: 1.1.2
promise:
git: https://github.com/spider-gazelle/promise.git
@ -150,7 +150,7 @@ shards:
shard:
git: https://github.com/maiha/shard.cr.git
version: 0.3.1
version: 1.0.0
version_from_shard:
git: https://github.com/hugopl/version_from_shard.git

View File

@ -24,9 +24,9 @@ license: GNU GPLv3
targets:
backend:
main: src/cli/backend.cr
main: src/backend.cr
crystal: 1.5.0
crystal: 1.6.1
dependencies:
clear:
@ -34,6 +34,7 @@ dependencies:
branch: master
graphql:
github: graphql-crystal/graphql
branch: main
jwt:
github: crystal-community/jwt
commander:

View File

@ -14,8 +14,15 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
require "commander"
require "docker"
require "./backend/*"
# Base module
module Backend
Docker.setup
Db.init
Commander.run(CLI, ARGV)
end

View File

@ -0,0 +1,82 @@
# 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/>.
require "jwt"
require "json"
module Backend
module Api
# Authorization and authentication utilities
module Auth
extend self
# Bearer token header
BEARER = "Bearer "
# JWT token
struct Token
include JSON::Serializable
getter iss : String
getter iat : Int64
getter exp : Int64
getter jti : String
getter context : Context
def initialize(
@iss : String,
@iat : Int64,
@exp : Int64,
@jti : String,
@context : Context
)
end
def encode : String
JWT.encode(self, Backend.config.api.jwt_secret, JWT::Algorithm::HS256)
end
def self.from_hash(token : Hash(String, JSON::Any)) : self
self.new(
iss: token["iss"].as_s,
iat: token["iat"].as_i64,
exp: token["exp"].as_i64,
jti: token["jti"].as_s,
context: Context.from_hash(token["context"].as_h)
)
end
def self.decode(jwt : String) : self
self.from_hash(JWT.decode(jwt, Backend.config.api.jwt_secret, JWT::Algorithm::HS256)[0].as_h)
end
end
# JWT token context data
struct Context
include JSON::Serializable
getter user : Int32
def initialize(@user : Int32)
end
def self.from_hash(data : Hash(String, JSON::Any))
self.new(user: data["user"].as_i)
end
end
end
end
end

View File

@ -16,6 +16,8 @@
require "graphql"
require "http/headers"
require "jwt/errors"
require "json"
module Backend
module Api
@ -24,6 +26,9 @@ module Backend
# Development mode
getter development
# Request status
getter status
# Authenticated user
getter user
@ -38,6 +43,7 @@ module Backend
def initialize(
@development : Bool,
@status : Status,
@user : Db::User?,
@admin : Bool?,
@role : Schema::UserRole?,
@ -45,37 +51,54 @@ module Backend
)
end
def initialize(headers : HTTP::Headers, @development : Bool, *rest)
def initialize(headers : HTTP::Headers, @development : Bool, @status = Status::OK, *rest)
super(*rest)
if (token = headers["authorization"]?) && token[..Auth::BEARER.size - 1] == Auth::BEARER
payload = Auth.decode_jwt?(token[Auth::BEARER.size..])
return unless payload
data = payload["data"].as_h
return unless @user = Db::User.find(data["user_id"].as_i)
if @user
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@external =
case @role.not_nil!
when .teacher?
@user.not_nil!.teacher
when .student?
@user.not_nil!.student
end
if (token = headers["authorization"]?) && token.starts_with?(Auth::BEARER)
begin
payload = Auth::Token.decode(token[Auth::BEARER.size..].strip)
rescue ex : JWT::ExpiredSignatureError
@status = Status::SessionExpired
rescue
@status = Status::JWTError
else
if @user = Db::User.find(payload.context.user)
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@external =
case @role.not_nil!
when .teacher?
@user.not_nil!.teacher
when .student?
@user.not_nil!.student
end
end
end
end
end
def on_development : Nil
{% if !flag?(:release) %}
if @development
yield
end
{% end %}
end
enum Status
OK
SessionExpired
JWTError
end
# User is authenticated
def authenticated? : Bool
!!@user
!!@user && @status.ok?
end
# :ditto:
def authenticated! : Bool
raise "Session expired" if @status.session_expired?
raise "Not authenticated" unless authenticated?
true
@ -88,20 +111,21 @@ module Backend
# :ditto:
def admin! : Bool
authenticated!
raise "Invalid permissions" unless admin?
true
end
# User's is one of *roles*
def role?(external_check = true, *roles : Schema::UserRole) : Bool
def role?(roles : Array(Schema::UserRole), external_check = true) : Bool
return false unless authenticated?
roles.each do |role|
return true if @role == role &&
if external_check
role ==
case @external.not_nil! # TODO: Simplify with Germanium in future but for now with macro iteration over `#resolve#constants`
case @external.not_nil!
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
@ -116,33 +140,64 @@ module Backend
end
# :ditto:
def role!(external_check = true, *roles : Schema::UserRole) : Bool
raise "Invalid permissions" unless role? external, *roles
def role!(roles : Array(Schema::UserRole), external_check = true) : Bool
authenticated!
raise "Invalid permissions" unless role?(roles, external_check)
true
end
# User is teacher
def teacher?(external_check = true) : Bool
role? external_check, Schema::UserRole::Teacher
role?([Schema::UserRole::Teacher], external_check)
end
# :ditto:
def teacher!(external_check = true) : Bool
role! external_check, Schema::UserRole::Teacher
role!([Schema::UserRole::Teacher], external_check)
end
# User is student
def student?(external_check = true) : Bool
role? external_check, Schema::UserRole::Student
role?([Schema::UserRole::Student], external_check)
end
# :ditto:
def student!(external_check = true) : Bool
role! external_check, Schema::UserRole::Student
role!([Schema::UserRole::Student], external_check)
end
# TODO: Custom error handler
# Custom error handler
def handle_exception(ex : Exception) : String?
pp! ex, ex.message
# ex.message
case ex
when Errors::Error
{% if !flag?(:release) %}
if @development
ex.message
else
nil
end
{% else %}
nil
{% end %}
when Errors::PublicError
ex.message
else
{% if !flag?(:release) %}
if @development
ex.message
else
nil
end
{% else %}
nil
{% end %}
end
end
end
end
end

View File

@ -0,0 +1,13 @@
module Backend::Api::Errors
abstract class Error < Exception
end
abstract class PrivateError < Error
end
abstract class PublicError < Error
end
class AuthenticationError < PublicError
end
end

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
require "ldap"
require "uuid"
module Backend
module Api
@ -24,17 +25,21 @@ module Backend
@[GraphQL::Field]
# Logs in as *username* with credential *password*
def login(username : String, password : String) : LoginPayload
raise "Auth failed" if username.empty? || password.empty?
raise Errors::AuthenticationError.new if username.empty? || password.empty?
user = Db::User.query.find { var(:username) == username }
raise "Auth failed" unless user && Ldap.authenticate?(Ldap::DN.uid(username), password)
raise Errors::AuthenticationError.new unless user && Ldap.authenticate?(Ldap::DN.uid(username), password)
jti = UUID.random
LoginPayload.new(
user: User.new(user),
token: Auth.create_user_jwt(
user.id.not_nil!.to_i,
(Time.utc + Backend.config.api.jwt_expiration.minutes).to_unix
),
token: Auth::Token.new(
iss: "mentorenwahl",
iat: Time.utc.to_unix,
exp: (Time.utc + Backend.config.api.jwt_expiration.minutes).to_unix,
jti: jti.hexstring,
context: Auth::Context.new(user.id.not_nil!)
).encode
)
end
@ -48,7 +53,7 @@ module Backend
rescue LDAP::Client::AuthError
true
end
user = Db::User.create!(username: input.username, role: input.role.to_db, skif: input.skif, admin: input.admin)
user = Db::User.create!(username: input.username, role: input.role.to_db, admin: input.admin)
Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue
User.new(user)
@ -65,16 +70,6 @@ module Backend
id
end
@[GraphQL::Field]
# Sends all unregistered teachers a registration email
def send_teachers_registration_email(context : Context) : Bool
context.admin!
Worker::Jobs::SendTeachersRegistrationEmailJob.new.enqueue
true
end
@[GraphQL::Field]
# Starts assignment job of mentors to students
def assign_students(context : Context) : Bool
@ -85,68 +80,68 @@ module Backend
true
end
@[GraphQL::Field]
# Creates teacher
def create_teacher(context : Context, input : TeacherCreateInput) : Teacher
context.admin!
# @[GraphQL::Field]
# # Creates teacher
# def create_teacher(context : Context, input : TeacherCreateInput) : Teacher
# context.admin!
teacher = Db::Teacher.create!(user_id: input.user_id, max_students: input.max_students)
Teacher.new(teacher)
end
# teacher = Db::Teacher.create!(user_id: input.user_id, max_students: input.max_students)
# Teacher.new(teacher)
# end
@[GraphQL::Field]
# Deletes teacher by ID
def delete_teacher(context : Context, id : Int32) : Int32
context.admin!
# @[GraphQL::Field]
# # Deletes teacher by ID
# def delete_teacher(context : Context, id : Int32) : Int32
# context.admin!
teacher = Db::Teacher.find!(id)
teacher.delete
# teacher = Db::Teacher.find!(id)
# teacher.delete
id
end
# 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
def create_student(context : Context, input : StudentCreateInput) : Student
context.admin!
# @[GraphQL::Field]
# # Creates student
# def create_student(context : Context, input : StudentCreateInput) : Student
# context.admin!
user = Db::User.find!(input.user_id)
raise "User not a student" unless user.role.to_api.student?
# user = Db::User.find!(input.user_id)
# raise "User not a student" unless user.role.to_api.student?
student = Db::Student.create!(user_id: user.id)
Student.new(student)
end
# student = Db::Student.create!(user_id: user.id)
# Student.new(student)
# end
@[GraphQL::Field]
# Deletes student by ID
def delete_student(context : Context, id : Int32) : Int32
context.admin!
# @[GraphQL::Field]
# # Deletes student by ID
# def delete_student(context : Context, id : Int32) : Int32
# context.admin!
student = Db::Student.find!(id)
student.delete
# student = Db::Student.find!(id)
# student.delete
id
end
# id
# end
@[GraphQL::Field]
# Self register as student
def register_student(context : Context) : Student
context.student! external_check: false
# @[GraphQL::Field]
# # Self register as student
# def register_student(context : Context) : Student
# context.student! external_check: false
Student.new(
Db::Student.create!(user_id: context.user.not_nil!.id)
)
end
# Student.new(
# Db::Student.create!(user_id: context.user.not_nil!.id)
# )
# end
@[GraphQL::Field]
# Creates vote for authenticated user's student
@ -163,12 +158,12 @@ module Backend
if teacher.nil?
raise "Teachers not found"
elsif teacher.user.skif != context.user.not_nil!.skif
if teacher.user.skif
raise "Teacher is SKIF, student is not"
else
raise "Teacher is not SKIF, student is"
end
# elsif teacher.user.skif != context.user.not_nil!.skif
# if teacher.user.skif
# raise "Teacher is SKIF, student is not"
# else
# raise "Teacher is not SKIF, student is"
# end
end
end

View File

@ -25,7 +25,12 @@ module Backend
# DB representation of the enum
def to_db : Db::UserRole
Db::UserRole.from_string(self.to_s.underscore)
case self
in Teacher
Db::UserRole::Teacher
in Student
Db::UserRole::Student
end
end
# GraphQL representation of the DB enum
@ -45,7 +50,7 @@ module Backend
# LDAP user data
def ldap : Ldap::User
unless raw_cache = Redis::CLIENT.get("ldap:user:#{id}")
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
@ -67,8 +72,8 @@ module Backend
@[GraphQL::Field]
# User's full name
def name : String
ldap.name
def name(formal : Bool = true) : String
ldap.name(formal)
end
@[GraphQL::Field]
@ -95,40 +100,27 @@ module Backend
@model.role.to_api
end
@[GraphQL::Field]
# User is at SKIF
def skif : Bool
@model.skif
end
@[GraphQL::Field]
# User's external ID
def external_id : Int32?
def external_id : Int32
case @model.role.to_api
when .teacher?
when Db::UserRole::Teacher
@model.teacher
when .student?
when Db::UserRole::Student
@model.student
end
.try(&.id.try(&.to_i))
end.not_nil!.id
end
@[GraphQL::Field]
# User's external teacher object
def teacher : Teacher?
teacher = @model.teacher
if teacher
Teacher.new(teacher)
end
@model.teacher.try { |t| Teacher.new(t) }
end
@[GraphQL::Field]
# User's external student object
def student : Student?
student = @model.student
if student
Student.new(student)
end
@model.student.try { |s| Student.new(s) }
end
end
@ -137,14 +129,12 @@ module Backend
class UserCreateInput < GraphQL::BaseInputObject
getter username
getter role
getter skif
getter admin
@[GraphQL::Field]
def initialize(
@username : String,
@role : UserRole,
@skif : Bool,
@admin : Bool = false
)
end

View File

@ -1,5 +1,4 @@
require "commander"
require "compiled_license"
module Backend
CLI = Commander::Command.new do |cmd|
@ -36,7 +35,7 @@ module Backend
c.long = c.short
c.run do
puts CompiledLicense::LICENSES
puts LICENSES
end
end
@ -55,13 +54,6 @@ module Backend
c.short = "Seeds the database with required data"
c.long = c.short
c.flags.add do |f|
f.name = "skif"
f.long = "--skif"
f.default = false
f.description = "User at SKIF"
end
c.flags.add do |f|
f.name = "admin"
f.long = "--admin"
@ -85,7 +77,14 @@ module Backend
abort unless gets(chomp: true).not_nil!.strip.downcase == "y"
end
user = Db::User.create!(username: username, role: role.to_s, skif: opts.bool["skif"], admin: opts.bool["admin"])
user = Db::User.create!(username: username, role: role.to_s, admin: opts.bool["admin"])
case role.to_api
in Api::Schema::UserRole::Student
Db::Student.create!(user_id: user.id)
in Api::Schema::UserRole::Teacher
Db::Teacher.create!(user_id: user.id)
end
Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue
puts "Done!"

View File

@ -32,25 +32,25 @@ module Backend
# Types of environments program can compiled for / with
enum BuildEnv
Production
Release
Development
end
# Type of environment program is running in
def build_env : BuildEnv
{{ flag?(:development) }} ? BuildEnv::Development : BuildEnv::Production
{{ flag?(:release) }} ? BuildEnv::Release : BuildEnv::Development
end
# Production mode
# Release mode
#
# `true` if the build environment is `BuildEnv::Development`
def production? : Bool
# `true` if the build environment is `BuildEnv::Release`
def release? : Bool
build_env.production?
end
# Development mode
#
# `true` if the build environment is `BuildEnv::Production`
# `true` if the build environment is `BuildEnv::Development`
def development? : Bool
build_env.development?
end
@ -87,20 +87,15 @@ module Backend
class ApiConfig
include EnvConfig
# GraphQL playground enable
getter graphql_playground : Bool
# JWT signing key
getter jwt_secret : String
# JWT expiration time in minutes
getter jwt_expiration : Int32
# Helper method for enabling GraphQL playground
#
# Returns `true` if `Config#development?` or `#graphql_playground` are
def graphql_playground_fully_enabled? : Bool
Backend.config.development? || graphql_playground
# Returns true of `playground` flag was set on compile time
def graphql_playground? : Bool
flag?(:playground)
end
end

View File

@ -29,7 +29,7 @@ module Backend
# Migration UIDs
MIGRATIONS = {{ run("./macros/migrations.cr", "db/migrations/*.sql").stringify.split("\n") }}
def init(severity = {% if flag?(:development) %} ::Log::Severity::Debug {% else %} ::Log::Severity::Info {% end %}) : Nil
def init(severity = {% if !flag?(:release) %} ::Log::Severity::Debug {% else %} ::Log::Severity::Info {% end %}) : Nil
::Log.builder.bind "clear.*", severity, ::Log::IOBackend.new
Retriable.retry(on: DB::ConnectionRefused, backoff: false) do
Clear::SQL.init(Backend.config.db.url)

View File

@ -5,7 +5,14 @@ module Backend
struct UserRole
# API representation of the enum
def to_api : Api::Schema::UserRole
Api::Schema::UserRole.parse(self.to_s)
case self
when Student
Api::Schema::UserRole::Student
when Teacher
Api::Schema::UserRole::Teacher
else
raise "Invalid enum value for UserRole"
end
end
# DB representation of the enum
@ -23,7 +30,7 @@ module Backend
column username : String
column role : UserRole
column admin : Bool = false
column skif : Bool
column jti : UUID?
has_one student : Student?, foreign_key: :user_id
has_one teacher : Teacher?, foreign_key: :user_id

View File

@ -26,22 +26,26 @@ module Backend
@[JSON::Field(key: "givenName")]
# First name
property first_name : String
getter first_name : String
@[JSON::Field(key: "sn")]
# Last name
property last_name : String
getter last_name : String
@[JSON::Field(key: "mail")]
# Email address
property email : String
getter email : String
def initialize(@first_name : String, @last_name : String, @email : String)
end
# Name
def name : String
"#{first_name} #{last_name}"
def name(formal = true) : String
if formal
"#{@last_name}, #{@first_name}"
else
"#{@first_name} #{@last_name}"
end
end
# Creates user data from LDAP entry

View File

@ -0,0 +1,5 @@
require "compiled_license"
module Backend
LICENSES = CompiledLicense::LICENSES
end

View File

@ -36,7 +36,7 @@ module Backend
# Run the backend
def run : self
{% if flag?(:development) %}
{% if !flag?(:release) %}
Log.warn { "Backend is running in development mode! Do not use this in production!" }
{% end %}

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
require "http/headers"
require "mime"
require "baked_file_system"
module Backend
module Web
@ -23,25 +23,29 @@ module Backend
@[ARTA::Route(path: "/")]
# GraphQL API controller
class ApiController < ATH::Controller
@[ARTA::Get("")]
def playground : ATH::Response | ATH::Exceptions::HTTPException
{% if flag?(:development) %}
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => MIME.from_extension(".html")}) do |io|
{% if flag?(:playground) %}
# Public folder virtual filesystem
private module Public
extend BakedFileSystem
bake_folder "../../../../public"
end
@[ARTA::Get("")]
def playground : ATH::Response
ATH::StreamedResponse.new(headers: HTTP::Headers{"Content-Type" => "text/html"}) do |io|
IO.copy(Public.get("index.html"), io)
end
{% else %}
if Backend.config.api.graphql_playground_fully_enabled?
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => MIME.from_extension(".html")}) do |io|
IO.copy(Public.get("index.html"), io)
end
else
ATH::Exceptions::ServiceUnavailable.new("GraphQL Playground is not enabled. Please enable it in the backend configuration.")
end
{% end %}
end
end
{% else %}
@[ARTA::Get("")]
def playground : ATH::Exceptions::ServiceUnavailable
ATH::Exceptions::ServiceUnavailable.new("GraphQL playground is disabled")
end
{% end %}
# GraphQL query request data
struct GraphQLQueryData
# GraphQL query data
struct GraphQLQuery
include JSON::Serializable
# Raw query
@ -55,15 +59,18 @@ module Backend
end
@[ARTA::Post("")]
@[ATHA::QueryParam("development")]
def endpoint(request : ATH::Request, development : Bool = false) : ATH::Response
{% if flag?(:development) %}
{% 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 %}
query = GraphQLQueryData.from_json(request.body.not_nil!)
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" => MIME.from_extension(".json")}) do |io|
ATH::StreamedResponse.new(headers: HTTP::Headers{"content-type" => "application/json"}) do |io|
Api::Schema::SCHEMA.execute(
io,
query.query,

View File

@ -21,23 +21,23 @@ require "./worker/*"
module Backend
# Worker module
module Worker
# :inherit:
module Mosquito::Serializers::Granite
# :inherit:
macro serialize_granite_model(klass)
{% method_suffix = klass.resolve.stringify.underscore.gsub(/::/, "__").id %}
# # :inherit:
# module Mosquito::Serializers::Granite
# # :inherit:
# macro serialize_granite_model(klass)
# {% method_suffix = klass.resolve.stringify.underscore.gsub(/::/, "__").id %}
# Serializes {{ klaas.id }} to redis manageable data
def serialize_{{ method_suffix }}(model : {{ klass.id }}) : String
model.id.to_s
end
# # Serializes {{ klaas.id }} to redis manageable data
# def serialize_{{ method_suffix }}(model : {{ klass.id }}) : String
# model.id.to_s
# end
# Deserializes {{ klaas.id }} from redis manageable data
def deserialize_{{ method_suffix }}(raw : String) : {{ klass.id }}
{{ klass.id }}.find!(raw.to_i)
end
end
end
# # Deserializes {{ klaas.id }} from redis manageable data
# def deserialize_{{ method_suffix }}(raw : String) : {{ klass.id }}
# {{ klass.id }}.find!(raw.to_i)
# end
# end
# end
Mosquito.configure do |settings|
settings.redis_url = Backend.config.redis.url

View File

@ -72,10 +72,10 @@ module Backend
# pp! possibilities
teacher_ids = Db::Teacher.query
.select("id")
.where { raw("(SELECT COUNT(*) FROM assignments WHERE teacher_id = teachers.id)") < max_students }
.map(&.id)
# teacher_ids = Db::Teacher.query
# .select("id")
# .where { raw("(SELECT COUNT(*) FROM assignments WHERE teacher_id = teachers.id)") < max_students }
# .map(&.id)
students = Db::Student.query
.where do
raw("NOT EXISTS (SELECT 1 FROM assignments WHERE student_id = students.id)") &
@ -100,13 +100,12 @@ module Backend
end
assignments = [] of {assignment: Hash(Int32, Array(Int32)), weighting: Int32}
empty_assignment = Hash.zip(teacher_ids, [[] of {assignment: Hash(Int32, Array(Int32)), weighting: Int32}] * teacher_ids.size)
# empty_assignment = Hash.zip(teacher_ids, [[] of {assignment: Hash(Int32, Array(Int32)), weighting: Int32}] * teacher_ids.size)
Backend.config.assignment_possibility_count.times do
random_votes.shuffle!(Random::Secure)
votes = random_votes.dup
a = empty_assignment.clone
# votes = random_votes.dup
# a = empty_assignment.clone
end
pp! assignments
end

View File

@ -19,7 +19,6 @@ version: "3"
services:
nginx:
image: nginx:alpine
container_name: nginx
restart: always
volumes:
- ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
@ -32,7 +31,6 @@ services:
db:
image: postgres:alpine
container_name: db
restart: always
networks:
- db
@ -44,7 +42,6 @@ services:
adminer:
image: adminer:standalone
container_name: adminer
restart: always
networks:
- default
@ -54,7 +51,6 @@ services:
redis:
image: redis:alpine
container_name: redis
restart: always
networks:
- redis
@ -62,11 +58,11 @@ services:
- redis:/data
backend:
image: mentorenwahl/backend
build:
context: ./docker/backend
context: ./backend
args:
BUILD_ENV: production
container_name: backend
restart: always
networks:
- default
@ -79,7 +75,6 @@ services:
BACKEND_URL: ${URL}
BACKEND_MINIMUM_TEACHER_SELECTION_COUNT: ${BACKEND_MINIMUM_TEACHER_SELECTION_COUNT}
BACKEND_ASSIGNMENT_POSSIBILITY_COUNT: ${BACKEND_ASSIGNMENT_POSSIBILITY_COUNT}
BACKEND_API_GRAPHQL_PLAYGROUND: ${BACKEND_API_GRAPHQL_PLAYGROUND}
BACKEND_API_JWT_SECRET: ${BACKEND_API_JWT_SECRET}
BACKEND_API_JWT_EXPIRATION: ${BACKEND_API_JWT_EXPIRATION}
BACKEND_SMTP_HELO: ${BACKEND_SMTP_HELO}
@ -101,9 +96,9 @@ services:
BACKEND_LDAP_CACHE_REFRESH_INTERVAL: ${BACKEND_LDAP_CACHE_REFRESH_INTERVAL}
frontend:
image: mentorenwahl/frontend
build:
context: ./docker/frontend
container_name: frontend
context: ./frontend
restart: always
networks:
- default
@ -116,6 +111,7 @@ networks:
db:
redis:
volumes:
db:
redis:

View File

@ -1,53 +0,0 @@
# 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/>.
require "jwt"
module Backend
module Api
# Authorization and authentication utilities
module Auth
extend self
# Bearer token header
BEARER = "Bearer "
# Creates raw JWT token
#
# WARNING: Always use a wrapper for this method
private def create_jwt(data, expiration : Int) : String
JWT.encode({"data" => data.to_h, "exp" => expiration}, Backend.config.api.jwt_secret, JWT::Algorithm::HS256)
end
# Decodes JWT token
def decode_jwt(jwt : String) : JSON::Any
JWT.decode(jwt, Backend.config.api.jwt_secret, JWT::Algorithm::HS256)[0]
end
# :ditto:
def decode_jwt?(jwt : String) : JSON::Any?
decode_jwt(jwt)
rescue
nil
end
# Creates JWT token for user
def create_user_jwt(user_id : Int, expiration : Int) : String
create_jwt({user_id: user_id}, expiration)
end
end
end
end

View File

@ -1,32 +0,0 @@
# 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/>.
module Backend
module Mailers
# Sends teacher a polite registration mail to ask if they may input their data
class TeacherRegistrationMailer < Quartz::Composer
def sender : Quartz::Message::Address
address email: Backend.config.smtp.username, name: Backend.config.smtp.name
end
def initialize(user : Ldap::User)
to name: user.name, email: user.email
subject "Mentorenwahl Lehrer Registrierung"
text Kilt.render("#{__DIR__}/templates/teacher_registration_mailer.txt.ecr")
end
end
end
end

View File

@ -1,5 +0,0 @@
Hey, <%= user.first_name %>!
Du wurdest erfolgreich als Lehrer registriert.
Initialisiere deinen Account, indem du auf den folgenden Link klickst und deine Daten eingibst:
<%= Path[Backend.config.url, "login"] %>

View File

@ -1,26 +0,0 @@
# 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/>.
require "baked_file_system"
module Backend
# Public folder virtual filesystem
module Public
extend BakedFileSystem
bake_folder "../../public"
end
end

View File

@ -1,49 +0,0 @@
# 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/>.
module Backend
module Worker
module Jobs
# Sends all unregistered teachers a polite registration mail to ask if they may input their data
class SendTeachersRegistrationEmailJob < Mosquito::QueuedJob
# :ditto:
def perform : Nil
users = Db::User.query.where { (x.role == Db::UserRole::Teacher) & (x.teacher_id == nil) }
count = users.count.to_i
channel = Channel(Nil).new(count)
users.each do |user|
spawn do
next unless user.role.to_api.teacher?
ldap_user = Ldap::User.from_username(user.username)
log "Sending teacher registration email to #{ldap_user.email} ##{user.id}"
Mailers::TeacherRegistrationMailer.new(ldap_user).deliver
channel.send(nil)
end
end
count.times do
channel.receive
end
Fiber.yield
end
end
end
end
end

View File

@ -1,117 +0,0 @@
# 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/>.
require "commander"
require "compiled_license"
require "docker"
require "../backend"
Docker.setup
Backend::Db.init
cli = Commander::Command.new do |cmd|
cmd.use = "backend"
cmd.short = "Mentorenwahl backend CLI"
cmd.run do
puts cmd.help
end
cmd.commands.add do |c|
c.use = "version"
c.short = "Prints version"
c.long = c.short
c.run do
puts Backend::VERSION
end
end
cmd.commands.add do |c|
c.use = "authors"
c.short = "Prints authors"
c.long = c.short
c.run do
puts Backend::AUTHORS.join("\n")
end
end
cmd.commands.add do |c|
c.use = "licenses"
c.short = "Prints licenses of projects used by this programs"
c.long = c.short
c.run do
puts CompiledLicense::LICENSES
end
end
cmd.commands.add do |c|
c.use = "run"
c.short = "Run the backend"
c.long = c.short
c.run do
Backend::Runner.new.run
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 = "skif"
f.long = "--skif"
f.default = false
f.description = "User at SKIF"
end
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 = Backend::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 = Backend::Db::User.create!(username: username, role: role.to_s, skif: opts.bool["skif"], admin: opts.bool["admin"])
Backend::Worker::Jobs::CacheLdapUserJob.new(user.id).enqueue
puts "Done!"
end
end
end
Commander.run(cli, ARGV)

View File

@ -1 +0,0 @@
16.14.2

1
frontend/.nvmrc Normal file
View File

@ -0,0 +1 @@
v16.17.1

View File

@ -1,5 +1,5 @@
FROM node:16-alpine
WORKDIR /app
WORKDIR /usr/src/frontend
COPY ./package.json ./yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .

View File

@ -4,10 +4,6 @@ export interface Node {
id: ID;
}
export interface Skif {
skif: boolean;
}
export enum UserRole {
STUDENT = "Student",
TEACHER = "Teacher",
@ -30,11 +26,11 @@ export interface UserExternal {
user: User;
}
export interface Student extends Node, UserExternal, Skif {
export interface Student extends Node, UserExternal {
vote?: Vote;
}
export interface Teacher extends Node, UserExternal, Skif {
export interface Teacher extends Node, UserExternal {
maxStudents: number;
}

View File

@ -47,81 +47,63 @@
`);
query(meStore);
const teacherRegisterFormMaxStudents = svelteForms.field("maxStudents", 0, [
validators.required(),
validators.min(0),
]);
const teacherRegisterFormSkif = svelteForms.field("skif", false);
const teacheRegisterForm = svelteForms.form(
teacherRegisterFormMaxStudents,
teacherRegisterFormSkif
);
// const teacherRegisterFormMaxStudents = svelteForms.field("maxStudents", 0, [
// validators.required(),
// validators.min(0),
// ]);
// const teacheRegisterForm = svelteForms.form(teacherRegisterFormMaxStudents);
interface RegisterTeacherData {
registerTeacher: Teacher;
}
// interface RegisterTeacherData {
// registerTeacher: Teacher;
// }
interface RegisterTeacherVars {
maxStudents: number;
skif: boolean;
}
// interface RegisterTeacherVars {
// maxStudents: number;
// }
const registerTeacherStore = operationStore<
RegisterTeacherData,
RegisterTeacherVars
>(gql`
mutation RegisterTeacher($maxStudents: Int!, $skif: Boolean!) {
registerTeacher(input: { maxStudents: $maxStudents, skif: $skif }) {
id
}
}
`);
// const registerTeacherStore = operationStore<
// RegisterTeacherData,
// RegisterTeacherVars
// >(gql`
// mutation RegisterTeacher($maxStudents: Int!) {
// registerTeacher(input: { maxStudents: $maxStudents }) {
// id
// }
// }
// `);
const registerTeacherMutation = mutation(registerTeacherStore);
// const registerTeacherMutation = mutation(registerTeacherStore);
async function registerTeacher(): Promise<void> {
await registerTeacherMutation({
maxStudents: $teacherRegisterFormMaxStudents.value,
skif: $teacherRegisterFormSkif.value,
});
// async function registerTeacher(): Promise<void> {
// await registerTeacherMutation({
// maxStudents: $teacherRegisterFormMaxStudents.value,
// });
if (!$registerTeacherStore.error && $registerTeacherStore.data) {
location.reload();
}
}
// if (!$registerTeacherStore.error && $registerTeacherStore.data) {
// location.reload();
// }
// }
const registerStudentSkif = svelteForms.field("skif", false);
const registerStudentForm = svelteForms.form(registerStudentSkif);
// interface RegisterStudentData {
// registerStudent: Student;
// }
interface RegisterStudentData {
registerStudent: Student;
}
// const registerStudentStore = operationStore<RegisterStudentData>(gql`
// mutation RegisterStudent() {
// registerStudent() {
// id
// }
// }
// `);
// const registerStudentMutation = mutation(registerStudentStore);
interface RegisterStudentVars {
skif: boolean;
}
// async function registerStudent(): Promise<void> {
// await registerStudentMutation();
const registerStudentStore = operationStore<
RegisterStudentData,
RegisterStudentVars
>(gql`
mutation RegisterStudent($skif: Boolean!) {
registerStudent(input: { skif: $skif }) {
id
}
}
`);
const registerStudentMutation = mutation(registerStudentStore);
async function registerStudent(): Promise<void> {
await registerStudentMutation({
skif: $registerStudentSkif.value,
});
if (!$registerStudentStore.error && $registerStudentStore.data) {
location.reload();
}
}
// if (!$registerStudentStore.error && $registerStudentStore.data) {
// location.reload();
// }
// }
</script>
{#if $meStore.error}
@ -133,7 +115,7 @@
<hr />
{#if $meStore.data.me.role === UserRole.TEACHER}
{#if !$meStore.data.me.teacher}
<!-- {#if !$meStore.data.me.teacher}
<p>Registriere dich jetzt als Lehrer:</p>
<form on:submit|preventDefault={registerTeacher}>
<label for="maxStudents">Maximale Schüler:</label>
@ -166,9 +148,9 @@
{:else if $registerTeacherStore.data}
<p>Registrierung erfolgreich!</p>
{/if}
{/if}
{/if} -->
{:else if $meStore.data.me.role === UserRole.STUDENT}
{#if !$meStore.data.me.student}
<!-- {#if !$meStore.data.me.student}
<p>Registriere dich jetzt als Schüler:</p>
<form on:submit|preventDefault={registerStudent}>
<label for="skif">SKIF:</label>
@ -192,6 +174,6 @@
{:else if $registerStudentStore.data}
<p>Registrierung erfolgreich!</p>
{/if}
{/if}
{/if} -->
{/if}
{/if}