Rewrite frontend in rust with yew
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Dominic Grimm 2022-11-04 21:23:36 +01:00
parent c56d359814
commit 860ae7ed5e
No known key found for this signature in database
GPG Key ID: 6F294212DEAAC530
54 changed files with 3135 additions and 211 deletions

View File

@ -16,6 +16,7 @@
require "jwt"
require "json"
require "uuid"
module Backend
module Api
@ -31,16 +32,18 @@ module Backend
include JSON::Serializable
getter iss : String
getter vrs : String
getter iat : Int64
getter exp : Int64
getter jti : String
getter jti : UUID
getter context : Context
def initialize(
@iss : String,
@vrs : String,
@iat : Int64,
@exp : Int64,
@jti : String,
@jti : UUID,
@context : Context
)
end
@ -52,9 +55,10 @@ module Backend
def self.from_hash(token : Hash(String, JSON::Any)) : self
self.new(
iss: token["iss"].as_s,
vrs: token["vrs"].as_s,
iat: token["iat"].as_i64,
exp: token["exp"].as_i64,
jti: token["jti"].as_s,
jti: UUID.new(token["jti"].as_s),
context: Context.from_hash(token["context"].as_h)
)
end

View File

@ -62,6 +62,8 @@ module Backend
rescue
@status = Status::JWTError
else
pp! payload
if @user = Db::User.find(payload.context.user)
@admin = user.not_nil!.admin
@role = user.not_nil!.role.to_api
@ -98,8 +100,10 @@ module Backend
# :ditto:
def authenticated! : Bool
raise "Session expired" if @status.session_expired?
raise "Not authenticated" unless authenticated?
# raise "Session expired" if @status.session_expired?
raise Errors::SessionExpired.new if @status.session_expired?
# raise "Not authenticated" unless authenticated?
raise Errors::NotAuthenticated.new unless authenticated?
true
end
@ -112,7 +116,8 @@ module Backend
# :ditto:
def admin! : Bool
authenticated!
raise "Invalid permissions" unless admin?
# raise "Invalid permissions" unless admin?
raise Errors::InvalidPermissions.new unless admin?
true
end
@ -142,7 +147,8 @@ module Backend
# :ditto:
def role!(roles : Array(Schema::UserRole), external_check = true) : Bool
authenticated!
raise "Invalid permissions" unless role?(roles, external_check)
# raise "Invalid permissions" unless role?(roles, external_check)
raise Errors::InvalidPermissions.new unless role?(roles, external_check)
true
end
@ -169,32 +175,31 @@ module Backend
# Custom error handler
def handle_exception(ex : Exception) : String?
pp! ex, ex.message
# ex.message
# pp! ex, ex.message, ex.class, typeof(ex), ex.is_a? Errors::PublicError
# # ex.message
case ex
when Errors::Error
when Errors::PublicError
ex.api_message
when Errors::PrivateError
{% if !flag?(:release) %}
if @development
ex.message
ex.api_message
else
nil
Errors::UNKNOWN_PRIVATE_ERROR
end
{% else %}
nil
Errors::UNKNOWN_PRIVATE_ERROR
{% end %}
when Errors::PublicError
ex.message
else
{% if !flag?(:release) %}
if @development
ex.message
ex.message || Errors::UNKNOWN_PRIVATE_ERROR
else
nil
Errors::UNKNOWN_PRIVATE_ERROR
end
{% else %}
nil
Errors::UNKNOWN_PRIVATE_ERROR
{% end %}
end
end

View File

@ -1,5 +1,9 @@
module Backend::Api::Errors
UNKNOWN_PRIVATE_ERROR = "UNKNOWN_ERROR"
UNKNOWN_PUBLIC_ERROR = "UNKNOWN_PUBLIC_ERROR"
abstract class Error < Exception
abstract def api_message : String
end
abstract class PrivateError < Error
@ -8,6 +12,57 @@ module Backend::Api::Errors
abstract class PublicError < Error
end
class AuthenticationError < PublicError
class SessionExpired < PublicError
def api_message : String
"Session expired"
end
end
class NotAuthenticated < PublicError
def api_message : String
"Not authenticated"
end
end
class Authentication < PublicError
def api_message : String
"Invalid username or password"
end
end
class InvalidPermissions < PublicError
def api_message : String
"Invalid permissions"
end
end
class LdapUserDoesNotExist < PublicError
def api_message : String
"LDAP user does not exist"
end
end
class DuplicateTeachers < PublicError
def api_message : String
"Duplicate teachers"
end
end
class NotEnoughTeachers < PublicError
def api_message : String
"Not enough teachers"
end
end
class TeachersNotRegistered < PublicError
def api_message : String
"Teachers not registered"
end
end
class TeachersNotFound < PublicError
def api_message : String
"Teachers not found"
end
end
end

