Added worker
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Dominic Grimm 2022-01-23 09:12:57 +01:00
parent 41b27fcfd7
commit 5bc10f8aaf
65 changed files with 1147 additions and 1029 deletions

View file

@ -10,7 +10,7 @@ http {
# }
location /graphql {
proxy_pass http://api;
proxy_pass http://backend;
}
location /adminer {

View file

@ -1,18 +1,22 @@
services:
nginx:
container_name: nginx
image: nginx:alpine
container_name: nginx
restart: always
volumes:
- ./config/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- 80:80
depends_on:
- api
- adminer
- backend
db:
image: postgres:alpine
container_name: db
env_file: .env
restart: always
networks:
- db
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
@ -22,20 +26,45 @@ services:
adminer:
image: adminer:standalone
container_name: adminer
restart: always
networks:
- default
- db
depends_on:
- db
api:
redis:
image: redis:alpine
container_name: redis
restart: always
networks:
- redis
volumes:
- redis:/data
backend:
build:
context: ./docker/api
context: ./docker/backend
args:
BUILD_ENV: production
container_name: api
container_name: backend
restart: always
networks:
- default
- db
- redis
environment:
API_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER}
API_JWT_SECRET: ${API_JWT_SECRET}
BACKEND_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_USER}
BACKEND_JWT_SECRET: ${API_JWT_SECRET}
BACKEND_WORKER_REDIS_URL: redis://redis:6379
depends_on:
- db
- redis
networks:
db:
redis:
volumes:
db: null
db:
redis:

View file

@ -1,43 +0,0 @@
require "crystal-argon2"
require "jwt"
module API
module Auth
extend self
BEARER = "Bearer "
def hash_password(password : String) : String
Argon2::Password.create(password)
end
def verify_password?(password : String, hash : String) : Bool
!!Argon2::Password.verify_password(password, hash)
rescue
false
end
private def create_jwt(data, expiration : Int) : String
payload = {
"data" => data.to_h,
"exp" => expiration,
}
JWT.encode(payload.to_h, ENV_REQUESTER["API_JWT_SECRET"], JWT::Algorithm::HS256)
end
def create_user_jwt(user_id : Int, expiration : Int = (Time.utc + Time::Span.new(hours: 6)).to_unix) : String
create_jwt({user_id: user_id}, expiration)
end
def decode_jwt(jwt : String) : JSON::Any
JWT.decode(jwt, ENV_REQUESTER["API_JWT_SECRET"], JWT::Algorithm::HS256)[0]
end
def decode_jwt?(jwt : String) : JSON::Any?
decode_jwt(jwt)
rescue
nil
end
end
end

View file

@ -1,81 +0,0 @@
require "commander"
require "fancyline"
require "./db"
module API
module CLI
extend self
private FANCY = Fancyline.new
FANCY.actions.set Fancyline::Key::Control::CtrlC do
exit
end
private def input(prompt : String) : String
x = FANCY.readline(prompt)
if x
x.chomp.strip
else
""
end
end
cli = Commander::Command.new do |cmd|
cmd.use = "api"
cmd.long = "Mentorenwahl API CLI"
cmd.run do
API.run
end
cmd.commands.add do |c|
c.use = "seed"
c.long = "Seeds the database with required data"
c.run do
puts "Seeding database with admin user..."
data = {
"firstname" => input("Firstname: "),
"lastname" => input("Lastname: "),
"email" => input("Email: "),
"password" => Auth.hash_password(input("Password: ")),
"role" => Db::UserRole::Admin.to_s,
}
password_confirmation = input("Password confirmation: ")
if data.values.any?(&.empty?)
abort "Values can't be empty!"
elsif !Auth.verify_password?(password_confirmation, data["password"])
abort "Passwords do not match!"
end
puts "---"
data.each { |k, v| puts "#{k.capitalize}: #{v}" }
puts "---"
unless input("Are you sure? (y/N) ").downcase == "y"
abort "Aborted!"
end
puts "Seeding database with admin user..."
user = Db::User.create!(data)
admin = Db::Admin.create!(user_id: user.id)
puts "Done!"
puts "---"
puts "User id: #{user.id}"
puts "Admin id: #{admin.id}"
puts "Token: #{Auth.create_user_jwt(user_id: user.id.not_nil!)}"
puts "---"
end
end
end
Commander.run(cli, ARGV)
end
end

View file

@ -1,104 +0,0 @@
require "http/request"
require "graphql"
require "granite"
module API
class Context < GraphQL::Context
getter user : Db::User?
getter role : Schema::UserRole?
getter external : (Db::Admin | Db::Teacher | Db::Student)?
def initialize(request : HTTP::Request, *rest)
super(*rest)
token = request.headers["Authorization"]?
if token && token[..Auth::BEARER.size - 1] == Auth::BEARER
payload = Auth.decode_jwt?(token[Auth::BEARER.size..])
return unless payload
data = payload["data"].as_h
@user = Db::User.find(data["user_id"].as_i)
return if @user.nil? || @user.not_nil!.blocked
if @user
@role = Schema::UserRole.parse?(@user.as(Db::User).role).not_nil!
if @role
@external =
case Schema::UserRole.parse?(@user.not_nil!.role)
when Schema::UserRole::Admin
@user.not_nil!.admin
when Schema::UserRole::Teacher
@user.not_nil!.teacher
when Schema::UserRole::Student
@user.not_nil!.student
end
end
end
end
end
def authenticated? : Bool
!@user.nil?
end
def authenticated! : Bool
raise "Not authenticated" unless authenticated?
true
end
def role?(external = true, *roles : Schema::UserRole) : Bool
return false unless authenticated?
roles.each do |role|
return true if @role == role && if external
role == case @external
when Db::Admin
Schema::UserRole::Admin
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
Schema::UserRole::Student
end
else
true
end
end
false
end
def role!(external = true, *roles : Schema::UserRole) : Bool
raise "Invalid permissions" unless role? external, *roles
true
end
private macro role_check(*roles)
{% for role in roles %}
{% name = role.names.last.underscore %}
def {{ name }}?(external = true) : Bool
role? external, {{ role }}
end
def {{ name }}!(external = true) : Bool
role! external, {{ role }}
end
{% end %}
end
role_check Schema::UserRole::Admin, Schema::UserRole::Teacher, Schema::UserRole::Student
def self.db_eq_role?(external : Granite::Base, role : Schema::UserRole) : Bool
role == case external
when Db::Admin
Schema::UserRole::Admin
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
Schema::UserRole::Student
end
end
end
end

