From 5169cad8ef4c9cab75e9c6079b064bebf7d59ee6 Mon Sep 17 00:00:00 2001 From: Antoine Pelletier Date: Sun, 23 Aug 2026 17:47:31 +0200 Subject: [PATCH] feat: add bikes, reservations, users tables --- .env | 2 +- .vscode/settings.json | 5 + Cargo.lock | 41 ++- Cargo.toml | 5 +- README.md | 69 ++-- config.example.yml | 2 +- db/migrations/20260101000000_create_items.sql | 10 - db/migrations/20260823153300_create_users.sql | 28 ++ db/migrations/20260823153310_create_bikes.sql | 22 ++ .../20260823153320_create_reservations.sql | 73 ++++ db/schema.sql | 327 ++++++++++++++++- db/seed.sql | 43 ++- frontend/index.html | 2 +- frontend/package.json | 2 +- frontend/public/cargobike-icon.png | Bin 0 -> 5147 bytes frontend/public/favicon.ico | Bin 4286 -> 15086 bytes frontend/public/logo-agep.png | Bin 0 -> 6540 bytes frontend/src/lib/api.d.ts | 49 +-- frontend/src/locales/en.yml | 5 +- frontend/src/locales/fr.yml | 5 +- frontend/src/services/api/items.ts | 39 -- frontend/src/utils/types.ts | 8 +- frontend/src/views/HomeView.vue | 22 +- rename.sh | 56 --- src/api/auth.rs | 180 ++++++++++ src/api/docs.rs | 4 - src/api/items.rs | 29 -- src/api/mod.rs | 2 - src/core/controller/authn.rs | 141 ++++++++ src/core/controller/bikes.rs | 52 +++ src/core/controller/items.rs | 19 - src/core/controller/mod.rs | 21 +- src/core/controller/reservations.rs | 104 ++++++ src/core/controller/users.rs | 32 ++ src/core/models/bike.rs | 34 ++ src/core/models/item.rs | 15 - src/core/models/mod.rs | 5 +- src/core/models/reservation.rs | 92 +++++ src/core/models/unit.rs | 1 + src/core/models/user.rs | 28 ++ src/core/repositories/bikes_repository.rs | 16 + src/core/repositories/items_repository.rs | 8 - src/core/repositories/mod.rs | 14 +- .../repositories/oidc_states_repository.rs | 15 + .../repositories/reservations_repository.rs | 37 ++ src/core/repositories/users_repository.rs | 18 + src/main.rs | 15 +- src/services/database/bikes.rs | 162 +++++++++ src/services/database/items.rs | 46 --- src/services/database/mod.rs | 8 +- src/services/database/oidc_states.rs | 78 ++++ src/services/database/reservations.rs | 335 ++++++++++++++++++ src/services/database/users.rs | 168 +++++++++ src/utils/config.rs | 4 +- src/utils/whiskey.rs | 261 ++++++++++++++ 55 files changed, 2361 insertions(+), 398 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 db/migrations/20260101000000_create_items.sql create mode 100644 db/migrations/20260823153300_create_users.sql create mode 100644 db/migrations/20260823153310_create_bikes.sql create mode 100644 db/migrations/20260823153320_create_reservations.sql create mode 100644 frontend/public/cargobike-icon.png create mode 100644 frontend/public/logo-agep.png delete mode 100644 frontend/src/services/api/items.ts delete mode 100755 rename.sh create mode 100644 src/api/auth.rs delete mode 100644 src/api/items.rs create mode 100644 src/core/controller/authn.rs create mode 100644 src/core/controller/bikes.rs delete mode 100644 src/core/controller/items.rs create mode 100644 src/core/controller/reservations.rs create mode 100644 src/core/controller/users.rs create mode 100644 src/core/models/bike.rs delete mode 100644 src/core/models/item.rs create mode 100644 src/core/models/reservation.rs create mode 100644 src/core/models/unit.rs create mode 100644 src/core/models/user.rs create mode 100644 src/core/repositories/bikes_repository.rs delete mode 100644 src/core/repositories/items_repository.rs create mode 100644 src/core/repositories/oidc_states_repository.rs create mode 100644 src/core/repositories/reservations_repository.rs create mode 100644 src/core/repositories/users_repository.rs create mode 100644 src/services/database/bikes.rs delete mode 100644 src/services/database/items.rs create mode 100644 src/services/database/oidc_states.rs create mode 100644 src/services/database/reservations.rs create mode 100644 src/services/database/users.rs create mode 100644 src/utils/whiskey.rs diff --git a/.env b/.env index f9a79d5..d5cf572 100644 --- a/.env +++ b/.env @@ -1,2 +1,2 @@ # This file is used by dbmate, and by the sqlx macros at compile time. -DATABASE_URL=postgres://postgres:postgres@localhost:5432/app_template?sslmode=disable +DATABASE_URL=postgres://postgres:postgres@localhost:5432/cargagep?sslmode=disable diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bfc0469 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "i18n-ally.localesPaths": [ + "frontend/src/locales" + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b229547..f5b68eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,25 +60,6 @@ dependencies = [ "libc", ] -[[package]] -name = "app-template" -version = "0.1.0" -dependencies = [ - "aide", - "async-trait", - "axum", - "config", - "schemars", - "serde", - "serde_json", - "sqlx", - "thiserror", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", -] - [[package]] name = "arraydeque" version = "0.5.1" @@ -229,6 +210,26 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cargagep" +version = "0.1.0" +dependencies = [ + "aide", + "async-trait", + "axum", + "chrono", + "config", + "schemars", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", +] + [[package]] name = "cc" version = "1.4.0" @@ -253,6 +254,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -1546,6 +1548,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ + "chrono", "dyn-clone", "indexmap", "ref-cast", diff --git a/Cargo.toml b/Cargo.toml index 225e75e..aca7a08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "app-template" +name = "cargagep" version = "0.1.0" edition = "2024" @@ -13,8 +13,9 @@ aide = { version = "0.15.1", features = [ ] } async-trait = "0.1.89" axum = { version = "0.8.9", features = ["macros"] } +chrono = { version = "0.4.45", default-features = false, features = ["serde", "clock", "std"] } config = "0.15.23" -schemars = "0.9.0" +schemars = { version = "0.9", features = ["chrono04"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sqlx = { version = "0.8.6", features = [ diff --git a/README.md b/README.md index b4d2897..6cb6b03 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ -# App template +# CarGAGEP -Starting point for an AGEPoly web project: a **Rust** backend (axum + sqlx + aide) serving a +Cargo bike reservation service for AGEPoly: a **Rust** backend (axum + sqlx + aide) serving a **Vue 3** frontend (TypeScript + vue-query + shadcn-vue), on **PostgreSQL** with **dbmate** migrations. -The template is deliberately almost empty: one table (`items`), one route -(`GET /api/items`) and one page (home) that displays it. They exist to show how the layers -fit together — rename them, or delete them once your own code is in place. +The data model is in place (users, bikes, reservations) down to the sqlx layer; the api +routes and the frontend views are not written yet. ## What you get @@ -18,30 +17,28 @@ fit together — rename them, or delete them once your own code is in place. - Tailwind 4 and the shadcn-vue components already vendored in `frontend/src/components/ui` - A single binary in production: the backend serves the built frontend -There is **no authentication** in this template: add whatever the project needs. +Authentication is **not wired yet**. Users are mirrored from AGEPoly's OIDC provider +(Whiskey): `users.oidc_sub` is the identity, and `AppController::login` upserts the row from +the claims of the token. Because the units also come from Whiskey, they are stored as plain +text (`units_users.unit_name`, `reservations.unit`) rather than as an enum or a reference +table: a unit unknown to us must never break a login. -## Bootstrap a new project +## Getting started ```bash -cp -r template /path/to/my-project && cd /path/to/my-project -git init - -# 1. Rename the crate and the database (app-template -> my-project) -./rename.sh my-project "My Project" && rm rename.sh - -# 2. Start the development database -cd dev-db && docker compose up -d && cd .. -psql -h localhost -U postgres -c 'CREATE DATABASE my_project' +# 1. Database +cd dev-db && docker compose up -d && cd .. # or use a local postgres +psql -h localhost -U postgres -c 'CREATE DATABASE cargagep' dbmate up psql "$(grep DATABASE_URL .env | cut -d= -f2-)" -f db/seed.sql # optional demo data -# 3. Configure the app +# 2. Configure the app cp config.example.yml config.yml -# 4. Run the backend (port 3000) +# 3. Run the backend (port 3000) cargo run -# 5. Run the frontend (port 5000, proxies /api to the backend) +# 4. Run the frontend (port 5000, proxies /api to the backend) cd frontend && npm install && npm run dev ``` @@ -103,7 +100,9 @@ their OpenAPI documentation sits right next to them (`fn *_docs`). 6. `src/api/things.rs`: the handlers and their docs, mounted in `src/api/mod.rs` 7. `cd frontend && npm run openapi` to regenerate the types, then write the service and the view -The `Item` entity follows exactly these steps: copy it. +Steps 1 to 5 are done for `users`, `bikes` and `reservations`; steps 6 and 7 are not. +Until an api route exists, the whole stack is unused, which is why `src/main.rs` carries a +crate-level `#![allow(dead_code)]` — delete it once the handlers are written. Request bodies are best kept separate from the domain models (an `api/models.rs` holding the `...Api` structs and their `Into` impls), so that the public contract does not @@ -154,3 +153,33 @@ Rules of thumb: `cargo build --release`, `npm run build`, then point `frontend_dir` at the built `frontend/dist`: the backend serves the static files and falls back on `index.html` so the vue router keeps working on a page reload. Only one process to deploy. + +## Data model + +``` +users ──< units_users a user belongs to several units (from Whiskey) + │ + ├──< reservations_users >── reservations ──< reservations_bikes >── bikes + └──── reservations.requester_id +``` + +A reservation moves through a state machine, enforced in +`ReservationsController::set_reservation_status` and documented on +`core::models::reservation::ReservationStatus`: + +``` +requested ──▶ refused + │ + ▼ +approved ──▶ cancelled + │ ▲ + ▼ │ + ongoing ────────┘ + │ + ▼ +archived +``` + +`refused`, `cancelled` and `archived` are final. A bike is `in_service` or `out_of_service`; +a bike that has ever been booked cannot be deleted (`ON DELETE RESTRICT`), take it out of +service instead. diff --git a/config.example.yml b/config.example.yml index de27a26..067184c 100644 --- a/config.example.yml +++ b/config.example.yml @@ -9,7 +9,7 @@ postgres: port: 5432 user: postgres password: postgres - name: app_template + name: cargagep # Directory containing the built frontend (frontend/dist after `npm run build`) frontend_dir: frontend/dist diff --git a/db/migrations/20260101000000_create_items.sql b/db/migrations/20260101000000_create_items.sql deleted file mode 100644 index 47c35c6..0000000 --- a/db/migrations/20260101000000_create_items.sql +++ /dev/null @@ -1,10 +0,0 @@ --- migrate:up --- Example table. Localized texts are stored as JSONB: {"fr": "...", "en": "..."} -CREATE TABLE items ( - id SERIAL PRIMARY KEY, - "name" JSONB NOT NULL, - "description" JSONB NOT NULL -); - --- migrate:down -DROP TABLE IF EXISTS items; diff --git a/db/migrations/20260823153300_create_users.sql b/db/migrations/20260823153300_create_users.sql new file mode 100644 index 0000000..621bcc3 --- /dev/null +++ b/db/migrations/20260823153300_create_users.sql @@ -0,0 +1,28 @@ +-- migrate:up + +-- Users come from the OIDC provider (Whiskey): `oidc_sub` is the stable identity, +-- and a user row is created/refreshed on every login (see `upsert_user`). +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + external_id TEXT UNIQUE, + firstname TEXT NOT NULL, + "name" TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + oidc_sub TEXT NOT NULL UNIQUE, + admin BOOLEAN NOT NULL DEFAULT FALSE +); + +-- The units a user belongs to. The list is provided by Whiskey, so the unit +-- identifiers are plain text rather than a fixed enum or a reference table: +-- a unit that appears in the provider must not break a login. +CREATE TABLE units_users ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + unit_name TEXT NOT NULL, + PRIMARY KEY (user_id, unit_name) +); + +CREATE INDEX units_users_unit_name_idx ON units_users (unit_name); + +-- migrate:down +DROP TABLE IF EXISTS units_users; +DROP TABLE IF EXISTS users; diff --git a/db/migrations/20260823153310_create_bikes.sql b/db/migrations/20260823153310_create_bikes.sql new file mode 100644 index 0000000..c09e4a9 --- /dev/null +++ b/db/migrations/20260823153310_create_bikes.sql @@ -0,0 +1,22 @@ +-- migrate:up + +-- A bike is either available for reservation, or withdrawn from the fleet +-- (maintenance, damage, ...). The set of states is fixed by the app, so an enum +-- type is the right fit here. +CREATE TYPE bike_status AS ENUM ('in_service', 'out_of_service'); + +CREATE TABLE bikes ( + id SERIAL PRIMARY KEY, + "name" TEXT NOT NULL, + -- Identifier written on the keys, and how many of them exist for this bike + key_number TEXT, + key_quantity INTEGER NOT NULL DEFAULT 0, + drivetrain TEXT, + battery TEXT, + status bike_status NOT NULL DEFAULT 'in_service', + CONSTRAINT bikes_key_quantity_positive CHECK (key_quantity >= 0) +); + +-- migrate:down +DROP TABLE IF EXISTS bikes; +DROP TYPE IF EXISTS bike_status; diff --git a/db/migrations/20260823153320_create_reservations.sql b/db/migrations/20260823153320_create_reservations.sql new file mode 100644 index 0000000..7fa8e07 --- /dev/null +++ b/db/migrations/20260823153320_create_reservations.sql @@ -0,0 +1,73 @@ +-- migrate:up + +-- Lifecycle of a reservation: +-- +-- requested ──▶ refused +-- │ +-- ▼ +-- approved ──▶ cancelled +-- │ ▲ +-- ▼ │ +-- ongoing ────────┘ +-- │ +-- ▼ +-- archived +-- +-- `refused`, `cancelled` and `archived` are final. The transitions are enforced +-- in the controller (`ReservationStatus::can_transition_to`), not by a trigger, +-- so the rule stays with the rest of the business logic. +CREATE TYPE reservation_status AS ENUM ( + 'requested', + 'refused', + 'approved', + 'cancelled', + 'ongoing', + 'archived' +); + +CREATE TABLE reservations ( + id SERIAL PRIMARY KEY, + -- Exactly one unit borrows the bikes. Free text for the same reason as + -- `units_users.unit_name`: the value comes from Whiskey. + unit TEXT NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + -- Who filed the request; also part of `reservations_users` + requester_id INTEGER NOT NULL REFERENCES users (id), + -- Telegram handle to reach the group, stored with its leading '@' + telegram TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + status reservation_status NOT NULL DEFAULT 'requested', + CONSTRAINT reservations_time_order CHECK (end_time > start_time), + CONSTRAINT reservations_telegram_handle CHECK ( + telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$' + ) +); + +CREATE INDEX reservations_unit_idx ON reservations (unit); +CREATE INDEX reservations_status_idx ON reservations (status); +CREATE INDEX reservations_period_idx ON reservations (start_time, end_time); + +-- People allowed to pick the bikes up for this reservation +CREATE TABLE reservations_users ( + reservation_id INTEGER NOT NULL REFERENCES reservations (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + PRIMARY KEY (reservation_id, user_id) +); + +-- Bikes booked by this reservation. A bike that has been booked cannot be +-- deleted (ON DELETE RESTRICT): take it out of the fleet with +-- `status = 'out_of_service'` instead. +CREATE TABLE reservations_bikes ( + reservation_id INTEGER NOT NULL REFERENCES reservations (id) ON DELETE CASCADE, + bike_id INTEGER NOT NULL REFERENCES bikes (id) ON DELETE RESTRICT, + PRIMARY KEY (reservation_id, bike_id) +); + +CREATE INDEX reservations_bikes_bike_id_idx ON reservations_bikes (bike_id); + +-- migrate:down +DROP TABLE IF EXISTS reservations_bikes; +DROP TABLE IF EXISTS reservations_users; +DROP TABLE IF EXISTS reservations; +DROP TYPE IF EXISTS reservation_status; diff --git a/db/schema.sql b/db/schema.sql index a514af1..f2912e8 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,7 @@ \restrict dbmate --- Dumped from database version 18.4 --- Dumped by pg_dump version 18.4 +-- Dumped from database version 18.6 +-- Dumped by pg_dump version 18.6 SET statement_timeout = 0; SET lock_timeout = 0; @@ -15,26 +15,55 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: bike_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.bike_status AS ENUM ( + 'in_service', + 'out_of_service' +); + + +-- +-- Name: reservation_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.reservation_status AS ENUM ( + 'requested', + 'refused', + 'approved', + 'cancelled', + 'ongoing', + 'archived' +); + + SET default_tablespace = ''; SET default_table_access_method = heap; -- --- Name: items; Type: TABLE; Schema: public; Owner: - +-- Name: bikes; Type: TABLE; Schema: public; Owner: - -- -CREATE TABLE public.items ( +CREATE TABLE public.bikes ( id integer NOT NULL, - name jsonb NOT NULL, - description jsonb NOT NULL + name text NOT NULL, + key_number text, + key_quantity integer DEFAULT 0 NOT NULL, + drivetrain text, + battery text, + status public.bike_status DEFAULT 'in_service'::public.bike_status NOT NULL, + CONSTRAINT bikes_key_quantity_positive CHECK ((key_quantity >= 0)) ); -- --- Name: items_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- Name: bikes_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- -CREATE SEQUENCE public.items_id_seq +CREATE SEQUENCE public.bikes_id_seq AS integer START WITH 1 INCREMENT BY 1 @@ -44,10 +73,68 @@ CREATE SEQUENCE public.items_id_seq -- --- Name: items_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- Name: bikes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- -ALTER SEQUENCE public.items_id_seq OWNED BY public.items.id; +ALTER SEQUENCE public.bikes_id_seq OWNED BY public.bikes.id; + + +-- +-- Name: reservations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reservations ( + id integer NOT NULL, + unit text NOT NULL, + start_time timestamp with time zone NOT NULL, + end_time timestamp with time zone NOT NULL, + requester_id integer NOT NULL, + telegram text NOT NULL, + description text DEFAULT ''::text NOT NULL, + status public.reservation_status DEFAULT 'requested'::public.reservation_status NOT NULL, + CONSTRAINT reservations_telegram_handle CHECK ((telegram ~ '^@[A-Za-z][A-Za-z0-9_]{4,31}$'::text)), + CONSTRAINT reservations_time_order CHECK ((end_time > start_time)) +); + + +-- +-- Name: reservations_bikes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reservations_bikes ( + reservation_id integer NOT NULL, + bike_id integer NOT NULL +); + + +-- +-- Name: reservations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.reservations_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: reservations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id; + + +-- +-- Name: reservations_users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.reservations_users ( + reservation_id integer NOT NULL, + user_id integer NOT NULL +); -- @@ -60,18 +147,101 @@ CREATE TABLE public.schema_migrations ( -- --- Name: items id; Type: DEFAULT; Schema: public; Owner: - +-- Name: units_users; Type: TABLE; Schema: public; Owner: - -- -ALTER TABLE ONLY public.items ALTER COLUMN id SET DEFAULT nextval('public.items_id_seq'::regclass); +CREATE TABLE public.units_users ( + user_id integer NOT NULL, + unit_name text NOT NULL +); -- --- Name: items items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- Name: users; Type: TABLE; Schema: public; Owner: - -- -ALTER TABLE ONLY public.items - ADD CONSTRAINT items_pkey PRIMARY KEY (id); +CREATE TABLE public.users ( + id integer NOT NULL, + external_id text, + firstname text NOT NULL, + name text NOT NULL, + email text NOT NULL, + oidc_sub text NOT NULL, + admin boolean DEFAULT false NOT NULL +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.users_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + + +-- +-- Name: bikes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bikes ALTER COLUMN id SET DEFAULT nextval('public.bikes_id_seq'::regclass); + + +-- +-- Name: reservations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations ALTER COLUMN id SET DEFAULT nextval('public.reservations_id_seq'::regclass); + + +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + + +-- +-- Name: bikes bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.bikes + ADD CONSTRAINT bikes_pkey PRIMARY KEY (id); + + +-- +-- Name: reservations_bikes reservations_bikes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_bikes + ADD CONSTRAINT reservations_bikes_pkey PRIMARY KEY (reservation_id, bike_id); + + +-- +-- Name: reservations reservations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations + ADD CONSTRAINT reservations_pkey PRIMARY KEY (id); + + +-- +-- Name: reservations_users reservations_users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_users + ADD CONSTRAINT reservations_users_pkey PRIMARY KEY (reservation_id, user_id); -- @@ -82,6 +252,129 @@ ALTER TABLE ONLY public.schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +-- +-- Name: units_users units_users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.units_users + ADD CONSTRAINT units_users_pkey PRIMARY KEY (user_id, unit_name); + + +-- +-- Name: users users_email_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_email_key UNIQUE (email); + + +-- +-- Name: users users_external_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_external_id_key UNIQUE (external_id); + + +-- +-- Name: users users_oidc_sub_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_oidc_sub_key UNIQUE (oidc_sub); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: reservations_bikes_bike_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_bikes_bike_id_idx ON public.reservations_bikes USING btree (bike_id); + + +-- +-- Name: reservations_period_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_period_idx ON public.reservations USING btree (start_time, end_time); + + +-- +-- Name: reservations_status_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_status_idx ON public.reservations USING btree (status); + + +-- +-- Name: reservations_unit_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX reservations_unit_idx ON public.reservations USING btree (unit); + + +-- +-- Name: units_users_unit_name_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX units_users_unit_name_idx ON public.units_users USING btree (unit_name); + + +-- +-- Name: reservations_bikes reservations_bikes_bike_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_bikes + ADD CONSTRAINT reservations_bikes_bike_id_fkey FOREIGN KEY (bike_id) REFERENCES public.bikes(id) ON DELETE RESTRICT; + + +-- +-- Name: reservations_bikes reservations_bikes_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_bikes + ADD CONSTRAINT reservations_bikes_reservation_id_fkey FOREIGN KEY (reservation_id) REFERENCES public.reservations(id) ON DELETE CASCADE; + + +-- +-- Name: reservations reservations_requester_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations + ADD CONSTRAINT reservations_requester_id_fkey FOREIGN KEY (requester_id) REFERENCES public.users(id); + + +-- +-- Name: reservations_users reservations_users_reservation_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_users + ADD CONSTRAINT reservations_users_reservation_id_fkey FOREIGN KEY (reservation_id) REFERENCES public.reservations(id) ON DELETE CASCADE; + + +-- +-- Name: reservations_users reservations_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.reservations_users + ADD CONSTRAINT reservations_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + +-- +-- Name: units_users units_users_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.units_users + ADD CONSTRAINT units_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + + -- -- PostgreSQL database dump complete -- @@ -94,4 +387,6 @@ ALTER TABLE ONLY public.schema_migrations -- INSERT INTO public.schema_migrations (version) VALUES - ('20260101000000'); + ('20260823153300'), + ('20260823153310'), + ('20260823153320'); diff --git a/db/seed.sql b/db/seed.sql index 16b32de..22b4286 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -2,12 +2,45 @@ -- psql "$DATABASE_URL" -f db/seed.sql BEGIN; -INSERT INTO public.items (id, "name", "description") VALUES - (1, '{"fr": "Premier objet", "en": "First item"}', '{"fr": "Un objet de démonstration.", "en": "A demo item."}'), - (2, '{"fr": "Deuxième objet", "en": "Second item"}', '{"fr": "Un autre objet de démonstration.", "en": "Another demo item."}') +INSERT INTO public.users (id, external_id, firstname, "name", email, oidc_sub, admin) VALUES + (1, '100001', 'Alice', 'Martin', 'alice.martin@epfl.ch', 'oidc-sub-alice', TRUE), + (2, '100002', 'Bob', 'Dupont', 'bob.dupont@epfl.ch', 'oidc-sub-bob', FALSE), + (3, NULL, 'Chloé', 'Favre', 'chloe.favre@epfl.ch', 'oidc-sub-chloe', FALSE) ON CONFLICT DO NOTHING; --- Keep the sequence in sync with the explicit ids inserted above -SELECT setval('public.items_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.items)); +-- Unit names come from Whiskey; these are placeholders for development +INSERT INTO public.units_users (user_id, unit_name) VALUES + (1, 'agepoly'), + (2, 'agepoly'), + (2, 'clic'), + (3, 'clic') +ON CONFLICT DO NOTHING; + +INSERT INTO public.bikes (id, "name", key_number, key_quantity, drivetrain, battery, status) VALUES + (1, 'Cargo 1', 'K-1042', 2, 'Shimano Nexus 8', 'BAT-A12', 'in_service'), + (2, 'Cargo 2', 'K-1043', 2, 'Shimano Nexus 8', 'BAT-A13', 'in_service'), + (3, 'Cargo 3', 'K-1044', 1, 'Rohloff Speedhub', NULL, 'out_of_service') +ON CONFLICT DO NOTHING; + +INSERT INTO public.reservations + (id, unit, start_time, end_time, requester_id, telegram, "description", status) VALUES + (1, 'agepoly', '2026-09-01 08:00:00+02', '2026-09-01 18:00:00+02', 1, '@alice_martin', + 'Transport du matériel pour la rentrée', 'approved'), + (2, 'clic', '2026-09-05 09:00:00+02', '2026-09-06 17:00:00+02', 3, '@chloe_favre', + 'Déménagement du stock de la commission', 'requested') +ON CONFLICT DO NOTHING; + +INSERT INTO public.reservations_users (reservation_id, user_id) VALUES + (1, 1), (1, 2), (2, 3) +ON CONFLICT DO NOTHING; + +INSERT INTO public.reservations_bikes (reservation_id, bike_id) VALUES + (1, 1), (1, 2), (2, 1) +ON CONFLICT DO NOTHING; + +-- Keep the sequences in sync with the explicit ids inserted above +SELECT setval('public.users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.users)); +SELECT setval('public.bikes_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.bikes)); +SELECT setval('public.reservations_id_seq', (SELECT COALESCE(MAX(id), 1) FROM public.reservations)); COMMIT; diff --git a/frontend/index.html b/frontend/index.html index 714f888..2b77d5c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,7 +9,7 @@ href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css" /> - App template + CarGAGEP
diff --git a/frontend/package.json b/frontend/package.json index 12863a1..0e5c4d2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "app-template-frontend", + "name": "cargagep-frontend", "version": "0.0.0", "private": true, "type": "module", diff --git a/frontend/public/cargobike-icon.png b/frontend/public/cargobike-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3db57ccee176c5f5bdf9bec0e605626043d82070 GIT binary patch literal 5147 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 diff --git a/frontend/public/logo-agep.png b/frontend/public/logo-agep.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a0926e723aa4717e4a7608478ea97cc1d2dd55 GIT binary patch literal 6540 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 export interface components { - schemas: { - Item: { - description: components['schemas']['LocalizedString'] - /** Format: int32 */ - id: number - name: components['schemas']['LocalizedString'] - } - /** @description Translated string, stored as a JSONB column in postgres */ - LocalizedString: { - en: string - fr: string - } - } + schemas: never responses: never parameters: never requestBodies: never diff --git a/frontend/src/locales/en.yml b/frontend/src/locales/en.yml index 7e05917..1b03e30 100644 --- a/frontend/src/locales/en.yml +++ b/frontend/src/locales/en.yml @@ -1,12 +1,9 @@ locale: en app: - title: App template + title: CarGAGEP sidebar: navigation: Navigation home: Home home: welcome: Welcome! version: 'Backend version: {version}' - items: Items - items-empty: No item yet. - items-error: Unable to load the items. diff --git a/frontend/src/locales/fr.yml b/frontend/src/locales/fr.yml index 862a314..1a57ace 100644 --- a/frontend/src/locales/fr.yml +++ b/frontend/src/locales/fr.yml @@ -1,12 +1,9 @@ locale: fr app: - title: App template + title: CarGAGEP sidebar: navigation: Navigation home: Accueil home: welcome: Bienvenue ! version: 'Version du backend : {version}' - items: Objets - items-empty: Aucun objet pour le moment. - items-error: Impossible de charger les objets. diff --git a/frontend/src/services/api/items.ts b/frontend/src/services/api/items.ts deleted file mode 100644 index fdbf815..0000000 --- a/frontend/src/services/api/items.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Example api service: one file per domain area, exposing vue-query hooks. - * Views never call `fetch` themselves, they use these hooks and get caching, - * loading and error states for free. - */ -import { useQuery } from '@tanstack/vue-query' -import { HttpStatus } from 'http-status-ts' -import { computed } from 'vue' - -import { i18n } from '../i18n' -import { getClient } from './client' - -export function useItems() { - const query = useQuery({ - queryKey: ['items'], - staleTime: 3600 * 1000, - queryFn: async () => { - const { data, response } = await getClient().GET('/api/items') - if (response.status === HttpStatus.OK) { - return data - } - throw new Error(`Unexpected status code received: ${response.status}`) - }, - }) - - const locale = i18n.locale - - // Localized fields are resolved here, so the views only deal with strings - return { - ...query, - data: computed(() => - query.data.value?.map((item) => ({ - ...item, - name: item.name[locale.value], - description: item.description[locale.value], - })), - ), - } -} diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 7c50122..023bda7 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -1,5 +1,7 @@ import type { components } from '@/lib/api' -// Shorthands over the generated schemas, so views never import `@/lib/api` directly -export type LocalizedString = components['schemas']['LocalizedString'] -export type Item = components['schemas']['Item'] +// Shorthands over the generated schemas, so views never import `@/lib/api` directly. +// Re-export a type here for every schema the views use, e.g. +// export type Bike = components['schemas']['Bike'] +// once `src/api/` exposes the routes and `npm run openapi` has been run again. +export type Schemas = components['schemas'] diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 3b7bb8e..45720a0 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,34 +1,16 @@ diff --git a/rename.sh b/rename.sh deleted file mode 100755 index b3a49f1..0000000 --- a/rename.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Renames the template to your own project name. -# -# ./rename.sh my-project ["My Project"] -# -# Replaces app-template / app_template / "App template" everywhere they are used -# (crate name, database name, package name, page title, ...). Run it once, right -# after copying the template, then delete this script. -set -euo pipefail - -if [ $# -lt 1 ]; then - echo "usage: $0 [\"Display Name\"]" >&2 - exit 1 -fi - -NAME="$1" -SNAKE="${NAME//-/_}" -DISPLAY="${2:-$NAME}" - -if ! [[ "$NAME" =~ ^[a-z][a-z0-9-]*$ ]]; then - echo "The project name must be lowercase, and may contain digits and dashes." >&2 - exit 1 -fi - -cd "$(dirname "$0")" - -FILES=( - Cargo.toml - Cargo.lock - .env - config.example.yml - config.yml - README.md - src/utils/config.rs - frontend/package.json - frontend/index.html - frontend/src/locales/fr.yml - frontend/src/locales/en.yml -) - -for file in "${FILES[@]}"; do - [ -f "$file" ] || continue - sed -i \ - -e "s/app-template/$NAME/g" \ - -e "s/app_template/$SNAKE/g" \ - -e "s/App template/$DISPLAY/g" \ - "$file" - echo "updated $file" -done - -echo -echo "Done. Next steps:" -echo " - create the database: psql -h localhost -U postgres -c 'CREATE DATABASE $SNAKE'" -echo " - dbmate up" -echo " - cp config.example.yml config.yml" -echo " - rm rename.sh" diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..514fbc6 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,180 @@ +use aide::{ + NoApi, + axum::{ + ApiRouter, + routing::{get_with, post_with}, + }, + transform::TransformOperation, +}; +use axum::{Json, debug_handler, http::StatusCode}; +use axum_login::AuthSession; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error, info}; + +use crate::{ + api::helpers::unexpected_error, + core::{ + controller::{AnonAppController, ControllerError, authn::AuthnControllerError}, + models::user::User, + }, +}; + +pub fn routes() -> ApiRouter { + let mut r = ApiRouter::new() + .api_route("/api/logout", post_with(logout, logout_docs)) + // .api_route("/api/me", get_with(me, me_docs)) + .api_route( + "/api/whiskey/authorize", + get_with(whiskey_authorize, whiskey_authorize_docs), + ) + .api_route( + "/api/whiskey/callback", + post_with(whiskey_callback, whiskey_callback_docs), + ); + + #[cfg(debug_assertions)] + { + r = r.api_route("/api/login", post_with(login_dev, login_dev_docs)) + } + + r +} + +#[debug_handler] +async fn logout( + NoApi(mut auth_session): NoApi>, +) -> Result<(), StatusCode> { + debug!("[HANDLER] logout"); + + if auth_session.user.is_none() { + debug!("[HANDLER] logout failed: no active session"); + return Err(StatusCode::UNAUTHORIZED); + } + + match auth_session.logout().await { + Ok(_) => { + debug!("[HANDLER] logout successful"); + Ok(()) + } + Err(err) => { + error!("[HANDLER] logout failed: {err:?}"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +fn logout_docs(op: TransformOperation) -> TransformOperation { + op.summary("logout the user") + .tag("Auth") + .response::<401, ()>() + .security_requirement("session_cookie") +} + +#[derive(Debug, JsonSchema, Serialize)] +struct GetAuthorizeResponse { + redirect_to: String, +} + +#[debug_handler] +async fn whiskey_authorize( + aac: AnonAppController, +) -> Result, (StatusCode, String)> { + match aac.whiskey_authorize().await { + Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => { + info!("[HANDLER] whiskey_authorize: protocol error"); + Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned())) + } + Err(err) => unexpected_error("whiskey_authorize", err), + Ok(redirect_to) => Ok(Json(GetAuthorizeResponse { redirect_to })), + } +} + +fn whiskey_authorize_docs(op: TransformOperation) -> TransformOperation { + op.summary("Whiskey - Get authorization URL") + .tag("Auth") + .response::<400, ()>() +} + +#[derive(Debug, JsonSchema, Deserialize)] +struct PostCallbackParams { + code: String, + state: String, +} + +#[debug_handler] +async fn whiskey_callback( + aac: AnonAppController, + NoApi(mut auth_session): NoApi>, + Json(PostCallbackParams { code, state }): Json, +) -> Result, (StatusCode, String)> { + match aac.whiskey_callback(code, state).await { + Ok(user) => { + let login_res = auth_session.login(&user).await; + + if let Err(err) = login_res { + error!("[HANDLER] whiskey_callback failed to login the user: {err:?}"); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Unexpected error".to_owned(), + )); + } + + Ok(Json(user.into())) + } + Err(ControllerError::Authn(AuthnControllerError::NotAuthorized)) => { + info!("[HANDLER] whiskey_callback: user not authorized (missing group)"); + Err(( + StatusCode::FORBIDDEN, + "You are not authorized to access this yet".to_owned(), + )) + } + Err(ControllerError::Authn(AuthnControllerError::OIDCProtocolError)) => { + info!("[HANDLER] whiskey_callback: protocol error"); + Err((StatusCode::BAD_REQUEST, "Protocol error".to_owned())) + } + Err(err) => unexpected_error("whiskey_callback", err), + } +} + +fn whiskey_callback_docs(op: TransformOperation) -> TransformOperation { + op.summary("Whiskey - Callback endpoint") + .tag("Auth") + .response::<400, ()>() + .response::<403, ()>() +} + +#[cfg(debug_assertions)] +#[derive(Debug, Deserialize, JsonSchema)] +struct LoginDevForm { + pub user: String, +} + +#[cfg(debug_assertions)] +#[debug_handler] +async fn login_dev( + aac: AnonAppController, + NoApi(mut auth): NoApi>, + Json(form): Json, +) -> Result<(), StatusCode> { + match aac.get_dev_user(form.user).await { + Ok(user) => { + if let Err(err) = auth.login(&user).await { + error!("Login dev: failed to login the user: {err:?}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(()) + } + Err(err) => { + error!("Login dev: failed to get dev user: {err:?}"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +#[cfg(debug_assertions)] +fn login_dev_docs(op: TransformOperation) -> TransformOperation { + op.tag("Auth") + .summary("Login with a dev user") + .description("This function expect only the name of the user. It created the user in db if required and logins the user with that user.") +} diff --git a/src/api/docs.rs b/src/api/docs.rs index 2baf206..c7b83fd 100644 --- a/src/api/docs.rs +++ b/src/api/docs.rs @@ -15,10 +15,6 @@ use axum::{Extension, Json, response::IntoResponse, routing::get}; pub fn api_docs_metadata(api: TransformOpenApi) -> TransformOpenApi { api.title(&format!("{} API documentation", env!("CRATE_NAME"))) .description(format!("Build version: {}", env!("GIT_HASH")).as_str()) - .tag(Tag { - name: "Items".to_owned(), - ..Default::default() - }) .tag(Tag { name: "misc".to_owned(), ..Default::default() diff --git a/src/api/items.rs b/src/api/items.rs deleted file mode 100644 index e807be8..0000000 --- a/src/api/items.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Example route. Copy this file for your own domain areas, and mount it in `mod.rs`. - -use aide::{ - axum::{ApiRouter, routing::get_with}, - transform::TransformOperation, -}; -use axum::{Json, http::StatusCode}; - -use crate::{ - api::helpers::unexpected_error, - core::{controller::AppController, models::item::Item}, -}; - -pub fn routes() -> ApiRouter { - ApiRouter::new().api_route("/", get_with(get_items, get_items_docs)) -} - -#[axum::debug_handler] -async fn get_items(controller: AppController) -> Result>, (StatusCode, String)> { - match controller.get_items().await { - Ok(items) => Ok(Json(items)), - // Every expected error gets its own arm and status code above this one - Err(err) => unexpected_error("get_items", err), - } -} - -fn get_items_docs(op: TransformOperation) -> TransformOperation { - op.tag("Items").summary("Get the list of items") -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 1d4628b..ca3ca31 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -21,7 +21,6 @@ use crate::core::controller::AppController; mod docs; mod helpers; -mod items; pub fn get_router(controller: AppController) -> Router { aide::generate::on_error(|err| error!("aide generated error: {err}")); @@ -36,7 +35,6 @@ pub fn get_router(controller: AppController) -> Router { |op| op.tag("misc").summary("Get app version"), ), ) - .nest_api_service("/api/items", items::routes()) .nest_api_service("/api/docs", docs::routes()) .finish_api_with(&mut api, docs::api_docs_metadata) .layer(Extension(controller)) diff --git a/src/core/controller/authn.rs b/src/core/controller/authn.rs new file mode 100644 index 0000000..32b89f5 --- /dev/null +++ b/src/core/controller/authn.rs @@ -0,0 +1,141 @@ +use axum_login::{AuthUser, AuthnBackend}; +use thiserror::Error; + +use crate::{ + core::{ + controller::{AnonAppController, ControllerError}, + models::user::{User, UserNoId}, + }, + utils::whiskey::{WhiskeyError, authorize, callback}, +}; + +impl AuthUser for User { + type Id = i32; + + fn id(&self) -> Self::Id { + self.id + } + + fn session_auth_hash(&self) -> &[u8] { + &self.oidc_sub.as_bytes() + } +} + +impl AuthnBackend for AnonAppController { + type User = User; + type Credentials = (); + type Error = ControllerError; + + async fn authenticate( + &self, + _creds: Self::Credentials, + ) -> Result, Self::Error> { + Ok(None) + } + + async fn get_user( + &self, + user_id: &axum_login::UserId, + ) -> Result, Self::Error> { + Ok(Some(self.db.get_user(user_id.clone()).await?)) + } +} + +impl super::AnonAppController { + pub async fn whiskey_authorize(&self) -> Result { + match authorize().await { + Ok((redirect_to, authorize_backend_data)) => { + self.db.save_whiskey_data(authorize_backend_data).await?; + Ok(redirect_to) + } + Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn( + AuthnControllerError::OIDCProtocolError, + )), + Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( + "Originated from Whiskey".to_string(), + )), + } + } + + async fn get_or_create_user_oidc( + &self, + sciper: String, + firstname: String, + name: String, + sub: String, + email: String, + ) -> Result { + self.db + .upsert_user(UserNoId { + external_id: Some(sciper), + firstname, + name, + email, + oidc_sub: sub, + units: vec![], //TODO use real units + admin: false, + }) + .await + .map_err(Into::into) + } + + pub async fn whiskey_callback( + &self, + code: String, + state: String, + ) -> Result { + let backend_data = self.db.get_whiskey_data(state.clone()).await?; + match callback(code, state, backend_data).await { + Ok(user_info) => { + self.get_or_create_user_oidc( + user_info.sciper, + user_info.firstname, + user_info.name, + user_info.sub, + user_info.email, + ) + .await + } + Err(WhiskeyError::ProtocolError) => Err(ControllerError::Authn( + AuthnControllerError::OIDCProtocolError, + )), + Err(WhiskeyError::InternalError) => Err(ControllerError::InternalError( + "originated from Whiskey".to_string(), + )), + } + } + + #[cfg(debug_assertions)] + pub async fn get_dev_user(&self, username: String) -> Result { + use crate::core::repositories::RepositoryError; + + let user = self.db.get_user_external_id(username.clone()).await; + let user = if let Err(RepositoryError::NotFound(_)) = user { + use crate::utils::config; + + let user = config::get() + .get_dev_users() + .into_iter() + .find(|u| u.external_id.clone().is_some_and(|u| u == username)) + .ok_or(AuthnControllerError::DevUserNotFound)?; + + self.db.upsert_user(user).await? + } else { + user? + }; + + Ok(user) + } +} + +#[derive(Error, Debug)] +pub enum AuthnControllerError { + #[error("OIDC protocol error")] + OIDCProtocolError, + #[error("Not authenticated")] + NotAuthenticated, + #[error("Not authorized")] + NotAuthorized, + #[error("Dev user does not exists")] + DevUserNotFound, +} diff --git a/src/core/controller/bikes.rs b/src/core/controller/bikes.rs new file mode 100644 index 0000000..2cbee57 --- /dev/null +++ b/src/core/controller/bikes.rs @@ -0,0 +1,52 @@ +use thiserror::Error; + +use crate::core::{ + controller::ControllerError, + models::bike::{Bike, BikeId, BikeStatus, NewBike}, +}; + +impl super::AppController { + pub async fn get_bikes(&self) -> Result, ControllerError> { + self.db.get_bikes().await.map_err(Into::into) + } + + pub async fn get_bike(&self, id: BikeId) -> Result { + self.db.get_bike(id).await.map_err(Into::into) + } + + pub async fn create_bike(&self, bike: NewBike) -> Result { + if bike.key_quantity < 0 || bike.name.trim().is_empty() { + return Err(BikesControllerError::BikeInvalid.into()); + } + self.db.create_bike(bike).await.map_err(Into::into) + } + + pub async fn update_bike(&self, bike: Bike) -> Result<(), ControllerError> { + if bike.key_quantity < 0 || bike.name.trim().is_empty() { + return Err(BikesControllerError::BikeInvalid.into()); + } + if bike == self.db.get_bike(bike.id).await? { + return Ok(()); + } + self.db.update_bike(bike).await.map_err(Into::into) + } + + pub async fn set_bike_status( + &self, + id: BikeId, + status: BikeStatus, + ) -> Result<(), ControllerError> { + self.db + .set_bike_status(id, status) + .await + .map_err(Into::into) + } +} + +#[derive(Error, Debug)] +pub enum BikesControllerError { + #[error("The bike is malformed")] + BikeInvalid, + #[error("The bike appears in a reservation: take it out of service instead of deleting it")] + BikeReserved, +} diff --git a/src/core/controller/items.rs b/src/core/controller/items.rs deleted file mode 100644 index 6387af4..0000000 --- a/src/core/controller/items.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Example business logic. Copy this file for your own domain areas. - -use thiserror::Error; - -use crate::core::{controller::ControllerError, models::item::Item}; - -impl super::AppController { - pub async fn get_items(&self) -> Result, ControllerError> { - self.db.get_items().await.map_err(Into::into) - } -} - -/// Errors specific to this domain area, turned into status codes by the api -#[derive(Error, Debug)] -#[allow(dead_code)] -pub enum ItemsControllerError { - #[error("The item is malformed")] - ItemInvalid, -} diff --git a/src/core/controller/mod.rs b/src/core/controller/mod.rs index cbfc28c..dbbf693 100644 --- a/src/core/controller/mod.rs +++ b/src/core/controller/mod.rs @@ -2,19 +2,21 @@ //! rules of the app. It talks to the outside world through the repository traits, //! so it depends neither on axum nor on sqlx. //! -//! One file per domain area (`items.rs` here), each one adding methods to -//! `AppController` through `impl super::AppController`. +//! One file per domain area, each one adding methods to `AppController` through +//! `impl super::AppController`. use std::sync::Arc; use thiserror::Error; use crate::core::{ - controller::items::ItemsControllerError, + controller::{bikes::BikesControllerError, reservations::ReservationsControllerError}, repositories::{DatabaseRepository, RepositoryError}, }; -pub mod items; +pub mod bikes; +pub mod reservations; +pub mod users; /// Entry point of the business logic. Cheap to clone: handlers get one per request. #[derive(Clone)] @@ -29,23 +31,22 @@ impl AppController { } /// Every error the api may have to translate into a status code. -/// Domain specific errors are nested (`Item`), so each area keeps its own enum. +/// Domain specific errors are nested, so each area keeps its own enum. #[derive(Error, Debug)] -// Skeleton kept for your own logic: the example only performs a read -#[allow(dead_code)] pub enum ControllerError { #[error("Internal error: {0}")] InternalError(String), #[error("Generic repository error")] RepositoryError(#[from] RepositoryError), - #[error("Item specific error: {0}")] - Item(#[from] ItemsControllerError), + #[error("Bike specific error: {0}")] + Bike(#[from] BikesControllerError), + #[error("Reservation specific error: {0}")] + Reservation(#[from] ReservationsControllerError), } impl ControllerError { /// Handy in the handlers: `Err(err) if err.is_not_found() => 404` - #[allow(dead_code)] pub fn is_not_found(&self) -> bool { matches!( self, diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs new file mode 100644 index 0000000..d8dc821 --- /dev/null +++ b/src/core/controller/reservations.rs @@ -0,0 +1,104 @@ +use thiserror::Error; + +use crate::core::{ + controller::ControllerError, + models::{ + bike::BikeStatus, + reservation::{ + NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, + }, + unit::UnitId, + }, +}; + +impl super::AppController { + pub async fn get_reservations(&self) -> Result, ControllerError> { + self.db.get_reservations().await.map_err(Into::into) + } + + pub async fn get_unit_reservations( + &self, + unit: UnitId, + ) -> Result, ControllerError> { + self.db + .get_unit_reservations(unit) + .await + .map_err(Into::into) + } + + pub async fn get_reservation(&self, id: ReservationId) -> Result { + self.db.get_reservation(id).await.map_err(Into::into) + } + + pub async fn create_reservation( + &self, + reservation: NewReservation, + ) -> Result { + if !reservation.is_valid() { + return Err(ReservationsControllerError::ReservationInvalid.into()); + } + // A bike out of service cannot be booked + for id in &reservation.bikes { + if self.get_bike(*id).await?.status == BikeStatus::OutOfService { + return Err(ReservationsControllerError::BikeOutOfService(*id).into()); + } + } + self.db + .create_reservation(reservation) + .await + .map_err(Into::into) + } + + pub async fn update_reservation( + &self, + reservation: ReservationEdit, + ) -> Result<(), ControllerError> { + if !reservation.is_valid() { + return Err(ReservationsControllerError::ReservationInvalid.into()); + } + + let current = self.db.get_reservation(reservation.id).await?; + if current.status.is_final() { + return Err(ReservationsControllerError::ReservationFinal(current.status).into()); + } + + self.db + .update_reservation(reservation) + .await + .map_err(Into::into) + } + + pub async fn set_reservation_status( + &self, + id: ReservationId, + status: ReservationStatus, + ) -> Result<(), ControllerError> { + let current = self.db.get_reservation(id).await?.status; + if current == status { + return Ok(()); + } + if !current.can_transition_to(status) { + return Err(ReservationsControllerError::InvalidTransition(current, status).into()); + } + self.db + .set_reservation_status(id, status) + .await + .map_err(Into::into) + } + + pub async fn delete_reservation(&self, id: ReservationId) -> Result<(), ControllerError> { + self.db.delete_reservation(id).await.map_err(Into::into) + } +} + +#[derive(Error, Debug)] +pub enum ReservationsControllerError { + #[error("The reservation is malformed")] + ReservationInvalid, + #[error("A reservation cannot go from {0:?} to {1:?}")] + InvalidTransition(ReservationStatus, ReservationStatus), + #[error("The reservation is {0:?} and cannot be modified any more")] + ReservationFinal(ReservationStatus), + #[error("Bike {0} is out of service and cannot be booked")] + BikeOutOfService(i32), +} diff --git a/src/core/controller/users.rs b/src/core/controller/users.rs new file mode 100644 index 0000000..aa57b03 --- /dev/null +++ b/src/core/controller/users.rs @@ -0,0 +1,32 @@ +//! Users are not created by the app: they are mirrored from the authentication +//! provider on login. The only decision that belongs to us is `admin`. + +use crate::core::{ + controller::ControllerError, + models::user::{NewUser, User, UserId}, +}; + +impl super::AppController { + pub async fn login(&self, user: NewUser) -> Result { + self.db.upsert_user(user).await.map_err(Into::into) + } + + pub async fn get_user(&self, id: UserId) -> Result { + self.db.get_user(id).await.map_err(Into::into) + } + + pub async fn get_user_email(&self, email: String) -> Result { + self.db.get_user_email(email).await.map_err(Into::into) + } + + pub async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result { + self.db + .get_user_oidc_sub(oidc_sub) + .await + .map_err(Into::into) + } + + pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> { + self.db.set_user_admin(id, admin).await.map_err(Into::into) + } +} diff --git a/src/core/models/bike.rs b/src/core/models/bike.rs new file mode 100644 index 0000000..aa372bc --- /dev/null +++ b/src/core/models/bike.rs @@ -0,0 +1,34 @@ +//! The cargo bikes of the fleet. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub type BikeId = i32; + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum BikeStatus { + InService, + OutOfService, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct Bike { + pub id: BikeId, + pub name: String, + pub key_number: Option, + pub key_quantity: i32, + pub drivetrain: Option, + pub battery: Option, + pub status: BikeStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct NewBike { + pub name: String, + pub key_number: Option, + pub key_quantity: i32, + pub drivetrain: Option, + pub battery: Option, + pub status: BikeStatus, +} diff --git a/src/core/models/item.rs b/src/core/models/item.rs deleted file mode 100644 index cf8fb64..0000000 --- a/src/core/models/item.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Example domain model. Rename/duplicate this file for your own entities. - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::core::models::localized_string::LocalizedString; - -pub type ItemId = i32; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct Item { - pub id: ItemId, - pub name: LocalizedString, - pub description: LocalizedString, -} diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs index 9dae50f..8d90518 100644 --- a/src/core/models/mod.rs +++ b/src/core/models/mod.rs @@ -1,2 +1,5 @@ -pub mod item; +pub mod bike; pub mod localized_string; +pub mod reservation; +pub mod unit; +pub mod user; diff --git a/src/core/models/reservation.rs b/src/core/models/reservation.rs new file mode 100644 index 0000000..6b4f894 --- /dev/null +++ b/src/core/models/reservation.rs @@ -0,0 +1,92 @@ +//! Reservations: one unit borrows a set of bikes over a period of time. + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::core::models::{bike::BikeId, unit::UnitId, user::UserId}; + +pub type ReservationId = i32; + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReservationStatus { + Requested, + Refused, + Approved, + Cancelled, + Ongoing, + Archived, +} + +impl ReservationStatus { + pub fn can_transition_to(self, next: Self) -> bool { + use ReservationStatus::*; + matches!( + (self, next), + (Requested, Refused) + | (Requested, Approved) + | (Approved, Cancelled) + | (Approved, Ongoing) + | (Ongoing, Cancelled) + | (Ongoing, Archived) + ) + } + + pub fn is_final(self) -> bool { + use ReservationStatus::*; + matches!(self, Refused | Cancelled | Archived) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct Reservation { + pub id: ReservationId, + pub unit: UnitId, + pub start_time: DateTime, + pub end_time: DateTime, + pub requester: UserId, + pub users: Vec, + pub telegram: String, + pub description: String, + pub bikes: Vec, + pub status: ReservationStatus, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct NewReservation { + pub unit: UnitId, + pub start_time: DateTime, + pub end_time: DateTime, + pub requester: UserId, + pub users: Vec, + pub telegram: String, + pub description: String, + pub bikes: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct ReservationEdit { + pub id: ReservationId, + pub unit: UnitId, + pub start_time: DateTime, + pub end_time: DateTime, + pub users: Vec, + pub telegram: String, + pub description: String, + pub bikes: Vec, +} + +impl NewReservation { + pub fn is_valid(&self) -> bool { + self.end_time > self.start_time + && !self.bikes.is_empty() + && self.users.contains(&self.requester) + } +} + +impl ReservationEdit { + pub fn is_valid(&self) -> bool { + self.end_time > self.start_time && !self.bikes.is_empty() && !self.users.is_empty() + } +} diff --git a/src/core/models/unit.rs b/src/core/models/unit.rs new file mode 100644 index 0000000..8cbab10 --- /dev/null +++ b/src/core/models/unit.rs @@ -0,0 +1 @@ +pub type UnitId = String; diff --git a/src/core/models/user.rs b/src/core/models/user.rs new file mode 100644 index 0000000..45b968c --- /dev/null +++ b/src/core/models/user.rs @@ -0,0 +1,28 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::core::models::unit::UnitId; + +pub type UserId = i32; + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct User { + pub id: UserId, + pub external_id: Option, + pub firstname: String, + pub name: String, + pub email: String, + pub oidc_sub: String, + pub units: Vec, + pub admin: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct NewUser { + pub external_id: Option, + pub firstname: String, + pub name: String, + pub email: String, + pub oidc_sub: String, + pub units: Vec, +} diff --git a/src/core/repositories/bikes_repository.rs b/src/core/repositories/bikes_repository.rs new file mode 100644 index 0000000..fe0c22f --- /dev/null +++ b/src/core/repositories/bikes_repository.rs @@ -0,0 +1,16 @@ +use async_trait::async_trait; + +use crate::core::{ + models::bike::{Bike, BikeId, BikeStatus, NewBike}, + repositories::RepositoryError, +}; + +#[async_trait] +pub trait BikesRepository { + async fn get_bikes(&self) -> Result, RepositoryError>; + async fn get_bike(&self, id: BikeId) -> Result; + async fn create_bike(&self, bike: NewBike) -> Result; + async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError>; + async fn set_bike_status(&self, id: BikeId, status: BikeStatus) -> Result<(), RepositoryError>; + async fn delete_bike(&self, id: BikeId) -> Result<(), RepositoryError>; +} diff --git a/src/core/repositories/items_repository.rs b/src/core/repositories/items_repository.rs deleted file mode 100644 index 82b26bb..0000000 --- a/src/core/repositories/items_repository.rs +++ /dev/null @@ -1,8 +0,0 @@ -use async_trait::async_trait; - -use crate::core::{models::item::Item, repositories::RepositoryError}; - -#[async_trait] -pub trait ItemsRepository { - async fn get_items(&self) -> Result, RepositoryError>; -} diff --git a/src/core/repositories/mod.rs b/src/core/repositories/mod.rs index 2bd5ec1..ede6268 100644 --- a/src/core/repositories/mod.rs +++ b/src/core/repositories/mod.rs @@ -7,13 +7,21 @@ use async_trait::async_trait; use thiserror::Error; -use crate::core::repositories::items_repository::ItemsRepository; +use crate::core::repositories::{ + bikes_repository::BikesRepository, reservations_repository::ReservationsRepository, + users_repository::UsersRepository, +}; -pub mod items_repository; +pub mod bikes_repository; +pub mod reservations_repository; +pub mod users_repository; /// Add every new repository trait here so the controller can use it through `db` #[async_trait] -pub trait DatabaseRepository: ItemsRepository + Send + Sync {} +pub trait DatabaseRepository: + UsersRepository + BikesRepository + ReservationsRepository + Send + Sync +{ +} #[derive(Error, Debug)] pub enum RepositoryError { diff --git a/src/core/repositories/oidc_states_repository.rs b/src/core/repositories/oidc_states_repository.rs new file mode 100644 index 0000000..01aa650 --- /dev/null +++ b/src/core/repositories/oidc_states_repository.rs @@ -0,0 +1,15 @@ +use async_trait::async_trait; + +use crate::{core::repositories::RepositoryError, utils::whiskey}; + +#[async_trait] +pub trait OidcStatesRepository { + async fn get_whiskey_data( + &self, + csrf_token: String, + ) -> Result; + async fn save_whiskey_data( + &self, + data: whiskey::AuthorizeBackendData, + ) -> Result<(), RepositoryError>; +} diff --git a/src/core/repositories/reservations_repository.rs b/src/core/repositories/reservations_repository.rs new file mode 100644 index 0000000..5827632 --- /dev/null +++ b/src/core/repositories/reservations_repository.rs @@ -0,0 +1,37 @@ +use async_trait::async_trait; + +use crate::core::{ + models::{ + reservation::{ + NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, + }, + unit::UnitId, + }, + repositories::RepositoryError, +}; + +#[async_trait] +pub trait ReservationsRepository { + async fn get_reservations(&self) -> Result, RepositoryError>; + async fn get_unit_reservations( + &self, + unit: UnitId, + ) -> Result, RepositoryError>; + async fn get_reservation(&self, id: ReservationId) -> Result; + + async fn create_reservation( + &self, + reservation: NewReservation, + ) -> Result; + + async fn update_reservation(&self, reservation: ReservationEdit) + -> Result<(), RepositoryError>; + + async fn set_reservation_status( + &self, + id: ReservationId, + status: ReservationStatus, + ) -> Result<(), RepositoryError>; + + async fn delete_reservation(&self, id: ReservationId) -> Result<(), RepositoryError>; +} diff --git a/src/core/repositories/users_repository.rs b/src/core/repositories/users_repository.rs new file mode 100644 index 0000000..bea0f9c --- /dev/null +++ b/src/core/repositories/users_repository.rs @@ -0,0 +1,18 @@ +use async_trait::async_trait; + +use crate::core::{ + models::user::{NewUser, User, UserId}, + repositories::RepositoryError, +}; + +#[async_trait] +pub trait UsersRepository { + async fn get_user(&self, id: UserId) -> Result; + async fn get_user_email(&self, email: String) -> Result; + async fn get_user_external_id(&self, external_id: String) -> Result; + async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result; + + async fn upsert_user(&self, user: NewUser) -> Result; + + async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>; +} diff --git a/src/main.rs b/src/main.rs index 6d82fcb..5c13945 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,8 @@ +// The models, repositories and controllers are in place, but no api route is +// mounted on them yet: without this the whole stack is reported as dead code. +// Remove it as soon as `src/api/` exposes the handlers. +#![allow(dead_code)] + use std::sync::Arc; use tower_http::services::{ServeDir, ServeFile}; @@ -32,12 +37,10 @@ async fn main() { // Anything that is not an api route is served from the built frontend, // falling back on index.html so the vue router can handle the path. // (`fallback` and not `not_found_service`, which would force a 404 status) - let app = api::get_router(controller).fallback_service( - ServeDir::new(&config.frontend_dir).fallback(ServeFile::new(format!( - "{}/index.html", - config.frontend_dir - ))), - ); + let app = + api::get_router(controller).fallback_service(ServeDir::new(&config.frontend_dir).fallback( + ServeFile::new(format!("{}/index.html", config.frontend_dir)), + )); let bind_address = config.get_bind_address(); let listener = tokio::net::TcpListener::bind(&bind_address).await.unwrap(); diff --git a/src/services/database/bikes.rs b/src/services/database/bikes.rs new file mode 100644 index 0000000..caf5dc8 --- /dev/null +++ b/src/services/database/bikes.rs @@ -0,0 +1,162 @@ +//! The bike fleet. `bike_status` is a postgres enum: `BikeStatusDB` mirrors the +//! core `BikeStatus` so that sqlx stays out of `core/models`. + +use async_trait::async_trait; +use sqlx::{query, query_as}; + +use crate::{ + core::{ + models::bike::{Bike, BikeId, BikeStatus, NewBike}, + repositories::{RepositoryError, bikes_repository::BikesRepository}, + }, + services::database::SqlxDatabase, +}; + +#[derive(Debug, Clone, Copy, sqlx::Type)] +#[sqlx(type_name = "bike_status", rename_all = "snake_case")] +enum BikeStatusDB { + InService, + OutOfService, +} + +impl From for BikeStatus { + fn from(value: BikeStatusDB) -> Self { + match value { + BikeStatusDB::InService => BikeStatus::InService, + BikeStatusDB::OutOfService => BikeStatus::OutOfService, + } + } +} + +impl From for BikeStatusDB { + fn from(value: BikeStatus) -> Self { + match value { + BikeStatus::InService => BikeStatusDB::InService, + BikeStatus::OutOfService => BikeStatusDB::OutOfService, + } + } +} + +struct BikeDB { + pub id: i32, + pub name: String, + pub key_number: Option, + pub key_quantity: i32, + pub drivetrain: Option, + pub battery: Option, + pub status: BikeStatusDB, +} + +impl From for Bike { + fn from(value: BikeDB) -> Self { + Bike { + id: value.id, + name: value.name, + key_number: value.key_number, + key_quantity: value.key_quantity, + drivetrain: value.drivetrain, + battery: value.battery, + status: value.status.into(), + } + } +} + +#[async_trait] +impl BikesRepository for SqlxDatabase { + async fn get_bikes(&self) -> Result, RepositoryError> { + Ok(query_as!( + BikeDB, + r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery, + status AS "status: BikeStatusDB" + FROM bikes + ORDER BY "name""# + ) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + async fn get_bike(&self, id: BikeId) -> Result { + Ok(query_as!( + BikeDB, + r#"SELECT id, "name", key_number, key_quantity, drivetrain, battery, + status AS "status: BikeStatusDB" + FROM bikes + WHERE id = $1"#, + id + ) + .fetch_one(&self.pool) + .await? + .into()) + } + + async fn create_bike(&self, bike: NewBike) -> Result { + let status: BikeStatusDB = bike.status.into(); + Ok(query_as!( + BikeDB, + r#"INSERT INTO bikes ("name", key_number, key_quantity, drivetrain, battery, status) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, "name", key_number, key_quantity, drivetrain, battery, + status AS "status: BikeStatusDB""#, + bike.name, + bike.key_number, + bike.key_quantity, + bike.drivetrain, + bike.battery, + status as BikeStatusDB + ) + .fetch_one(&self.pool) + .await? + .into()) + } + + async fn update_bike(&self, bike: Bike) -> Result<(), RepositoryError> { + let status: BikeStatusDB = bike.status.into(); + let result = query!( + r#"UPDATE bikes + SET "name" = $2, key_number = $3, key_quantity = $4, drivetrain = $5, + battery = $6, status = $7 + WHERE id = $1"#, + bike.id, + bike.name, + bike.key_number, + bike.key_quantity, + bike.drivetrain, + bike.battery, + status as BikeStatusDB + ) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!("bike {}", bike.id))); + } + Ok(()) + } + + async fn set_bike_status(&self, id: BikeId, status: BikeStatus) -> Result<(), RepositoryError> { + let status: BikeStatusDB = status.into(); + let result = query!( + r#"UPDATE bikes SET status = $2 WHERE id = $1"#, + id, + status as BikeStatusDB + ) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!("bike {id}"))); + } + Ok(()) + } + + async fn delete_bike(&self, id: BikeId) -> Result<(), RepositoryError> { + let result = query!(r#"DELETE FROM bikes WHERE id = $1"#, id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!("bike {id}"))); + } + Ok(()) + } +} diff --git a/src/services/database/items.rs b/src/services/database/items.rs deleted file mode 100644 index f6b9229..0000000 --- a/src/services/database/items.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Example repository implementation: maps the database rows to the core models. -//! Localized columns are JSONB, so they go through `serde_json`. - -use async_trait::async_trait; -use serde_json::Value; -use sqlx::query_as; - -use crate::{ - core::{ - models::item::Item, - repositories::{RepositoryError, items_repository::ItemsRepository}, - }, - services::database::SqlxDatabase, -}; - -/// Database representation of an item. Fields must match the columns of the -/// `items` table, in the same order (requirement of `query_as!` with `SELECT *`). -struct ItemDB { - pub id: i32, - pub name: Value, - pub description: Value, -} - -impl TryFrom for Item { - type Error = RepositoryError; - - fn try_from(value: ItemDB) -> Result { - Ok(Item { - id: value.id, - name: serde_json::from_value(value.name)?, - description: serde_json::from_value(value.description)?, - }) - } -} - -#[async_trait] -impl ItemsRepository for SqlxDatabase { - async fn get_items(&self) -> Result, RepositoryError> { - query_as!(ItemDB, r#"SELECT * FROM items ORDER BY id"#) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(TryInto::try_into) - .collect() - } -} diff --git a/src/services/database/mod.rs b/src/services/database/mod.rs index 09d79e3..c815cf6 100644 --- a/src/services/database/mod.rs +++ b/src/services/database/mod.rs @@ -1,10 +1,12 @@ //! Postgres implementation of the repository traits, using sqlx. //! //! The `query!`/`query_as!` macros check the sql against a real database at compile -//! time: `dev-db` must be up and migrated, or the `.sqlx` offline data must be present -//! (`cargo sqlx prepare`). +//! time: the development database must be up and migrated, or the `.sqlx` offline +//! data must be present (`cargo sqlx prepare`). -mod items; +mod bikes; +mod reservations; +mod users; use async_trait::async_trait; use sqlx::{Pool, Postgres, postgres::PgPoolOptions}; diff --git a/src/services/database/oidc_states.rs b/src/services/database/oidc_states.rs new file mode 100644 index 0000000..a82563e --- /dev/null +++ b/src/services/database/oidc_states.rs @@ -0,0 +1,78 @@ +use async_trait::async_trait; +use sqlx::{prelude::FromRow, query, query_as}; + +use crate::{ + core::repositories::{RepositoryError, oidc_states_repository::OidcStatesRepository}, + services::database::SqlxDatabase, + utils::whiskey, +}; + +#[derive(FromRow)] +struct DBOidcStateData { + key: String, + data: String, +} + +impl TryFrom for DBOidcStateData { + type Error = RepositoryError; + + fn try_from(value: whiskey::AuthorizeBackendData) -> Result { + Ok(DBOidcStateData { + key: value.csrf_token(), + data: serde_json::to_string(&value)?, + }) + } +} + +impl TryInto for DBOidcStateData { + type Error = RepositoryError; + + fn try_into(self) -> Result { + let value: whiskey::AuthorizeBackendData = serde_json::from_str(&self.data)?; + if value.csrf_token() != self.key { + return Err(RepositoryError::TypeConversion( + "key of whiskey authorize backend data doesn't match".to_owned(), + )); + } + Ok(value) + } +} + +#[async_trait] +impl OidcStatesRepository for SqlxDatabase { + async fn get_whiskey_data( + &self, + csrf_token: String, + ) -> Result { + query_as!( + DBOidcStateData, + r#"SELECT + key, + data + FROM oidc_states + WHERE key = $1"#, + csrf_token + ) + .fetch_one(&self.pool) + .await? + .try_into() + } + + // TODO: Expire the data and remove it periodically + async fn save_whiskey_data( + &self, + data: whiskey::AuthorizeBackendData, + ) -> Result<(), RepositoryError> { + let data: DBOidcStateData = data.try_into()?; + query!( + r#"INSERT INTO oidc_states + (key, data) + VALUES ($1, $2)"#, + data.key, + data.data + ) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/src/services/database/reservations.rs b/src/services/database/reservations.rs new file mode 100644 index 0000000..685ad4d --- /dev/null +++ b/src/services/database/reservations.rs @@ -0,0 +1,335 @@ +//! Reservations, with their users and their bikes. +//! +//! The two link tables are read back with `ARRAY(SELECT ...)` subqueries rather +//! than joins, so listing reservations stays a single round trip. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{query, query_as}; + +use crate::{ + core::{ + models::{ + reservation::{ + NewReservation, Reservation, ReservationEdit, ReservationId, ReservationStatus, + }, + unit::UnitId, + }, + repositories::{RepositoryError, reservations_repository::ReservationsRepository}, + }, + services::database::SqlxDatabase, +}; + +#[derive(Debug, Clone, Copy, sqlx::Type)] +#[sqlx(type_name = "reservation_status", rename_all = "snake_case")] +enum ReservationStatusDB { + Requested, + Refused, + Approved, + Cancelled, + Ongoing, + Archived, +} + +impl From for ReservationStatus { + fn from(value: ReservationStatusDB) -> Self { + match value { + ReservationStatusDB::Requested => ReservationStatus::Requested, + ReservationStatusDB::Refused => ReservationStatus::Refused, + ReservationStatusDB::Approved => ReservationStatus::Approved, + ReservationStatusDB::Cancelled => ReservationStatus::Cancelled, + ReservationStatusDB::Ongoing => ReservationStatus::Ongoing, + ReservationStatusDB::Archived => ReservationStatus::Archived, + } + } +} + +impl From for ReservationStatusDB { + fn from(value: ReservationStatus) -> Self { + match value { + ReservationStatus::Requested => ReservationStatusDB::Requested, + ReservationStatus::Refused => ReservationStatusDB::Refused, + ReservationStatus::Approved => ReservationStatusDB::Approved, + ReservationStatus::Cancelled => ReservationStatusDB::Cancelled, + ReservationStatus::Ongoing => ReservationStatusDB::Ongoing, + ReservationStatus::Archived => ReservationStatusDB::Archived, + } + } +} + +struct ReservationDB { + pub id: i32, + pub unit: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub requester_id: i32, + pub telegram: String, + pub description: String, + pub status: ReservationStatusDB, + pub users: Vec, + pub bikes: Vec, +} + +impl From for Reservation { + fn from(value: ReservationDB) -> Self { + Reservation { + id: value.id, + unit: value.unit, + start_time: value.start_time, + end_time: value.end_time, + requester: value.requester_id, + users: value.users, + telegram: value.telegram, + description: value.description, + bikes: value.bikes, + status: value.status.into(), + } + } +} + +impl SqlxDatabase { + async fn set_reservation_links( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + id: ReservationId, + users: &[i32], + bikes: &[i32], + ) -> Result<(), RepositoryError> { + query!( + r#"DELETE FROM reservations_users WHERE reservation_id = $1"#, + id + ) + .execute(&mut **tx) + .await?; + query!( + r#"INSERT INTO reservations_users (reservation_id, user_id) + SELECT $1, UNNEST($2::integer[])"#, + id, + users + ) + .execute(&mut **tx) + .await?; + + query!( + r#"DELETE FROM reservations_bikes WHERE reservation_id = $1"#, + id + ) + .execute(&mut **tx) + .await?; + query!( + r#"INSERT INTO reservations_bikes (reservation_id, bike_id) + SELECT $1, UNNEST($2::integer[])"#, + id, + bikes + ) + .execute(&mut **tx) + .await?; + + Ok(()) + } +} + +#[async_trait] +impl ReservationsRepository for SqlxDatabase { + async fn get_reservations(&self) -> Result, RepositoryError> { + Ok(query_as!( + ReservationDB, + r#"SELECT + r.id, + r.unit, + r.start_time, + r.end_time, + r.requester_id, + r.telegram, + r."description", + r.status AS "status: ReservationStatusDB", + ARRAY( + SELECT user_id FROM reservations_users + WHERE reservation_id = r.id ORDER BY user_id + ) AS "users!", + ARRAY( + SELECT bike_id FROM reservations_bikes + WHERE reservation_id = r.id ORDER BY bike_id + ) AS "bikes!" + FROM reservations r + ORDER BY r.start_time DESC"# + ) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + async fn get_unit_reservations( + &self, + unit: UnitId, + ) -> Result, RepositoryError> { + Ok(query_as!( + ReservationDB, + r#"SELECT + r.id, + r.unit, + r.start_time, + r.end_time, + r.requester_id, + r.telegram, + r."description", + r.status AS "status: ReservationStatusDB", + ARRAY( + SELECT user_id FROM reservations_users + WHERE reservation_id = r.id ORDER BY user_id + ) AS "users!", + ARRAY( + SELECT bike_id FROM reservations_bikes + WHERE reservation_id = r.id ORDER BY bike_id + ) AS "bikes!" + FROM reservations r + WHERE r.unit = $1 + ORDER BY r.start_time DESC"#, + unit + ) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + async fn get_reservation(&self, id: ReservationId) -> Result { + Ok(query_as!( + ReservationDB, + r#"SELECT + r.id, + r.unit, + r.start_time, + r.end_time, + r.requester_id, + r.telegram, + r."description", + r.status AS "status: ReservationStatusDB", + ARRAY( + SELECT user_id FROM reservations_users + WHERE reservation_id = r.id ORDER BY user_id + ) AS "users!", + ARRAY( + SELECT bike_id FROM reservations_bikes + WHERE reservation_id = r.id ORDER BY bike_id + ) AS "bikes!" + FROM reservations r + WHERE r.id = $1"#, + id + ) + .fetch_one(&self.pool) + .await? + .into()) + } + + async fn create_reservation( + &self, + reservation: NewReservation, + ) -> Result { + let mut tx = self.pool.begin().await?; + + // No status here: the column defaults to 'requested', the start of the + // state machine. + let id = query!( + r#"INSERT INTO reservations (unit, start_time, end_time, requester_id, telegram, "description") + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id"#, + reservation.unit, + reservation.start_time, + reservation.end_time, + reservation.requester, + reservation.telegram, + reservation.description + ) + .fetch_one(&mut *tx) + .await? + .id; + + Self::set_reservation_links(&mut tx, id, &reservation.users, &reservation.bikes).await?; + + tx.commit().await?; + + Ok(Reservation { + id, + unit: reservation.unit, + start_time: reservation.start_time, + end_time: reservation.end_time, + requester: reservation.requester, + users: reservation.users, + telegram: reservation.telegram, + description: reservation.description, + bikes: reservation.bikes, + status: ReservationStatus::Requested, + }) + } + + async fn update_reservation( + &self, + reservation: ReservationEdit, + ) -> Result<(), RepositoryError> { + let mut tx = self.pool.begin().await?; + + let result = query!( + r#"UPDATE reservations + SET unit = $2, start_time = $3, end_time = $4, telegram = $5, "description" = $6 + WHERE id = $1"#, + reservation.id, + reservation.unit, + reservation.start_time, + reservation.end_time, + reservation.telegram, + reservation.description + ) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!( + "reservation {}", + reservation.id + ))); + } + + Self::set_reservation_links( + &mut tx, + reservation.id, + &reservation.users, + &reservation.bikes, + ) + .await?; + + tx.commit().await?; + Ok(()) + } + + async fn set_reservation_status( + &self, + id: ReservationId, + status: ReservationStatus, + ) -> Result<(), RepositoryError> { + let status: ReservationStatusDB = status.into(); + let result = query!( + r#"UPDATE reservations SET status = $2 WHERE id = $1"#, + id, + status as ReservationStatusDB + ) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!("reservation {id}"))); + } + Ok(()) + } + + async fn delete_reservation(&self, id: ReservationId) -> Result<(), RepositoryError> { + // The link tables cascade + let result = query!(r#"DELETE FROM reservations WHERE id = $1"#, id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!("reservation {id}"))); + } + Ok(()) + } +} diff --git a/src/services/database/users.rs b/src/services/database/users.rs new file mode 100644 index 0000000..e218c2e --- /dev/null +++ b/src/services/database/users.rs @@ -0,0 +1,168 @@ +use async_trait::async_trait; +use sqlx::{Executor, Postgres, query, query_as}; + +use crate::{ + core::{ + models::{ + unit::UnitId, + user::{NewUser, User, UserId}, + }, + repositories::{RepositoryError, users_repository::UsersRepository}, + }, + services::database::SqlxDatabase, +}; + +struct UserDB { + pub id: i32, + pub external_id: Option, + pub firstname: String, + pub name: String, + pub email: String, + pub oidc_sub: String, + pub admin: bool, +} + +impl UserDB { + fn into_user(self, units: Vec) -> User { + User { + id: self.id, + external_id: self.external_id, + firstname: self.firstname, + name: self.name, + email: self.email, + oidc_sub: self.oidc_sub, + admin: self.admin, + units: units.into_iter().map(|u| u.name).collect(), + } + } +} + +struct UnitIdDB { + pub name: UnitId, +} + +impl SqlxDatabase { + async fn user_with_units<'a, E>(user: UserDB, executor: E) -> Result + where + E: Executor<'a, Database = Postgres>, + { + let units = query_as!( + UnitIdDB, + r#"SELECT unit_name AS "name!" FROM units_users WHERE user_id = $1 ORDER BY unit_name"#, + user.id + ) + .fetch_all(executor) + .await?; + Ok(user.into_user(units)) + } +} + +#[async_trait] +impl UsersRepository for SqlxDatabase { + async fn get_user(&self, id: UserId) -> Result { + let mut tx = self.pool.begin().await?; + let user = query_as!(UserDB, r#"SELECT * FROM users WHERE id = $1"#, id) + .fetch_one(&mut *tx) + .await?; + let user = Self::user_with_units(user, &mut *tx).await?; + tx.commit().await?; + Ok(user) + } + + async fn get_user_email(&self, email: String) -> Result { + let mut tx = self.pool.begin().await?; + let user = query_as!(UserDB, r#"SELECT * FROM users WHERE email = $1"#, email) + .fetch_one(&mut *tx) + .await?; + let user = Self::user_with_units(user, &mut *tx).await?; + tx.commit().await?; + Ok(user) + } + + async fn get_user_external_id(&self, external_id: String) -> Result { + let mut tx = self.pool.begin().await?; + let user = query_as!( + UserDB, + r#"SELECT * FROM users WHERE external_id = $1"#, + external_id + ) + .fetch_one(&mut *tx) + .await?; + let user = Self::user_with_units(user, &mut *tx).await?; + tx.commit().await?; + Ok(user) + } + + async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result { + let mut tx = self.pool.begin().await?; + let user = query_as!( + UserDB, + r#"SELECT * FROM users WHERE oidc_sub = $1"#, + oidc_sub + ) + .fetch_one(&mut *tx) + .await?; + let user = Self::user_with_units(user, &mut *tx).await?; + tx.commit().await?; + Ok(user) + } + + async fn upsert_user(&self, user: NewUser) -> Result { + let mut tx = self.pool.begin().await?; + + let user_db = query_as!( + UserDB, + r#"INSERT INTO users (external_id, firstname, "name", email, oidc_sub) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (oidc_sub) + DO UPDATE SET + external_id = EXCLUDED.external_id, + firstname = EXCLUDED.firstname, + "name" = EXCLUDED.name, + email = EXCLUDED.email + RETURNING *"#, + user.external_id, + user.firstname, + user.name, + user.email, + user.oidc_sub + ) + .fetch_one(&mut *tx) + .await?; + + query!(r#"DELETE FROM units_users WHERE user_id = $1"#, user_db.id) + .execute(&mut *tx) + .await?; + query!( + r#"INSERT INTO units_users (user_id, unit_name) + SELECT $1, UNNEST($2::text[])"#, + user_db.id, + &user.units + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(User { + id: user_db.id, + external_id: user_db.external_id, + firstname: user_db.firstname, + name: user_db.name, + email: user_db.email, + oidc_sub: user_db.oidc_sub, + units: user.units, + admin: user_db.admin, + }) + } + + async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> { + let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound(format!("user {id}"))); + } + Ok(()) + } +} diff --git a/src/utils/config.rs b/src/utils/config.rs index 1dac1a1..63a8113 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -46,12 +46,12 @@ impl AppConfig { /// Loads the configuration, by decreasing priority: /// - `APP__SERVER__PORT=3000` style environment variables /// - `./config.yml` -/// - the file pointed by `$APP_CONFIG` (`/etc/app-template/config.yml` by default) +/// - the file pointed by `$APP_CONFIG` (`/etc/cargagep/config.yml` by default) pub fn get() -> &'static AppConfig { static CONFIG: OnceLock = OnceLock::new(); CONFIG.get_or_init(|| { let config_path = - std::env::var("APP_CONFIG").unwrap_or("/etc/app-template/config.yml".to_owned()); + std::env::var("APP_CONFIG").unwrap_or("/etc/cargagep/config.yml".to_owned()); let config_path = Path::new(&config_path); let raw = Config::builder() diff --git a/src/utils/whiskey.rs b/src/utils/whiskey.rs new file mode 100644 index 0000000..8a9e528 --- /dev/null +++ b/src/utils/whiskey.rs @@ -0,0 +1,261 @@ +use std::sync::OnceLock; + +use openidconnect::{ + AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, + IdTokenFields, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, + RedirectUrl, Scope, StandardTokenResponse, TokenResponse, + core::{ + CoreAuthenticationFlow, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreTokenType, + }, + reqwest, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::sync::OnceCell; +use tracing::{debug, error}; + +use crate::utils::config; + +#[derive(Error, Debug)] +pub enum WhiskeyError { + #[error("Internal error")] + InternalError, + #[error("Protocol error")] + ProtocolError, +} + +fn get_http_client() -> &'static reqwest::Client { + static HTTP_CLIENT: OnceLock = OnceLock::new(); + HTTP_CLIENT.get_or_init(|| { + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("Unable to build async http client") + }) +} + +async fn get_client() -> &'static Client { + static CLIENT: OnceCell = OnceCell::const_new(); + CLIENT + .get_or_init(|| async { + let http_client = get_http_client(); + let config = config::get().clone(); + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(config.oidc.issuer_url).unwrap(), + http_client, + ) + .await + .unwrap(); + + Client::from_provider_metadata( + provider_metadata, + ClientId::new(config.oidc.client_id), + Some(ClientSecret::new(config.oidc.client_secret)), + ) + .set_redirect_uri( + RedirectUrl::new(format!("{}/whiskey/callback", config.server.base_url)).unwrap(), + ) + }) + .await +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthorizeBackendData { + pub csrf_token: String, + pub pkce_verifier: PkceCodeVerifier, + pub nonce: Nonce, +} + +impl AuthorizeBackendData { + pub fn csrf_token(&self) -> String { + self.csrf_token.clone() + } +} + +pub async fn authorize() -> Result<(String, AuthorizeBackendData), WhiskeyError> { + let client = get_client().await; + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (redirect_to, csrf_token, nonce) = client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("openid".to_owned())) + .add_scope(Scope::new("profile".to_owned())) + .add_scope(Scope::new("email".to_owned())) + .set_pkce_challenge(pkce_challenge) + .url(); + + Ok(( + redirect_to.into(), + AuthorizeBackendData { + csrf_token: csrf_token.secret().to_owned(), + pkce_verifier, + nonce, + }, + )) +} + +#[derive(Debug, Serialize, Clone)] +pub struct UserInfoData { + pub sub: String, + pub sciper: String, + pub name: String, + pub firstname: String, + pub email: String, +} + +pub async fn callback( + code: String, + state: String, + backend_data: AuthorizeBackendData, +) -> Result { + let client = get_client().await; + if state != backend_data.csrf_token { + error!("Wrong csrf token"); + return Err(WhiskeyError::ProtocolError); + } + + let token_response = client + .exchange_code(AuthorizationCode::new(code)) + .map_err(|err| { + error!("Unable to build exchange code url: {err:?}"); + WhiskeyError::InternalError + })? + .set_pkce_verifier(backend_data.pkce_verifier) + .request_async(get_http_client()) + .await + .map_err(|err| match err { + openidconnect::RequestTokenError::ServerResponse(err) => { + error!("oidc protocol error: {err:?}"); + WhiskeyError::ProtocolError + } + _ => { + error!("other oidc server error: {err:?}"); + WhiskeyError::InternalError + } + })?; + + let id_token = token_response.id_token().ok_or_else(|| { + error!("missing id_token"); + WhiskeyError::InternalError + })?; + let id_token_verifier = client.id_token_verifier(); + let claims = id_token + .claims(&id_token_verifier, &backend_data.nonce) + .map_err(|err| { + error!("unable to verify claims: {err:?}"); + WhiskeyError::InternalError + })?; + + if let Some(expected_access_token_hash) = claims.access_token_hash() { + let actual_access_token_hash = AccessTokenHash::from_token( + token_response.access_token(), + id_token.signing_alg().map_err(|err| { + error!("invalid signature algorithm in id_token: {err:?}"); + WhiskeyError::InternalError + })?, + id_token.signing_key(&id_token_verifier).map_err(|err| { + error!("invalid signature key in id_token: {err:?}"); + WhiskeyError::InternalError + })?, + ) + .map_err(|err| { + error!("unable to compute access token hash: {err:?}"); + WhiskeyError::InternalError + })?; + if actual_access_token_hash != *expected_access_token_hash { + error!("actual_access_token_hash != expected_access_token_hash"); + return Err(WhiskeyError::ProtocolError); + } + } else { + error!("no hash in claims"); + return Err(WhiskeyError::ProtocolError); + } + + debug!("Whiskey id_token: {id_token:?}"); + + let firstname = claims + .given_name() + .ok_or_else(|| { + error!("missing given_name claim from claims"); + WhiskeyError::InternalError + })? + .get(None) + .ok_or_else(|| { + error!("missing given_name value from claims"); + WhiskeyError::InternalError + })? + .to_string(); + + let name = claims + .family_name() + .ok_or_else(|| { + error!("missing family_name claim from claims"); + WhiskeyError::InternalError + })? + .get(None) + .ok_or_else(|| { + error!("missing family_name value from claims"); + WhiskeyError::InternalError + })? + .to_string(); + let email = claims + .email() + .ok_or_else(|| { + error!("missing email claim from claims"); + WhiskeyError::InternalError + })? + .to_string(); + let sub = claims.subject().to_string(); + + let sciper = claims.additional_claims().sciper.clone(); + + Ok(UserInfoData { + firstname, + name, + sub, + sciper, + email, + }) +} + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub struct WhiskeyClaims { + pub sciper: String, +} + +impl openidconnect::AdditionalClaims for WhiskeyClaims {} + +pub type WhiskeyFields = IdTokenFields< + WhiskeyClaims, + EmptyExtraTokenFields, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, +>; + +pub type WhiskeyTokenResponse = StandardTokenResponse; + +pub type Client = openidconnect::Client< + WhiskeyClaims, + openidconnect::core::CoreAuthDisplay, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJsonWebKey, + openidconnect::core::CoreAuthPrompt, + openidconnect::StandardErrorResponse, + WhiskeyTokenResponse, + openidconnect::core::CoreTokenIntrospectionResponse, + openidconnect::core::CoreRevocableToken, + openidconnect::core::CoreRevocationErrorResponse, + openidconnect::EndpointSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointMaybeSet, + openidconnect::EndpointMaybeSet, +>;