View File

@ -16,6 +16,8 @@
require "ldap"
require "uuid"
require "uuid/json"
require "random/secure"
module Backend
module Api
@ -25,19 +27,20 @@ module Backend
@[GraphQL::Field]
# Logs in as *username* with credential *password*
def login(username : String, password : String) : LoginPayload
raise Errors::AuthenticationError.new if username.empty? || password.empty?
raise Errors::Authentication.new if username.empty? || password.empty?
user = Db::User.query.find { var(:username) == username }
raise Errors::AuthenticationError.new unless user && Ldap.authenticate?(Ldap::DN.uid(username), password)
raise Errors::Authentication.new unless user && Ldap.authenticate?(Ldap::DN.uid(username), password)
jti = UUID.random
jti = UUID.random(Random::Secure)
LoginPayload.new(
user: User.new(user),
token: Auth::Token.new(
iss: "mentorenwahl",
iss: "Mentorenwahl",
vrs: Backend::VERSION,
iat: Time.utc.to_unix,
exp: (Time.utc + Backend.config.api.jwt_expiration.minutes).to_unix,
jti: jti.hexstring,
jti: jti,
context: Auth::Context.new(user.id.not_nil!)
).encode
)
@ -48,11 +51,11 @@ module Backend
def create_user(context : Context, input : UserCreateInput, check_ldap : Bool = true) : User
context.admin!
raise "LDAP user does not exist" if check_ldap && begin
!Ldap::User.from_username(input.username)
rescue LDAP::Client::AuthError
true
end
raise Errors::LdapUserDoesNotExist.new if check_ldap && begin
!Ldap::User.from_username(input.username)
rescue LDAP::Client::AuthError
true
end
user = Db::User.create!(username: input.username, role: input.role.to_db, admin: input.admin)
Worker::Jobs::CacheLdapUserJob.new(user.id.not_nil!.to_i).enqueue
@ -148,16 +151,16 @@ module Backend
def create_vote(context : Context, input : VoteCreateInput) : Vote
context.student!
raise "Duplicate teachers" if input.teacher_ids.uniq.size != input.teacher_ids.size
raise "Not enough teachers" if input.teacher_ids.size < Backend.config.minimum_teacher_selection_count
raise Errors::DuplicateTeachers.new if input.teacher_ids.uniq.size != input.teacher_ids.size
raise Errors::NotEnoughTeachers.new if input.teacher_ids.size < Backend.config.minimum_teacher_selection_count
teacher_role_count = Db::User.query.where(role: Db::UserRole::Teacher).count
raise "Teachers not registered" if teacher_role_count != Db::Teacher.query.count || teacher_role_count.zero?
raise Errors::TeachersNotRegistered.new if teacher_role_count != Db::Teacher.query.count || teacher_role_count.zero?
input.teacher_ids.each do |id|
teacher = Db::Teacher.find(id)
if teacher.nil?
raise "Teachers not found"
raise Errors::TeachersNotFound.new
# elsif teacher.user.skif != context.user.not_nil!.skif
# if teacher.user.skif
# raise "Teacher is SKIF, student is not"

View File

@ -125,6 +125,14 @@ module Backend
students > 0 && votes >= students
end
@[GraphQL::Field]
# Students can vote
def students_can_vote : Bool
teacher_role_count = Db::User.query.where(role: Db::UserRole::Teacher).count
teacher_role_count > 0 && teacher_role_count == Db::Teacher.query.count
end
@[GraphQL::Field]
# Teacher vote by ID
def teacher_vote(context : Context, id : Int32) : TeacherVote