View file

@ -1,43 +0,0 @@
module API
class EnvRequester
private property keys
def initialize(@keys = {} of String => String?)
end
def initialize(keys : Array(String))
@keys = {} of String => String?
keys.each { |k| self.<< k }
end
def <<(key : String) : self
@keys[key] = ENV[key]?
self
end
def []?(key : String) : String?
if @keys.has_key?(key)
@keys[key]?
end
end
def [](key : String) : String
if @keys.has_key?(key)
val = @keys[key]?
raise "ENV[#{key}] is nil" unless val
val
else
raise "No such key: #{key}"
end
end
end
ENV_REQUESTER = EnvRequester.new([
"API_DATABASE_URL",
"API_ADMIN_EMAIL",
"API_ADMIN_PASSWORD",
"API_JWT_SECRET",
])
end

View file

@ -1,13 +0,0 @@
require "http/server"
module API
extend self
def run : Nil
Server.run(80, [HTTP::LogHandler.new, HTTP::ErrorHandler.new]) do |server|
address = server.bind_tcp("0.0.0.0", 80, true)
puts "Listening on http://#{address}"
server.listen
end
end
end

View file

@ -1,10 +0,0 @@
require "graphql"
require "./schema/helpers"
require "./schema/*"
module API
module Schema
SCHEMA = GraphQL::Schema.new(Query.new, Mutation.new)
end
end

View file

@ -1,26 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class Admin < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Admin
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
end
@[GraphQL::InputObject]
class AdminCreateInput < GraphQL::BaseInputObject
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32)
end
end
end
end

View file

@ -1,65 +0,0 @@
require "graphql"
module API
module Schema
module Helpers
module ObjectMacros
macro field(type)
property {{ type.var }} {% if type.value %} = {{ type.value }}{% end %}
@[GraphQL::Field]
def {{ type.var }} : {{ type.type }}
@{{ type.var }}
end
end
end
module ObjectDbInit
macro db_init(type)
def initialize(obj : {{ type }})
initialize(obj.id.not_nil!)
end
end
end
module ObjectFinders
macro finders(type)
def find : {{ type }}?
{{ type }}.find(@id)
end
def find! : {{ type }}
obj = find
raise "#{{{ type }}} not found" unless obj
obj
end
end
end
module DbObject
macro db_object(type)
include ::API::Schema::Helpers::ObjectDbInit
include ::API::Schema::Helpers::ObjectFinders
db_init {{ type }}
finders {{ type }}
property id
def initialize(@id : Int32)
end
def initialize(obj : {{ type }})
@id = obj.id.not_nil!.to_i
end
@[GraphQL::Field]
def id : Int32
@id
end
end
end
end
end
end

View file

@ -1,162 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class Mutation < GraphQL::BaseMutation
@[GraphQL::Field]
def login(input : LoginInput) : LoginPayload
user = Db::User.find_by(email: input.email)
raise "Auth failed" unless user && Auth.verify_password?(input.password, user.password)
LoginPayload.new(
user: User.new(user),
token: Auth.create_user_jwt(user.id.not_nil!.to_i),
)
end
@[GraphQL::Field]
def update_password(context : Context, password : String) : LoginPayload
context.authenticated!
if Auth.verify_password?(password, context.user.not_nil!.password)
raise "New password must be different from old password"
end
context.user.not_nil!.update!(password: Auth.hash_password(password))
LoginPayload.new(
user: User.new(context.user.not_nil!),
token: Auth.create_user_jwt(context.user.not_nil!.id.not_nil!.to_i),
)
end
@[GraphQL::Field]
def create_user(context : Context, input : UserCreateInput) : User
context.admin!
user = Db::User.create!(
firstname: input.firstname,
lastname: input.lastname,
email: input.email,
password: Auth.hash_password(input.password),
role: input.role.to_s,
blocked: input.blocked,
)
if input.create_external && input.role
case input.role
when UserRole::Teacher
user.teacher = Db::Teacher.create!(user_id: user.id, max_students: input.teacher.not_nil!.max_students)
when UserRole::Student
user.student = Db::Student.create!(user_id: user.id, skif: input.student.not_nil!.skif)
end
user.save!
end
User.new(user)
end
@[GraphQL::Field]
def delete_user(context : Context, id : Int32) : Int32
context.admin!
user = Db::User.find!(id)
user.destroy!
id
end
@[GraphQL::Field]
def create_admin(context : Context, input : AdminCreateInput) : Admin
context.admin!
admin = Db::Admin.create!(user_id: input.user_id)
Admin.new(admin)
end
@[GraphQL::Field]
def delete_admin(context : Context, id : Int32) : Int32
context.admin!
admin = Db::Admin.find!(id)
admin.destroy!
id
end
@[GraphQL::Field]
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
@[GraphQL::Field]
def delete_teacher(context : Context, id : Int32) : Int32
context.admin!
teacher = Db::Teacher.find!(id)
teacher.destroy!
id
end
@[GraphQL::Field]
def register_teacher(context : Context, input : TeacherInput) : Teacher
context.teacher? external: false
Teacher.new(
Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students, skif: input.skif)
)
end
@[GraphQL::Field]
def create_student(context : Context, input : StudentCreateInput) : Student
context.admin!
user = Db::User.find!(input.user_id)
raise "User not a student" unless UserRole.parse(user.role) == UserRole::Student
student = Db::Student.create!(user_id: user.id)
Student.new(student)
end
@[GraphQL::Field]
def delete_student(context : Context, id : Int32) : Int32
context.admin!
student = Db::Student.find!(id)
student.destroy!
id
end
@[GraphQL::Field]
def create_vote(context : Context, input : VoteCreateInput) : Vote
context.student!
skif = context.external.as(Db::Student).skif
input.teacher_ids.each do |id|
teacher = Db::Teacher.find(id)
if teacher.nil?
raise "Teachers not found"
elsif teacher.skif != skif
if teacher.skif
raise "Teacher is SKIF, student is not"
else
raise "Teacher is not SKIF, student is"
end
end
end
student = context.external.not_nil!.as(Db::Student)
vote = Db::Vote.create!(student_id: student.id)
Db::TeacherVote.import(input.teacher_ids.map_with_index { |id, i| Db::TeacherVote.new(vote_id: vote.id, teacher_id: id.to_i64, priority: i) })
Vote.new(vote)
end
end
end
end

