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

This commit is contained in:
Dominic Grimm 2022-11-21 19:48:53 +01:00
parent 55afb91ece
commit 100f7c8ad6
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
52 changed files with 727 additions and 313 deletions

View File

@ -39,31 +39,35 @@ RUN shards install --production
FROM crystallang/crystal:1.6-alpine as builder FROM crystallang/crystal:1.6-alpine as builder
WORKDIR /usr/src/mentorenwahl WORKDIR /usr/src/mentorenwahl
RUN apk add --no-cache pcre2-dev 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/shard.yml /usr/src/mentorenwahl/shard.lock ./
COPY --from=deps /usr/src/mentorenwahl/lib ./lib COPY --from=deps /usr/src/mentorenwahl/lib ./lib
COPY ./LICENSE .
COPY ./Makefile .
COPY ./db ./db
COPY ./src ./src
ARG BUILD_ENV ARG BUILD_ENV
COPY ./Makefile .
COPY ./LICENSE .
COPY ./db ./db
COPY --from=public /usr/src/public/dist ./public COPY --from=public /usr/src/public/dist ./public
COPY ./src ./src
RUN if [ "${BUILD_ENV}" = "development" ]; then \ RUN if [ "${BUILD_ENV}" = "development" ]; then \
make dev; \ make dev; \
else \ else \
make; \ make; \
fi fi
RUN mkdir deps
RUN if [ "${BUILD_ENV}" = "development" ]; then \ RUN if [ "${BUILD_ENV}" = "development" ]; then \
ldd bin/backend | tr -s '[:blank:]' '\n' | grep '^/' | \ ldd bin/backend | tr -s '[:blank:]' '\n' | grep '^/' | \
xargs -I % sh -c 'mkdir -p $(dirname deps%); cp % deps%;'; \ xargs -I % sh -c 'mkdir -p $(dirname deps%); cp % deps%;'; \
fi fi
FROM scratch as runner FROM scratch as runner
LABEL maintainer="Dominic Grimm <dominic@dergrimm.net>" \
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 / WORKDIR /
COPY --from=micrate-builder /usr/src/micrate/bin/micrate ./bin/micrate 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/deps /
COPY --from=builder /usr/src/mentorenwahl/bin/backend ./bin/backend COPY --from=builder /usr/src/mentorenwahl/bin/backend ./bin/backend
COPY --from=builder /usr/src/mentorenwahl/db ./db
EXPOSE 80 EXPOSE 80
ENTRYPOINT [ "./bin/backend" ] CMD [ "./bin/backend", "run" ]
CMD [ "run" ]

View File

@ -30,6 +30,7 @@ CREATE TABLE tokens(
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
iat timestamp NOT NULL, iat timestamp NOT NULL,
exp timestamp NOT NULL, exp timestamp NOT NULL,
active boolean NOT NULL,
user_id int NOT NULL REFERENCES users(id) user_id int NOT NULL REFERENCES users(id)
); );

View File

@ -18,6 +18,7 @@ require "graphql"
require "http/headers" require "http/headers"
require "jwt/errors" require "jwt/errors"
require "json" require "json"
require "uuid"
module Backend module Backend
module Api module Api
@ -41,13 +42,17 @@ module Backend
# User's external object # User's external object
getter external getter external
# JTI of request token
getter jti
def initialize( def initialize(
@development : Bool, @development : Bool,
@status : Status, @status : Status,
@user : Db::User?, @user : Db::User?,
@admin : Bool?, @admin : Bool?,
@role : Schema::UserRole?, @role : Schema::UserRole?,
@external : (Db::Teacher | Db::Student)? @external : (Db::Teacher | Db::Student)?,
@jti : UUID?
) )
end end
@ -63,15 +68,20 @@ module Backend
@status = Status::JWTError @status = Status::JWTError
else else
if @user = Db::User.find(payload.context.user) if @user = Db::User.find(payload.context.user)
@admin = user.not_nil!.admin @jti = payload.jti
@role = user.not_nil!.role.to_api if Db::Token.query.where { (id == payload.jti) & active }.first.nil?
@external = @status = Status::SessionExpired
case @role.not_nil! else
when .teacher? @admin = user.not_nil!.admin
@user.not_nil!.teacher @role = user.not_nil!.role.to_api
when .student? @external =
@user.not_nil!.student case @role.not_nil!
end when .teacher?
@user.not_nil!.teacher
when .student?
@user.not_nil!.student
end
end
end end
end end
end end