View File

@ -56,7 +56,7 @@ module Backend
if ex
raise ex
else
raise Exception.new unless Backend.config.db.allow_old_schema
raise "Database schema is not up to date" unless Backend.config.db.allow_old_schema
end
end
end

View File

@ -33,7 +33,7 @@ module Backend
@[ARTA::Get("")]
def playground : ATH::Response
ATH::StreamedResponse.new(headers: HTTP::Headers{"Content-Type" => "text/html"}) do |io|
ATH::StreamedResponse.new(headers: HTTP::Headers{"Content-Type" => "text/html", "Access-Control-Allow-Origin" => "*"}) do |io|
IO.copy(Public.get("index.html"), io)
end
end

View File

@ -20,7 +20,7 @@ events {
http {
server {
location / {
proxy_pass http://frontend:3000/;
proxy_pass http://frontend/;
}
location /graphql {

View File

@ -104,8 +104,6 @@ services:
- default
depends_on:
- backend
environment:
NODE_ENV: production
networks:
db:

2
frontend/.cargo/config Normal file
View File

@ -0,0 +1,2 @@
[build]
target = "wasm32-unknown-unknown"

View File

@ -1,14 +1,20 @@
.DS_Store
node_modules
yarn-error.log
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
dist/
Dockerfile
.dockerignore
.gitignore
README.md
.nvmrc
.dockerignore
vendor/

26
frontend/.gitignore vendored
View File

@ -1,9 +1,17 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
yarn-error.log
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
dist/
vendor/

View File

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

19
frontend/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "frontend"
version = "0.1.0"
edition = "2021"
[dependencies]
yew = "0.19.3"
wasm-logger = "0.2.0"
log = "0.4.6"
yew-router = "0.16.0"
wee_alloc = "0.4.5"
graphql_client = { version = "0.11.0", features = ["reqwest"] }
reqwest = "0.11.12"
wasm-bindgen-futures = "0.4.33"
serde = "1.0.147"
web-sys = { version = "0.3.60", features = ["Window", "Location"] }
wasm-cookies = "0.1.0"
lazy_static = "1.4.0"
const_format = "0.2.30"

View File

@ -1,8 +1,26 @@
FROM node:16-alpine
FROM lukemathwalker/cargo-chef:latest-rust-1.65.0 as chef
WORKDIR /usr/src/frontend
COPY ./package.json ./yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN yarn build
EXPOSE 3000
CMD ["yarn", "preview", "--host"]
FROM chef as planner
WORKDIR /usr/src/frontend
RUN mkdir src && touch src/main.rs
COPY ./Cargo.toml .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
WORKDIR /usr/local/bin
ARG TRUNK_VERSION="v0.16.0"
RUN wget -qO- https://github.com/thedodd/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-
WORKDIR /usr/src/frontend
RUN rustup target add wasm32-unknown-unknown
COPY ./.cargo ./.cargo
COPY --from=planner /usr/src/frontend/recipe.json .
RUN cargo chef cook --release --recipe-path recipe.json
COPY ./index.html .
COPY ./graphql ./graphql
COPY ./src ./src
RUN trunk build --release
FROM nginx:alpine as runner
COPY ./nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /usr/src/frontend/dist /var/www/html

View File

@ -0,0 +1,3 @@
query Ok {
ok
}

View File

@ -0,0 +1,92 @@
type Config {
minimumTeacherSelectionCount: Int!
}
type Query {
admins: [User!]!
allStudentsVoted: Boolean!
config: Config!
me: User!
ok: Boolean!
student(id: Int!): Student!
students: [Student!]!
studentsCanVote: Boolean!
teacher(id: Int!): Teacher!
teacherVote(id: Int!): TeacherVote!
teacherVotes: [TeacherVote!]!
teachers: [Teacher!]!
user(id: Int!): User!
userByUsername(username: String!): User!
users: [User!]!
vote(id: Int!): Vote!
votes: [Vote!]!
}
type Student {
id: Int!
user: User!
vote: Vote
}
type Teacher {
id: Int!
maxStudents: Int!
teacherVotes: [TeacherVote!]!
user: User!
}
type TeacherVote {
id: Int!
priority: Int!
teacher: Teacher!
vote: Vote!
}
type User {
admin: Boolean!
email: String!
externalId: Int!
firstName: String!
id: Int!
lastName: String!
name(formal: Boolean! = true): String!
role: UserRole!
student: Student
teacher: Teacher
username: String!
}
enum UserRole {
Student
Teacher
}
type Vote {
id: Int!
student: Student!
teacherVotes: [TeacherVote!]!
}
type LoginPayload {
bearer: String!
token: String!
user: User!
}
type Mutation {
assignStudents: Boolean!
createUser(checkLdap: Boolean! = true, input: UserCreateInput!): User!
createVote(input: VoteCreateInput!): Vote!
deleteUser(id: Int!): Int!
login(password: String!, username: String!): LoginPayload!
}
input UserCreateInput {
username: String!
role: UserRole!
admin: Boolean! = false
}
input VoteCreateInput {
teacherIds: [Int!]!
}

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
</body>
</html>

37
frontend/nginx.conf Normal file
View File

@ -0,0 +1,37 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
keepalive_timeout 65;
#gzip on;
server {
listen 80;
root /var/www/html;
location / {
try_files $uri /index.html;
}
}
}

View File

@ -0,0 +1,4 @@
use const_format::concatcp;
pub const BASE: &str = "mentorenwahl_";
pub const TOKEN: &str = concatcp!(BASE, "token");

View File

@ -0,0 +1,13 @@
use lazy_static::lazy_static;
use std::path::Path;
pub mod queries;
lazy_static! {
pub static ref URL: String =
Path::new(&web_sys::window().unwrap().location().origin().unwrap())
.join("graphql")
.to_str()
.unwrap()
.to_string();
}

View File

@ -0,0 +1,9 @@
use graphql_client::GraphQLQuery;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "graphql/schema.graphql",
query_path = "graphql/queries/ok.graphql",
response_derives = "Debug"
)]
pub struct Ok;

View File

@ -0,0 +1,56 @@
use graphql_client::reqwest::post_graphql;
use wasm_bindgen_futures;
use yew::prelude::*;
use yew_router::prelude::*;
use crate::cookie_names;
use crate::graphql;
use crate::routes;
#[derive(Properties, PartialEq)]
pub struct MainProps {
#[prop_or_default]
pub children: Children,
}
#[function_component(Main)]
pub fn main(props: &MainProps) -> Html {
let client = reqwest::Client::new();
wasm_bindgen_futures::spawn_local(async move {
let response = post_graphql::<graphql::queries::Ok, _>(
&client,
graphql::URL.as_str(),
graphql::queries::ok::Variables {},
)
.await
.unwrap();
log::debug!("{:?}", response);
log::debug!("{:?}", wasm_cookies::get(cookie_names::TOKEN));
});
let history = use_history().unwrap();
let loginout_onclick = Callback::once(move |_| history.push(routes::Route::Login));
html! {
<>
<nav>
<ul>
<li>
<Link<routes::Route> to={routes::Route::Home}>
<button>{ "Home" }</button>
</Link<routes::Route>>
</li>
<li>
<button onclick={loginout_onclick}>{ "Login/Logout" }</button>
</li>
</ul>
</nav>
<hr />
<main>
{ for props.children.iter() }
</main>
</>
}
}

View File

@ -0,0 +1 @@
pub mod main;

5
frontend/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod components;
pub mod cookie_names;
pub mod graphql;
pub mod layouts;
pub mod routes;

22
frontend/src/main.rs Normal file
View File

@ -0,0 +1,22 @@
use yew::prelude::*;
use yew_router::prelude::*;
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use frontend::routes;
#[function_component(App)]
fn app() -> Html {
html! {
<BrowserRouter>
<Switch<routes::Route> render={Switch::render(routes::switch)} />
</BrowserRouter>
}
}
fn main() {
wasm_logger::init(wasm_logger::Config::default());
yew::start_app::<App>();
}

View File

@ -0,0 +1,8 @@
use yew::prelude::*;
#[function_component(Home)]
pub fn home() -> Html {
html! {
<h1>{ "HOME!" }</h1>
}
}

View File

@ -0,0 +1,8 @@
use yew::prelude::*;
#[function_component(Login)]
pub fn login() -> Html {
html! {
<h1>{ "LOGIN!" }</h1>
}
}

View File

@ -0,0 +1,29 @@
use yew::prelude::*;
use yew_router::prelude::*;
use crate::layouts;
pub mod home;
pub mod login;
pub mod not_found;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home,
#[at("/login")]
Login,
#[not_found]
#[at("/404")]
NotFound,
}
pub fn switch(routes: &Route) -> Html {
match routes {
Route::Home => html! { <layouts::main::Main><home::Home /></layouts::main::Main> },
Route::Login => html! { <layouts::main::Main><login::Login /></layouts::main::Main> },
Route::NotFound => {
html! { <layouts::main::Main><not_found::NotFound /></layouts::main::Main> }
}
}
}