View file

@ -1,86 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class Query < GraphQL::BaseQuery
@[GraphQL::Field]
def ok : Bool
true
end
@[GraphQL::Field]
def me(context : Context) : User
context.authenticated!
User.new(context.user.not_nil!)
end
@[GraphQL::Field]
def user(context : Context, id : Int32) : User
context.admin!
User.new(id)
end
@[GraphQL::Field]
def users(context : Context) : Array(User)
context.admin!
Db::User.all.map { |user| User.new(user) }
end
@[GraphQL::Field]
def admin(context : Context, id : Int32) : Admin
context.admin!
Admin.new(Db::Admin.find!(id))
end
@[GraphQL::Field]
def admins(context : Context) : Array(Admin)
context.admin!
Db::Admin.all.map { |admin| Admin.new(admin) }
end
@[GraphQL::Field]
def teacher(id : Int32) : Teacher
Teacher.new(Db::Teacher.find!(id))
end
@[GraphQL::Field]
def teachers : Array(Teacher)
Db::Teacher.all.map { |teacher| Teacher.new(teacher) }
end
@[GraphQL::Field]
def student(context : Context, id : Int32) : Student
context.admin!
Student.new(Db::Student.find!(id))
end
@[GraphQL::Field]
def students(context : Context) : Array(Student)
context.admin!
Db::Student.all.map { |student| Student.new(student) }
end
@[GraphQL::Field]
def vote(context : Context, id : Int32) : Vote
context.admin!
Vote.new(Db::Vote.find!(id))
end
@[GraphQL::Field]
def votes(context : Context) : Array(Vote)
context.admin!
Db::Vote.all.map { |vote| Vote.new(vote) }
end
end
end
end

View file

@ -1,48 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class Student < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Student
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
@[GraphQL::Field]
def skif : Bool
find!.skif
end
@[GraphQL::Field]
def vote : Vote?
vote = find!.vote
Vote.new(vote) if vote
end
end
@[GraphQL::InputObject]
class StudentInput < GraphQL::BaseInputObject
getter skif
@[GraphQL::Field]
def initialize(@skif : Bool)
end
end
@[GraphQL::InputObject]
class StudentCreateInput < StudentInput
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32, skif : Bool)
super(skif)
end
end
end
end

View file

@ -1,47 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class Teacher < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Teacher
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
@[GraphQL::Field]
def max_students : Int32
find!.max_students
end
@[GraphQL::Field]
def skif : Bool
find!.skif
end
end
@[GraphQL::InputObject]
class TeacherInput < GraphQL::BaseInputObject
getter max_students
getter skif
@[GraphQL::Field]
def initialize(@max_students : Int32, @skif : Bool)
end
end
@[GraphQL::InputObject]
class TeacherCreateInput < TeacherInput
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32, max_students : Int32, skif : Bool)
super(max_students, skif)
end
end
end
end

View file

@ -1,33 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class TeacherVote < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::TeacherVote
@[GraphQL::Field]
def teacher : Teacher
Teacher.new(find!.teacher.not_nil!)
end
@[GraphQL::Field]
def priority : Int32
find!.priority
end
end
@[GraphQL::InputObject]
class TeacherVoteCreateInput < GraphQL::BaseInputObject
getter vote_id
getter teacher_id
getter priority
@[GraphQL::Field]
def initialize(@vote_id : Int32, @teacher_id : Int32, @priority : Int32)
end
end
end
end

View file