View File

@ -20,7 +20,7 @@ module Backend
# Schema helper macros # Schema helper macros
module Helpers module Helpers
# Defines DB model field helper functions # Defines DB model field helper functions
macro db_object(type) macro db_object(type, id_type)
def initialize(@model : {{ type }}) def initialize(@model : {{ type }})
end end
@ -32,7 +32,7 @@ module Backend
@[GraphQL::Field] @[GraphQL::Field]
# {{ space_name }}'s ID # {{ space_name }}'s ID
def id : Int32 def id : {{ id_type }}
@model.id @model.id
end end
end end

View File

@ -36,9 +36,9 @@ module Backend
id: UUID.random(Random::Secure), id: UUID.random(Random::Secure),
iat: Time.utc, iat: Time.utc,
exp: Time.utc + Backend.config.api.jwt_expiration.minutes, exp: Time.utc + Backend.config.api.jwt_expiration.minutes,
active: true,
user_id: user.id user_id: user.id
) )
pp! token, typeof(token.id)
LoginPayload.new( LoginPayload.new(
user: User.new(user), user: User.new(user),
@ -51,6 +51,29 @@ module Backend
) )
end 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] @[GraphQL::Field]
# Creates user # Creates user
def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User? def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User?

View File

@ -39,6 +39,16 @@ module Backend
User.new(context.user.not_nil!) User.new(context.user.not_nil!)
end 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] @[GraphQL::Field]
# User by ID # User by ID
def user(context : Context, id : Int32) : User? def user(context : Context, id : Int32) : User?

View File

@ -0,0 +1 @@
require "./scalars/*"

View File

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

View File

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

View File

@ -22,7 +22,7 @@ module Backend
class Student < GraphQL::BaseObject class Student < GraphQL::BaseObject
include Helpers include Helpers
db_object Db::Student db_object Db::Student, Int32
@[GraphQL::Field] @[GraphQL::Field]
# Student's user # Student's user

View File

@ -22,7 +22,7 @@ module Backend
class Teacher < GraphQL::BaseObject class Teacher < GraphQL::BaseObject
include Helpers include Helpers
db_object Db::Teacher db_object Db::Teacher, Int32
@[GraphQL::Field] @[GraphQL::Field]
# Teacher's user # Teacher's user

View File

@ -22,7 +22,7 @@ module Backend
class TeacherVote < GraphQL::BaseObject class TeacherVote < GraphQL::BaseObject
include Helpers include Helpers
db_object Db::TeacherVote db_object Db::TeacherVote, Int32
@[GraphQL::Field] @[GraphQL::Field]
# Voted teacher # Voted teacher

View File

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

View File

@ -44,7 +44,7 @@ module Backend
class User < GraphQL::BaseObject class User < GraphQL::BaseObject
include Helpers include Helpers
db_object Db::User db_object Db::User, Int32
@ldap : Ldap::User? @ldap : Ldap::User?

View File

@ -22,7 +22,7 @@ module Backend
class Vote < GraphQL::BaseObject class Vote < GraphQL::BaseObject
include Helpers include Helpers
db_object Db::Vote db_object Db::Vote, Int32
@[GraphQL::Field] @[GraphQL::Field]
# Student who voted # Student who voted

View File

@ -14,16 +14,14 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
module Backend module Backend::Db
module Db class Assignment
class Assignment include Clear::Model
include Clear::Model self.table = :assignments
self.table = :assignments
primary_key type: :serial primary_key type: :serial
belongs_to student : Student belongs_to student : Student
belongs_to teacher : Teacher belongs_to teacher : Teacher
end
end end
end end

View File