View File

@ -0,0 +1,8 @@
use yew::prelude::*;
#[function_component(NotFound)]
pub fn not_found() -> Html {
html! {
<h1>{ "404" }</h1>
}
}

View File

@ -0,0 +1,12 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
yarn-error.log
Dockerfile
.gitignore
.dockerignore

9
frontend_old/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
yarn-error.log

22
frontend_old/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:16-alpine as deps
WORKDIR /usr/src/frontend
COPY ./package.json ./yarn.lock ./
RUN yarn install --frozen-lockfile
FROM node:16-alpine as builder
WORKDIR /usr/src/frontend
COPY --from=deps /usr/src/frontend/package.json .
COPY --from=deps /usr/src/frontend/node_modules ./node_modules
COPY svelte.config.js tsconfig.json ./
COPY ./static ./static
COPY ./src ./src
RUN yarn build
FROM node:16-alpine as runner
WORKDIR /usr/src/frontend
COPY --from=deps /usr/src/frontend/package.json .
COPY --from=deps /usr/src/frontend/node_modules ./node_modules
COPY svelte.config.js .
COPY --from=builder /usr/src/frontend/.svelte-kit ./.svelte-kit
EXPOSE 3000
CMD [ "yarn", "preview", "--host" ]

View File

@ -12,7 +12,7 @@
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-auto": "^1.0.0-next.86",
"@sveltejs/kit": "next",
"@types/cookie": "^0.4.1",
"@typescript-eslint/eslint-plugin": "^5.10.1",