@ -1,152 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class User < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::User
@[GraphQL::Field]
def firstname : String
find!.firstname
end
@[GraphQL::Field]
def lastname : String
find!.lastname
end
@[GraphQL::Field]
def email : String
find!.email
end
@[GraphQL::Field]
def role : UserRole
role = Db::UserRole.parse(find!.role)
case role
when Db::UserRole::Admin
UserRole::Admin
when Db::UserRole::Teacher
UserRole::Teacher
when Db::UserRole::Student
UserRole::Student
else
raise "Unknown role: #{role}"
end
end
@[GraphQL::Field]
def external_id : Int32?
case Db::UserRole.parse(find!.role)
when Db::UserRole::Admin
find!.admin
when Db::UserRole::Teacher
find!.teacher
when Db::UserRole::Student
find!.student
end.not_nil!.id.not_nil!.to_i
rescue NilAssertionError
nil
end
@[GraphQL::Field]
def admin : Admin?
admin = find!.admin
if admin
Admin.new(admin)
end
end
@[GraphQL::Field]
def teacher : Teacher?
teacher = find!.teacher
if teacher
Teacher.new(teacher)
end
end
@[GraphQL::Field]
def student : Student?
student = find!.student
if student
Student.new(student)
end
end
@[GraphQL::Field]
def blocked : Bool
find!.blocked
end
end
@[GraphQL::InputObject]
class UserCreateInput < GraphQL::BaseInputObject
getter firstname
getter lastname
getter email
getter password
getter role
getter teacher
getter student
getter create_external
getter blocked
@[GraphQL::Field]
def initialize(
@firstname : String,
@lastname : String,
@email : String,
@password : String,
@role : UserRole,
@teacher : TeacherInput? = nil,
@student : StudentInput? = nil,
@create_external : Bool = false,
@blocked : Bool = false
)
end
end
@[GraphQL::InputObject]
class LoginInput < GraphQL::BaseInputObject
getter email
getter password
@[GraphQL::Field]
def initialize(
@email : String,
@password : String
)
end
end
@[GraphQL::Object]
class LoginPayload < GraphQL::BaseObject
property user
property token
def initialize(
@user : User,
@token : String
)
end
@[GraphQL::Field]
def user : User
@user
end
@[GraphQL::Field]
def token : String
@token
end
@[GraphQL::Field]
def bearer : String
Auth::BEARER + @token
end
end
end
end

View file

@ -1,12 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Enum]
enum UserRole
Admin
Teacher
Student
end
end
end

View file

@ -1,31 +0,0 @@
require "graphql"
module API
module Schema
@[GraphQL::Object]
class Vote < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Vote
@[GraphQL::Field]
def student : Student
Student.new(find!.student)
end
@[GraphQL::Field]
def teacher_votes : Array(TeacherVote)
find!.teacher_votes.map { |tv| TeacherVote.new(tv) }
end
end
@[GraphQL::InputObject]
class VoteCreateInput < GraphQL::BaseInputObject
getter teacher_ids
@[GraphQL::Field]
def initialize(@teacher_ids : Array(Int32))
end
end
end
end

View file

@ -1,31 +0,0 @@
require "toro"
require "json"
module API
class Server < Toro::Router
private struct GraphQLData
include JSON::Serializable
property query : String
property variables : Hash(String, JSON::Any)?
property operation_name : String?
end
def routes
on "graphql" do
post do
content_type "application/json"
data = GraphQLData.from_json(context.request.body.not_nil!.gets.not_nil!)
write Schema::SCHEMA.execute(
data.query,
data.variables,
data.operation_name,
Context.new(context.request)
)
end
end
end
end
end

View file

@ -1,5 +0,0 @@
require "micrate"
require "pg"
Micrate::DB.connection_url = ENV["API_DATABASE_URL"]?
Micrate::Cli.run

View file

@ -17,13 +17,14 @@ RUN if [ "${BUILD_ENV}" = "development" ]; then \
fi
FROM ubuntu:latest as user
RUN useradd -u 10001 api
RUN useradd -u 10001 backend
FROM scratch as runner
WORKDIR /app
COPY --from=user /etc/passwd /etc/passwd
COPY --from=builder /app/bin /bin
COPY ./db ./db
USER api
USER backend
EXPOSE 80
ENTRYPOINT [ "api" ]
ENTRYPOINT [ "backend" ]
CMD [ "run" ]

View file

@ -40,6 +40,10 @@ shards:
git: https://github.com/graphql-crystal/graphql.git
version: 0.3.2+git.commit.8c6dc73c0c898ca511d9d12efefca7c837c25946
habitat:
git: https://github.com/luckyframework/habitat.git
version: 0.4.7
jwt:
git: https://github.com/crystal-community/jwt.git
version: 1.6.0
@ -48,6 +52,10 @@ shards:
git: https://github.com/juanedi/micrate.git
version: 0.12.0
mosquito:
git: https://github.com/mosquito-cr/mosquito.git
version: 0.11.1
openssl_ext:
git: https://github.com/spider-gazelle/openssl_ext.git
version: 2.1.5
@ -56,10 +64,26 @@ shards:
git: https://github.com/will/crystal-pg.git
version: 0.24.0
pool:
git: https://github.com/ysbaddaden/pool.git
version: 0.2.4
redis:
git: https://github.com/stefanwille/crystal-redis.git
version: 2.8.3
secrets-env:
git: https://github.com/spider-gazelle/secrets-env.git
version: 1.3.1
seg:
git: https://github.com/soveran/seg.git
version: 0.1.0+git.commit.7f1cee94fb7ed7a2ba15f1388cbaede72a85eef9
senf:
git: https://git.dergrimm.net/dergrimm/senf.git
version: 0.1.0
toro:
git: https://github.com/soveran/toro.git
version: 0.4.3

View file