@ -1,14 +1,12 @@
module Backend module Backend::Db
module Db class MicrateDbVersion
class MicrateDbVersion include Clear::Model
include Clear::Model self.table = :micrate_db_version
self.table = :micrate_db_version
primary_key type: :serial primary_key type: :serial
column version_id : Int64 column version_id : Int64
column is_applied : Bool column is_applied : Bool
column tstamp : Time? column tstamp : Time?
end
end end
end end

View File

@ -1,15 +1,13 @@
module Backend module Backend::Db
module Db class Student
class Student include Clear::Model
include Clear::Model self.table = :students
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 vote : Vote?, foreign_key: :student_id
has_one assignment : Assignment?, foreign_key: :student_id has_one assignment : Assignment?, foreign_key: :student_id
end
end end
end end

View File

@ -1,17 +1,15 @@
module Backend module Backend::Db
module Db class Teacher
class Teacher include Clear::Model
include Clear::Model self.table = :teachers
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 teacher_votes : TeacherVote, foreign_key: :teacher_id
has_many assignments : Assignment, foreign_key: :teacher_id has_many assignments : Assignment, foreign_key: :teacher_id
end
end end
end end

View File

@ -1,15 +1,13 @@
module Backend module Backend::Db
module Db class TeacherVote
class TeacherVote include Clear::Model
include Clear::Model self.table = :teacher_votes
self.table = :teacher_votes
primary_key type: :serial primary_key type: :serial
belongs_to vote : Vote belongs_to vote : Vote
belongs_to teacher : Teacher belongs_to teacher : Teacher
column priority : Int32 column priority : Int32
end
end end
end end

View File

@ -7,6 +7,7 @@ module Backend::Db
column iat : Time column iat : Time
column exp : Time column exp : Time
column active : Bool
belongs_to user : User belongs_to user : User
end end

View File

@ -1,40 +1,38 @@
module Backend module Backend::Db
module Db Clear.enum UserRole, :teacher, :student
Clear.enum UserRole, :teacher, :student
struct UserRole struct UserRole
# API representation of the enum # API representation of the enum
def to_api : Api::Schema::UserRole def to_api : Api::Schema::UserRole
case self case self
when Student when Student
Api::Schema::UserRole::Student Api::Schema::UserRole::Student
when Teacher when Teacher
Api::Schema::UserRole::Teacher Api::Schema::UserRole::Teacher
else else
raise "Invalid enum value for UserRole" 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
end end
end end
class User # DB representation of the enum
include Clear::Model def self.from_api(role : Api::Schema::UserRole) : self
self.table = :users role.to_db
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
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 end

View File

@ -1,14 +1,12 @@
module Backend module Backend::Db
module Db class Vote
class Vote include Clear::Model
include Clear::Model self.table = :votes
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 has_many teacher_votes : TeacherVote, foreign_key: :vote_id
end
end end
end end

View File