View File

@ -1,4 +1,4 @@
import type { RequestEvent, App, ResolveOpts } from "@sveltejs/kit/types";
import type { RequestEvent, ResolveOpts } from "@sveltejs/kit";
import * as cookie from "cookie";
import * as cookieNames from "$lib/cookieNames";
@ -8,7 +8,8 @@ export async function handle(input: {
opts?: ResolveOpts;
resolve(event: RequestEvent, opts?: ResolveOpts): Promise<Response>;
}): Promise<Response> {
const cookies = cookie.parse(input.event.request.headers.get("cookie") || "");
const header = input.event.request.headers.get("cookie");
const cookies = header ? cookie.parse(header) : {};
const token: string | undefined = cookies[cookieNames.TOKEN];
input.event.locals = {

View File

@ -0,0 +1 @@
<h3>STUDENT!</h3>

View File

@ -0,0 +1 @@
<h3>TEACHER!</h3>

View File

@ -17,11 +17,12 @@
<script lang="ts">
import { operationStore, query, gql, mutation } from "@urql/svelte";
import * as svelteForms from "svelte-forms";
import * as validators from "svelte-forms/validators";
// import * as svelteForms from "svelte-forms";
// import * as validators from "svelte-forms/validators";
import type { User, Teacher, Student } from "$lib/graphql";
import type { User } from "$lib/graphql";
import { UserRole } from "$lib/graphql";
import StudentHome from "$lib/StudentHome.svelte";
interface Data {
me: User;
@ -175,5 +176,6 @@
<p>Registrierung erfolgreich!</p>
{/if}
{/if} -->
<StudentHome />
{/if}
{/if}

View File

2085
frontend_old/test.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -27,5 +27,5 @@
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte", "src/lib/cookieNames.ts"]
}

File diff suppressed because it is too large Load Diff