@ -1,12 +1,14 @@
name: api
name: backend
version: 0.1.0
authors:
- Dominic Grimm <dominic.grimm@gmail.com>
license: MIT
targets:
api:
main: src/api.cr
backend:
main: src/backend.cr
micrate:
main: src/micrate.cr
@ -35,3 +37,9 @@ dependencies:
github: Papierkorb/fancyline
micrate:
github: juanedi/micrate
senf:
git: https://git.dergrimm.net/dergrimm/senf.git
mosquito:
github: mosquito-cr/mosquito
secrets-env:
github: spider-gazelle/secrets-env

View file

@ -0,0 +1,7 @@
require "secrets-env"
require "./backend/*"
module Backend
CLI.run
end

View file

@ -0,0 +1,45 @@
require "crystal-argon2"
require "jwt"
module Backend
module API
module Auth
extend self
BEARER = "Bearer "
def hash_password(password : String) : String
Argon2::Password.create(password)
end
def verify_password?(password : String, hash : String) : Bool
!!Argon2::Password.verify_password(password, hash)
rescue
false
end
private def create_jwt(data, expiration : Int) : String
payload = {
"data" => data.to_h,
"exp" => expiration,
}
JWT.encode(payload.to_h, SAFE_ENV["BACKEND_JWT_SECRET"], JWT::Algorithm::HS256)
end
def create_user_jwt(user_id : Int, expiration : Int = (Time.utc + Time::Span.new(hours: 6)).to_unix) : String
create_jwt({user_id: user_id}, expiration)
end
def decode_jwt(jwt : String) : JSON::Any
JWT.decode(jwt, SAFE_ENV["BACKEND_JWT_SECRET"], JWT::Algorithm::HS256)[0]
end
def decode_jwt?(jwt : String) : JSON::Any?
decode_jwt(jwt)
rescue
nil
end
end
end
end

View file

@ -0,0 +1,106 @@
require "http/request"
require "graphql"
require "granite"
module Backend
module API
class Context < GraphQL::Context
getter user : Db::User?
getter role : Schema::UserRole?
getter external : (Db::Admin | Db::Teacher | Db::Student)?
def initialize(request : HTTP::Request, *rest)
super(*rest)
token = request.headers["Authorization"]?
if token && token[..Auth::BEARER.size - 1] == Auth::BEARER
payload = Auth.decode_jwt?(token[Auth::BEARER.size..])
return unless payload
data = payload["data"].as_h
@user = Db::User.find(data["user_id"].as_i)
return if @user.nil? || @user.not_nil!.blocked
if @user
@role = Schema::UserRole.parse?(@user.as(Db::User).role).not_nil!
if @role
@external =
case Schema::UserRole.parse?(@user.not_nil!.role)
when Schema::UserRole::Admin
@user.not_nil!.admin
when Schema::UserRole::Teacher
@user.not_nil!.teacher
when Schema::UserRole::Student
@user.not_nil!.student
end
end
end
end
end
def authenticated? : Bool
!@user.nil?
end
def authenticated! : Bool
raise "Not authenticated" unless authenticated?
true
end
def role?(external = true, *roles : Schema::UserRole) : Bool
return false unless authenticated?
roles.each do |role|
return true if @role == role && if external
role == case @external
when Db::Admin
Schema::UserRole::Admin
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
Schema::UserRole::Student
end
else
true
end
end
false
end
def role!(external = true, *roles : Schema::UserRole) : Bool
raise "Invalid permissions" unless role? external, *roles
true
end
private macro role_check(*roles)
{% for role in roles %}
{% name = role.names.last.underscore %}
def {{ name }}?(external = true) : Bool
role? external, {{ role }}
end
def {{ name }}!(external = true) : Bool
role! external, {{ role }}
end
{% end %}
end
role_check Schema::UserRole::Admin, Schema::UserRole::Teacher, Schema::UserRole::Student
def self.db_eq_role?(external : Granite::Base, role : Schema::UserRole) : Bool
role == case external
when Db::Admin
Schema::UserRole::Admin
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
Schema::UserRole::Student
end
end
end
end
end

View file

@ -0,0 +1,14 @@
require "http/server"
module Backend
module API
extend self
def run : Nil
Server.run(80, [HTTP::LogHandler.new, HTTP::ErrorHandler.new]) do |server|
server.bind_tcp("0.0.0.0", 80, true)
server.listen
end
end
end
end

View file

@ -0,0 +1,12 @@
require "graphql"
require "./schema/helpers"
require "./schema/*"
module Backend
module API
module Schema
SCHEMA = GraphQL::Schema.new(Query.new, Mutation.new)
end
end
end

View file

@ -0,0 +1,26 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class Admin < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Admin
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
end
@[GraphQL::InputObject]
class AdminCreateInput < GraphQL::BaseInputObject
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32)
end
end
end
end
end

View file

@ -0,0 +1,65 @@
module Backend
module API
module Schema
module Helpers
module ObjectMacros
macro field(type)
property {{ type.var }} {% if type.value %} = {{ type.value }}{% end %}
@[GraphQL::Field]
def {{ type.var }} : {{ type.type }}
@{{ type.var }}
end
end
end
module ObjectDbInit
macro db_init(type)
def initialize(obj : {{ type }})
initialize(obj.id.not_nil!)
end
end
end
module ObjectFinders
macro finders(type)
def find : {{ type }}?
{{ type }}.find(@id)
end
def find! : {{ type }}
obj = find
raise "#{{{ type }}} not found" unless obj
obj
end
end
end
module DbObject
macro db_object(type)
include ::Backend::API::Schema::Helpers::ObjectDbInit
include ::Backend::API::Schema::Helpers::ObjectFinders
db_init {{ type }}
finders {{ type }}
property id
def initialize(@id : Int32)
end
def initialize(obj : {{ type }})
@id = obj.id.not_nil!.to_i
end
@[GraphQL::Field]
def id : Int32
@id
end
end
end
end
end
end
end

