diff --git a/backend/Dockerfile b/backend/Dockerfile index c1cc1cf..0c44e05 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -39,31 +39,35 @@ RUN shards install --production FROM crystallang/crystal:1.6-alpine as builder WORKDIR /usr/src/mentorenwahl RUN apk add --no-cache pcre2-dev +RUN mkdir deps 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 ./db ./db -COPY ./src ./src ARG BUILD_ENV +COPY ./Makefile . +COPY ./LICENSE . +COPY ./db ./db COPY --from=public /usr/src/public/dist ./public +COPY ./src ./src 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 +LABEL maintainer="Dominic Grimm " \ + org.opencontainers.image.description="Backend of Mentorenwahl" \ + org.opencontainers.image.licenses="GNU GPLv3" \ + org.opencontainers.image.source="https://git.dergrimm.net/mentorenwahl/mentorenwahl" \ + org.opencontainers.image.url="https://git.dergrimm.net/mentorenwahl/mentorenwahl" WORKDIR / COPY --from=micrate-builder /usr/src/micrate/bin/micrate ./bin/micrate +COPY --from=builder /usr/src/mentorenwahl/db ./db 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 [ "./bin/backend" ] -CMD [ "run" ] +CMD [ "./bin/backend", "run" ] diff --git a/backend/db/migrations/20220414171336_create_users.sql b/backend/db/migrations/20220414171336_create_users.sql index 24ad6e7..c92c438 100644 --- a/backend/db/migrations/20220414171336_create_users.sql +++ b/backend/db/migrations/20220414171336_create_users.sql @@ -30,6 +30,7 @@ CREATE TABLE tokens( id uuid PRIMARY KEY, iat timestamp NOT NULL, exp timestamp NOT NULL, + active boolean NOT NULL, user_id int NOT NULL REFERENCES users(id) ); diff --git a/backend/src/backend/api/context.cr b/backend/src/backend/api/context.cr index 3eb8829..65054ac 100644 --- a/backend/src/backend/api/context.cr +++ b/backend/src/backend/api/context.cr @@ -18,6 +18,7 @@ require "graphql" require "http/headers" require "jwt/errors" require "json" +require "uuid" module Backend module Api @@ -41,13 +42,17 @@ module Backend # User's external object getter external + # JTI of request token + getter jti + def initialize( @development : Bool, @status : Status, @user : Db::User?, @admin : Bool?, @role : Schema::UserRole?, - @external : (Db::Teacher | Db::Student)? + @external : (Db::Teacher | Db::Student)?, + @jti : UUID? ) end @@ -63,15 +68,20 @@ module Backend @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 + @jti = payload.jti + if Db::Token.query.where { (id == payload.jti) & active }.first.nil? + @status = Status::SessionExpired + else + @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 diff --git a/backend/src/backend/api/schema/helpers.cr b/backend/src/backend/api/schema/helpers.cr index 46d8ea7..0a1836d 100644 --- a/backend/src/backend/api/schema/helpers.cr +++ b/backend/src/backend/api/schema/helpers.cr @@ -20,7 +20,7 @@ module Backend # Schema helper macros module Helpers # Defines DB model field helper functions - macro db_object(type) + macro db_object(type, id_type) def initialize(@model : {{ type }}) end @@ -32,7 +32,7 @@ module Backend @[GraphQL::Field] # {{ space_name }}'s ID - def id : Int32 + def id : {{ id_type }} @model.id end end diff --git a/backend/src/backend/api/schema/mutation.cr b/backend/src/backend/api/schema/mutation.cr index b290f73..fe1dcc2 100644 --- a/backend/src/backend/api/schema/mutation.cr +++ b/backend/src/backend/api/schema/mutation.cr @@ -36,9 +36,9 @@ module Backend id: UUID.random(Random::Secure), iat: Time.utc, exp: Time.utc + Backend.config.api.jwt_expiration.minutes, + active: true, user_id: user.id ) - pp! token, typeof(token.id) LoginPayload.new( user: User.new(user), @@ -51,6 +51,29 @@ module Backend ) end + @[GraphQL::Field] + # Logs out of account by revoking token + def logout(context : Context) : Scalars::UUID? + context.authenticated! + + jti = context.jti.not_nil! + + token = Db::Token.find!(jti) + token.active = false + token.save! + + Scalars::UUID.new(jti) + end + + @[GraphQL::Field] + def revoke_token(context : Context, token : Scalars::UUID) : Scalars::UUID + context.authenticated! + + Db::Token.query.find!(token.value).delete + + Scalars::UUID.new(token.value) + end + @[GraphQL::Field] # Creates user def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User? diff --git a/backend/src/backend/api/schema/query.cr b/backend/src/backend/api/schema/query.cr index 235261f..46a22d0 100644 --- a/backend/src/backend/api/schema/query.cr +++ b/backend/src/backend/api/schema/query.cr @@ -39,6 +39,16 @@ module Backend User.new(context.user.not_nil!) end + @[GraphQL::Field] + # Active tokens of the current user + def tokens(context : Context) : Array(Token)? + context.authenticated! + + now = Time.utc + + Db::Token.query.where { (user_id == context.user.not_nil!.id) & (exp > now) & active }.map { |t| Token.new(t) } + end + @[GraphQL::Field] # User by ID def user(context : Context, id : Int32) : User? diff --git a/backend/src/backend/api/schema/scalars.cr b/backend/src/backend/api/schema/scalars.cr new file mode 100644 index 0000000..6a0c93b --- /dev/null +++ b/backend/src/backend/api/schema/scalars.cr @@ -0,0 +1 @@ +require "./scalars/*" diff --git a/backend/src/backend/api/schema/scalars/time.cr b/backend/src/backend/api/schema/scalars/time.cr new file mode 100644 index 0000000..86b0579 --- /dev/null +++ b/backend/src/backend/api/schema/scalars/time.cr @@ -0,0 +1,17 @@ +module Backend::Api::Schema::Scalars + @[GraphQL::Scalar] + class Time < GraphQL::BaseScalar + property value + + def initialize(@value : ::Time) + end + + def self.from_json(string_or_io) + self.new(::Time.parse_iso8601(string_or_io)) + end + + def to_json(builder : JSON::Builder) + builder.scalar(@value.to_rfc3339) + end + end +end diff --git a/backend/src/backend/api/schema/scalars/uuid.cr b/backend/src/backend/api/schema/scalars/uuid.cr new file mode 100644 index 0000000..56b5a48 --- /dev/null +++ b/backend/src/backend/api/schema/scalars/uuid.cr @@ -0,0 +1,19 @@ +require "uuid" + +module Backend::Api::Schema::Scalars + @[GraphQL::Scalar] + class UUID < GraphQL::BaseScalar + property value + + def initialize(@value : ::UUID) + end + + def self.from_json(string_or_io) + self.new(::UUID.from_json(string_or_io)) + end + + def to_json(builder : JSON::Builder) + builder.scalar(@value.to_s) + end + end +end diff --git a/backend/src/backend/api/schema/student.cr b/backend/src/backend/api/schema/student.cr index 60daa47..df72952 100644 --- a/backend/src/backend/api/schema/student.cr +++ b/backend/src/backend/api/schema/student.cr @@ -22,7 +22,7 @@ module Backend class Student < GraphQL::BaseObject include Helpers - db_object Db::Student + db_object Db::Student, Int32 @[GraphQL::Field] # Student's user diff --git a/backend/src/backend/api/schema/teacher.cr b/backend/src/backend/api/schema/teacher.cr index b356593..48b8342 100644 --- a/backend/src/backend/api/schema/teacher.cr +++ b/backend/src/backend/api/schema/teacher.cr @@ -22,7 +22,7 @@ module Backend class Teacher < GraphQL::BaseObject include Helpers - db_object Db::Teacher + db_object Db::Teacher, Int32 @[GraphQL::Field] # Teacher's user diff --git a/backend/src/backend/api/schema/teacher_vote.cr b/backend/src/backend/api/schema/teacher_vote.cr index 1a57b98..f59bf5e 100644 --- a/backend/src/backend/api/schema/teacher_vote.cr +++ b/backend/src/backend/api/schema/teacher_vote.cr @@ -22,7 +22,7 @@ module Backend class TeacherVote < GraphQL::BaseObject include Helpers - db_object Db::TeacherVote + db_object Db::TeacherVote, Int32 @[GraphQL::Field] # Voted teacher diff --git a/backend/src/backend/api/schema/token.cr b/backend/src/backend/api/schema/token.cr new file mode 100644 index 0000000..3b8d0d8 --- /dev/null +++ b/backend/src/backend/api/schema/token.cr @@ -0,0 +1,34 @@ +module Backend::Api::Schema + @[GraphQL::Object] + # Token model + class Token < GraphQL::BaseObject + include Helpers + + def initialize(@model : Db::Token) + end + + @[GraphQL::Field] + # Token ID / JTI + def id : Scalars::UUID + Scalars::UUID.new(@model.id) + end + + @[GraphQL::Field] + # Time the token was issued at + def iat : Scalars::Time + Scalars::Time.new(@model.iat) + end + + @[GraphQL::Field] + # Time for the token to expire + def exp : Scalars::Time + Scalars::Time.new(@model.exp) + end + + @[GraphQL::Field] + # Token is expired + def expired : Bool + @model.exp > Time.utc + end + end +end diff --git a/backend/src/backend/api/schema/user.cr b/backend/src/backend/api/schema/user.cr index 2a208df..5ee8b84 100644 --- a/backend/src/backend/api/schema/user.cr +++ b/backend/src/backend/api/schema/user.cr @@ -44,7 +44,7 @@ module Backend class User < GraphQL::BaseObject include Helpers - db_object Db::User + db_object Db::User, Int32 @ldap : Ldap::User? diff --git a/backend/src/backend/api/schema/vote.cr b/backend/src/backend/api/schema/vote.cr index 83e349e..8ae756e 100644 --- a/backend/src/backend/api/schema/vote.cr +++ b/backend/src/backend/api/schema/vote.cr @@ -22,7 +22,7 @@ module Backend class Vote < GraphQL::BaseObject include Helpers - db_object Db::Vote + db_object Db::Vote, Int32 @[GraphQL::Field] # Student who voted diff --git a/backend/src/backend/db/assignment.cr b/backend/src/backend/db/assignment.cr index 2d0d3ee..f4e1706 100644 --- a/backend/src/backend/db/assignment.cr +++ b/backend/src/backend/db/assignment.cr @@ -14,16 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -module Backend - module Db - class Assignment - include Clear::Model - self.table = :assignments +module Backend::Db + class Assignment + include Clear::Model + self.table = :assignments - primary_key type: :serial + primary_key type: :serial - belongs_to student : Student - belongs_to teacher : Teacher - end + belongs_to student : Student + belongs_to teacher : Teacher end end diff --git a/backend/src/backend/db/micrate_db_version.cr b/backend/src/backend/db/micrate_db_version.cr index 91d5c1e..ac81bc8 100644 --- a/backend/src/backend/db/micrate_db_version.cr +++ b/backend/src/backend/db/micrate_db_version.cr @@ -1,14 +1,12 @@ -module Backend - module Db - class MicrateDbVersion - include Clear::Model - self.table = :micrate_db_version +module Backend::Db + class MicrateDbVersion + include Clear::Model + self.table = :micrate_db_version - primary_key type: :serial + primary_key type: :serial - column version_id : Int64 - column is_applied : Bool - column tstamp : Time? - end + column version_id : Int64 + column is_applied : Bool + column tstamp : Time? end end diff --git a/backend/src/backend/db/student.cr b/backend/src/backend/db/student.cr index dab65ce..8d797f3 100644 --- a/backend/src/backend/db/student.cr +++ b/backend/src/backend/db/student.cr @@ -1,15 +1,13 @@ -module Backend - module Db - class Student - include Clear::Model - self.table = :students +module Backend::Db + class Student + include Clear::Model + self.table = :students - primary_key type: :serial + primary_key type: :serial - belongs_to user : User + belongs_to user : User - has_one vote : Vote?, foreign_key: :student_id - has_one assignment : Assignment?, foreign_key: :student_id - end + has_one vote : Vote?, foreign_key: :student_id + has_one assignment : Assignment?, foreign_key: :student_id end end diff --git a/backend/src/backend/db/teacher.cr b/backend/src/backend/db/teacher.cr index a15a948..dd863f3 100644 --- a/backend/src/backend/db/teacher.cr +++ b/backend/src/backend/db/teacher.cr @@ -1,17 +1,15 @@ -module Backend - module Db - class Teacher - include Clear::Model - self.table = :teachers +module Backend::Db + class Teacher + include Clear::Model + self.table = :teachers - primary_key type: :serial + primary_key type: :serial - belongs_to user : User + belongs_to user : User - column max_students : Int32 + column max_students : Int32 - has_many teacher_votes : TeacherVote, foreign_key: :teacher_id - has_many assignments : Assignment, foreign_key: :teacher_id - end + has_many teacher_votes : TeacherVote, foreign_key: :teacher_id + has_many assignments : Assignment, foreign_key: :teacher_id end end diff --git a/backend/src/backend/db/teacher_vote.cr b/backend/src/backend/db/teacher_vote.cr index 8464099..3d90e22 100644 --- a/backend/src/backend/db/teacher_vote.cr +++ b/backend/src/backend/db/teacher_vote.cr @@ -1,15 +1,13 @@ -module Backend - module Db - class TeacherVote - include Clear::Model - self.table = :teacher_votes +module Backend::Db + class TeacherVote + include Clear::Model + self.table = :teacher_votes - primary_key type: :serial + primary_key type: :serial - belongs_to vote : Vote - belongs_to teacher : Teacher + belongs_to vote : Vote + belongs_to teacher : Teacher - column priority : Int32 - end + column priority : Int32 end end diff --git a/backend/src/backend/db/token.cr b/backend/src/backend/db/token.cr index f927d13..a5e1009 100644 --- a/backend/src/backend/db/token.cr +++ b/backend/src/backend/db/token.cr @@ -7,6 +7,7 @@ module Backend::Db column iat : Time column exp : Time + column active : Bool belongs_to user : User end diff --git a/backend/src/backend/db/user.cr b/backend/src/backend/db/user.cr index 82290af..6888408 100644 --- a/backend/src/backend/db/user.cr +++ b/backend/src/backend/db/user.cr @@ -1,40 +1,38 @@ -module Backend - module Db - Clear.enum UserRole, :teacher, :student +module Backend::Db + Clear.enum UserRole, :teacher, :student - struct UserRole - # API representation of the enum - def to_api : Api::Schema::UserRole - 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 - def self.from_api(role : Api::Schema::UserRole) : self - role.to_db + struct UserRole + # API representation of the enum + def to_api : Api::Schema::UserRole + case self + when Student + Api::Schema::UserRole::Student + when Teacher + Api::Schema::UserRole::Teacher + else + raise "Invalid enum value for UserRole" end end - class User - include Clear::Model - self.table = :users - - primary_key type: :serial - - column username : String - column role : UserRole - column admin : Bool = false - - has_one student : Student?, foreign_key: :user_id - has_one teacher : Teacher?, foreign_key: :user_id - - has_many tokens : Token, foreign_key: :user_id + # DB representation of the enum + def self.from_api(role : Api::Schema::UserRole) : self + role.to_db end end + + class User + include Clear::Model + self.table = :users + + primary_key type: :serial + + column username : String + column role : UserRole + column admin : Bool = false + + has_one student : Student?, foreign_key: :user_id + has_one teacher : Teacher?, foreign_key: :user_id + + has_many tokens : Token, foreign_key: :user_id + end end diff --git a/backend/src/backend/db/vote.cr b/backend/src/backend/db/vote.cr index 9a30b99..aca88ee 100644 --- a/backend/src/backend/db/vote.cr +++ b/backend/src/backend/db/vote.cr @@ -1,14 +1,12 @@ -module Backend - module Db - class Vote - include Clear::Model - self.table = :votes +module Backend::Db + class Vote + include Clear::Model + self.table = :votes - primary_key type: :serial + primary_key type: :serial - belongs_to student : Student + belongs_to student : Student - has_many teacher_votes : TeacherVote, foreign_key: :vote_id - end + has_many teacher_votes : TeacherVote, foreign_key: :vote_id end end diff --git a/backend/src/backend/worker/jobs/assignment_job.cr b/backend/src/backend/worker/jobs/assignment_job.cr index 42567ae..8dfd2fc 100644 --- a/backend/src/backend/worker/jobs/assignment_job.cr +++ b/backend/src/backend/worker/jobs/assignment_job.cr @@ -14,28 +14,27 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -require "random/secure" - module Backend module Worker module Jobs # Assigns students to teachers when all students voted class AssignStudentsJob < Mosquito::QueuedJob - def rescheduleable? + # run_every 1.minute + + def rescheduleable? : Bool false end + alias TeacherVote = {student: Int32, priority: Int32} + alias Assignment = {teacher: Int32, priority: Int32} + # :ditto: def perform : Nil - user_count = Db::User.query.count teacher_count = Db::Teacher.query.count student_count = Db::Student.query.count vote_count = Db::Vote.query.count - if user_count == 0 - log "No users found, skipping assignment" - fail - elsif teacher_count == 0 + if teacher_count == 0 log "No teachers found, skipping assignment" fail elsif student_count == 0 @@ -47,67 +46,123 @@ module Backend elsif vote_count < student_count log "Not all students voted, skipping assignment" fail + elsif Db::Assignment.query.count > 0 + log "Assignment has already run, skipping another assignment" + fail end - # possibilities = [] of Possibility - # teachers = Db::Teacher.query.to_a.select! { |t| t.assignments.count < t.max_students } - # empty_assignment = Hash.zip(teachers.map(&.id), [[] of StudentAssignment] * teachers.size) - # random_votes = Db::Student.query.to_a.select!(&.vote).map do |s| - # { - # student: s.id, - # teachers: s.vote.not_nil!.teacher_votes.to_a - # .sort_by!(&.priority) - # .reverse! - # .map(&.teacher.id), - # } - # end - - # Backend.config.assignment_retry_count.times do - # pp! random_votes.shuffle!(Random::Secure) - # a = empty_assignment.clone - # random_votes. - - # possibilities << {assignment: a, weighting: 0} - # end - - # pp! possibilities - - # 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 + teachers = Db::Teacher.query .where do - raw("NOT EXISTS (SELECT 1 FROM assignments WHERE student_id = students.id)") & - raw("EXISTS (SELECT 1 FROM votes WHERE student_id = students.id)") + raw("EXISTS (SELECT 1 FROM teacher_votes WHERE teacher_id = teachers.id)") & + (max_students > 0) end - .with_vote(&.with_teacher_votes(&.order_by(:priority, :asc))) - # student_ids = students.map(&.id) + .with_teacher_votes + .to_a + vote_index = Hash.zip(teachers.map(&.id), [0] * teachers.size) + teacher_votes : Hash(Int32, Array(TeacherVote)) = Hash.zip( + teachers.map(&.id), + teachers.map do |t| + t.teacher_votes.map do |tv| + vote_index[t.id] += 1 - # votes = Hash.zip( - # student_ids, - # students.map do |s| - # s.vote.not_nil!.teacher_votes.map(&.teacher_id) - # end, - # ) - # pp! votes, student_ids + { + student: tv.vote.student.id, + priority: tv.priority, + } + end + end + ) + teachers.sort_by! { |t| vote_index[t.id] } - random_votes = students.map do |s| - { - student: s.id, - teachers: s.vote.not_nil!.teacher_votes.map(&.teacher_id), - } + students = Db::Student.query + .with_vote(&.with_teacher_votes(&.order_by(priority: :asc))) + .to_a + student_ids = students.map(&.id) + votes = Hash.zip( + student_ids, + students.map do |s| + s.vote.not_nil!.teacher_votes + .to_a + .select { |tv| teacher_votes.has_key?(tv.teacher.id) } + .map do |tv| + { + teacher: tv.teacher.id, + teacher_max_students: tv.teacher.max_students, + } + end + end + ) + + best_assignment = { + assignment: {} of Int32 => Assignment, + score: Float32::INFINITY, + } + + Backend.config.assignment_possibility_count.times.each do + assignment = {} of Int32 => Assignment + assignment_count = Hash.zip(teachers.map(&.id), [0] * teachers.size) + # teachers.each do |t| + # queue = Deque.new(teacher_votes[t.id].shuffle) + + # count = 1 + # while count < t.max_students + # break unless x = queue.shift? + # tv = x.not_nil! + + # if assignment[tv[:student]]?.nil? || assignment[tv[:student]][:priority] <= tv[:priority] + # assignment[tv[:student]] = {teacher: t.id, priority: tv[:priority]} + # count += 1 + # end + # end + # end + votes.to_a.shuffle.each do |s, tvs| + tvs.each_with_index do |tv, i| + if assignment[s]?.nil? + assignment_count[tv[:teacher]] += 1 + assignment[s] = {teacher: tv[:teacher], priority: i} + elsif assignment_count[tv[:teacher]] < tv[:teacher_max_students] + assignment_count[assignment[s][:teacher]] -= 1 + assignment_count[tv[:teacher]] += 1 + assignment[s] = {teacher: tv[:teacher], priority: i} + end + end + end + + pp! assignment, assignment_count + + score = 0_f32 + # positivity = 0 + # assignment.each do |s, a| + # ratio = (vote_sizes[s] - a[:priority]) / vote_sizes[s] + # score += 2 ** ratio + # positivity += ratio > 0.5 ? 1 : -1 + # end + assignment.each do |s, a| + size = votes[s].size + p! a[:priority], (votes[s].size - a[:priority]) / size + # score += 1 if ((votes[s].size - a[:priority]) / size) >= 0.5 + score += a[:priority] + end + + # full_score = score ** positivity + if score < best_assignment[:score] + best_assignment = { + assignment: assignment, + score: score, + } + end 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) - Backend.config.assignment_possibility_count.times do - random_votes.shuffle!(Random::Secure) + pp! best_assignment - # votes = random_votes.dup - # a = empty_assignment.clone + str = String.build do |str| + str << "===========================\n" + best_assignment[:assignment].each do |s, a| + str << "#{Db::Student.query.find!(s).user.username} : #{Db::Teacher.query.find!(a[:teacher]).user.username} (#{a[:priority]} / #{votes[s].size})\n" + end + str << "===========================\n" end - pp! assignments + print str end end end diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf index a9c7906..20d933e 100644 --- a/config/nginx/nginx.conf +++ b/config/nginx/nginx.conf @@ -15,9 +15,13 @@ # along with this program. If not, see . events { + worker_connections 1024; } http { + server_tokens off; + more_clear_headers Server; + server { location / { proxy_pass http://frontend/; diff --git a/docker-compose.yml b/docker-compose.yml index d2fd727..69ae161 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ version: "3" services: nginx: - image: nginx:alpine + image: byjg/nginx-extras restart: always volumes: - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro @@ -58,7 +58,7 @@ services: - redis:/data backend: - image: mentorenwahl/backend + image: git.dergrimm.net/mentorenwahl/backend:latest build: context: ./backend args: @@ -96,7 +96,7 @@ services: BACKEND_LDAP_CACHE_REFRESH_INTERVAL: ${BACKEND_LDAP_CACHE_REFRESH_INTERVAL} frontend: - image: mentorenwahl/frontend + image: git.dergrimm.net/mentorenwahl/frontend:latest build: context: ./frontend restart: always diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 24d1d3a..8de8f74 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -36,6 +36,11 @@ WORKDIR /usr/src/public COPY --from=public /usr/src/public . RUN find . -name "*.wasm" -type f | xargs -I % wasm-opt % -o % -O --intrinsic-lowering -Oz -FROM nginx:alpine as runner +FROM byjg/nginx-extras as runner +LABEL maintainer="Dominic Grimm " \ + org.opencontainers.image.description="Frontend of Mentorenwahl" \ + org.opencontainers.image.licenses="GNU GPLv3" \ + org.opencontainers.image.source="https://git.dergrimm.net/mentorenwahl/mentorenwahl" \ + org.opencontainers.image.url="https://git.dergrimm.net/mentorenwahl/mentorenwahl" COPY ./nginx.conf /etc/nginx/nginx.conf COPY --from=binaryen /usr/src/public /var/www/html diff --git a/frontend/graphql/mutations/logout.graphql b/frontend/graphql/mutations/logout.graphql new file mode 100644 index 0000000..82d7baf --- /dev/null +++ b/frontend/graphql/mutations/logout.graphql @@ -0,0 +1,3 @@ +mutation Logout { + logout +} diff --git a/frontend/graphql/mutations/revoke_token.graphql b/frontend/graphql/mutations/revoke_token.graphql new file mode 100644 index 0000000..e66e959 --- /dev/null +++ b/frontend/graphql/mutations/revoke_token.graphql @@ -0,0 +1,3 @@ +mutation RevokeToken($token: UUID!) { + revokeToken(token: $token) +} diff --git a/frontend/graphql/queries/tokens.graphql b/frontend/graphql/queries/tokens.graphql new file mode 100644 index 0000000..fbab76f --- /dev/null +++ b/frontend/graphql/queries/tokens.graphql @@ -0,0 +1,7 @@ +query Tokens { + tokens { + id + iat + exp + } +} \ No newline at end of file diff --git a/frontend/graphql/schema.graphql b/frontend/graphql/schema.graphql index 8ba8bff..c55dc98 100644 --- a/frontend/graphql/schema.graphql +++ b/frontend/graphql/schema.graphql @@ -15,6 +15,7 @@ type Query { teacherVote(id: Int!): TeacherVote teacherVotes: [TeacherVote!] teachers: [Teacher!]! + tokens: [Token!] user(id: Int!): User userByUsername(username: String!): User users: [User!] @@ -42,6 +43,17 @@ type TeacherVote { vote: Vote! } +scalar Time + +type Token { + exp: Time! + expired: Boolean! + iat: Time! + id: UUID! +} + +scalar UUID + type User { admin: Boolean! email: String! @@ -79,7 +91,9 @@ type Mutation { createVote(input: VoteCreateInput!): Vote deleteUser(id: Int!): Int login(password: String!, username: String!): LoginPayload + logout: UUID registerTeacher(input: TeacherInput!): Teacher + revokeToken(token: UUID!): UUID! } input TeacherInput { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7e00e07..8b2b43d 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,29 +1,10 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log notice; -pid /var/run/nginx.pid; - events { worker_connections 1024; } http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - tcp_nopush on; - - keepalive_timeout 65; - - #gzip on; + server_tokens off; + more_clear_headers Server; server { listen 80; diff --git a/frontend/src/graphql/mutations/login.rs b/frontend/src/graphql/mutations/login.rs index 0993e88..300b496 100644 --- a/frontend/src/graphql/mutations/login.rs +++ b/frontend/src/graphql/mutations/login.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/mutations/login.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct Login; diff --git a/frontend/src/graphql/mutations/logout.rs b/frontend/src/graphql/mutations/logout.rs new file mode 100644 index 0000000..4e4381b --- /dev/null +++ b/frontend/src/graphql/mutations/logout.rs @@ -0,0 +1,12 @@ +use graphql_client::GraphQLQuery; + +type UUID = String; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.graphql", + query_path = "graphql/mutations/logout.graphql", + response_derives = "Debug", + skip_serializing_none +)] +pub struct Logout; diff --git a/frontend/src/graphql/mutations/mod.rs b/frontend/src/graphql/mutations/mod.rs index 521c7c4..da438a2 100644 --- a/frontend/src/graphql/mutations/mod.rs +++ b/frontend/src/graphql/mutations/mod.rs @@ -1,3 +1,5 @@ pub mod login; +pub mod logout; pub mod register_teacher; +pub mod revoke_token; pub mod vote; diff --git a/frontend/src/graphql/mutations/register_teacher.rs b/frontend/src/graphql/mutations/register_teacher.rs index 56bb0f0..2aec680 100644 --- a/frontend/src/graphql/mutations/register_teacher.rs +++ b/frontend/src/graphql/mutations/register_teacher.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/mutations/register_teacher.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct RegisterTeacher; diff --git a/frontend/src/graphql/mutations/revoke_token.rs b/frontend/src/graphql/mutations/revoke_token.rs new file mode 100644 index 0000000..68f22c4 --- /dev/null +++ b/frontend/src/graphql/mutations/revoke_token.rs @@ -0,0 +1,12 @@ +use graphql_client::GraphQLQuery; + +type UUID = String; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.graphql", + query_path = "graphql/mutations/revoke_token.graphql", + response_derives = "Debug", + skip_serializing_none +)] +pub struct RevokeToken; diff --git a/frontend/src/graphql/mutations/vote.rs b/frontend/src/graphql/mutations/vote.rs index 4588dd5..704bc27 100644 --- a/frontend/src/graphql/mutations/vote.rs +++ b/frontend/src/graphql/mutations/vote.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/mutations/vote.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct Vote; diff --git a/frontend/src/graphql/queries/config.rs b/frontend/src/graphql/queries/config.rs index 8e18b58..f02c5c5 100644 --- a/frontend/src/graphql/queries/config.rs +++ b/frontend/src/graphql/queries/config.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/queries/config.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct Config; diff --git a/frontend/src/graphql/queries/me.rs b/frontend/src/graphql/queries/me.rs index ffa5508..cfaa4bb 100644 --- a/frontend/src/graphql/queries/me.rs +++ b/frontend/src/graphql/queries/me.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/queries/me.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct Me; diff --git a/frontend/src/graphql/queries/mod.rs b/frontend/src/graphql/queries/mod.rs index f227e2e..ad3318f 100644 --- a/frontend/src/graphql/queries/mod.rs +++ b/frontend/src/graphql/queries/mod.rs @@ -3,3 +3,4 @@ pub mod me; pub mod ok; pub mod students_can_vote; pub mod teachers; +pub mod tokens; diff --git a/frontend/src/graphql/queries/ok.rs b/frontend/src/graphql/queries/ok.rs index d3aba17..1bfb579 100644 --- a/frontend/src/graphql/queries/ok.rs +++ b/frontend/src/graphql/queries/ok.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/queries/ok.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] -pub struct Ok; \ No newline at end of file +pub struct Ok; diff --git a/frontend/src/graphql/queries/students_can_vote.rs b/frontend/src/graphql/queries/students_can_vote.rs index 749bdab..185e1ba 100644 --- a/frontend/src/graphql/queries/students_can_vote.rs +++ b/frontend/src/graphql/queries/students_can_vote.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/queries/students_can_vote.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct StudentsCanVote; diff --git a/frontend/src/graphql/queries/teachers.rs b/frontend/src/graphql/queries/teachers.rs index 8a0f437..bc1634d 100644 --- a/frontend/src/graphql/queries/teachers.rs +++ b/frontend/src/graphql/queries/teachers.rs @@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery; #[graphql( schema_path = "graphql/schema.graphql", query_path = "graphql/queries/teachers.graphql", - response_derives = "Debug" + response_derives = "Debug", + skip_serializing_none )] pub struct Teachers; diff --git a/frontend/src/graphql/queries/tokens.rs b/frontend/src/graphql/queries/tokens.rs new file mode 100644 index 0000000..1b87cfe --- /dev/null +++ b/frontend/src/graphql/queries/tokens.rs @@ -0,0 +1,12 @@ +use graphql_client::GraphQLQuery; + +type UUID = String; +type Time = String; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.graphql", + query_path = "graphql/queries/tokens.graphql", + response_derives = "Debug" +)] +pub struct Tokens; diff --git a/frontend/src/layouts/main.rs b/frontend/src/layouts/main.rs index 4bb7880..f583349 100644 --- a/frontend/src/layouts/main.rs +++ b/frontend/src/layouts/main.rs @@ -1,17 +1,21 @@ +use graphql_client::reqwest::post_graphql; use yew::prelude::*; use yew_agent::{Dispatched, Dispatcher}; use yew_router::prelude::*; use crate::agents; use crate::components; +use crate::graphql; use crate::routes; pub enum Msg { - LogOut, + LogOutClicked, + LogOut(Option>), } #[derive(Properties, PartialEq)] pub struct MainProps { + pub token: Option, pub logged_in: bool, #[prop_or_default] pub children: Children, @@ -20,6 +24,7 @@ pub struct MainProps { pub struct Main { logged_in: bool, logged_in_event_bus: Dispatcher, + errors: Option>, } impl Component for Main { @@ -30,50 +35,75 @@ impl Component for Main { Self { logged_in: ctx.props().logged_in, logged_in_event_bus: agents::logged_in::EventBus::dispatcher(), + errors: None, } } - fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { - Msg::LogOut => { - self.logged_in_event_bus - .send(agents::logged_in::Request::LoggedIn(false)); + Msg::LogOutClicked => { + let client = graphql::client(ctx.props().token.as_ref()).unwrap(); + ctx.link().send_future(async move { + let response = post_graphql::( + &client, + graphql::URL.as_str(), + graphql::mutations::logout::logout::Variables, + ) + .await + .unwrap(); + + Msg::LogOut(components::graphql_errors::convert(response.errors)) + }); + false } + Msg::LogOut(errors) => { + if self.errors.is_none() { + self.logged_in_event_bus + .send(agents::logged_in::Request::LoggedIn(false)); + false + } else { + self.errors = errors; + true + } + } } } fn view(&self, ctx: &Context) -> Html { - let logout_onclick = ctx.link().callback(move |_| Msg::LogOut); - html! { - <> - + <> + + +
+
+ { for ctx.props().children.iter() } +
+ } } } diff --git a/frontend/src/routes/home/mod.rs b/frontend/src/routes/home/mod.rs index 9416bd1..ba35ddb 100644 --- a/frontend/src/routes/home/mod.rs +++ b/frontend/src/routes/home/mod.rs @@ -22,13 +22,8 @@ pub struct HomeProps { pub token: Option, } -enum State { - Fetching, - Done, -} - pub struct Home { - state: State, + fetching: bool, errors: Option>, me: Option, } @@ -37,9 +32,25 @@ impl Component for Home { type Message = Msg; type Properties = HomeProps; - fn create(_ctx: &Context) -> Self { + fn create(ctx: &Context) -> Self { + let client = graphql::client(ctx.props().token.as_ref()).unwrap(); + ctx.link().send_future(async move { + let response = post_graphql::( + &client, + graphql::URL.as_str(), + graphql::queries::me::me::Variables, + ) + .await + .unwrap(); + + Msg::DoneFetching { + errors: components::graphql_errors::convert(response.errors), + me: response.data.unwrap().me.unwrap(), + } + }); + Self { - state: State::Fetching, + fetching: true, errors: None, me: None, } @@ -48,7 +59,7 @@ impl Component for Home { fn update(&mut self, _ctx: &Context, msg: Self::Message) -> bool { match msg { Msg::DoneFetching { errors, me } => { - self.state = State::Done; + self.fetching = false; self.errors = errors; self.me = Some(me); true @@ -60,62 +71,39 @@ impl Component for Home { html! { <> - { - match self.state { - State::Fetching => { - let client = graphql::client(ctx.props().token.as_ref()).unwrap(); - ctx.link().send_future(async move { - let response = post_graphql::<graphql::queries::me::Me, _>( - &client, - graphql::URL.as_str(), - graphql::queries::me::me::Variables, - ) - .await - .unwrap(); - - Msg::DoneFetching { - errors: components::graphql_errors::convert(response.errors), - me: response.data.unwrap().me.unwrap(), - } - }); + if self.fetching { + <p>{ "Fetching..." }</p> + } else { + if let Some(errors) = &self.errors { + <components::graphql_errors::GraphQLErrors errors={errors.clone()} /> + } else { + {{ + let me = self.me.as_ref().unwrap(); html! { - <p>{ "Fetching..." }</p> - } - } - State::Done => { - if let Some(errors) = &self.errors { - html! { - <components::graphql_errors::GraphQLErrors errors={errors.clone()} /> - } - } else { - let me = self.me.as_ref().unwrap(); - - html! { - <> - <h1>{ format!("Hey, {}!", me.first_name) }</h1> - <hr /> - { - match me.role { - graphql::queries::me::me::UserRole::Student => html! { - <student_home::StudentHome - token={ctx.props().token.as_ref().unwrap().to_owned()} - voted={me.student.as_ref().unwrap().vote.is_some()} - /> - }, - graphql::queries::me::me::UserRole::Teacher => html! { - <teacher_home::TeacherHome - token={ctx.props().token.as_ref().unwrap().to_owned()} - registered={me.teacher.is_some()} - /> - }, - graphql::queries::me::me::UserRole::Other(_) => html! {} - } + <> + <h1>{ format!("Hey, {}!", me.first_name) }</h1> + <hr /> + { + match me.role { + graphql::queries::me::me::UserRole::Student => html! { + <student_home::StudentHome + token={ctx.props().token.as_ref().unwrap().to_owned()} + voted={me.student.as_ref().unwrap().vote.is_some()} + /> + }, + graphql::queries::me::me::UserRole::Teacher => html! { + <teacher_home::TeacherHome + token={ctx.props().token.as_ref().unwrap().to_owned()} + registered={me.teacher.is_some()} + /> + }, + graphql::queries::me::me::UserRole::Other(_) => html! {} } - </> - } + } + </> } - } + }} } } </> diff --git a/frontend/src/routes/home/student_vote.rs b/frontend/src/routes/home/student_vote.rs index c527104..4d73979 100644 --- a/frontend/src/routes/home/student_vote.rs +++ b/frontend/src/routes/home/student_vote.rs @@ -231,10 +231,8 @@ impl Component for StudentVote { <form {onsubmit}> { for (0..self.slots).map(|i| { let curr_t = self.votes.get(&i); - if let Some(te) = curr_t { - if let Some(t) = te { - teachers.push(*t); - } + if let Some(Some(t)) = curr_t { + teachers.push(*t); } let name = format!("mentors_{}", i); diff --git a/frontend/src/routes/home/teacher_registration.rs b/frontend/src/routes/home/teacher_registration.rs index 6f77ae1..e676aed 100644 --- a/frontend/src/routes/home/teacher_registration.rs +++ b/frontend/src/routes/home/teacher_registration.rs @@ -87,7 +87,7 @@ impl Component for TeacherRegistration { <label for="max_students"> { "Maximale Anzahl von Schülern:" } <br /> - <input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=1 /> + <input ref={self.max_students.clone()} type="number" id="max_students" name="max_students" min=0 /> </label> </div> diff --git a/frontend/src/routes/mod.rs b/frontend/src/routes/mod.rs index e68b15c..3f1f21b 100644 --- a/frontend/src/routes/mod.rs +++ b/frontend/src/routes/mod.rs @@ -7,11 +7,14 @@ use crate::layouts; pub mod home; pub mod login; pub mod not_found; +pub mod settings; #[derive(Clone, Routable, PartialEq, Eq)] pub enum Route { #[at("/")] Home, + #[at("/settings")] + Settings, #[at("/login")] Login, #[not_found] @@ -39,19 +42,28 @@ pub fn switch(routes: &Route) -> Html { Route::Home => { html! { <layouts::logged_in::LoggedIn {logged_in}> - <layouts::main::Main {logged_in}> + <layouts::main::Main token={token.to_owned()} {logged_in}> <home::Home {token} /> </layouts::main::Main> </layouts::logged_in::LoggedIn> } } + Route::Settings => { + html! { + <layouts::logged_in::LoggedIn {logged_in}> + <layouts::main::Main token={token.to_owned()} {logged_in}> + <settings::Settings {token} /> + </layouts::main::Main> + </layouts::logged_in::LoggedIn> + } + } Route::Login => html! { - <layouts::main::Main {logged_in}> + <layouts::main::Main {token} {logged_in}> <login::Login /> </layouts::main::Main> }, Route::NotFound => html! { - <layouts::main::Main {logged_in}> + <layouts::main::Main {token} {logged_in}> <not_found::NotFound /> </layouts::main::Main> }, diff --git a/frontend/src/routes/settings/mod.rs b/frontend/src/routes/settings/mod.rs new file mode 100644 index 0000000..94a7b86 --- /dev/null +++ b/frontend/src/routes/settings/mod.rs @@ -0,0 +1,31 @@ +use yew::prelude::*; +use yew_side_effect::title::Title; + +pub mod tokens; + +#[derive(Properties, PartialEq, Eq)] +pub struct SettingsProps { + pub token: Option<String>, +} + +pub struct Settings; + +impl Component for Settings { + type Message = (); + type Properties = SettingsProps; + + fn create(_ctx: &Context<Self>) -> Self { + Self + } + + fn view(&self, ctx: &Context<Self>) -> Html { + html! { + <> + <Title value="Settings" /> + <section> + <tokens::Tokens token={ctx.props().token.as_ref().unwrap().to_owned()} /> + </section> + </> + } + } +} diff --git a/frontend/src/routes/settings/tokens.rs b/frontend/src/routes/settings/tokens.rs new file mode 100644 index 0000000..f32d68a --- /dev/null +++ b/frontend/src/routes/settings/tokens.rs @@ -0,0 +1,130 @@ +use graphql_client::reqwest::post_graphql; +use yew::prelude::*; + +use crate::components; +use crate::graphql; + +pub enum Msg { + DoneFetching { + errors: Option<Vec<String>>, + tokens: Vec<graphql::queries::tokens::tokens::TokensTokens>, + }, + Revoke(usize), + RevokeDone { + errors: Option<Vec<String>>, + id: usize, + }, +} + +#[derive(Properties, PartialEq, Eq)] +pub struct TokensProps { + pub token: String, +} + +pub struct Tokens { + fetching: bool, + errors: Option<Vec<String>>, + tokens: Option<Vec<graphql::queries::tokens::tokens::TokensTokens>>, +} + +impl Component for Tokens { + type Message = Msg; + type Properties = TokensProps; + + fn create(ctx: &Context<Self>) -> Self { + let client = graphql::client(Some(&ctx.props().token)).unwrap(); + ctx.link().send_future(async move { + let response = post_graphql::<graphql::queries::tokens::Tokens, _>( + &client, + graphql::URL.as_str(), + graphql::queries::tokens::tokens::Variables, + ) + .await + .unwrap(); + + Msg::DoneFetching { + errors: None, + tokens: response.data.unwrap().tokens.unwrap(), + } + }); + + Self { + fetching: true, + errors: None, + tokens: None, + } + } + + fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool { + match msg { + Msg::DoneFetching { errors, tokens } => { + self.fetching = false; + self.errors = errors; + self.tokens = Some(tokens); + true + } + Msg::Revoke(id) => { + let client = graphql::client(Some(&ctx.props().token)).unwrap(); + let token = self.tokens.as_ref().unwrap()[id].id.to_owned(); + ctx.link().send_future(async move { + let response = + post_graphql::<graphql::mutations::revoke_token::RevokeToken, _>( + &client, + graphql::URL.as_str(), + graphql::mutations::revoke_token::revoke_token::Variables { token }, + ) + .await + .unwrap(); + + Msg::RevokeDone { + errors: components::graphql_errors::convert(response.errors), + id, + } + }); + + true + } + Msg::RevokeDone { errors, id } => { + self.errors = errors; + self.tokens.as_mut().unwrap().remove(id); + true + } + } + } + + fn view(&self, ctx: &Context<Self>) -> Html { + html! { + <fieldset> + <legend>{ "Tokens" }</legend> + if self.fetching { + <p>{ "Fetching..." }</p> + } else { + <table> + <tr> + <th>{ "ID" }</th> + <th>{ "Issued at" }</th> + <th>{ "Expires at" }</th> + <th>{ "Revoke" }</th> + </tr> + { for self.tokens.as_ref().unwrap().iter().enumerate().map(|(i, t)| { + html! { + <tr> + <td><code>{ &t.id }</code></td> + <td><code>{ &t.iat }</code></td> + <td><code>{ &t.exp }</code></td> + <td> + <button onclick={ctx.link().callback(move |_| Msg::Revoke(i))}> + { "Revoke" } + </button> + </td> + </tr> + } + }) } + </table> + + <components::graphql_errors::GraphQLErrors errors={self.errors.clone()} /> + } + </fieldset> + } + } +}