feat: add bikes, reservations, users tables
This commit is contained in:
parent
25b8d726e0
commit
5169cad8ef
55 changed files with 2361 additions and 398 deletions
2
.env
2
.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
|
||||
|
|
|
|||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"i18n-ally.localesPaths": [
|
||||
"frontend/src/locales"
|
||||
]
|
||||
}
|
||||
41
Cargo.lock
generated
41
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
69
README.md
69
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<Domain>` 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
28
db/migrations/20260823153300_create_users.sql
Normal file
28
db/migrations/20260823153300_create_users.sql
Normal file
|
|
@ -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;
|
||||
22
db/migrations/20260823153310_create_bikes.sql
Normal file
22
db/migrations/20260823153310_create_bikes.sql
Normal file
|
|
@ -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;
|
||||
73
db/migrations/20260823153320_create_reservations.sql
Normal file
73
db/migrations/20260823153320_create_reservations.sql
Normal file
|
|
@ -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;
|
||||
327
db/schema.sql
327
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');
|
||||
|
|
|
|||
43
db/seed.sql
43
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;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>App template</title>
|
||||
<title>CarGAGEP</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "app-template-frontend",
|
||||
"name": "cargagep-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
BIN
frontend/public/cargobike-icon.png
Normal file
BIN
frontend/public/cargobike-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/logo-agep.png
Normal file
BIN
frontend/public/logo-agep.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
49
frontend/src/lib/api.d.ts
vendored
49
frontend/src/lib/api.d.ts
vendored
|
|
@ -39,57 +39,10 @@ export interface paths {
|
|||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
'/api/items': {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
/** Get the list of items */
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never
|
||||
header?: never
|
||||
path?: never
|
||||
cookie?: never
|
||||
}
|
||||
requestBody?: never
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown
|
||||
}
|
||||
content: {
|
||||
'application/json': components['schemas']['Item'][]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
put?: never
|
||||
post?: never
|
||||
delete?: never
|
||||
options?: never
|
||||
head?: never
|
||||
patch?: never
|
||||
trace?: never
|
||||
}
|
||||
}
|
||||
export type webhooks = Record<string, never>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -1,34 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* Only page of the template: shows the backend version and the example route.
|
||||
* Replace its content with your own, and add a view per route in `router/index.ts`.
|
||||
* Only page so far: shows the backend version.
|
||||
* Add a view per route in `router/index.ts`.
|
||||
*/
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useItems } from '@/services/api/items'
|
||||
import { useVersion } from '@/services/api/misc'
|
||||
|
||||
const { data: version } = useVersion()
|
||||
const { data: items, isPending, isError } = useItems()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-semibold">{{ $t('home.welcome') }}</h1>
|
||||
<p class="text-muted-foreground text-sm">{{ $t('home.version', { version }) }}</p>
|
||||
|
||||
<h2 class="text-lg font-medium">{{ $t('home.items') }}</h2>
|
||||
<div v-if="isPending" class="flex flex-col gap-2">
|
||||
<Skeleton class="h-20 w-full" />
|
||||
<Skeleton class="h-20 w-full" />
|
||||
</div>
|
||||
<p v-else-if="isError" class="text-destructive">{{ $t('home.items-error') }}</p>
|
||||
<p v-else-if="!items?.length" class="text-muted-foreground">{{ $t('home.items-empty') }}</p>
|
||||
<Card v-for="item in items" v-else :key="item.id">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ item.name }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="text-muted-foreground">{{ item.description }}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
56
rename.sh
56
rename.sh
|
|
@ -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 <project-name> [\"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"
|
||||
180
src/api/auth.rs
Normal file
180
src/api/auth.rs
Normal file
|
|
@ -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<AuthSession<AnonAppController>>,
|
||||
) -> 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<Json<GetAuthorizeResponse>, (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<AuthSession<AnonAppController>>,
|
||||
Json(PostCallbackParams { code, state }): Json<PostCallbackParams>,
|
||||
) -> Result<Json<User>, (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<AuthSession<AnonAppController>>,
|
||||
Json(form): Json<LoginDevForm>,
|
||||
) -> 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.")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<Json<Vec<Item>>, (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")
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
141
src/core/controller/authn.rs
Normal file
141
src/core/controller/authn.rs
Normal file
|
|
@ -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<Option<Self::User>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_user(
|
||||
&self,
|
||||
user_id: &axum_login::UserId<Self>,
|
||||
) -> Result<Option<Self::User>, Self::Error> {
|
||||
Ok(Some(self.db.get_user(user_id.clone()).await?))
|
||||
}
|
||||
}
|
||||
|
||||
impl super::AnonAppController {
|
||||
pub async fn whiskey_authorize(&self) -> Result<String, ControllerError> {
|
||||
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<User, ControllerError> {
|
||||
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<User, ControllerError> {
|
||||
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<User, ControllerError> {
|
||||
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,
|
||||
}
|
||||
52
src/core/controller/bikes.rs
Normal file
52
src/core/controller/bikes.rs
Normal file
|
|
@ -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<Vec<Bike>, ControllerError> {
|
||||
self.db.get_bikes().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_bike(&self, id: BikeId) -> Result<Bike, ControllerError> {
|
||||
self.db.get_bike(id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn create_bike(&self, bike: NewBike) -> Result<Bike, ControllerError> {
|
||||
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,
|
||||
}
|
||||
|
|
@ -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<Vec<Item>, 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
104
src/core/controller/reservations.rs
Normal file
104
src/core/controller/reservations.rs
Normal file
|
|
@ -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<Vec<Reservation>, ControllerError> {
|
||||
self.db.get_reservations().await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_unit_reservations(
|
||||
&self,
|
||||
unit: UnitId,
|
||||
) -> Result<Vec<Reservation>, ControllerError> {
|
||||
self.db
|
||||
.get_unit_reservations(unit)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, ControllerError> {
|
||||
self.db.get_reservation(id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
) -> Result<Reservation, ControllerError> {
|
||||
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),
|
||||
}
|
||||
32
src/core/controller/users.rs
Normal file
32
src/core/controller/users.rs
Normal file
|
|
@ -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<User, ControllerError> {
|
||||
self.db.upsert_user(user).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, id: UserId) -> Result<User, ControllerError> {
|
||||
self.db.get_user(id).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_user_email(&self, email: String) -> Result<User, ControllerError> {
|
||||
self.db.get_user_email(email).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, ControllerError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
34
src/core/models/bike.rs
Normal file
34
src/core/models/bike.rs
Normal file
|
|
@ -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<String>,
|
||||
pub key_quantity: i32,
|
||||
pub drivetrain: Option<String>,
|
||||
pub battery: Option<String>,
|
||||
pub status: BikeStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct NewBike {
|
||||
pub name: String,
|
||||
pub key_number: Option<String>,
|
||||
pub key_quantity: i32,
|
||||
pub drivetrain: Option<String>,
|
||||
pub battery: Option<String>,
|
||||
pub status: BikeStatus,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -1,2 +1,5 @@
|
|||
pub mod item;
|
||||
pub mod bike;
|
||||
pub mod localized_string;
|
||||
pub mod reservation;
|
||||
pub mod unit;
|
||||
pub mod user;
|
||||
|
|
|
|||
92
src/core/models/reservation.rs
Normal file
92
src/core/models/reservation.rs
Normal file
|
|
@ -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<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub requester: UserId,
|
||||
pub users: Vec<UserId>,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub bikes: Vec<BikeId>,
|
||||
pub status: ReservationStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct NewReservation {
|
||||
pub unit: UnitId,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub requester: UserId,
|
||||
pub users: Vec<UserId>,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub bikes: Vec<BikeId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct ReservationEdit {
|
||||
pub id: ReservationId,
|
||||
pub unit: UnitId,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub users: Vec<UserId>,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub bikes: Vec<BikeId>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
1
src/core/models/unit.rs
Normal file
1
src/core/models/unit.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub type UnitId = String;
|
||||
28
src/core/models/user.rs
Normal file
28
src/core/models/user.rs
Normal file
|
|
@ -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<String>,
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub oidc_sub: String,
|
||||
pub units: Vec<UnitId>,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)]
|
||||
pub struct NewUser {
|
||||
pub external_id: Option<String>,
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub oidc_sub: String,
|
||||
pub units: Vec<UnitId>,
|
||||
}
|
||||
16
src/core/repositories/bikes_repository.rs
Normal file
16
src/core/repositories/bikes_repository.rs
Normal file
|
|
@ -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<Vec<Bike>, RepositoryError>;
|
||||
async fn get_bike(&self, id: BikeId) -> Result<Bike, RepositoryError>;
|
||||
async fn create_bike(&self, bike: NewBike) -> Result<Bike, RepositoryError>;
|
||||
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>;
|
||||
}
|
||||
|
|
@ -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<Vec<Item>, RepositoryError>;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
15
src/core/repositories/oidc_states_repository.rs
Normal file
15
src/core/repositories/oidc_states_repository.rs
Normal file
|
|
@ -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<whiskey::AuthorizeBackendData, RepositoryError>;
|
||||
async fn save_whiskey_data(
|
||||
&self,
|
||||
data: whiskey::AuthorizeBackendData,
|
||||
) -> Result<(), RepositoryError>;
|
||||
}
|
||||
37
src/core/repositories/reservations_repository.rs
Normal file
37
src/core/repositories/reservations_repository.rs
Normal file
|
|
@ -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<Vec<Reservation>, RepositoryError>;
|
||||
async fn get_unit_reservations(
|
||||
&self,
|
||||
unit: UnitId,
|
||||
) -> Result<Vec<Reservation>, RepositoryError>;
|
||||
async fn get_reservation(&self, id: ReservationId) -> Result<Reservation, RepositoryError>;
|
||||
|
||||
async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
) -> Result<Reservation, RepositoryError>;
|
||||
|
||||
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>;
|
||||
}
|
||||
18
src/core/repositories/users_repository.rs
Normal file
18
src/core/repositories/users_repository.rs
Normal file
|
|
@ -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<User, RepositoryError>;
|
||||
async fn get_user_email(&self, email: String) -> Result<User, RepositoryError>;
|
||||
async fn get_user_external_id(&self, external_id: String) -> Result<User, RepositoryError>;
|
||||
async fn get_user_oidc_sub(&self, oidc_sub: String) -> Result<User, RepositoryError>;
|
||||
|
||||
async fn upsert_user(&self, user: NewUser) -> Result<User, RepositoryError>;
|
||||
|
||||
async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>;
|
||||
}
|
||||
15
src/main.rs
15
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();
|
||||
|
|
|
|||
162
src/services/database/bikes.rs
Normal file
162
src/services/database/bikes.rs
Normal file
|
|
@ -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<BikeStatusDB> for BikeStatus {
|
||||
fn from(value: BikeStatusDB) -> Self {
|
||||
match value {
|
||||
BikeStatusDB::InService => BikeStatus::InService,
|
||||
BikeStatusDB::OutOfService => BikeStatus::OutOfService,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BikeStatus> 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<String>,
|
||||
pub key_quantity: i32,
|
||||
pub drivetrain: Option<String>,
|
||||
pub battery: Option<String>,
|
||||
pub status: BikeStatusDB,
|
||||
}
|
||||
|
||||
impl From<BikeDB> 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<Vec<Bike>, 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<Bike, RepositoryError> {
|
||||
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<Bike, RepositoryError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ItemDB> for Item {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_from(value: ItemDB) -> Result<Self, Self::Error> {
|
||||
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<Vec<Item>, RepositoryError> {
|
||||
query_as!(ItemDB, r#"SELECT * FROM items ORDER BY id"#)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(TryInto::try_into)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
78
src/services/database/oidc_states.rs
Normal file
78
src/services/database/oidc_states.rs
Normal file
|
|
@ -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<whiskey::AuthorizeBackendData> for DBOidcStateData {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_from(value: whiskey::AuthorizeBackendData) -> Result<Self, Self::Error> {
|
||||
Ok(DBOidcStateData {
|
||||
key: value.csrf_token(),
|
||||
data: serde_json::to_string(&value)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryInto<whiskey::AuthorizeBackendData> for DBOidcStateData {
|
||||
type Error = RepositoryError;
|
||||
|
||||
fn try_into(self) -> Result<whiskey::AuthorizeBackendData, Self::Error> {
|
||||
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<whiskey::AuthorizeBackendData, RepositoryError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
335
src/services/database/reservations.rs
Normal file
335
src/services/database/reservations.rs
Normal file
|
|
@ -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<ReservationStatusDB> 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<ReservationStatus> 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<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub requester_id: i32,
|
||||
pub telegram: String,
|
||||
pub description: String,
|
||||
pub status: ReservationStatusDB,
|
||||
pub users: Vec<i32>,
|
||||
pub bikes: Vec<i32>,
|
||||
}
|
||||
|
||||
impl From<ReservationDB> 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<Vec<Reservation>, 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<Vec<Reservation>, 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<Reservation, 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.id = $1"#,
|
||||
id
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn create_reservation(
|
||||
&self,
|
||||
reservation: NewReservation,
|
||||
) -> Result<Reservation, RepositoryError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
168
src/services/database/users.rs
Normal file
168
src/services/database/users.rs
Normal file
|
|
@ -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<String>,
|
||||
pub firstname: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub oidc_sub: String,
|
||||
pub admin: bool,
|
||||
}
|
||||
|
||||
impl UserDB {
|
||||
fn into_user(self, units: Vec<UnitIdDB>) -> 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<User, RepositoryError>
|
||||
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<User, RepositoryError> {
|
||||
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<User, RepositoryError> {
|
||||
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<User, RepositoryError> {
|
||||
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<User, RepositoryError> {
|
||||
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<User, RepositoryError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppConfig> = 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()
|
||||
|
|
|
|||
261
src/utils/whiskey.rs
Normal file
261
src/utils/whiskey.rs
Normal file
|
|
@ -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<reqwest::Client> = 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<Client> = 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<UserInfoData, WhiskeyError> {
|
||||
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<WhiskeyFields, CoreTokenType>;
|
||||
|
||||
pub type Client = openidconnect::Client<
|
||||
WhiskeyClaims,
|
||||
openidconnect::core::CoreAuthDisplay,
|
||||
openidconnect::core::CoreGenderClaim,
|
||||
openidconnect::core::CoreJweContentEncryptionAlgorithm,
|
||||
openidconnect::core::CoreJsonWebKey,
|
||||
openidconnect::core::CoreAuthPrompt,
|
||||
openidconnect::StandardErrorResponse<openidconnect::core::CoreErrorResponseType>,
|
||||
WhiskeyTokenResponse,
|
||||
openidconnect::core::CoreTokenIntrospectionResponse,
|
||||
openidconnect::core::CoreRevocableToken,
|
||||
openidconnect::core::CoreRevocationErrorResponse,
|
||||
openidconnect::EndpointSet,
|
||||
openidconnect::EndpointNotSet,
|
||||
openidconnect::EndpointNotSet,
|
||||
openidconnect::EndpointNotSet,
|
||||
openidconnect::EndpointMaybeSet,
|
||||
openidconnect::EndpointMaybeSet,
|
||||
>;
|
||||
Loading…
Add table
Reference in a new issue