View file

@ -0,0 +1,162 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class Mutation < GraphQL::BaseMutation
@[GraphQL::Field]
def login(input : LoginInput) : LoginPayload
user = Db::User.find_by(email: input.email)
raise "Auth failed" unless user && Auth.verify_password?(input.password, user.password)
LoginPayload.new(
user: User.new(user),
token: Auth.create_user_jwt(user.id.not_nil!.to_i),
)
end
@[GraphQL::Field]
def update_password(context : Context, password : String) : LoginPayload
context.authenticated!
if Auth.verify_password?(password, context.user.not_nil!.password)
raise "New password must be different from old password"
end
context.user.not_nil!.update!(password: Auth.hash_password(password))
LoginPayload.new(
user: User.new(context.user.not_nil!),
token: Auth.create_user_jwt(context.user.not_nil!.id.not_nil!.to_i),
)
end
@[GraphQL::Field]
def create_user(context : Context, input : UserCreateInput) : User
context.admin!
user = Db::User.create!(
firstname: input.firstname,
lastname: input.lastname,
email: input.email,
password: Auth.hash_password(input.password),
role: input.role.to_s,
blocked: input.blocked,
)
if input.create_external && input.role
case input.role
when UserRole::Teacher
user.teacher = Db::Teacher.create!(user_id: user.id, max_students: input.teacher.not_nil!.max_students)
when UserRole::Student
user.student = Db::Student.create!(user_id: user.id, skif: input.student.not_nil!.skif)
end
user.save!
end
User.new(user)
end
@[GraphQL::Field]
def delete_user(context : Context, id : Int32) : Int32
context.admin!
user = Db::User.find!(id)
user.destroy!
id
end
@[GraphQL::Field]
def create_admin(context : Context, input : AdminCreateInput) : Admin
context.admin!
admin = Db::Admin.create!(user_id: input.user_id)
Admin.new(admin)
end
@[GraphQL::Field]
def delete_admin(context : Context, id : Int32) : Int32
context.admin!
admin = Db::Admin.find!(id)
admin.destroy!
id
end
@[GraphQL::Field]
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
@[GraphQL::Field]
def delete_teacher(context : Context, id : Int32) : Int32
context.admin!
teacher = Db::Teacher.find!(id)
teacher.destroy!
id
end
@[GraphQL::Field]
def register_teacher(context : Context, input : TeacherInput) : Teacher
context.teacher? external: false
Teacher.new(
Db::Teacher.create!(user_id: context.user.not_nil!.id, max_students: input.max_students, skif: input.skif)
)
end
@[GraphQL::Field]
def create_student(context : Context, input : StudentCreateInput) : Student
context.admin!
user = Db::User.find!(input.user_id)
raise "User not a student" unless UserRole.parse(user.role) == UserRole::Student
student = Db::Student.create!(user_id: user.id)
Student.new(student)
end
@[GraphQL::Field]
def delete_student(context : Context, id : Int32) : Int32
context.admin!
student = Db::Student.find!(id)
student.destroy!
id
end
@[GraphQL::Field]
def create_vote(context : Context, input : VoteCreateInput) : Vote
context.student!
skif = context.external.as(Db::Student).skif
input.teacher_ids.each do |id|
teacher = Db::Teacher.find(id)
if teacher.nil?
raise "Teachers not found"
elsif teacher.skif != skif
if teacher.skif
raise "Teacher is SKIF, student is not"
else
raise "Teacher is not SKIF, student is"
end
end
end
student = context.external.not_nil!.as(Db::Student)
vote = Db::Vote.create!(student_id: student.id)
Db::TeacherVote.import(input.teacher_ids.map_with_index { |id, i| Db::TeacherVote.new(vote_id: vote.id, teacher_id: id.to_i64, priority: i) })
Vote.new(vote)
end
end
end
end
end

View file

@ -0,0 +1,86 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class Query < GraphQL::BaseQuery
@[GraphQL::Field]
def ok : Bool
true
end
@[GraphQL::Field]
def me(context : Context) : User
context.authenticated!
User.new(context.user.not_nil!)
end
@[GraphQL::Field]
def user(context : Context, id : Int32) : User
context.admin!
User.new(id)
end
@[GraphQL::Field]
def users(context : Context) : Array(User)
context.admin!
Db::User.all.map { |user| User.new(user) }
end
@[GraphQL::Field]
def admin(context : Context, id : Int32) : Admin
context.admin!
Admin.new(Db::Admin.find!(id))
end
@[GraphQL::Field]
def admins(context : Context) : Array(Admin)
context.admin!
Db::Admin.all.map { |admin| Admin.new(admin) }
end
@[GraphQL::Field]
def teacher(id : Int32) : Teacher
Teacher.new(Db::Teacher.find!(id))
end
@[GraphQL::Field]
def teachers : Array(Teacher)
Db::Teacher.all.map { |teacher| Teacher.new(teacher) }
end
@[GraphQL::Field]
def student(context : Context, id : Int32) : Student
context.admin!
Student.new(Db::Student.find!(id))
end
@[GraphQL::Field]
def students(context : Context) : Array(Student)
context.admin!
Db::Student.all.map { |student| Student.new(student) }
end
@[GraphQL::Field]
def vote(context : Context, id : Int32) : Vote
context.admin!
Vote.new(Db::Vote.find!(id))
end
@[GraphQL::Field]
def votes(context : Context) : Array(Vote)
context.admin!
Db::Vote.all.map { |vote| Vote.new(vote) }
end
end
end
end
end

