Compare commits

...
26 Commits
Author SHA1 Message Date
pepaandClaude Opus 5.5 b3795a0b6c Removed unprotected server function exposing bookings, remaining ones require login. Fixes #19
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:43:27 +02:00
pepaandClaude Opus 5.5 91c2a06a3e Customers can view and cancel their booking through a link in the e-mail. Fixes #18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:25:46 +02:00
pepaandClaude Opus 5.5 d610479c52 Added simple editor for the text under the banner, stored HTML is sanitized. Fixes #17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:51:50 +02:00
pepaandClaude Opus 5.5 0f48160fb4 Added layout options for the public booking form. Fixes #16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:27:29 +02:00
pepaandClaude Opus 5.5 5fcea0cf85 Added banner options, logo and image upload with validation to appearance settings. Fixes #15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:14:07 +02:00
pepaandClaude Opus 5.5 9da18a67ac Added customizable colours of the public page with palettes and live preview. Fixes #14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:54:24 +02:00
pepaandClaude Opus 5.5 1a1688d322 Banner style is no longer a generated file, public page gets appearance as CSS variables. Fixes #13
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:00 +02:00
pepaandClaude Opus 5.5 5683acc101 Deployment script removes anonymous volumes together with containers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:53:28 +02:00
pepaandClaude Opus 5.5 e91784e241 Documented distributing the Docker image through the Gitea registry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:35:24 +02:00
pepaandClaude Opus 5.5 0b8ebc4bbb Docker image can be built with Docker 20.10, deployment script probes health itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:48:34 +02:00
pepaandClaude Opus 5.5 6c59c87f27 Deployment supports nginx and PostgreSQL installed on the host, added import of an existing installation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:24:20 +02:00
pepaandClaude Opus 5.5 156fe1a0f4 Added health check and deployment setup for one container per customer. Fixes #12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:34:40 +02:00
pepaandClaude Opus 5.5 c1d998878a Added .dockerignore, Docker image can be built on arm64. Refs #12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:15:15 +02:00
pepaandClaude Opus 5.5 53c043a6b0 Made database connection pool size configurable. Fixes #11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:47:36 +02:00
pepaandClaude Opus 5.5 e097d8ba51 Removed default admin password, the first admin account needs a configured one. Fixes #10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:43:37 +02:00
pepaandClaude Opus 5.5 21b7007d28 Moved uploaded banner and generated styles to a persistent data directory. Fixes #9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:25:57 +02:00
pepaandClaude Opus 5.5 cc21056ed3 Moved config file out of the publicly served directory. Fixes #8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:05:12 +02:00
pepaandClaude Opus 5.5 ed72dc20f9 Added dark theme to admin portal. Fixes #7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:07:29 +02:00
pepaandClaude Opus 5.5 9abf87c34d Fixed styling guidance in CLAUDE.md and frontend rules.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:45:50 +02:00
pepaandClaude Opus 5.5 1d5da2a6bf Fixed show password icon on login page. Fixes #6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:00:54 +02:00
pepaandClaude Opus 5.5 ec73351405 Upgraded Leptos to 0.8. Fixes #1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:51:29 +02:00
pepaandClaude Opus 5.5 01d48ac0e4 Fixed parsing of opening hours with a discount. Fixes #4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:09:44 +02:00
pepaandClaude Opus 5.5 15ee091a0a Fixed missing separator between booking items in mail summary. Fixes #5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:19:33 +02:00
pepaandClaude Opus 5.5 11ace448ff Fixed opening hours validation accepting input the server cannot parse. Fixes #3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:15:41 +02:00
pepaandClaude Opus 5.5 2babe19299 Added unit tests for pure logic. Refs #2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:01:51 +02:00
pepaandClaude Opus 5.5 5dbe7c36b1 Added CLAUDE.md and development rules for Claude Code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:18:35 +02:00
87 changed files with 7717 additions and 1460 deletions
+3
View File
@@ -0,0 +1,3 @@
# spow (proof of work for leptos-captcha) needs the getrandom backend selected for the browser
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']
+62
View File
@@ -0,0 +1,62 @@
---
paths:
- "src/backend/**"
- "src/main.rs"
- "src/error.rs"
- "src/config.rs"
---
# Backend rules
## Server functions
- One file per domain in `src/backend/`. Server-only helpers and their imports go in the `cfg_if! { if #[cfg(feature = "ssr")] { ... }}` block at the top; `#[server]` functions follow below it. Extra server-only imports inside a server fn body are written as local `use` statements.
- Use plain `#[server]` for new functions (a few older ones use `#[server(name = Name, prefix = "/api", endpoint = "name")]`; both end up under `/api`). The positional form with `"Url"` no longer compiles. Server functions are registered by `leptos_routes` in `src/main.rs`; there is no separate `/api` route.
- The current request is `use_context::<leptos_actix::Request>()` (it derefs to `HttpRequest`); extractors go through `leptos_actix::extract().await?`.
- Return `Result<ApiResponse<T>, ServerFnError>`. Expected/business failures are `Ok(ApiResponse::Error("English message".to_string()))` — the client passes that string through `trl()`, so add it to the catalogue. `Err(ServerFnError)` is for real failures (DB down etc.).
- A function used with `<DataForm>` / `<QuestionDialog>` must return `ApiResponse<()>` and take the form entity as a single struct argument; its name is the prefix of the form field names (`company: Company` ↔ `name="company[name]"`).
- For `<DataForm>`, implement `ForValidation` for the generated struct (PascalCase of the fn name) right below the function:
```rust
impl ForValidation for UpdateCompany {
fn entity(&self) -> &dyn Validate { &self.company }
}
```
That only validates in the browser. Anything that matters must be checked again inside the server fn.
## Authorization
- Every server fn that reads or changes non-public data starts with `perm_check!(is_logged_in)` or `perm_check!(is_admin)`; use `user_check!(login)` when a non-admin may only touch their own record. The macros return early with 403 and `ApiResponse::Error`, so they only work in functions returning `Result<ApiResponse<_>, _>`.
- The auth middleware protects only `/admin*` page requests. `/api/*` is not covered by it — a server fn without `perm_check!` is public, whether or not any page calls it. Only the endpoints of the public booking form and of the customer's booking page should be.
- A server fn that does not return `ApiResponse` can't use the macros; it starts with `require_login().await?` (`src/backend/user.rs`).
- The test `every_server_fn_checks_permissions_or_is_listed_as_public` in `src/backend/mod.rs` scans the sources and fails for a server fn that neither checks the user nor is on its `PUBLIC_SERVER_FNS` list. Put a function on the list only when it returns nothing about other people — in particular never a booking `uuid`, which is the secret of the customer's booking page.
- Server-side code must not call a permission-checked server fn to get data for a request of a customer (e.g. mail templates when a booking is made); use the helper underneath (`message_for_type`).
- Plain Actix handlers must check the session themselves.
- Files are uploaded through a multipart server function (`upload_image` in `src/backend/appearance.rs`): `#[server(input = MultipartFormData)]`, called in the browser through `Action::new_local` with a `web_sys::FormData`. The server stores a file under a name it makes itself, checks size and content, and never trusts the name sent by the browser. The multipart codec panics on a request without a boundary; `is_broken_upload` in `src/main.rs` refuses those first — extend it when adding another multipart function.
- HTML that comes from a user and is rendered with `inner_html` (the text under the banner) must go through `sanitize_text` in `src/backend/rich_text.rs` before it is stored. Extend its allow-list together with the editor toolbar, never the other way round.
- The logged-in `User` is stored in the cookie session under `"user"`; read it with `logged_in_user()`.
## Database access
- Get the pool with `get_pool().await?` (and the mailer with `get_mailing().await?`); they come from `AppData` in Actix app data.
- Runtime-checked sqlx only: `query` / `query_as::<_, T>` with `$n` binds. No `query!` macros (there is no `DATABASE_URL`/offline data at build time).
- Always bind values; never format user input into SQL.
- Multi-statement writes go through a transaction (`pool.begin()`, helpers take `&mut Transaction<'_, Postgres>`), as in `create_reservation`.
- The users table is `"user"` — always quoted.
## Data types (`data.rs`)
- Types crossing the server/client boundary live in `src/backend/data.rs` and must compile for WASM: sqlx derives only as `#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]` / `sqlx::Type`.
- `id` fields are private with an `id()` getter; other fields are mostly `pub`.
- Validation uses `validator` 0.16 derive attributes; every `message = "..."` is an English translation key.
- Rust enums mapped to Postgres enums (`SlotType`, `ReservationState`, `MessageType`) must keep variant names identical to the SQL enum labels.
## Errors, logging, mail
- Server-side helpers return `AppError`; add a `From` impl in `src/error.rs` for new error sources (ssr-only ones behind `#[cfg(feature = "ssr")]`). The `From` impls already log the error.
- Log with the `log` macros (`info!`, `warn!`, `error!`), not `println!`.
- Send mail through `Mailer::send_mail` (queued to a background sender thread), never directly from a request. Templates are `message` rows keyed by `MessageType` with `#date#`, `#summary#` and `#link#` placeholders; the link to the booking page is added with `MailMessage::with_link`, built from `get_base_url()`.
- `get_booking` and `cancel_booking` are public on purpose: the uuid of a booking is the secret. They must never return or log more about the customer than the e-mails contain, and must answer an unknown uuid the same way as a malformed one. Anything that changes a booking stays a POST behind a button — mail clients open links on their own.
- Times of bookings are naive local times; compare them with `Local::now().naive_local()`.
- A new `[section]`/key in `config.toml` needs the struct in `src/config.rs` and an entry in `config_sample.toml`.
+15
View File
@@ -0,0 +1,15 @@
---
paths:
- "migrations/**"
- "src/backend/data.rs"
---
# Database rules
- PostgreSQL. Schema changes are plain SQL files in `migrations/`, named `NN_short_name.sql` with the next two-digit number. They are embedded with `sqlx::migrate!()` and applied automatically at server start.
- Never edit or rename a migration that has been committed — sqlx stores a checksum per version and existing installations will refuse to start. Fix things with a new migration.
- Migrations must work on a database that already holds production data: add columns as nullable or with a `DEFAULT`, backfill in the same file if needed.
- `migrate!()` reads the directory at compile time and there is no `build.rs`, so cargo may not notice a new `.sql` file. If a new migration doesn't run, touch `src/main.rs` and rebuild.
- Default rows (admin user, company, message templates, appearance) are seeded in Rust at startup (`create_admin`, `check_*`), not in migrations. A new singleton table or `message_type` value needs the matching seeding code.
- Keep the schema and `src/backend/data.rs` in sync in the same change: column names = struct field names (`SELECT *` + `FromRow`), Postgres enum labels = Rust variant names. A new enum value needs `ALTER TYPE ... ADD VALUE` plus the Rust variant, its `Display` arm and, for `message_type`, the list in `check_messages`.
- Naming: singular snake_case tables (`reservation_sum`, `closing_time`), `id SERIAL PRIMARY KEY`, `"user"` always quoted, money as `NUMERIC(9, 2)` ↔ `rust_decimal::Decimal`, dates/times as `DATE`/`TIME` ↔ `chrono::NaiveDate`/`NaiveTime`.
+54
View File
@@ -0,0 +1,54 @@
---
paths:
- "src/pages/**"
- "src/components/**"
- "src/locales/**"
- "src/app.rs"
- "src/validator.rs"
- "style/**"
- "assets/css/**"
---
# Frontend rules
## Components
- Leptos 0.8 API only (`use leptos::prelude::*`). Components are `#[component]` functions returning `impl IntoView`, one page or dialog per file in `src/pages/`; reusable building blocks go to `src/components/`. Register new files in the respective `mod.rs` (`pub mod` only if used from `app.rs`).
- This code runs on the server (SSR) and in the browser (hydration). Browser-only APIs (`window()`, `web_sys`) must not run during SSR — keep them in event handlers/effects or behind `#[cfg(not(feature = "ssr"))]`. Markup must render the same on both sides or hydration breaks.
- A new admin page needs a `<Route path="admin/..."/>` in `src/app.rs` wrapped in `<AdminPortal>`, and a menu entry in `src/components/admin_portal.rs`. Paths starting with `/admin` are login-protected by the middleware; data access is still guarded by `perm_check!` in the server fn.
## Leptos 0.8 pitfalls
- Views are statically typed: `if`/`else` and `match` arms that return different markup must each end with `.into_any()`.
- Context is not available in event handlers (`on:click` etc.). Call `use_context` in the component body and keep the result — `DialogOpener` and `MenuOpener` do this in `new()`, so create them in a component body, not inside a handler.
- Markup must be valid HTML, or hydration panics in the browser and the whole page stops reacting: no block elements (`<div>`, `<table>`, another `<p>`) inside `<p>`, and `<tr>` only inside `<tbody>`/`<thead>`. The compiler doesn't catch this — load the page with a full reload and check the browser console.
- HTML attributes are typed. Deprecated ones (`align`, `width` on `<table>`) don't compile; use `style`.
- A value used twice in one `view!` (e.g. in an attribute and as a child) must be cloned for the first use.
- Client-side form validation hooks into `<ActionForm>` with `on:submit:capture` and `ev.prevent_default()`; a plain `on:submit` runs too late. `DataForm` already does this.
- `action.value()` is not an `RwSignal`; pass it on as `Signal::derive(move || action.value().get())`.
## Data loading and forms
- Load data with `Resource::new_blocking` / `Resource::new` calling the server fn, rendered inside `<Transition fallback=...>` with `{trl("Loading...")}`. Read the resource in a closure — `{move || res.get().map(...)}` — a plain `{res.get()...}` block is evaluated once and never updates. Use a dialog's `opener.visible()` as the resource source so lists reload after the dialog closes (see `src/pages/users.rs`).
- Handle all three outcomes of a server call: `Err(e)`, `Ok(ApiResponse::Error(msg))`, `Ok(ApiResponse::Data(d))`.
- Edit/create dialogs: `DialogOpener::new()` in the parent, `ServerAction::<ServerFnStruct>::new()` in the dialog, wrapped in `<DataForm opener=.. action=.. title="..">`. It supplies the modal, Save/Close buttons, client validation and server error display. Confirmations use `<QuestionDialog>`.
- Form field names follow the server fn argument: `name="company[name]"`. Pass the id as a hidden input. Fill inputs with `prop:value={move || signal.get().field}`.
- `DataForm`/`ModalDialog` take `title` as an untranslated English key (`&'static str`); they translate it themselves.
## Translations
- Every user-visible string goes through `trl("English text")`; never hard-code Czech (or any text) in a view. The English text is the key and the fallback.
- When you add or change a key, add the Czech translation to the `cs` map in `src/locales/catalogues.rs`. This includes validation `message`s and `ApiResponse::Error` strings from the backend.
- `trl()` works everywhere: in the browser it falls back to the languages of the browser where context is not available (event handlers), on the server it reads `Accept-Language` of the current request — so a text built in a server function (e.g. a sentence put into an e-mail) is in the language of whoever made that request.
- `trl()` returns a closure — use it directly as `{trl("...")}` or attribute value; call it (`trl("...")()`) when a `String` is needed.
- Dates shown to users go through `loc_date()`, weekday names through `show_day()`.
## Styling
- UI is the Bootstrap-based admin template in `assets/vendor/` plus Boxicons (`<i class="bx bx-...">`). Reuse its classes (`card`, `table`, `btn btn-primary`, `form-control`, `row`/`col`) before writing CSS.
- Custom styles go to a CSS file in `assets/css/` and must be linked with `<Link rel="stylesheet">` in `src/components/header.rs` (order matters: after the vendor files to override them). `style/main.scss` is not linked anywhere and has no effect. Never edit `assets/vendor/` (the one app-owned file there is `assets/vendor/css/control.css`).
- The admin portal has a light and a dark theme (`src/components/theme.rs`, switch in the navbar: auto / light / dark, stored in the `theme` cookie). Dark colours are overrides in `assets/css/dark.css`, which `AdminPortal` links with a `media` attribute — the public page and the login page never load it. When you add or restyle something in the admin portal, check it in both themes and add the dark override there; don't hard-code light colours (`bg-light`, `bg-white`, inline `color`) in admin views.
- Charts are drawn on the server, so the chart server fns take a `dark` flag from `Theme::is_dark()`.
- Styles of the public page are in `assets/css/public.css`, scoped by its own classes (`.public-page` wraps the public route only, `.public-banner*`), so they don't leak into the admin portal. The customer's appearance settings reach it as CSS variables in a `style` attribute (`Colors::style()` in `src/backend/color.rs`, `BannerOptions::style()` in `src/backend/banner.rs`, `Appearance::banner_style()` in `src/backend/data.rs`) — and as classes on the wrapper for the layout options (`LayoutOptions::classes()` in `src/backend/layout.rs`); never generate CSS files. A new look option goes the same way: a column with a default equal to the current look, a validated field, a variable or class, a rule in `public.css`, a control in the appearance settings — the markup of the public form stays the same for all values. Every value put into a `style` attribute must be validated first.
- The preview in the appearance settings is a `.public-page.public-preview` element inside the admin portal, styled by the same `public.css`. A rule there must set every property that `dark.css` sets for the same component, otherwise the preview changes with the admin theme. When the public form gets a new element, add it to the preview (`Preview` in `src/pages/appearance_settings.rs`) and check it with a dark palette.
- Stylesheets and meta tags are declared in `src/components/header.rs`.
+23
View File
@@ -0,0 +1,23 @@
# Build output and VCS data are not needed in the image and are huge
/target/
/.git/
node_modules/
# Local configuration with passwords and runtime data must never get into an image layer
/config.toml
/data/
/assets/data/
/Maildir/
# Local tooling and scratch files
/.idea/
/.vscode/
/.settings/
/.project
/.claude/
/html/
/local_history.patch
/end2end/
# Deployment tooling and its secrets (customer configs, backups)
/deploy/
+4
View File
@@ -18,4 +18,8 @@ playwright/.cache/
/.settings/ /.settings/
/.vscode/ /.vscode/
/config.toml /config.toml
/data/
/html/ /html/
/deploy/.env
/deploy/customers/
/deploy/backups/
+72
View File
@@ -0,0 +1,72 @@
# Rezervovator
Booking system for sport/service facilities. Full-stack Rust: Leptos 0.8 (SSR + hydration) on Actix Web, PostgreSQL via sqlx. Crate name is `rezervator`, product name is "Rezervovator".
One crate, compiled twice by cargo-leptos:
- server binary — feature `ssr` (Actix, sqlx, lettre, charts-rs)
- browser WASM — feature `hydrate`, target `wasm32-unknown-unknown`
Every change must compile for both. Server-only code and imports go behind `#[cfg(feature = "ssr")]` / `cfg_if!`.
## Commands
```bash
cargo leptos watch # dev server with reload (needs config.toml + running Postgres)
cargo leptos build --release # production build -> target/release/rezervator + target/site
cargo check --features ssr # type-check the server side
cargo check --lib --features hydrate --target wasm32-unknown-unknown # type-check the WASM side
cargo test --features ssr # unit tests
```
Run both `cargo check` commands and the tests after any change. They don't catch hydration errors, so after changing a view also load the page in a browser with a full reload (the browser caches the old WASM otherwise) and check the console; a plain `cargo check` / `cargo test` (no features) checks neither side properly.
Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[cfg(all(test, feature = "ssr"))]` for ssr-only code). They cover pure logic only — parsing, validation, paging, slot merging, mail templates, config. Nothing touches the database, so server functions, permissions and SQL are untested. Add tests for new pure logic; keep them free of DB and network.
`end2end/` is still the untouched Leptos template (it asserts "Welcome to Leptos") — it does not test this app.
## Running locally
- `config.toml` (gitignored) is read from the working directory, or from `-c <path>`. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`.
- The config file holds secrets and must stay outside the site root (`target/site`), which is served as static files; the server refuses to start otherwise (`is_publicly_served` in `src/config.rs`). In Docker it is mounted to `/app/config/config.toml`.
- Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup.
- First start creates the `admin` user with the password from `REZERVATOR_ADMIN_PASSWORD` or `[admin] initial_password` in the config (no default; the server refuses to start when no admin exists and no password is given). It also seeds a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`).
- With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent.
- `assets/` is copied to `target/site` on build and served as static files.
- Files created at runtime (the uploaded banner and logo) go to the data directory: `data/` in the working directory by default (gitignored), `[storage] data_dir` in the config, `/app/data` volume in Docker. It is served under `/data` and survives rebuilds. Server code gets the path with `get_data_dir()`; never write into `target/site`.
## Layout
- `src/main.rs` — Actix setup: config, pool, migrations, seeding, session + auth middleware, `/api/*` server fns, `/health` (`src/backend/health.rs`).
- `src/app.rs` — `<App/>`, router, shared contexts (`MenuHelper`, `MenuOpener`, `DialogHelper`).
- `src/backend/` — server functions per domain, plus `data.rs` (all DTOs/entities shared by server and client) and `auth_middleware.rs` (redirects unauthenticated `/admin*` page requests to `/login`).
- `src/pages/` — page and dialog components; `src/components/` — reusable pieces (`DataForm`, `QuestionDialog`, `ModalDialog`, `ServerErr`, `ValidationErr`, `AdminPortal`, `Theme` for the light/dark theme of the admin portal).
- `src/backend/banner.rs` — options of the public page banner (`BannerOptions`) and the checks of uploaded images; shared by server and browser.
- `src/backend/rich_text.rs` — the text under the banner: `sanitize_text` reduces its HTML to a few allowed tags before it is stored (it is shown to every visitor), `normalize_link` prepares link addresses.
- `src/backend/cancellation.rs` — cancelling of a booking by the customer: the deadline rule, the link to the booking page and putting it into e-mails.
- `src/backend/layout.rs` — layout options of the public booking form (`LayoutOptions`), which become classes on the page wrapper.
- `src/backend/color.rs` — colours of the public page set by the customer (`Colors`), contrast checks and the ready-made palettes; shared by server and browser.
- `src/locales/` — `trl()` and the translation catalogue.
- `src/error.rs` — `AppError`; `src/config.rs` — `config.toml` structs (ssr only); `src/validator.rs` — client-side form validation helper.
- `deploy/` — running one container per customer on a Docker host: `rezervator.sh` (add, import of an existing installation, upgrade with rollback, backup, restore, remove), `docker-compose.yml` (optional Traefik and PostgreSQL containers), `README.md`. Proxy and PostgreSQL are each either a container or the one on the host (`REZERVATOR_PROXY=traefik|nginx`, `REZERVATOR_POSTGRES=container|host`); a change to the script must work in both. The script relies on the `/health` endpoint, the `public_url` config key, the `TZ` variable, the `REZERVATOR_ADMIN_PASSWORD` variable, the `/app/config` and `/app/data` mounts and the `max_connections` config key — keep them working when changing startup or config code, and test script changes against a real Docker stack with another `REZERVATOR_PREFIX`.
- `assets/vendor/` — third-party admin template (Bootstrap based). Don't edit or reformat it. App styles are plain CSS files in `assets/css/`, linked in `src/components/header.rs`.
- `style/main.scss` is compiled to `/pkg/rezervator.css`, but nothing links that file, so styles written there have no effect.
Routes: `/` public booking form, `/booking/<uuid>` the customer's page of a booking (linked from e-mails, the uuid is the only protection), `/login`, `/admin`, `/admin/settings`, `/admin/mail_settings`, `/admin/bookings`, `/admin/customers`, `/admin/appearance`.
## Domain model
`property` is a bookable thing with a price and slot length. A booking is one `reservation_sum` (date, customer, total price, state `New` → `Approved` / `Canceled`, public `uuid`) with one or more `reservation` rows (property + from/to time). `opening_hour` rows are per weekday, `closing_time` rows are date ranges when booking is closed. Customers are matched by e-mail. The public form is protected by a proof-of-work captcha (`leptos-captcha`).
## Conventions
- Code, comments, identifiers, log messages and commit messages are in English. UI strings are English keys translated at runtime (see `.claude/rules/frontend.md`).
- Commit messages are always written in English, even when the conversation is in Czech: short past-tense sentences ending with a period, e.g. "Added setting for closing days."
- The project has no rustfmt config and the code is not rustfmt-formatted. Don't run `cargo fmt` over existing files; match the surrounding style and keep diffs limited to what you changed.
- Leptos is 0.8 — use the 0.8 API (`use leptos::prelude::*`, `signal()`, `RwSignal::new`, `Resource::new_blocking`, `ServerAction::<T>::new()`), not the 0.6 `create_*` functions.
- `wasm-bindgen` is pinned to an exact version in `Cargo.toml`. The `wasm-bindgen` CLI that cargo-leptos runs must be exactly the same version (`cargo install -f wasm-bindgen-cli --version <same>`), otherwise the front-end build fails with a schema version mismatch. Leptos 0.8 needs at least 0.2.108. Change the pin and the CLI together, never one of them.
- `#![recursion_limit = "512"]` at the top of `src/lib.rs` and `src/main.rs` is required: without it the release build fails with "queries overflow the depth limit".
- `.cargo/config.toml` selects the `getrandom` backend for the WASM target (needed by the captcha's proof of work). `getrandom` and `uuid` have their `js` features enabled in `Cargo.toml` for the same reason; don't remove them.
- Version lives in `Cargo.toml` and is shown in the page footer.
Detailed rules are in `.claude/rules/`: `backend.md`, `frontend.md`, `database.md`.
Generated
+1085 -720
View File
File diff suppressed because it is too large Load Diff
+23 -15
View File
@@ -10,23 +10,26 @@ crate-type = ["cdylib", "rlib"]
actix-files = { version = "0.6.2", optional = true } actix-files = { version = "0.6.2", optional = true }
actix-web = { version = "4.5.1", optional = true, features = ["macros"] } actix-web = { version = "4.5.1", optional = true, features = ["macros"] }
actix-session = { version = "0.10.0", optional = true, features = ["cookie-session"] } actix-session = { version = "0.10.0", optional = true, features = ["cookie-session"] }
actix-multipart = { version = "0.7.2", optional = true }
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
# Used by the multipart codec of server_fn; needed directly to check a request before it gets there
multer = { version = "3", optional = true }
# Sanitizes the HTML of the text shown on the public page
ammonia = { version = "4", optional = true }
cfg-if = "1" cfg-if = "1"
leptos = { version = "0.6.13" } leptos = { version = "0.8.22", features = ["multipart"] }
leptos_meta = { version = "0.6.13" } leptos_meta = { version = "0.8" }
leptos_actix = { version = "0.6.13", optional = true } leptos_actix = { version = "0.8", optional = true }
server_fn = { version = "0.6.13", features = ["multipart"] } server_fn = { version = "0.8", features = ["multipart"] }
leptos_router = { version = "0.6.13" } leptos_router = { version = "0.8" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.117" serde_json = "1.0.117"
wasm-bindgen = "=0.2.100" wasm-bindgen = "=0.2.129"
web-sys = { version = "0.3.69", features = ["Navigator"] } web-sys = { version = "0.3.69", features = ["Navigator", "Blob", "ClipboardEvent", "DataTransfer", "DomRect", "Element", "File", "FileList", "FormData", "HtmlDocument", "HtmlInputElement", "MouseEvent", "Url"] }
lazy_static = "1.4.0" lazy_static = "1.4.0"
chrono = { version = "0.4.38", features = ["serde"]} chrono = { version = "0.4.38", features = ["serde"]}
sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] } sqlx = { version = "0.8.0", optional = true, features = ["runtime-tokio-rustls", "postgres", "chrono", "rust_decimal", "uuid"] }
rust_decimal = "1.35.0" rust_decimal = "1.35.0"
uuid = {version = "1.8.0", features = ["v4", "serde"]} uuid = {version = "1.8.0", features = ["v4", "serde", "js"]}
validator = {version = "0.16.1", features = ["derive"]} validator = {version = "0.16.1", features = ["derive"]}
pwhash = "1.0.0" pwhash = "1.0.0"
futures-util = "0.3.30" futures-util = "0.3.30"
@@ -35,23 +38,26 @@ toml = "0.8.12"
log = "0.4.21" log = "0.4.21"
env_logger = "0.11.3" env_logger = "0.11.3"
getopts = "0.2.21" getopts = "0.2.21"
leptos-use = "0.13.13" leptos-use = "0.19.2"
codee = "0.3"
lettre = {version = "0.11.6", features = ["tokio1-native-tls", "smtp-transport", "file-transport"], optional = true} lettre = {version = "0.11.6", features = ["tokio1-native-tls", "smtp-transport", "file-transport"], optional = true}
leptos-captcha = "0.2.0" leptos-captcha = "0.4.0"
charts-rs = { version = "0.3.5", optional = true} charts-rs = { version = "0.3.5", optional = true}
#image = { version = "0.24.8", optional = true } #image = { version = "0.24.8", optional = true }
base64 = "0.22.0" base64 = "0.22.0"
rand = "0.8.5" # The captcha's proof of work needs the "js" backend when compiled for the browser
getrandom = { version = "0.2", features = ["js"] }
[features] [features]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] csr = ["leptos/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] hydrate = ["leptos/hydrate"]
ssr = [ ssr = [
"dep:actix-files", "dep:actix-files",
"dep:actix-web", "dep:actix-web",
"dep:leptos_actix", "dep:leptos_actix",
"dep:actix-session", "dep:actix-session",
"dep:actix-multipart", "dep:multer",
"dep:ammonia",
"dep:sqlx", "dep:sqlx",
"dep:lettre", "dep:lettre",
"dep:charts-rs", "dep:charts-rs",
@@ -59,6 +65,8 @@ ssr = [
"leptos/ssr", "leptos/ssr",
"leptos_meta/ssr", "leptos_meta/ssr",
"leptos_router/ssr", "leptos_router/ssr",
"leptos-use/ssr",
"leptos-use/actix",
] ]
[profile.wasm-release] [profile.wasm-release]
+21 -7
View File
@@ -2,9 +2,10 @@ FROM rust:1.86.0-bookworm AS builder
# Install cargo-binstall, which makes it easier to install other # Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos # cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz # (uname -m gives x86_64 or aarch64, so the image builds on both architectures)
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-$(uname -m)-unknown-linux-musl.tgz \
RUN cp cargo-binstall /usr/local/cargo/bin && tar -xvf cargo-binstall-$(uname -m)-unknown-linux-musl.tgz \
&& cp cargo-binstall /usr/local/cargo/bin
# Install required tools # Install required tools
RUN apt-get update -y \ RUN apt-get update -y \
@@ -32,7 +33,7 @@ RUN LEPTOS_OUTPUT_NAME="rezervator-$(tr -dc a-z0-9 </dev/urandom | head -c 10)"
FROM debian:bookworm-slim AS runtime FROM debian:bookworm-slim AS runtime
WORKDIR /app WORKDIR /app
RUN apt-get update -y \ RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \ && apt-get install -y --no-install-recommends openssl ca-certificates tzdata \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& apt-get clean -y \ && apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -43,9 +44,22 @@ COPY --from=builder /app/target/release/rezervator /app/
# /target/site contains our JS/WASM/CSS, etc. # /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/target/site COPY --from=builder /app/target/site /app/target/site
# Set any required env variables and # Bookings are in local time: set TZ (e.g. TZ=Europe/Prague), otherwise the clock is UTC
EXPOSE 3000 EXPOSE 3000
# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml -- # The config file holds passwords and the session key. Mount it to /app/config, never under
# /app/target/site, which is served as static files.
RUN mkdir -p /app/config
VOLUME /app/config
# Uploaded banner and logo. Keep this volume when the container is replaced.
RUN mkdir -p /app/data
VOLUME /app/data
# Healthy when the server answers and can reach its database.
# (No --start-interval: it needs Docker 25, and deploy/rezervator.sh probes on its own.)
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["/app/rezervator", "-c", "/app/config/config.toml", "--healthcheck"]
# Run the server # Run the server
CMD ["/app/rezervator", "-c /app/target/site/data/config.toml"] CMD ["/app/rezervator", "-c", "/app/config/config.toml"]
+63
View File
@@ -13,10 +13,73 @@ You will need cargo-leptos tool:
`cargo install cargo-leptos` `cargo install cargo-leptos`
The `wasm-bindgen` command line tool must be exactly the same version as the `wasm-bindgen` dependency pinned in `Cargo.toml`:
`cargo install -f wasm-bindgen-cli --version 0.2.129`
## Running project ## Running project
`cargo leptos watch` `cargo leptos watch`
## Configuration
The server reads `config.toml` from the working directory, or from the path given with `-c`. Use `config_sample.toml` as a template.
The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it.
## Links in e-mails
E-mails about a booking contain a link to a page where the customer can view the booking and cancel it. Set `public_url` in the `[network]` section of the config to the address under which the application is reached from outside (e.g. `https://booking.example.com`). Without it the links use the address of the request that caused the e-mail, as the reverse proxy reports it; that is safe only when the proxy passes requests for your domain only, otherwise somebody could have e-mails sent with links to another site.
In the mail templates the link is written as `#link#`. E-mails for the customer about a new and an approved booking get it at the end when their template does not contain it. How long before its start a booking can be cancelled is set with the company data in the admin portal.
Times of bookings are local times, and the deadline for cancelling is compared with the clock of the server. Make sure the server runs in the time zone of your customers.
## Database connections
Each instance opens database connections on demand, up to `max_connections` in the `[database]` section of the config (10 by default), and closes the ones that stay idle. When many instances share one PostgreSQL server, the sum of their limits must stay below the server's own `max_connections` (100 by default): lower the limit per instance, raise it on the server, or put PgBouncer in front of PostgreSQL.
## First admin account
On the first start, when the database has no admin account, the server creates the user `admin`. Its password must be given, at least 8 characters, in one of these ways:
- the `REZERVATOR_ADMIN_PASSWORD` environment variable, or
- `initial_password` in the `[admin]` section of the config.
Without it the server refuses to start. Once an admin account exists the value is not used any more and should be removed.
Older versions created `admin` with the password `admin`. The server logs a warning on start while that password is still in use.
## Data directory
Files created while the application runs (the uploaded banner and logo) are stored in the data directory and served under `/data`. By default it is `data` in the working directory; change it with `data_dir` in the `[storage]` section of the config. Keep this directory when you upgrade the application and include it in backups. Never put the config file into it.
When upgrading from a version that kept these files in `target/site/data`, move the banner image to the data directory.
## Running in Docker
Mount the config file to `/app/config/config.toml` and a persistent volume for the data directory to `/app/data`:
`docker run -p 3000:3000 -v /path/to/config-dir:/app/config -v rezervator-data:/app/data rezervator`
Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container.
The clock in the container is UTC unless you set the time zone, e.g. `-e TZ=Europe/Prague`. Bookings are in local time, so set it.
Older versions of the image expected the config in `/app/target/site/data/config.toml`, where it could be downloaded from `/data/config.toml`. If you ran such a container, move the file and change the database password, the SMTP password and the session key.
## Health check
`GET /health` returns 200 when the server can reach its database and 503 otherwise. `rezervator --healthcheck` asks the running server on this machine and exits with 0 or 1; the Docker image uses it as its health check.
## Hosting many customers
The application serves one company per instance. `deploy/` contains a Docker Compose setup and a script for running one container per customer behind a reverse proxy, including upgrades, backups and restores. See [deploy/README.md](deploy/README.md).
## Running tests
`cargo test --features ssr`
## Executing a Server on a Remote Machine Without the Toolchain ## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are: After running a `cargo leptos build --release` the minimum files needed are:
-28
View File
@@ -1,28 +0,0 @@
div.header_banner {
height:100px;
clip-path: inset(0 0 0 0);
/*bg-img*/
background-size:cover;
padding:30px;
color: white;
}
div.header_banner_text {
padding: 10px;
}
h1.header_banner {
font-size: x-large;
color: white;
text-shadow: -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000, 3px 3px 0 #000;
}
@media (min-width: 1200px) {
div.header_banner {
height: 200px;
}
h1.header_banner {
font-size: xxx-large;
}
}
+249
View File
@@ -0,0 +1,249 @@
/*
* Dark theme of the admin portal.
*
* The file is linked only from the admin portal (src/components/admin_portal.rs) and switched
* on and off with the media attribute of the link, so the rules are not scoped by any class.
* It overrides colours of the vendor template for the components the admin portal uses.
******************************************************************************/
:root {
color-scheme: dark;
--dark-bg: #232333;
--dark-card: #2b2c40;
--dark-text: #a3a4cc;
--dark-heading: #cbcbe2;
--dark-muted: #7071a4;
--dark-border: #444564;
--dark-hover: rgba(255, 255, 255, 0.06);
--bs-body-bg: var(--dark-bg);
--bs-body-bg-rgb: 35, 35, 51;
--bs-body-color: var(--dark-text);
--bs-body-color-rgb: 163, 164, 204;
}
/* Typography */
h1, .h1, h2, .h2, h3, .h3, h4, .h4, h5, .h5, h6, .h6 {
color: var(--dark-heading);
}
hr {
color: var(--dark-border);
}
.text-muted {
color: var(--dark-muted) !important;
}
/* Layout: side menu, navbar, footer */
.bg-menu-theme,
.bg-navbar-theme {
background-color: var(--dark-card) !important;
color: var(--dark-text);
}
.bg-footer-theme {
background-color: var(--dark-bg) !important;
color: var(--dark-text);
}
.bg-menu-theme .menu-link,
.bg-menu-theme .menu-horizontal-prev,
.bg-menu-theme .menu-horizontal-next,
.bg-navbar-theme .navbar-nav > .nav-link,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link,
.bg-navbar-theme .navbar-nav > .nav > .nav-item > .nav-link {
color: var(--dark-text);
}
.bg-menu-theme .menu-link:hover,
.bg-menu-theme .menu-link:focus,
.bg-navbar-theme .navbar-nav > .nav-link:hover,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link:hover,
.bg-navbar-theme .navbar-nav > .nav-item > .nav-link:focus {
color: var(--dark-heading);
}
.bg-menu-theme .menu-inner > .menu-item:not(.active) > .menu-link:hover {
background-color: var(--dark-hover);
}
.layout-navbar,
.layout-navbar h4 {
color: var(--dark-heading);
}
.layout-menu,
.layout-navbar.navbar-detached {
box-shadow: 0 0 0.375rem 0.25rem rgba(0, 0, 0, 0.2);
}
.layout-overlay {
background: #000;
}
/* Year and month bar on the booking overview */
.filter-bar.bg-light {
background-color: var(--dark-card) !important;
border-radius: 0.5rem;
}
.filter-bar .nav-link {
color: var(--dark-text);
}
/* Cards */
.card {
background-color: var(--dark-card);
border-color: var(--dark-border);
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.2);
}
.card-link.disabled,
a.disabled {
color: var(--dark-border);
}
/* Tables */
.table {
--bs-table-striped-color: var(--dark-text);
--bs-table-striped-bg: rgba(255, 255, 255, 0.03);
--bs-table-active-color: var(--dark-text);
--bs-table-active-bg: rgba(255, 255, 255, 0.08);
--bs-table-hover-color: var(--dark-text);
--bs-table-hover-bg: var(--dark-hover);
color: var(--dark-text);
border-color: var(--dark-border);
}
.table:not(.table-dark) th {
color: var(--dark-heading);
}
.table > :not(:first-child) {
border-top-color: var(--dark-border);
}
/* Forms */
.form-label,
.col-form-label,
.form-check-label {
color: var(--dark-heading);
}
.form-control,
.form-select,
.input-group-text {
color: var(--dark-heading);
background-color: var(--dark-card);
border-color: var(--dark-border);
}
.form-control::placeholder {
color: var(--dark-muted);
}
.form-control:focus,
.form-select:focus {
color: var(--dark-heading);
background-color: var(--dark-card);
border-color: #696cff;
}
.form-control:disabled,
.form-control[readonly],
.form-select:disabled {
background-color: var(--dark-bg);
}
.form-control[type="file"]::file-selector-button {
color: var(--dark-heading);
background-color: var(--dark-bg);
}
.form-control[type="file"]:hover:not(:disabled):not([readonly])::file-selector-button {
background-color: var(--dark-border);
}
.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a3a4cc' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
}
.form-check-input:not(:checked) {
background-color: var(--dark-card);
border-color: var(--dark-border);
}
/* Buttons */
.btn-outline-secondary {
color: var(--dark-text);
border-color: var(--dark-muted);
}
.btn-outline-secondary:hover {
color: var(--dark-heading);
background-color: var(--dark-hover);
border-color: var(--dark-text);
}
.btn.p-0,
.btn:not([class*="btn-"]) {
color: var(--dark-text);
}
/* Dropdown menus */
.dropdown-menu {
color: var(--dark-text);
background-color: var(--dark-card);
border-color: var(--dark-border);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
}
.dropdown-item {
color: var(--dark-text);
}
.dropdown-item:hover,
.dropdown-item:focus {
color: var(--dark-heading);
background-color: var(--dark-hover);
}
.dropdown-divider {
border-top-color: var(--dark-border);
}
/* Dialogs */
.modal-content {
background-color: var(--dark-card);
box-shadow: 0 0.25rem 1.5rem rgba(0, 0, 0, 0.5);
}
.modal .btn-close {
background-color: var(--dark-border);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.4);
}
/* Alerts */
.alert-danger {
background-color: #55303a;
border-color: #6b3540;
color: #ff8a75;
}
/* Public booking form embedded in the "Create booking" dialog */
.leptos-captcha {
color: var(--dark-text);
}
+281
View File
@@ -0,0 +1,281 @@
/*
* Public booking page.
*
* The customer's appearance settings come as CSS variables in style attributes
* (Colors::style in src/backend/color.rs, Appearance::banner_style in src/backend/data.rs),
* so nothing here is generated. The rules are scoped by .public-page, which wraps the public
* route and the preview in the appearance settings; the admin portal keeps its own colours.
* They override the colours that the vendor template hard-codes, and they set everything
* that assets/css/dark.css sets, so the preview looks the same in both admin themes.
******************************************************************************/
.public-page {
--rz-primary: #696cff;
--rz-bg: #f5f5f9;
--rz-card: #ffffff;
--rz-text: #566a7f;
--rz-on-primary: #ffffff;
--rz-scheme: light;
/* Shades derived from the settings */
--rz-body: color-mix(in srgb, var(--rz-text) 89%, var(--rz-card));
--rz-muted: color-mix(in srgb, var(--rz-text) 50%, var(--rz-card));
--rz-border: color-mix(in srgb, var(--rz-text) 22%, var(--rz-card));
--rz-primary-hover: color-mix(in srgb, var(--rz-primary) 90%, var(--rz-on-primary));
--rz-primary-shadow: color-mix(in srgb, var(--rz-primary) 40%, transparent);
min-height: 100vh;
color: var(--rz-body);
background-color: var(--rz-bg);
color-scheme: var(--rz-scheme);
}
/* Preview in the appearance settings */
.public-page.public-preview {
min-height: 0;
border: 1px solid var(--rz-border);
border-radius: 0.5rem;
overflow: hidden;
}
/* Typography */
.public-page h1, .public-page h2, .public-page h3,
.public-page h4, .public-page h5, .public-page h6 {
color: var(--rz-text);
}
.public-page a {
color: var(--rz-primary);
}
.public-page a:hover {
color: var(--rz-primary-hover);
}
/* Cards and dialogs */
.public-page .card {
background-color: var(--rz-card);
border-color: var(--rz-border);
box-shadow: 0 2px 6px 0 rgba(67, 89, 113, 0.12);
}
.public-page .modal-content {
color: var(--rz-body);
background-color: var(--rz-card);
box-shadow: 0 2px 20px 0 rgba(67, 89, 113, 0.45);
}
/* Forms */
.public-page .form-label,
.public-page .form-check-label {
color: var(--rz-text);
}
.public-page .form-control {
color: var(--rz-body);
background-color: var(--rz-card);
border-color: var(--rz-border);
}
.public-page .form-control::placeholder {
color: var(--rz-muted);
}
.public-page .form-control:focus {
color: var(--rz-body);
background-color: var(--rz-card);
border-color: var(--rz-primary);
box-shadow: 0 0 0.25rem 0.05rem color-mix(in srgb, var(--rz-primary) 10%, transparent);
}
.public-page .form-check-input:not(:checked) {
background-color: var(--rz-card);
border-color: var(--rz-border);
}
.public-page .form-check-input:focus {
border-color: var(--rz-primary);
box-shadow: 0 2px 4px 0 var(--rz-primary-shadow);
}
.public-page .form-check-input:checked {
background-color: var(--rz-primary);
border-color: var(--rz-primary);
box-shadow: 0 2px 4px 0 var(--rz-primary-shadow);
}
/* Buttons */
.public-page .btn-primary {
color: var(--rz-on-primary);
background-color: var(--rz-primary);
border-color: var(--rz-primary);
box-shadow: 0 0.125rem 0.25rem 0 var(--rz-primary-shadow);
}
.public-page .btn-primary:hover,
.public-page .btn-primary:focus {
color: var(--rz-on-primary);
background-color: var(--rz-primary-hover);
border-color: var(--rz-primary-hover);
}
.public-page .btn-outline-secondary {
color: var(--rz-body);
border-color: var(--rz-muted);
}
.public-page .btn-outline-secondary:hover,
.public-page .btn-outline-secondary:focus {
color: var(--rz-card);
background-color: var(--rz-body);
border-color: var(--rz-body);
}
/* Time slots: a hidden checkbox followed by its label */
.public-page .btn-outline-primary {
color: var(--rz-primary);
border-color: var(--rz-primary);
background: transparent;
}
.public-page .btn-outline-primary:hover,
.public-page .btn-check:checked + .btn-outline-primary {
color: var(--rz-on-primary);
background-color: var(--rz-primary);
border-color: var(--rz-primary);
box-shadow: 0 0.125rem 0.25rem 0 var(--rz-primary-shadow);
}
.public-page .btn-check:focus + .btn-outline-primary {
border-color: var(--rz-primary-hover);
}
/* Captcha */
.public-page .leptos-captcha {
color: var(--rz-body);
}
/* Layout options: classes on .public-page from LayoutOptions::classes in src/backend/layout.rs.
The markup is the same for all of them. */
.public-page.rz-width-centered .public-form,
.public-page.rz-width-centered .public-banner-text {
max-width: 1100px;
margin-left: auto;
margin-right: auto;
}
.public-page.rz-width-centered.rz-form-stacked .public-form,
.public-page.rz-width-centered.rz-form-stacked .public-banner-text {
max-width: 680px;
}
.public-page.rz-form-stacked .public-form-columns > * {
flex: 0 0 100%;
max-width: 100%;
}
.public-page.rz-slots-large .btn-check + .btn {
min-width: 5.5rem;
margin: 0 0.4rem 0.4rem 0;
padding: 0.8rem 1.1rem;
font-size: 1.0625rem;
}
.public-page.rz-corners-square,
.public-page.rz-corners-square * {
border-radius: 0 !important;
}
/* The preview is much narrower than a real window, so the centred column is shown in proportion */
.public-page.public-preview.rz-width-centered .public-form,
.public-page.public-preview.rz-width-centered .public-banner-text {
max-width: 75%;
}
.public-page.public-preview.rz-width-centered.rz-form-stacked .public-form,
.public-page.public-preview.rz-width-centered.rz-form-stacked .public-banner-text {
max-width: 55%;
}
/* Banner: the image (or a strip in the primary colour) with an optional logo and the title.
The fallbacks are for the form embedded in the admin portal, outside of .public-page. */
.public-banner {
--rz-banner-current-height: var(--rz-banner-height, 100px);
display: flex;
align-items: center;
justify-content: var(--rz-banner-justify, flex-start);
gap: 1rem;
height: var(--rz-banner-current-height);
padding: 0 30px;
overflow: hidden;
background-color: var(--rz-primary, #696cff);
background-image:
linear-gradient(rgba(0, 0, 0, var(--rz-banner-dim, 0)), rgba(0, 0, 0, var(--rz-banner-dim, 0))),
var(--rz-banner-image, none);
background-size: cover;
background-position: var(--rz-banner-position, center);
}
.public-banner-logo {
flex: none;
height: calc(var(--rz-banner-current-height) * 0.6);
max-width: 40%;
object-fit: contain;
}
/* Two classes, to win over the heading colour above */
.public-banner .public-banner-title {
min-width: 0;
margin: 0;
font-size: x-large;
color: var(--rz-banner-title, #ffffff);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.public-banner-text {
padding: 10px;
}
@media (min-width: 1200px) {
.public-banner {
--rz-banner-current-height: var(--rz-banner-height-wide, 200px);
}
.public-banner .public-banner-title {
font-size: xxx-large;
}
}
/* The preview is narrow whatever the width of the window. Clicking into its banner sets
the part of the image that stays visible, the mark shows it. */
.public-preview .public-banner {
--rz-banner-current-height: var(--rz-banner-height, 100px);
position: relative;
cursor: crosshair;
}
.public-preview .public-banner .public-banner-title {
font-size: x-large;
}
/* Three classes: the mark stays round with square corners too */
.public-page .public-banner .public-banner-focus {
position: absolute;
width: 14px;
height: 14px;
border: 2px solid #ffffff;
border-radius: 50% !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
transform: translate(-50%, -50%);
pointer-events: none;
}
+10
View File
@@ -2,3 +2,13 @@ a.disabled {
pointer-events: none; pointer-events: none;
color: #ccc; color: #ccc;
} }
/* Editor of the text under the banner in the appearance settings */
.text-editor {
min-height: 8rem;
height: auto;
}
.text-editor > :last-child {
margin-bottom: 0;
}
+18
View File
@@ -4,6 +4,10 @@ key = '------------- Secure session key, 64 character length -------------'
[network] [network]
bind_ip = '127.0.0.1' bind_ip = '127.0.0.1'
port = 3000 port = 3000
# Address under which the application is reached from outside. It is used for links in e-mails
# (the page where a customer can view and cancel a booking). Without it the address of the
# request is used, which is safe only behind a reverse proxy that passes just your domain.
#public_url = 'https://booking.example.com'
[database] [database]
user = 'user' user = 'user'
@@ -11,6 +15,9 @@ password = 'password'
db_name = 'rezervator' db_name = 'rezervator'
host = 'localhost' host = 'localhost'
port = 5432 port = 5432
# Upper limit of open database connections, default is 10. Lower it when many instances
# share one PostgreSQL server.
#max_connections = 10
[mailing] [mailing]
transport = "File" #or Smtp transport = "File" #or Smtp
@@ -24,5 +31,16 @@ path = "Maildir"
#tls = true #tls = true
#accept_all_certs = true #accept_all_certs = true
# Password of the "admin" account, which is created on the first start when no admin exists.
# At least 8 characters. Can be given in the REZERVATOR_ADMIN_PASSWORD environment variable instead.
# It is not used once an admin account exists; remove it then.
#[admin]
#initial_password = ""
# Directory for uploaded files (banner, logo), served under /data.
# Default is "data" in the working directory. Must survive application upgrades.
#[storage]
#data_dir = "data"
[logging] [logging]
severity = "debug" severity = "debug"
+49
View File
@@ -0,0 +1,49 @@
# Settings of the stack. Copy to .env (./rezervator.sh init does it and generates the password).
# Prefix of container, network and volume names. Change it to run a second stack on the same host.
REZERVATOR_PREFIX=rezervator
# Image the customer containers are started from
REZERVATOR_IMAGE=rezervator:latest
# Time zone of the customers. Bookings are made in local time, and the application compares
# them with its clock (today's date, the deadline for cancelling a booking).
REZERVATOR_TZ=Europe/Prague
# Reverse proxy in front of the customer containers:
# traefik runs as a container of this stack, routes by domain, can issue certificates itself
# nginx the nginx already installed on this host. Every customer container listens on
# 127.0.0.1 on its own port (from BASE_PORT up) and the script generates an nginx
# server block for it. TLS is up to nginx; the settings below REZERVATOR_TLS are ignored.
REZERVATOR_PROXY=traefik
BASE_PORT=3100
# PostgreSQL server holding the customer databases:
# container runs as a container of this stack
# host the PostgreSQL already installed on this host. The script manages databases
# with "PG_ADMIN psql" and the containers connect to PG_HOST:PG_PORT.
REZERVATOR_POSTGRES=container
PG_ADMIN="sudo -u postgres"
PG_HOST=host.docker.internal
PG_PORT=5432
# Only for REZERVATOR_PROXY=traefik:
# true: HTTPS with Let's Encrypt certificates, HTTP is redirected. Needs public DNS records
# pointing to this host and ACME_EMAIL.
# false: plain HTTP only, for local testing or behind another TLS terminating proxy.
REZERVATOR_TLS=true
ACME_EMAIL=
# Ports published by Traefik
HTTP_PORT=80
HTTPS_PORT=443
# Only for REZERVATOR_POSTGRES=container: password of the PostgreSQL superuser.
# Generated by ./rezervator.sh init.
POSTGRES_PASSWORD=
# Connection limits. max_connections of PostgreSQL must be above
# (number of customers) x CUSTOMER_MAX_CONNECTIONS, or put PgBouncer in front of PostgreSQL.
# PG_MAX_CONNECTIONS sets it for REZERVATOR_POSTGRES=container; on the host, set it in postgresql.conf.
PG_MAX_CONNECTIONS=300
CUSTOMER_MAX_CONNECTIONS=5
+204
View File
@@ -0,0 +1,204 @@
# Running Rezervovator for many customers
The application serves one company per instance. To host many customers, every customer gets their own container from the same image, with their own database, configuration and data volume. This directory contains what is needed to run that on one Docker host:
- `rezervator.sh` — creates, imports, upgrades, backs up, restores and removes customers
- `docker-compose.yml` — the optional shared services: a reverse proxy (Traefik) and a PostgreSQL server
- `config.template.toml` — template of a customer's configuration
- `.env.example` — settings of the stack
The reverse proxy and PostgreSQL can each be either a container of this stack or the one already installed on the host:
| `.env` | Value | Meaning |
|---|---|---|
| `REZERVATOR_PROXY` | `traefik` (default) | Traefik container, routes by domain, issues Let's Encrypt certificates itself |
| | `nginx` | nginx installed on the host; the script generates a server block per customer |
| `REZERVATOR_POSTGRES` | `container` (default) | PostgreSQL container of this stack |
| | `host` | PostgreSQL installed on the host |
The two settings are independent. [Setup with nginx and PostgreSQL on the host](#setup-with-nginx-and-postgresql-on-the-host) below describes the combination for a server that already runs both.
## Requirements
- Docker 20.10 or newer, `bash` and `openssl` on the host. The Compose plugin is needed only when Traefik or PostgreSQL run as containers of this stack.
- The application image on the host: `docker build -t rezervator:latest .` in the repository root, or pulled from a registry (see [Distributing the image](#distributing-the-image)). The image must match the CPU architecture of the host.
- A DNS record for every customer domain pointing to the host
## First setup
```sh
./rezervator.sh init # creates .env and stops
$EDITOR .env # proxy, PostgreSQL, image
$EDITOR config.template.toml # the [mailing] section: your SMTP server
./rezervator.sh init # starts the shared services, if any, and checks the setup
```
With `REZERVATOR_PROXY=traefik`, `REZERVATOR_TLS=true` gets a Let's Encrypt certificate for every customer domain and redirects HTTP to HTTPS; it needs `ACME_EMAIL` and ports 80 and 443 reachable from the internet. Use `false` only for local testing or behind another proxy that terminates TLS.
The session cookie is marked `Secure`. Whatever the setup, customers must reach the application over HTTPS, otherwise logging in works only on `localhost` addresses.
## Distributing the image
Building the image takes several minutes and needs the Rust toolchain image. To set up another server quickly, build once, push the image to a container registry and pull it elsewhere. Gitea has a registry built in; the examples use the one at `gitea.itsolved.cz` and the organization `itsolved`.
On the server where the image was built:
```sh
docker login gitea.itsolved.cz
docker tag rezervator:latest gitea.itsolved.cz/itsolved/rezervator:1.2.0
docker push gitea.itsolved.cz/itsolved/rezervator:1.2.0
```
Log in with a Gitea user name and an access token (Settings → Applications) that may write packages. Use the version from `Cargo.toml` as the tag rather than only `latest`: it shows what runs where, and an older version stays available.
On another server:
```sh
docker login gitea.itsolved.cz # only when the package is not public; a token that may read packages is enough
docker pull gitea.itsolved.cz/itsolved/rezervator:1.2.0
```
and in `.env`:
```ini
REZERVATOR_IMAGE=gitea.itsolved.cz/itsolved/rezervator:1.2.0
```
To release a new version, build and push it under a new tag, then on every server pull it, change the tag in `.env` and run `./rezervator.sh upgrade --all`.
Things to know:
- The image runs only on the CPU architecture it was built on (`x86_64` when built on such a server).
- The package belongs to the organization and follows its visibility: the image of a public organization can be pulled without logging in. The image contains no configuration or passwords.
- If the push fails with `413 Request Entity Too Large`, the reverse proxy in front of Gitea limits the upload size; raise `client_max_body_size` in its nginx server block.
Without a registry, an image can be copied to one server directly:
```sh
docker save rezervator:latest | gzip | ssh user@other-server 'gunzip | docker load'
```
## Setup with nginx and PostgreSQL on the host
```ini
# .env
REZERVATOR_PROXY=nginx
REZERVATOR_POSTGRES=host
PG_ADMIN="sudo -u postgres"
```
Nothing from `docker-compose.yml` runs in this setup; `init` only creates the Docker network and checks that it can manage PostgreSQL and that containers can reach it.
Run the script as a user who may use Docker and `sudo -u postgres`.
### PostgreSQL
The script manages databases with `sudo -u postgres psql` and `pg_dump`. The customer containers connect over the Docker network to `host.docker.internal:5432`, which is the host's address on the Docker bridge, so PostgreSQL has to accept connections from there. A default installation listens on `localhost` only.
1. `postgresql.conf`: listen on the Docker bridge too. `listen_addresses = '*'` is the simple way; the next two steps keep it closed to everybody else. Check `max_connections` (see [Sizing](#sizing)).
2. `pg_hba.conf`: allow the Docker networks with password authentication. Every customer has a database and a role of the same name, so `sameuser` limits each role to its own database:
```text
host sameuser all 172.16.0.0/12 scram-sha-256
```
3. Firewall: allow port 5432 from `172.16.0.0/12` and keep it closed from outside.
4. Restart PostgreSQL and run `./rezervator.sh init`. It fails with a message when the containers cannot reach the database.
`172.16.0.0/12` covers Docker's default address pools; adjust it if your Docker uses other ranges. These steps were not tried on a Linux server yet — the script was tested against a PostgreSQL outside the stack reached through the host address, on Docker Desktop.
### nginx
Every customer container listens on `127.0.0.1` on its own port (`BASE_PORT` and up), not reachable from outside. `add` writes `customers/<name>/nginx.conf`, a plain HTTP server block that proxies the domain to that port; the commands to install it and to add a certificate with certbot are at the top of the file. Adapt it to how TLS is done on your server.
nginx's default limit for uploads is 1 MB; the generated block raises it to 20 MB. The application itself accepts banner and logo images up to 5 MB.
### Moving an existing installation into a container
`import` creates a customer from an installation that runs directly on the host: it copies its database into a new one and the uploaded files from its data directory into a new volume. The accounts and passwords come along. The source is only read.
```sh
./rezervator.sh import main rezervace.example.com rezervator /srv/rezervator/target/site/data
```
The arguments are the new customer name, its domain, the name of the existing database and the directory with the uploaded banner (in old versions `target/site/data` next to the binary; leave it out if there is no banner). Config files in that directory are not copied.
Suggested order:
1. Copy the `[mailing]` section of the old `config.toml` into `config.template.toml`.
2. Try it: run `import`, check the new instance on its local port (`./rezervator.sh list` shows it), then `./rezervator.sh remove main --purge`.
3. For real: stop the old installation, run `import` again, and point the existing nginx server block of the domain to the new port (`proxy_pass http://127.0.0.1:<port>;`) — keeping its TLS settings is easier than installing the generated file. Reload nginx.
4. Keep the old database until the new instance has proven itself, then drop it.
Bookings made in the old installation between the import and the switch are not in the new one, hence stopping it first.
The new instance gets a new session key and a new database password, so everybody has to log in again. If the old installation kept its `config.toml` in `target/site/data`, that file was publicly downloadable: change the SMTP password too. `import` warns when the account `admin` still has the password `admin`.
## Adding a customer
```sh
./rezervator.sh add alpha rezervace.alpha.example
```
This creates the database and its role, writes `customers/alpha/config.toml` with a unique session key and database password, creates the data volume, starts the container and waits until it is healthy. At the end it prints the generated password of the `admin` account — it is shown only once.
An optional third argument sets the sender address of e-mails (default `noreply@<domain>`).
The config gets `public_url = 'https://<domain>'`, the address used for links in e-mails (the page where a customer can view and cancel a booking). Customers created before this setting existed have no `public_url`; their links use the address of the request, which is fine behind Traefik or an nginx server block of the domain. Add the line to `customers/<name>/config.toml` and run `upgrade <name>` if you want it explicit.
Containers run in the time zone set by `REZERVATOR_TZ` in `.env` (default `Europe/Prague`). It takes effect when a container is created, that is on `add`, `upgrade` and `restore`.
`./rezervator.sh list` shows all customers, their ports and the state of their containers.
## Upgrading
Build or pull the new image under the name set in `REZERVATOR_IMAGE`, then:
```sh
./rezervator.sh upgrade alpha # try one customer first
./rezervator.sh upgrade --all
```
Customers are upgraded one by one. For each one the script makes a backup, replaces the container (database migrations run when the new version starts) and waits for it to become healthy. Customers that already run the image are skipped, so an interrupted upgrade can simply be run again.
If a customer does not become healthy, the script puts back the database and data from the backup it just made, starts the previous image again and stops. The remaining customers are not touched. A customer is unavailable for a few seconds while its container is replaced.
## Backup and restore
```sh
./rezervator.sh backup --all
./rezervator.sh restore alpha 20260101T020000Z
```
A backup is two files in `backups/<name>/`: `<timestamp>-db.sql.gz` (database dump) and `<timestamp>-data.tar.gz` (uploaded banner and logo). `restore` replaces both the database and the data volume, and keeps the image the customer was running.
The script does not schedule backups, does not delete old ones and does not copy them off the host. Run `backup --all` from cron, and ship `backups/`, `customers/` and `.env` somewhere else — `customers/` and `.env` hold the passwords and session keys and cannot be regenerated.
## Removing a customer
```sh
./rezervator.sh remove alpha # stops the container, keeps everything else
./rezervator.sh remove alpha --purge # also deletes the database, data volume and config
```
After a plain `remove`, `upgrade alpha` starts the customer again. `--purge` asks for the customer name as confirmation and keeps existing backups. With nginx, remove the customer's server block yourself.
## Health and monitoring
Every instance answers `GET /health` with 200 when it can reach its database and with 503 otherwise. The image uses it as its Docker health check; `list` shows the result, and Traefik sends traffic only to healthy containers. Point an external monitor to `https://<domain>/health`.
## Sizing
- One idle instance used about 7 MiB of memory in a test, about 12 MiB after some use. PostgreSQL needs far more than that.
- Every instance may open up to `CUSTOMER_MAX_CONNECTIONS` database connections (5 by default; written into the customer's config when the customer is created) and closes idle ones. `max_connections` of PostgreSQL (100 in a default installation, `PG_MAX_CONNECTIONS` for the container) must stay above the number of customers times that limit, plus what else uses the server. Beyond that, put PgBouncer in front of PostgreSQL.
## Security notes
- `customers/` and `.env` contain secrets and are readable only by the user who ran the script. Keep them out of version control (they are in `.gitignore`).
- Customers share one PostgreSQL server but each has its own database and role, without access to the others.
- With Traefik: the proxy has read access to the Docker socket to discover containers, which is equivalent to root on the host. Do not expose its API or dashboard.
- With PostgreSQL on the host: the script runs `psql` as the PostgreSQL superuser, so whoever may run it controls all databases on that server, not only the customers'.
## Running a second stack
Change `REZERVATOR_PREFIX` (and the ports) in `.env` of another copy of this directory. All container, network and volume names are derived from the prefix.
+33
View File
@@ -0,0 +1,33 @@
# Template of the configuration of one customer instance. ./rezervator.sh add fills in the
# @PLACEHOLDERS@ and writes the result to customers/<name>/config.toml.
# Edit the [mailing] section before adding the first customer.
[session]
key = '@SESSION_KEY@'
[network]
bind_ip = '0.0.0.0'
port = 3000
# Address for links in e-mails (the page where a customer can view and cancel a booking)
public_url = 'https://@DOMAIN@'
[database]
user = '@DB_USER@'
password = '@DB_PASSWORD@'
db_name = '@DB_NAME@'
host = '@DB_HOST@'
port = @DB_PORT@
max_connections = @MAX_CONNECTIONS@
[mailing]
transport = "Smtp"
from = "@MAIL_FROM@"
server = "smtp.example.com"
#hello_name = "host.example.com"
port = 587
user = "smtp-user"
password = "smtp-password"
tls = true
[logging]
severity = "info"
+65
View File
@@ -0,0 +1,65 @@
# Shared infrastructure for running one Rezervovator container per customer:
# the reverse proxy and the PostgreSQL server. Customer containers are not listed
# here, they are created with ./rezervator.sh (see README.md in this directory).
#
# Both services are optional and selected by profiles: ./rezervator.sh init starts
# "traefik" unless REZERVATOR_PROXY=nginx, and "postgres" unless REZERVATOR_POSTGRES=host.
name: ${REZERVATOR_PREFIX:-rezervator}
services:
proxy:
profiles: ["traefik"]
image: traefik:v3
container_name: ${REZERVATOR_PREFIX:-rezervator}-proxy
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=${REZERVATOR_PREFIX:-rezervator}
# Only containers of this stack, so two stacks on one host don't mix
- --providers.docker.constraints=Label(`rezervator.stack`,`${REZERVATOR_PREFIX:-rezervator}`)
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
# Let's Encrypt certificates, used when REZERVATOR_TLS=true
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:-}
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- rezervator
postgres:
profiles: ["postgres"]
image: postgres:17
container_name: ${REZERVATOR_PREFIX:-rezervator}-postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
# Every customer instance may open up to CUSTOMER_MAX_CONNECTIONS connections
command: ["postgres", "-c", "max_connections=${PG_MAX_CONNECTIONS:-300}"]
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
interval: 5s
timeout: 3s
retries: 20
networks:
- rezervator
# Created by ./rezervator.sh init, because customer containers use it even when
# neither service above runs
networks:
rezervator:
name: ${REZERVATOR_PREFIX:-rezervator}
external: true
volumes:
letsencrypt:
postgres-data:
+704
View File
@@ -0,0 +1,704 @@
#!/usr/bin/env bash
#
# Management of a Rezervovator stack with one container per customer.
# See README.md in this directory.
set -euo pipefail
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$DEPLOY_DIR/.env"
CUSTOMERS_DIR="$DEPLOY_DIR/customers"
BACKUPS_DIR="$DEPLOY_DIR/backups"
usage() {
cat <<USAGE
Usage: $(basename "$0") <command> [arguments]
init Create .env; then start the shared services and check the setup
add <name> <domain> [from] Create a customer: database, config, volume, container.
[from] is the sender address of e-mails, default noreply@<domain>
import <name> <domain> <database> [data-dir] [from]
Create a customer from an existing installation: copies its
database and the files from its data directory. The source is
only read.
list List customers and the state of their containers
upgrade <name>...|--all Recreate containers from REZERVATOR_IMAGE, one by one.
Backs up first, rolls back and stops when one fails.
backup <name>...|--all Dump the database and the data volume to backups/<name>/
restore <name> <timestamp> Restore a customer from backups/<name>/<timestamp>-*
remove <name> [--purge] Stop and remove the container. With --purge also the database,
the data volume and the config (asks for confirmation).
<name> is lowercase letters, digits and dashes, starting with a letter.
USAGE
}
die() {
echo "Error: $*" >&2
exit 1
}
info() {
echo "==> $*"
}
random_string() {
# $1 = number of bytes, output is twice as many hex characters
openssl rand -hex "$1"
}
load_env() {
[ -f "$ENV_FILE" ] || die "$ENV_FILE not found, run '$(basename "$0") init' first"
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
PREFIX="${REZERVATOR_PREFIX:-rezervator}"
IMAGE="${REZERVATOR_IMAGE:-rezervator:latest}"
TLS="${REZERVATOR_TLS:-true}"
PROXY="${REZERVATOR_PROXY:-traefik}"
POSTGRES="${REZERVATOR_POSTGRES:-container}"
PG_CONTAINER="$PREFIX-postgres"
PG_PORT="${PG_PORT:-5432}"
NETWORK="$PREFIX"
CUSTOMER_MAX_CONNECTIONS="${CUSTOMER_MAX_CONNECTIONS:-5}"
BASE_PORT="${BASE_PORT:-3100}"
TIME_ZONE="${REZERVATOR_TZ:-Europe/Prague}"
case "$PROXY" in traefik|nginx) ;; *) die "REZERVATOR_PROXY must be traefik or nginx" ;; esac
case "$POSTGRES" in container|host) ;; *) die "REZERVATOR_POSTGRES must be container or host" ;; esac
if [ "$POSTGRES" = "host" ]; then
# Address of the Docker host as the containers see it
DB_HOST="${PG_HOST:-host.docker.internal}"
# How to run psql and pg_dump as the PostgreSQL superuser; may be empty
PG_ADMIN="${PG_ADMIN-sudo -u postgres}"
else
DB_HOST="$PG_CONTAINER"
fi
}
compose() {
docker compose --project-directory "$DEPLOY_DIR" --env-file "$ENV_FILE" "$@"
}
# Starts the shared services that run as containers in this setup, if any
compose_up() {
local profiles=()
if [ "$PROXY" = "traefik" ]; then profiles+=(--profile traefik); fi
if [ "$POSTGRES" = "container" ]; then profiles+=(--profile postgres); fi
docker network inspect "$NETWORK" >/dev/null 2>&1 || docker network create "$NETWORK" >/dev/null
if [ "${#profiles[@]}" -gt 0 ]; then
compose "${profiles[@]}" up --detach
fi
}
check_name() {
[[ "$1" =~ ^[a-z][a-z0-9-]{0,30}$ ]] || die "Invalid customer name '$1'"
}
container_name() { echo "$PREFIX-app-$1"; }
volume_name() { echo "$PREFIX-data-$1"; }
# Database and role have the same name
db_name() { echo "rz_${1//-/_}"; }
customer_exists() {
[ -f "$CUSTOMERS_DIR/$1/customer.env" ]
}
require_customer() {
check_name "$1"
customer_exists "$1" || die "Unknown customer '$1'"
}
all_customers() {
[ -d "$CUSTOMERS_DIR" ] || return 0
for dir in "$CUSTOMERS_DIR"/*/; do
if [ -f "${dir}customer.env" ]; then basename "$dir"; fi
done
return 0
}
# Reads a value from the customer's customer.env. $1 = name, $2 = variable
customer_value() {
sed -n "s/^$2=//p" "$CUSTOMERS_DIR/$1/customer.env" | head -n 1
}
# psql as the PostgreSQL superuser, on the host or in the container
psql_admin() {
if [ "$POSTGRES" = "host" ]; then
# shellcheck disable=SC2086
$PG_ADMIN psql -v ON_ERROR_STOP=1 -q "$@"
else
docker exec -i "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -q "$@"
fi
}
pg_dump_admin() {
if [ "$POSTGRES" = "host" ]; then
# shellcheck disable=SC2086
$PG_ADMIN pg_dump "$@"
else
docker exec "$PG_CONTAINER" pg_dump -U postgres "$@"
fi
}
wait_for_postgres() {
if [ "$POSTGRES" = "host" ]; then
psql_admin -tAc "SELECT 1" >/dev/null 2>&1 \
|| die "Cannot run psql as the PostgreSQL superuser (PG_ADMIN='$PG_ADMIN')"
return 0
fi
for _ in $(seq 1 60); do
# Over TCP: while the image initializes a new cluster, its temporary server listens on the socket only
if docker exec "$PG_CONTAINER" pg_isready -h 127.0.0.1 -U postgres -q 2>/dev/null; then
return 0
fi
sleep 1
done
die "PostgreSQL in container $PG_CONTAINER is not ready"
}
# With PostgreSQL on the host, the containers must be able to reach it over the Docker network
check_db_reachable() {
[ "$POSTGRES" = "host" ] || return 0
docker run --rm --network "$NETWORK" --add-host "host.docker.internal:host-gateway" \
--entrypoint bash "$IMAGE" -c "exec 3<>/dev/tcp/$DB_HOST/$PG_PORT" 2>/dev/null \
|| die "Containers cannot reach PostgreSQL at $DB_HOST:$PG_PORT. Check listen_addresses, pg_hba.conf and the firewall, see README.md."
}
# First free port for a customer container behind nginx
next_port() {
local port="$BASE_PORT" name used
for name in $(all_customers); do
used="$(customer_value "$name" PORT)"
if [ -n "$used" ] && [ "$used" -ge "$port" ]; then
port=$((used + 1))
fi
done
echo "$port"
}
# Writes the nginx server block of a customer to customers/<name>/nginx.conf
write_nginx_conf() {
local name="$1" domain port
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
cat > "$CUSTOMERS_DIR/$name/nginx.conf" <<NGINX
# Rezervovator, customer '$name'. Generated by rezervator.sh.
# Install it into nginx and add TLS, e.g.:
# sudo cp $CUSTOMERS_DIR/$name/nginx.conf /etc/nginx/conf.d/$PREFIX-$name.conf
# sudo nginx -t && sudo systemctl reload nginx
# sudo certbot --nginx -d $domain
server {
listen 80;
listen [::]:80;
server_name $domain;
# Banner images are uploaded through the application
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:$port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
NGINX
}
# Containers are always removed with --volumes: the image declares /app/config as a volume
# and only a file is mounted into it, so every container gets an anonymous volume that would
# otherwise stay behind. The named data volume of the customer is not affected by --volumes.
# Starts the container of a customer. $1 = name, $2 = image, $3 = initial admin password (optional)
run_container() {
local name="$1" image="$2" admin_password="${3:-}"
local container router domain port
container="$(container_name "$name")"
router="$PREFIX-$name"
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
local args=(
--detach
--name "$container"
--restart unless-stopped
--network "$NETWORK"
--volume "$CUSTOMERS_DIR/$name/config.toml:/app/config/config.toml:ro"
--volume "$(volume_name "$name"):/app/data"
# Times of bookings are local times; the deadline for cancelling is compared with the clock
--env "TZ=$TIME_ZONE"
--label "rezervator.stack=$PREFIX"
--label "rezervator.customer=$name"
--label "rezervator.domain=$domain"
)
if [ "$POSTGRES" = "host" ]; then
args+=(--add-host "host.docker.internal:host-gateway")
fi
if [ "$PROXY" = "nginx" ]; then
[ -n "$port" ] || die "Customer '$name' has no PORT in customer.env"
# Only the nginx on this host may reach the container
args+=(--publish "127.0.0.1:$port:3000")
elif [ "$TLS" = "true" ]; then
args+=(
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
--label "traefik.http.routers.$router.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router.entrypoints=websecure"
--label "traefik.http.routers.$router.tls.certresolver=le"
--label "traefik.http.routers.$router-http.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router-http.entrypoints=web"
--label "traefik.http.routers.$router-http.middlewares=$router-https"
--label "traefik.http.middlewares.$router-https.redirectscheme.scheme=https"
)
else
args+=(
--label "traefik.enable=true"
--label "traefik.http.services.$router.loadbalancer.server.port=3000"
--label "traefik.http.routers.$router.rule=Host(\`$domain\`)"
--label "traefik.http.routers.$router.entrypoints=web"
)
fi
if [ -n "$admin_password" ]; then
args+=(--env "REZERVATOR_ADMIN_PASSWORD=$admin_password")
fi
docker run "${args[@]}" "$image" >/dev/null
}
# Waits until the application in the container is up and can reach its database.
# Returns 1 when the container stops or does not get healthy in time.
wait_healthy() {
local container="$1" state has_check
has_check="$(docker inspect --format '{{if .Config.Healthcheck}}yes{{else}}no{{end}}' "$container" 2>/dev/null || echo "no")"
if [ "$has_check" = "no" ]; then
# Image from before the health check: running for a while is the best we can tell
sleep 5
[ "$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)" = "running" ]
return
fi
# Probe directly instead of waiting for Docker: its first check comes only after the
# check interval, and the faster start interval needs Docker 25
local ready="no"
for _ in $(seq 1 60); do
state="$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null || echo "missing")"
case "$state" in
running)
if docker exec "$container" /app/rezervator -c /app/config/config.toml --healthcheck >/dev/null 2>&1; then
ready="yes"
break
fi ;;
restarting|created) ;;
*) return 1 ;;
esac
# A container that fails at start is restarted by Docker over and over
if [ "$(docker inspect --format '{{.RestartCount}}' "$container" 2>/dev/null || echo 0)" -ge 3 ]; then
return 1
fi
sleep 2
done
[ "$ready" = "yes" ] || return 1
if [ "$PROXY" = "traefik" ]; then
# Traefik routes to the container only once Docker itself reports it healthy
for _ in $(seq 1 45); do
state="$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || echo "missing")"
case "$state" in
healthy) sleep 3; return 0 ;;
starting) ;;
*) return 1 ;;
esac
sleep 2
done
return 1
fi
return 0
}
cmd_init() {
command -v docker >/dev/null || die "docker not found"
command -v openssl >/dev/null || die "openssl not found"
if [ ! -f "$ENV_FILE" ]; then
sed "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(random_string 24)/" "$DEPLOY_DIR/.env.example" > "$ENV_FILE"
chmod 600 "$ENV_FILE"
info "Created $ENV_FILE. Review it (proxy, PostgreSQL, image) and run init again."
exit 0
fi
load_env
if [ "$PROXY" = "traefik" ] && [ "$TLS" = "true" ] && [ -z "${ACME_EMAIL:-}" ]; then
die "REZERVATOR_TLS=true needs ACME_EMAIL in $ENV_FILE"
fi
mkdir -p "$CUSTOMERS_DIR" "$BACKUPS_DIR"
chmod 700 "$CUSTOMERS_DIR" "$BACKUPS_DIR"
compose_up
wait_for_postgres
if docker image inspect "$IMAGE" >/dev/null 2>&1; then
check_db_reachable
else
info "Image $IMAGE is not on this host yet, build or pull it before adding customers"
fi
info "Ready (proxy: $PROXY, PostgreSQL: $POSTGRES)"
}
# Validates the input and creates everything a customer needs except the running container:
# database and role, config, data volume. $1 = name, $2 = domain, $3 = sender address
prepare_customer() {
local name="$1" domain="$2" mail_from="$3"
check_name "$name"
[[ "$domain" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || die "Invalid domain '$domain'"
[[ "$mail_from" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$ ]] || die "Invalid sender address '$mail_from'"
if customer_exists "$name"; then die "Customer '$name' already exists"; fi
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first"
wait_for_postgres
check_db_reachable
local db db_password dir
db="$(db_name "$name")"
db_password="$(random_string 24)"
dir="$CUSTOMERS_DIR/$name"
if [ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'")" = "1" ]; then
die "Database $db already exists"
fi
if [ "$(psql_admin -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$db'")" = "1" ]; then
die "Database role $db already exists"
fi
info "Creating database $db"
psql_admin <<SQL
CREATE ROLE "$db" LOGIN PASSWORD '$db_password';
CREATE DATABASE "$db" OWNER "$db";
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
SQL
info "Writing config to $dir"
mkdir -p "$dir"
chmod 700 "$dir"
sed -e "s|@SESSION_KEY@|$(random_string 48)|" \
-e "s|@DB_USER@|$db|" \
-e "s|@DB_PASSWORD@|$db_password|" \
-e "s|@DB_NAME@|$db|" \
-e "s|@DB_HOST@|$DB_HOST|" \
-e "s|@DB_PORT@|$PG_PORT|" \
-e "s|@MAX_CONNECTIONS@|$CUSTOMER_MAX_CONNECTIONS|" \
-e "s|@MAIL_FROM@|$mail_from|" \
-e "s|@DOMAIN@|$domain|" \
"$DEPLOY_DIR/config.template.toml" > "$dir/config.toml"
# The directory is closed to other users; the application in the container must be able to read the file
chmod 644 "$dir/config.toml"
if [ "$PROXY" = "nginx" ]; then
# Computed before customer.env exists, so the new customer is not counted
local port
port="$(next_port)"
printf 'DOMAIN=%s\nPORT=%s\n' "$domain" "$port" > "$dir/customer.env"
write_nginx_conf "$name"
else
printf 'DOMAIN=%s\n' "$domain" > "$dir/customer.env"
fi
docker volume create "$(volume_name "$name")" >/dev/null
}
# Starts a prepared customer and waits for it. $1 = name, $2 = initial admin password (optional)
start_new_customer() {
local name="$1" admin_password="${2:-}"
info "Starting container $(container_name "$name")"
run_container "$name" "$IMAGE" "$admin_password"
if ! wait_healthy "$(container_name "$name")"; then
docker logs --tail 20 "$(container_name "$name")" >&2 || true
die "Container $(container_name "$name") did not become healthy. Fix the problem and run 'remove $name --purge' before creating it again."
fi
}
print_address() {
local name="$1" domain
domain="$(customer_value "$name" DOMAIN)"
echo
if [ "$PROXY" = "nginx" ]; then
echo "Customer '$name' is running on 127.0.0.1:$(customer_value "$name" PORT)."
echo "To publish it at $domain, install $CUSTOMERS_DIR/$name/nginx.conf"
echo "into nginx; the commands are at the top of that file."
elif [ "$TLS" = "true" ]; then
echo "Customer '$name' is running at https://$domain"
else
echo "Customer '$name' is running at http://$domain"
fi
}
cmd_add() {
[ $# -ge 2 ] || die "Usage: add <name> <domain> [from]"
local name="$1" domain="$2" mail_from="${3:-noreply@$2}" admin_password
load_env
prepare_customer "$name" "$domain" "$mail_from"
admin_password="$(random_string 9)"
start_new_customer "$name" "$admin_password"
print_address "$name"
cat <<MSG
Admin login: admin
Admin password: $admin_password
The password is shown only now. Hand it over and have it changed after the first login.
MSG
}
cmd_import() {
[ $# -ge 3 ] || die "Usage: import <name> <domain> <database> [data-dir] [from]"
local name="$1" domain="$2" source_db="$3" data_dir="${4:-}" mail_from="${5:-noreply@$2}"
local dir timestamp
load_env
[[ "$source_db" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "Invalid database name '$source_db'"
if [ -n "$data_dir" ] && [ ! -d "$data_dir" ]; then
die "Data directory '$data_dir' not found"
fi
wait_for_postgres
[ "$(psql_admin -tAc "SELECT 1 FROM pg_database WHERE datname = '$source_db'")" = "1" ] \
|| die "Source database '$source_db' not found"
prepare_customer "$name" "$domain" "$mail_from"
# The copy goes through the same files as a backup, so it can be restored again later
dir="$BACKUPS_DIR/$name"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$dir"
chmod 700 "$dir"
info "Copying database $source_db"
pg_dump_admin --no-owner --no-privileges "$source_db" | gzip > "$dir/$timestamp-db.sql.gz"
if [ -n "$data_dir" ]; then
info "Copying files from $data_dir"
# Old installations kept the config file with passwords next to the uploads
tar czf "$dir/$timestamp-data.tar.gz" --exclude='*.toml' -C "$data_dir" .
else
tar czf "$dir/$timestamp-data.tar.gz" --files-from /dev/null
fi
restore_customer "$name" "$timestamp"
# The accounts come with the database, so no initial admin password
start_new_customer "$name"
print_address "$name"
echo
echo "Accounts and passwords were taken over from database $source_db."
if docker logs "$(container_name "$name")" 2>&1 | grep -q "still uses the default password"; then
echo "WARNING: the account 'admin' still has the default password 'admin'. Change it now."
fi
echo "The source database and files were not changed. Stop the old installation before"
echo "switching the domain over, otherwise bookings made there in the meantime are lost."
}
cmd_list() {
load_env
printf '%-20s %-34s %-7s %-18s %s\n' "CUSTOMER" "DOMAIN" "PORT" "STATE" "IMAGE"
local name domain port state image
for name in $(all_customers); do
domain="$(customer_value "$name" DOMAIN)"
port="$(customer_value "$name" PORT)"
state="$(docker inspect --format '{{.State.Status}}{{if .State.Health}}/{{.State.Health.Status}}{{end}}' "$(container_name "$name")" 2>/dev/null || echo "no container")"
image="$(docker inspect --format '{{.Image}}' "$(container_name "$name")" 2>/dev/null || true)"
if [ -n "$image" ]; then
# Tag of the image when it still has one, otherwise the short id
image="$(docker image inspect --format '{{if .RepoTags}}{{index .RepoTags 0}}{{else}}{{slice .Id 7 19}}{{end}}' "$image" 2>/dev/null || echo "${image:7:12}")"
else
image="-"
fi
printf '%-20s %-34s %-7s %-18s %s\n' "$name" "$domain" "${port:--}" "$state" "$image"
done
}
# Dumps the database and the data volume. $1 = name. Prints the timestamp of the backup.
backup_customer() {
local name="$1" db dir timestamp
db="$(db_name "$name")"
dir="$BACKUPS_DIR/$name"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$dir"
chmod 700 "$dir"
pg_dump_admin --no-owner --no-privileges "$db" | gzip > "$dir/$timestamp-db.sql.gz"
# Any image with tar would do; the application image is certainly present
docker run --rm --volume "$(volume_name "$name"):/data:ro" --entrypoint tar "$IMAGE" \
czf - -C /data . > "$dir/$timestamp-data.tar.gz"
# An empty or truncated dump must not pass for a backup
gzip -t "$dir/$timestamp-db.sql.gz" && gzip -t "$dir/$timestamp-data.tar.gz"
[ "$(gzip -dc "$dir/$timestamp-db.sql.gz" | wc -c)" -gt 0 ] || die "Database dump of '$name' is empty"
echo "$timestamp"
}
cmd_backup() {
[ $# -ge 1 ] || die "Usage: backup <name>...|--all"
load_env
wait_for_postgres
local names=("$@") name timestamp
if [ "$1" = "--all" ]; then
# shellcheck disable=SC2207
names=($(all_customers))
fi
[ "${#names[@]}" -gt 0 ] || { info "No customers"; return 0; }
for name in "${names[@]}"; do
require_customer "$name"
timestamp="$(backup_customer "$name")"
info "Backup of '$name': $BACKUPS_DIR/$name/$timestamp-db.sql.gz, $timestamp-data.tar.gz"
done
}
# Replaces the database and the data volume by a backup. The container must be stopped.
restore_customer() {
local name="$1" timestamp="$2" db dir
db="$(db_name "$name")"
dir="$BACKUPS_DIR/$name"
psql_admin <<SQL
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
CREATE DATABASE "$db" OWNER "$db";
REVOKE ALL ON DATABASE "$db" FROM PUBLIC;
SQL
# Loaded by the superuser acting as the customer's role, so that role owns everything
{ echo "SET ROLE \"$db\";"; gzip -dc "$dir/$timestamp-db.sql.gz"; } | psql_admin -d "$db" >/dev/null
docker run --rm -i --volume "$(volume_name "$name"):/data" --entrypoint sh "$IMAGE" \
-c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < "$dir/$timestamp-data.tar.gz"
}
cmd_restore() {
[ $# -eq 2 ] || die "Usage: restore <name> <timestamp>"
local name="$1" timestamp="$2" container image
load_env
require_customer "$name"
[ -f "$BACKUPS_DIR/$name/$timestamp-db.sql.gz" ] && [ -f "$BACKUPS_DIR/$name/$timestamp-data.tar.gz" ] \
|| die "Backup $timestamp of '$name' not found in $BACKUPS_DIR/$name"
wait_for_postgres
container="$(container_name "$name")"
# Keep the image the customer runs now; the configured one may be newer than the backup
image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || echo "$IMAGE")"
info "Stopping $container"
docker rm --force --volumes "$container" >/dev/null 2>&1 || true
info "Restoring '$name' from backup $timestamp"
restore_customer "$name" "$timestamp"
run_container "$name" "$image"
wait_healthy "$container" || die "Container $container is not healthy after the restore, see 'docker logs $container'"
info "Customer '$name' restored and running"
}
cmd_upgrade() {
[ $# -ge 1 ] || die "Usage: upgrade <name>...|--all"
load_env
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "Image $IMAGE not found, build or pull it first"
wait_for_postgres
local names=("$@") name container old_image new_image timestamp
if [ "$1" = "--all" ]; then
# shellcheck disable=SC2207
names=($(all_customers))
fi
[ "${#names[@]}" -gt 0 ] || { info "No customers"; return 0; }
new_image="$(docker image inspect --format '{{.Id}}' "$IMAGE")"
for name in "${names[@]}"; do
require_customer "$name"
container="$(container_name "$name")"
old_image="$(docker inspect --format '{{.Image}}' "$container" 2>/dev/null || true)"
if [ "$old_image" = "$new_image" ]; then
info "'$name' already runs $IMAGE, skipping"
continue
fi
info "Upgrading '$name'"
timestamp="$(backup_customer "$name")"
docker rm --force --volumes "$container" >/dev/null 2>&1 || true
run_container "$name" "$IMAGE"
if wait_healthy "$container"; then
info "'$name' upgraded (backup before the upgrade: $timestamp)"
continue
fi
echo "Upgrade of '$name' failed, last log lines of the new container:" >&2
docker logs --tail 20 "$container" >&2 || true
docker rm --force --volumes "$container" >/dev/null 2>&1 || true
if [ -z "$old_image" ]; then
die "'$name' had no container before, nothing to roll back to. Backup: $timestamp"
fi
# Migrations of the new version may have changed the database, so the old version
# gets the database from before the upgrade
info "Rolling '$name' back to the previous image and to backup $timestamp"
restore_customer "$name" "$timestamp"
run_container "$name" "$old_image"
if wait_healthy "$container"; then
die "'$name' was rolled back. Upgrade stopped, the remaining customers were not touched."
fi
die "'$name' is DOWN: the rollback did not become healthy either. Backup: $timestamp"
done
}
cmd_remove() {
[ $# -ge 1 ] || die "Usage: remove <name> [--purge]"
local name="$1" purge="${2:-}" db answer
load_env
require_customer "$name"
info "Removing container $(container_name "$name")"
docker rm --force --volumes "$(container_name "$name")" >/dev/null 2>&1 || true
if [ "$purge" != "--purge" ]; then
info "Database, data volume and config of '$name' were kept. Start it again with 'upgrade $name'."
return 0
fi
read -r -p "This deletes the database, uploaded files and config of '$name'. Type the name to confirm: " answer
[ "$answer" = "$name" ] || die "Not confirmed, nothing else was deleted"
wait_for_postgres
db="$(db_name "$name")"
psql_admin <<SQL
DROP DATABASE IF EXISTS "$db" WITH (FORCE);
DROP ROLE IF EXISTS "$db";
SQL
docker volume rm "$(volume_name "$name")" >/dev/null 2>&1 || true
rm -rf "${CUSTOMERS_DIR:?}/$name"
info "Customer '$name' deleted. Backups in $BACKUPS_DIR/$name were kept."
if [ "$PROXY" = "nginx" ]; then
info "Remove its server block from nginx as well."
fi
}
main() {
[ $# -ge 1 ] || { usage; exit 1; }
local command="$1"
shift
case "$command" in
init) cmd_init "$@" ;;
add) cmd_add "$@" ;;
import) cmd_import "$@" ;;
list) cmd_list "$@" ;;
upgrade) cmd_upgrade "$@" ;;
backup) cmd_backup "$@" ;;
restore) cmd_restore "$@" ;;
remove) cmd_remove "$@" ;;
-h|--help|help) usage ;;
*) usage; exit 1 ;;
esac
}
main "$@"
+4
View File
@@ -0,0 +1,4 @@
-- Banner style is no longer a generated file, the public page gets its settings as CSS variables
ALTER TABLE appearance DROP COLUMN css_name;
ALTER TABLE appearance DROP COLUMN style_wide;
ALTER TABLE appearance DROP COLUMN style_narrow;
+5
View File
@@ -0,0 +1,5 @@
-- Colours of the public page, defaults are the colours of the original template
ALTER TABLE appearance ADD color_primary VARCHAR(7) NOT NULL DEFAULT '#696cff';
ALTER TABLE appearance ADD color_background VARCHAR(7) NOT NULL DEFAULT '#f5f5f9';
ALTER TABLE appearance ADD color_card VARCHAR(7) NOT NULL DEFAULT '#ffffff';
ALTER TABLE appearance ADD color_text VARCHAR(7) NOT NULL DEFAULT '#566a7f';
+8
View File
@@ -0,0 +1,8 @@
-- Look of the public page banner and an optional logo
ALTER TABLE appearance ADD banner_height VARCHAR(10) NOT NULL DEFAULT 'medium';
ALTER TABLE appearance ADD banner_focus_x SMALLINT NOT NULL DEFAULT 50;
ALTER TABLE appearance ADD banner_focus_y SMALLINT NOT NULL DEFAULT 50;
ALTER TABLE appearance ADD banner_dim SMALLINT NOT NULL DEFAULT 30;
ALTER TABLE appearance ADD title_color VARCHAR(7) NOT NULL DEFAULT '#ffffff';
ALTER TABLE appearance ADD title_align VARCHAR(10) NOT NULL DEFAULT 'left';
ALTER TABLE appearance ADD logo VARCHAR;
+5
View File
@@ -0,0 +1,5 @@
-- Layout of the public booking form
ALTER TABLE appearance ADD layout_width VARCHAR(10) NOT NULL DEFAULT 'full';
ALTER TABLE appearance ADD layout_arrangement VARCHAR(10) NOT NULL DEFAULT 'columns';
ALTER TABLE appearance ADD layout_slots VARCHAR(10) NOT NULL DEFAULT 'normal';
ALTER TABLE appearance ADD layout_corners VARCHAR(10) NOT NULL DEFAULT 'rounded';
+4
View File
@@ -0,0 +1,4 @@
-- Customers can cancel their bookings through a link in the e-mail
-- How many hours before the start of a booking the customer may still cancel it, 0 = until the start
ALTER TABLE company ADD cancel_hours INTEGER NOT NULL DEFAULT 0;
ALTER TABLE reservation_sum ADD canceled_by_customer BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,4 @@
-- no-transaction
-- E-mail for the staff about a booking cancelled by the customer. A new enum value can't be
-- added inside a transaction on older PostgreSQL versions, hence the separate file.
ALTER TYPE message_type ADD VALUE IF NOT EXISTS 'CustomerCanceled';
+28 -20
View File
@@ -1,42 +1,48 @@
use crate::locales::init_locales; use crate::locales::init_locales;
use crate::pages::home_page::HomePage; use crate::pages::home_page::HomePage;
use crate::pages::settings::Settings; use crate::pages::settings::Settings;
use leptos::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::*;
use leptos_router::*; use leptos_router::components::{Route, Router, Routes};
use leptos_router::hooks::use_location;
use leptos_router::path;
use crate::components::admin_portal::AdminPortal; use crate::components::admin_portal::AdminPortal;
use crate::components::header::Header; use crate::components::header::Header;
use crate::components::theme::Theme;
use crate::components::user_menu::MenuOpener; use crate::components::user_menu::MenuOpener;
use crate::pages::all_reservations::Bookings; use crate::pages::all_reservations::Bookings;
use crate::pages::appearance_settings::Appearance; use crate::pages::appearance_settings::Appearance;
use crate::pages::customers::Customers; use crate::pages::customers::Customers;
use crate::pages::login::Login; use crate::pages::login::Login;
use crate::pages::mail_settings::MailSettings; use crate::pages::mail_settings::MailSettings;
use crate::pages::public::Public; use crate::pages::public::PublicPage;
use crate::pages::booking::BookingPage;
#[derive(Clone, Copy)] #[derive(Clone, Copy, Debug)]
pub struct MenuHelper { pub struct MenuHelper {
opened: RwSignal<MenuOpener> opened: RwSignal<Option<MenuOpener>>
} }
impl MenuHelper { impl MenuHelper {
pub fn new() -> Self { pub fn new() -> Self {
let opened = create_rw_signal(MenuOpener::new()); let opened = RwSignal::new(None);
Self { Self {
opened opened
} }
} }
pub fn set_opened(&self, opened: MenuOpener) { pub fn set_opened(&self, opened: MenuOpener) {
self.opened.set(opened); self.opened.set(Some(opened));
} }
pub fn close(&self) { pub fn close(&self) {
self.opened.get().close(); if let Some(opened) = self.opened.get_untracked() {
opened.close();
}
} }
pub fn reset(&self) { pub fn reset(&self) {
self.opened.set(MenuOpener::new()); self.opened.set(None);
} }
} }
@@ -47,7 +53,7 @@ pub struct DialogHelper {
impl DialogHelper { impl DialogHelper {
pub fn new() -> Self { pub fn new() -> Self {
let opened = create_rw_signal(false); let opened = RwSignal::new(false);
Self { Self {
opened opened
} }
@@ -68,7 +74,7 @@ fn app_footer() -> impl IntoView {
view! { view! {
<footer class="content-footer footer bg-footer-theme" style={move || if loc.pathname.get().starts_with("/admin") {"display: none;"} else {"display: block;"}}> <footer class="content-footer footer bg-footer-theme" style={move || if loc.pathname.get().starts_with("/admin") {"display: none;"} else {"display: block;"}}>
<div class="mb-2 mb-md-0" align="center"> <div class="mb-2 mb-md-0" style="text-align: center">
<a href="https://rezervovator.cz" target="_blank"><img src="/rezervovator_l.svg" width="110"/></a> {format!(" v {}", env!("CARGO_PKG_VERSION"))} <a href="https://rezervovator.cz" target="_blank"><img src="/rezervovator_l.svg" width="110"/></a> {format!(" v {}", env!("CARGO_PKG_VERSION"))}
</div> </div>
</footer> </footer>
@@ -83,41 +89,43 @@ pub fn App() -> impl IntoView {
provide_context(MenuHelper::new()); // Only one menu can be opened provide_context(MenuHelper::new()); // Only one menu can be opened
provide_context(MenuOpener::new()); // Drawer opener provide_context(MenuOpener::new()); // Drawer opener
provide_context(DialogHelper::new()); // Gray dialog background provide_context(DialogHelper::new()); // Gray dialog background
provide_context(Theme::new()); // Light/dark theme of admin portal
view! { view! {
<Header/> <Header/>
<Router> <Router>
<main> <main>
<Routes> <Routes fallback=|| "Page not found.">
<Route path="" view=|| view! { <Public/> }/> <Route path=path!("") view=|| view! { <PublicPage/> }/>
<Route path="login" view=|| view! { <Login/> }/> <Route path=path!("booking/:uuid") view=|| view! { <BookingPage/> }/>
<Route path="admin" view=|| view! { <Route path=path!("login") view=|| view! { <Login/> }/>
<Route path=path!("admin") view=|| view! {
<AdminPortal> <AdminPortal>
<HomePage/> <HomePage/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/settings" view=|| view! { <Route path=path!("admin/settings") view=|| view! {
<AdminPortal> <AdminPortal>
<Settings/> <Settings/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/mail_settings" view=|| view! { <Route path=path!("admin/mail_settings") view=|| view! {
<AdminPortal> <AdminPortal>
<MailSettings/> <MailSettings/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/bookings" view=|| view! { <Route path=path!("admin/bookings") view=|| view! {
<AdminPortal> <AdminPortal>
<Bookings/> <Bookings/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/customers" view=|| view! { <Route path=path!("admin/customers") view=|| view! {
<AdminPortal> <AdminPortal>
<Customers/> <Customers/>
</AdminPortal> </AdminPortal>
}/> }/>
<Route path="admin/appearance" view=|| view! { <Route path=path!("admin/appearance") view=|| view! {
<AdminPortal> <AdminPortal>
<Appearance/> <Appearance/>
</AdminPortal> </AdminPortal>
+250 -115
View File
@@ -1,28 +1,43 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use server_fn::codec::{MultipartData, MultipartFormData};
use crate::backend::banner::BannerOptions;
use crate::backend::color::Colors;
use crate::backend::data::{ApiResponse, Appearance}; use crate::backend::data::{ApiResponse, Appearance};
use crate::components::data_form::ForValidation; use crate::backend::layout::LayoutOptions;
cfg_if! { if #[cfg(feature = "ssr")] { cfg_if! { if #[cfg(feature = "ssr")] {
use std::fs; use std::fs;
use actix_web::{post, Responder}; use std::path::Path;
use actix_multipart::Multipart;
use actix_session::Session;
use actix_web::web::Redirect;
use std::fs::File;
use std::io::{Write, Read};
use futures_util::{StreamExt, TryStreamExt};
use crate::backend::data::User;
use crate::error::AppError; use crate::error::AppError;
use sqlx::{query, query_as, PgPool}; use sqlx::{query, query_as, PgPool};
use crate::backend::get_pool; use crate::backend::get_pool;
use actix_web::web::Data; use crate::backend::banner::{image_extension, is_image_content, is_stored_image_name, ImageKind, MAX_IMAGE_SIZE};
use crate::backend::AppData; use log::info;
use regex::Regex; use uuid::Uuid;
use rand::Rng;
use rand::distributions::Alphanumeric; /// Creates the data directory and removes the banner styles that older versions generated into it
pub fn ensure_data_dir(data_dir: &Path) -> Result<(), AppError> {
fs::create_dir_all(data_dir)?;
for entry in fs::read_dir(data_dir)? {
let name = entry?.file_name().to_string_lossy().to_string();
if is_old_banner_style(&name) {
info!("Removing unused banner style {}", name);
// Not needed any more, so a failure is not a problem
let _ = fs::remove_file(data_dir.join(&name));
}
}
Ok(())
}
/// `banner.css` template and `banner-<random>.css` styles generated from it
fn is_old_banner_style(name: &str) -> bool {
name == "banner.css" || (name.starts_with("banner-") && name.ends_with(".css"))
}
pub async fn check_appearance(pool: &PgPool) -> Result<(), AppError> { pub async fn check_appearance(pool: &PgPool) -> Result<(), AppError> {
let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance") let count: (i64,) = query_as("SELECT COUNT(id) FROM appearance")
@@ -35,94 +50,48 @@ cfg_if! { if #[cfg(feature = "ssr")] {
.await?; .await?;
} }
let app = query_as::<_, Appearance>("SELECT * FROM appearance").fetch_one(pool).await?;
if let None = app.css_name {
query("UPDATE appearance SET css_name = 'banner.css'").execute(pool).await?;
}
Ok(()) Ok(())
} }
async fn set_banner_name(file_name: &str, pool: &PgPool) -> Result<(), AppError> { /// The multipart codec of server_fn panics when the request has no usable boundary.
query("UPDATE appearance SET banner = $1") /// Such a request to the upload must be refused before it gets there (see `src/main.rs`).
pub fn is_broken_upload(path: &str, content_type: Option<&str>) -> bool {
path.starts_with("/api/upload_image")
&& !content_type.is_some_and(|content_type| multer::parse_boundary(content_type).is_ok())
}
/// Name under which an uploaded image is stored. It is always a new one, so a replaced image
/// is never served from the browser cache, and nothing of the name sent by the browser gets
/// into the file system except the extension.
fn new_image_name(kind: ImageKind, extension: &str) -> String {
let id = Uuid::new_v4().simple().to_string();
format!("{}-{}.{}", kind.name(), &id[..8], extension)
}
/// Stores the name of the image (or none) and removes the file of the previous one
async fn replace_image(kind: ImageKind, file_name: Option<&str>, pool: &PgPool, data_dir: &Path) -> Result<(), AppError> {
// The column name comes from the enum, never from the request
let column = kind.name();
let old: (Option<String>,) = query_as(&format!("SELECT {} FROM appearance", column))
.fetch_one(pool)
.await?;
query(&format!("UPDATE appearance SET {} = $1", column))
.bind(file_name) .bind(file_name)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) if let Some(old) = old.0 {
} if is_stored_image_name(&old) && Some(old.as_str()) != file_name {
// The file may be already gone
async fn modify_style(file_name: &str, pool: &PgPool) -> Result<(), AppError> { let _ = fs::remove_file(data_dir.join(old));
let mut css_file = File::open("target/site/data/banner.css")?; }
let mut css_str= String::new();
css_file.read_to_string(&mut css_str)?;
if css_str.contains("/*bg-img*/") {
css_str = css_str.replace("/*bg-img*/", &format!("background-image: url('{}');", file_name));
} else {
let re = Regex::new(r#"background-image: url\('[aA-zZ._0-9\-]+'\)"#).unwrap();
css_str = re.replace(&css_str, &format!("background-image: url('{}')", file_name)).to_string();
} }
let old_css: (String,) = query_as("SELECT css_name FROM appearance")
.fetch_one(pool)
.await?;
if old_css.0 != "banner.css" {
fs::remove_file(format!("target/site/data/{}", old_css.0))?;
}
let s: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(5)
.map(char::from)
.collect();
let css_name = format!("banner-{}.css", s);
let mut css_file = File::create(format!("target/site/data/{}", css_name))?;
css_file.write_all(css_str.as_bytes())?;
query("UPDATE appearance SET css_name = $1")
.bind(css_name)
.execute(pool)
.await?;
Ok(()) Ok(())
} }
#[post("/upload")]
pub async fn upload(mut data: Multipart, session: Session, app_data: Data<AppData>) -> impl Responder {
let user = session.get::<User>("user").unwrap_or(None);
if user.is_none() {
return Redirect::to("/login").see_other();
}
if let Some(u) = user {
if !u.admin {
return Redirect::to("/admin/appearance").see_other();
}
}
if let Ok(Some(mut field)) = data.try_next().await {
let content_disp = field.content_disposition();
let file_name = content_disp.unwrap().get_filename().unwrap().to_string();
if file_name.is_empty() {
return Redirect::to("/admin/appearance").see_other();
}
let mut file = File::create(format!("target/site/data/{}", file_name)).unwrap();
let _name = field.name();
while let Some(chunk) = field.next().await {
let c = chunk.unwrap();
let _ = file.write_all(&c);
}
let _ = set_banner_name(&file_name, &app_data.db_pool).await;
let _ = modify_style(&file_name, &app_data.db_pool).await;
}
Redirect::to("/admin/appearance").see_other()
}
}} }}
#[server] #[server]
@@ -135,48 +104,214 @@ pub async fn get_appearance() -> Result<Appearance, ServerFnError> {
Ok(appearance) Ok(appearance)
} }
/// Saves everything that is edited on the appearance page except the images: title and text of
/// the public page, its colours, the options of the banner and the layout of the form.
/// Returns the text as it was stored, that is after sanitization.
#[server] #[server]
pub async fn update_appearance(appearance: Appearance) -> Result<ApiResponse<()>, ServerFnError> { pub async fn save_appearance(
title: String,
text: String,
colors: Colors,
banner: BannerOptions,
layout: LayoutOptions) -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
use crate::backend::rich_text::{sanitize_text, MAX_TEXT_LENGTH, MAX_TITLE_LENGTH};
use validator::Validate;
perm_check!(is_admin); perm_check!(is_admin);
let title = title.trim();
if title.chars().count() > MAX_TITLE_LENGTH {
return Ok(ApiResponse::Error("Title is too long".to_string()))
}
if text.len() > MAX_TEXT_LENGTH {
return Ok(ApiResponse::Error("Text is too long".to_string()))
}
// The values end up in style and class attributes of the public page
if colors.validate().is_err() {
return Ok(ApiResponse::Error("Invalid colour".to_string()))
}
if banner.validate().is_err() {
return Ok(ApiResponse::Error("Invalid banner settings".to_string()))
}
if layout.validate().is_err() {
return Ok(ApiResponse::Error("Invalid layout".to_string()))
}
let pool = get_pool().await?; let pool = get_pool().await?;
let id = appearance.id(); // The text is shown to every visitor as HTML
query("UPDATE appearance SET title = $1, text = $2 WHERE id = $3") let text = sanitize_text(&text);
.bind(appearance.title) let colors = colors.sanitized();
.bind(appearance.text) let banner = banner.sanitized();
.bind(id) let layout = layout.sanitized();
query("UPDATE appearance SET title = $1, text = $2, \
color_primary = $3, color_background = $4, color_card = $5, color_text = $6, \
banner_height = $7, banner_focus_x = $8, banner_focus_y = $9, banner_dim = $10, title_color = $11, title_align = $12, \
layout_width = $13, layout_arrangement = $14, layout_slots = $15, layout_corners = $16")
.bind(title)
.bind(&text)
.bind(colors.primary)
.bind(colors.background)
.bind(colors.card)
.bind(colors.text)
.bind(banner.height)
.bind(banner.focus_x)
.bind(banner.focus_y)
.bind(banner.dim)
.bind(banner.title_color)
.bind(banner.title_align)
.bind(layout.width)
.bind(layout.arrangement)
.bind(layout.slots)
.bind(layout.corners)
.execute(&pool) .execute(&pool)
.await?; .await?;
Ok(ApiResponse::Data(())) Ok(ApiResponse::Data(text.unwrap_or_default()))
} }
impl ForValidation for UpdateAppearance { /// Upload of the banner image or the logo. The form has the fields `kind` (`banner` or `logo`,
fn entity(&self) -> &dyn Validate { /// it must come first) and `file`.
&self.appearance #[server(input = MultipartFormData)]
pub async fn upload_image(data: MultipartData) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check;
use crate::backend::get_data_dir;
perm_check!(is_admin);
let Some(mut data) = data.into_inner() else {
return Ok(ApiResponse::Error("No file was sent".to_string()))
};
let mut kind = None;
while let Some(mut field) = data.next_field().await.map_err(|e| ServerFnError::new(e.to_string()))? {
match field.name() {
Some("kind") => {
let name = field.text().await.map_err(|e| ServerFnError::new(e.to_string()))?;
kind = ImageKind::from_name(&name);
}
Some("file") => {
let Some(kind) = kind else {
return Ok(ApiResponse::Error("Invalid upload".to_string()))
};
let Some(extension) = image_extension(field.file_name().unwrap_or_default()) else {
return Ok(ApiResponse::Error("Unsupported image type, use JPG, PNG, GIF or WebP".to_string()))
};
// The limit is small enough to keep the image in memory until it is known to be acceptable
let mut content: Vec<u8> = vec![];
while let Some(chunk) = field.chunk().await.map_err(|e| ServerFnError::new(e.to_string()))? {
if content.len() + chunk.len() > MAX_IMAGE_SIZE {
return Ok(ApiResponse::Error("Image is too large, 5 MB at most".to_string()))
}
content.extend_from_slice(&chunk);
}
if !is_image_content(&extension, &content) {
return Ok(ApiResponse::Error("Unsupported image type, use JPG, PNG, GIF or WebP".to_string()))
}
let pool = get_pool().await?;
let data_dir = get_data_dir().await?;
let file_name = new_image_name(kind, &extension);
fs::write(data_dir.join(&file_name), &content).map_err(AppError::from)?;
replace_image(kind, Some(&file_name), &pool, &data_dir).await?;
return Ok(ApiResponse::Data(()))
}
_ => {}
}
} }
Ok(ApiResponse::Error("No file was sent".to_string()))
} }
#[server] #[server]
pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> { pub async fn delete_banner() -> Result<ApiResponse<()>, ServerFnError> {
use std::fs;
use crate::perm_check; use crate::perm_check;
use crate::backend::get_data_dir;
perm_check!(is_admin); perm_check!(is_admin);
let appearance = get_appearance().await?; replace_image(ImageKind::Banner, None, &get_pool().await?, &get_data_dir().await?).await?;
let pool = get_pool().await?;
query("UPDATE appearance SET banner = $1 WHERE id = $2")
.bind(None::<String>)
.bind(appearance.id())
.execute(&pool)
.await?;
if let Some(f) = appearance.banner {
fs::remove_file(format!("target/site/data/{}", f))?;
}
Ok(ApiResponse::Data(())) Ok(ApiResponse::Data(()))
} }
#[server]
pub async fn delete_logo() -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check;
use crate::backend::get_data_dir;
perm_check!(is_admin);
replace_image(ImageKind::Logo, None, &get_pool().await?, &get_data_dir().await?).await?;
Ok(ApiResponse::Data(()))
}
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
#[test]
fn new_image_names_are_safe_and_unique() {
let first = new_image_name(ImageKind::Banner, "jpg");
let second = new_image_name(ImageKind::Banner, "jpg");
assert!(first.starts_with("banner-") && first.ends_with(".jpg"), "name: {}", first);
assert_eq!(first.len(), "banner-12345678.jpg".len());
assert!(is_stored_image_name(&first));
assert_ne!(first, second);
assert!(new_image_name(ImageKind::Logo, "png").starts_with("logo-"));
// A new banner must not look like a style generated by old versions, those are removed on start
assert!(!is_old_banner_style(&first));
}
#[test]
fn broken_uploads_are_recognized() {
let upload = "/api/upload_image8164345504715272186";
assert!(!is_broken_upload(upload, Some("multipart/form-data; boundary=----WebKitFormBoundaryabc")));
assert!(is_broken_upload(upload, Some("multipart/form-data")));
assert!(is_broken_upload(upload, Some("application/x-www-form-urlencoded")));
assert!(is_broken_upload(upload, Some("")));
assert!(is_broken_upload(upload, None));
// Other server functions are not multipart
assert!(!is_broken_upload("/api/update_style123", Some("application/x-www-form-urlencoded")));
assert!(!is_broken_upload("/", None));
}
#[test]
fn ensure_data_dir_creates_directory() {
let base = std::env::temp_dir().join(format!("rezervator-test-data-{}", std::process::id()));
let data_dir = base.join("data/nested");
ensure_data_dir(&data_dir).unwrap();
assert!(data_dir.is_dir());
// An existing directory is fine
ensure_data_dir(&data_dir).unwrap();
fs::remove_dir_all(base).unwrap();
}
#[test]
fn ensure_data_dir_removes_only_old_banner_styles() {
let data_dir = std::env::temp_dir().join(format!("rezervator-test-styles-{}", std::process::id()));
fs::create_dir_all(&data_dir).unwrap();
for name in ["banner.css", "banner-DnyIx.css", "banner.jpg", "banner-1.png", "other.css"] {
fs::write(data_dir.join(name), "x").unwrap();
}
ensure_data_dir(&data_dir).unwrap();
assert!(!data_dir.join("banner.css").exists());
assert!(!data_dir.join("banner-DnyIx.css").exists());
assert!(data_dir.join("banner.jpg").exists());
assert!(data_dir.join("banner-1.png").exists());
assert!(data_dir.join("other.css").exists());
fs::remove_dir_all(data_dir).unwrap();
}
}
+318
View File
@@ -0,0 +1,318 @@
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
use crate::backend::color::is_color;
/// Largest image accepted as a banner or a logo
pub const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024;
/// The image can be darkened by this many percent at most, more would hide it
pub const MAX_DIM: i16 = 80;
const IMAGE_EXTENSIONS: [&str; 5] = ["jpg", "jpeg", "png", "gif", "webp"];
/// Value for the `accept` attribute of the file inputs
pub const IMAGE_ACCEPT: &str = ".jpg,.jpeg,.png,.gif,.webp";
const DEFAULT_HEIGHT: &str = "medium";
const DEFAULT_ALIGN: &str = "left";
const DEFAULT_TITLE_COLOR: &str = "#ffffff";
const DEFAULT_DIM: i16 = 30;
/// Banner heights: name, height in pixels on a narrow and on a wide screen
const HEIGHTS: [(&str, u16, u16); 3] = [
("low", 70, 120),
(DEFAULT_HEIGHT, 100, 200),
("high", 150, 300),
];
/// Title alignments: name and the matching `justify-content` value
const ALIGNS: [(&str, &str); 2] = [
(DEFAULT_ALIGN, "flex-start"),
("center", "center"),
];
/// The two images a customer can upload
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageKind {
Banner,
Logo,
}
impl ImageKind {
/// Value of the form field that says what is being uploaded
pub fn name(&self) -> &'static str {
match self {
ImageKind::Banner => "banner",
ImageKind::Logo => "logo",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"banner" => Some(ImageKind::Banner),
"logo" => Some(ImageKind::Logo),
_ => None,
}
}
}
/// How the banner of the public page looks, set by the customer in the appearance settings
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct BannerOptions {
/// `low`, `medium` or `high`
#[cfg_attr(feature = "ssr", sqlx(rename = "banner_height"))]
#[validate(custom(function = "validate_height", message = "Invalid banner height"))]
pub height: String,
/// Point of the image that stays visible when it is cropped, in percent from the left and from the top
#[cfg_attr(feature = "ssr", sqlx(rename = "banner_focus_x"))]
#[validate(range(min = 0, max = 100, message = "Invalid banner position"))]
pub focus_x: i16,
#[cfg_attr(feature = "ssr", sqlx(rename = "banner_focus_y"))]
#[validate(range(min = 0, max = 100, message = "Invalid banner position"))]
pub focus_y: i16,
/// Darkening of the image in percent, so the title stays readable
#[cfg_attr(feature = "ssr", sqlx(rename = "banner_dim"))]
#[validate(range(min = 0, max = 80, message = "Invalid banner darkening"))]
pub dim: i16,
#[validate(custom(function = "validate_title_color", message = "Invalid colour"))]
pub title_color: String,
/// `left` or `center`
#[validate(custom(function = "validate_align", message = "Invalid title alignment"))]
pub title_align: String,
}
impl Default for BannerOptions {
fn default() -> Self {
BannerOptions {
height: DEFAULT_HEIGHT.to_string(),
focus_x: 50,
focus_y: 50,
dim: DEFAULT_DIM,
title_color: DEFAULT_TITLE_COLOR.to_string(),
title_align: DEFAULT_ALIGN.to_string(),
}
}
}
impl BannerOptions {
/// Replaces values that are not valid with the default ones, so the result is safe to put into a style
pub fn sanitized(&self) -> Self {
let defaults = BannerOptions::default();
BannerOptions {
height: if is_height(&self.height) { self.height.clone() } else { defaults.height },
focus_x: self.focus_x.clamp(0, 100),
focus_y: self.focus_y.clamp(0, 100),
dim: self.dim.clamp(0, MAX_DIM),
title_color: if is_color(&self.title_color) { self.title_color.to_lowercase() } else { defaults.title_color },
title_align: if is_align(&self.title_align) { self.title_align.clone() } else { defaults.title_align },
}
}
/// CSS variables for the banner, without the image. An image is darkened, the plain strip
/// shown when there is no image is not.
pub fn style(&self, has_image: bool) -> String {
let options = self.sanitized();
let (_, narrow, wide) = HEIGHTS.iter().find(|h| h.0 == options.height).unwrap_or(&HEIGHTS[1]);
let (_, justify) = ALIGNS.iter().find(|a| a.0 == options.title_align).unwrap_or(&ALIGNS[0]);
let dim = if has_image { options.dim } else { 0 };
format!("--rz-banner-height: {}px; --rz-banner-height-wide: {}px; --rz-banner-position: {}% {}%; \
--rz-banner-dim: {}.{:02}; --rz-banner-title: {}; --rz-banner-justify: {}",
narrow, wide, options.focus_x, options.focus_y, dim / 100, dim % 100, options.title_color, justify)
}
}
fn is_height(height: &str) -> bool {
HEIGHTS.iter().any(|h| h.0 == height)
}
fn is_align(align: &str) -> bool {
ALIGNS.iter().any(|a| a.0 == align)
}
fn validate_height(height: &str) -> Result<(), ValidationError> {
if is_height(height) { Ok(()) } else { Err(ValidationError::new("invalid height")) }
}
fn validate_align(align: &str) -> Result<(), ValidationError> {
if is_align(align) { Ok(()) } else { Err(ValidationError::new("invalid align")) }
}
fn validate_title_color(color: &str) -> Result<(), ValidationError> {
if is_color(color) { Ok(()) } else { Err(ValidationError::new("invalid color")) }
}
/// Lower-case extension of an image that can be used as a banner or a logo, taken from the name
/// of the uploaded file. Nothing else of that name is used.
pub fn image_extension(file_name: &str) -> Option<String> {
let (stem, extension) = file_name.rsplit_once('.')?;
let extension = extension.to_lowercase();
if stem.is_empty() || !IMAGE_EXTENSIONS.contains(&extension.as_str()) {
return None
}
Some(extension)
}
/// Checks the first bytes of a file against the format its extension claims
pub fn is_image_content(extension: &str, head: &[u8]) -> bool {
match extension {
"jpg" | "jpeg" => head.starts_with(&[0xff, 0xd8, 0xff]),
"png" => head.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]),
"gif" => head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a"),
"webp" => head.len() >= 12 && head.starts_with(b"RIFF") && &head[8..12] == b"WEBP",
_ => false,
}
}
/// Files are stored under names made by the server (`banner-1a2b3c4d.jpg`); only such a name
/// may get into a URL or a style.
pub fn is_stored_image_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('.')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_options_are_valid() {
let options = BannerOptions::default();
assert!(options.validate().is_ok());
assert_eq!(options, options.sanitized());
}
#[test]
fn validation_rejects_other_values() {
let invalid: [fn(&mut BannerOptions); 8] = [
|o| o.height = "huge".to_string(),
|o| o.height = "100px; color: red".to_string(),
|o| o.focus_x = -1,
|o| o.focus_y = 101,
|o| o.dim = 81,
|o| o.dim = -5,
|o| o.title_color = "white".to_string(),
|o| o.title_align = "right".to_string(),
];
for (index, change) in invalid.iter().enumerate() {
let mut options = BannerOptions::default();
change(&mut options);
assert!(options.validate().is_err(), "case {}", index);
}
}
#[test]
fn validation_accepts_all_listed_values() {
for height in ["low", "medium", "high"] {
for align in ["left", "center"] {
let options = BannerOptions { height: height.to_string(), title_align: align.to_string(),
focus_x: 0, focus_y: 100, dim: 80, title_color: "#000000".to_string() };
assert!(options.validate().is_ok(), "{} {}", height, align);
}
}
}
#[test]
fn sanitized_replaces_invalid_values() {
let options = BannerOptions {
height: "x".to_string(),
focus_x: -20,
focus_y: 300,
dim: 100,
title_color: "red; x: y".to_string(),
title_align: "justify".to_string(),
}.sanitized();
assert_eq!(options, BannerOptions { focus_x: 0, focus_y: 100, dim: 80, ..Default::default() });
}
#[test]
fn style_of_default_options() {
assert_eq!(BannerOptions::default().style(true),
"--rz-banner-height: 100px; --rz-banner-height-wide: 200px; --rz-banner-position: 50% 50%; \
--rz-banner-dim: 0.30; --rz-banner-title: #ffffff; --rz-banner-justify: flex-start");
}
#[test]
fn style_of_changed_options() {
let options = BannerOptions { height: "high".to_string(), focus_x: 10, focus_y: 85, dim: 5,
title_color: "#102030".to_string(), title_align: "center".to_string() };
assert_eq!(options.style(true),
"--rz-banner-height: 150px; --rz-banner-height-wide: 300px; --rz-banner-position: 10% 85%; \
--rz-banner-dim: 0.05; --rz-banner-title: #102030; --rz-banner-justify: center");
}
#[test]
fn style_without_image_is_not_darkened() {
assert!(BannerOptions::default().style(false).contains("--rz-banner-dim: 0.00;"));
}
#[test]
fn style_never_contains_invalid_values() {
let options = BannerOptions { height: "1px; x: url(y)".to_string(), title_color: "</style>".to_string(),
title_align: "\"".to_string(), ..Default::default() };
assert_eq!(options.style(true), BannerOptions::default().style(true));
}
#[test]
fn image_extension_of_supported_files() {
assert_eq!(image_extension("banner1.jpg"), Some("jpg".to_string()));
assert_eq!(image_extension("My Photo (1).JPEG"), Some("jpeg".to_string()));
assert_eq!(image_extension("../../x.PNG"), Some("png".to_string()));
assert_eq!(image_extension("a.tar.webp"), Some("webp".to_string()));
assert_eq!(image_extension("a.gif"), Some("gif".to_string()));
}
#[test]
fn image_extension_rejects_other_files() {
for name in ["", "banner", "banner.css", "config.toml", "index.html", "script.js", "image.svg", ".jpg", "..", "dir/", "a.jpg.exe"] {
assert_eq!(image_extension(name), None, "name: {:?}", name);
}
}
#[test]
fn image_content_matches_extension() {
let jpeg = [0xff, 0xd8, 0xff, 0xe0, 0, 0];
let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0];
let webp = b"RIFF\x10\x00\x00\x00WEBPVP8 ";
assert!(is_image_content("jpg", &jpeg));
assert!(is_image_content("jpeg", &jpeg));
assert!(is_image_content("png", &png));
assert!(is_image_content("gif", b"GIF89a..."));
assert!(is_image_content("gif", b"GIF87a..."));
assert!(is_image_content("webp", webp));
assert!(!is_image_content("png", &jpeg));
assert!(!is_image_content("jpg", &png));
assert!(!is_image_content("jpg", b"<html><script>"));
assert!(!is_image_content("gif", b"GIF"));
assert!(!is_image_content("webp", b"RIFF\x10\x00\x00\x00WAVE"));
assert!(!is_image_content("webp", b"RIFF"));
assert!(!is_image_content("svg", b"<svg"));
assert!(!is_image_content("jpg", &[]));
}
#[test]
fn stored_image_names() {
assert!(is_stored_image_name("banner-1a2b3c4d.jpg"));
assert!(is_stored_image_name("banner1.jpg"));
for name in ["", "a'); color: red; x: url('.jpg", "a b.jpg", "../config.toml", "a/b.jpg", "a\\b.jpg", "a\"b.jpg",
"a;b.jpg", "a)b.jpg", ".hidden"] {
assert!(!is_stored_image_name(name), "name: {:?}", name);
}
}
#[test]
fn image_kind_names() {
for kind in [ImageKind::Banner, ImageKind::Logo] {
assert_eq!(ImageKind::from_name(kind.name()), Some(kind));
}
assert_eq!(ImageKind::from_name("config"), None);
}
}
+188
View File
@@ -0,0 +1,188 @@
//! Cancelling of a booking by the customer, through the link sent in e-mails.
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::backend::data::ReservationState;
/// Longest deadline that can be set, in hours before the start of a booking (30 days)
pub const MAX_CANCEL_HOURS: i32 = 720;
/// Placeholder of the link to the booking page in mail templates
pub const LINK_PLACEHOLDER: &str = "#link#";
/// Whether the customer may cancel a booking
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CancelStatus {
/// It can be cancelled until the given time
Allowed(NaiveDateTime),
AlreadyCanceled,
/// It could be cancelled until the given time, which has passed
TooLate(NaiveDateTime),
}
/// A booking starts with the earliest of its items. One without items (which should not exist)
/// counts from the beginning of its day.
pub fn booking_start(date: NaiveDate, item_starts: &[NaiveTime]) -> NaiveDateTime {
date.and_time(item_starts.iter().min().copied().unwrap_or(NaiveTime::MIN))
}
/// Last moment when the customer may cancel: the given number of hours before the start.
/// A negative or too large setting is treated as the nearest allowed one.
pub fn cancel_deadline(start: NaiveDateTime, cancel_hours: i32) -> NaiveDateTime {
start - Duration::hours(cancel_hours.clamp(0, MAX_CANCEL_HOURS) as i64)
}
/// Bookings waiting for approval and approved ones can be cancelled until the deadline
pub fn cancel_status(state: &ReservationState, start: NaiveDateTime, cancel_hours: i32, now: NaiveDateTime) -> CancelStatus {
let deadline = cancel_deadline(start, cancel_hours);
if *state == ReservationState::Canceled {
CancelStatus::AlreadyCanceled
} else if now < deadline {
CancelStatus::Allowed(deadline)
} else {
CancelStatus::TooLate(deadline)
}
}
/// Address of the page of a booking. The uuid is the only secret that protects it.
pub fn booking_link(base_url: &str, uuid: &str) -> String {
format!("{}/booking/{}", base_url.trim_end_matches('/'), uuid)
}
/// Puts the link to the booking page into the text of an e-mail: in place of every `#link#`.
/// A text without the placeholder gets the link at its end, introduced by the given sentence,
/// when there is one; otherwise it is left as it is.
pub fn insert_link(text: &str, link: &str, intro_when_missing: Option<&str>) -> String {
if text.contains(LINK_PLACEHOLDER) {
return text.replace(LINK_PLACEHOLDER, link)
}
match intro_when_missing {
Some(intro) => format!("{}\n\n{}\n{}", text.trim_end(), intro, link),
None => text.to_string(),
}
}
/// Cleans up the public address from the config: no trailing slash, and only `http(s)://host[:port][/path]`.
/// The address gets into e-mails, so anything suspicious is refused.
pub fn normalize_base_url(url: &str) -> Option<String> {
let url = url.trim().trim_end_matches('/');
let rest = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://"))?;
if rest.is_empty() || rest.starts_with('/')
|| !rest.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '/' | '[' | ']')) {
return None
}
Some(url.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn date(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, day).unwrap()
}
fn time(hour: u32, minute: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, minute, 0).unwrap()
}
fn at(day: u32, hour: u32, minute: u32) -> NaiveDateTime {
date(day).and_time(time(hour, minute))
}
#[test]
fn booking_starts_with_earliest_item() {
assert_eq!(booking_start(date(20), &[time(18, 0), time(9, 30), time(14, 0)]), at(20, 9, 30));
assert_eq!(booking_start(date(20), &[time(18, 0)]), at(20, 18, 0));
assert_eq!(booking_start(date(20), &[]), at(20, 0, 0));
}
#[test]
fn deadline_is_hours_before_start() {
assert_eq!(cancel_deadline(at(20, 18, 0), 0), at(20, 18, 0));
assert_eq!(cancel_deadline(at(20, 18, 0), 2), at(20, 16, 0));
// Over midnight
assert_eq!(cancel_deadline(at(20, 9, 0), 24), at(19, 9, 0));
assert_eq!(cancel_deadline(at(20, 1, 0), 3), at(19, 22, 0));
}
#[test]
fn deadline_ignores_invalid_settings() {
assert_eq!(cancel_deadline(at(20, 18, 0), -5), at(20, 18, 0));
assert_eq!(cancel_deadline(at(20, 18, 0), i32::MAX), at(20, 18, 0) - Duration::hours(MAX_CANCEL_HOURS as i64));
}
#[test]
fn new_and_approved_bookings_can_be_cancelled_before_deadline() {
for state in [ReservationState::New, ReservationState::Approved] {
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 17, 59)), CancelStatus::Allowed(at(20, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(19, 17, 59)), CancelStatus::Allowed(at(19, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(10, 0, 0)), CancelStatus::Allowed(at(19, 18, 0)));
}
}
#[test]
fn bookings_cannot_be_cancelled_from_the_deadline_on() {
for state in [ReservationState::New, ReservationState::Approved] {
// Exactly at the start, after it, and in the past
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 18, 0)), CancelStatus::TooLate(at(20, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(20, 18, 1)), CancelStatus::TooLate(at(20, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 0, at(25, 0, 0)), CancelStatus::TooLate(at(20, 18, 0)));
// Inside the deadline, although the booking has not started yet
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(19, 18, 0)), CancelStatus::TooLate(at(19, 18, 0)));
assert_eq!(cancel_status(&state, at(20, 18, 0), 24, at(20, 10, 0)), CancelStatus::TooLate(at(19, 18, 0)));
}
}
#[test]
fn cancelled_booking_stays_cancelled() {
assert_eq!(cancel_status(&ReservationState::Canceled, at(20, 18, 0), 0, at(1, 0, 0)), CancelStatus::AlreadyCanceled);
assert_eq!(cancel_status(&ReservationState::Canceled, at(20, 18, 0), 0, at(25, 0, 0)), CancelStatus::AlreadyCanceled);
}
#[test]
fn booking_link_joins_address_and_uuid() {
let uuid = "0b6ff0a4-2f4b-4c57-9d0a-0d4f7f0f3b11";
assert_eq!(booking_link("https://booking.example.com", uuid), format!("https://booking.example.com/booking/{}", uuid));
assert_eq!(booking_link("https://booking.example.com/", uuid), format!("https://booking.example.com/booking/{}", uuid));
assert_eq!(booking_link("http://localhost:3000", uuid), format!("http://localhost:3000/booking/{}", uuid));
}
#[test]
fn insert_link_replaces_placeholder() {
assert_eq!(insert_link("See #link# for details.", "https://x/booking/1", Some("Intro:")), "See https://x/booking/1 for details.");
assert_eq!(insert_link("#link#\n#link#", "L", None), "L\nL");
}
#[test]
fn insert_link_appends_when_placeholder_is_missing() {
assert_eq!(insert_link("Thank you.\n", "https://x/booking/1", Some("Your booking:")),
"Thank you.\n\nYour booking:\nhttps://x/booking/1");
assert_eq!(insert_link("", "L", Some("Intro:")), "\n\nIntro:\nL");
}
#[test]
fn insert_link_leaves_text_without_intro() {
assert_eq!(insert_link("Thank you.\n", "https://x/booking/1", None), "Thank you.\n");
}
#[test]
fn base_url_is_normalized() {
assert_eq!(normalize_base_url("https://booking.example.com"), Some("https://booking.example.com".to_string()));
assert_eq!(normalize_base_url(" https://booking.example.com/ "), Some("https://booking.example.com".to_string()));
assert_eq!(normalize_base_url("http://localhost:3000"), Some("http://localhost:3000".to_string()));
assert_eq!(normalize_base_url("https://example.com/app"), Some("https://example.com/app".to_string()));
assert_eq!(normalize_base_url("http://[::1]:3000"), Some("http://[::1]:3000".to_string()));
}
#[test]
fn base_url_rejects_other_values() {
for url in ["", "booking.example.com", "ftp://example.com", "https://", "https:///path", "javascript:alert(1)",
"https://example.com/?x=1", "https://exa mple.com", "https://example.com\nBcc: x@y.z", "https://user@example.com",
"https://example.com/<script>"] {
assert_eq!(normalize_base_url(url), None, "url: {:?}", url);
}
}
}
+292
View File
@@ -0,0 +1,292 @@
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
/// Text is considered readable from this contrast ratio up (WCAG AA for normal text)
pub const MIN_TEXT_CONTRAST: f64 = 4.5;
/// Buttons and time slots drawn in the primary colour should have at least this contrast (WCAG AA for UI components)
pub const MIN_PRIMARY_CONTRAST: f64 = 3.0;
const DEFAULT_PRIMARY: &str = "#696cff";
const DEFAULT_BACKGROUND: &str = "#f5f5f9";
const DEFAULT_CARD: &str = "#ffffff";
const DEFAULT_TEXT: &str = "#566a7f";
/// Ready-made colour sets offered in the appearance settings: name, primary, background, card, text.
/// The name is a translation key.
pub const PALETTES: [(&str, &str, &str, &str, &str); 8] = [
("Violet", DEFAULT_PRIMARY, DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT),
("Green", "#1a8f6a", "#f1f7f4", "#ffffff", "#33443d"),
("Blue", "#1b66b1", "#eef3f8", "#ffffff", "#2c3e50"),
("Orange", "#c9501f", "#faf3ef", "#ffffff", "#4a3a33"),
("Pink", "#c2416f", "#fbf2f5", "#ffffff", "#4b3640"),
("Sand", "#8a6a2f", "#f4efe6", "#fffdf8", "#4a4235"),
("Graphite", "#ef9f27", "#1f2128", "#2b2e38", "#d8dae3"),
("Night", "#6ea8fe", "#0f1b2d", "#182a44", "#d3dcea"),
];
/// Colours of the public booking page, set by the customer in the appearance settings
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct Colors {
/// Buttons, selected time slots, links
#[cfg_attr(feature = "ssr", sqlx(rename = "color_primary"))]
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
pub primary: String,
/// Page background
#[cfg_attr(feature = "ssr", sqlx(rename = "color_background"))]
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
pub background: String,
/// Background of cards, inputs and dialogs
#[cfg_attr(feature = "ssr", sqlx(rename = "color_card"))]
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
pub card: String,
/// Headings and labels; body text is a slightly softer shade derived in CSS
#[cfg_attr(feature = "ssr", sqlx(rename = "color_text"))]
#[validate(custom(function = "validate_color", message = "Invalid colour"))]
pub text: String,
}
impl Default for Colors {
fn default() -> Self {
Colors::new(DEFAULT_PRIMARY, DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT)
}
}
/// What is hard to see with a set of colours
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContrastIssue {
TextOnCard,
TextOnBackground,
PrimaryOnCard,
}
impl ContrastIssue {
/// Translation key of the warning
pub fn message(&self) -> &'static str {
match self {
ContrastIssue::TextOnCard => "Text is hard to read on the card background",
ContrastIssue::TextOnBackground => "Text is hard to read on the page background",
ContrastIssue::PrimaryOnCard => "Primary colour is hard to see on the card background",
}
}
}
impl Colors {
pub fn new(primary: &str, background: &str, card: &str, text: &str) -> Self {
Colors {
primary: primary.to_string(),
background: background.to_string(),
card: card.to_string(),
text: text.to_string(),
}
}
pub fn palette(index: usize) -> Option<Self> {
PALETTES.get(index).map(|(_, primary, background, card, text)| Colors::new(primary, background, card, text))
}
/// Replaces values that are not colours with the default ones, so the result is safe to put into a style
pub fn sanitized(&self) -> Self {
let pick = |value: &str, default: &str| {
if is_color(value) { value.to_lowercase() } else { default.to_string() }
};
Colors {
primary: pick(&self.primary, DEFAULT_PRIMARY),
background: pick(&self.background, DEFAULT_BACKGROUND),
card: pick(&self.card, DEFAULT_CARD),
text: pick(&self.text, DEFAULT_TEXT),
}
}
/// CSS variables for the wrapper of the public page, used as its `style` attribute.
/// The styles themselves are in `assets/css/public.css`.
pub fn style(&self) -> String {
let colors = self.sanitized();
format!("--rz-primary: {}; --rz-bg: {}; --rz-card: {}; --rz-text: {}; --rz-on-primary: {}; --rz-scheme: {}",
colors.primary,
colors.background,
colors.card,
colors.text,
text_color_on(&colors.primary),
if is_dark(&colors.card) { "dark" } else { "light" })
}
pub fn contrast_issues(&self) -> Vec<ContrastIssue> {
let colors = self.sanitized();
let mut issues = vec![];
if contrast(&colors.text, &colors.card) < MIN_TEXT_CONTRAST {
issues.push(ContrastIssue::TextOnCard);
}
if contrast(&colors.text, &colors.background) < MIN_TEXT_CONTRAST {
issues.push(ContrastIssue::TextOnBackground);
}
if contrast(&colors.primary, &colors.card) < MIN_PRIMARY_CONTRAST {
issues.push(ContrastIssue::PrimaryOnCard);
}
issues
}
}
fn validate_color(color: &str) -> Result<(), ValidationError> {
if is_color(color) {
Ok(())
} else {
Err(ValidationError::new("invalid color"))
}
}
/// Only the `#rrggbb` form is a colour here, it is what `<input type="color">` produces
pub fn is_color(color: &str) -> bool {
parse_color(color).is_some()
}
fn parse_color(color: &str) -> Option<(u8, u8, u8)> {
let hex = color.strip_prefix('#')?;
if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None
}
let channel = |from: usize| u8::from_str_radix(&hex[from..from + 2], 16).ok();
Some((channel(0)?, channel(2)?, channel(4)?))
}
/// Relative luminance by WCAG 2: 0 for black, 1 for white. An invalid colour counts as black.
pub fn luminance(color: &str) -> f64 {
let (r, g, b) = parse_color(color).unwrap_or((0, 0, 0));
let linear = |channel: u8| {
let c = channel as f64 / 255.0;
if c <= 0.03928 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
/// Contrast ratio by WCAG 2, from 1 (same colours) to 21 (black and white)
pub fn contrast(first: &str, second: &str) -> f64 {
let (first, second) = (luminance(first), luminance(second));
(first.max(second) + 0.05) / (first.min(second) + 0.05)
}
/// Text colour for a button of the given colour. White is preferred while it is readable enough:
/// on saturated mid-tones (like the default violet) the WCAG formula favours black, which looks worse.
pub fn text_color_on(color: &str) -> &'static str {
if contrast(color, "#ffffff") >= MIN_PRIMARY_CONTRAST { "#ffffff" } else { "#000000" }
}
/// Closer to black than to white by contrast
fn is_dark(color: &str) -> bool {
contrast(color, "#ffffff") > contrast(color, "#000000")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_color_accepts_only_six_digit_hex() {
for color in ["#000000", "#ffffff", "#696cff", "#ABCDEF", "#aBc123"] {
assert!(is_color(color), "color: {:?}", color);
}
for color in ["", "#", "#fff", "#fffff", "#fffffff", "696cff", "#gggggg", "red", "rgb(0,0,0)", " #ffffff",
"#ffffff ", "#fff;ff", "#ffffff; color: red", "#ééé", "#ffffé"] {
assert!(!is_color(color), "color: {:?}", color);
}
}
#[test]
fn luminance_of_black_and_white() {
assert_eq!(luminance("#000000"), 0.0);
assert!((luminance("#ffffff") - 1.0).abs() < 1e-9);
assert_eq!(luminance("nonsense"), 0.0);
}
#[test]
fn contrast_is_symmetric_and_in_range() {
assert!((contrast("#000000", "#ffffff") - 21.0).abs() < 1e-9);
assert!((contrast("#ffffff", "#000000") - 21.0).abs() < 1e-9);
assert!((contrast("#696cff", "#696cff") - 1.0).abs() < 1e-9);
// Reference value of a common pair
assert!((contrast("#777777", "#ffffff") - 4.48).abs() < 0.01);
}
#[test]
fn text_color_on_picks_readable_one() {
assert_eq!(text_color_on("#000000"), "#ffffff");
assert_eq!(text_color_on("#1b66b1"), "#ffffff");
// White stays on the default violet, although black has a slightly higher contrast there
assert_eq!(text_color_on("#696cff"), "#ffffff");
assert_eq!(text_color_on("#ffffff"), "#000000");
assert_eq!(text_color_on("#ef9f27"), "#000000");
assert_eq!(text_color_on("#ffff00"), "#000000");
}
#[test]
fn default_colors_are_valid() {
let colors = Colors::default();
assert!(colors.validate().is_ok());
assert_eq!(colors, colors.sanitized());
assert_eq!(Colors::palette(0), Some(colors));
}
#[test]
fn validation_rejects_other_values() {
let mut colors = Colors::default();
colors.card = "white".to_string();
assert!(colors.validate().is_err());
let mut colors = Colors::default();
colors.primary = "#fff; background: url(x)".to_string();
assert!(colors.validate().is_err());
}
#[test]
fn sanitized_replaces_invalid_values() {
let colors = Colors::new("#ABCDEF", "", "red; --x: 1", "#12345").sanitized();
assert_eq!(colors, Colors::new("#abcdef", DEFAULT_BACKGROUND, DEFAULT_CARD, DEFAULT_TEXT));
}
#[test]
fn style_of_default_colors() {
assert_eq!(Colors::default().style(),
"--rz-primary: #696cff; --rz-bg: #f5f5f9; --rz-card: #ffffff; --rz-text: #566a7f; --rz-on-primary: #ffffff; --rz-scheme: light");
}
#[test]
fn style_of_dark_colors() {
assert_eq!(Colors::new("#ef9f27", "#1f2128", "#2b2e38", "#d8dae3").style(),
"--rz-primary: #ef9f27; --rz-bg: #1f2128; --rz-card: #2b2e38; --rz-text: #d8dae3; --rz-on-primary: #000000; --rz-scheme: dark");
}
#[test]
fn style_never_contains_invalid_values() {
let style = Colors::new("red; background: url(x)", "</style>", "\"", "#fff").style();
assert_eq!(style, Colors::default().style());
}
#[test]
fn contrast_issues_are_reported() {
assert_eq!(Colors::default().contrast_issues(), vec![]);
assert_eq!(Colors::new("#696cff", "#f5f5f9", "#ffffff", "#bbbbbb").contrast_issues(),
vec![ContrastIssue::TextOnCard, ContrastIssue::TextOnBackground]);
assert_eq!(Colors::new("#f0f0f0", "#333333", "#ffffff", "#444444").contrast_issues(),
vec![ContrastIssue::TextOnBackground, ContrastIssue::PrimaryOnCard]);
}
#[test]
fn palettes_are_valid_and_readable() {
for (index, palette) in PALETTES.iter().enumerate() {
let colors = Colors::palette(index).unwrap();
assert!(colors.validate().is_ok(), "palette: {}", palette.0);
assert_eq!(colors.contrast_issues(), vec![], "palette: {}", palette.0);
}
assert_eq!(Colors::palette(PALETTES.len()), None);
}
}
+10 -4
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, Company}; use crate::backend::data::{ApiResponse, Company};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -25,7 +25,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
} }
}} }}
#[server(GetCompany, "/api", "Url", "get_company")] #[server(name = GetCompany, prefix = "/api", endpoint = "get_company")]
pub async fn get_company() -> Result<ApiResponse<Company>, ServerFnError> { pub async fn get_company() -> Result<ApiResponse<Company>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
@@ -40,16 +40,21 @@ pub async fn get_company() -> Result<ApiResponse<Company>, ServerFnError> {
Ok(ApiResponse::Data(cmp)) Ok(ApiResponse::Data(cmp))
} }
#[server(UpdateCompany, "/api", "Url", "update_company")] #[server(name = UpdateCompany, prefix = "/api", endpoint = "update_company")]
pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerFnError> { pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
perm_check!(is_admin); perm_check!(is_admin);
// The form validates in the browser only
if company.validate().is_err() {
return Ok(ApiResponse::Error("Invalid company data".to_string()))
}
let pool = get_pool().await?; let pool = get_pool().await?;
sqlx::query( sqlx::query(
"UPDATE company SET name = $1, street = $2, house_number = $3, zip_code = $4, city = $5 \ "UPDATE company SET name = $1, street = $2, house_number = $3, zip_code = $4, city = $5, cancel_hours = $7 \
WHERE id = $6") WHERE id = $6")
.bind(company.name.clone()) .bind(company.name.clone())
.bind(company.street.clone()) .bind(company.street.clone())
@@ -57,6 +62,7 @@ pub async fn update_company(company: Company) -> Result<ApiResponse<()>, ServerF
.bind(company.zip_code.clone()) .bind(company.zip_code.clone())
.bind(company.city.clone()) .bind(company.city.clone())
.bind(company.id()) .bind(company.id())
.bind(company.cancel_hours)
.execute(&pool) .execute(&pool)
.await?; .await?;
+1 -2
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::{server, ServerFnError}; use leptos::prelude::*;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::backend::data::Customer; use crate::backend::data::Customer;
@@ -8,7 +8,6 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use sqlx::{query_as, query}; use sqlx::{query_as, query};
use sqlx::Error; use sqlx::Error;
use std::ops::DerefMut; use std::ops::DerefMut;
use leptos::expect_context;
use leptos_actix::extract; use leptos_actix::extract;
use actix_web::cookie::Cookie; use actix_web::cookie::Cookie;
use actix_web::cookie::SameSite; use actix_web::cookie::SameSite;
+654 -8
View File
@@ -4,6 +4,9 @@ use std::fmt::{Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
use chrono::{Local, NaiveDate, NaiveTime, Weekday}; use chrono::{Local, NaiveDate, NaiveTime, Weekday};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use crate::backend::banner::{is_stored_image_name, BannerOptions};
use crate::backend::color::Colors;
use crate::backend::layout::LayoutOptions;
use regex::Regex; use regex::Regex;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -118,6 +121,10 @@ pub struct Company {
pub house_number: String, pub house_number: String,
pub zip_code: String, pub zip_code: String,
pub city: String, pub city: String,
/// How many hours before the start of a booking the customer may still cancel it, 0 = until the start
#[serde(default)]
#[validate(range(min = 0, max = 720, message = "Cancellation deadline must be between 0 and 720 hours"))]
pub cancel_hours: i32,
} }
impl Company { impl Company {
@@ -309,7 +316,19 @@ impl TryFrom<&str> for DayHour {
return Err(AppError::HourParseError) return Err(AppError::HourParseError)
} }
let times = value.split("-") // Optional discount in parentheses after the interval: "08:00 - 12:00 (10)"
let (interval, discount) = match value.trim().strip_suffix(")") {
Some(rest) => {
let (interval, discount) = rest.rsplit_once("(").ok_or(AppError::HourParseError)?;
if discount.is_empty() || !discount.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::HourParseError)
}
(interval, Some(discount.parse::<i32>().map_err(|_| AppError::HourParseError)?))
}
None => (value, None)
};
let times = interval.split("-")
.map(|t| t.trim()).collect::<Vec<_>>(); .map(|t| t.trim()).collect::<Vec<_>>();
if times.len() != 2 { if times.len() != 2 {
@@ -326,31 +345,41 @@ impl TryFrom<&str> for DayHour {
Ok(DayHour { Ok(DayHour {
from: from.unwrap(), from: from.unwrap(),
to: to.unwrap(), to: to.unwrap(),
discount: None discount
}) })
} }
} }
fn hours_error(code: &'static str, message: &'static str) -> ValidationError {
let mut err = ValidationError::new(code);
err.message = Some(message.into());
err
}
fn validate_hours(value: &WeekHours) -> Result<(), ValidationError> { fn validate_hours(value: &WeekHours) -> Result<(), ValidationError> {
if value.hours().is_empty() { // Wrong format is already reported by the regex on the field
if value.hours().is_empty() || !RE_HOURS.is_match(value.hours()) {
return Ok(()) return Ok(())
} }
if let Ok(h) = DayHours::try_new(value.hours()) { if let Ok(h) = DayHours::try_new(value.hours()) {
for hr in h.hours() { for hr in h.hours() {
if hr.from() >= hr.to() { return Err(ValidationError::new("TO_BEFORE_FROM")) } if hr.from() >= hr.to() {
return Err(hours_error("TO_BEFORE_FROM", "Time 'to' must be after time 'from'"))
}
} }
Ok(()) Ok(())
} else { } else {
Ok(()) Err(hours_error("HOURS_FORMAT", "Hours must be in HH:MM - HH:MM format"))
} }
} }
lazy_static! { lazy_static! {
static ref RE_HOURS: Regex = Regex::new(r"^$|(^\d{2}:\d{2} ?- ?\d{2}:\d{2} ?(\(\d+\))?,? ?)+").unwrap(); static ref RE_HOURS: Regex = Regex::new(
r"^$|^\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?( ?, ?\d{2}:\d{2} ?- ?\d{2}:\d{2}( ?\(\d+\))?)*$").unwrap();
} }
#[derive(Clone, Serialize, Deserialize, Debug, Validate)] #[derive(Clone, Serialize, Deserialize, Debug, Validate)]
#[validate(schema(function = "validate_hours", message = "Time 'to' must be after time 'from'"))] #[validate(schema(function = "validate_hours"))]
pub struct WeekHours { pub struct WeekHours {
day: Weekday, day: Weekday,
#[validate(regex(path = "RE_HOURS", message = "Hours must be in HH:MM - HH:MM format"))] #[validate(regex(path = "RE_HOURS", message = "Hours must be in HH:MM - HH:MM format"))]
@@ -641,6 +670,9 @@ pub struct ReservationSum {
pub date_create: NaiveDate, pub date_create: NaiveDate,
pub edited_by: Option<i32>, pub edited_by: Option<i32>,
pub note: Option<String>, pub note: Option<String>,
/// Cancelled through the link in the e-mail, not by the staff
#[serde(default)]
pub canceled_by_customer: bool,
} }
impl ReservationSum { impl ReservationSum {
@@ -656,6 +688,28 @@ pub struct ResSumWithItems {
pub reservations: Vec<ResWithProperty> pub reservations: Vec<ResWithProperty>
} }
/// One booked item as the customer sees it on the page of their booking
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct PublicBookingItem {
pub name: String,
pub from: NaiveTime,
pub to: NaiveTime,
}
/// A booking as shown to whoever has its link. It has nothing more than the e-mails about
/// the booking: no name, e-mail address or phone number of the customer.
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct PublicBooking {
pub date: NaiveDate,
pub items: Vec<PublicBookingItem>,
pub price: Decimal,
pub state: ReservationState,
/// The customer may cancel it now
pub can_cancel: bool,
/// Until when it can (or could) be cancelled
pub cancel_until: chrono::NaiveDateTime,
}
#[derive(Clone, Serialize, Deserialize, Debug, Default)] #[derive(Clone, Serialize, Deserialize, Debug, Default)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct ResAllView { pub struct ResAllView {
@@ -676,6 +730,8 @@ pub enum MessageType {
NewReservationCust, NewReservationCust,
ReservationApp, ReservationApp,
ReservationCanceled, ReservationCanceled,
/// For the staff: the customer cancelled the booking
CustomerCanceled,
} }
impl Display for MessageType { impl Display for MessageType {
@@ -685,6 +741,7 @@ impl Display for MessageType {
MessageType::NewReservationCust => {"NewReservationCust"} MessageType::NewReservationCust => {"NewReservationCust"}
MessageType::ReservationApp => {"ReservationApp"} MessageType::ReservationApp => {"ReservationApp"}
MessageType::ReservationCanceled => {"ReservationCanceled"} MessageType::ReservationCanceled => {"ReservationCanceled"}
MessageType::CustomerCanceled => {"CustomerCanceled"}
}) })
} }
} }
@@ -720,13 +777,63 @@ pub struct Appearance {
pub banner: Option<String>, pub banner: Option<String>,
pub text: Option<String>, pub text: Option<String>,
pub title: Option<String>, pub title: Option<String>,
pub css_name: Option<String> #[serde(default)]
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub colors: Colors,
pub logo: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub banner_options: BannerOptions,
#[serde(default)]
#[cfg_attr(feature = "ssr", sqlx(flatten))]
pub layout: LayoutOptions
} }
impl Appearance { impl Appearance {
pub fn id(&self) -> i32 { pub fn id(&self) -> i32 {
self.id self.id
} }
/// URL of the banner image, if there is one
pub fn banner_url(&self) -> Option<String> {
stored_image_url(&self.banner)
}
/// URL of the logo, if there is one
pub fn logo_url(&self) -> Option<String> {
stored_image_url(&self.logo)
}
/// The public page has a header when there is anything to show in it
pub fn has_banner(&self) -> bool {
self.banner_url().is_some()
|| self.logo_url().is_some()
|| !self.title.clone().unwrap_or_default().trim().is_empty()
}
/// CSS variables for the banner of the public page, used as its `style` attribute.
/// The styles themselves are in `assets/css/public.css`.
pub fn banner_style(&self) -> String {
banner_style(self.banner_url(), &self.banner_options)
}
}
/// Style of a banner with the given image; the preview in the appearance settings uses it
/// with an image that is not uploaded yet
pub fn banner_style(image_url: Option<String>, options: &BannerOptions) -> String {
let options = options.style(image_url.is_some());
match image_url {
Some(url) => format!("--rz-banner-image: url('{}'); {}", url, options),
None => options
}
}
/// The value is put into a `style` attribute, so it must not be able to end the `url()` or the declaration
fn stored_image_url(name: &Option<String>) -> Option<String> {
name.as_ref()
.filter(|name| is_stored_image_name(name))
.map(|name| format!("/data/{}", name))
} }
pub fn validate_closing_time(time: &ClosingTime) -> Result<(), ValidationError> { pub fn validate_closing_time(time: &ClosingTime) -> Result<(), ValidationError> {
@@ -752,3 +859,542 @@ impl ClosingTime {
self.id self.id
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn time(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).unwrap()
}
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn has_field_error<T: Validate>(entity: &T, field: &str) -> bool {
match entity.validate() {
Ok(_) => false,
Err(e) => e.field_errors().contains_key(field)
}
}
// Page
#[test]
fn page_starts_on_first_page() {
let page = Page::new(10);
assert_eq!(page.page_num(), 1);
assert_eq!(page.limit(), 10);
assert_eq!(page.offset(), 0);
}
#[test]
fn page_offset_follows_page_number() {
let page = Page::new(10).with_page(3);
assert_eq!(page.page_num(), 3);
assert_eq!(page.offset(), 20);
}
#[test]
fn page_with_zero_size_is_unlimited() {
let page = Page::new(0).with_page(5);
assert_eq!(page.limit(), i32::MAX);
assert_eq!(page.offset(), 0);
}
#[test]
fn page_navigation() {
let mut page = Page::new(10);
page.prev();
assert_eq!(page.page_num(), 1);
page.next();
page.next();
assert_eq!(page.page_num(), 3);
page.prev();
assert_eq!(page.page_num(), 2);
page.go_to(7);
assert_eq!(page.page_num(), 7);
page.first();
assert_eq!(page.page_num(), 1);
}
// PagedResponse
#[test]
fn paged_response_first_and_last() {
let first = PagedResponse::new(vec![1, 2], Page::new(2), 5);
assert!(first.is_first());
assert!(!first.is_last());
let middle = PagedResponse::new(vec![3, 4], Page::new(2).with_page(2), 5);
assert!(!middle.is_first());
assert!(!middle.is_last());
let last = PagedResponse::new(vec![5], Page::new(2).with_page(3), 5);
assert!(!last.is_first());
assert!(last.is_last());
}
#[test]
fn paged_response_exactly_full_last_page() {
let last = PagedResponse::new(vec![3, 4], Page::new(2).with_page(2), 4);
assert!(last.is_last());
}
#[test]
fn paged_response_total_pages() {
assert_eq!(PagedResponse::new(Vec::<i32>::new(), Page::new(10), 0).total_pages(), 0);
assert_eq!(PagedResponse::new(vec![1], Page::new(10), 1).total_pages(), 1);
assert_eq!(PagedResponse::new(vec![1], Page::new(10), 10).total_pages(), 1);
assert_eq!(PagedResponse::new(vec![1], Page::new(10), 11).total_pages(), 2);
}
#[test]
fn paged_response_unlimited_is_single_page() {
let resp = PagedResponse::new(vec![1, 2, 3], Page::new(0), 3);
assert!(resp.is_first());
assert!(resp.is_last());
assert_eq!(resp.total_pages(), 1);
assert_eq!(resp.data(), vec![1, 2, 3]);
}
// DayHour, DayHours
#[test]
fn day_hour_parses_with_and_without_spaces() {
let expected = DayHour::new(time(8, 0), time(12, 30), None);
assert_eq!(DayHour::try_from("08:00 - 12:30"), Ok(expected.clone()));
assert_eq!(DayHour::try_from("08:00-12:30"), Ok(expected.clone()));
assert_eq!(DayHour::try_from("08:00 - 12:30".to_string()), Ok(expected));
}
#[test]
fn day_hour_rejects_malformed_input() {
for input in ["", "08:00", "08:00 - ", "08:00 - 12:00 - 14:00", "aa:bb - cc:dd", "25:00 - 26:00", "08:00 12:00"] {
assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input);
}
}
#[test]
fn day_hour_parses_discount() {
let expected = DayHour::new(time(8, 0), time(12, 30), Some(10));
assert_eq!(DayHour::try_from("08:00 - 12:30 (10)"), Ok(expected.clone()));
assert_eq!(DayHour::try_from("08:00-12:30(10)"), Ok(expected));
assert_eq!(DayHour::try_from("08:00 - 12:30 (0)"), Ok(DayHour::new(time(8, 0), time(12, 30), Some(0))));
}
#[test]
fn day_hour_rejects_malformed_discount() {
for input in ["08:00 - 12:00 ()", "08:00 - 12:00 (x)", "08:00 - 12:00 (-5)", "08:00 - 12:00 (1.5)", "08:00 - 12:00 (10",
"08:00 - 12:00 10)", "(10) 08:00 - 12:00", "08:00 (10) - 12:00", "08:00 - 12:00 (10) (20)", "(10)",
"08:00 - 12:00 (99999999999)"] {
assert_eq!(DayHour::try_from(input), Err(AppError::HourParseError), "input: {:?}", input);
}
}
#[test]
fn day_hours_empty_string_is_closed_day() {
let hours = DayHours::try_new("").unwrap();
assert!(hours.hours().is_empty());
assert_eq!(hours.to_string(), "");
}
#[test]
fn day_hours_parses_list() {
let hours = DayHours::try_new("08:00 - 12:00, 13:00 - 17:00").unwrap();
assert_eq!(hours.hours(), &vec![
DayHour::new(time(8, 0), time(12, 0), None),
DayHour::new(time(13, 0), time(17, 0), None)
]);
}
#[test]
fn day_hours_rejects_list_with_invalid_item() {
assert!(DayHours::try_new("08:00 - 12:00, nonsense").is_err());
assert!(DayHours::try_new("08:00 - 12:00,").is_err());
}
#[test]
fn day_hours_display() {
let hours = DayHours(vec![
DayHour::new(time(8, 0), time(12, 0), None),
DayHour::new(time(13, 0), time(17, 0), Some(10))
]);
assert_eq!(hours.to_string(), "08:00 - 12:00, 13:00 - 17:00 (10)");
}
#[test]
fn day_hours_display_round_trip() {
let hours = DayHours(vec![
DayHour::new(time(8, 0), time(12, 0), None),
DayHour::new(time(13, 0), time(17, 0), Some(10)),
DayHour::new(time(18, 0), time(20, 0), Some(0))
]);
let parsed = DayHours::try_new(&hours.to_string()).unwrap();
assert_eq!(parsed.hours(), hours.hours());
}
// WeekHours
#[test]
fn week_hours_new_formats_hours() {
let hours = WeekHours::new(Weekday::Tue, vec![DayHour::new(time(9, 0), time(18, 0), None)]);
assert_eq!(hours.day(), Weekday::Tue);
assert_eq!(hours.hours(), "09:00 - 18:00");
}
#[test]
fn week_hours_accepts_valid_hours() {
for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00, 13:00 - 17:00",
"08:00 - 12:00 (10)", "08:00 - 12:00 (10), 13:00 - 17:00", "08:00 - 12:00, 13:00 - 17:00(20)"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(hours.validate().is_ok(), "input: {:?}", input);
assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input);
}
}
#[test]
fn week_hours_rejects_wrong_format() {
for input in ["8-12", "8:00 - 12:00", "morning", "08.00 - 12.00",
"08:00 - 12:00, nonsense", "08:00 - 12:00 garbage", "08:00 - 12:00,", ", 08:00 - 12:00"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(has_field_error(&hours, "hours"), "input: {:?}", input);
assert!(!has_field_error(&hours, "__all__"), "input: {:?}", input);
}
}
#[test]
fn week_hours_rejects_hours_that_cannot_be_parsed() {
// Matches the format, but is not a valid time
for input in ["25:00 - 26:00", "08:00 - 12:60", "08:00 - 12:00, 13:00 - 24:00"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(has_field_error(&hours, "__all__"), "input: {:?}", input);
}
}
#[test]
fn week_hours_validation_agrees_with_parser() {
for input in ["", "08:00 - 12:00", "08:00-12:00", "08:00 - 12:00,13:00 - 17:00", "08:00 - 12:00 , 13:00 - 17:00",
"08:00 - 12:00 (10)", "08:00 - 12:00 (10) , 13:00 - 17:00 (5)", "08:00 - 12:00 (99999999999)", "08:00 - 12:00, nonsense", "08:00 - 12:00,", "25:00 - 26:00", "08:00 - 12:00 garbage"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
if hours.validate().is_ok() {
assert!(DayHours::try_new(input).is_ok(), "input: {:?}", input);
}
}
}
#[test]
fn week_hours_error_messages() {
let message = |input: &str| {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
let errors = hours.validate().unwrap_err();
let all = errors.field_errors().values().flat_map(|e| e.iter()).map(|e| e.message.clone().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(all.len(), 1, "input: {:?}", input);
all[0].clone()
};
assert_eq!(message("nonsense"), "Hours must be in HH:MM - HH:MM format");
assert_eq!(message("25:00 - 26:00"), "Hours must be in HH:MM - HH:MM format");
assert_eq!(message("12:00 - 08:00"), "Time 'to' must be after time 'from'");
}
#[test]
fn week_hours_rejects_to_before_from() {
for input in ["12:00 - 08:00", "08:00 - 08:00", "08:00 - 12:00, 17:00 - 13:00", "12:00 - 08:00 (10)"] {
let hours = WeekHours { day: Weekday::Mon, hours: input.to_string() };
assert!(has_field_error(&hours, "__all__"), "input: {:?}", input);
}
}
// TmCheck
#[test]
fn tm_check_parses_slot() {
let check = TmCheck::from_str("08:00:00-08:30:00").unwrap();
assert_eq!(check, TmCheck { from: time(8, 0), to: time(8, 30) });
}
#[test]
fn tm_check_rejects_malformed_slot() {
for input in ["", "08:00:00", "08:00:00-", "08:00:00-09:00:00-10:00:00", "a-b"] {
assert_eq!(TmCheck::from_str(input), Err(AppError::HourParseError), "input: {:?}", input);
}
}
#[test]
fn tm_check_sorts_by_time() {
let mut checks = vec![
TmCheck::from_str("10:00:00-11:00:00").unwrap(),
TmCheck::from_str("08:00:00-09:00:00").unwrap(),
TmCheck::from_str("09:00:00-10:00:00").unwrap()
];
checks.sort();
assert_eq!(checks.iter().map(|c| c.from).collect::<Vec<_>>(), vec![time(8, 0), time(9, 0), time(10, 0)]);
}
// Reservations
fn slot(from: u32, to: u32) -> TmCheck {
TmCheck { from: time(from, 0), to: time(to, 0) }
}
#[test]
fn reservations_starts_empty() {
let reservations = Reservations::new();
assert!(reservations.is_empty());
assert!(reservations.reservations().is_empty());
}
#[test]
fn reservations_merges_adjacent_slots() {
let mut reservations = Reservations::new();
reservations.add_slot(&slot(8, 9), 1);
reservations.add_slot(&slot(9, 10), 1);
reservations.add_slot(&slot(10, 11), 1);
assert_eq!(reservations.reservations(), &vec![Reservation::new(time(8, 0), time(11, 0), 1)]);
}
#[test]
fn reservations_keeps_gap_between_slots() {
let mut reservations = Reservations::new();
reservations.add_slot(&slot(8, 9), 1);
reservations.add_slot(&slot(10, 11), 1);
assert_eq!(reservations.reservations(), &vec![
Reservation::new(time(8, 0), time(9, 0), 1),
Reservation::new(time(10, 0), time(11, 0), 1)
]);
}
#[test]
fn reservations_does_not_merge_across_properties() {
let mut reservations = Reservations::new();
reservations.add_slot(&slot(8, 9), 1);
reservations.add_slot(&slot(9, 10), 2);
assert_eq!(reservations.reservations(), &vec![
Reservation::new(time(8, 0), time(9, 0), 1),
Reservation::new(time(9, 0), time(10, 0), 2)
]);
}
// Entity validation
#[test]
fn company_requires_name_street_and_house_number() {
let company = Company::default();
assert!(has_field_error(&company, "name"));
assert!(has_field_error(&company, "street"));
assert!(has_field_error(&company, "house_number"));
assert!(!has_field_error(&company, "zip_code"));
assert!(!has_field_error(&company, "city"));
}
#[test]
fn company_valid() {
let company = Company {
name: "Sport club".to_string(),
street: "Main".to_string(),
house_number: "1".to_string(),
..Company::default()
};
assert!(company.validate().is_ok());
}
#[test]
fn res_property_requires_name() {
let mut property = ResProperty::default();
assert!(has_field_error(&property, "name"));
property.name = "Court 1".to_string();
assert!(property.validate().is_ok());
}
#[test]
fn user_profile_validation() {
let valid = UserProfile {
login: "joe".to_string(),
password: Some("secret".to_string()),
password_ver: Some("secret".to_string()),
full_name: "Joe Doe".to_string(),
email: "joe@example.com".to_string(),
get_emails: None,
admin: None
};
assert!(valid.validate().is_ok());
assert!(has_field_error(&UserProfile { login: "".to_string(), ..valid.clone() }, "login"));
assert!(has_field_error(&UserProfile { email: "not an email".to_string(), ..valid.clone() }, "email"));
assert!(has_field_error(&UserProfile { password_ver: Some("other".to_string()), ..valid.clone() }, "password"));
}
#[test]
fn user_profile_checkboxes() {
let unchecked = UserProfile::default();
assert!(!unchecked.admin());
assert!(!unchecked.get_emails());
let checked = UserProfile { admin: Some("on".to_string()), get_emails: Some("on".to_string()), ..UserProfile::default() };
assert!(checked.admin());
assert!(checked.get_emails());
}
#[test]
fn pwd_change_validation() {
let valid = PwdChange {
login: "joe".to_string(),
old_password: "old".to_string(),
password: "new".to_string(),
password_ver: "new".to_string()
};
assert!(valid.validate().is_ok());
assert!(has_field_error(&PwdChange { password_ver: "different".to_string(), ..valid.clone() }, "password"));
assert!(has_field_error(&PwdChange { password: "".to_string(), password_ver: "".to_string(), ..valid.clone() }, "password"));
}
#[test]
fn message_requires_subject_and_text() {
let mut message = Message::default();
assert!(has_field_error(&message, "subject"));
assert!(has_field_error(&message, "text"));
message.subject = "Booking".to_string();
message.text = "Hello".to_string();
assert!(message.validate().is_ok());
}
fn valid_reservation() -> CrReservation {
CrReservation {
date: Local::now().date_naive(),
slots: vec!["08:00:00-09:00:00|1".to_string()],
full_name: "Joe Doe".to_string(),
email: "joe@example.com".to_string(),
phone: "123456789".to_string(),
note: "".to_string(),
remember: false
}
}
#[test]
fn cr_reservation_valid_for_today_and_future() {
assert!(valid_reservation().validate().is_ok());
let future = CrReservation { date: Local::now().date_naive() + Duration::days(30), ..valid_reservation() };
assert!(future.validate().is_ok());
}
#[test]
fn cr_reservation_rejects_past_date() {
let past = CrReservation { date: Local::now().date_naive() - Duration::days(1), ..valid_reservation() };
assert!(has_field_error(&past, "date"));
}
#[test]
fn cr_reservation_requires_slot_and_contact() {
assert!(has_field_error(&CrReservation { slots: vec![], ..valid_reservation() }, "slots"));
assert!(has_field_error(&CrReservation { full_name: "".to_string(), ..valid_reservation() }, "full_name"));
assert!(has_field_error(&CrReservation { email: "joe".to_string(), ..valid_reservation() }, "email"));
assert!(has_field_error(&CrReservation { phone: "".to_string(), ..valid_reservation() }, "phone"));
}
#[test]
fn closing_time_validation() {
let one_day = ClosingTime { id: 0, from_date: date(2030, 12, 24), to_date: date(2030, 12, 24) };
assert!(one_day.validate().is_ok());
let range = ClosingTime { id: 0, from_date: date(2030, 12, 24), to_date: date(2031, 1, 1) };
assert!(range.validate().is_ok());
let reversed = ClosingTime { id: 0, from_date: date(2031, 1, 1), to_date: date(2030, 12, 24) };
assert!(has_field_error(&reversed, "__all__"));
}
// Enums stored in the database must keep these names
#[test]
fn enum_display() {
assert_eq!(ReservationState::New.to_string(), "New");
assert_eq!(ReservationState::Approved.to_string(), "Approved");
assert_eq!(ReservationState::Canceled.to_string(), "Canceled");
assert_eq!(MessageType::NewReservation.to_string(), "NewReservation");
assert_eq!(MessageType::NewReservationCust.to_string(), "NewReservationCust");
assert_eq!(MessageType::ReservationApp.to_string(), "ReservationApp");
assert_eq!(MessageType::ReservationCanceled.to_string(), "ReservationCanceled");
assert_eq!(SlotType::Quarter.to_string(), "Quarter an hour");
assert_eq!(SlotType::Half.to_string(), "Half an hour");
assert_eq!(SlotType::Hour.to_string(), "Hour");
assert_eq!(SlotType::Day.to_string(), "Day");
}
#[test]
fn banner_style_sets_image_variable() {
let appearance = Appearance { banner: Some("my-banner_1.jpg".to_string()), ..Default::default() };
assert_eq!(appearance.banner_style(),
format!("--rz-banner-image: url('/data/my-banner_1.jpg'); {}", BannerOptions::default().style(true)));
assert!(appearance.has_banner());
}
#[test]
fn banner_style_without_image_has_only_options() {
assert_eq!(Appearance::default().banner_style(), BannerOptions::default().style(false));
}
#[test]
fn banner_style_ignores_unsafe_file_names() {
for name in ["", "a'); color: red; x: url('.jpg", "a b.jpg", "../config.toml", "a/b.jpg", ".hidden"] {
let appearance = Appearance { banner: Some(name.to_string()), logo: Some(name.to_string()), ..Default::default() };
assert_eq!(appearance.banner_style(), BannerOptions::default().style(false), "name: {:?}", name);
assert_eq!(appearance.banner_url(), None, "name: {:?}", name);
assert_eq!(appearance.logo_url(), None, "name: {:?}", name);
assert!(!appearance.has_banner(), "name: {:?}", name);
}
}
#[test]
fn banner_is_shown_for_title_logo_or_image() {
assert!(!Appearance::default().has_banner());
assert!(!Appearance { title: Some(" ".to_string()), ..Default::default() }.has_banner());
assert!(Appearance { title: Some("Tennis".to_string()), ..Default::default() }.has_banner());
let with_logo = Appearance { logo: Some("logo-1a2b3c4d.png".to_string()), ..Default::default() };
assert!(with_logo.has_banner());
assert_eq!(with_logo.logo_url(), Some("/data/logo-1a2b3c4d.png".to_string()));
}
#[test]
fn enum_defaults() {
assert_eq!(ReservationState::default(), ReservationState::New);
assert_eq!(SlotType::default(), SlotType::Hour);
}
}
+94
View File
@@ -0,0 +1,94 @@
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
use actix_web::{get, HttpResponse, Responder};
use actix_web::web::Data;
use log::error;
use crate::backend::AppData;
/// Health of the instance for the reverse proxy and for monitoring. The application is
/// healthy when it can talk to its database.
#[get("/health")]
pub async fn health(app_data: Data<AppData>) -> impl Responder {
match sqlx::query("SELECT 1").execute(app_data.db_pool()).await {
Ok(_) => HttpResponse::Ok().body("ok"),
Err(e) => {
error!("Health check failed: {}", e);
HttpResponse::ServiceUnavailable().body("database unavailable")
}
}
}
/// Asks a running instance on this machine for its health. Used by the container health
/// check (`rezervator --healthcheck`), so the image doesn't need curl.
pub fn is_healthy(port: u16) -> bool {
let timeout = Duration::from_secs(3);
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let Ok(mut stream) = TcpStream::connect_timeout(&addr, timeout) else {
return false
};
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
if stream.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").is_err() {
return false
}
let mut response = [0u8; 64];
let read = stream.read(&mut response).unwrap_or(0);
String::from_utf8_lossy(&response[..read]).lines().next()
.map(|status| {
let mut parts = status.split_whitespace();
parts.next().is_some_and(|version| version.starts_with("HTTP/")) && parts.next() == Some("200")
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
/// Starts a server that answers one request with the given response, returns its port
fn serve_once(response: &'static str) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut request = [0u8; 512];
let _ = stream.read(&mut request);
let _ = stream.write_all(response.as_bytes());
}
});
port
}
#[test]
fn healthy_when_server_answers_200() {
assert!(is_healthy(serve_once("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")));
}
#[test]
fn unhealthy_when_database_is_down() {
assert!(!is_healthy(serve_once("HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\n\r\n")));
}
#[test]
fn unhealthy_on_other_answers() {
assert!(!is_healthy(serve_once("HTTP/1.1 302 Found\r\nlocation: /login\r\n\r\n")));
assert!(!is_healthy(serve_once("")));
assert!(!is_healthy(serve_once("garbage 200")));
}
#[test]
fn unhealthy_when_nothing_listens() {
// Take a free port and release it again
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
assert!(!is_healthy(port));
}
}
+161
View File
@@ -0,0 +1,161 @@
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError};
/// Values of the layout options: the first one of each list is the default and adds no class,
/// the others are paired with the class they put on the wrapper of the public page
/// (styled in `assets/css/public.css`).
const WIDTHS: [(&str, &str); 2] = [("full", ""), ("centered", "rz-width-centered")];
const ARRANGEMENTS: [(&str, &str); 2] = [("columns", ""), ("stacked", "rz-form-stacked")];
const SLOTS: [(&str, &str); 2] = [("normal", ""), ("large", "rz-slots-large")];
const CORNERS: [(&str, &str); 2] = [("rounded", ""), ("square", "rz-corners-square")];
/// Layout of the public booking form, set by the customer in the appearance settings
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Validate)]
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
pub struct LayoutOptions {
/// Content over the full width of the window (`full`) or in a column in the middle (`centered`)
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_width"))]
#[validate(custom(function = "validate_width", message = "Invalid layout"))]
pub width: String,
/// The two cards of the form side by side (`columns`) or below each other (`stacked`)
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_arrangement"))]
#[validate(custom(function = "validate_arrangement", message = "Invalid layout"))]
pub arrangement: String,
/// Size of the time slot buttons: `normal` or `large`
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_slots"))]
#[validate(custom(function = "validate_slots", message = "Invalid layout"))]
pub slots: String,
/// Corners of cards, buttons and inputs: `rounded` or `square`
#[cfg_attr(feature = "ssr", sqlx(rename = "layout_corners"))]
#[validate(custom(function = "validate_corners", message = "Invalid layout"))]
pub corners: String,
}
impl Default for LayoutOptions {
fn default() -> Self {
LayoutOptions {
width: WIDTHS[0].0.to_string(),
arrangement: ARRANGEMENTS[0].0.to_string(),
slots: SLOTS[0].0.to_string(),
corners: CORNERS[0].0.to_string(),
}
}
}
impl LayoutOptions {
/// Replaces values that are not valid with the default ones
pub fn sanitized(&self) -> Self {
let pick = |value: &str, options: &[(&str, &str)]| {
if is_option(value, options) { value.to_string() } else { options[0].0.to_string() }
};
LayoutOptions {
width: pick(&self.width, &WIDTHS),
arrangement: pick(&self.arrangement, &ARRANGEMENTS),
slots: pick(&self.slots, &SLOTS),
corners: pick(&self.corners, &CORNERS),
}
}
/// Classes for the wrapper of the public page, separated by spaces. Only known classes
/// get there, whatever the values are.
pub fn classes(&self) -> String {
[
class_of(&self.width, &WIDTHS),
class_of(&self.arrangement, &ARRANGEMENTS),
class_of(&self.slots, &SLOTS),
class_of(&self.corners, &CORNERS),
].into_iter().filter(|class| !class.is_empty()).collect::<Vec<_>>().join(" ")
}
}
fn is_option(value: &str, options: &[(&str, &str)]) -> bool {
options.iter().any(|option| option.0 == value)
}
fn class_of(value: &str, options: &[(&'static str, &'static str)]) -> &'static str {
options.iter().find(|option| option.0 == value).map(|option| option.1).unwrap_or_default()
}
fn validate_option(value: &str, options: &[(&str, &str)]) -> Result<(), ValidationError> {
if is_option(value, options) { Ok(()) } else { Err(ValidationError::new("invalid option")) }
}
fn validate_width(value: &str) -> Result<(), ValidationError> {
validate_option(value, &WIDTHS)
}
fn validate_arrangement(value: &str) -> Result<(), ValidationError> {
validate_option(value, &ARRANGEMENTS)
}
fn validate_slots(value: &str) -> Result<(), ValidationError> {
validate_option(value, &SLOTS)
}
fn validate_corners(value: &str) -> Result<(), ValidationError> {
validate_option(value, &CORNERS)
}
#[cfg(test)]
mod tests {
use super::*;
fn layout(width: &str, arrangement: &str, slots: &str, corners: &str) -> LayoutOptions {
LayoutOptions {
width: width.to_string(),
arrangement: arrangement.to_string(),
slots: slots.to_string(),
corners: corners.to_string(),
}
}
#[test]
fn default_layout_is_valid_and_adds_no_class() {
let default = LayoutOptions::default();
assert_eq!(default, layout("full", "columns", "normal", "rounded"));
assert!(default.validate().is_ok());
assert_eq!(default, default.sanitized());
assert_eq!(default.classes(), "");
}
#[test]
fn all_listed_values_are_valid() {
for width in ["full", "centered"] {
for arrangement in ["columns", "stacked"] {
for slots in ["normal", "large"] {
for corners in ["rounded", "square"] {
let options = layout(width, arrangement, slots, corners);
assert!(options.validate().is_ok(), "{:?}", options);
assert_eq!(options, options.sanitized());
}
}
}
}
}
#[test]
fn validation_rejects_other_values() {
assert!(layout("wide", "columns", "normal", "rounded").validate().is_err());
assert!(layout("full", "rows", "normal", "rounded").validate().is_err());
assert!(layout("full", "columns", "", "rounded").validate().is_err());
assert!(layout("full", "columns", "normal", "rz-corners-square").validate().is_err());
// A value of another option is not accepted either
assert!(layout("large", "columns", "normal", "rounded").validate().is_err());
}
#[test]
fn classes_of_changed_options() {
assert_eq!(layout("centered", "stacked", "large", "square").classes(),
"rz-width-centered rz-form-stacked rz-slots-large rz-corners-square");
assert_eq!(layout("full", "stacked", "normal", "square").classes(), "rz-form-stacked rz-corners-square");
assert_eq!(layout("centered", "columns", "normal", "rounded").classes(), "rz-width-centered");
}
#[test]
fn invalid_values_never_get_into_classes() {
let options = layout("\" onclick=\"x", "evil-class", "<script>", "a b c");
assert_eq!(options.classes(), "");
assert_eq!(options.sanitized(), LayoutOptions::default());
}
}
+180 -14
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, Message, MessageType}; use crate::backend::data::{ApiResponse, Message, MessageType};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -14,12 +14,12 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use crate::config::Mailing; use crate::config::Mailing;
use crate::config::MailTransport; use crate::config::MailTransport;
use crate::backend::data::ResSumWithItems; use crate::backend::data::ResSumWithItems;
use crate::backend::cancellation::insert_link;
use lettre::message::Message as LettreMessage; use lettre::message::Message as LettreMessage;
use lettre::{FileTransport, SmtpTransport, Transport}; use lettre::{FileTransport, SmtpTransport, Transport};
use lettre::transport::smtp::client::{Tls, TlsParameters}; use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::extension::ClientId; use lettre::transport::smtp::extension::ClientId;
use std::ops::Add;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::sync::mpsc; use std::sync::mpsc;
@@ -30,9 +30,25 @@ cfg_if! { if #[cfg(feature = "ssr")] {
.await?) .await?)
} }
/// Subject and text a new template starts with. The first templates start empty and are
/// written by the administrator; one added by an upgrade must be usable right away,
/// otherwise existing installations would send empty e-mails.
fn initial_message(msg_type: &MessageType) -> (&'static str, &'static str) {
match msg_type {
MessageType::CustomerCanceled => (
"Booking cancelled by the customer",
"The customer cancelled a booking.\n\nDate: #date#\n#summary#\n\n#link#"),
_ => ("", "")
}
}
pub async fn init_message(msg_type: &MessageType, pool: &PgPool) -> Result<(), Error> { pub async fn init_message(msg_type: &MessageType, pool: &PgPool) -> Result<(), Error> {
query("INSERT INTO message(msg_type, subject, text) VALUES($1, '', '')") let (subject, text) = initial_message(msg_type);
query("INSERT INTO message(msg_type, subject, text) VALUES($1, $2, $3)")
.bind(msg_type) .bind(msg_type)
.bind(subject)
.bind(text)
.execute(pool) .execute(pool)
.await?; .await?;
@@ -44,7 +60,8 @@ cfg_if! { if #[cfg(feature = "ssr")] {
MessageType::NewReservation, MessageType::NewReservation,
MessageType::NewReservationCust, MessageType::NewReservationCust,
MessageType::ReservationApp, MessageType::ReservationApp,
MessageType::ReservationCanceled]; MessageType::ReservationCanceled,
MessageType::CustomerCanceled];
for msg_type in types { for msg_type in types {
let msg = message_for_type(&msg_type, pool).await; let msg = message_for_type(&msg_type, pool).await;
@@ -78,18 +95,23 @@ cfg_if! { if #[cfg(feature = "ssr")] {
} }
} }
/// Puts the link to the page of the booking into the text: in place of `#link#`, or at the
/// end after the given sentence when the text has no placeholder. Without a sentence
/// a text without the placeholder stays as it is.
pub fn with_link(mut self, link: &str, intro_when_missing: Option<&str>) -> Self {
self.text = insert_link(&self.text, link, intro_when_missing);
self
}
fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String { fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String {
text text
.replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string()) .replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string())
.replace("#summary#", &{ .replace("#summary#", &reservation.reservations.iter()
let mut sum = "".to_string(); .map(|p| format!("{}: {} - {}",
for p in &reservation.reservations { p.property.name,
sum = sum.add(&format!("{}: {} - {}", p.reservation.from.format("%H:%M"), p.reservation.to.format("%H:%M")))
p.property.name, .collect::<Vec<_>>()
p.reservation.from.format("%H:%M").to_string(), p.reservation.to.format("%H:%M").to_string())); .join("\n")
}
sum
}
) )
} }
@@ -231,8 +253,13 @@ cfg_if! { if #[cfg(feature = "ssr")] {
} }
}} }}
/// A mail template for the mail settings. Code that sends e-mails reads templates with
/// `message_for_type`, it runs for requests without login too.
#[server] #[server]
pub async fn get_message(msg_type: MessageType) -> Result<Message, ServerFnError> { pub async fn get_message(msg_type: MessageType) -> Result<Message, ServerFnError> {
use crate::backend::user::require_login;
require_login().await?;
let pool = get_pool().await?; let pool = get_pool().await?;
Ok(message_for_type(&msg_type, &pool).await?) Ok(message_for_type(&msg_type, &pool).await?)
@@ -259,4 +286,143 @@ impl ForValidation for UpdateMessage {
fn entity(&self) -> &dyn Validate { fn entity(&self) -> &dyn Validate {
&self.message &self.message
} }
} }
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
use chrono::{NaiveDate, NaiveTime};
use crate::backend::data::{Reservation, ResProperty, ResWithProperty};
fn item(name: &str, from: (u32, u32), to: (u32, u32)) -> ResWithProperty {
let mut property = ResProperty::default();
property.name = name.to_string();
ResWithProperty {
reservation: Reservation::new(
NaiveTime::from_hms_opt(from.0, from.1, 0).unwrap(),
NaiveTime::from_hms_opt(to.0, to.1, 0).unwrap(),
1),
property
}
}
fn reservation() -> ResSumWithItems {
let mut reservation = ResSumWithItems::default();
reservation.summary.date = NaiveDate::from_ymd_opt(2030, 3, 5).unwrap();
reservation.reservations.push(item("Court 1", (8, 0), (9, 30)));
reservation
}
fn message(subject: &str, text: &str) -> Message {
let mut message = Message::default();
message.subject = subject.to_string();
message.text = text.to_string();
message
}
#[test]
fn mail_message_replaces_variables() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Date: #date#, items: #summary#."),
&reservation());
assert_eq!(mail.subject, "Booking");
assert_eq!(mail.text, "Date: 05. 03. 2030, items: Court 1: 08:00 - 09:30.");
}
#[test]
fn mail_message_summary_has_one_item_per_line() {
let mut reservation = reservation();
reservation.reservations.push(item("Court 2", (10, 0), (11, 0)));
reservation.reservations.push(item("Sauna", (18, 0), (20, 0)));
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Items:\n#summary#\nBye"),
&reservation);
assert_eq!(mail.text, "Items:\nCourt 1: 08:00 - 09:30\nCourt 2: 10:00 - 11:00\nSauna: 18:00 - 20:00\nBye");
}
#[test]
fn mail_message_summary_is_empty_without_items() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Items: #summary#."),
&ResSumWithItems::default());
assert_eq!(mail.text, "Items: .");
}
#[test]
fn template_added_by_upgrade_is_not_empty() {
let (subject, text) = initial_message(&MessageType::CustomerCanceled);
assert!(!subject.is_empty());
assert!(text.contains("#date#") && text.contains("#summary#") && text.contains("#link#"));
assert_eq!(initial_message(&MessageType::NewReservation), ("", ""));
}
#[test]
fn mail_message_link_is_inserted_or_appended() {
let with_placeholder = MailMessage::new("a@b.cz".to_string(), "c@d.cz".to_string(),
message("Booking", "Date: #date#\nDetail: #link#"), &reservation())
.with_link("https://x/booking/1", Some("Your booking:"));
assert!(with_placeholder.text.ends_with("Detail: https://x/booking/1"), "text: {}", with_placeholder.text);
assert!(!with_placeholder.text.contains("Your booking:"));
let without = MailMessage::new("a@b.cz".to_string(), "c@d.cz".to_string(),
message("Booking", "Thank you."), &reservation())
.with_link("https://x/booking/1", Some("Your booking:"));
assert_eq!(without.text, "Thank you.\n\nYour booking:\nhttps://x/booking/1");
let untouched = MailMessage::new("a@b.cz".to_string(), "c@d.cz".to_string(),
message("Booking", "Thank you."), &reservation())
.with_link("https://x/booking/1", None);
assert_eq!(untouched.text, "Thank you.");
}
#[test]
fn mail_message_without_variables_is_unchanged() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Plain text"),
&reservation());
assert_eq!(mail.text, "Plain text");
}
#[test]
fn build_mail_sets_headers() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"customer@example.com".to_string(),
message("Booking", "Hello"),
&reservation());
let built = String::from_utf8(mail.build_mail("noreply@example.com".to_string()).unwrap().formatted()).unwrap();
assert!(built.contains("From: noreply@example.com"));
assert!(built.contains("Reply-To: admin@example.com"));
assert!(built.contains("To: customer@example.com"));
assert!(built.contains("Subject: Booking"));
}
#[test]
fn build_mail_rejects_invalid_address() {
let mail = MailMessage::new(
"admin@example.com".to_string(),
"not an address".to_string(),
message("Booking", "Hello"),
&reservation());
assert!(matches!(mail.build_mail("noreply@example.com".to_string()), Err(AppError::MailAddrParseErr(_))));
}
}
+131 -5
View File
@@ -9,6 +9,13 @@ pub mod reservation;
pub mod customer; pub mod customer;
pub mod mail; pub mod mail;
pub mod appearance; pub mod appearance;
pub mod color;
pub mod banner;
pub mod layout;
pub mod rich_text;
pub mod cancellation;
#[cfg(feature = "ssr")]
pub mod health;
#[macro_export] #[macro_export]
macro_rules! perm_check { macro_rules! perm_check {
@@ -54,23 +61,38 @@ cfg_if!{
use sqlx::PgPool; use sqlx::PgPool;
use actix_web::web::Data; use actix_web::web::Data;
use leptos_actix::extract; use leptos_actix::extract;
use leptos::ServerFnError; use leptos::prelude::ServerFnError;
use crate::backend::mail::Mailer; use crate::backend::mail::Mailer;
use std::path::PathBuf;
#[derive(Clone)] #[derive(Clone)]
pub struct AppData { pub struct AppData {
db_pool: PgPool, db_pool: PgPool,
mailer: Mailer mailer: Mailer,
data_dir: PathBuf,
public_url: Option<String>
} }
impl AppData { impl AppData {
pub fn new(db_pool: PgPool, mailer: Mailer) -> Self { pub fn new(db_pool: PgPool, mailer: Mailer, data_dir: PathBuf, public_url: Option<String>) -> Self {
Self { Self {
db_pool, db_pool,
mailer mailer,
data_dir,
public_url
} }
} }
/// Address under which the application is reached from outside, when the config has it
pub fn public_url(&self) -> &Option<String> {
&self.public_url
}
/// Directory for files created at runtime, served under `/data`
pub fn data_dir(&self) -> &PathBuf {
&self.data_dir
}
pub fn db_pool(&self) -> &PgPool { pub fn db_pool(&self) -> &PgPool {
&self.db_pool &self.db_pool
} }
@@ -80,14 +102,118 @@ cfg_if!{
} }
} }
/// Address for links sent in e-mails: `public_url` from the config, otherwise the address
/// the current request came to (as the reverse proxy reports it).
pub async fn get_base_url() -> Result<String, ServerFnError> {
use crate::backend::cancellation::normalize_base_url;
use leptos::prelude::use_context;
let data = extract::<Data<AppData>>().await?;
if let Some(url) = data.public_url() {
return Ok(url.clone())
}
let request = use_context::<leptos_actix::Request>().ok_or(ServerFnError::new("No request"))?;
let info = request.connection_info();
normalize_base_url(&format!("{}://{}", info.scheme(), info.host()))
.ok_or(ServerFnError::new("Can't determine public address"))
}
pub async fn get_pool() -> Result<PgPool, ServerFnError> { pub async fn get_pool() -> Result<PgPool, ServerFnError> {
let data = extract::<Data<AppData>>().await?; let data = extract::<Data<AppData>>().await?;
Ok(data.db_pool().clone()) Ok(data.db_pool().clone())
} }
pub async fn get_data_dir() -> Result<PathBuf, ServerFnError> {
let data = extract::<Data<AppData>>().await?;
Ok(data.data_dir().clone())
}
pub async fn get_mailing() -> Result<Mailer, ServerFnError> { pub async fn get_mailing() -> Result<Mailer, ServerFnError> {
let data = extract::<Data<AppData>>().await?; let data = extract::<Data<AppData>>().await?;
Ok(data.mailer().clone()) Ok(data.mailer().clone())
} }
} }
} }
#[cfg(test)]
mod tests {
use std::fs;
use regex::Regex;
/// Server functions that anybody may call. Everything under `/api` is reachable without
/// login unless the function checks the user itself, so each of these is here on purpose:
/// the public booking form, the customer's page of a booking, and logging in.
/// Before adding a name, make sure the function returns nothing about other people.
const PUBLIC_SERVER_FNS: [&str; 18] = [
// Look of the public page
"get_appearance",
// The customer's own data from their cookie
"get_remembered",
// Data of the booking form
"get_hours", "get_hours_for_day", "get_closing_time", "get_closing_times",
"get_properties", "get_active_properties", "get_public_form_data", "get_pow",
"create_reservation",
// The customer's page of a booking; the uuid from the e-mail is the secret
"get_booking", "cancel_booking",
// Session of the caller
"login", "logout", "auth_check", "admin_check", "get_user",
];
/// Names of the server functions in a source file with the information whether they check the user
fn server_fns(source: &str) -> Vec<(String, bool)> {
let header = Regex::new(r"#\[server[^\]]*\]\s*pub async fn (\w+)").unwrap();
let headers = header.captures_iter(source)
.map(|c| (c.get(0).unwrap().start(), c[1].to_string()))
.collect::<Vec<_>>();
headers.iter().enumerate().map(|(index, (start, name))| {
// The body ends where the next server function starts; close enough for a check
let end = headers.get(index + 1).map(|next| next.0).unwrap_or(source.len());
let body = &source[*start..end];
let checked = body.contains("perm_check!(") || body.contains("user_check!(") || body.contains("require_login().await?");
(name.clone(), checked)
}).collect()
}
#[test]
fn server_fns_are_recognized() {
let source = "#[server]\npub async fn open() -> X {\n 1\n}\n\n\
#[server(name = A, prefix = \"/api\", endpoint = \"a\")]\npub async fn closed() -> X {\n perm_check!(is_admin);\n}\n\n\
#[server]\npub async fn other() -> X {\n require_login().await?;\n}\n";
assert_eq!(server_fns(source), vec![
("open".to_string(), false), ("closed".to_string(), true), ("other".to_string(), true)]);
}
/// Fails when a server function neither checks the user nor is listed as public
#[test]
fn every_server_fn_checks_permissions_or_is_listed_as_public() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backend");
let mut unchecked = vec![];
let mut found = 0;
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().is_some_and(|e| e == "rs") {
for (name, checked) in server_fns(&fs::read_to_string(&path).unwrap()) {
found += 1;
if !checked {
unchecked.push(name);
}
}
}
}
unchecked.sort();
let mut public = PUBLIC_SERVER_FNS.to_vec();
public.sort();
assert!(found > 40, "only {} server functions found, the scan is broken", found);
assert_eq!(unchecked, public,
"server functions without a permission check differ from the list of public ones; \
add perm_check!/user_check!/require_login to the function, or put it on the list if it is meant to be public");
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use chrono::Weekday; use chrono::Weekday;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, ClosingTime, DayHour, WeekHours}; use crate::backend::data::{ApiResponse, ClosingTime, DayHour, WeekHours};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
+1 -1
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, ResProperty}; use crate::backend::data::{ApiResponse, ResProperty};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
+246 -29
View File
@@ -1,6 +1,6 @@
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicFormData, ResSumWithItems, Page, PagedResponse}; use crate::backend::data::{ApiResponse, CrReservation, Reservation, PublicBooking, PublicFormData, ResSumWithItems, Page, PagedResponse};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
use cfg_if::cfg_if; use cfg_if::cfg_if;
use chrono::{NaiveDate, NaiveTime}; use chrono::{NaiveDate, NaiveTime};
@@ -18,8 +18,12 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType, ChartData, ResAllView}; use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType, ChartData, ResAllView};
use crate::backend::get_pool; use crate::backend::get_pool;
use crate::backend::get_mailing; use crate::backend::get_mailing;
use crate::backend::get_base_url;
use crate::backend::cancellation::{booking_link, booking_start, cancel_status, CancelStatus};
use crate::backend::data::{PublicBookingItem};
use chrono::NaiveDateTime;
use crate::backend::mail::MailMessage; use crate::backend::mail::MailMessage;
use crate::backend::mail::get_message; use crate::backend::mail::message_for_type;
use crate::error::AppError; use crate::error::AppError;
use sqlx::PgPool; use sqlx::PgPool;
use crate::backend::user::admin_email; use crate::backend::user::admin_email;
@@ -72,6 +76,61 @@ cfg_if! { if #[cfg(feature = "ssr")] {
}) })
} }
/// A booking by the uuid from its link, `None` when there is no such booking
async fn find_booking(uuid: &Uuid) -> Result<Option<ResSumWithItems>, ServerFnError> {
let pool = get_pool().await?;
let summary = query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE uuid = $1")
.bind(uuid)
.fetch_optional(&pool)
.await?;
let Some(summary) = summary else {
return Ok(None)
};
let sum_id = summary.id();
let cust_id = summary.customer;
Ok(Some(ResSumWithItems {
summary,
customer: customer_for_reservation(cust_id, &pool).await?,
reservations: items_for_reservation(sum_id, &pool).await?
}))
}
/// Whether the customer may cancel the booking now, by the deadline set for the company.
/// The times of bookings are local times of the server.
async fn customer_cancel_status(booking: &ResSumWithItems, now: NaiveDateTime) -> Result<CancelStatus, ServerFnError> {
let pool = get_pool().await?;
let cancel_hours: (i32,) = query_as("SELECT cancel_hours FROM company")
.fetch_one(&pool)
.await?;
let starts = booking.reservations.iter().map(|item| item.reservation.from).collect::<Vec<_>>();
Ok(cancel_status(&booking.summary.state, booking_start(booking.summary.date, &starts), cancel_hours.0, now))
}
/// Link to the page of a booking for e-mails. Without a known public address there is none,
/// and the placeholder is replaced by nothing.
async fn link_for(uuid: &Uuid) -> String {
match get_base_url().await {
Ok(base_url) => booking_link(&base_url, &uuid.to_string()),
Err(e) => {
warn!("No link to the booking in the e-mail: {}", e);
String::new()
}
}
}
/// Sentence that introduces the link appended to an e-mail whose template has no `#link#`.
/// It is in the language of the browser that made the request. Without a link nothing is appended.
fn link_intro(link: &str) -> Option<String> {
if link.is_empty() {
None
} else {
Some(trl("You can view or cancel your booking here:")())
}
}
async fn items_for_reservation(id: i32, pool: &PgPool) -> Result<Vec<ResWithProperty>, ServerFnError> { async fn items_for_reservation(id: i32, pool: &PgPool) -> Result<Vec<ResWithProperty>, ServerFnError> {
Ok(query_as::<_, ResWithProperty>( Ok(query_as::<_, ResWithProperty>(
"SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \ "SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \
@@ -160,7 +219,8 @@ cfg_if! { if #[cfg(feature = "ssr")] {
async fn set_state(uuid: Uuid, state: ReservationState) -> Result<(), ServerFnError> { async fn set_state(uuid: Uuid, state: ReservationState) -> Result<(), ServerFnError> {
let pool = get_pool().await?; let pool = get_pool().await?;
query("UPDATE reservation_sum SET state = $1 WHERE uuid = $2") // Used by the staff only; the mark of a cancellation by the customer no longer applies
query("UPDATE reservation_sum SET state = $1, canceled_by_customer = false WHERE uuid = $2")
.bind(state) .bind(state)
.bind(uuid) .bind(uuid)
.execute(&pool) .execute(&pool)
@@ -169,12 +229,20 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(()) Ok(())
} }
/// Mail template of the given type. Not through the `get_message` server function: that one
/// is for logged-in users, and e-mails are sent for requests of customers too.
async fn template(msg_type: MessageType) -> Result<Message, AppError> {
Ok(message_for_type(&msg_type, &get_pool().await?).await?)
}
async fn notify_new_all(admin_mail: String, reservation: &ResSumWithItems) -> Result<(), AppError> { async fn notify_new_all(admin_mail: String, reservation: &ResSumWithItems) -> Result<(), AppError> {
let mailing = get_mailing().await?; let mailing = get_mailing().await?;
let msg = get_message(MessageType::NewReservation).await?; let msg = template(MessageType::NewReservation).await?;
let link = link_for(&reservation.summary.uuid).await;
for m in emails_for_notify().await? { for m in emails_for_notify().await? {
mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), reservation))?; mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), reservation).with_link(&link, None))?;
} }
Ok(()) Ok(())
@@ -182,7 +250,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
async fn notify_new(uuid: Uuid) -> Result<(), AppError> { async fn notify_new(uuid: Uuid) -> Result<(), AppError> {
let mailing = get_mailing().await?; let mailing = get_mailing().await?;
let msg = get_message(MessageType::NewReservationCust).await?; let msg = template(MessageType::NewReservationCust).await?;
let reservation = reservation_by_uuid(uuid).await?; let reservation = reservation_by_uuid(uuid).await?;
let admin_mail = admin_email().await; let admin_mail = admin_email().await;
@@ -190,11 +258,15 @@ cfg_if! { if #[cfg(feature = "ssr")] {
return Err(AppError::MailSendError("No admin mail".to_string())) return Err(AppError::MailSendError("No admin mail".to_string()))
} }
mailing.send_mail(MailMessage::new(admin_mail.clone().unwrap(), reservation.customer.email.clone(), msg, &reservation))?; let link = link_for(&uuid).await;
mailing.send_mail(MailMessage::new(admin_mail.clone().unwrap(), reservation.customer.email.clone(), msg, &reservation)
.with_link(&link, link_intro(&link).as_deref()))?;
notify_new_all(admin_mail.unwrap(), &reservation).await notify_new_all(admin_mail.unwrap(), &reservation).await
} }
async fn send_notify(uuid: Uuid, msg: Message) -> Result<(), AppError> { /// Sends a message about the booking to its customer. The link to the booking is appended
/// to a template without `#link#` only when asked for.
async fn send_notify(uuid: Uuid, msg: Message, append_link: bool) -> Result<(), AppError> {
let mailing = get_mailing().await?; let mailing = get_mailing().await?;
let reservation = reservation_by_uuid(uuid).await?; let reservation = reservation_by_uuid(uuid).await?;
let admin_mail = admin_email().await; let admin_mail = admin_email().await;
@@ -203,15 +275,39 @@ cfg_if! { if #[cfg(feature = "ssr")] {
return Err(AppError::MailSendError("No admin mail".to_string())) return Err(AppError::MailSendError("No admin mail".to_string()))
} }
mailing.send_mail(MailMessage::new(admin_mail.unwrap(), reservation.customer.email.clone(), msg, &reservation)) let link = link_for(&uuid).await;
let intro = if append_link { link_intro(&link) } else { None };
mailing.send_mail(MailMessage::new(admin_mail.unwrap(), reservation.customer.email.clone(), msg, &reservation)
.with_link(&link, intro.as_deref()))
} }
async fn notify_approve(uuid: Uuid) -> Result<(), AppError> { async fn notify_approve(uuid: Uuid) -> Result<(), AppError> {
send_notify(uuid, get_message(MessageType::ReservationApp).await?).await send_notify(uuid, template(MessageType::ReservationApp).await?, true).await
} }
/// There is nothing left to cancel, so no link is appended
async fn notify_cancel(uuid: Uuid) -> Result<(), AppError> { async fn notify_cancel(uuid: Uuid) -> Result<(), AppError> {
send_notify(uuid, get_message(MessageType::ReservationCanceled).await?).await send_notify(uuid, template(MessageType::ReservationCanceled).await?, false).await
}
/// The customer cancelled: a confirmation for them and a message for the staff
async fn notify_customer_cancel(uuid: Uuid) -> Result<(), AppError> {
notify_cancel(uuid).await?;
let mailing = get_mailing().await?;
let msg = template(MessageType::CustomerCanceled).await?;
let reservation = reservation_by_uuid(uuid).await?;
let Some(admin_mail) = admin_email().await else {
return Err(AppError::MailSendError("No admin mail".to_string()))
};
let link = link_for(&uuid).await;
for m in emails_for_notify().await? {
mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), &reservation).with_link(&link, None))?;
}
Ok(())
} }
async fn month_chart_data(year: i32) -> Result<Vec<ChartData>, AppError> { async fn month_chart_data(year: i32) -> Result<Vec<ChartData>, AppError> {
@@ -407,15 +503,6 @@ fn num_days(month: u32, year: i32) -> i64 {
.num_days() .num_days()
} }
#[server]
pub async fn get_reservations_for_month(month: u32, year: i32) -> Result<ApiResponse<Vec<ResSumWithItems>>, ServerFnError> {
let data = reservations_in_range(&NaiveDate::from_ymd_opt(year, month, 1).unwrap(),
&NaiveDate::from_ymd_opt(year, month, num_days(month, year).to_u32().unwrap_or_default()).unwrap(),
None, Page::default()).await?;
Ok(ApiResponse::Data(data.0))
}
#[server] #[server]
pub async fn approve(uuid: String) -> Result<ApiResponse<()>, ServerFnError> { pub async fn approve(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
@@ -448,9 +535,83 @@ pub async fn cancel(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
Ok(ApiResponse::Data(())) Ok(ApiResponse::Data(()))
} }
/// The booking for the page behind the link from e-mails. Whoever knows the uuid may see it;
/// an unknown and a malformed uuid give the same answer.
#[server]
pub async fn get_booking(uuid: String) -> Result<ApiResponse<PublicBooking>, ServerFnError> {
use chrono::Local;
let Ok(uuid) = Uuid::parse_str(&uuid) else {
return Ok(ApiResponse::Error("Booking not found".to_string()))
};
let Some(booking) = find_booking(&uuid).await? else {
return Ok(ApiResponse::Error("Booking not found".to_string()))
};
let status = customer_cancel_status(&booking, Local::now().naive_local()).await?;
let mut items = booking.reservations.iter().map(|item| PublicBookingItem {
name: item.property.name.clone(),
from: item.reservation.from,
to: item.reservation.to,
}).collect::<Vec<_>>();
items.sort_by(|a, b| (a.from, &a.name).cmp(&(b.from, &b.name)));
Ok(ApiResponse::Data(PublicBooking {
date: booking.summary.date,
items,
price: booking.summary.price,
state: booking.summary.state.clone(),
can_cancel: matches!(status, CancelStatus::Allowed(_)),
cancel_until: match status {
CancelStatus::Allowed(until) | CancelStatus::TooLate(until) => until,
CancelStatus::AlreadyCanceled => booking_start(booking.summary.date, &[]),
},
}))
}
/// Cancelling by the customer, from the page behind the link. Public: the uuid is the secret.
/// The booking is kept, in the cancelled state.
#[server]
pub async fn cancel_booking(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
use chrono::Local;
use crate::backend::data::ReservationState;
let Ok(uuid) = Uuid::parse_str(&uuid) else {
return Ok(ApiResponse::Error("Booking not found".to_string()))
};
let Some(booking) = find_booking(&uuid).await? else {
return Ok(ApiResponse::Error("Booking not found".to_string()))
};
match customer_cancel_status(&booking, Local::now().naive_local()).await? {
// Cancelling twice (a double click, a reloaded page) is not an error
CancelStatus::AlreadyCanceled => return Ok(ApiResponse::Data(())),
CancelStatus::TooLate(_) => return Ok(ApiResponse::Error("This booking can no longer be cancelled".to_string())),
CancelStatus::Allowed(_) => {}
}
let pool = get_pool().await?;
let updated = query("UPDATE reservation_sum SET state = $1, canceled_by_customer = true WHERE uuid = $2 AND state <> $1")
.bind(ReservationState::Canceled)
.bind(uuid)
.execute(&pool)
.await?;
// Somebody else may have cancelled it in the meantime; then the e-mails were sent already
if updated.rows_affected() > 0 {
info!("Booking cancelled by the customer");
if let Err(e) = notify_customer_cancel(uuid).await {
warn!("Cancel notification not send: {}", e);
}
}
Ok(ApiResponse::Data(()))
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String { fn chart(data: &Vec<ChartData>, title: &str, month: bool, dark: bool) -> String {
use charts_rs::{BarChart, THEME_ANT}; use charts_rs::{BarChart, THEME_ANT, THEME_DARK};
use base64::Engine; use base64::Engine;
use base64::prelude::BASE64_STANDARD; use base64::prelude::BASE64_STANDARD;
use std::ops::Add; use std::ops::Add;
@@ -463,7 +624,11 @@ fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
} else { } else {
data.iter().map(|d| d.period.to_string()).collect() data.iter().map(|d| d.period.to_string()).collect()
}, },
THEME_ANT); if dark { THEME_DARK } else { THEME_ANT });
if dark {
// Card background of the dark theme, see assets/css/dark.css
chart.background_color = (43, 44, 64).into();
}
chart.title_text = title.to_string(); chart.title_text = title.to_string();
chart.legend_show = Some(false); chart.legend_show = Some(false);
chart.series_label_font_size = 8.0; chart.series_label_font_size = 8.0;
@@ -480,25 +645,28 @@ fn chart(data: &Vec<ChartData>, title: &str, month: bool) -> String {
} }
#[server] #[server]
pub async fn month_chart(year: i32) -> Result<ApiResponse<String>, ServerFnError> { pub async fn month_chart(year: i32, dark: bool) -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
perm_check!(is_logged_in); perm_check!(is_logged_in);
let data = month_chart_data(year).await?; let data = month_chart_data(year).await?;
Ok(ApiResponse::Data(chart(&data, &trl("Month bookings")(), true))) Ok(ApiResponse::Data(chart(&data, &trl("Month bookings")(), true, dark)))
} }
#[server] #[server]
pub async fn year_chart() -> Result<ApiResponse<String>, ServerFnError> { pub async fn year_chart(dark: bool) -> Result<ApiResponse<String>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
perm_check!(is_logged_in); perm_check!(is_logged_in);
let data = year_chart_data().await?; let data = year_chart_data().await?;
Ok(ApiResponse::Data(chart(&data, &trl("Year bookings")(), false))) Ok(ApiResponse::Data(chart(&data, &trl("Year bookings")(), false, dark)))
} }
#[server] #[server]
pub async fn years() -> Result<Vec<i32>, ServerFnError> { pub async fn years() -> Result<Vec<i32>, ServerFnError> {
use crate::backend::user::require_login;
require_login().await?;
let pool = get_pool().await?; let pool = get_pool().await?;
let data: Result<Vec<(f64,)>, Error> = query_as("SELECT DISTINCT date_part('year', date) as year FROM reservation_sum ORDER BY year DESC") let data: Result<Vec<(f64,)>, Error> = query_as("SELECT DISTINCT date_part('year', date) as year FROM reservation_sum ORDER BY year DESC")
@@ -510,6 +678,9 @@ pub async fn years() -> Result<Vec<i32>, ServerFnError> {
#[server] #[server]
pub async fn months(year: i32) -> Result<Vec<u32>, ServerFnError> { pub async fn months(year: i32) -> Result<Vec<u32>, ServerFnError> {
use crate::backend::user::require_login;
require_login().await?;
let pool = get_pool().await?; let pool = get_pool().await?;
let data: Result<Vec<(f64,)>, Error> = query_as("SELECT DISTINCT date_part('month', date) as month \ let data: Result<Vec<(f64,)>, Error> = query_as("SELECT DISTINCT date_part('month', date) as month \
@@ -535,4 +706,50 @@ pub async fn reservations_in_month(year: i32, month: u32) -> Result<ApiResponse<
.await?; .await?;
Ok(ApiResponse::Data(ret.0)) Ok(ApiResponse::Data(ret.0))
} }
#[cfg(test)]
mod tests {
use super::*;
fn time(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).unwrap()
}
#[test]
fn is_reserved_inside_reservation() {
let reservations = vec![Reservation::new(time(8, 0), time(10, 0), 1)];
assert!(is_reserved(&reservations, &time(8, 0), 1));
assert!(is_reserved(&reservations, &time(9, 30), 1));
}
#[test]
fn is_reserved_end_is_exclusive() {
let reservations = vec![Reservation::new(time(8, 0), time(10, 0), 1)];
assert!(!is_reserved(&reservations, &time(10, 0), 1));
assert!(!is_reserved(&reservations, &time(7, 59), 1));
}
#[test]
fn is_reserved_checks_property() {
let reservations = vec![Reservation::new(time(8, 0), time(10, 0), 1)];
assert!(!is_reserved(&reservations, &time(9, 0), 2));
}
#[test]
fn is_reserved_with_more_reservations() {
let reservations = vec![
Reservation::new(time(8, 0), time(9, 0), 1),
Reservation::new(time(12, 0), time(13, 0), 2),
Reservation::new(time(15, 0), time(16, 0), 1)
];
assert!(is_reserved(&reservations, &time(15, 30), 1));
assert!(is_reserved(&reservations, &time(12, 0), 2));
assert!(!is_reserved(&reservations, &time(12, 0), 1));
assert!(!is_reserved(&vec![], &time(12, 0), 1));
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Title and formatted text shown at the top of the public page.
//!
//! The text is written in a simple editor in the appearance settings and stored as HTML.
//! It is shown to every visitor, so the server reduces it to a few harmless tags before storing it.
/// Longest title of the public page, in characters
pub const MAX_TITLE_LENGTH: usize = 200;
/// Longest text under the banner, in bytes of its HTML
pub const MAX_TEXT_LENGTH: usize = 20_000;
/// Schemes a link in the text may have
const LINK_SCHEMES: [&str; 4] = ["http", "https", "mailto", "tel"];
/// Turns what a user typed as a link address into a URL the sanitizer accepts: an address
/// without a scheme becomes `https://` (or `mailto:` for an e-mail address).
/// Returns `None` when it can't be a link.
pub fn normalize_link(input: &str) -> Option<String> {
let link = input.trim();
if link.is_empty() || link.chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '<' | '>')) {
return None
}
let lower = link.to_lowercase();
if LINK_SCHEMES.iter().any(|scheme| {
lower.strip_prefix(scheme).and_then(|rest| rest.strip_prefix(':')).is_some_and(|rest| !rest.is_empty())
}) {
return Some(link.to_string())
}
// Any other scheme (javascript:, data:, ...); a colon after a host name is a port
if let Some((before, after)) = lower.split_once(':') {
let is_port = after.split('/').next().is_some_and(|port| !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()));
if !before.contains('/') && !is_port {
return None
}
}
if link.contains('@') && !link.contains('/') {
Some(format!("mailto:{}", link))
} else {
Some(format!("https://{}", link))
}
}
/// Reduces HTML to what the editor can produce: paragraphs, line breaks, bold, italic,
/// headings, lists and links. Everything else is removed, scripts and styles with their content.
/// Returns `None` when no text is left.
#[cfg(feature = "ssr")]
pub fn sanitize_text(html: &str) -> Option<String> {
use std::collections::{HashMap, HashSet};
use ammonia::{Builder, UrlRelative};
let clean = Builder::default()
// `div` is what browsers put around lines typed into an editable element
.tags(HashSet::from(["p", "div", "br", "strong", "b", "em", "i", "h2", "h3", "h4", "ul", "ol", "li", "a"]))
.tag_attributes(HashMap::from([("a", HashSet::from(["href"]))]))
.generic_attributes(HashSet::new())
.url_schemes(HashSet::from(LINK_SCHEMES))
.url_relative(UrlRelative::Deny)
.link_rel(Some("noopener noreferrer"))
.clean(html)
.to_string();
// Markup without any text, like the "<p><br></p>" an emptied editor leaves behind
let text = regex::Regex::new(r"<[^>]*>").unwrap().replace_all(&clean, "");
if text.replace("&nbsp;", "").trim().is_empty() {
return None
}
Some(clean)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_link_keeps_allowed_schemes() {
for link in ["https://example.com/a?b=c#d", "http://example.com", "mailto:info@example.com", "tel:+420123456789",
"HTTPS://Example.com"] {
assert_eq!(normalize_link(link), Some(link.to_string()));
}
}
#[test]
fn normalize_link_adds_scheme() {
assert_eq!(normalize_link("example.com"), Some("https://example.com".to_string()));
assert_eq!(normalize_link(" www.example.com/path "), Some("https://www.example.com/path".to_string()));
assert_eq!(normalize_link("example.com:8080/path"), Some("https://example.com:8080/path".to_string()));
assert_eq!(normalize_link("example.com/a:b"), Some("https://example.com/a:b".to_string()));
assert_eq!(normalize_link("info@example.com"), Some("mailto:info@example.com".to_string()));
}
#[test]
fn normalize_link_rejects_other_input() {
for link in ["", " ", "javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,x", "vbscript:x", "file:///etc/passwd",
"two words", "a\"b", "<script>", "https:", "ftp://example.com"] {
assert_eq!(normalize_link(link), None, "link: {:?}", link);
}
}
#[cfg(feature = "ssr")]
mod sanitize {
use super::super::sanitize_text;
fn clean(html: &str) -> String {
sanitize_text(html).unwrap_or_default()
}
#[test]
fn keeps_what_the_editor_produces() {
let html = "<h3>Opening hours</h3><p>Open <strong>daily</strong>, <em>also</em> on <b>holidays</b> <i>!</i><br>8–20</p>\
<ul><li>one</li><li>two</li></ul><ol><li>first</li></ol><div>line</div><h2>a</h2><h4>b</h4>";
assert_eq!(clean(html), html);
}
#[test]
fn links_keep_only_safe_addresses() {
assert_eq!(clean("<a href=\"https://example.com\">web</a>"),
"<a href=\"https://example.com\" rel=\"noopener noreferrer\">web</a>");
assert_eq!(clean("<a href=\"mailto:info@example.com\">mail</a>"),
"<a href=\"mailto:info@example.com\" rel=\"noopener noreferrer\">mail</a>");
assert_eq!(clean("<a href=\"tel:+420123456789\">call</a>"),
"<a href=\"tel:+420123456789\" rel=\"noopener noreferrer\">call</a>");
for href in ["javascript:alert(1)", "JaVaScRiPt:alert(1)", "data:text/html,<script>x</script>", "vbscript:x",
"/relative", "relative.html", "ftp://example.com"] {
let cleaned = clean(&format!("<a href=\"{}\">x</a>", href));
assert!(!cleaned.contains("href"), "href: {:?} -> {:?}", href, cleaned);
}
}
#[test]
fn removes_scripts_styles_and_handlers() {
assert_eq!(clean("<p>a</p><script>alert(1)</script>"), "<p>a</p>");
assert_eq!(clean("<style>p { color: red }</style><p>a</p>"), "<p>a</p>");
assert_eq!(clean("<p onclick=\"alert(1)\" style=\"color: red\" class=\"x\" id=\"y\">a</p>"), "<p>a</p>");
assert_eq!(clean("<a href=\"https://example.com\" onclick=\"x()\" target=\"_blank\" style=\"x\">a</a>"),
"<a href=\"https://example.com\" rel=\"noopener noreferrer\">a</a>");
assert_eq!(clean("<img src=\"x\" onerror=\"alert(1)\">text"), "text");
assert_eq!(clean("<iframe src=\"https://example.com\"></iframe>text"), "text");
assert_eq!(clean("<svg onload=\"alert(1)\"></svg><p>a</p>"), "<p>a</p>");
}
#[test]
fn removes_other_tags_but_keeps_their_text() {
assert_eq!(clean("<h1>Title</h1><table><tr><td>cell</td></tr></table><span style=\"font-size: 40px\">big</span><u>u</u>"),
"Titlecellbigu");
// Formatting pasted from a word processor
assert_eq!(clean("<p class=\"MsoNormal\" style=\"margin: 0\"><span lang=\"CS\"><font color=\"red\">Text</font></span></p>"),
"<p>Text</p>");
}
#[test]
fn repairs_broken_markup() {
assert_eq!(clean("<p>unclosed <strong>bold"), "<p>unclosed <strong>bold</strong></p>");
assert_eq!(clean("plain text"), "plain text");
assert_eq!(clean("a < b & c"), "a &lt; b &amp; c");
}
#[test]
fn empty_content_is_none() {
for html in ["", " ", "<p><br></p>", "<div><br></div>", "<p>&nbsp;</p>", "<script>alert(1)</script>", "<p> </p><ul><li></li></ul>"] {
assert_eq!(sanitize_text(html), None, "html: {:?}", html);
}
}
}
}
+30 -5
View File
@@ -1,5 +1,5 @@
use cfg_if::cfg_if; use cfg_if::cfg_if;
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
use crate::backend::data::{ApiResponse, PwdChange, User, UserProfile}; use crate::backend::data::{ApiResponse, PwdChange, User, UserProfile};
use crate::components::data_form::ForValidation; use crate::components::data_form::ForValidation;
@@ -11,6 +11,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use log::{info, warn}; use log::{info, warn};
use crate::error::AppError; use crate::error::AppError;
use crate::backend::get_pool; use crate::backend::get_pool;
use crate::config::Admin;
pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> { pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> {
let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#) let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#)
@@ -21,15 +22,24 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(count.0 > 0) Ok(count.0 > 0)
} }
pub async fn create_admin(pool: &PgPool) -> Result<(), AppError> { /// Creates the first admin account when there is none. The password comes from the
/// configuration, there is no default one.
pub async fn create_admin(pool: &PgPool, admin_conf: &Admin) -> Result<(), AppError> {
if !has_admin_user(pool).await? { if !has_admin_user(pool).await? {
let pwd = pwhash::bcrypt::hash("admin"); let password = admin_conf.initial_password().map_err(AppError::FatalError)?;
let pwd = pwhash::bcrypt::hash(password).map_err(|e| AppError::FatalError(e.to_string()))?;
query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#) query(r#"INSERT INTO "user"(login, password, full_name, admin) VALUES($1, $2, $3, $4)"#)
.bind("admin") .bind("admin")
.bind(pwd.unwrap()) .bind(pwd)
.bind("Admin User") .bind("Admin User")
.bind(true) .bind(true)
.execute(pool).await?; .execute(pool).await?;
info!("Created admin account with the configured initial password");
} else if let Ok(admin) = user_from_login(pool, "admin").await {
// Installations created by older versions started with a known password
if pwhash::bcrypt::verify("admin", &admin.password) {
warn!("Account admin still uses the default password, change it!");
}
} }
Ok(()) Ok(())
@@ -62,6 +72,21 @@ cfg_if! { if #[cfg(feature = "ssr")] {
logged_in_user().await.is_some() logged_in_user().await.is_some()
} }
/// Permission check for server functions that don't return `ApiResponse`, where the
/// `perm_check!` macro can't be used: answers 403 with an error unless a user is logged in.
pub async fn require_login() -> Result<(), ServerFnError> {
if is_logged_in().await {
return Ok(())
}
if let Some(response) = use_context::<leptos_actix::ResponseOptions>() {
response.set_status(actix_web::http::StatusCode::FORBIDDEN);
}
warn!("Permission denied for a request without login");
Err(ServerFnError::new("Forbidden"))
}
pub async fn is_admin() -> bool { pub async fn is_admin() -> bool {
if let Some(user) = logged_in_user().await { if let Some(user) = logged_in_user().await {
user.admin user.admin
@@ -157,7 +182,7 @@ pub async fn get_user() -> Result<Option<User>, ServerFnError> {
Ok(logged_in_user().await) Ok(logged_in_user().await)
} }
#[server(GetUsers, "/api", "Url", "get_users")] #[server(name = GetUsers, prefix = "/api", endpoint = "get_users")]
pub async fn get_users() -> Result<ApiResponse<Vec<User>>, ServerFnError> { pub async fn get_users() -> Result<ApiResponse<Vec<User>>, ServerFnError> {
use crate::perm_check; use crate::perm_check;
use crate::backend::get_pool; use crate::backend::get_pool;
+17 -4
View File
@@ -1,6 +1,7 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
use crate::components::theme::Theme;
use crate::components::user_menu::{MenuOpener, UserMenu}; use crate::components::user_menu::{MenuOpener, UserMenu};
use crate::locales::trl; use crate::locales::trl;
use crate::pages::change_pwd::ChangePassword; use crate::pages::change_pwd::ChangePassword;
@@ -12,7 +13,7 @@ fn about(opener: DialogOpener) -> impl IntoView {
<ModalDialog opener=opener title=""> <ModalDialog opener=opener title="">
<ModalBody> <ModalBody>
<img src="/rezervovator_l.svg" width="180"/> <br /><br/> <img src="/rezervovator_l.svg" width="180"/> <br /><br/>
<p> <div>
{trl("Online booking application for sports facilities and service providers.")}<br/> {trl("Online booking application for sports facilities and service providers.")}<br/>
{format!(" v {}", env!("CARGO_PKG_VERSION"))}<br/><br/> {format!(" v {}", env!("CARGO_PKG_VERSION"))}<br/><br/>
<div align="center"> <div align="center">
@@ -20,7 +21,7 @@ fn about(opener: DialogOpener) -> impl IntoView {
<a href="https://leptos.dev" target="_blank"><img src="/Leptos_logo.png" height="40"/></a> <br/><br/> <a href="https://leptos.dev" target="_blank"><img src="/Leptos_logo.png" height="40"/></a> <br/><br/>
"(c) 2023 - 2025" "(c) 2023 - 2025"
</div> </div>
</p> </div>
</ModalBody> </ModalBody>
</ModalDialog> </ModalDialog>
} }
@@ -66,13 +67,16 @@ fn settings_menu(opener: MenuOpener) -> impl IntoView {
pub fn AdminPortal(children: Children) -> impl IntoView { pub fn AdminPortal(children: Children) -> impl IntoView {
let user_menu = MenuOpener::new(); let user_menu = MenuOpener::new();
let settings_menu = MenuOpener::new(); let settings_menu = MenuOpener::new();
let (user, set_user) = create_signal(User::default()); let (user, set_user) = signal(User::default());
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let pw_changer = DialogOpener::new(); let pw_changer = DialogOpener::new();
let drawer = use_context::<MenuOpener>().expect("No drawer opener"); let drawer = use_context::<MenuOpener>().expect("No drawer opener");
let about_dlg = DialogOpener::new(); let about_dlg = DialogOpener::new();
let theme = use_context::<Theme>().expect("No theme");
view! { view! {
// Dark theme is limited to the admin portal: the stylesheet is loaded only here
<link rel="stylesheet" href="/css/dark.css" media=move || theme.mode().media()/>
<div class="layout-wrapper layout-content-navbar"> <div class="layout-wrapper layout-content-navbar">
<div class="layout-container"> <div class="layout-container">
//<!-- Menu --> //<!-- Menu -->
@@ -141,6 +145,15 @@ pub fn AdminPortal(children: Children) -> impl IntoView {
<ul class="navbar-nav flex-row align-items-center ms-auto"> <ul class="navbar-nav flex-row align-items-center ms-auto">
//<!-- Theme -->
<li class="nav-item">
<a class="nav-link" href="javascript:void(0)"
title=move || trl(theme.mode().label())()
on:click=move |_| theme.cycle()>
<i class=move || theme.mode().icon()></i>
</a>
</li>
<li class="nav-item navbar-dropdown dropdown-user dropdown"> <li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow" href="#" on:click=move |_| settings_menu.toggle()> <a class="nav-link dropdown-toggle hide-arrow" href="#" on:click=move |_| settings_menu.toggle()>
<i class="bx bx-cog fs-3 lh-0"></i> <i class="bx bx-cog fs-3 lh-0"></i>
+33 -31
View File
@@ -1,11 +1,12 @@
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter};
use leptos::*; use leptos::prelude::*;
use leptos::server_fn::client::browser::BrowserClient; use leptos::form::{ActionForm, FromFormData};
use leptos::server_fn::codec::{Json, PostUrl}; use leptos::ev::SubmitEvent;
use leptos::server_fn::error::NoCustomError; use leptos::server_fn::client::Client;
use leptos::server_fn::ServerFn; use leptos::server_fn::codec::PostUrl;
use leptos_router::*; use leptos::server_fn::request::ClientReq;
use leptos::server_fn::{Http, ServerFn};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use validator::Validate; use validator::Validate;
use crate::components::server_err::ServerErr; use crate::components::server_err::ServerErr;
@@ -18,27 +19,28 @@ pub trait ForValidation {
} }
#[component] #[component]
pub fn data_form<T: 'static + Clone + pub fn data_form<T, O>(
ServerFn<Client = BrowserClient,
InputEncoding = PostUrl,
OutputEncoding = Json,
Error = NoCustomError,
Output = ApiResponse<()>> +
DeserializeOwned + ForValidation>(
opener: DialogOpener, opener: DialogOpener,
action: Action<T, Result<ApiResponse<()>, ServerFnError>>, action: ServerAction<T>,
title: &'static str, title: &'static str,
children: Children children: Children
) -> impl IntoView { ) -> impl IntoView
let upd_val = action.value(); where
T: ServerFn<Protocol = Http<PostUrl, O>, Output = ApiResponse<()>, Error = ServerFnError>
+ DeserializeOwned + ForValidation + Clone + Send + Sync + 'static,
T::Client: Client<ServerFnError>,
<<T::Client as Client<ServerFnError>>::Request as ClientReq<ServerFnError>>::FormData: From<web_sys::FormData>,
O: 'static
{
let upd_val = Signal::derive(move || action.value().get());
let validator = Validator::new(); let validator = Validator::new();
view! { view! {
<ActionForm <ActionForm
on:submit=move |ev| { // Capture phase, so the validation runs before the form is dispatched
let act = T::from_event(&ev); on:submit:capture=move |ev: SubmitEvent| {
if !act.is_err() { if let Ok(act) = T::from_event(&ev) {
validator.check(act.unwrap().entity(), &ev); validator.check(act.entity(), &ev);
} }
} }
action=action> action=action>
@@ -65,20 +67,20 @@ pub fn data_form<T: 'static + Clone +
} }
#[component] #[component]
pub fn question_dialog<T: 'static pub fn question_dialog<T, O>(
+ ServerFn<Client = BrowserClient,
InputEncoding = PostUrl,
OutputEncoding = Json,
Error = NoCustomError,
Output = ApiResponse<()>>
+ DeserializeOwned
+ Clone>(
opener: DialogOpener, opener: DialogOpener,
action: Action<T, Result<ApiResponse<()>, ServerFnError>>, action: ServerAction<T>,
title: &'static str, title: &'static str,
children: Children children: Children
) -> impl IntoView { ) -> impl IntoView
let upd_val = action.value(); where
T: ServerFn<Protocol = Http<PostUrl, O>, Output = ApiResponse<()>, Error = ServerFnError>
+ DeserializeOwned + Clone + Send + Sync + 'static,
T::Client: Client<ServerFnError>,
<<T::Client as Client<ServerFnError>>::Request as ClientReq<ServerFnError>>::FormData: From<web_sys::FormData>,
O: 'static
{
let upd_val = Signal::derive(move || action.value().get());
view! { view! {
<ActionForm action=action> <ActionForm action=action>
+7 -18
View File
@@ -1,25 +1,23 @@
use leptos::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::*;
use crate::app::DialogHelper; use crate::app::DialogHelper;
use crate::backend::appearance::get_appearance;
use crate::components::user_menu::MenuOpener; use crate::components::user_menu::MenuOpener;
#[component] #[component]
pub fn Header() -> impl IntoView { pub fn Header() -> impl IntoView {
let drawer = use_context::<MenuOpener>().expect("No drawer opener"); let drawer = use_context::<MenuOpener>().expect("No drawer opener");
let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper"); let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper");
let appearance = create_blocking_resource(||(), |_| get_appearance());
view! { view! {
<Html <Html
{..}
lang="cz" lang="cz"
dir="ltr" dir="ltr"
class = {move || if drawer.visible() {"light-style layout-menu-fixed layout-menu-expanded"} class = {move || if drawer.visible() {"light-style layout-menu-fixed layout-menu-expanded"}
else {"light-style layout-menu-fixed"} } else {"light-style layout-menu-fixed"} }
attributes=vec![ data-theme="theme-default"
("data-theme", "theme-default".into_attribute()), data-template="vertical-menu-template-free"
("data-template", "vertical-menu-template-free".into_attribute()), data-assets-path="/"
("data-assets-path", "/".into_attribute())]
/> />
<Title text="Rezervovator"/> <Title text="Rezervovator"/>
<Meta charset="utf-8"/> <Meta charset="utf-8"/>
@@ -40,20 +38,11 @@ pub fn Header() -> impl IntoView {
<Link rel="stylesheet" href="/vendor/css/core.css" /> <Link rel="stylesheet" href="/vendor/css/core.css" />
<Link rel="stylesheet" href="/vendor/css/theme-default.css" /> <Link rel="stylesheet" href="/vendor/css/theme-default.css" />
<Link rel="stylesheet" href="/css/demo.css" /> <Link rel="stylesheet" href="/css/demo.css" />
<Transition fallback=move || view! {""}> <Link rel="stylesheet" href="/css/public.css" />
{
appearance.get().map(|a| match a {
Ok(a) => view! {<Link rel="stylesheet" href={format!("/data/{}", a.css_name.unwrap_or_default())} />},
Err(_) => view! {<Link rel="stylesheet" href="/data/banner.css" />}
})
}
</Transition>
<Link rel="stylesheet" href="/vendor/css/control.css" /> <Link rel="stylesheet" href="/vendor/css/control.css" />
//<!-- Vendors CSS --> //<!-- Vendors CSS -->
<Link rel="stylesheet" href="/vendor/libs/perfect-scrollbar/perfect-scrollbar.css" /> <Link rel="stylesheet" href="/vendor/libs/perfect-scrollbar/perfect-scrollbar.css" />
<Body attributes=vec![ <Body {..} style={move || if dlg_helper.is_open() {"overflow: hidden;"} else {""}}/>
("style", {move || if dlg_helper.is_open() {"overflow: hidden;"} else {""}}.into_attribute())
]/>
} }
} }
+1
View File
@@ -5,4 +5,5 @@ pub mod header;
pub mod admin_portal; pub mod admin_portal;
pub mod user_menu; pub mod user_menu;
pub mod data_form; pub mod data_form;
pub mod theme;
+18 -13
View File
@@ -1,5 +1,5 @@
use crate::locales::trl; use crate::locales::trl;
use leptos::*; use leptos::prelude::*;
use crate::app::DialogHelper; use crate::app::DialogHelper;
use crate::validator::Validator; use crate::validator::Validator;
@@ -11,15 +11,17 @@ pub struct DialogOpener {
set_empty: WriteSignal<String>, set_empty: WriteSignal<String>,
not_checked: ReadSignal<Option<String>>, not_checked: ReadSignal<Option<String>>,
set_not_checked: WriteSignal<Option<String>>, set_not_checked: WriteSignal<Option<String>>,
show_err: RwSignal<bool> show_err: RwSignal<bool>,
// Context is not available in event handlers, so the helper is looked up when the opener is created
dlg_helper: Option<DialogHelper>
} }
impl DialogOpener { impl DialogOpener {
pub fn new() -> Self { pub fn new() -> Self {
let (visible, set_visible) = create_signal(false); let (visible, set_visible) = signal(false);
let (empty, set_empty) = create_signal("".to_string()); let (empty, set_empty) = signal("".to_string());
let (not_checked, set_not_checked) = create_signal(None); let (not_checked, set_not_checked) = signal(None);
let show_err = create_rw_signal(false); let show_err = RwSignal::new(false);
DialogOpener { DialogOpener {
visible, visible,
set_visible, set_visible,
@@ -27,7 +29,8 @@ impl DialogOpener {
set_empty, set_empty,
not_checked, not_checked,
set_not_checked, set_not_checked,
show_err show_err,
dlg_helper: use_context::<DialogHelper>()
} }
} }
@@ -36,15 +39,17 @@ impl DialogOpener {
} }
pub fn show(&self) { pub fn show(&self) {
let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper"); if let Some(dlg_helper) = self.dlg_helper {
dlg_helper.set_opened(true); dlg_helper.set_opened(true);
self.set_visible.update(|state| *state = true); }
self.set_visible.set(true);
} }
pub fn hide(&self) { pub fn hide(&self) {
let dlg_helper = use_context::<DialogHelper>().expect("No dialog helper"); if let Some(dlg_helper) = self.dlg_helper {
dlg_helper.set_opened(false); dlg_helper.set_opened(false);
self.set_visible.update(|state| *state = false); }
self.set_visible.set(false);
self.set_empty.set("".to_string()); self.set_empty.set("".to_string());
self.set_not_checked.set(None); self.set_not_checked.set(None);
self.show_err.set(false); self.show_err.set(false);
+7 -6
View File
@@ -1,11 +1,12 @@
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use leptos::*; use leptos::prelude::*;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::locales::trl; use crate::locales::trl;
#[component] #[component]
pub fn ServerErr( pub fn ServerErr(
result: RwSignal<Option<Result<ApiResponse<()>, ServerFnError>>>, #[prop(into)]
result: Signal<Option<Result<ApiResponse<()>, ServerFnError>>>,
opener: DialogOpener, opener: DialogOpener,
) -> impl IntoView { ) -> impl IntoView {
view! {{move || { view! {{move || {
@@ -23,22 +24,22 @@ pub fn ServerErr(
}}> }}>
{trl(&err)} {trl(&err)}
</div> </div>
} }.into_any()
} }
else { else {
opener.hide(); opener.hide();
view! {<div></div>} view! {<div></div>}.into_any()
} }
Err(e) => { Err(e) => {
view! { view! {
<div class="alert alert-danger"> <div class="alert alert-danger">
"Server error: " {e.to_string()} "Server error: " {e.to_string()}
</div> </div>
} }.into_any()
} }
} }
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
}} }}
} }
+168
View File
@@ -0,0 +1,168 @@
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use codee::string::FromToStringCodec;
use leptos::prelude::*;
use leptos_use::{use_cookie_with_options, use_preferred_dark, SameSite, UseCookieOptions};
const THEME_COOKIE: &str = "theme";
const YEAR_MS: i64 = 365 * 24 * 3600 * 1000;
/// Colour theme of the admin portal chosen by the user
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ThemeMode {
/// Follow the operating system setting
#[default]
Auto,
Light,
Dark
}
impl ThemeMode {
/// Next mode in the order of the switch: auto -> light -> dark -> auto
pub fn next(&self) -> Self {
match self {
ThemeMode::Auto => ThemeMode::Light,
ThemeMode::Light => ThemeMode::Dark,
ThemeMode::Dark => ThemeMode::Auto
}
}
/// Media query for the dark stylesheet link
pub fn media(&self) -> &'static str {
match self {
ThemeMode::Auto => "(prefers-color-scheme: dark)",
ThemeMode::Light => "not all",
ThemeMode::Dark => "all"
}
}
pub fn icon(&self) -> &'static str {
match self {
ThemeMode::Auto => "bx bx-adjust fs-3 lh-0",
ThemeMode::Light => "bx bx-sun fs-3 lh-0",
ThemeMode::Dark => "bx bx-moon fs-3 lh-0"
}
}
/// Translation key of the mode description
pub fn label(&self) -> &'static str {
match self {
ThemeMode::Auto => "Theme: by system",
ThemeMode::Light => "Theme: light",
ThemeMode::Dark => "Theme: dark"
}
}
pub fn is_dark(&self, system_dark: bool) -> bool {
match self {
ThemeMode::Auto => system_dark,
ThemeMode::Light => false,
ThemeMode::Dark => true
}
}
}
impl Display for ThemeMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", match self {
ThemeMode::Auto => "auto",
ThemeMode::Light => "light",
ThemeMode::Dark => "dark"
})
}
}
impl FromStr for ThemeMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"auto" => Ok(ThemeMode::Auto),
"light" => Ok(ThemeMode::Light),
"dark" => Ok(ThemeMode::Dark),
_ => Err(())
}
}
}
/// Theme of the admin portal. The choice is kept in a cookie, so the server renders the same theme as the browser.
#[derive(Clone, Copy)]
pub struct Theme {
cookie: Signal<Option<String>>,
set_cookie: WriteSignal<Option<String>>,
system_dark: Signal<bool>
}
impl Theme {
pub fn new() -> Self {
let (cookie, set_cookie) = use_cookie_with_options::<String, FromToStringCodec>(
THEME_COOKIE,
UseCookieOptions::default()
.max_age(YEAR_MS)
.path("/")
.same_site(SameSite::Lax));
Self {
cookie,
set_cookie,
system_dark: use_preferred_dark()
}
}
pub fn mode(&self) -> ThemeMode {
self.cookie.get().and_then(|m| m.parse().ok()).unwrap_or_default()
}
/// Switch to the next mode
pub fn cycle(&self) {
let next = self.cookie.get_untracked().and_then(|m| m.parse::<ThemeMode>().ok()).unwrap_or_default().next();
self.set_cookie.set(Some(next.to_string()));
}
/// Is the dark theme displayed right now? The server can't know the system setting, there it is false in auto mode.
pub fn is_dark(&self) -> bool {
self.mode().is_dark(self.system_dark.get())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_cycles_through_all_states() {
assert_eq!(ThemeMode::Auto.next(), ThemeMode::Light);
assert_eq!(ThemeMode::Light.next(), ThemeMode::Dark);
assert_eq!(ThemeMode::Dark.next(), ThemeMode::Auto);
}
#[test]
fn mode_string_round_trip() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(mode.to_string().parse::<ThemeMode>(), Ok(mode));
}
}
#[test]
fn unknown_cookie_value_is_rejected() {
assert!("".parse::<ThemeMode>().is_err());
assert!("Dark".parse::<ThemeMode>().is_err());
assert!("blue".parse::<ThemeMode>().is_err());
assert_eq!(ThemeMode::default(), ThemeMode::Auto);
}
#[test]
fn media_query_for_dark_stylesheet() {
assert_eq!(ThemeMode::Auto.media(), "(prefers-color-scheme: dark)");
assert_eq!(ThemeMode::Light.media(), "not all");
assert_eq!(ThemeMode::Dark.media(), "all");
}
#[test]
fn dark_follows_system_only_in_auto_mode() {
assert!(ThemeMode::Auto.is_dark(true));
assert!(!ThemeMode::Auto.is_dark(false));
assert!(!ThemeMode::Light.is_dark(true));
assert!(ThemeMode::Dark.is_dark(false));
}
}
+21 -16
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::app::MenuHelper; use crate::app::MenuHelper;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::{get_user, logout}; use crate::backend::user::{get_user, logout};
@@ -9,14 +9,17 @@ use crate::locales::trl;
pub struct MenuOpener { pub struct MenuOpener {
visible: ReadSignal<bool>, visible: ReadSignal<bool>,
set_visible: WriteSignal<bool>, set_visible: WriteSignal<bool>,
// Context is not available in event handlers, so the helper is looked up when the opener is created
helper: Option<MenuHelper>,
} }
impl MenuOpener { impl MenuOpener {
pub fn new() -> Self { pub fn new() -> Self {
let (visible, set_visible) = create_signal(false); let (visible, set_visible) = signal(false);
MenuOpener { MenuOpener {
visible, visible,
set_visible, set_visible,
helper: use_context::<MenuHelper>(),
} }
} }
@@ -25,22 +28,24 @@ impl MenuOpener {
} }
pub fn toggle(self) { pub fn toggle(self) {
let visible = self.visible.get(); let visible = self.visible.get_untracked();
self.set_visible.update(|v| *v = !visible); self.set_visible.set(!visible);
let helper = use_context::<MenuHelper>().expect("No menu helper"); if let Some(helper) = self.helper {
if !visible { if !visible {
helper.close(); helper.close();
helper.set_opened(self); helper.set_opened(self);
} else { } else {
helper.reset(); helper.reset();
}
} }
} }
pub fn close(&self) { pub fn close(&self) {
self.set_visible.set(false); self.set_visible.set(false);
let helper = use_context::<MenuHelper>().expect("No menu helper"); if let Some(helper) = self.helper {
helper.reset(); helper.reset();
}
} }
} }
@@ -50,7 +55,7 @@ pub fn UserMenu(
editor: DialogOpener, editor: DialogOpener,
pw_dialog: DialogOpener, pw_dialog: DialogOpener,
user_profile: WriteSignal<User>) -> impl IntoView { user_profile: WriteSignal<User>) -> impl IntoView {
let user = create_resource(move || opener.visible(), move |_| get_user()); let user = Resource::new(move || opener.visible(), move |_| get_user());
view! { view! {
<ul <ul
@@ -84,13 +89,13 @@ pub fn UserMenu(
<span class="fw-semibold d-block"> <span class="fw-semibold d-block">
{usr.full_name} {usr.full_name}
</span> </span>
} }.into_any()
} }
Err(_) => { Err(_) => {
view! { view! {
// <small class="text-muted">"Admin"</small> // <small class="text-muted">"Admin"</small>
<span>"Error loading user"</span> <span>"Error loading user"</span>
} }.into_any()
} }
}) })
}} }}
@@ -114,7 +119,7 @@ pub fn UserMenu(
class="dropdown-item" class="dropdown-item"
href="/login" href="/login"
on:click=move |_| { on:click=move |_| {
spawn_local(async move { leptos::task::spawn_local(async move {
let _ = logout().await; let _ = logout().await;
}); });
} }
+4 -4
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::locales::trl; use crate::locales::trl;
use crate::validator::Validator; use crate::validator::Validator;
@@ -20,16 +20,16 @@ pub fn ValidationErr(
<div> <div>
{out_msgs} {out_msgs}
</div> </div>
} }.into_any()
} else { } else {
view! { view! {
<div class="alert alert-danger"> <div class="alert alert-danger">
{trl("Validation error")} {trl("Validation error")}
</div> </div>
} }.into_any()
} }
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
}} }}
} }
+288 -3
View File
@@ -3,6 +3,8 @@ use std::fs::read_to_string;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::net::SocketAddr; use std::net::SocketAddr;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use std::path::{Path, PathBuf};
#[cfg(feature = "ssr")]
use std::str::FromStr; use std::str::FromStr;
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
use serde::Deserialize; use serde::Deserialize;
@@ -11,7 +13,9 @@ use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Network { pub struct Network {
bind_ip: String, bind_ip: String,
port: u16 port: u16,
/// Address under which the application is reached from outside, for links in e-mails
public_url: Option<String>
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -29,6 +33,22 @@ impl Session {
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl Network { impl Network {
pub fn port(&self) -> u16 {
self.port
}
/// The public address without a trailing slash. `Err` with the configured value when it is not
/// an `http(s)://` address.
pub fn public_url(&self) -> Result<Option<String>, String> {
use crate::backend::cancellation::normalize_base_url;
match &self.public_url {
None => Ok(None),
Some(url) if url.trim().is_empty() => Ok(None),
Some(url) => normalize_base_url(url).map(Some).ok_or(url.clone())
}
}
pub fn bind_address(&self) -> SocketAddr { pub fn bind_address(&self) -> SocketAddr {
SocketAddr::from_str(format!("{}:{}", &self.bind_ip, self.port).as_str()).expect("Can't parse bind IP address") SocketAddr::from_str(format!("{}:{}", &self.bind_ip, self.port).as_str()).expect("Can't parse bind IP address")
} }
@@ -41,11 +61,18 @@ pub struct Database {
password: String, password: String,
db_name: String, db_name: String,
host: String, host: String,
port: Option<u16> port: Option<u16>,
max_connections: Option<u32>
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
impl Database { impl Database {
/// Upper limit of open database connections, 10 by default. With many instances on one
/// PostgreSQL server the sum of the limits must fit into its `max_connections`.
pub fn max_connections(&self) -> u32 {
self.max_connections.unwrap_or(10).max(1)
}
pub fn con_string(&self) -> String { pub fn con_string(&self) -> String {
format!("postgres://{}:{}@{}:{}/{}", self.user, self.password, self.host, self.port.unwrap_or(5432), self.db_name) format!("postgres://{}:{}@{}:{}/{}", self.user, self.password, self.host, self.port.unwrap_or(5432), self.db_name)
} }
@@ -120,6 +147,57 @@ impl Logging {
} }
} }
/// Where files created at runtime (uploaded banner) are kept
#[cfg(feature = "ssr")]
#[derive(Deserialize, Default)]
pub struct Storage {
data_dir: Option<String>
}
#[cfg(feature = "ssr")]
impl Storage {
/// Directory served under `/data`. Defaults to `data` in the working directory.
pub fn data_dir(&self) -> PathBuf {
PathBuf::from(self.data_dir.as_deref().unwrap_or("data"))
}
}
/// Environment variable with the password of the first admin account. Takes precedence over the config file.
#[cfg(feature = "ssr")]
pub const ADMIN_PASSWORD_ENV: &str = "REZERVATOR_ADMIN_PASSWORD";
#[cfg(feature = "ssr")]
pub const ADMIN_PASSWORD_MIN_LEN: usize = 8;
/// Setup of the first admin account
#[cfg(feature = "ssr")]
#[derive(Deserialize, Default)]
pub struct Admin {
initial_password: Option<String>
}
#[cfg(feature = "ssr")]
impl Admin {
/// Password for the admin account created on first start: the environment variable, or the
/// `initial_password` config key. There is no default, a known password must never be used.
pub fn initial_password(&self) -> Result<String, String> {
resolve_initial_password(std::env::var(ADMIN_PASSWORD_ENV).ok(), self.initial_password.clone())
}
}
#[cfg(feature = "ssr")]
fn resolve_initial_password(env: Option<String>, config: Option<String>) -> Result<String, String> {
let password = env.filter(|p| !p.is_empty()).or(config.filter(|p| !p.is_empty()))
.ok_or(format!(
"No admin account exists and no initial password is set. Set the {} environment variable or initial_password in the [admin] section of the config.",
ADMIN_PASSWORD_ENV))?;
if password.chars().count() < ADMIN_PASSWORD_MIN_LEN {
return Err(format!("Initial admin password must have at least {} characters.", ADMIN_PASSWORD_MIN_LEN))
}
Ok(password)
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct Configuration { pub struct Configuration {
@@ -127,7 +205,11 @@ pub struct Configuration {
network: Network, network: Network,
database: Database, database: Database,
mailing: Mailing, mailing: Mailing,
logging: Logging logging: Logging,
#[serde(default)]
storage: Storage,
#[serde(default)]
admin: Admin
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -147,6 +229,22 @@ impl Configuration {
pub fn logging(&self) -> &Logging { pub fn logging(&self) -> &Logging {
&self.logging &self.logging
} }
pub fn storage(&self) -> &Storage {
&self.storage
}
pub fn admin(&self) -> &Admin {
&self.admin
}
}
/// The config file holds passwords and the session key, so it must never lie in a directory
/// that is served as static files. Returns true when `config_path` is inside `public_dir`.
#[cfg(feature = "ssr")]
pub fn is_publicly_served(config_path: &Path, public_dir: &Path) -> bool {
match (config_path.canonicalize(), public_dir.canonicalize()) {
(Ok(config), Ok(root)) => config.starts_with(root),
_ => false
}
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
@@ -154,3 +252,190 @@ pub fn load_config(path: &str) -> Configuration {
toml::from_str(read_to_string(path).expect("Can not open config file").as_str()).expect("Can not parse config file") toml::from_str(read_to_string(path).expect("Can not open config file").as_str()).expect("Can not parse config file")
} }
#[cfg(all(test, feature = "ssr"))]
mod tests {
use super::*;
fn sample() -> Configuration {
load_config(concat!(env!("CARGO_MANIFEST_DIR"), "/config_sample.toml"))
}
#[test]
fn sample_config_is_valid() {
let config = sample();
assert_eq!(config.network().bind_address(), SocketAddr::from_str("127.0.0.1:3000").unwrap());
assert_eq!(config.database().con_string(), "postgres://user:password@localhost:5432/rezervator");
assert_eq!(config.logging().severity(), "debug");
assert!(matches!(config.mailing().transport(), MailTransport::File));
assert_eq!(config.mailing().from(), "from@address.cz");
assert_eq!(config.mailing().path(), &Some("Maildir".to_string()));
assert_eq!(config.mailing().server(), &None);
// Commented out in the sample
assert_eq!(config.network().public_url(), Ok(None));
}
fn network(public_url: &str) -> Network {
toml::from_str(&format!("bind_ip = '127.0.0.1'\nport = 3000\npublic_url = '{}'", public_url)).unwrap()
}
#[test]
fn public_url_is_normalized() {
assert_eq!(network("https://booking.example.com/").public_url(), Ok(Some("https://booking.example.com".to_string())));
assert_eq!(network(" ").public_url(), Ok(None));
}
#[test]
fn invalid_public_url_is_reported() {
assert_eq!(network("booking.example.com").public_url(), Err("booking.example.com".to_string()));
assert_eq!(network("https://example.com/?a=b").public_url(), Err("https://example.com/?a=b".to_string()));
}
/// Creates `<temp>/<name>/site/data` and returns the `<temp>/<name>` directory
fn site_dir(name: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("rezervator-test-{}-{}", name, std::process::id()));
std::fs::create_dir_all(base.join("site/data")).unwrap();
base
}
#[test]
fn config_inside_site_root_is_publicly_served() {
let base = site_dir("inside");
let config = base.join("site/data/config.toml");
std::fs::write(&config, "").unwrap();
assert!(is_publicly_served(&config, &base.join("site")));
// Relative segments must not hide it
assert!(is_publicly_served(&base.join("site/data/../data/config.toml"), &base.join("site")));
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn config_outside_site_root_is_not_publicly_served() {
let base = site_dir("outside");
let config = base.join("config.toml");
std::fs::write(&config, "").unwrap();
assert!(!is_publicly_served(&config, &base.join("site")));
// A sibling directory with the same prefix is not inside the site root
std::fs::create_dir_all(base.join("site-config")).unwrap();
std::fs::write(base.join("site-config/config.toml"), "").unwrap();
assert!(!is_publicly_served(&base.join("site-config/config.toml"), &base.join("site")));
std::fs::remove_dir_all(base).unwrap();
}
#[test]
fn initial_password_is_required() {
assert!(resolve_initial_password(None, None).is_err());
assert!(resolve_initial_password(Some("".to_string()), Some("".to_string())).is_err());
// The sample config must not ship a usable password
assert!(resolve_initial_password(None, sample().admin.initial_password).is_err());
}
#[test]
fn initial_password_from_config() {
let admin: Admin = toml::from_str("initial_password = 'correct horse'").unwrap();
assert_eq!(resolve_initial_password(None, admin.initial_password), Ok("correct horse".to_string()));
}
#[test]
fn initial_password_env_takes_precedence() {
assert_eq!(
resolve_initial_password(Some("from-environment".to_string()), Some("from-config".to_string())),
Ok("from-environment".to_string()));
// An empty variable does not hide the config value
assert_eq!(
resolve_initial_password(Some("".to_string()), Some("from-config".to_string())),
Ok("from-config".to_string()));
}
#[test]
fn initial_password_must_not_be_short() {
assert!(resolve_initial_password(Some("admin".to_string()), None).is_err());
assert!(resolve_initial_password(None, Some("1234567".to_string())).is_err());
assert!(resolve_initial_password(None, Some("12345678".to_string())).is_ok());
// Length is counted in characters, not bytes
assert!(resolve_initial_password(None, Some("čšřžýáí".to_string())).is_err());
}
#[test]
fn data_dir_defaults_to_data() {
// The sample config has the storage section commented out
assert_eq!(sample().storage().data_dir(), PathBuf::from("data"));
let storage: Storage = toml::from_str("").unwrap();
assert_eq!(storage.data_dir(), PathBuf::from("data"));
}
#[test]
fn data_dir_can_be_configured() {
let storage: Storage = toml::from_str("data_dir = '/var/lib/rezervator'").unwrap();
assert_eq!(storage.data_dir(), PathBuf::from("/var/lib/rezervator"));
}
#[test]
fn sample_session_key_is_long_enough() {
// actix_web::cookie::Key::from panics on keys shorter than 64 bytes
assert!(sample().session().key().len() >= 64);
}
#[test]
fn database_port_defaults_to_5432() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'db.example.com'").unwrap();
assert_eq!(database.con_string(), "postgres://u:p@db.example.com:5432/db");
}
#[test]
fn database_custom_port() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'db.example.com'\nport = 6543").unwrap();
assert_eq!(database.con_string(), "postgres://u:p@db.example.com:6543/db");
}
#[test]
fn max_connections_defaults_to_ten() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'localhost'").unwrap();
assert_eq!(database.max_connections(), 10);
// The sample config has the key commented out
assert_eq!(sample().database().max_connections(), 10);
}
#[test]
fn max_connections_can_be_configured() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'localhost'\nmax_connections = 3").unwrap();
assert_eq!(database.max_connections(), 3);
}
#[test]
fn max_connections_is_at_least_one() {
let database: Database = toml::from_str(
"user = 'u'\npassword = 'p'\ndb_name = 'db'\nhost = 'localhost'\nmax_connections = 0").unwrap();
assert_eq!(database.max_connections(), 1);
}
#[test]
fn smtp_mailing_config() {
let mailing: Mailing = toml::from_str(
"transport = 'Smtp'\nfrom = 'a@b.cz'\nserver = 'smtp.b.cz'\nport = 587\ntls = true").unwrap();
assert!(matches!(mailing.transport(), MailTransport::Smtp));
assert_eq!(mailing.server(), &Some("smtp.b.cz".to_string()));
assert_eq!(mailing.port(), Some(587));
assert_eq!(mailing.tls(), Some(true));
assert_eq!(mailing.accept_all_certs(), None);
}
}
+48 -2
View File
@@ -1,7 +1,7 @@
use std::error::Error; use std::error::Error;
use std::fmt::{Debug, Display, Formatter}; use std::fmt::{Debug, Display, Formatter};
use chrono::ParseError; use chrono::ParseError;
use leptos::ServerFnError; use leptos::prelude::ServerFnError;
use log::error; use log::error;
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
@@ -101,4 +101,50 @@ impl From<std::io::Error> for AppError {
log_error(&value); log_error(&value);
AppError::FileIOError(value.to_string()) AppError::FileIOError(value.to_string())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveTime;
#[test]
fn error_messages() {
assert_eq!(AppError::HourParseError.to_string(), "Hour parse error");
assert_eq!(AppError::SlotParseError.to_string(), "Book slot parse error");
assert_eq!(AppError::ServerError("boom".to_string()).to_string(), "Server error: boom");
assert_eq!(AppError::FatalError("boom".to_string()).to_string(), "Fatal error: boom");
assert_eq!(AppError::MailAddrParseErr("boom".to_string()).to_string(), "Cannot parse email address: boom");
assert_eq!(AppError::MailSendError("boom".to_string()).to_string(), "Cannot send email: boom");
assert_eq!(AppError::FileIOError("boom".to_string()).to_string(), "IO error: boom");
}
#[test]
fn time_parse_error_becomes_hour_parse_error() {
let err = NaiveTime::parse_from_str("nonsense", "%H:%M").unwrap_err();
assert_eq!(AppError::from(err), AppError::HourParseError);
}
#[test]
fn server_fn_error_keeps_message() {
let err = AppError::from(ServerFnError::new("boom"));
assert!(matches!(err, AppError::ServerError(ref m) if m.contains("boom")));
}
#[cfg(feature = "ssr")]
#[test]
fn io_error_becomes_file_io_error() {
let err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
assert!(matches!(AppError::from(err), AppError::FileIOError(ref m) if m.contains("missing")));
}
#[test]
fn app_error_converts_to_server_fn_error() {
let err: ServerFnError = AppError::HourParseError.into();
assert!(err.to_string().contains("Hour parse error"));
}
}
+9 -17
View File
@@ -1,3 +1,6 @@
// Leptos view types are deeply nested; release builds overflow the default limit
#![recursion_limit = "512"]
pub mod app; pub mod app;
pub mod locales; pub mod locales;
pub mod backend; pub mod backend;
@@ -7,23 +10,12 @@ mod validator;
pub mod error; pub mod error;
pub mod config; pub mod config;
use cfg_if::cfg_if; #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::*;
cfg_if! { console_error_panic_hook::set_once();
if #[cfg(feature = "hydrate")] {
use wasm_bindgen::prelude::wasm_bindgen; leptos::mount::hydrate_body(App);
#[wasm_bindgen]
pub fn hydrate() {
use app::*;
use leptos::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(move || {
view! { <App/> }
});
}
}
} }
+100 -6
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use leptos::*; use leptos::prelude::*;
use crate::locales::Locales; use crate::locales::Locales;
lazy_static! { lazy_static! {
@@ -103,6 +103,10 @@ lazy_static! {
("Date can't be in past", "Datum nesmí být v minulosti"), ("Date can't be in past", "Datum nesmí být v minulosti"),
("Enter your username", "Zadejte své uživatelské jméno"), ("Enter your username", "Zadejte své uživatelské jméno"),
("Hours must be in HH:MM - HH:MM format", "Hodiny musí být zadány ve tvaru HH:MM - HH:MM"), ("Hours must be in HH:MM - HH:MM format", "Hodiny musí být zadány ve tvaru HH:MM - HH:MM"),
("Time 'to' must be after time 'from'", "Čas 'do' musí být po čase 'od'"),
("Theme: by system", "Vzhled: podle systému"),
("Theme: light", "Vzhled: světlý"),
("Theme: dark", "Vzhled: tmavý"),
("Edit profile", "Upravit profil"), ("Edit profile", "Upravit profil"),
("Get emails", "Dostávat e-maily"), ("Get emails", "Dostávat e-maily"),
("Enter username", "Zadejte uživatelské jméno"), ("Enter username", "Zadejte uživatelské jméno"),
@@ -144,6 +148,97 @@ lazy_static! {
("Are you sure you want to delete banner?", "Opravdu chcete smazat banner?"), ("Are you sure you want to delete banner?", "Opravdu chcete smazat banner?"),
("Delete banner", "Smazat banner"), ("Delete banner", "Smazat banner"),
("Appearance", "Vzhled"), ("Appearance", "Vzhled"),
("Colours", "Barvy"),
("Palettes", "Palety"),
("Violet", "Fialová"),
("Green", "Zelená"),
("Blue", "Modrá"),
("Orange", "Oranžová"),
("Pink", "Růžová"),
("Sand", "Písková"),
("Graphite", "Grafitová"),
("Night", "Noční"),
("Primary colour", "Hlavní barva"),
("Page background", "Pozadí stránky"),
("Card background", "Pozadí karet"),
("Text colour", "Barva textu"),
("Save", "Uložit"),
("Reset to default", "Obnovit výchozí"),
("Settings saved", "Nastavení je uloženo"),
("Your booking", "Vaše rezervace"),
("Booked", "Rezervováno"),
("Waiting for approval", "Čeká na potvrzení"),
("Booking not found", "Rezervace nebyla nalezena"),
("You can cancel this booking until", "Rezervaci můžete zrušit do"),
("Do you really want to cancel this booking?", "Opravdu chcete rezervaci zrušit?"),
("Yes, cancel booking", "Ano, zrušit rezervaci"),
("This booking is cancelled.", "Tato rezervace je zrušená."),
("This booking can no longer be cancelled, it was possible until", "Tuto rezervaci už zrušit nelze, bylo to možné do"),
("This booking can no longer be cancelled", "Tuto rezervaci už zrušit nelze"),
("You can view or cancel your booking here:", "Rezervaci si můžete zobrazit nebo zrušit zde:"),
("Canceled by customer", "Zrušená zákazníkem"),
("Booking canceled by customer - for staff", "Rezervace zrušená zákazníkem - pro obsluhu"),
("Customers can cancel a booking until (hours before its start)", "Zákazník může rezervaci zrušit nejpozději (hodin před začátkem)"),
("0 means until the start of the booking.", "0 znamená až do začátku rezervace."),
("Cancellation deadline must be between 0 and 720 hours", "Lhůta pro zrušení musí být 0 až 720 hodin"),
("Invalid company data", "Neplatné údaje organizace"),
("In the texts, #date# is replaced by the date of the booking, #summary# by the booked items and #link# by the link to the page where the customer can view and cancel the booking. E-mails for the customer about a new and an approved booking get the link at the end when their text does not contain #link#.", "V textech se #date# nahradí datem rezervace, #summary# rezervovanými položkami a #link# odkazem na stránku, kde si zákazník může rezervaci zobrazit a zrušit. E-maily pro zákazníka o nové a o potvrzené rezervaci dostanou odkaz na konec, pokud jejich text #link# neobsahuje."),
("Text under the banner", "Text pod bannerem"),
("Bold", "Tučně"),
("Italic", "Kurzíva"),
("Heading", "Nadpis"),
("Paragraph", "Odstavec"),
("Bullet list", "Seznam s odrážkami"),
("Numbered list", "Číslovaný seznam"),
("Link", "Odkaz"),
("Remove link", "Odebrat odkaz"),
("Link address", "Adresa odkazu"),
("Title is too long", "Titulek je příliš dlouhý"),
("Text is too long", "Text je příliš dlouhý"),
("Resets colours, banner options and layout", "Vrátí barvy, nastavení banneru a rozložení"),
("Layout", "Rozložení"),
("Content width", "Šířka obsahu"),
("Full width", "Celá šířka"),
("Centred", "Na střed"),
("Form", "Formulář"),
("Side by side", "Vedle sebe"),
("Stacked", "Pod sebou"),
("Time slots", "Časy"),
("Normal", "Běžné"),
("Large", "Velké"),
("Corners", "Rohy"),
("Rounded", "Zaoblené"),
("Square", "Hranaté"),
("Invalid layout", "Neplatné rozložení"),
("Title", "Titulek"),
("Enter title", "Zadejte titulek"),
("Title and text", "Titulek a text"),
("Banner", "Banner"),
("Banner image", "Obrázek banneru"),
("Logo", "Logo"),
("Height", "Výška"),
("Low", "Nízký"),
("Medium", "Střední"),
("High", "Vysoký"),
("Darkening", "Ztmavení"),
("Title colour", "Barva titulku"),
("Title alignment", "Zarovnání titulku"),
("Left", "Vlevo"),
("Centre", "Na střed"),
("Click into the banner in the preview to choose the part of the image that stays visible.", "Kliknutím do banneru v náhledu určíte část obrázku, která zůstane vidět."),
("Unsupported image type, use JPG, PNG, GIF or WebP", "Nepodporovaný typ obrázku. Použijte JPG, PNG, GIF nebo WebP."),
("Image is too large, 5 MB at most", "Obrázek je příliš velký, nejvýše 5 MB."),
("No file was sent", "Nebyl odeslán žádný soubor"),
("Invalid upload", "Neplatné nahrání souboru"),
("Invalid banner settings", "Neplatné nastavení banneru"),
("Delete logo", "Smazat logo"),
("Are you sure you want to delete logo?", "Opravdu chcete smazat logo?"),
("Unsaved changes", "Neuložené změny"),
("Invalid colour", "Neplatná barva"),
("Text is hard to read on the card background", "Text je na pozadí karet špatně čitelný"),
("Text is hard to read on the page background", "Text je na pozadí stránky špatně čitelný"),
("Primary colour is hard to see on the card background", "Hlavní barva je na pozadí karet špatně vidět"),
("Preview of the public page", "Náhled veřejné stránky"),
("Month bookings", "Rezrvace po měsících"), ("Month bookings", "Rezrvace po měsících"),
("Year bookings", "Rezervace po letech"), ("Year bookings", "Rezervace po letech"),
("January", "Leden"), ("January", "Leden"),
@@ -181,16 +276,15 @@ lazy_static! {
}; };
} }
fn get_locales() -> Vec<Option<String>> { pub fn get_locales() -> Vec<Option<String>> {
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
{ {
use_context::<Locales>().unwrap_or(Locales(vec![])).0 // Context is not available in event handlers, ask the browser there
use_context::<Locales>().unwrap_or_else(crate::locales::browser_locales).0
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
{ {
use actix_web::HttpRequest; let req = use_context::<leptos_actix::Request>();
let req = use_context::<HttpRequest>();
if let Some(r) = req { if let Some(r) = req {
if let Some(al) = r.head().headers.get("accept-language") { if let Some(al) = r.head().headers.get("accept-language") {
Locales(al.to_str().unwrap_or("").split(",").map(|l| Some(l.to_string())).collect()).0 Locales(al.to_str().unwrap_or("").split(",").map(|l| Some(l.to_string())).collect()).0
+18 -18
View File
@@ -1,6 +1,6 @@
use chrono::{NaiveDate, Weekday}; use chrono::{NaiveDate, Weekday};
use leptos::use_context; use leptos::prelude::*;
use crate::locales::catalogues::get_dictionary; use crate::locales::catalogues::{get_dictionary, get_locales};
mod catalogues; mod catalogues;
@@ -10,24 +10,12 @@ pub struct Locales(pub Vec<Option<String>>);
pub fn init_locales() { pub fn init_locales() {
#[cfg(not(feature = "ssr"))] #[cfg(not(feature = "ssr"))]
{ {
use leptos::*; provide_context(browser_locales());
let loc = Locales(
window()
.navigator()
.languages()
.into_iter()
.map(|val| val.as_string())
.collect::<Vec<_>>(),
);
provide_context(loc);
} }
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
{ {
use actix_web::HttpRequest; let req = use_context::<leptos_actix::Request>();
use leptos::provide_context;
let req = use_context::<HttpRequest>();
if let Some(r) = req { if let Some(r) = req {
if r.query_string().contains("lang") { if r.query_string().contains("lang") {
let languages = r.query_string() let languages = r.query_string()
@@ -49,6 +37,19 @@ pub fn init_locales() {
} }
} }
/// Languages preferred by the browser
#[cfg(not(feature = "ssr"))]
pub fn browser_locales() -> Locales {
Locales(
window()
.navigator()
.languages()
.into_iter()
.map(|val| val.as_string())
.collect::<Vec<_>>(),
)
}
pub fn trl(phrase: &str) -> impl Fn() -> String { pub fn trl(phrase: &str) -> impl Fn() -> String {
let mut translated = phrase; let mut translated = phrase;
if let Some(dict) = get_dictionary() { if let Some(dict) = get_dictionary() {
@@ -65,8 +66,7 @@ pub fn trl(phrase: &str) -> impl Fn() -> String {
// ToDo better date formatting // ToDo better date formatting
pub fn loc_date(date: NaiveDate) -> impl Fn() -> String { pub fn loc_date(date: NaiveDate) -> impl Fn() -> String {
let mut dt = date.format("%Y-%m-%d").to_string(); let mut dt = date.format("%Y-%m-%d").to_string();
let locs = use_context::<Locales>().unwrap_or(Locales(vec![])).0; for loc in get_locales() {
for loc in locs {
if let Some(key) = loc { if let Some(key) = loc {
if let Some(k) = key.split("-").collect::<Vec<_>>().get(0) { if let Some(k) = key.split("-").collect::<Vec<_>>().get(0) {
if *k != "en" { if *k != "en" {
+99 -30
View File
@@ -1,7 +1,10 @@
// Leptos view types are deeply nested; release builds overflow the default limit
#![recursion_limit = "512"]
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use leptos_captcha::spow::pow::Pow; use leptos_captcha::spow::pow::Pow;
use log::error; use log::{error, warn};
use rezervator::backend::appearance::check_appearance; use rezervator::backend::appearance::{check_appearance, ensure_data_dir, is_broken_upload};
use rezervator::backend::company::check_company; use rezervator::backend::company::check_company;
use rezervator::backend::mail::{check_messages, Mailer}; use rezervator::backend::mail::{check_messages, Mailer};
use rezervator::backend::user::create_admin; use rezervator::backend::user::create_admin;
@@ -11,7 +14,9 @@ use rezervator::backend::user::create_admin;
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
use actix_files::Files; use actix_files::Files;
use actix_web::*; use actix_web::*;
use leptos::*; use leptos::prelude::*;
use leptos::config::get_configuration;
use leptos_meta::MetaTags;
use leptos_actix::{generate_route_list, LeptosRoutes}; use leptos_actix::{generate_route_list, LeptosRoutes};
use rezervator::app::*; use rezervator::app::*;
use rezervator::backend::*; use rezervator::backend::*;
@@ -22,7 +27,9 @@ async fn main() -> std::io::Result<()> {
use sqlx::migrate; use sqlx::migrate;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use rezervator::backend::auth_middleware::Authentication; use rezervator::backend::auth_middleware::Authentication;
use rezervator::config::load_config; use rezervator::config::{is_publicly_served, load_config};
use rezervator::backend::health::{health, is_healthy};
use std::path::Path;
use env_logger::Env; use env_logger::Env;
use std::env::args; use std::env::args;
use getopts::Options; use getopts::Options;
@@ -32,6 +39,7 @@ async fn main() -> std::io::Result<()> {
let mut opts = Options::new(); let mut opts = Options::new();
opts.optopt("c", "config", "Path to config file. Default is ./config.toml", ""); opts.optopt("c", "config", "Path to config file. Default is ./config.toml", "");
opts.optflag("h", "help", "Show help"); opts.optflag("h", "help", "Show help");
opts.optflag("", "healthcheck", "Check health of the running server and exit with 0 (healthy) or 1");
let matches = opts.parse(&args[1..]).expect("Can't parse commandline"); let matches = opts.parse(&args[1..]).expect("Can't parse commandline");
if matches.opt_present("h") { if matches.opt_present("h") {
@@ -40,29 +48,53 @@ async fn main() -> std::io::Result<()> {
return Ok(()); return Ok(());
} }
Pow::init_random().expect("Cannot init captcha");
let cfg_path = matches.opt_str("c").unwrap_or("config.toml".to_string()); let cfg_path = matches.opt_str("c").unwrap_or("config.toml".to_string());
let srv_conf = load_config(cfg_path.trim()); let srv_conf = load_config(cfg_path.trim());
// Used as the container health check, see Dockerfile
if matches.opt_present("healthcheck") {
std::process::exit(if is_healthy(srv_conf.network().port()) { 0 } else { 1 });
}
Pow::init_random().expect("Cannot init captcha");
env_logger::Builder::from_env(Env::default().default_filter_or(srv_conf.logging().severity())).init(); env_logger::Builder::from_env(Env::default().default_filter_or(srv_conf.logging().severity())).init();
info!("Starting server"); info!("Starting server");
let conf = get_configuration(None).await.unwrap(); let conf = get_configuration(None).unwrap();
// Generate the list of routes in your Leptos App
let routes = generate_route_list(|| view! { <App/> }); // Files created at runtime (uploaded banner) live outside the site root, so they survive upgrades
let data_dir = srv_conf.storage().data_dir();
if let Err(e) = ensure_data_dir(&data_dir) {
error!("Cannot prepare data directory {}: {}", data_dir.display(), e);
return Err(std::io::Error::other(e));
}
// The site root and the data directory are served as static files, the config with passwords must not be there
for public_dir in [Path::new(&*conf.leptos_options.site_root), data_dir.as_path()] {
if is_publicly_served(Path::new(cfg_path.trim()), public_dir) {
let msg = format!(
"Config file {} is inside the publicly served directory {}. Move it elsewhere and change the passwords and the session key in it.",
cfg_path.trim(), public_dir.display());
error!("{}", msg);
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg));
}
}
let key = Key::from(srv_conf.session().key()); let key = Key::from(srv_conf.session().key());
let pool = PgPoolOptions::new() let pool = PgPoolOptions::new()
.max_connections(10) .max_connections(srv_conf.database().max_connections())
.connect(&srv_conf.database().con_string()).await.unwrap(); .connect(&srv_conf.database().con_string()).await.unwrap();
migrate!().run(&pool).await.expect("could not run SQLx migrations"); migrate!().run(&pool).await.expect("could not run SQLx migrations");
let mut mailer = Mailer::new(srv_conf.mailing()); let mut mailer = Mailer::new(srv_conf.mailing());
mailer.start_sender(); mailer.start_sender();
if let Err(e) = create_admin(&pool).await { // Without an admin account nobody can log in, and it must not get a known password
error!("Error while checking admin user: {:?}", e); if let Err(e) = create_admin(&pool, srv_conf.admin()).await {
error!("Cannot set up admin account: {}", e);
return Err(std::io::Error::other(e));
} }
if let Err(e) = check_company(&pool).await { if let Err(e) = check_company(&pool).await {
error!("Error while checking company: {:?}", e); error!("Error while checking company: {:?}", e);
@@ -74,25 +106,68 @@ async fn main() -> std::io::Result<()> {
error!("Error while checking messages: {:?}", e); error!("Error while checking messages: {:?}", e);
} }
let public_url = match srv_conf.network().public_url() {
Ok(url) => url,
Err(invalid) => {
error!("public_url in the [network] section of the config is not an http(s) address: {}", invalid);
std::process::exit(1);
}
};
if public_url.is_none() {
warn!("public_url is not set in the [network] section of the config. Links in e-mails use the address \
of the request instead; set it, unless a reverse proxy passes only requests for your domain.");
}
HttpServer::new(move || { HttpServer::new(move || {
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let leptos_options = &conf.leptos_options; let leptos_options = &conf.leptos_options;
let site_root = &leptos_options.site_root; let site_root = leptos_options.site_root.clone().to_string();
App::new() App::new()
.app_data(Data::new(AppData::new(pool.clone(), mailer.clone()))) .app_data(Data::new(AppData::new(pool.clone(), mailer.clone(), data_dir.clone(), public_url.clone())))
.wrap(Logger::default()) // The health check is called every few seconds, keep it out of the access log
.wrap_fn(|req, srv| {
use actix_web::dev::Service;
use futures_util::future::{ready, Either};
// Without this the multipart codec of server_fn would panic on such a request
let content_type = req.headers().get(http::header::CONTENT_TYPE).and_then(|value| value.to_str().ok());
if is_broken_upload(req.path(), content_type) {
return Either::Left(ready(Err(error::ErrorBadRequest("Invalid upload"))))
}
Either::Right(srv.call(req))
})
.wrap(Logger::default().exclude("/health"))
.wrap(Authentication) .wrap(Authentication)
.wrap(SessionMiddleware::new( .wrap(SessionMiddleware::new(
CookieSessionStore::default(), CookieSessionStore::default(),
key.clone() key.clone()
)) ))
.route("/api/{tail:.*}", leptos_actix::handle_server_fns()) // Server functions under /api are registered by leptos_routes
.leptos_routes( .leptos_routes(routes, {
leptos_options.to_owned(), let leptos_options = leptos_options.clone();
routes.to_owned(), move || {
|| view! { <App/> }, view! {
) <!DOCTYPE html>
.service(appearance::upload) <html>
<head>
<AutoReload options=leptos_options.clone()/>
<HydrationScripts options=leptos_options.clone()/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
})
.app_data(Data::new(leptos_options.to_owned()))
.service(health)
// Runtime files first, the site root would shadow them
.service(Files::new("/data", data_dir.clone()))
.service(Files::new("/", site_root)) .service(Files::new("/", site_root))
.wrap(middleware::Compress::default()) .wrap(middleware::Compress::default())
}) })
@@ -114,15 +189,9 @@ pub fn main() {
// a client-side main function is required for using `trunk serve` // a client-side main function is required for using `trunk serve`
// prefer using `cargo leptos serve` instead // prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features ssg` // to run: `trunk serve --open --features ssg`
use leptos::*; use rezervator::app::*;
use leptos_start::app::*;
use wasm_bindgen::prelude::wasm_bindgen;
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
leptos::mount_to_body(move || { leptos::mount::mount_to_body(App);
// note: for testing it may be preferrable to replace this with a
// more specific component, although leptos_router should still work
view! {<App/> }
});
} }
+44 -38
View File
@@ -1,17 +1,28 @@
use chrono::{Datelike, Local}; use chrono::{Datelike, Local};
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, ReservationState, ResSumWithItems}; use crate::backend::data::{ApiResponse, ReservationState, ReservationSum, ResSumWithItems};
use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years}; use crate::backend::reservation::{month_chart, reservations_in_month, year_chart, years};
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog};
use crate::components::theme::Theme;
use crate::components::user_menu::MenuOpener; use crate::components::user_menu::MenuOpener;
use crate::locales::{loc_date, show_day, trl}; use crate::locales::{loc_date, show_day, trl};
use crate::pages::new_reservations::CancelDialog; use crate::pages::new_reservations::CancelDialog;
/// Translation key of the state of a booking
fn state_key(summary: &ReservationSum) -> &'static str {
match summary.state {
ReservationState::New => "New",
ReservationState::Approved => "Approved",
ReservationState::Canceled if summary.canceled_by_customer => "Canceled by customer",
ReservationState::Canceled => "Canceled",
}
}
#[component] #[component]
fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView { fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let menu = MenuOpener::new(); let menu = MenuOpener::new();
let cancel_opener = DialogOpener::new(); let cancel_opener = DialogOpener::new();
let menu_visible = create_rw_signal(false); let menu_visible = RwSignal::new(false);
view! { view! {
<CancelDialog opener=cancel_opener reservation=reservation/> <CancelDialog opener=cancel_opener reservation=reservation/>
<ModalDialog title="Booking detail" opener=opener> <ModalDialog title="Booking detail" opener=opener>
@@ -27,7 +38,7 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
let:item> let:item>
{item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/> {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
</For> </For>
{trl("Customer: ")}{detail.customer.full_name}", "<a href={format!("mailto:{}", detail.customer.email)}>{detail.customer.email}</a>", "{detail.customer.phone}<br/> {trl("Customer: ")}{detail.customer.full_name}", "<a href={format!("mailto:{}", detail.customer.email)}>{detail.customer.email.clone()}</a>", "{detail.customer.phone}<br/>
{ {
let note = detail.summary.note.clone(); let note = detail.summary.note.clone();
let show = note.is_some() && !note.clone().unwrap().is_empty(); let show = note.is_some() && !note.clone().unwrap().is_empty();
@@ -42,7 +53,7 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
}} }}
</p> </p>
<Show when=move || menu_visible.get()> <Show when=move || menu_visible.get()>
<table width="100%"> <table style="width: 100%"><tbody>
<tr> <tr>
<td width="100%"></td> <td width="100%"></td>
<td> <td>
@@ -62,7 +73,7 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
</div> </div>
</td> </td>
</tr> </tr>
</table> </tbody></table>
</Show> </Show>
</ModalBody> </ModalBody>
</ModalDialog> </ModalDialog>
@@ -71,20 +82,21 @@ fn booking_detail(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
#[component] #[component]
pub fn bookings() -> impl IntoView { pub fn bookings() -> impl IntoView {
let year_chart = create_blocking_resource(||(), |_| year_chart()); let theme = use_context::<Theme>().expect("No theme");
let years = create_blocking_resource(||(), |_| years()); let year_chart = Resource::new_blocking(move || theme.is_dark(), |dark| year_chart(dark));
let year = create_rw_signal(Local::now().year()); let years = Resource::new_blocking(||(), |_| years());
let month = create_rw_signal(Local::now().month()); let year = RwSignal::new(Local::now().year());
let chart = create_blocking_resource(move || year.get(),move |y| month_chart(y)); let month = RwSignal::new(Local::now().month());
let chart = Resource::new_blocking(move || (year.get(), theme.is_dark()), move |(y, dark)| month_chart(y, dark));
let detail_dlg = DialogOpener::new(); let detail_dlg = DialogOpener::new();
let reservations = create_blocking_resource(move || (year.get(), month.get(), detail_dlg.visible()), move |p| reservations_in_month(p.0, p.1)); let reservations = Resource::new_blocking(move || (year.get(), month.get(), detail_dlg.visible()), move |p| reservations_in_month(p.0, p.1));
let all_months: Vec<u32> = vec![1,2,3,4,5,6,7,8,9,10,11,12]; let all_months: Vec<u32> = vec![1,2,3,4,5,6,7,8,9,10,11,12];
let res_detail = create_rw_signal(ResSumWithItems::default()); let res_detail = RwSignal::new(ResSumWithItems::default());
view! { view! {
<h1>{trl("Booking overview")}</h1> <h1>{trl("Booking overview")}</h1>
<BookingDetail reservation=res_detail.read_only() opener=detail_dlg /> <BookingDetail reservation=res_detail.read_only() opener=detail_dlg />
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-5"> <nav class="navbar navbar-expand-lg navbar-light bg-light filter-bar mb-5">
<div class="container-fluid"> <div class="container-fluid">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
@@ -94,7 +106,7 @@ pub fn bookings() -> impl IntoView {
</li> </li>
<li class="nav-item"> <li class="nav-item">
<Transition fallback=move || view! {<div>"Loading"</div>} > <Transition fallback=move || view! {<div>"Loading"</div>} >
{ {move || {
years.get().map(|y| match y { years.get().map(|y| match y {
Ok(y) => { Ok(y) => {
view! { view! {
@@ -106,11 +118,11 @@ pub fn bookings() -> impl IntoView {
<option prop:value=move || y prop:selected=move || year.get() == y>{y}</option> <option prop:value=move || y prop:selected=move || year.get() == y>{y}</option>
</For> </For>
</select> </select>
} }.into_any()
} }
Err(_) => {view! {<select><option></option></select>}} Err(_) => {view! {<select><option></option></select>}.into_any()}
}) })
} }}
</Transition> </Transition>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@@ -135,30 +147,24 @@ pub fn bookings() -> impl IntoView {
<div class="col-md"> <div class="col-md">
<div class="card md-3"> <div class="card md-3">
<Transition fallback=move || view! {<div>"Loading"</div>}> <Transition fallback=move || view! {<div>"Loading"</div>}>
{ {move || {
chart.get().map(move |c| match c { chart.get().map(move |c| match c {
Ok(c) => { match c { Ok(ApiResponse::Data(c)) => {view! {<img style="margin: 1em" src={c.clone()} /> }.into_any()}
ApiResponse::Data(c) => {view! {<img style="margin: 1em" src={c.clone()} /> }} _ => { view! {<img src=""/> }.into_any() }
ApiResponse::Error(_) => {view! {<img src=""/> }} }
}
Err(_) => { view! {<img src=""/> } }
}) })
} }}
</Transition> </Transition>
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<div class="card md-3"> <div class="card md-3">
<Transition fallback=move || view! {<div>"Loading"</div>}> <Transition fallback=move || view! {<div>"Loading"</div>}>
{ {move || {
year_chart.get().map(move |c| match c { year_chart.get().map(move |c| match c {
Ok(c) => { match c { Ok(ApiResponse::Data(c)) => {view! {<img style="margin: 1em" src={c.clone()} /> }.into_any()}
ApiResponse::Data(c) => {view! {<img style="margin: 1em" src={c.clone()} /> }} _ => { view! {<img src=""/> }.into_any() }
ApiResponse::Error(_) => {view! {<img src=""/> }} }
}
Err(_) => { view! {<img src=""/> } }
}) })
} }}
</Transition> </Transition>
</div> </div>
</div> </div>
@@ -178,7 +184,7 @@ pub fn bookings() -> impl IntoView {
<th>{trl("Actions")}</th> <th>{trl("Actions")}</th>
</tr> </tr>
</thead> </thead>
{ {move || {
reservations.get().map(|r| match r { reservations.get().map(|r| match r {
Ok(r) => { match r { Ok(r) => { match r {
ApiResponse::Data(r) => { ApiResponse::Data(r) => {
@@ -191,7 +197,7 @@ pub fn bookings() -> impl IntoView {
<td>{loc_date(data.summary.date)}</td> <td>{loc_date(data.summary.date)}</td>
<td>{data.customer.full_name.clone()}</td> <td>{data.customer.full_name.clone()}</td>
<td>{data.summary.price.to_string()}</td> <td>{data.summary.price.to_string()}</td>
<td>{trl(&data.summary.state.to_string())}</td> <td>{trl(state_key(&data.summary))}</td>
<td><button type="button" class="btn" <td><button type="button" class="btn"
on:click=move |_| { on:click=move |_| {
res_detail.set(data.clone()); res_detail.set(data.clone());
@@ -202,20 +208,20 @@ pub fn bookings() -> impl IntoView {
</tr> </tr>
</For> </For>
</tbody> </tbody>
} }.into_any()
} }
ApiResponse::Error(e) => { ApiResponse::Error(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
} }
} }
Err(e) => { Err(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=5>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
}) })
} }}
</table> </table>
</Transition> </Transition>
</div> </div>
+642 -93
View File
@@ -1,59 +1,117 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::appearance::{DeleteBanner, get_appearance, UpdateAppearance}; use leptos::ev::{ClipboardEvent, MouseEvent};
use crate::backend::data::Appearance; use leptos::wasm_bindgen::JsCast;
use crate::components::data_form::{DataForm, QuestionDialog}; use web_sys::{Element, File, FormData, HtmlDocument, HtmlInputElement, Url};
use crate::backend::appearance::{DeleteBanner, DeleteLogo, get_appearance, SaveAppearance, upload_image};
use crate::backend::banner::{image_extension, BannerOptions, ImageKind, IMAGE_ACCEPT, MAX_DIM, MAX_IMAGE_SIZE};
use crate::backend::color::{Colors, PALETTES};
use crate::backend::data::{banner_style, ApiResponse, Appearance};
use crate::backend::layout::LayoutOptions;
use crate::backend::rich_text::{normalize_link, MAX_TITLE_LENGTH};
use crate::components::data_form::QuestionDialog;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use crate::locales::trl; use crate::locales::trl;
/// The public page as it is being edited. Nothing is stored until it is saved.
#[derive(Clone, Copy)]
struct Look {
title: RwSignal<String>,
/// HTML of the text under the banner, as it is in the editor
text: RwSignal<String>,
colors: RwSignal<Colors>,
banner: RwSignal<BannerOptions>,
layout: RwSignal<LayoutOptions>,
/// Images selected in the file inputs and not uploaded yet, as object URLs for the preview
new_banner: RwSignal<Option<String>>,
new_logo: RwSignal<Option<String>>,
}
/// Button of the editor toolbar. It must not take the focus, the selection in the text would be lost.
#[component] #[component]
fn edit_title(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView { fn tool_button(icon: &'static str, title: &'static str, #[prop(into)] on_press: Callback<()>) -> impl IntoView {
let update = create_server_action::<UpdateAppearance>();
view! { view! {
<DataForm opener=opener action=update title="Update title"> <button
<input type="hidden" prop:value={move || appearance.get().text.unwrap_or_default()} name="appearance[text]" /> type="button"
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/> class="btn btn-sm btn-outline-secondary"
<div class="row"> title=trl(title)
<div class="col mb-3"> aria-label=trl(title)
<label for="title" class="form-label">{trl("Title")}</label> on:mousedown=move |ev| ev.prevent_default()
<input on:click=move |_| on_press.run(())>
type="text" <i class=format!("bx {}", icon)></i>
id="title" </button>
class="form-control"
placeholder={trl("Enter title")}
prop:value={move || appearance.get().title.unwrap_or_default()}
name="appearance[title]"
/>
</div>
</div>
</DataForm>
} }
} }
/// Simple editor of the text under the banner: bold, italic, heading, lists and links.
/// The browser does the editing (`contenteditable` and `execCommand`); whatever comes out of it,
/// the server reduces to the allowed tags when the text is saved (`sanitize_text`).
/// The content is put into the element by its owner, through the node reference.
#[component] #[component]
fn edit_text(opener: DialogOpener, appearance: ReadSignal<Appearance>) -> impl IntoView { fn text_editor(text: RwSignal<String>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
let update = create_server_action::<UpdateAppearance>(); // Translations are not available in event handlers
let link_prompt = trl("Link address")();
let sync = move || {
if let Some(element) = editor.get_untracked() {
text.set(element.inner_html());
}
};
let exec = move |command: &str, value: &str| {
if let Ok(document) = document().dyn_into::<HtmlDocument>() {
let _ = document.exec_command_with_show_ui_and_value(command, false, value);
}
sync();
};
let add_link = move || {
if let Ok(Some(input)) = window().prompt_with_message_and_default(&link_prompt, "https://") {
if let Some(link) = normalize_link(&input) {
exec("createLink", &link);
}
}
};
view! { view! {
<DataForm opener=opener action=update title="Update text"> <div class="btn-toolbar mb-2" role="toolbar">
<input type="hidden" prop:value={move || appearance.get().title.unwrap_or_default()} name="appearance[title]" /> <div class="btn-group me-2" role="group">
<input type="hidden" prop:value={move || appearance.get().id()} name="appearance[id]"/> <ToolButton icon="bx-bold" title="Bold" on_press=move |_| exec("bold", "")/>
<div class="row"> <ToolButton icon="bx-italic" title="Italic" on_press=move |_| exec("italic", "")/>
<div class="col mb-3">
<label for="text" class="form-label">{trl("Text")}</label>
<textarea
id="title"
class="form-control"
prop:value={move || appearance.get().text.unwrap_or_default()}
name="appearance[text]"
/>
</div>
</div> </div>
</DataForm> <div class="btn-group me-2" role="group">
<ToolButton icon="bx-heading" title="Heading" on_press=move |_| exec("formatBlock", "h3")/>
<ToolButton icon="bx-paragraph" title="Paragraph" on_press=move |_| exec("formatBlock", "p")/>
</div>
<div class="btn-group me-2" role="group">
<ToolButton icon="bx-list-ul" title="Bullet list" on_press=move |_| exec("insertUnorderedList", "")/>
<ToolButton icon="bx-list-ol" title="Numbered list" on_press=move |_| exec("insertOrderedList", "")/>
</div>
<div class="btn-group" role="group">
<ToolButton icon="bx-link" title="Link" on_press=move |_| add_link()/>
<ToolButton icon="bx-unlink" title="Remove link" on_press=move |_| exec("unlink", "")/>
</div>
</div>
<div
id="text_editor"
class="form-control text-editor"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-labelledby="text_editor_label"
node_ref=editor
on:input=move |_| sync()
// Only the text of what is pasted, not the formatting it had elsewhere
on:paste=move |ev: ClipboardEvent| {
if let Some(pasted) = ev.clipboard_data().and_then(|data| data.get_data("text/plain").ok()) {
ev.prevent_default();
exec("insertText", &pasted);
}
}
></div>
} }
} }
#[component] #[component]
fn delete_banner(opener: DialogOpener) -> impl IntoView { fn delete_banner(opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteBanner>(); let delete = ServerAction::<DeleteBanner>::new();
view! { view! {
<QuestionDialog opener=opener action=delete title="Delete banner"> <QuestionDialog opener=opener action=delete title="Delete banner">
<div>{trl("Are you sure you want to delete banner?")}</div> <div>{trl("Are you sure you want to delete banner?")}</div>
@@ -62,66 +120,557 @@ fn delete_banner(opener: DialogOpener) -> impl IntoView {
} }
#[component] #[component]
pub fn appearance() -> impl IntoView { fn delete_logo(opener: DialogOpener) -> impl IntoView {
let title_edit = DialogOpener::new(); let delete = ServerAction::<DeleteLogo>::new();
let text_edit = DialogOpener::new();
let del_dialog = DialogOpener::new();
let appearance = create_resource(move || title_edit.visible() || text_edit.visible() || del_dialog.visible(), |_| get_appearance());
let app_edit = create_rw_signal(Appearance::default());
view! { view! {
<EditTitle opener=title_edit appearance=app_edit.read_only()/> <QuestionDialog opener=opener action=delete title="Delete logo">
<EditText opener=text_edit appearance=app_edit.read_only()/> <div>{trl("Are you sure you want to delete logo?")}</div>
<DeleteBanner opener=del_dialog/> </QuestionDialog>
<h1>{trl("Appearance settings")}</h1> }
<div class="row mb-3"> }
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Top banner")}</h5>
<form method="post" action="/upload" enctype="multipart/form-data">
<label for="banner_file">{trl("Banner file")}</label>
<input id="banner_file" type="file" class="form-control" name="file_to_upload"/>
<button type="submit" class="btn btn-primary">
{trl("Upload")}
</button>
</form><br/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{
appearance.get().map(|a| match a {
Ok(a) => {
app_edit.set(a.clone());
let app = a.clone();
view! {
<div>
<Show when=move || a.clone().banner.is_some()>
<div class="header_banner">
<h1 class="header_banner">{app.title.clone().unwrap_or_default()}</h1>
</div>
</Show>
<div>
<button class="btn btn-info" on:click=move |_| title_edit.show()>
<i class="bx bx-edit-alt me-1"></i> {trl("Edit title")}</button>
<button class="btn btn-danger" on:click=move |_| del_dialog.show()>
{trl("Delete")}
</button>
<br/><br/>
<div inner_html={app.text.unwrap_or("<< TEXT >>".to_string())}>
</div> #[component]
<a class="dropdown-item" href="javascript:void(0);" on:click=move |_| text_edit.show()> fn color_input(
<i class="bx bx-edit-alt me-1"></i> {trl("Edit text")}</a> id: &'static str,
</div> label: &'static str,
</div> #[prop(into)] value: Signal<String>,
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
view! {
<div class="d-flex align-items-center justify-content-between mb-2">
<label for=id class="form-label mb-0">{trl(label)}</label>
<input
type="color"
id=id
class="form-control form-control-color"
prop:value=move || value.get()
on:input=move |ev| on_change.run(event_target_value(&ev))
/>
</div>
}
}
/// One of a few values, as a row of buttons. Options are pairs of a value and its label.
#[component]
fn choice(
label: &'static str,
options: Vec<(&'static str, &'static str)>,
#[prop(into)] value: Signal<String>,
#[prop(into)] on_change: Callback<String>) -> impl IntoView {
view! {
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="form-label mb-0">{trl(label)}</div>
<div class="btn-group" role="group" aria-label=trl(label)>
{options.into_iter().map(|(option, option_label)| view! {
<button
type="button"
class=move || if value.get() == option { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline-secondary" }
on:click=move |_| on_change.run(option.to_string())>
{trl(option_label)}
</button>
}).collect_view()}
</div>
</div>
}
}
/// Object URLs are made by the browser; still, only a plain one is let into a style
fn is_object_url(url: &str) -> bool {
url.starts_with("blob:")
&& url.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_' | '[' | ']'))
}
/// Selecting, uploading and deleting of the banner image or the logo. A selected image is shown
/// in the preview first and is uploaded on request.
#[component]
fn image_upload(
kind: ImageKind,
label: &'static str,
/// The stored image exists
#[prop(into)] exists: Signal<bool>,
/// Object URL of the selected image, for the preview
selected: RwSignal<Option<String>>,
/// Counter of finished uploads, the page reloads its data when it changes
uploads: RwSignal<u32>,
delete: DialogOpener) -> impl IntoView {
let file = RwSignal::new_local(None::<File>);
// A translation key; translations are not available in event handlers
let error = RwSignal::new(None::<String>);
let input_ref = NodeRef::<leptos::html::Input>::new();
let upload = Action::new_local(|data: &FormData| upload_image(data.clone().into()));
let id = format!("{}_file", kind.name());
let clear = move || {
if let Some(url) = selected.get_untracked() {
let _ = Url::revoke_object_url(&url);
}
selected.set(None);
file.set(None);
if let Some(input) = input_ref.get_untracked() {
input.set_value("");
}
};
Effect::new(move |_| {
match upload.value().get() {
Some(Ok(ApiResponse::Data(_))) => {
clear();
uploads.update(|count| *count += 1);
}
Some(Ok(ApiResponse::Error(e))) => error.set(Some(e)),
Some(Err(e)) => error.set(Some(format!("Server error: {}", e))),
None => {}
}
});
view! {
<div class="mb-3">
<label for=id.clone() class="form-label">{trl(label)}</label>
<input
type="file"
id=id
class="form-control"
accept=IMAGE_ACCEPT
node_ref=input_ref
on:change=move |ev| {
let input: HtmlInputElement = event_target(&ev);
let chosen = input.files().and_then(|files| files.get(0));
// Forget the previous selection, but keep the file in the input
if let Some(url) = selected.get_untracked() {
let _ = Url::revoke_object_url(&url);
}
selected.set(None);
file.set(None);
error.set(None);
// The server checks the same again
if let Some(chosen) = chosen {
if image_extension(&chosen.name()).is_none() {
error.set(Some("Unsupported image type, use JPG, PNG, GIF or WebP".to_string()));
} else if chosen.size() > MAX_IMAGE_SIZE as f64 {
error.set(Some("Image is too large, 5 MB at most".to_string()));
} else {
selected.set(Url::create_object_url_with_blob(&chosen).ok().filter(|url| is_object_url(url)));
file.set(Some(chosen));
} }
} }
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}}
})
} }
</Transition> />
{move || error.get().map(|e| view! {
<div class="text-danger mt-1">{trl(&e)}</div>
})}
<div class="mt-2">
{move || if file.get().is_some() {
view! {
<button type="button" class="btn btn-sm btn-primary me-2" disabled=move || upload.pending().get()
on:click=move |_| {
if let (Some(chosen), Ok(data)) = (file.get_untracked(), FormData::new()) {
// The kind has to be sent before the file
let _ = data.append_with_str("kind", kind.name());
let _ = data.append_with_blob_and_filename("file", &chosen, &chosen.name());
error.set(None);
upload.dispatch_local(data);
}
}>
{trl("Upload")}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| clear()>
{trl("Cancel")}
</button>
}.into_any()
} else if exists.get() {
view! {
<button type="button" class="btn btn-sm btn-outline-secondary" on:click=move |_| delete.show()>
{trl("Delete")}
</button>
}.into_any()
} else {
view! {<span></span>}.into_any()
}}
</div>
</div>
}
}
/// Sample of the public page in the look being edited. It uses the same classes and the same
/// stylesheet (assets/css/public.css) as the page itself.
#[component]
fn preview(look: Look, appearance: ReadSignal<Appearance>) -> impl IntoView {
let image = move || look.new_banner.get().or_else(|| appearance.get().banner_url());
let logo = move || look.new_logo.get().or_else(|| appearance.get().logo_url());
let title = move || look.title.get();
let has_banner = move || image().is_some() || logo().is_some() || !title().trim().is_empty();
// Clicking into the banner chooses the part of the image that stays visible
let set_focus = move |ev: MouseEvent| {
let Some(banner) = ev.current_target().and_then(|target| target.dyn_into::<Element>().ok()) else {
return
};
let rect = banner.get_bounding_client_rect();
if rect.width() <= 0.0 || rect.height() <= 0.0 {
return
}
let percent = |position: f64, size: f64| ((position / size) * 100.0).round().clamp(0.0, 100.0) as i16;
let x = percent(ev.client_x() as f64 - rect.left(), rect.width());
let y = percent(ev.client_y() as f64 - rect.top(), rect.height());
look.banner.update(|banner| {
banner.focus_x = x;
banner.focus_y = y;
});
};
view! {
<div class=move || format!("public-page public-preview {}", look.layout.get().classes())
style=move || look.colors.get().style()>
<Show when=has_banner>
<div class="public-banner" style=move || banner_style(image(), &look.banner.get()) on:click=set_focus>
{move || logo().map(|url| view! {
<img class="public-banner-logo" src=url alt=""/>
})}
<h1 class="public-banner-title">{title}</h1>
<Show when=move || image().is_some()>
<span class="public-banner-focus" style=move || {
let banner = look.banner.get().sanitized();
format!("left: {}%; top: {}%", banner.focus_x, banner.focus_y)
}></span>
</Show>
</div>
</Show>
<div class="public-banner-text" inner_html=move || look.text.get()></div>
<div class="card-body public-form">
<div class="row public-form-columns">
<div class="col-sm">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title"><i class="bx bx-basket"></i>" "{trl("Booking")}</h5>
<div class="mb-3">
<label for="preview_date" class="form-label">{trl("Date")}</label>
<input type="date" id="preview_date" class="form-control"/>
</div>
<div class="form-label">{trl("Time slot")}</div>
<div class="mb-3">
<input type="checkbox" class="btn-check" id="preview_slot1" autocomplete="off"/>
<label class="btn btn-outline-primary" for="preview_slot1">"17:00"</label>
<input type="checkbox" class="btn-check" id="preview_slot2" autocomplete="off" checked=true/>
<label class="btn btn-outline-primary" for="preview_slot2">"18:00"</label>
<input type="checkbox" class="btn-check" id="preview_slot3" autocomplete="off" disabled=true/>
<label class="btn btn-outline-primary" for="preview_slot3">"19:00"</label>
<input type="checkbox" class="btn-check" id="preview_slot4" autocomplete="off"/>
<label class="btn btn-outline-primary" for="preview_slot4">"20:00"</label>
</div>
<div class="form-label">{trl("Total price of booking")}</div>
<div>"250 Kč"</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title"><i class="bx bxs-contact"></i>" "{trl("Who is booking")}</h5>
<div class="mb-3">
<label for="preview_name" class="form-label">{trl("Full name")}</label>
<input type="text" id="preview_name" class="form-control" placeholder=trl("Enter full name")/>
</div>
<div class="mb-3">
<input type="checkbox" id="preview_remember" class="form-check-input" checked=true/>
" "
<label for="preview_remember" class="form-label">{trl("Remember for next time")}</label>
</div>
<div class="text-end">
<button type="button" class="btn btn-primary">{trl("Book")}</button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
} }
} }
#[component]
fn color_settings(colors: RwSignal<Colors>) -> impl IntoView {
view! {
<div class="form-label">{trl("Palettes")}</div>
<div class="d-flex flex-wrap gap-2 mb-3">
{PALETTES.iter().enumerate().map(|(index, (name, primary, background, _, text))| view! {
<button
type="button"
class="btn p-0 border"
style="width: 3rem; height: 2rem; overflow: hidden; display: inline-flex"
title=trl(name)
aria-label=trl(name)
on:click=move |_| {
if let Some(palette) = Colors::palette(index) {
colors.set(palette);
}
}>
<span style=format!("flex: 1; height: 100%; background: {}", primary)></span>
<span style=format!("flex: 1; height: 100%; background: {}", background)></span>
<span style=format!("flex: 1; height: 100%; background: {}", text)></span>
</button>
}).collect_view()}
</div>
<ColorInput id="color_primary" label="Primary colour"
value=move || colors.get().primary on_change=move |v| colors.update(|c| c.primary = v)/>
<ColorInput id="color_background" label="Page background"
value=move || colors.get().background on_change=move |v| colors.update(|c| c.background = v)/>
<ColorInput id="color_card" label="Card background"
value=move || colors.get().card on_change=move |v| colors.update(|c| c.card = v)/>
<ColorInput id="color_text" label="Text colour"
value=move || colors.get().text on_change=move |v| colors.update(|c| c.text = v)/>
{move || colors.get().contrast_issues().into_iter().map(|issue| view! {
<div class="text-warning mb-1"><i class="bx bx-error"></i>" "{trl(issue.message())}</div>
}).collect_view()}
}
}
#[component]
fn banner_settings(
look: Look,
appearance: ReadSignal<Appearance>,
uploads: RwSignal<u32>,
delete_banner: DialogOpener,
delete_logo: DialogOpener) -> impl IntoView {
let banner = look.banner;
view! {
<ImageUpload kind=ImageKind::Banner label="Banner image" selected=look.new_banner uploads=uploads
exists=move || appearance.get().banner_url().is_some() delete=delete_banner/>
<ImageUpload kind=ImageKind::Logo label="Logo" selected=look.new_logo uploads=uploads
exists=move || appearance.get().logo_url().is_some() delete=delete_logo/>
<Choice label="Height" options=vec![("low", "Low"), ("medium", "Medium"), ("high", "High")]
value=move || banner.get().height on_change=move |v| banner.update(|b| b.height = v)/>
<div class="d-flex align-items-center justify-content-between mb-2">
<label for="banner_dim" class="form-label mb-0">{trl("Darkening")}</label>
<input
type="range"
id="banner_dim"
class="form-range w-50"
min="0"
max=MAX_DIM.to_string()
step="5"
prop:value=move || banner.get().dim.to_string()
on:input=move |ev| {
if let Ok(dim) = event_target_value(&ev).parse::<i16>() {
banner.update(|b| b.dim = dim);
}
}
/>
</div>
<ColorInput id="title_color" label="Title colour"
value=move || banner.get().title_color on_change=move |v| banner.update(|b| b.title_color = v)/>
<Choice label="Title alignment" options=vec![("left", "Left"), ("center", "Centre")]
value=move || banner.get().title_align on_change=move |v| banner.update(|b| b.title_align = v)/>
<div class="text-muted mt-3">
{trl("Click into the banner in the preview to choose the part of the image that stays visible.")}
</div>
}
}
#[component]
fn layout_settings(layout: RwSignal<LayoutOptions>) -> impl IntoView {
view! {
<Choice label="Content width" options=vec![("full", "Full width"), ("centered", "Centred")]
value=move || layout.get().width on_change=move |v| layout.update(|l| l.width = v)/>
<Choice label="Form" options=vec![("columns", "Side by side"), ("stacked", "Stacked")]
value=move || layout.get().arrangement on_change=move |v| layout.update(|l| l.arrangement = v)/>
<Choice label="Time slots" options=vec![("normal", "Normal"), ("large", "Large")]
value=move || layout.get().slots on_change=move |v| layout.update(|l| l.slots = v)/>
<Choice label="Corners" options=vec![("rounded", "Rounded"), ("square", "Square")]
value=move || layout.get().corners on_change=move |v| layout.update(|l| l.corners = v)/>
}
}
/// Everything that is saved together: title, text, colours, banner options and layout
type Saved = (String, String, Colors, BannerOptions, LayoutOptions);
#[component]
fn save_bar(look: Look, saved: RwSignal<Saved>, editor: NodeRef<leptos::html::Div>) -> impl IntoView {
let save = ServerAction::<SaveAppearance>::new();
let sent = StoredValue::new(Saved::default());
let current = move || (look.title.get(), look.text.get(), look.colors.get(), look.banner.get(), look.layout.get());
let dirty = move || current() != saved.get();
Effect::new(move |_| {
if let Some(Ok(ApiResponse::Data(stored_text))) = save.value().get() {
let (title, text, colors, banner, layout) = sent.get_value();
let title = title.trim().to_string();
// The server may have removed something from the text; show what is really stored
if stored_text != text {
if let Some(element) = editor.get_untracked() {
element.set_inner_html(&stored_text);
}
}
saved.set((title.clone(), stored_text.clone(), colors, banner, layout));
// Only if nothing was changed while the request was running
if look.title.get_untracked() == sent.get_value().0 {
look.title.set(title);
}
if look.text.get_untracked() == text {
look.text.set(stored_text);
}
}
});
view! {
<div>
<button type="button" class="btn btn-primary me-2" on:click=move |_| {
let (title, text, colors, banner, layout) = current();
sent.set_value((title.clone(), text.clone(), colors.clone(), banner.clone(), layout.clone()));
save.dispatch(SaveAppearance { title, text, colors, banner, layout });
}>
{trl("Save")}
</button>
<button type="button" class="btn btn-outline-secondary" title=trl("Resets colours, banner options and layout")
on:click=move |_| {
look.colors.set(Colors::default());
look.banner.set(BannerOptions::default());
look.layout.set(LayoutOptions::default());
}>
{trl("Reset to default")}
</button>
</div>
<div class="mt-2">
{move || {
if dirty() {
return view! {<span class="text-muted">{trl("Unsaved changes")}</span>}.into_any()
}
match save.value().get() {
Some(Ok(ApiResponse::Data(_))) => view! {
<span class="text-success">{trl("Settings saved")}</span>
}.into_any(),
_ => view! {<span></span>}.into_any()
}
}}
{move || match save.value().get() {
Some(Ok(ApiResponse::Error(e))) => view! {
<div class="alert alert-danger mt-2">{trl(&e)}</div>
}.into_any(),
Some(Err(e)) => view! {
<div class="alert alert-danger mt-2">"Server error: " {e.to_string()}</div>
}.into_any(),
_ => view! {<span></span>}.into_any()
}}
</div>
}
}
#[component]
pub fn appearance() -> impl IntoView {
let del_banner = DialogOpener::new();
let del_logo = DialogOpener::new();
let uploads = RwSignal::new(0u32);
let appearance = Resource::new(
move || (del_banner.visible() || del_logo.visible(), uploads.get()),
|_| get_appearance());
let look = Look {
title: RwSignal::new(String::new()),
text: RwSignal::new(String::new()),
colors: RwSignal::new(Colors::default()),
banner: RwSignal::new(BannerOptions::default()),
layout: RwSignal::new(LayoutOptions::default()),
new_banner: RwSignal::new(None),
new_logo: RwSignal::new(None),
};
let saved = RwSignal::new(Saved::default());
let app_preview = RwSignal::new(Appearance::default());
let loaded = StoredValue::new(false);
let editor = NodeRef::<leptos::html::Div>::new();
// Runs in the browser only, so the server and the browser render the same markup first.
// The data are loaded again after every upload and deleted image; that must not throw away what is being edited.
Effect::new(move |_| {
if let (Some(element), Some(Ok(app))) = (editor.get(), appearance.get()) {
if !loaded.get_value() {
let title = app.title.clone().unwrap_or_default();
let text = app.text.clone().unwrap_or_default();
element.set_inner_html(&text);
look.title.set(title.clone());
look.text.set(text.clone());
look.colors.set(app.colors.clone());
look.banner.set(app.banner_options.clone());
look.layout.set(app.layout.clone());
saved.set((title, text, app.colors.clone(), app.banner_options.clone(), app.layout.clone()));
loaded.set_value(true);
}
app_preview.set(app);
}
});
view! {
<DeleteBanner opener=del_banner/>
<DeleteLogo opener=del_logo/>
<h1>{trl("Appearance settings")}</h1>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| match a {
Ok(_) => view! {<div></div>}.into_any(),
Err(e) => view! {
<div class="alert alert-danger">{trl("Error loading data")}": "{e.to_string()}</div>
}.into_any()
})
}}
</Transition>
<div class="row mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Title and text")}</h5>
<div class="mb-3">
<label for="title" class="form-label">{trl("Title")}</label>
<input
type="text"
id="title"
class="form-control"
maxlength=MAX_TITLE_LENGTH.to_string()
placeholder={trl("Enter title")}
prop:value=move || look.title.get()
on:input=move |ev| look.title.set(event_target_value(&ev))
/>
</div>
<div class="form-label" id="text_editor_label">{trl("Text under the banner")}</div>
<TextEditor text=look.text editor=editor/>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-lg-8 order-lg-2 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">{trl("Preview of the public page")}</h5>
<Preview look=look appearance=app_preview.read_only()/>
</div>
</div>
</div>
<div class="col-lg-4 order-lg-1">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{trl("Colours")}</h5>
<ColorSettings colors=look.colors/>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{trl("Banner")}</h5>
<BannerSettings look=look appearance=app_preview.read_only() uploads=uploads
delete_banner=del_banner delete_logo=del_logo/>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{trl("Layout")}</h5>
<LayoutSettings layout=look.layout/>
</div>
</div>
<div class="card mb-3">
<div class="card-body">
<SaveBar look=look saved=saved editor=editor/>
</div>
</div>
</div>
</div>
}
}
+131
View File
@@ -0,0 +1,131 @@
use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use crate::backend::data::{ApiResponse, PublicBooking, ReservationState};
use crate::backend::reservation::{get_booking, CancelBooking};
use crate::locales::{loc_date, trl};
use crate::pages::public::PublicShell;
/// The route of the link sent in e-mails: the booking and the possibility to cancel it
#[component]
pub fn BookingPage() -> impl IntoView {
view! {
<PublicShell>
<Booking/>
</PublicShell>
}
}
#[component]
fn booking_detail(booking: PublicBooking, uuid: String, cancel: ServerAction<CancelBooking>) -> impl IntoView {
// Cancelling is asked twice, a wrong tap must not cancel a booking
let confirming = RwSignal::new(false);
let uuid = StoredValue::new(uuid);
let canceled = booking.state == ReservationState::Canceled;
let deadline = format!("{} {}", loc_date(booking.cancel_until.date())(), booking.cancel_until.time().format("%H:%M"));
let state_class = match booking.state {
ReservationState::New => "badge bg-label-warning",
ReservationState::Approved => "badge bg-label-success",
ReservationState::Canceled => "badge bg-label-secondary",
};
let state_text = match booking.state {
ReservationState::New => "Waiting for approval",
ReservationState::Approved => "Approved",
ReservationState::Canceled => "Canceled",
};
view! {
<div class="mb-3"><span class=state_class>{trl(state_text)}</span></div>
<div class="form-label">{trl("Date")}</div>
<div class="mb-3">{loc_date(booking.date)}</div>
<div class="form-label">{trl("Booked")}</div>
<ul class="list-unstyled mb-3">
{booking.items.iter().map(|item| view! {
<li>{item.name.clone()}": "{item.from.format("%H:%M").to_string()}" – "{item.to.format("%H:%M").to_string()}</li>
}).collect_view()}
</ul>
<div class="form-label">{trl("Total price of booking")}</div>
<div class="mb-4">{format!("{} Kč", booking.price)}</div>
{if booking.can_cancel {
view! {
<p>{trl("You can cancel this booking until")}" "{deadline}"."</p>
<Show
when=move || confirming.get()
fallback=move || view! {
<button type="button" class="btn btn-outline-secondary" on:click=move |_| confirming.set(true)>
{trl("Cancel booking")}
</button>
}>
<p><b>{trl("Do you really want to cancel this booking?")}</b></p>
<button type="button" class="btn btn-primary me-2" disabled=move || cancel.pending().get()
on:click=move |_| {
cancel.dispatch(CancelBooking { uuid: uuid.get_value() });
}>
{trl("Yes, cancel booking")}
</button>
<button type="button" class="btn btn-outline-secondary" on:click=move |_| confirming.set(false)>
{trl("No")}
</button>
</Show>
}.into_any()
} else if canceled {
view! {
<p>{trl("This booking is cancelled.")}</p>
}.into_any()
} else {
view! {
<p>{trl("This booking can no longer be cancelled, it was possible until")}" "{deadline}"."</p>
}.into_any()
}}
}
}
#[component]
fn booking() -> impl IntoView {
let params = use_params_map();
let uuid = move || params.read().get("uuid").unwrap_or_default();
let cancel = ServerAction::<CancelBooking>::new();
// Loaded again after an attempt to cancel, whatever its result
let booking = Resource::new_blocking(move || (uuid(), cancel.version().get()), |(uuid, _)| get_booking(uuid));
view! {
<div class="card-body public-form">
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title"><i class="bx bx-calendar-check"></i>" "{trl("Your booking")}</h5>
{move || match cancel.value().get() {
Some(Ok(ApiResponse::Error(e))) => view! {
<div class="alert alert-danger">{trl(&e)}</div>
}.into_any(),
Some(Err(e)) => view! {
<div class="alert alert-danger">{trl("Something went wrong")}": "{e.to_string()}</div>
}.into_any(),
_ => view! {<span></span>}.into_any()
}}
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
booking.get().map(|b| match b {
Ok(ApiResponse::Data(b)) => view! {
<BookingDetail booking=b uuid=uuid() cancel=cancel/>
}.into_any(),
Ok(ApiResponse::Error(e)) => view! {
<p>{trl(&e)}</p>
}.into_any(),
Err(e) => view! {
<div><p>{trl("Error loading data")}</p><p>{e.to_string()}</p></div>
}.into_any()
})
}}
</Transition>
<div class="mt-4">
<a href="/">{trl("New booking")}</a>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::{ChangePwd, get_user}; use crate::backend::user::{ChangePwd, get_user};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,8 +7,8 @@ use crate::locales::trl;
#[component] #[component]
pub fn change_password(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { pub fn change_password(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView {
let change_pwd = create_server_action::<ChangePwd>(); let change_pwd = ServerAction::<ChangePwd>::new();
let logged_in = create_resource(||(), move |_| get_user()); let logged_in = Resource::new(||(), move |_| get_user());
view! { view! {
<DataForm opener=opener action=change_pwd title="Change password"> <DataForm opener=opener action=change_pwd title="Change password">
+7 -7
View File
@@ -1,5 +1,5 @@
use chrono::Local; use chrono::Local;
use leptos::*; use leptos::prelude::*;
use crate::backend::data::ClosingTime; use crate::backend::data::ClosingTime;
use crate::backend::opening_hours::{get_closing_times, DeleteClosingTime, InsertClosingTime}; use crate::backend::opening_hours::{get_closing_times, DeleteClosingTime, InsertClosingTime};
use crate::components::data_form::{DataForm, QuestionDialog}; use crate::components::data_form::{DataForm, QuestionDialog};
@@ -8,7 +8,7 @@ use crate::locales::{loc_date, trl};
#[component] #[component]
pub fn del_closing_day(closing_time: ReadSignal<ClosingTime>, opener: DialogOpener) -> impl IntoView { pub fn del_closing_day(closing_time: ReadSignal<ClosingTime>, opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteClosingTime>(); let delete = ServerAction::<DeleteClosingTime>::new();
view! { view! {
<QuestionDialog opener=opener action=delete title="Delete closing days"> <QuestionDialog opener=opener action=delete title="Delete closing days">
@@ -20,7 +20,7 @@ pub fn del_closing_day(closing_time: ReadSignal<ClosingTime>, opener: DialogOpen
#[component] #[component]
pub fn insert_closing_days(opener: DialogOpener) -> impl IntoView { pub fn insert_closing_days(opener: DialogOpener) -> impl IntoView {
let insert_day = create_server_action::<InsertClosingTime>(); let insert_day = ServerAction::<InsertClosingTime>::new();
view! { view! {
<DataForm opener=opener action=insert_day title="Closing days"> <DataForm opener=opener action=insert_day title="Closing days">
@@ -52,8 +52,8 @@ pub fn insert_closing_days(opener: DialogOpener) -> impl IntoView {
pub fn closing_days() -> impl IntoView { pub fn closing_days() -> impl IntoView {
let delete_dialog = DialogOpener::new(); let delete_dialog = DialogOpener::new();
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let times = create_blocking_resource(move || editor.visible() || delete_dialog.visible(), move |_| {get_closing_times()}); let times = Resource::new_blocking(move || editor.visible() || delete_dialog.visible(), move |_| {get_closing_times()});
let time_to_del = create_rw_signal(ClosingTime::default()); let time_to_del = RwSignal::new(ClosingTime::default());
view! { view! {
<DelClosingDay closing_time=time_to_del.read_only() opener=delete_dialog/> <DelClosingDay closing_time=time_to_del.read_only() opener=delete_dialog/>
<InsertClosingDays opener=editor/> <InsertClosingDays opener=editor/>
@@ -78,7 +78,7 @@ pub fn closing_days() -> impl IntoView {
e.to_string() e.to_string()
}; };
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=3>{trl(&err)}</td></tr></tbody>}} <tr><td colspan=3>{trl(&err)}</td></tr></tbody>}.into_any()}
Ok(c) => { Ok(c) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<For each=move || c.clone() <For each=move || c.clone()
@@ -100,7 +100,7 @@ pub fn closing_days() -> impl IntoView {
</tr> </tr>
} }
}/></tbody> }/></tbody>
} }.into_any()
} }
}) })
}} }}
+19 -2
View File
@@ -1,7 +1,8 @@
use crate::backend::cancellation::MAX_CANCEL_HOURS;
use crate::backend::data::Company; use crate::backend::data::Company;
use crate::backend::company::UpdateCompany; use crate::backend::company::UpdateCompany;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use leptos::*; use leptos::prelude::*;
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
use crate::locales::trl; use crate::locales::trl;
@@ -10,7 +11,7 @@ pub fn CompanyEdit(
company: ReadSignal<Company>, company: ReadSignal<Company>,
opener: DialogOpener, opener: DialogOpener,
) -> impl IntoView { ) -> impl IntoView {
let update_company = create_server_action::<UpdateCompany>(); let update_company = ServerAction::<UpdateCompany>::new();
view! { view! {
<DataForm opener=opener action=update_company title="Edit company"> <DataForm opener=opener action=update_company title="Edit company">
@@ -76,6 +77,22 @@ pub fn CompanyEdit(
/> />
</div> </div>
</div> </div>
<div class="row">
<div class="col mb-3">
<label for="cancelHours" class="form-label">{trl("Customers can cancel a booking until (hours before its start)")}</label>
<input
type="number"
id="cancelHours"
class="form-control"
min="0"
max=MAX_CANCEL_HOURS.to_string()
step="1"
prop:value={move || company.get().cancel_hours.to_string()}
name="company[cancel_hours]"
/>
<div class="form-text">{trl("0 means until the start of the booking.")}</div>
</div>
</div>
</DataForm> </DataForm>
} }
} }
+10 -8
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::company::get_company; use crate::backend::company::get_company;
use crate::backend::data::{ApiResponse, Company}; use crate::backend::data::{ApiResponse, Company};
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -8,21 +8,21 @@ use crate::pages::company_edit::CompanyEdit;
#[component] #[component]
pub fn CompanyInfo() -> impl IntoView { pub fn CompanyInfo() -> impl IntoView {
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let company = create_blocking_resource(move|| editor.visible(), move |_| { get_company() }); let company = Resource::new_blocking(move|| editor.visible(), move |_| { get_company() });
let (cmp, set_cmp) = create_signal(Company::default()); let (cmp, set_cmp) = signal(Company::default());
view! { view! {
<CompanyEdit company={cmp} opener=editor/> <CompanyEdit company={cmp} opener=editor/>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl("Company info")}</h5> <h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl("Company info")}</h5>
<p class="card-text"> <div class="card-text">
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || { {move || {
company.get().map(|c| match c { company.get().map(|c| match c {
Err(e) => {view! {<div><p>{trl("Error loading data")}</p> Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div> <p>{e.to_string()}</p></div>
}} }.into_any()}
Ok(c) => { Ok(c) => {
match c { match c {
ApiResponse::Data(d) => {set_cmp.update(|cmp| *cmp = d.clone()); ApiResponse::Data(d) => {set_cmp.update(|cmp| *cmp = d.clone());
@@ -30,16 +30,18 @@ pub fn CompanyInfo() -> impl IntoView {
<p><b>{d.name}</b></p> <p><b>{d.name}</b></p>
<p>{d.street}" "{d.house_number}<br/> <p>{d.street}" "{d.house_number}<br/>
{d.zip_code}" "{d.city} {d.zip_code}" "{d.city}
</p></div>} </p>
<p>{trl("Customers can cancel a booking until (hours before its start)")}": "{d.cancel_hours}</p>
</div>}.into_any()
} }
ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}} ApiResponse::Error(_) => {view! {<div><p>{trl("Error loading data")}</p></div>}.into_any()}
} }
} }
}) })
}} }}
</Transition> </Transition>
</p> </div>
<a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()> <a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()>
<i class="bx bx-edit-alt fs-4 lh-0"></i></a> <i class="bx bx-edit-alt fs-4 lh-0"></i></a>
</div> </div>
+7 -7
View File
@@ -1,11 +1,11 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::customer::get_customers; use crate::backend::customer::get_customers;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::locales::trl; use crate::locales::trl;
#[component] #[component]
pub fn customers() -> impl IntoView { pub fn customers() -> impl IntoView {
let customers = create_blocking_resource(||(), |_| get_customers()); let customers = Resource::new_blocking(||(), |_| get_customers());
view! { view! {
<h1>{trl("Customers")}</h1> <h1>{trl("Customers")}</h1>
@@ -21,7 +21,7 @@ pub fn customers() -> impl IntoView {
<th>{trl("Phone")}</th> <th>{trl("Phone")}</th>
</tr> </tr>
</thead> </thead>
{ {move || {
customers.get().map(|c| match c { customers.get().map(|c| match c {
Ok(c) => {match c { Ok(c) => {match c {
ApiResponse::Data(c) => { ApiResponse::Data(c) => {
@@ -35,20 +35,20 @@ pub fn customers() -> impl IntoView {
</tr> </tr>
</For> </For>
</tbody> </tbody>
} }.into_any()
} }
ApiResponse::Error(e) => { ApiResponse::Error(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
} }
} }
Err(e) => { Err(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>} <tr><td colspan=3>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()
} }
}) })
} }}
</table> </table>
</Transition> </Transition>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use crate::locales::trl; use crate::locales::trl;
use crate::pages::new_reservations::NewReservations; use crate::pages::new_reservations::NewReservations;
+3 -3
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::WeekHours; use crate::backend::data::WeekHours;
use crate::backend::opening_hours::{DeleteClosingTime, UpdateHours}; use crate::backend::opening_hours::{DeleteClosingTime, UpdateHours};
use crate::components::data_form::{DataForm, QuestionDialog}; use crate::components::data_form::{DataForm, QuestionDialog};
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn EditHours(opener: DialogOpener, hours: ReadSignal<WeekHours>) -> impl IntoView { pub fn EditHours(opener: DialogOpener, hours: ReadSignal<WeekHours>) -> impl IntoView {
let update_hours = create_server_action::<UpdateHours>(); let update_hours = ServerAction::<UpdateHours>::new();
view! { view! {
<DataForm opener=opener action=update_hours title="Edit hours"> <DataForm opener=opener action=update_hours title="Edit hours">
@@ -31,7 +31,7 @@ pub fn EditHours(opener: DialogOpener, hours: ReadSignal<WeekHours>) -> impl Int
#[component] #[component]
pub fn del_closing_days(opener: DialogOpener) -> impl IntoView { pub fn del_closing_days(opener: DialogOpener) -> impl IntoView {
let delete = create_server_action::<DeleteClosingTime>(); let delete = ServerAction::<DeleteClosingTime>::new();
view! { view! {
<QuestionDialog opener=opener action=delete title="Delete closing days"> <QuestionDialog opener=opener action=delete title="Delete closing days">
+9 -6
View File
@@ -1,6 +1,6 @@
use leptos::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::*;
use leptos_router::ActionForm; use leptos::form::ActionForm;
use crate::backend::user::Login; use crate::backend::user::Login;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
use crate::components::server_err::ServerErr; use crate::components::server_err::ServerErr;
@@ -8,8 +8,9 @@ use crate::locales::trl;
#[component] #[component]
pub fn Login() -> impl IntoView { pub fn Login() -> impl IntoView {
let login = create_server_action::<Login>(); let login = ServerAction::<Login>::new();
let login_val = login.value(); let login_val = Signal::derive(move || login.value().get());
let show_password = RwSignal::new(false);
view! { view! {
<Link rel="stylesheet" href="/vendor/css/pages/page-auth.css" /> <Link rel="stylesheet" href="/vendor/css/pages/page-auth.css" />
<div class="authentication-wrapper authentication-basic container-p-y"> <div class="authentication-wrapper authentication-basic container-p-y">
@@ -50,13 +51,15 @@ pub fn Login() -> impl IntoView {
</div> </div>
<div class="input-group input-group-merge"> <div class="input-group input-group-merge">
<input <input
type="password" type=move || if show_password.get() {"text"} else {"password"}
id="password" id="password"
class="form-control" class="form-control"
name="password" name="password"
aria-describedby="password" aria-describedby="password"
/> />
<span class="input-group-text cursor-pointer"><i class="bx bx-hide"></i></span> <span class="input-group-text cursor-pointer" on:click=move |_| show_password.update(|show| *show = !*show)>
<i class=move || if show_password.get() {"bx bx-show"} else {"bx bx-hide"}></i>
</span>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
+8 -1
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::MessageType; use crate::backend::data::MessageType;
use crate::locales::trl; use crate::locales::trl;
use crate::pages::mail_view::MailView; use crate::pages::mail_view::MailView;
@@ -7,6 +7,7 @@ use crate::pages::mail_view::MailView;
pub fn mail_settings() -> impl IntoView { pub fn mail_settings() -> impl IntoView {
view! { view! {
<h1>{trl("Mail settings")}</h1> <h1>{trl("Mail settings")}</h1>
<p>{trl("In the texts, #date# is replaced by the date of the booking, #summary# by the booked items and #link# by the link to the page where the customer can view and cancel the booking. E-mails for the customer about a new and an approved booking get the link at the end when their text does not contain #link#.")}</p>
<div class="row mb-5"> <div class="row mb-5">
<div class="col-md"> <div class="col-md">
<MailView title="New booking" mail_type=MessageType::NewReservation/> <MailView title="New booking" mail_type=MessageType::NewReservation/>
@@ -23,5 +24,11 @@ pub fn mail_settings() -> impl IntoView {
<MailView title="Booking canceled" mail_type=MessageType::ReservationCanceled/> <MailView title="Booking canceled" mail_type=MessageType::ReservationCanceled/>
</div> </div>
</div> </div>
<div class="row mb-5">
<div class="col-md">
<MailView title="Booking canceled by customer - for staff" mail_type=MessageType::CustomerCanceled/>
</div>
<div class="col-md"></div>
</div>
} }
} }
+10 -10
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{Message, MessageType}; use crate::backend::data::{Message, MessageType};
use crate::backend::mail::{get_message, UpdateMessage}; use crate::backend::mail::{get_message, UpdateMessage};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
fn mail_edit(opener: DialogOpener, mail: ReadSignal<Message>) -> impl IntoView { fn mail_edit(opener: DialogOpener, mail: ReadSignal<Message>) -> impl IntoView {
let update = create_server_action::<UpdateMessage>(); let update = ServerAction::<UpdateMessage>::new();
view! { view! {
<DataForm opener=opener title="Edit mail" action=update> <DataForm opener=opener title="Edit mail" action=update>
@@ -44,17 +44,17 @@ fn mail_edit(opener: DialogOpener, mail: ReadSignal<Message>) -> impl IntoView {
#[component] #[component]
pub fn mail_view(title: &'static str, mail_type: MessageType) -> impl IntoView { pub fn mail_view(title: &'static str, mail_type: MessageType) -> impl IntoView {
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let mail = create_blocking_resource( move || editor.visible(), move |_| get_message(mail_type.clone())); let mail = Resource::new_blocking( move || editor.visible(), move |_| get_message(mail_type.clone()));
let for_edit = create_rw_signal(Message::default()); let for_edit = RwSignal::new(Message::default());
view! { view! {
<MailEdit opener=editor mail={for_edit.read_only()}/> <MailEdit opener=editor mail={for_edit.read_only()}/>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl(title)}</h5> <h5 class="card-title"><i class="bx bx-buildings"></i>" "{trl(title)}</h5>
<p class="card-text"> <div class="card-text">
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{ {move || {
mail.get().map(|m| match m { mail.get().map(|m| match m {
Ok(m) => { Ok(m) => {
for_edit.set(m.clone()); for_edit.set(m.clone());
@@ -63,16 +63,16 @@ pub fn mail_view(title: &'static str, mail_type: MessageType) -> impl IntoView {
<p>{trl("Subject: ")}{m.subject}</p> <p>{trl("Subject: ")}{m.subject}</p>
<p>{m.text}</p> <p>{m.text}</p>
</div> </div>
} }.into_any()
} }
Err(_) => { Err(_) => {
view! { view! {
<div>"Error loading data"</div> <div>"Error loading data"</div>
} }.into_any()
}}) }})
} }}
</Transition> </Transition>
</p> </div>
<a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()> <a href="javascript:void(0)" class="card-link" on:click = move |_| editor.show()>
<i class="bx bx-edit-alt fs-4 lh-0"></i></a> <i class="bx bx-edit-alt fs-4 lh-0"></i></a>
</div> </div>
+1
View File
@@ -4,6 +4,7 @@ mod company_info;
mod company_edit; mod company_edit;
pub mod login; pub mod login;
pub mod public; pub mod public;
pub mod booking;
pub mod profile_edit; pub mod profile_edit;
pub mod change_pwd; pub mod change_pwd;
mod users; mod users;
+15 -15
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, Page, ResSumWithItems}; use crate::backend::data::{ApiResponse, Page, ResSumWithItems};
use crate::backend::reservation::{Approve, Cancel, get_new_reservations}; use crate::backend::reservation::{Approve, Cancel, get_new_reservations};
use crate::components::data_form::QuestionDialog; use crate::components::data_form::QuestionDialog;
@@ -10,7 +10,7 @@ use chrono::Datelike;
#[component] #[component]
fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView { fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let approve = create_server_action::<Approve>(); let approve = ServerAction::<Approve>::new();
view! { view! {
<QuestionDialog opener=opener action=approve title="Approve booking"> <QuestionDialog opener=opener action=approve title="Approve booking">
@@ -27,7 +27,7 @@ fn approve_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener
#[component] #[component]
pub fn cancel_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView { pub fn cancel_dialog(reservation: ReadSignal<ResSumWithItems>, opener: DialogOpener) -> impl IntoView {
let cancel = create_server_action::<Cancel>(); let cancel = ServerAction::<Cancel>::new();
view! { view! {
<QuestionDialog opener=opener action=cancel title="Cancel booking"> <QuestionDialog opener=opener action=cancel title="Cancel booking">
@@ -56,13 +56,13 @@ fn create_dialog(opener: DialogOpener) -> impl IntoView {
#[component] #[component]
pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView { pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView {
let create = DialogOpener::new(); let create = DialogOpener::new();
let page = create_rw_signal(Page::new(5)); let page = RwSignal::new(Page::new(5));
let first = create_rw_signal(false); let first = RwSignal::new(false);
let last = create_rw_signal(false); let last = RwSignal::new(false);
let total_pages = create_rw_signal(0i32); let total_pages = RwSignal::new(0i32);
let res = create_blocking_resource(move || (app_opener.visible() || cancel_opener.visible() || create.visible(), page), let res = Resource::new_blocking(move || (app_opener.visible() || cancel_opener.visible() || create.visible(), page),
move |d| get_new_reservations(d.1.get())); move |d| get_new_reservations(d.1.get()));
let reservation = create_rw_signal(ResSumWithItems::default()); let reservation = RwSignal::new(ResSumWithItems::default());
view! { view! {
<ApproveDialog opener=app_opener reservation=reservation.read_only() /> <ApproveDialog opener=app_opener reservation=reservation.read_only() />
@@ -75,7 +75,7 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
{move || { {move || {
res.get().map(|r| match r { res.get().map(|r| match r {
Err(e) => { Err(e) => {
view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}} view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}.into_any()}
Ok(r) => { match r { Ok(r) => { match r {
ApiResponse::Data(r) => { ApiResponse::Data(r) => {
first.set(r.is_first()); first.set(r.is_first());
@@ -98,7 +98,7 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
let:item> let:item>
{item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/> {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
</For> </For>
{trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email}</a>", "{data.customer.phone}<br/> {trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email.clone()}</a>", "{data.customer.phone}<br/>
{move || { {move || {
let note = data.summary.note.clone(); let note = data.summary.note.clone();
let show = note.is_some() && !note.clone().unwrap().is_empty(); let show = note.is_some() && !note.clone().unwrap().is_empty();
@@ -109,7 +109,7 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
} }
}} }}
{trl("Price: ")}{data.summary.price.to_string()}<br/> {trl("Price: ")}{data.summary.price.to_string()}<br/>
<table width="100%"> <table style="width: 100%"><tbody>
<tr> <tr>
<td width="100%"></td> <td width="100%"></td>
<td> <td>
@@ -135,15 +135,15 @@ pub fn new_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -
</div> </div>
</td> </td>
</tr> </tr>
</table> </tbody></table>
<hr/> <hr/>
} }
}} }}
</For> </For>
</div> </div>
} }.into_any()
} }
ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}}} ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}.into_any()}}
} }
}) })
}} }}
+11 -11
View File
@@ -1,5 +1,5 @@
use chrono::Weekday; use chrono::Weekday;
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{DayHours, WeekHours}; use crate::backend::data::{DayHours, WeekHours};
use crate::backend::opening_hours::get_hours; use crate::backend::opening_hours::get_hours;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -18,8 +18,8 @@ fn show_time(tm: &str) -> impl Fn() -> String {
pub fn OpeningHours() -> impl IntoView { pub fn OpeningHours() -> impl IntoView {
let editor = DialogOpener::new(); let editor = DialogOpener::new();
let closing_delete = DialogOpener::new(); let closing_delete = DialogOpener::new();
let hours = create_blocking_resource(move || editor.visible(), move |_| {get_hours()}); let hours = Resource::new_blocking(move || editor.visible(), move |_| {get_hours()});
let hrs = create_rw_signal(WeekHours::default()); let hrs = RwSignal::new(WeekHours::default());
view! { view! {
<EditHours opener=editor hours=hrs.read_only() /> <EditHours opener=editor hours=hrs.read_only() />
@@ -27,16 +27,16 @@ pub fn OpeningHours() -> impl IntoView {
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title"><i class="bx bxs-watch"></i>" "{trl("Opening hours")}</h5> <h5 class="card-title"><i class="bx bxs-watch"></i>" "{trl("Opening hours")}</h5>
<p class="card-text"> <div class="card-text">
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || { {move || {
hours.get().map(|h| match h { hours.get().map(|h| match h {
Ok(h) => { Ok(h) => {
let h = create_rw_signal(h); let h = RwSignal::new(h);
let d = create_rw_signal((0..7).collect::<Vec<u8>>()); let d = RwSignal::new((0..7).collect::<Vec<u8>>());
view! { view! {
<div> <div>
<table class="table card-table"> <table class="table card-table"><tbody>
<For each=move || d.get() key=|day| *day <For each=move || d.get() key=|day| *day
children=move |day| { children=move |day| {
let week_day = Weekday::try_from(day).unwrap_or(Weekday::Mon); let week_day = Weekday::try_from(day).unwrap_or(Weekday::Mon);
@@ -54,17 +54,17 @@ pub fn OpeningHours() -> impl IntoView {
} }
} }
/> />
</table> </tbody></table>
</div> </div>
} }.into_any()
} }
Err(e) => {view! {<div><p>{trl("Error loading data")}</p> Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div> <p>{e.to_string()}</p></div>
}} }.into_any()}
}) })
}} }}
</Transition> </Transition>
</p> </div>
</div> </div>
</div> </div>
} }
+3 -3
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::{get_user, UpdateProfile}; use crate::backend::user::{get_user, UpdateProfile};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,8 +7,8 @@ use crate::locales::trl;
#[component] #[component]
pub fn ProfileEdit(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { pub fn ProfileEdit(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView {
let update_user = create_server_action::<UpdateProfile>(); let update_user = ServerAction::<UpdateProfile>::new();
let logged_in = create_resource(||(), move |_| get_user()); let logged_in = Resource::new(||(), move |_| get_user());
view! { view! {
<DataForm opener=opener action=update_user title="Edit profile"> <DataForm opener=opener action=update_user title="Edit profile">
+12 -12
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use leptos_use::use_media_query; use leptos_use::use_media_query;
use crate::backend::data::{ApiResponse, ResProperty}; use crate::backend::data::{ApiResponse, ResProperty};
use crate::backend::property::get_properties; use crate::backend::property::get_properties;
@@ -11,13 +11,13 @@ use crate::pages::property_edit::PropertyEdit;
#[component] #[component]
pub fn properties() -> impl IntoView { pub fn properties() -> impl IntoView {
let is_wide = use_media_query("(min-width: 500px)"); let is_wide = use_media_query("(min-width: 500px)");
let properties = create_rw_signal::<Vec<ResProperty>>(vec![]); let properties = RwSignal::<Vec<ResProperty>>::new(vec![]);
let prop = create_rw_signal(ResProperty::default()); let prop = RwSignal::new(ResProperty::default());
let empty_prop = create_rw_signal(ResProperty::default()); let empty_prop = RwSignal::new(ResProperty::default());
let create_form = DialogOpener::new(); let create_form = DialogOpener::new();
let edit_form = DialogOpener::new(); let edit_form = DialogOpener::new();
let delete_dlg = DialogOpener::new(); let delete_dlg = DialogOpener::new();
let props = create_blocking_resource(move || create_form.visible() || edit_form.visible() || delete_dlg.visible(), move |_| get_properties()); let props = Resource::new_blocking(move || create_form.visible() || edit_form.visible() || delete_dlg.visible(), move |_| get_properties());
view! { view! {
<PropertyEdit property=prop.read_only() opener=edit_form edit=true/> <PropertyEdit property=prop.read_only() opener=edit_form edit=true/>
@@ -31,8 +31,8 @@ pub fn properties() -> impl IntoView {
<thead> <thead>
<tr> <tr>
<th>{trl("Name")}</th> <th>{trl("Name")}</th>
{move || if is_wide.get() {view! {<th>{trl("Description")}</th>}} {move || if is_wide.get() {view! {<th>{trl("Description")}</th>}.into_any()}
else {view! {<th></th>}} } else {view! {<th></th>}.into_any()} }
<th>{trl("Price")}</th> <th>{trl("Price")}</th>
<th>{trl("Actions")}</th> <th>{trl("Actions")}</th>
</tr> </tr>
@@ -41,7 +41,7 @@ pub fn properties() -> impl IntoView {
props.get().map(|u| match u { props.get().map(|u| match u {
Err(e) => { Err(e) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}} <tr><td colspan=4>{trl("Something went wrong")}<br/>{e.to_string()}</td></tr></tbody>}.into_any()}
Ok(u) => { Ok(u) => {
match u { match u {
ApiResponse::Data(p) => { ApiResponse::Data(p) => {
@@ -58,8 +58,8 @@ pub fn properties() -> impl IntoView {
view! { view! {
<tr> <tr>
<td>{data.name.clone()}</td> <td>{data.name.clone()}</td>
{move || if is_wide.get() {view! {<td>{data.description.clone()}</td>}} {move || if is_wide.get() {view! {<td>{data.description.clone()}</td>}.into_any()}
else {view! {<td></td>}} } else {view! {<td></td>}.into_any()} }
<td>{data.price.to_string()}</td> <td>{data.price.to_string()}</td>
<td> <td>
<div class="dropdown"> <div class="dropdown">
@@ -87,11 +87,11 @@ pub fn properties() -> impl IntoView {
}} }}
</For> </For>
</tbody> </tbody>
} }.into_any()
}, },
ApiResponse::Error(s) => { ApiResponse::Error(s) => {
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl(&s)}</td></tr></tbody>} <tr><td colspan=4>{trl(&s)}</td></tr></tbody>}.into_any()
} }
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::ResProperty; use crate::backend::data::ResProperty;
use crate::backend::property::DeleteProperty; use crate::backend::property::DeleteProperty;
use crate::components::data_form::QuestionDialog; use crate::components::data_form::QuestionDialog;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn property_delete(property: ReadSignal<ResProperty>, opener: DialogOpener) -> impl IntoView { pub fn property_delete(property: ReadSignal<ResProperty>, opener: DialogOpener) -> impl IntoView {
let del_property = create_server_action::<DeleteProperty>(); let del_property = ServerAction::<DeleteProperty>::new();
view! { view! {
<QuestionDialog opener=opener action=del_property title="Delete property"> <QuestionDialog opener=opener action=del_property title="Delete property">
+8 -8
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ResProperty, SlotType}; use crate::backend::data::{ResProperty, SlotType};
use crate::backend::property::{CreateProperty, EditProperty}; use crate::backend::property::{CreateProperty, EditProperty};
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView { fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView {
let active_str = create_rw_signal("false".to_string()); let active_str = RwSignal::new("false".to_string());
view! { view! {
<input type="hidden" prop:value={move || property.get().id()} name="property[id]"/> <input type="hidden" prop:value={move || property.get().id()} name="property[id]"/>
<div class="row"> <div class="row">
@@ -79,9 +79,9 @@ fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView {
<input type="hidden" prop:value=active_str name="property[active]"/> <input type="hidden" prop:value=active_str name="property[active]"/>
</div> </div>
</div> </div>
} }.into_any()
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
} }
}} }}
@@ -89,19 +89,19 @@ fn form_inner(property: ReadSignal<ResProperty>) -> impl IntoView {
#[component] #[component]
pub fn property_edit(property: ReadSignal<ResProperty>, edit: bool, opener: DialogOpener) -> impl IntoView { pub fn property_edit(property: ReadSignal<ResProperty>, edit: bool, opener: DialogOpener) -> impl IntoView {
let action_create = create_server_action::<CreateProperty>(); let action_create = ServerAction::<CreateProperty>::new();
let action_edit = create_server_action::<EditProperty>(); let action_edit = ServerAction::<EditProperty>::new();
view! { view! {
{move || {move ||
if edit { if edit {
view! {<DataForm opener=opener action=action_edit title="Edit property"> view! {<DataForm opener=opener action=action_edit title="Edit property">
<FormInner property=property/> <FormInner property=property/>
</DataForm>}} </DataForm>}.into_any()}
else { else {
view! {<DataForm opener=opener action=action_create title="Create property"> view! {<DataForm opener=opener action=action_create title="Create property">
<FormInner property=property/> <FormInner property=property/>
</DataForm>}} </DataForm>}.into_any()}
} }
} }
} }
+72 -50
View File
@@ -1,7 +1,8 @@
use chrono::{Duration, Local, NaiveDate, NaiveTime, Timelike}; use chrono::{Duration, Local, NaiveDate, NaiveTime, Timelike};
use leptos::*; use leptos::prelude::*;
use leptos_captcha::{Captcha, pow_dispatch}; use leptos_captcha::{Captcha, pow_dispatch};
use leptos_router::*; use leptos::form::{ActionForm, FromFormData};
use leptos::ev::SubmitEvent;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use crate::backend::appearance::get_appearance; use crate::backend::appearance::get_appearance;
use crate::backend::customer::get_remembered; use crate::backend::customer::get_remembered;
@@ -36,7 +37,7 @@ fn time_selector(
match property.slot { match property.slot {
SlotType::Quarter => { SlotType::Quarter => {
let mut ret: Vec<TmCheck> = vec![]; let mut ret: Vec<TmCheck> = vec![];
logging::log!("quarter"); leptos::logging::log!("quarter");
for n in 0..(h.to() - h.from()).num_minutes() * 4 / 60 { for n in 0..(h.to() - h.from()).num_minutes() * 4 / 60 {
ret.push(TmCheck { ret.push(TmCheck {
from: h.from() + Duration::minutes(n * 15), from: h.from() + Duration::minutes(n * 15),
@@ -47,7 +48,7 @@ fn time_selector(
} }
SlotType::Half => { SlotType::Half => {
let mut ret: Vec<TmCheck> = vec![]; let mut ret: Vec<TmCheck> = vec![];
logging::log!("half"); leptos::logging::log!("half");
for n in 0..(h.to() - h.from()).num_minutes() * 2 / 60 { for n in 0..(h.to() - h.from()).num_minutes() * 2 / 60 {
ret.push(TmCheck { ret.push(TmCheck {
from: h.from() + Duration::minutes(n * 30), from: h.from() + Duration::minutes(n * 30),
@@ -108,24 +109,72 @@ fn time_selector(
} }
} }
/// Wrapper of the public pages: the colours and layout the customer has set, and the banner.
/// Styles in `assets/css/public.css` are scoped by it, so the form embedded in the admin portal
/// is not affected.
#[component]
pub fn PublicShell(
/// Show the text under the banner
#[prop(optional)] with_text: bool,
children: ChildrenFn) -> impl IntoView {
let appearance = Resource::new_blocking(||(), move |_| get_appearance());
view! {
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }>
{move || {
appearance.get().map(|a| {
// Default look when the settings can't be loaded, the page reports its own errors
let a = a.unwrap_or_default();
let title = a.title.clone().unwrap_or_default();
view! {
<div class=format!("public-page {}", a.layout.classes()) style=a.colors.style()>
{a.has_banner().then(|| view! {
<div class="public-banner" style=a.banner_style()>
{a.logo_url().map(|url| view! {
<img class="public-banner-logo" src=url alt=""/>
})}
<h1 class="public-banner-title">{title}</h1>
</div>
})}
{with_text.then(|| view! {
<div class="public-banner-text" inner_html=a.text.clone().unwrap_or_default()></div>
})}
{children()}
</div>
}
})
}}
</Transition>
}
}
/// The public route: the booking form
#[component]
pub fn PublicPage() -> impl IntoView {
view! {
<PublicShell with_text=true>
<Public/>
</PublicShell>
}
}
#[component] #[component]
pub fn Public() -> impl IntoView { pub fn Public() -> impl IntoView {
let day = create_rw_signal(NaiveDate::default()); let day = RwSignal::new(NaiveDate::default());
let form_data = create_blocking_resource(move || day.get(), move |d| get_public_form_data(d)); let form_data = Resource::new_blocking(move || day.get(), move |d| get_public_form_data(d));
let slots = create_rw_signal::<Vec<String>>(vec![]); let slots = RwSignal::<Vec<String>>::new(vec![]);
let price = create_rw_signal(Decimal::from(0)); let price = RwSignal::new(Decimal::from(0));
let cr_reservation = create_server_action::<CreateReservation>(); let cr_reservation = ServerAction::<CreateReservation>::new();
let validator = Validator::new(); let validator = Validator::new();
let invalid_dlg = DialogOpener::new(); let invalid_dlg = DialogOpener::new();
let result_dlg = DialogOpener::new(); let result_dlg = DialogOpener::new();
let result = cr_reservation.value(); let result = Signal::derive(move || cr_reservation.value().get());
let is_pending = create_rw_signal(None); let is_pending = RwSignal::new(None);
let active_str = create_rw_signal("true".to_string()); let active_str = RwSignal::new("true".to_string());
let get_customer = create_blocking_resource(||(), move |_| get_remembered()); let get_customer = Resource::new_blocking(||(), move |_| get_remembered());
let appearance = create_blocking_resource(||(), move |_| get_appearance()); let customer = RwSignal::new(Customer::default());
let customer = create_rw_signal(Customer::default());
create_effect(move |_| { Effect::new(move |_| {
day.set(Local::now().date_naive()); day.set(Local::now().date_naive());
}); });
@@ -138,37 +187,10 @@ pub fn Public() -> impl IntoView {
slots=slots.write_only() slots=slots.write_only()
captcha_state=is_pending.read_only()/> captcha_state=is_pending.read_only()/>
<Transition fallback=move || view! {<p>{trl("Loading...")}</p> }> <div class="card-body public-form">
{
appearance.get().map(|a| match a {
Ok(a) => {
let app = a.clone();
view! {
<div>
<Show when=move || a.clone().banner.is_some()>
<div class="header_banner">
<h1 class="header_banner">{app.title.clone().unwrap_or_default()}</h1>
</div>
</Show>
<div>
<div class="header_banner_text" inner_html={app.text.unwrap_or("".to_string())}>
</div>
</div>
</div>
}
},
Err(e) => {view! {<div><p>{trl("Error loading data")}</p>
<p>{e.to_string()}</p></div>
}}
})
}
</Transition>
<div class="card-body">
<ActionForm <ActionForm
on:submit=move |ev| { // Capture phase, so the form is validated and the captcha solved before it is dispatched
cr_reservation.set_pending(true); on:submit:capture=move |ev: SubmitEvent| {
if let Ok(mut act) = CreateReservation::from_event(&ev) { if let Ok(mut act) = CreateReservation::from_event(&ev) {
validator.check(act.entity(), &ev); validator.check(act.entity(), &ev);
@@ -185,7 +207,7 @@ pub fn Public() -> impl IntoView {
} }
} }
action=cr_reservation> action=cr_reservation>
<div class="row mb-5"> <div class="row mb-5 public-form-columns">
<div class="col-md"> <div class="col-md">
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
@@ -219,7 +241,7 @@ pub fn Public() -> impl IntoView {
}); });
form_data.get().map(|u| match u { form_data.get().map(|u| match u {
Err(e) => { Err(e) => {
view! {<div>{e.to_string()}</div>}} view! {<div>{e.to_string()}</div>}.into_any()}
Ok(u) => { Ok(u) => {
match u { match u {
ApiResponse::Data(p) => { ApiResponse::Data(p) => {
@@ -252,10 +274,10 @@ pub fn Public() -> impl IntoView {
value={data}/> value={data}/>
</For> </For>
</div> </div>
} }.into_any()
}, },
ApiResponse::Error(s) => { ApiResponse::Error(s) => {
view! {<div>{trl(&s)}</div>} view! {<div>{trl(&s)}</div>}.into_any()
} }
}} }}
}) })
+7 -6
View File
@@ -1,5 +1,5 @@
use chrono::{Local, NaiveDate}; use chrono::{Local, NaiveDate};
use leptos::*; use leptos::prelude::*;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use crate::backend::data::ApiResponse; use crate::backend::data::ApiResponse;
use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter}; use crate::components::modal_box::{DialogOpener, ModalBody, ModalDialog, ModalFooter};
@@ -29,7 +29,8 @@ pub fn res_error(opener: DialogOpener, validator: Validator) -> impl IntoView {
#[component] #[component]
pub fn res_saved( pub fn res_saved(
opener: DialogOpener, opener: DialogOpener,
save_result: RwSignal<Option<Result<ApiResponse<NaiveDate>, ServerFnError>>>, #[prop(into)]
save_result: Signal<Option<Result<ApiResponse<NaiveDate>, ServerFnError>>>,
day: RwSignal<NaiveDate>, day: RwSignal<NaiveDate>,
price: WriteSignal<Decimal>, price: WriteSignal<Decimal>,
slots: WriteSignal<Vec<String>>, slots: WriteSignal<Vec<String>>,
@@ -68,7 +69,7 @@ pub fn res_saved(
</ModalFooter> </ModalFooter>
</ModalDialog> </ModalDialog>
</div> </div>
} }.into_any()
}, },
ApiResponse::Error(err) => { ApiResponse::Error(err) => {
view! { view! {
@@ -88,7 +89,7 @@ pub fn res_saved(
</ModalFooter> </ModalFooter>
</ModalDialog> </ModalDialog>
</div> </div>
} }.into_any()
} }
} }
} }
@@ -110,11 +111,11 @@ pub fn res_saved(
</ModalFooter> </ModalFooter>
</ModalDialog> </ModalDialog>
</div> </div>
} }.into_any()
} }
} }
} else { } else {
view! {<div></div>} view! {<div></div>}.into_any()
} }
}} }}
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::locales::trl; use crate::locales::trl;
use crate::pages::closing_days::ClosingDays; use crate::pages::closing_days::ClosingDays;
use crate::pages::company_info::CompanyInfo; use crate::pages::company_info::CompanyInfo;
+9 -9
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, ResSumWithItems}; use crate::backend::data::{ApiResponse, ResSumWithItems};
use crate::backend::reservation::get_next_reservations; use crate::backend::reservation::get_next_reservations;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -9,9 +9,9 @@ use crate::pages::new_reservations::CancelDialog;
#[component] #[component]
pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView { pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener) -> impl IntoView {
let res = create_blocking_resource( move || app_opener.visible() || cancel_opener.visible(), move |_| get_next_reservations()); let res = Resource::new_blocking( move || app_opener.visible() || cancel_opener.visible(), move |_| get_next_reservations());
let menu = MenuOpener::new(); let menu = MenuOpener::new();
let reservation = create_rw_signal(ResSumWithItems::default()); let reservation = RwSignal::new(ResSumWithItems::default());
view! { view! {
<CancelDialog opener=cancel_opener reservation=reservation.read_only()/> <CancelDialog opener=cancel_opener reservation=reservation.read_only()/>
@@ -22,7 +22,7 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
{move || { {move || {
res.get().map(|r| match r { res.get().map(|r| match r {
Err(e) => { Err(e) => {
view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}} view! {<div>{trl("Something went wrong")}<br/>{e.to_string()}</div>}.into_any()}
Ok(r) => { match r { Ok(r) => { match r {
ApiResponse::Data(r) => { ApiResponse::Data(r) => {
view! { view! {
@@ -40,7 +40,7 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
let:item> let:item>
{item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/> {item.property.name}": "{item.reservation.from.to_string()}" - "{item.reservation.to.to_string()}<br/>
</For> </For>
{trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email}</a>", "{data.customer.phone}<br/> {trl("Customer: ")}{data.customer.full_name}", "<a href={format!("mailto:{}", data.customer.email)}>{data.customer.email.clone()}</a>", "{data.customer.phone}<br/>
{move || { {move || {
let note = data.summary.note.clone(); let note = data.summary.note.clone();
let show = note.is_some() && !note.clone().unwrap().is_empty(); let show = note.is_some() && !note.clone().unwrap().is_empty();
@@ -51,7 +51,7 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
} }
}} }}
{trl("Price: ")}{data.summary.price.to_string()}<br/> {trl("Price: ")}{data.summary.price.to_string()}<br/>
<table width="100%"> <table style="width: 100%"><tbody>
<tr> <tr>
<td width="100%"></td> <td width="100%"></td>
<td> <td>
@@ -72,15 +72,15 @@ pub fn next_reservations(app_opener: DialogOpener, cancel_opener: DialogOpener)
</div> </div>
</td> </td>
</tr> </tr>
</table> </tbody></table>
<hr/> <hr/>
} }
}} }}
</For> </For>
</div> </div>
} }.into_any()
} }
ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}}} ApiResponse::Error(e) => {view! {<div>{trl(&e)}</div>}.into_any()}}
} }
}) })
}} }}
+2 -2
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::User; use crate::backend::data::User;
use crate::backend::user::DeleteUser; use crate::backend::user::DeleteUser;
use crate::components::data_form::QuestionDialog; use crate::components::data_form::QuestionDialog;
@@ -7,7 +7,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn user_delete(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView { pub fn user_delete(user: ReadSignal<User>, opener: DialogOpener) -> impl IntoView {
let del_user = create_server_action::<DeleteUser>(); let del_user = ServerAction::<DeleteUser>::new();
view! { view! {
<QuestionDialog opener=opener action=del_user title="Delete user"> <QuestionDialog opener=opener action=del_user title="Delete user">
+2 -2
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::user::CreateUser; use crate::backend::user::CreateUser;
use crate::components::data_form::DataForm; use crate::components::data_form::DataForm;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -6,7 +6,7 @@ use crate::locales::trl;
#[component] #[component]
pub fn user_edit(opener: DialogOpener) -> impl IntoView { pub fn user_edit(opener: DialogOpener) -> impl IntoView {
let create_usr = create_server_action::<CreateUser>(); let create_usr = ServerAction::<CreateUser>::new();
view! { view! {
<DataForm opener=opener action=create_usr title="Create user"> <DataForm opener=opener action=create_usr title="Create user">
//<input type="hidden" value={move || company.get().id()} name="company[id]"/> //<input type="hidden" value={move || company.get().id()} name="company[id]"/>
+14 -14
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use crate::backend::data::{ApiResponse, User}; use crate::backend::data::{ApiResponse, User};
use crate::backend::user::get_users; use crate::backend::user::get_users;
use crate::components::modal_box::DialogOpener; use crate::components::modal_box::DialogOpener;
@@ -16,10 +16,10 @@ pub fn users() -> impl IntoView {
let profile_editor = DialogOpener::new(); let profile_editor = DialogOpener::new();
let pwd_dialog = DialogOpener::new(); let pwd_dialog = DialogOpener::new();
let delete_dialog = DialogOpener::new(); let delete_dialog = DialogOpener::new();
let users = create_blocking_resource( let users = Resource::new_blocking(
move || editor.visible() || profile_editor.visible() || delete_dialog.visible(), move |_| {get_users()}); move || editor.visible() || profile_editor.visible() || delete_dialog.visible(), move |_| {get_users()});
let (usr, set_usr) = create_signal::<Vec<User>>(vec![]); let (usr, set_usr) = signal::<Vec<User>>(vec![]);
let (profile, set_profile) = create_signal(User::default()); let (profile, set_profile) = signal(User::default());
let is_wide = use_media_query("(min-width: 500px)"); let is_wide = use_media_query("(min-width: 500px)");
view! { view! {
@@ -35,8 +35,8 @@ pub fn users() -> impl IntoView {
<thead> <thead>
<tr> <tr>
<th>{trl("Username")}</th> <th>{trl("Username")}</th>
{move || if is_wide.get() {view! {<th>{trl("Full name")}</th>}} {move || if is_wide.get() {view! {<th>{trl("Full name")}</th>}.into_any()}
else {view! {<th></th>}} } else {view! {<th></th>}.into_any()} }
<th>{trl("Admin")}</th> <th>{trl("Admin")}</th>
<th>{trl("Actions")}</th> <th>{trl("Actions")}</th>
</tr> </tr>
@@ -50,7 +50,7 @@ pub fn users() -> impl IntoView {
e.to_string() e.to_string()
}; };
view! {<tbody class="table-border-bottom-0"> view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl(&err)}</td></tr></tbody>}} <tr><td colspan=4>{trl(&err)}</td></tr></tbody>}.into_any()}
Ok(u) => { Ok(u) => {
match u { match u {
ApiResponse::Data(u) => { ApiResponse::Data(u) => {
@@ -66,11 +66,11 @@ pub fn users() -> impl IntoView {
let full_name = user.full_name.clone().unwrap_or("".to_string()); let full_name = user.full_name.clone().unwrap_or("".to_string());
view! { view! {
<tr> <tr>
<td>{&user.login}</td> <td>{user.login.clone()}</td>
{move || if is_wide.get() {view! {<td>{full_name.clone()}</td>}} {move || if is_wide.get() {view! {<td>{full_name.clone()}</td>}.into_any()}
else {view! {<td></td>}} } else {view! {<td></td>}.into_any()} }
<td>{if user.admin {view! {<i class="bx bx-check"></i>}} <td>{if user.admin {view! {<i class="bx bx-check"></i>}.into_any()}
else {view! {<i></i>}}}</td> else {view! {<i></i>}.into_any()}}</td>
<td> <td>
<div class="dropdown"> <div class="dropdown">
<button type="button" class="btn p-0 dropdown-toggle hide-arrow" <button type="button" class="btn p-0 dropdown-toggle hide-arrow"
@@ -101,10 +101,10 @@ pub fn users() -> impl IntoView {
</tr> </tr>
} }
}/></tbody> }/></tbody>
} }.into_any()
} }
ApiResponse::Error(e) => {view! {<tbody class="table-border-bottom-0"> ApiResponse::Error(e) => {view! {<tbody class="table-border-bottom-0">
<tr><td colspan=4>{trl(&e)}</td></tr></tbody>}} <tr><td colspan=4>{trl(&e)}</td></tr></tbody>}.into_any()}
} }
} }
}) })
+4 -4
View File
@@ -1,4 +1,4 @@
use leptos::*; use leptos::prelude::*;
use validator::Validate; use validator::Validate;
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
@@ -13,9 +13,9 @@ pub struct Validator {
impl Validator { impl Validator {
pub fn new() -> Self { pub fn new() -> Self {
let (valid, set_valid) = create_signal(true); let (valid, set_valid) = signal(true);
//let (message, set_message) = create_signal(None); //let (message, set_message) = signal(None);
let (messages, set_messages) = create_signal(None); let (messages, set_messages) = signal(None);
Self { Self {
//message, //message,
//set_message, //set_message,