From 5bc10f8aaf8e420267421980bef62eb336d47334 Mon Sep 17 00:00:00 2001 From: Dominic Grimm Date: Sun, 23 Jan 2022 09:12:57 +0100 Subject: [PATCH] Added worker --- config/nginx/nginx.conf | 2 +- docker-compose.yml | 47 ++++- docker/api/src/api/auth.cr | 43 ----- docker/api/src/api/cli.cr | 81 --------- docker/api/src/api/context.cr | 104 ----------- docker/api/src/api/env_requester.cr | 43 ----- docker/api/src/api/run.cr | 13 -- docker/api/src/api/schema.cr | 10 -- docker/api/src/api/schema/admin.cr | 26 --- docker/api/src/api/schema/helpers.cr | 65 ------- docker/api/src/api/schema/mutation.cr | 162 ------------------ docker/api/src/api/schema/query.cr | 86 ---------- docker/api/src/api/schema/student.cr | 48 ------ docker/api/src/api/schema/teacher.cr | 47 ----- docker/api/src/api/schema/teacher_vote.cr | 33 ---- docker/api/src/api/schema/user.cr | 152 ---------------- docker/api/src/api/schema/user_role.cr | 12 -- docker/api/src/api/schema/vote.cr | 31 ---- docker/api/src/api/server.cr | 31 ---- docker/api/src/micrate.cr | 5 - docker/{api => backend}/.dockerignore | 0 docker/{api => backend}/.editorconfig | 0 docker/{api => backend}/.gitignore | 0 docker/{api => backend}/Dockerfile | 7 +- .../20220120165102_create_users.sql | 0 .../20220120165204_create_user_roles.sql | 0 .../20220120165316_create_user_externals.sql | 0 .../20220120165453_create_votes.sql | 0 docker/{api => backend}/shard.lock | 24 +++ docker/{api => backend}/shard.yml | 14 +- docker/backend/src/backend.cr | 7 + .../{api/src => backend/src/backend}/api.cr | 0 docker/backend/src/backend/api/auth.cr | 45 +++++ docker/backend/src/backend/api/context.cr | 106 ++++++++++++ docker/backend/src/backend/api/run.cr | 14 ++ docker/backend/src/backend/api/schema.cr | 12 ++ .../backend/src/backend/api/schema/admin.cr | 26 +++ .../backend/src/backend/api/schema/helpers.cr | 65 +++++++ .../src/backend/api/schema/mutation.cr | 162 ++++++++++++++++++ .../backend/src/backend/api/schema/query.cr | 86 ++++++++++ .../backend/src/backend/api/schema/student.cr | 48 ++++++ .../backend/src/backend/api/schema/teacher.cr | 47 +++++ .../src/backend/api/schema/teacher_vote.cr | 33 ++++ docker/backend/src/backend/api/schema/user.cr | 152 ++++++++++++++++ .../src/backend/api/schema/user_role.cr | 12 ++ docker/backend/src/backend/api/schema/vote.cr | 31 ++++ docker/backend/src/backend/api/server.cr | 33 ++++ docker/backend/src/backend/cli.cr | 92 ++++++++++ .../src/api => backend/src/backend}/db.cr | 5 +- .../api => backend/src/backend}/db/admin.cr | 4 +- .../api => backend/src/backend}/db/student.cr | 4 +- .../api => backend/src/backend}/db/teacher.cr | 4 +- .../src/backend}/db/teacher_vote.cr | 4 +- .../api => backend/src/backend}/db/user.cr | 3 +- .../src/backend}/db/user_role.cr | 2 +- .../api => backend/src/backend}/db/vote.cr | 4 +- docker/backend/src/backend/run.cr | 28 +++ docker/backend/src/backend/safe_env.cr | 11 ++ docker/backend/src/backend/worker.cr | 11 ++ docker/backend/src/backend/worker/jobs.cr | 1 + .../src/backend/worker/jobs/hello_world.cr | 13 ++ docker/backend/src/backend/worker/run.cr | 9 + docker/backend/src/micrate.cr | 11 ++ scripts/backend.sh | 3 + scripts/micrate.sh | 2 +- 65 files changed, 1147 insertions(+), 1029 deletions(-) delete mode 100644 docker/api/src/api/auth.cr delete mode 100644 docker/api/src/api/cli.cr delete mode 100644 docker/api/src/api/context.cr delete mode 100644 docker/api/src/api/env_requester.cr delete mode 100644 docker/api/src/api/run.cr delete mode 100644 docker/api/src/api/schema.cr delete mode 100644 docker/api/src/api/schema/admin.cr delete mode 100644 docker/api/src/api/schema/helpers.cr delete mode 100644 docker/api/src/api/schema/mutation.cr delete mode 100644 docker/api/src/api/schema/query.cr delete mode 100644 docker/api/src/api/schema/student.cr delete mode 100644 docker/api/src/api/schema/teacher.cr delete mode 100644 docker/api/src/api/schema/teacher_vote.cr delete mode 100644 docker/api/src/api/schema/user.cr delete mode 100644 docker/api/src/api/schema/user_role.cr delete mode 100644 docker/api/src/api/schema/vote.cr delete mode 100644 docker/api/src/api/server.cr delete mode 100644 docker/api/src/micrate.cr rename docker/{api => backend}/.dockerignore (100%) rename docker/{api => backend}/.editorconfig (100%) rename docker/{api => backend}/.gitignore (100%) rename docker/{api => backend}/Dockerfile (89%) rename docker/{api => backend}/db/migrations/20220120165102_create_users.sql (100%) rename docker/{api => backend}/db/migrations/20220120165204_create_user_roles.sql (100%) rename docker/{api => backend}/db/migrations/20220120165316_create_user_externals.sql (100%) rename docker/{api => backend}/db/migrations/20220120165453_create_votes.sql (100%) rename docker/{api => backend}/shard.lock (74%) rename docker/{api => backend}/shard.yml (74%) create mode 100644 docker/backend/src/backend.cr rename docker/{api/src => backend/src/backend}/api.cr (100%) create mode 100644 docker/backend/src/backend/api/auth.cr create mode 100644 docker/backend/src/backend/api/context.cr create mode 100644 docker/backend/src/backend/api/run.cr create mode 100644 docker/backend/src/backend/api/schema.cr create mode 100644 docker/backend/src/backend/api/schema/admin.cr create mode 100644 docker/backend/src/backend/api/schema/helpers.cr create mode 100644 docker/backend/src/backend/api/schema/mutation.cr create mode 100644 docker/backend/src/backend/api/schema/query.cr create mode 100644 docker/backend/src/backend/api/schema/student.cr create mode 100644 docker/backend/src/backend/api/schema/teacher.cr create mode 100644 docker/backend/src/backend/api/schema/teacher_vote.cr create mode 100644 docker/backend/src/backend/api/schema/user.cr create mode 100644 docker/backend/src/backend/api/schema/user_role.cr create mode 100644 docker/backend/src/backend/api/schema/vote.cr create mode 100644 docker/backend/src/backend/api/server.cr create mode 100644 docker/backend/src/backend/cli.cr rename docker/{api/src/api => backend/src/backend}/db.cr (64%) rename docker/{api/src/api => backend/src/backend}/db/admin.cr (82%) rename docker/{api/src/api => backend/src/backend}/db/student.cr (86%) rename docker/{api/src/api => backend/src/backend}/db/teacher.cr (89%) rename docker/{api/src/api => backend/src/backend}/db/teacher_vote.cr (96%) rename docker/{api/src/api => backend/src/backend}/db/user.cr (97%) rename docker/{api/src/api => backend/src/backend}/db/user_role.cr (85%) rename docker/{api/src/api => backend/src/backend}/db/vote.cr (86%) create mode 100644 docker/backend/src/backend/run.cr create mode 100644 docker/backend/src/backend/safe_env.cr create mode 100644 docker/backend/src/backend/worker.cr create mode 100644 docker/backend/src/backend/worker/jobs.cr create mode 100644 docker/backend/src/backend/worker/jobs/hello_world.cr create mode 100644 docker/backend/src/backend/worker/run.cr create mode 100644 docker/backend/src/micrate.cr create mode 100644 scripts/backend.sh diff --git a/config/nginx/nginx.conf b/config/nginx/nginx.conf index 8d9ddba..bb41f6e 100644 --- a/config/nginx/nginx.conf +++ b/config/nginx/nginx.conf @@ -10,7 +10,7 @@ http { # } location /graphql { - proxy_pass http://api; + proxy_pass http://backend; } location /adminer { diff --git a/docker-compose.yml b/docker-compose.yml index 52578a0..bc50a93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker/api/src/api/auth.cr b/docker/api/src/api/auth.cr deleted file mode 100644 index d226d7e..0000000 --- a/docker/api/src/api/auth.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/cli.cr b/docker/api/src/api/cli.cr deleted file mode 100644 index 0b7ce07..0000000 --- a/docker/api/src/api/cli.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/context.cr b/docker/api/src/api/context.cr deleted file mode 100644 index bfb33df..0000000 --- a/docker/api/src/api/context.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/env_requester.cr b/docker/api/src/api/env_requester.cr deleted file mode 100644 index 5c391a7..0000000 --- a/docker/api/src/api/env_requester.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/run.cr b/docker/api/src/api/run.cr deleted file mode 100644 index 9f0c047..0000000 --- a/docker/api/src/api/run.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema.cr b/docker/api/src/api/schema.cr deleted file mode 100644 index 9784864..0000000 --- a/docker/api/src/api/schema.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/admin.cr b/docker/api/src/api/schema/admin.cr deleted file mode 100644 index 11f8b19..0000000 --- a/docker/api/src/api/schema/admin.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/helpers.cr b/docker/api/src/api/schema/helpers.cr deleted file mode 100644 index e785336..0000000 --- a/docker/api/src/api/schema/helpers.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/mutation.cr b/docker/api/src/api/schema/mutation.cr deleted file mode 100644 index d9e0762..0000000 --- a/docker/api/src/api/schema/mutation.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/query.cr b/docker/api/src/api/schema/query.cr deleted file mode 100644 index 258b20d..0000000 --- a/docker/api/src/api/schema/query.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/student.cr b/docker/api/src/api/schema/student.cr deleted file mode 100644 index 2fd7acd..0000000 --- a/docker/api/src/api/schema/student.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/teacher.cr b/docker/api/src/api/schema/teacher.cr deleted file mode 100644 index 0795eec..0000000 --- a/docker/api/src/api/schema/teacher.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/teacher_vote.cr b/docker/api/src/api/schema/teacher_vote.cr deleted file mode 100644 index 7a210ec..0000000 --- a/docker/api/src/api/schema/teacher_vote.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/user.cr b/docker/api/src/api/schema/user.cr deleted file mode 100644 index fa6945b..0000000 --- a/docker/api/src/api/schema/user.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/schema/user_role.cr b/docker/api/src/api/schema/user_role.cr deleted file mode 100644 index bd002da..0000000 --- a/docker/api/src/api/schema/user_role.cr +++ /dev/null @@ -1,12 +0,0 @@ -require "graphql" - -module API - module Schema - @[GraphQL::Enum] - enum UserRole - Admin - Teacher - Student - end - end -end diff --git a/docker/api/src/api/schema/vote.cr b/docker/api/src/api/schema/vote.cr deleted file mode 100644 index fecca59..0000000 --- a/docker/api/src/api/schema/vote.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/api/server.cr b/docker/api/src/api/server.cr deleted file mode 100644 index 4767f76..0000000 --- a/docker/api/src/api/server.cr +++ /dev/null @@ -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 diff --git a/docker/api/src/micrate.cr b/docker/api/src/micrate.cr deleted file mode 100644 index e00e371..0000000 --- a/docker/api/src/micrate.cr +++ /dev/null @@ -1,5 +0,0 @@ -require "micrate" -require "pg" - -Micrate::DB.connection_url = ENV["API_DATABASE_URL"]? -Micrate::Cli.run diff --git a/docker/api/.dockerignore b/docker/backend/.dockerignore similarity index 100% rename from docker/api/.dockerignore rename to docker/backend/.dockerignore diff --git a/docker/api/.editorconfig b/docker/backend/.editorconfig similarity index 100% rename from docker/api/.editorconfig rename to docker/backend/.editorconfig diff --git a/docker/api/.gitignore b/docker/backend/.gitignore similarity index 100% rename from docker/api/.gitignore rename to docker/backend/.gitignore diff --git a/docker/api/Dockerfile b/docker/backend/Dockerfile similarity index 89% rename from docker/api/Dockerfile rename to docker/backend/Dockerfile index 5e8c751..e1d1656 100644 --- a/docker/api/Dockerfile +++ b/docker/backend/Dockerfile @@ -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" ] diff --git a/docker/api/db/migrations/20220120165102_create_users.sql b/docker/backend/db/migrations/20220120165102_create_users.sql similarity index 100% rename from docker/api/db/migrations/20220120165102_create_users.sql rename to docker/backend/db/migrations/20220120165102_create_users.sql diff --git a/docker/api/db/migrations/20220120165204_create_user_roles.sql b/docker/backend/db/migrations/20220120165204_create_user_roles.sql similarity index 100% rename from docker/api/db/migrations/20220120165204_create_user_roles.sql rename to docker/backend/db/migrations/20220120165204_create_user_roles.sql diff --git a/docker/api/db/migrations/20220120165316_create_user_externals.sql b/docker/backend/db/migrations/20220120165316_create_user_externals.sql similarity index 100% rename from docker/api/db/migrations/20220120165316_create_user_externals.sql rename to docker/backend/db/migrations/20220120165316_create_user_externals.sql diff --git a/docker/api/db/migrations/20220120165453_create_votes.sql b/docker/backend/db/migrations/20220120165453_create_votes.sql similarity index 100% rename from docker/api/db/migrations/20220120165453_create_votes.sql rename to docker/backend/db/migrations/20220120165453_create_votes.sql diff --git a/docker/api/shard.lock b/docker/backend/shard.lock similarity index 74% rename from docker/api/shard.lock rename to docker/backend/shard.lock index dc53aaa..110e0f4 100644 --- a/docker/api/shard.lock +++ b/docker/backend/shard.lock @@ -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 diff --git a/docker/api/shard.yml b/docker/backend/shard.yml similarity index 74% rename from docker/api/shard.yml rename to docker/backend/shard.yml index 97a7197..dbad516 100644 --- a/docker/api/shard.yml +++ b/docker/backend/shard.yml @@ -1,12 +1,14 @@ -name: api +name: backend version: 0.1.0 authors: - Dominic Grimm +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 diff --git a/docker/backend/src/backend.cr b/docker/backend/src/backend.cr new file mode 100644 index 0000000..0ba2dc7 --- /dev/null +++ b/docker/backend/src/backend.cr @@ -0,0 +1,7 @@ +require "secrets-env" + +require "./backend/*" + +module Backend + CLI.run +end diff --git a/docker/api/src/api.cr b/docker/backend/src/backend/api.cr similarity index 100% rename from docker/api/src/api.cr rename to docker/backend/src/backend/api.cr diff --git a/docker/backend/src/backend/api/auth.cr b/docker/backend/src/backend/api/auth.cr new file mode 100644 index 0000000..b27d429 --- /dev/null +++ b/docker/backend/src/backend/api/auth.cr @@ -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 diff --git a/docker/backend/src/backend/api/context.cr b/docker/backend/src/backend/api/context.cr new file mode 100644 index 0000000..08bc1b1 --- /dev/null +++ b/docker/backend/src/backend/api/context.cr @@ -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 diff --git a/docker/backend/src/backend/api/run.cr b/docker/backend/src/backend/api/run.cr new file mode 100644 index 0000000..f689931 --- /dev/null +++ b/docker/backend/src/backend/api/run.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema.cr b/docker/backend/src/backend/api/schema.cr new file mode 100644 index 0000000..6cc4858 --- /dev/null +++ b/docker/backend/src/backend/api/schema.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/admin.cr b/docker/backend/src/backend/api/schema/admin.cr new file mode 100644 index 0000000..51b5936 --- /dev/null +++ b/docker/backend/src/backend/api/schema/admin.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/helpers.cr b/docker/backend/src/backend/api/schema/helpers.cr new file mode 100644 index 0000000..20dc638 --- /dev/null +++ b/docker/backend/src/backend/api/schema/helpers.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/mutation.cr b/docker/backend/src/backend/api/schema/mutation.cr new file mode 100644 index 0000000..69e18e7 --- /dev/null +++ b/docker/backend/src/backend/api/schema/mutation.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/query.cr b/docker/backend/src/backend/api/schema/query.cr new file mode 100644 index 0000000..0f60111 --- /dev/null +++ b/docker/backend/src/backend/api/schema/query.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/student.cr b/docker/backend/src/backend/api/schema/student.cr new file mode 100644 index 0000000..0405baf --- /dev/null +++ b/docker/backend/src/backend/api/schema/student.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/teacher.cr b/docker/backend/src/backend/api/schema/teacher.cr new file mode 100644 index 0000000..e24048d --- /dev/null +++ b/docker/backend/src/backend/api/schema/teacher.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/teacher_vote.cr b/docker/backend/src/backend/api/schema/teacher_vote.cr new file mode 100644 index 0000000..ace40de --- /dev/null +++ b/docker/backend/src/backend/api/schema/teacher_vote.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/user.cr b/docker/backend/src/backend/api/schema/user.cr new file mode 100644 index 0000000..eee050c --- /dev/null +++ b/docker/backend/src/backend/api/schema/user.cr @@ -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 diff --git a/docker/backend/src/backend/api/schema/user_role.cr b/docker/backend/src/backend/api/schema/user_role.cr new file mode 100644 index 0000000..1f99c7e --- /dev/null +++ b/docker/backend/src/backend/api/schema/user_role.cr @@ -0,0 +1,12 @@ +module Backend + module API + module Schema + @[GraphQL::Enum] + enum UserRole + Admin + Teacher + Student + end + end + end +end diff --git a/docker/backend/src/backend/api/schema/vote.cr b/docker/backend/src/backend/api/schema/vote.cr new file mode 100644 index 0000000..3349822 --- /dev/null +++ b/docker/backend/src/backend/api/schema/vote.cr @@ -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 diff --git a/docker/backend/src/backend/api/server.cr b/docker/backend/src/backend/api/server.cr new file mode 100644 index 0000000..bcbddeb --- /dev/null +++ b/docker/backend/src/backend/api/server.cr @@ -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 diff --git a/docker/backend/src/backend/cli.cr b/docker/backend/src/backend/cli.cr new file mode 100644 index 0000000..7da83c4 --- /dev/null +++ b/docker/backend/src/backend/cli.cr @@ -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 diff --git a/docker/api/src/api/db.cr b/docker/backend/src/backend/db.cr similarity index 64% rename from docker/api/src/api/db.cr rename to docker/backend/src/backend/db.cr index 2cdcf6f..cf3a4aa 100644 --- a/docker/api/src/api/db.cr +++ b/docker/backend/src/backend/db.cr @@ -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 diff --git a/docker/api/src/api/db/admin.cr b/docker/backend/src/backend/db/admin.cr similarity index 82% rename from docker/api/src/api/db/admin.cr rename to docker/backend/src/backend/db/admin.cr index 534cca7..af3ed6f 100644 --- a/docker/api/src/api/db/admin.cr +++ b/docker/backend/src/backend/db/admin.cr @@ -1,6 +1,4 @@ -require "granite" - -module API +module Backend module Db class Admin < Granite::Base table admins diff --git a/docker/api/src/api/db/student.cr b/docker/backend/src/backend/db/student.cr similarity index 86% rename from docker/api/src/api/db/student.cr rename to docker/backend/src/backend/db/student.cr index c11956f..97d49a8 100644 --- a/docker/api/src/api/db/student.cr +++ b/docker/backend/src/backend/db/student.cr @@ -1,6 +1,4 @@ -require "granite" - -module API +module Backend module Db class Student < Granite::Base table students diff --git a/docker/api/src/api/db/teacher.cr b/docker/backend/src/backend/db/teacher.cr similarity index 89% rename from docker/api/src/api/db/teacher.cr rename to docker/backend/src/backend/db/teacher.cr index a487184..b1516ec 100644 --- a/docker/api/src/api/db/teacher.cr +++ b/docker/backend/src/backend/db/teacher.cr @@ -1,6 +1,4 @@ -require "granite" - -module API +module Backend module Db class Teacher < Granite::Base table teachers diff --git a/docker/api/src/api/db/teacher_vote.cr b/docker/backend/src/backend/db/teacher_vote.cr similarity index 96% rename from docker/api/src/api/db/teacher_vote.cr rename to docker/backend/src/backend/db/teacher_vote.cr index 7c3f3b8..8a7a32b 100644 --- a/docker/api/src/api/db/teacher_vote.cr +++ b/docker/backend/src/backend/db/teacher_vote.cr @@ -1,6 +1,4 @@ -require "granite" - -module API +module Backend module Db class TeacherVote < Granite::Base table teacher_votes diff --git a/docker/api/src/api/db/user.cr b/docker/backend/src/backend/db/user.cr similarity index 97% rename from docker/api/src/api/db/user.cr rename to docker/backend/src/backend/db/user.cr index 899821d..f724494 100644 --- a/docker/api/src/api/db/user.cr +++ b/docker/backend/src/backend/db/user.cr @@ -1,7 +1,6 @@ require "CrystalEmail" -require "granite" -module API +module Backend module Db class User < Granite::Base table users diff --git a/docker/api/src/api/db/user_role.cr b/docker/backend/src/backend/db/user_role.cr similarity index 85% rename from docker/api/src/api/db/user_role.cr rename to docker/backend/src/backend/db/user_role.cr index 7122863..d359f46 100644 --- a/docker/api/src/api/db/user_role.cr +++ b/docker/backend/src/backend/db/user_role.cr @@ -1,4 +1,4 @@ -module API +module Backend module Db enum UserRole Admin diff --git a/docker/api/src/api/db/vote.cr b/docker/backend/src/backend/db/vote.cr similarity index 86% rename from docker/api/src/api/db/vote.cr rename to docker/backend/src/backend/db/vote.cr index b9fb6c7..d9a1a70 100644 --- a/docker/api/src/api/db/vote.cr +++ b/docker/backend/src/backend/db/vote.cr @@ -1,6 +1,4 @@ -require "granite" - -module API +module Backend module Db class Vote < Granite::Base table votes diff --git a/docker/backend/src/backend/run.cr b/docker/backend/src/backend/run.cr new file mode 100644 index 0000000..99bb48c --- /dev/null +++ b/docker/backend/src/backend/run.cr @@ -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 diff --git a/docker/backend/src/backend/safe_env.cr b/docker/backend/src/backend/safe_env.cr new file mode 100644 index 0000000..c95476c --- /dev/null +++ b/docker/backend/src/backend/safe_env.cr @@ -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 diff --git a/docker/backend/src/backend/worker.cr b/docker/backend/src/backend/worker.cr new file mode 100644 index 0000000..483267a --- /dev/null +++ b/docker/backend/src/backend/worker.cr @@ -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 diff --git a/docker/backend/src/backend/worker/jobs.cr b/docker/backend/src/backend/worker/jobs.cr new file mode 100644 index 0000000..db7012a --- /dev/null +++ b/docker/backend/src/backend/worker/jobs.cr @@ -0,0 +1 @@ +require "./jobs/*" diff --git a/docker/backend/src/backend/worker/jobs/hello_world.cr b/docker/backend/src/backend/worker/jobs/hello_world.cr new file mode 100644 index 0000000..dcb3840 --- /dev/null +++ b/docker/backend/src/backend/worker/jobs/hello_world.cr @@ -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 diff --git a/docker/backend/src/backend/worker/run.cr b/docker/backend/src/backend/worker/run.cr new file mode 100644 index 0000000..6941c91 --- /dev/null +++ b/docker/backend/src/backend/worker/run.cr @@ -0,0 +1,9 @@ +module Backend + module Worker + extend self + + def run : Nil + Mosquito::Runner.start + end + end +end diff --git a/docker/backend/src/micrate.cr b/docker/backend/src/micrate.cr new file mode 100644 index 0000000..e7fa019 --- /dev/null +++ b/docker/backend/src/micrate.cr @@ -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 diff --git a/scripts/backend.sh b/scripts/backend.sh new file mode 100644 index 0000000..82fdb1f --- /dev/null +++ b/scripts/backend.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +docker-compose exec backend backend "$@" diff --git a/scripts/micrate.sh b/scripts/micrate.sh index eb140dc..153d5bc 100644 --- a/scripts/micrate.sh +++ b/scripts/micrate.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -docker-compose exec api micrate "$@" +docker-compose exec backend micrate "$@"