@ -14,28 +14,27 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
require "random/secure"
module Backend module Backend
module Worker module Worker
module Jobs module Jobs
# Assigns students to teachers when all students voted # Assigns students to teachers when all students voted
class AssignStudentsJob < Mosquito::QueuedJob class AssignStudentsJob < Mosquito::QueuedJob
def rescheduleable? # run_every 1.minute
def rescheduleable? : Bool
false false
end end
alias TeacherVote = {student: Int32, priority: Int32}
alias Assignment = {teacher: Int32, priority: Int32}
# :ditto: # :ditto:
def perform : Nil def perform : Nil
user_count = Db::User.query.count
teacher_count = Db::Teacher.query.count teacher_count = Db::Teacher.query.count
student_count = Db::Student.query.count student_count = Db::Student.query.count
vote_count = Db::Vote.query.count vote_count = Db::Vote.query.count
if user_count == 0 if teacher_count == 0
log "No users found, skipping assignment"
fail
elsif teacher_count == 0
log "No teachers found, skipping assignment" log "No teachers found, skipping assignment"
fail fail
elsif student_count == 0 elsif student_count == 0
@ -47,67 +46,123 @@ module Backend
elsif vote_count < student_count elsif vote_count < student_count
log "Not all students voted, skipping assignment" log "Not all students voted, skipping assignment"
fail fail
elsif Db::Assignment.query.count > 0
log "Assignment has already run, skipping another assignment"
fail
end end
# possibilities = [] of Possibility teachers = Db::Teacher.query
# 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
.where do .where do
raw("NOT EXISTS (SELECT 1 FROM assignments WHERE student_id = students.id)") & raw("EXISTS (SELECT 1 FROM teacher_votes WHERE teacher_id = teachers.id)") &
raw("EXISTS (SELECT 1 FROM votes WHERE student_id = students.id)") (max_students > 0)
end end
.with_vote(&.with_teacher_votes(&.order_by(:priority, :asc))) .with_teacher_votes
# student_ids = students.map(&.id) .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, student: tv.vote.student.id,
# students.map do |s| priority: tv.priority,
# s.vote.not_nil!.teacher_votes.map(&.teacher_id) }
# end, end
# ) end
# pp! votes, student_ids )
teachers.sort_by! { |t| vote_index[t.id] }
random_votes = students.map do |s| students = Db::Student.query
{ .with_vote(&.with_teacher_votes(&.order_by(priority: :asc)))
student: s.id, .to_a
teachers: s.vote.not_nil!.teacher_votes.map(&.teacher_id), 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 end
assignments = [] of {assignment: Hash(Int32, Array(Int32)), weighting: Int32} pp! best_assignment
# 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 str = String.build do |str|
# a = empty_assignment.clone 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 end
pp! assignments print str
end end
end end
end end

View File

@ -15,9 +15,13 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
events { events {
worker_connections 1024;
} }
http { http {
server_tokens off;
more_clear_headers Server;
server { server {
location / { location / {
proxy_pass http://frontend/; proxy_pass http://frontend/;

View File

@ -18,7 +18,7 @@ version: "3"
services: services:
nginx: nginx:
image: nginx:alpine image: byjg/nginx-extras
restart: always restart: always
volumes: volumes:
- ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
@ -58,7 +58,7 @@ services:
- redis:/data - redis:/data
backend: backend:
image: mentorenwahl/backend image: git.dergrimm.net/mentorenwahl/backend:latest
build: build:
context: ./backend context: ./backend
args: args:
@ -96,7 +96,7 @@ services:
BACKEND_LDAP_CACHE_REFRESH_INTERVAL: ${BACKEND_LDAP_CACHE_REFRESH_INTERVAL} BACKEND_LDAP_CACHE_REFRESH_INTERVAL: ${BACKEND_LDAP_CACHE_REFRESH_INTERVAL}
frontend: frontend:
image: mentorenwahl/frontend image: git.dergrimm.net/mentorenwahl/frontend:latest
build: build:
context: ./frontend context: ./frontend
restart: always restart: always

View File

@ -36,6 +36,11 @@ WORKDIR /usr/src/public
COPY --from=public /usr/src/public . COPY --from=public /usr/src/public .
RUN find . -name "*.wasm" -type f | xargs -I % wasm-opt % -o % -O --intrinsic-lowering -Oz 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 <dominic@dergrimm.net>" \
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 ./nginx.conf /etc/nginx/nginx.conf
COPY --from=binaryen /usr/src/public /var/www/html COPY --from=binaryen /usr/src/public /var/www/html

View File

@ -0,0 +1,3 @@
mutation Logout {
logout
}

View File

@ -0,0 +1,3 @@
mutation RevokeToken($token: UUID!) {
revokeToken(token: $token)
}

View File

@ -0,0 +1,7 @@
query Tokens {
tokens {
id
iat
exp
}
}

View File

@ -15,6 +15,7 @@ type Query {
teacherVote(id: Int!): TeacherVote teacherVote(id: Int!): TeacherVote
teacherVotes: [TeacherVote!] teacherVotes: [TeacherVote!]
teachers: [Teacher!]! teachers: [Teacher!]!
tokens: [Token!]
user(id: Int!): User user(id: Int!): User
userByUsername(username: String!): User userByUsername(username: String!): User
users: [User!] users: [User!]
@ -42,6 +43,17 @@ type TeacherVote {
vote: Vote! vote: Vote!
} }
scalar Time
type Token {
exp: Time!
expired: Boolean!
iat: Time!
id: UUID!
}
scalar UUID
type User { type User {
admin: Boolean! admin: Boolean!
email: String! email: String!
@ -79,7 +91,9 @@ type Mutation {
createVote(input: VoteCreateInput!): Vote createVote(input: VoteCreateInput!): Vote
deleteUser(id: Int!): Int deleteUser(id: Int!): Int
login(password: String!, username: String!): LoginPayload login(password: String!, username: String!): LoginPayload
logout: UUID
registerTeacher(input: TeacherInput!): Teacher registerTeacher(input: TeacherInput!): Teacher
revokeToken(token: UUID!): UUID!
} }
input TeacherInput { input TeacherInput {

View File

@ -1,29 +1,10 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events { events {
worker_connections 1024; worker_connections 1024;
} }
http { http {
include /etc/nginx/mime.types; server_tokens off;
default_type application/octet-stream; more_clear_headers Server;
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 { server {
listen 80; listen 80;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/login.graphql", query_path = "graphql/mutations/login.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct Login; pub struct Login;

View File

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

View File

@ -1,3 +1,5 @@
pub mod login; pub mod login;
pub mod logout;
pub mod register_teacher; pub mod register_teacher;
pub mod revoke_token;
pub mod vote; pub mod vote;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/register_teacher.graphql", query_path = "graphql/mutations/register_teacher.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct RegisterTeacher; pub struct RegisterTeacher;

View File

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

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/mutations/vote.graphql", query_path = "graphql/mutations/vote.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct Vote; pub struct Vote;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/config.graphql", query_path = "graphql/queries/config.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct Config; pub struct Config;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/me.graphql", query_path = "graphql/queries/me.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct Me; pub struct Me;

View File

@ -3,3 +3,4 @@ pub mod me;
pub mod ok; pub mod ok;
pub mod students_can_vote; pub mod students_can_vote;
pub mod teachers; pub mod teachers;
pub mod tokens;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/ok.graphql", query_path = "graphql/queries/ok.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct Ok; pub struct Ok;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/students_can_vote.graphql", query_path = "graphql/queries/students_can_vote.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct StudentsCanVote; pub struct StudentsCanVote;

View File

@ -4,6 +4,7 @@ use graphql_client::GraphQLQuery;
#[graphql( #[graphql(
schema_path = "graphql/schema.graphql", schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/teachers.graphql", query_path = "graphql/queries/teachers.graphql",
response_derives = "Debug" response_derives = "Debug",
skip_serializing_none
)] )]
pub struct Teachers; pub struct Teachers;

View File

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

View File

@ -1,17 +1,21 @@
use graphql_client::reqwest::post_graphql;
use yew::prelude::*; use yew::prelude::*;
use yew_agent::{Dispatched, Dispatcher}; use yew_agent::{Dispatched, Dispatcher};
use yew_router::prelude::*; use yew_router::prelude::*;
use crate::agents; use crate::agents;
use crate::components; use crate::components;
use crate::graphql;
use crate::routes; use crate::routes;
pub enum Msg { pub enum Msg {
LogOut, LogOutClicked,
LogOut(Option<Vec<String>>),
} }
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct MainProps { pub struct MainProps {
pub token: Option<String>,
pub logged_in: bool, pub logged_in: bool,
#[prop_or_default] #[prop_or_default]
pub children: Children, pub children: Children,
@ -20,6 +24,7 @@ pub struct MainProps {
pub struct Main { pub struct Main {
logged_in: bool, logged_in: bool,
logged_in_event_bus: Dispatcher<agents::logged_in::EventBus>, logged_in_event_bus: Dispatcher<agents::logged_in::EventBus>,
errors: Option<Vec<String>>,
} }
impl Component for Main { impl Component for Main {
@ -30,50 +35,75 @@ impl Component for Main {
Self { Self {
logged_in: ctx.props().logged_in, logged_in: ctx.props().logged_in,
logged_in_event_bus: agents::logged_in::EventBus::dispatcher(), logged_in_event_bus: agents::logged_in::EventBus::dispatcher(),
errors: None,
} }
} }
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg { match msg {
Msg::LogOut => { Msg::LogOutClicked => {
self.logged_in_event_bus let client = graphql::client(ctx.props().token.as_ref()).unwrap();
.send(agents::logged_in::Request::LoggedIn(false)); ctx.link().send_future(async move {
let response = post_graphql::<graphql::mutations::logout::Logout, _>(
&client,
graphql::URL.as_str(),
graphql::mutations::logout::logout::Variables,
)
.await
.unwrap();
Msg::LogOut(components::graphql_errors::convert(response.errors))
});
false 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<Self>) -> Html { fn view(&self, ctx: &Context<Self>) -> Html {
let logout_onclick = ctx.link().callback(move |_| Msg::LogOut);
html! { html! {
<> <>
<nav> <components::logged_in_handler::LoggedInHandler logged_in={self.logged_in} />
<ul> <nav>
<li> <ul>
<Link<routes::Route> to={routes::Route::Home}> <li>
<button>{ "Home" }</button> <Link<routes::Route> to={routes::Route::Home}>
</Link<routes::Route>> <button>{ "Home" }</button>
</li> </Link<routes::Route>>
<li> </li>
if self.logged_in { <li>
<button onclick={logout_onclick}>{ "Logout" }</button> <Link<routes::Route> to={routes::Route::Settings}>
} else { <button>{ "Settings" }</button>
<Link<routes::Route> to={routes::Route::Login}> </Link<routes::Route>>
<button>{ "Login" }</button> </li>
</Link<routes::Route>> <li>
} if self.logged_in {
</li> <button onclick={ctx.link().callback(move |_| Msg::LogOutClicked)}>{ "Logout" }</button>
</ul> } else {
</nav> <Link<routes::Route> to={routes::Route::Login}>
<button>{ "Login" }</button>
</Link<routes::Route>>
}
</li>
</ul>
<hr /> <components::graphql_errors::GraphQLErrors errors={self.errors.clone()} />
</nav>
<components::logged_in_handler::LoggedInHandler logged_in={self.logged_in} /> <hr />
<main> <main>
{ for ctx.props().children.iter() } { for ctx.props().children.iter() }
</main> </main>
</> </>
} }
} }
} }

View File

@ -22,13 +22,8 @@ pub struct HomeProps {
pub token: Option<String>, pub token: Option<String>,
} }
enum State {
Fetching,
Done,
}
pub struct Home { pub struct Home {
state: State, fetching: bool,
errors: Option<Vec<String>>, errors: Option<Vec<String>>,
me: Option<graphql::queries::me::me::MeMe>, me: Option<graphql::queries::me::me::MeMe>,
} }
@ -37,9 +32,25 @@ impl Component for Home {
type Message = Msg; type Message = Msg;
type Properties = HomeProps; type Properties = HomeProps;
fn create(_ctx: &Context<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
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(),
}
});
Self { Self {
state: State::Fetching, fetching: true,
errors: None, errors: None,
me: None, me: None,
} }
@ -48,7 +59,7 @@ impl Component for Home {
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool { fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg { match msg {
Msg::DoneFetching { errors, me } => { Msg::DoneFetching { errors, me } => {
self.state = State::Done; self.fetching = false;
self.errors = errors; self.errors = errors;
self.me = Some(me); self.me = Some(me);
true true
@ -60,62 +71,39 @@ impl Component for Home {
html! { html! {
<> <>
<Title value="Home" /> <Title value="Home" />
{ if self.fetching {
match self.state { <p>{ "Fetching..." }</p>
State::Fetching => { } else {
let client = graphql::client(ctx.props().token.as_ref()).unwrap(); if let Some(errors) = &self.errors {
ctx.link().send_future(async move { <components::graphql_errors::GraphQLErrors errors={errors.clone()} />
let response = post_graphql::<graphql::queries::me::Me, _>( } else {
&client, {{
graphql::URL.as_str(), let me = self.me.as_ref().unwrap();
graphql::queries::me::me::Variables,
)
.await
.unwrap();
Msg::DoneFetching {
errors: components::graphql_errors::convert(response.errors),
me: response.data.unwrap().me.unwrap(),
}
});
html! { html! {
<p>{ "Fetching..." }</p> <>
} <h1>{ format!("Hey, {}!", me.first_name) }</h1>
} <hr />
State::Done => { {
if let Some(errors) = &self.errors { match me.role {
html! { graphql::queries::me::me::UserRole::Student => html! {
<components::graphql_errors::GraphQLErrors errors={errors.clone()} /> <student_home::StudentHome
} token={ctx.props().token.as_ref().unwrap().to_owned()}
} else { voted={me.student.as_ref().unwrap().vote.is_some()}
let me = self.me.as_ref().unwrap(); />
},
html! { graphql::queries::me::me::UserRole::Teacher => html! {
<> <teacher_home::TeacherHome
<h1>{ format!("Hey, {}!", me.first_name) }</h1> token={ctx.props().token.as_ref().unwrap().to_owned()}
<hr /> registered={me.teacher.is_some()}
{ />
match me.role { },
graphql::queries::me::me::UserRole::Student => html! { graphql::queries::me::me::UserRole::Other(_) => 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! {}
}
} }
</> }
} </>
} }
} }}
} }
} }
</> </>

View File

@ -231,10 +231,8 @@ impl Component for StudentVote {
<form {onsubmit}> <form {onsubmit}>
{ for (0..self.slots).map(|i| { { for (0..self.slots).map(|i| {
let curr_t = self.votes.get(&i); let curr_t = self.votes.get(&i);
if let Some(te) = curr_t { if let Some(Some(t)) = curr_t {
if let Some(t) = te { teachers.push(*t);
teachers.push(*t);
}
} }
let name = format!("mentors_{}", i); let name = format!("mentors_{}", i);

View File

@ -87,7 +87,7 @@ impl Component for TeacherRegistration {
<label for="max_students"> <label for="max_students">
{ "Maximale Anzahl von Schülern:" } { "Maximale Anzahl von Schülern:" }
<br /> <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> </label>
</div> </div>

View File

@ -7,11 +7,14 @@ use crate::layouts;
pub mod home; pub mod home;
pub mod login; pub mod login;
pub mod not_found; pub mod not_found;
pub mod settings;
#[derive(Clone, Routable, PartialEq, Eq)] #[derive(Clone, Routable, PartialEq, Eq)]
pub enum Route { pub enum Route {
#[at("/")] #[at("/")]
Home, Home,
#[at("/settings")]
Settings,
#[at("/login")] #[at("/login")]
Login, Login,
#[not_found] #[not_found]
@ -39,19 +42,28 @@ pub fn switch(routes: &Route) -> Html {
Route::Home => { Route::Home => {
html! { html! {
<layouts::logged_in::LoggedIn {logged_in}> <layouts::logged_in::LoggedIn {logged_in}>
<layouts::main::Main {logged_in}> <layouts::main::Main token={token.to_owned()} {logged_in}>
<home::Home {token} /> <home::Home {token} />
</layouts::main::Main> </layouts::main::Main>
</layouts::logged_in::LoggedIn> </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! { Route::Login => html! {
<layouts::main::Main {logged_in}> <layouts::main::Main {token} {logged_in}>
<login::Login /> <login::Login />
</layouts::main::Main> </layouts::main::Main>
}, },
Route::NotFound => html! { Route::NotFound => html! {
<layouts::main::Main {logged_in}> <layouts::main::Main {token} {logged_in}>
<not_found::NotFound /> <not_found::NotFound />
</layouts::main::Main> </layouts::main::Main>
}, },

View File

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

View File

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