View file

@ -0,0 +1,48 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class Student < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Student
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
@[GraphQL::Field]
def skif : Bool
find!.skif
end
@[GraphQL::Field]
def vote : Vote?
vote = find!.vote
Vote.new(vote) if vote
end
end
@[GraphQL::InputObject]
class StudentInput < GraphQL::BaseInputObject
getter skif
@[GraphQL::Field]
def initialize(@skif : Bool)
end
end
@[GraphQL::InputObject]
class StudentCreateInput < StudentInput
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32, skif : Bool)
super(skif)
end
end
end
end
end

View file

@ -0,0 +1,47 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class Teacher < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Teacher
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
@[GraphQL::Field]
def max_students : Int32
find!.max_students
end
@[GraphQL::Field]
def skif : Bool
find!.skif
end
end
@[GraphQL::InputObject]
class TeacherInput < GraphQL::BaseInputObject
getter max_students
getter skif
@[GraphQL::Field]
def initialize(@max_students : Int32, @skif : Bool)
end
end
@[GraphQL::InputObject]
class TeacherCreateInput < TeacherInput
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32, max_students : Int32, skif : Bool)
super(max_students, skif)
end
end
end
end
end

View file

@ -0,0 +1,33 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class TeacherVote < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::TeacherVote
@[GraphQL::Field]
def teacher : Teacher
Teacher.new(find!.teacher.not_nil!)
end
@[GraphQL::Field]
def priority : Int32
find!.priority
end
end
@[GraphQL::InputObject]
class TeacherVoteCreateInput < GraphQL::BaseInputObject
getter vote_id
getter teacher_id
getter priority
@[GraphQL::Field]
def initialize(@vote_id : Int32, @teacher_id : Int32, @priority : Int32)
end
end
end
end
end

View file

@ -0,0 +1,152 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class User < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::User
@[GraphQL::Field]
def firstname : String
find!.firstname
end
@[GraphQL::Field]
def lastname : String
find!.lastname
end
@[GraphQL::Field]
def email : String
find!.email
end
@[GraphQL::Field]
def role : UserRole
role = Db::UserRole.parse(find!.role)
case role
when Db::UserRole::Admin
UserRole::Admin
when Db::UserRole::Teacher
UserRole::Teacher
when Db::UserRole::Student
UserRole::Student
else
raise "Unknown role: #{role}"
end
end
@[GraphQL::Field]
def external_id : Int32?
case Db::UserRole.parse(find!.role)
when Db::UserRole::Admin
find!.admin
when Db::UserRole::Teacher
find!.teacher
when Db::UserRole::Student
find!.student
end.not_nil!.id.not_nil!.to_i
rescue NilAssertionError
nil
end
@[GraphQL::Field]
def admin : Admin?
admin = find!.admin
if admin
Admin.new(admin)
end
end
@[GraphQL::Field]
def teacher : Teacher?
teacher = find!.teacher
if teacher
Teacher.new(teacher)
end
end
@[GraphQL::Field]
def student : Student?
student = find!.student
if student
Student.new(student)
end
end
@[GraphQL::Field]
def blocked : Bool
find!.blocked
end
end
@[GraphQL::InputObject]
class UserCreateInput < GraphQL::BaseInputObject
getter firstname
getter lastname
getter email
getter password
getter role
getter teacher
getter student
getter create_external
getter blocked
@[GraphQL::Field]
def initialize(
@firstname : String,
@lastname : String,
@email : String,
@password : String,
@role : UserRole,
@teacher : TeacherInput? = nil,
@student : StudentInput? = nil,
@create_external : Bool = false,
@blocked : Bool = false
)
end
end
@[GraphQL::InputObject]
class LoginInput < GraphQL::BaseInputObject
getter email
getter password
@[GraphQL::Field]
def initialize(
@email : String,
@password : String
)
end
end
@[GraphQL::Object]
class LoginPayload < GraphQL::BaseObject
property user
property token
def initialize(
@user : User,
@token : String
)
end
@[GraphQL::Field]
def user : User
@user
end
@[GraphQL::Field]
def token : String
@token
end
@[GraphQL::Field]
def bearer : String
Auth::BEARER + @token
end
end
end
end
end

View file

@ -0,0 +1,12 @@
module Backend
module API
module Schema
@[GraphQL::Enum]
enum UserRole
Admin
Teacher
Student
end
end
end
end

View file

@ -0,0 +1,31 @@
module Backend
module API
module Schema
@[GraphQL::Object]
class Vote < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Vote
@[GraphQL::Field]
def student : Student
Student.new(find!.student)
end
@[GraphQL::Field]
def teacher_votes : Array(TeacherVote)
find!.teacher_votes.map { |tv| TeacherVote.new(tv) }
end
end
@[GraphQL::InputObject]
class VoteCreateInput < GraphQL::BaseInputObject
getter teacher_ids
@[GraphQL::Field]
def initialize(@teacher_ids : Array(Int32))
end
end
end
end
end

View file

@ -0,0 +1,33 @@
require "toro"
require "json"
module Backend
module API
class Server < Toro::Router
private struct GraphQLData
include JSON::Serializable
property query : String
property variables : Hash(String, JSON::Any)?
property operation_name : String?
end
def routes
on "graphql" do
post do
content_type "application/json"
data = GraphQLData.from_json(context.request.body.not_nil!.gets.not_nil!)
write Schema::SCHEMA.execute(
data.query,
data.variables,
data.operation_name,
Context.new(context.request)
)
end
end
end
end
end
end

View file

@ -0,0 +1,92 @@
require "commander"
require "fancyline"
require "./db"
module Backend
module CLI
extend self
private FANCY = Fancyline.new
private def input(prompt : String) : String
x = FANCY.readline(prompt)
if x
x.chomp.strip
else
""
end
end
def run : Nil
FANCY.actions.set Fancyline::Key::Control::CtrlC do
exit
end
cli = Commander::Command.new do |cmd|
cmd.use = "backend"
cmd.long = "Mentorenwahl backend CLI"
cmd.run do
puts cmd.help
end
cmd.commands.add do |c|
c.use = "run"
c.long = "Run the backend"
c.run do
Backend.run
end
end
cmd.commands.add do |c|
c.use = "seed"
c.long = "Seeds the database with required data"
c.run do
puts "Seeding database with admin user..."
data = {
"firstname" => input("Firstname: "),
"lastname" => input("Lastname: "),
"email" => input("Email: "),
"password" => API::Auth.hash_password(input("Password: ")),
"role" => Db::UserRole::Admin.to_s,
}
password_confirmation = input("Password confirmation: ")
if data.values.any?(&.empty?)
abort "Values can't be empty!"
elsif !API::Auth.verify_password?(password_confirmation, data["password"])
abort "Passwords do not match!"
end
puts "---"
data.each { |k, v| puts "#{k.capitalize}: #{v}" }
puts "---"
unless input("Are you sure? (y/N) ").downcase == "y"
abort "Aborted!"
end
puts "Seeding database with admin user..."
user = Db::User.create!(data)
admin = Db::Admin.create!(user_id: user.id)
puts "Done!"
puts "---"
puts "User id: #{user.id}"
puts "Admin id: #{admin.id}"
puts "Token: #{API::Auth.create_user_jwt(user_id: user.id.not_nil!)}"
puts "---"
end
end
end
Commander.run(cli, ARGV)
end
end
end

View file

@ -1,9 +1,10 @@
require "granite"
require "granite/adapter/pg"
require "./db/*"
module API
module Backend
module Db
Granite::Connections << Granite::Adapter::Pg.new(name: "pg", url: ENV_REQUESTER["API_DATABASE_URL"])
Granite::Connections << Granite::Adapter::Pg.new(name: "pg", url: SAFE_ENV["BACKEND_DATABASE_URL"])
end
end

View file

@ -1,6 +1,4 @@
require "granite"
module API
module Backend
module Db
class Admin < Granite::Base
table admins

View file

@ -1,6 +1,4 @@
require "granite"
module API
module Backend
module Db
class Student < Granite::Base
table students

View file

@ -1,6 +1,4 @@
require "granite"
module API
module Backend
module Db
class Teacher < Granite::Base
table teachers

View file

@ -1,6 +1,4 @@
require "granite"
module API
module Backend
module Db
class TeacherVote < Granite::Base
table teacher_votes

View file

@ -1,7 +1,6 @@
require "CrystalEmail"
require "granite"
module API
module Backend
module Db
class User < Granite::Base
table users

View file

@ -1,4 +1,4 @@
module API
module Backend
module Db
enum UserRole
Admin

View file

@ -1,6 +1,4 @@
require "granite"
module API
module Backend
module Db
class Vote < Granite::Base
table votes

View file

@ -0,0 +1,28 @@
module Backend
extend self
def run : Nil
puts "Running backend..."
puts "-" * 10
channel = Channel(Nil).new
spawn same_thread: true do
puts "Starting API..."
API.run
channel.send(nil)
end
spawn same_thread: true do
puts "Starting worker..."
Worker.run
channel.send(nil)
end
2.times do
channel.receive
end
end
end

View file

@ -0,0 +1,11 @@
require "senf"
module Backend
SAFE_ENV = Senf::SafeEnv.new([
"BACKEND_DATABASE_URL",
"BACKEND_ADMIN_EMAIL",
"BACKEND_ADMIN_PASSWORD",
"BACKEND_JWT_SECRET",
"BACKEND_WORKER_REDIS_URL",
])
end

View file

@ -0,0 +1,11 @@
require "mosquito"
require "./worker/*"
module Backend
module Worker
Mosquito.configure do |settings|
settings.redis_url = SAFE_ENV["BACKEND_WORKER_REDIS_URL"]
end
end
end

View file

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

View file

@ -0,0 +1,13 @@
module Backend
module Worker
module Jobs
class HelloWorldJob < Mosquito::PeriodicJob
run_every 30.seconds
def perform : Nil
log "Hello World!"
end
end
end
end
end

View file

@ -0,0 +1,9 @@
module Backend
module Worker
extend self
def run : Nil
Mosquito::Runner.start
end
end
end

View file

@ -0,0 +1,11 @@
require "secrets-env"
require "senf"
require "micrate"
require "pg"
SAFE_ENV = Senf::SafeEnv.new([
"BACKEND_DATABASE_URL",
])
Micrate::DB.connection_url = SAFE_ENV["BACKEND_DATABASE_URL"]?
Micrate::Cli.run

3
scripts/backend.sh Normal file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker-compose exec backend backend "$@"

View file

@ -1,3 +1,3 @@
#!/usr/bin/env bash
docker-compose exec api micrate "$@"
docker-compose exec backend micrate "$@"