Compare commits
6 Commits
fc1a5e3ac7
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
ce28426075
|
|||
|
c46ab8397c
|
|||
|
daa92328c5
|
|||
|
2f0ebc8144
|
|||
|
797ab461ab
|
|||
|
71c4cf1061
|
@@ -1 +0,0 @@
|
|||||||
/home/phundrak/code/web/phundrak.com
|
|
||||||
@@ -7,6 +7,3 @@ APP__EMAIL__USER="username"
|
|||||||
APP__EMAIL__PASSWORD="changeme"
|
APP__EMAIL__PASSWORD="changeme"
|
||||||
APP__EMAIL__RECIPIENT="Recipient <user@example.com>"
|
APP__EMAIL__RECIPIENT="Recipient <user@example.com>"
|
||||||
APP__EMAIL__FROM="Contact Form <noreply@example.com>"
|
APP__EMAIL__FROM="Contact Form <noreply@example.com>"
|
||||||
NUXT_PUBLIC_BACKEND_URL=http://localhost:3100
|
|
||||||
NUXT_PUBLIC_TURNSTILE_SITE_KEY="changeme"
|
|
||||||
NUXT_TURNSTILE_SECRET_KEY="changeme"
|
|
||||||
|
|||||||
36
.envrc
36
.envrc
@@ -12,43 +12,13 @@ dotenv_if_exists
|
|||||||
watch_file flake.nix
|
watch_file flake.nix
|
||||||
watch_file flake.lock
|
watch_file flake.lock
|
||||||
watch_file .envrc.local
|
watch_file .envrc.local
|
||||||
watch_file backend/shell.nix
|
watch_file nix/shell.nix
|
||||||
watch_file frontend/shell.nix
|
|
||||||
|
|
||||||
# Check if .envrc.local exists and contains a shell preference
|
# Check if .envrc.local exists and contains a shell preference
|
||||||
if [[ -f .envrc.local ]]; then
|
if [[ -f .envrc.local ]]; then
|
||||||
source .envrc.local
|
source .envrc.local
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If no shell is specified, prompt the user interactively
|
if ! use flake . --no-pure-eval; then
|
||||||
if [[ -z "$NIX_SHELL_NAME" ]]; then
|
echo "Devenv could not be built. The devenv environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2
|
||||||
echo ""
|
|
||||||
echo "🔧 Available development shells:"
|
|
||||||
echo " 1) frontend - Nuxt.js/Vue development environment"
|
|
||||||
echo " 2) backend - Rust backend development environment"
|
|
||||||
echo ""
|
|
||||||
echo "💡 Tip: Create a .envrc.local file with 'export NIX_SHELL_NAME=frontend' to skip this prompt"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Read user input
|
|
||||||
read -p "Select shell (1 or 2): " choice
|
|
||||||
|
|
||||||
case $choice in
|
|
||||||
1|frontend)
|
|
||||||
NIX_SHELL_NAME=frontend
|
|
||||||
;;
|
|
||||||
2|backend)
|
|
||||||
NIX_SHELL_NAME=backend
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ Invalid choice. Please select 1 or 2."
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "✅ Loading ${NIX_SHELL_NAME} environment..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! use flake ".#${NIX_SHELL_NAME}" --no-pure-eval; then
|
|
||||||
echo "❌ devenv could not be built. The devenv environment was not loaded. Make the necessary changes to flake.nix and hit enter to try again." >&2
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
6
.github/workflows/README.md
vendored
6
.github/workflows/README.md
vendored
@@ -7,7 +7,7 @@ The `publish-docker.yml` workflow automatically builds and publishes Docker imag
|
|||||||
### Triggers and Tagging Strategy
|
### Triggers and Tagging Strategy
|
||||||
|
|
||||||
| Event | Condition | Published Tags | Example |
|
| Event | Condition | Published Tags | Example |
|
||||||
|--------------+-----------------------------+------------------------+-------------------|
|
|--------------|-----------------------------|------------------------|-------------------|
|
||||||
| Tag push | Tag pushed to `main` branch | `latest` + version tag | `latest`, `1.0.0` |
|
| Tag push | Tag pushed to `main` branch | `latest` + version tag | `latest`, `1.0.0` |
|
||||||
| Branch push | Push to `develop` branch | `develop` | `develop` |
|
| Branch push | Push to `develop` branch | `develop` | `develop` |
|
||||||
| Pull request | PR opened or updated | `pr<number>` | `pr12` |
|
| Pull request | PR opened or updated | `pr<number>` | `pr12` |
|
||||||
@@ -18,7 +18,7 @@ The `publish-docker.yml` workflow automatically builds and publishes Docker imag
|
|||||||
Configure these secrets in your repository settings (`Settings` → `Secrets and variables` → `Actions`):
|
Configure these secrets in your repository settings (`Settings` → `Secrets and variables` → `Actions`):
|
||||||
|
|
||||||
| Secret Name | Description | Example Value |
|
| Secret Name | Description | Example Value |
|
||||||
|---------------------+---------------------------------------------+-----------------------------------------|
|
|---------------------|---------------------------------------------|-----------------------------------------|
|
||||||
| `DOCKER_USERNAME` | Username for Docker registry authentication | `phundrak` |
|
| `DOCKER_USERNAME` | Username for Docker registry authentication | `phundrak` |
|
||||||
| `DOCKER_PASSWORD` | Password or token for Docker registry | Personal Access Token (PAT) or password |
|
| `DOCKER_PASSWORD` | Password or token for Docker registry | Personal Access Token (PAT) or password |
|
||||||
| `CACHIX_AUTH_TOKEN` | (Optional) Token for Cachix caching | Your Cachix auth token |
|
| `CACHIX_AUTH_TOKEN` | (Optional) Token for Cachix caching | Your Cachix auth token |
|
||||||
@@ -84,7 +84,7 @@ Cachix is a Nix binary cache that dramatically speeds up builds by caching build
|
|||||||
Configure these in the workflow's `env` section or as repository variables:
|
Configure these in the workflow's `env` section or as repository variables:
|
||||||
|
|
||||||
| Variable | Description | Default Value | Example |
|
| Variable | Description | Default Value | Example |
|
||||||
|--------------------+------------------------------------------------+---------------+--------------------|
|
|--------------------|------------------------------------------------|---------------|--------------------|
|
||||||
| `CACHIX_NAME` | Name of the Cachix cache to use | `devenv` | `phundrak-dot-com` |
|
| `CACHIX_NAME` | Name of the Cachix cache to use | `devenv` | `phundrak-dot-com` |
|
||||||
| `CACHIX_SKIP_PUSH` | Whether to skip pushing artifacts to the cache | `true` | `false` |
|
| `CACHIX_SKIP_PUSH` | Whether to skip pushing artifacts to the cache | `true` | `false` |
|
||||||
|
|
||||||
|
|||||||
13
.github/workflows/publish-docker.yml
vendored
13
.github/workflows/publish-docker.yml
vendored
@@ -12,7 +12,6 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
CACHIX_NAME: devenv
|
CACHIX_NAME: devenv
|
||||||
CACHIX_SKIP_PUSH: true
|
|
||||||
DOCKER_REGISTRY: labs.phundrak.com # Override in repository settings if needed
|
DOCKER_REGISTRY: labs.phundrak.com # Override in repository settings if needed
|
||||||
IMAGE_NAME: phundrak/phundrak-dot-com-backend
|
IMAGE_NAME: phundrak/phundrak-dot-com-backend
|
||||||
|
|
||||||
@@ -38,7 +37,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: '${{ env.CACHIX_NAME }}'
|
name: '${{ env.CACHIX_NAME }}'
|
||||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
skipPush: ${{ env.CACHIX_SKIP_PUSH }}
|
skipPush: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
- name: Coverage
|
||||||
|
run: |
|
||||||
|
nix develop --no-pure-eval --command just coverage
|
||||||
|
|
||||||
|
- name: Sonar analysis
|
||||||
|
uses: SonarSource/sonarqube-scan-action@v6
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
|
||||||
- name: Build Docker image with Nix
|
- name: Build Docker image with Nix
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ skip-clean = true
|
|||||||
target-dir = "coverage"
|
target-dir = "coverage"
|
||||||
output-dir = "coverage"
|
output-dir = "coverage"
|
||||||
fail-under = 60
|
fail-under = 60
|
||||||
exclude-files = ["target/*"]
|
exclude-files = ["target/*", "private/*"]
|
||||||
40
backend/Cargo.lock → Cargo.lock
generated
40
backend/Cargo.lock → Cargo.lock
generated
@@ -134,6 +134,26 @@ dependencies = [
|
|||||||
"fs_extra",
|
"fs_extra",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bakit"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"config",
|
||||||
|
"dotenvy",
|
||||||
|
"governor",
|
||||||
|
"lettre",
|
||||||
|
"poem",
|
||||||
|
"poem-openapi",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"validator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -1573,26 +1593,6 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phundrak-dot-com-backend"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"config",
|
|
||||||
"dotenvy",
|
|
||||||
"governor",
|
|
||||||
"lettre",
|
|
||||||
"poem",
|
|
||||||
"poem-openapi",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"validator",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project"
|
name = "pin-project"
|
||||||
version = "0.4.30"
|
version = "0.4.30"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "phundrak-dot-com-backend"
|
name = "bakit"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
@@ -11,7 +11,7 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
name = "phundrak-dot-com-backend"
|
name = "bakit"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
@@ -1,6 +1,30 @@
|
|||||||
# phundrak.com Backend
|
---
|
||||||
|
include_toc: true
|
||||||
|
gitea: none
|
||||||
|
---
|
||||||
|
|
||||||
The backend for [phundrak.com](https://phundrak.com), built with Rust and the [Poem](https://github.com/poem-web/poem) web framework.
|
<h1 align="center">Bakit</h1>
|
||||||
|
<div align="center">
|
||||||
|
<strong>
|
||||||
|
A backend for my personal website
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://sonar.phundrak.com/dashboard?id=bakit" target="_blank">
|
||||||
|
<img src="https://sonar.phundrak.com/api/project_badges/measure?project=bakit&metric=coverage&token=sqb_bda24bf36825576d6c6b76048044e103339c3c5f" alt="Sonar Coverage" />
|
||||||
|
</a>
|
||||||
|
<a href="https://sonar.phundrak.com/dashboard?id=bakit" target="_blank">
|
||||||
|
<img src="https://sonar.phundrak.com/api/project_badges/measure?project=bakit&metric=alert_status&token=sqb_bda24bf36825576d6c6b76048044e103339c3c5f" alt="Sonar Quality Gate Status" />
|
||||||
|
</a>
|
||||||
|
<a href="#license">
|
||||||
|
<img src="https://img.shields.io/badge/License-AGPL--3.0--only-blue" alt="License" />
|
||||||
|
</a>
|
||||||
|
<a href="https://www.gnu.org/software/emacs/" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Made%20with-GNU%2FEmacs-blueviolet.svg?logo=GNU%20Emacs&logoColor=white" alt="Made with GNU/Emacs" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -117,14 +141,14 @@ For optimized production builds:
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
The compiled binary will be at `target/release/backend`.
|
The compiled binary will be at `target/release/bakit`.
|
||||||
|
|
||||||
**With Nix:**
|
**With Nix:**
|
||||||
|
|
||||||
Build the backend binary:
|
Build the backend binary:
|
||||||
```bash
|
```bash
|
||||||
nix build .#backend
|
nix build .#backend
|
||||||
# Binary available at: ./result/bin/backend
|
# Binary available at: ./result/bin/bakit
|
||||||
```
|
```
|
||||||
|
|
||||||
Build Docker images:
|
Build Docker images:
|
||||||
@@ -137,7 +161,7 @@ nix build .#backendDockerLatest
|
|||||||
|
|
||||||
# Load into Docker
|
# Load into Docker
|
||||||
docker load < result
|
docker load < result
|
||||||
# Image will be available as: localhost/phundrak/backend-rust:latest
|
# Image will be available as: localhost/phundrak/bakit:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
The Nix build ensures reproducible builds with all dependencies pinned.
|
The Nix build ensures reproducible builds with all dependencies pinned.
|
||||||
@@ -178,6 +202,7 @@ just coverage
|
|||||||
- Tests use `get_test_app()` helper for consistent test setup
|
- Tests use `get_test_app()` helper for consistent test setup
|
||||||
- Telemetry is automatically disabled during tests
|
- Telemetry is automatically disabled during tests
|
||||||
- Tests are organized in `#[cfg(test)]` modules within each file
|
- Tests are organized in `#[cfg(test)]` modules within each file
|
||||||
|
- Email sending is tested using lettre's `StubTransport` for mocking SMTP operations
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
@@ -256,12 +281,15 @@ backend/
|
|||||||
│ ├── startup.rs # Application builder, server setup
|
│ ├── startup.rs # Application builder, server setup
|
||||||
│ ├── settings.rs # Configuration management
|
│ ├── settings.rs # Configuration management
|
||||||
│ ├── telemetry.rs # Logging and tracing setup
|
│ ├── telemetry.rs # Logging and tracing setup
|
||||||
|
│ ├── errors.rs # Error type re-exports
|
||||||
│ ├── middleware/ # Custom middleware
|
│ ├── middleware/ # Custom middleware
|
||||||
│ │ ├── mod.rs # Middleware module
|
│ │ ├── mod.rs # Middleware module
|
||||||
│ │ └── rate_limit.rs # Rate limiting middleware
|
│ │ └── rate_limit.rs # Rate limiting middleware
|
||||||
│ └── route/ # API route handlers
|
│ └── route/ # API route handlers
|
||||||
│ ├── mod.rs # Route organization
|
│ ├── mod.rs # Route organization
|
||||||
│ ├── contact.rs # Contact form endpoint
|
│ ├── contact/ # Contact form module
|
||||||
|
│ │ ├── mod.rs # Contact form endpoint
|
||||||
|
│ │ └── errors.rs # Contact form error types
|
||||||
│ ├── health.rs # Health check endpoint
|
│ ├── health.rs # Health check endpoint
|
||||||
│ └── meta.rs # Metadata endpoint
|
│ └── meta.rs # Metadata endpoint
|
||||||
├── settings/ # Configuration files
|
├── settings/ # Configuration files
|
||||||
@@ -308,7 +336,7 @@ Docker images are automatically built and published via GitHub Actions to the co
|
|||||||
Pull and run the latest image:
|
Pull and run the latest image:
|
||||||
```bash
|
```bash
|
||||||
# Pull from Phundrak Labs (labs.phundrak.com)
|
# Pull from Phundrak Labs (labs.phundrak.com)
|
||||||
docker pull labs.phundrak.com/phundrak/phundrak-dot-com-backend:latest
|
docker pull labs.phundrak.com/phundrak/bakit:latest
|
||||||
|
|
||||||
# Run the container
|
# Run the container
|
||||||
docker run -d \
|
docker run -d \
|
||||||
@@ -321,7 +349,7 @@ docker run -d \
|
|||||||
-e APP__EMAIL__PASSWORD=your_password \
|
-e APP__EMAIL__PASSWORD=your_password \
|
||||||
-e APP__EMAIL__FROM="Contact Form <noreply@example.com>" \
|
-e APP__EMAIL__FROM="Contact Form <noreply@example.com>" \
|
||||||
-e APP__EMAIL__RECIPIENT="Admin <admin@example.com>" \
|
-e APP__EMAIL__RECIPIENT="Admin <admin@example.com>" \
|
||||||
labs.phundrak.com/phundrak/phundrak-dot-com-backend:latest
|
labs.phundrak.com/phundrak/bakit:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Image Tags
|
### Available Image Tags
|
||||||
@@ -339,7 +367,7 @@ Build with Nix (recommended for reproducibility):
|
|||||||
```bash
|
```bash
|
||||||
nix build .#backendDockerLatest
|
nix build .#backendDockerLatest
|
||||||
docker load < result
|
docker load < result
|
||||||
docker run -p 3100:3100 localhost/phundrak/backend-rust:latest
|
docker run -p 3100:3100 localhost/phundrak/bakit:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Build with Docker directly:
|
Build with Docker directly:
|
||||||
@@ -355,7 +383,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: labs.phundrak.com/phundrak/phundrak-dot-com-backend:latest
|
image: labs.phundrak.com/phundrak/bakit:latest
|
||||||
ports:
|
ports:
|
||||||
- "3100:3100"
|
- "3100:3100"
|
||||||
environment:
|
environment:
|
||||||
@@ -407,7 +435,7 @@ To use the published images, authenticate with the registry:
|
|||||||
echo $GITHUB_TOKEN | docker login labs.phundrak.com -u USERNAME --password-stdin
|
echo $GITHUB_TOKEN | docker login labs.phundrak.com -u USERNAME --password-stdin
|
||||||
|
|
||||||
# Pull the image
|
# Pull the image
|
||||||
docker pull labs.phundrak.com/phundrak/phundrak-dot-com-backend:latest
|
docker pull labs.phundrak.com/phundrak/bakit:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Required Secrets
|
### Required Secrets
|
||||||
@@ -417,8 +445,8 @@ The workflow requires these GitHub secrets:
|
|||||||
- `DOCKER_PASSWORD` - Registry password or token
|
- `DOCKER_PASSWORD` - Registry password or token
|
||||||
- `CACHIX_AUTH_TOKEN` - (Optional) For Nix build caching
|
- `CACHIX_AUTH_TOKEN` - (Optional) For Nix build caching
|
||||||
|
|
||||||
See [.github/workflows/README.md](../.github/workflows/README.md) for detailed setup instructions.
|
See [.github/workflows/README.md](./.github/workflows/README.md) for detailed setup instructions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0-only - See the root repository for full license information.
|
AGPL-3.0-only - See [LICENSE.md](./LICENSE.md) for full license information.
|
||||||
76
README.org
76
README.org
@@ -1,76 +0,0 @@
|
|||||||
#+title: phundrak.com
|
|
||||||
|
|
||||||
#+html: <a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-Backend-orange.svg?style=flat-square&logo=Rust&logoColor=white" /></a>
|
|
||||||
#+html: <a href="https://nuxt.com/"><img src="https://img.shields.io/badge/Frontend-Nuxt%204-00DC82?logo=Nuxt.js&logoColor=white&style=flat-square"/></a>
|
|
||||||
#+html: <a href="https://vuejs.org/"><img src="https://img.shields.io/badge/Vue-3-42B883?logo=Vue.js&logoColor=white&style=flat-square"/></a>
|
|
||||||
#+html: <a href="https://phundrak.com"><img src="https://img.shields.io/badge/Website-phundrak.com-blue?style=flat-square&logo=buffer" /></a>
|
|
||||||
|
|
||||||
* Introduction
|
|
||||||
This is the repository for my website [[https://phundrak.com][phundrak.com]] which contains the
|
|
||||||
code available on the =main= branch. Code available on the =develop=
|
|
||||||
branch is available at [[https://beta.phundrak.com][beta.phundrak.com]].
|
|
||||||
|
|
||||||
* Architecture
|
|
||||||
The website follows a modern full-stack architecture:
|
|
||||||
|
|
||||||
- *Backend*: Rust using the [[https://github.com/poem-web/poem][Poem]] web framework (located in [[file:backend/][backend/]])
|
|
||||||
- *Frontend*: Nuxt 4 + Vue 3 + TypeScript (located in [[file:frontend/][frontend/]])
|
|
||||||
|
|
||||||
** Backend
|
|
||||||
The backend is written in Rust and provides a RESTful API using the
|
|
||||||
Poem framework with OpenAPI support.
|
|
||||||
|
|
||||||
*** Running the Backend
|
|
||||||
To run the backend in development mode:
|
|
||||||
#+begin_src shell
|
|
||||||
cd backend
|
|
||||||
cargo run
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
To run tests:
|
|
||||||
#+begin_src shell
|
|
||||||
cd backend
|
|
||||||
cargo test
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
For continuous testing and linting during development, use [[https://dystroy.org/bacon/][bacon]]:
|
|
||||||
#+begin_src shell
|
|
||||||
cd backend
|
|
||||||
bacon
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Building the Backend
|
|
||||||
To build the backend for production:
|
|
||||||
#+begin_src shell
|
|
||||||
cd backend
|
|
||||||
cargo build --release
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
The compiled binary will be available at =backend/target/release/backend=.
|
|
||||||
|
|
||||||
** Frontend
|
|
||||||
The frontend is built with Nuxt 4, Vue 3, and TypeScript, providing a
|
|
||||||
modern single-page application experience.
|
|
||||||
|
|
||||||
*** Installing Dependencies
|
|
||||||
First, install the required dependencies using =pnpm=:
|
|
||||||
#+begin_src shell
|
|
||||||
cd frontend
|
|
||||||
pnpm install
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Running the Frontend
|
|
||||||
To run the frontend in development mode:
|
|
||||||
#+begin_src shell
|
|
||||||
cd frontend
|
|
||||||
pnpm dev
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Building the Frontend
|
|
||||||
To build the frontend for production:
|
|
||||||
#+begin_src shell
|
|
||||||
cd frontend
|
|
||||||
pnpm build
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
The compiled version of the website can then be found in =frontend/.output=.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
{
|
|
||||||
inputs,
|
|
||||||
pkgs,
|
|
||||||
system,
|
|
||||||
self,
|
|
||||||
rust-overlay,
|
|
||||||
...
|
|
||||||
}: let
|
|
||||||
rustPlatform = import ./rust-version.nix { inherit rust-overlay inputs system; };
|
|
||||||
in
|
|
||||||
inputs.devenv.lib.mkShell {
|
|
||||||
inherit inputs pkgs;
|
|
||||||
modules = [
|
|
||||||
{
|
|
||||||
devenv.root = let
|
|
||||||
devenvRootFileContent = builtins.readFile "${self}/.devenv-root";
|
|
||||||
in
|
|
||||||
pkgs.lib.mkIf (devenvRootFileContent != "") devenvRootFileContent;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
packages = with rustPlatform.pkgs; [
|
|
||||||
(rustPlatform.version.override {
|
|
||||||
extensions = [
|
|
||||||
"clippy"
|
|
||||||
"rust-src"
|
|
||||||
"rust-analyzer"
|
|
||||||
"rustfmt"
|
|
||||||
];
|
|
||||||
})
|
|
||||||
bacon
|
|
||||||
cargo-deny
|
|
||||||
cargo-shuttle
|
|
||||||
cargo-tarpaulin
|
|
||||||
cargo-watch
|
|
||||||
flyctl
|
|
||||||
just
|
|
||||||
marksman
|
|
||||||
tombi # TOML lsp server
|
|
||||||
];
|
|
||||||
|
|
||||||
services.mailpit = {
|
|
||||||
enable = true;
|
|
||||||
# HTTP interface for viewing emails
|
|
||||||
uiListenAddress = "127.0.0.1:8025";
|
|
||||||
# SMTP server for receiving emails
|
|
||||||
smtpListenAddress = "127.0.0.1:1025";
|
|
||||||
};
|
|
||||||
|
|
||||||
processes.run.exec = "cargo watch -x run";
|
|
||||||
|
|
||||||
enterShell = ''
|
|
||||||
echo "🦀 Rust backend development environment loaded!"
|
|
||||||
echo "📦 Rust version: $(rustc --version)"
|
|
||||||
echo "📦 Cargo version: $(cargo --version)"
|
|
||||||
echo ""
|
|
||||||
echo "Available tools:"
|
|
||||||
echo " - rust-analyzer (LSP)"
|
|
||||||
echo " - clippy (linter)"
|
|
||||||
echo " - rustfmt (formatter)"
|
|
||||||
echo " - bacon (continuous testing/linting)"
|
|
||||||
echo " - cargo-deny (dependency checker)"
|
|
||||||
echo " - cargo-tarpaulin (code coverage)"
|
|
||||||
echo ""
|
|
||||||
echo "📧 Mailpit service:"
|
|
||||||
echo " - SMTP server: 127.0.0.1:1025"
|
|
||||||
echo " - Web UI: http://127.0.0.1:8025"
|
|
||||||
echo ""
|
|
||||||
echo "🚀 Quick start:"
|
|
||||||
echo " Run 'devenv up' to launch:"
|
|
||||||
echo " - Mailpit service (email testing)"
|
|
||||||
echo " - Backend with 'cargo watch -x run' (auto-reload)"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
//! Contact form endpoint for handling user submissions and sending emails.
|
|
||||||
//!
|
|
||||||
//! This module provides functionality to:
|
|
||||||
//! - Validate contact form submissions
|
|
||||||
//! - Detect spam using honeypot fields
|
|
||||||
//! - Send emails via SMTP with various TLS configurations
|
|
||||||
|
|
||||||
use lettre::{
|
|
||||||
Message, SmtpTransport, Transport, message::header::ContentType,
|
|
||||||
transport::smtp::authentication::Credentials,
|
|
||||||
};
|
|
||||||
use poem_openapi::{ApiResponse, Object, OpenApi, payload::Json};
|
|
||||||
use validator::Validate;
|
|
||||||
|
|
||||||
use super::ApiCategory;
|
|
||||||
use crate::settings::{EmailSettings, Starttls};
|
|
||||||
|
|
||||||
impl TryFrom<&EmailSettings> for SmtpTransport {
|
|
||||||
type Error = lettre::transport::smtp::Error;
|
|
||||||
|
|
||||||
fn try_from(settings: &EmailSettings) -> Result<Self, Self::Error> {
|
|
||||||
if settings.tls {
|
|
||||||
// Implicit TLS (SMTPS) - typically port 465
|
|
||||||
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using implicit TLS (SMTPS)");
|
|
||||||
let creds = Credentials::new(settings.user.clone(), settings.password.clone());
|
|
||||||
Ok(Self::relay(&settings.host)?
|
|
||||||
.port(settings.port)
|
|
||||||
.credentials(creds)
|
|
||||||
.build())
|
|
||||||
} else {
|
|
||||||
// STARTTLS or no encryption
|
|
||||||
match settings.starttls {
|
|
||||||
Starttls::Never => {
|
|
||||||
// For local development without TLS
|
|
||||||
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using unencrypted connection");
|
|
||||||
let builder = Self::builder_dangerous(&settings.host).port(settings.port);
|
|
||||||
if settings.user.is_empty() {
|
|
||||||
Ok(builder.build())
|
|
||||||
} else {
|
|
||||||
let creds =
|
|
||||||
Credentials::new(settings.user.clone(), settings.password.clone());
|
|
||||||
Ok(builder.credentials(creds).build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Starttls::Opportunistic | Starttls::Always => {
|
|
||||||
// STARTTLS - typically port 587
|
|
||||||
tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using STARTTLS");
|
|
||||||
let creds = Credentials::new(settings.user.clone(), settings.password.clone());
|
|
||||||
Ok(Self::starttls_relay(&settings.host)?
|
|
||||||
.port(settings.port)
|
|
||||||
.credentials(creds)
|
|
||||||
.build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Object, Validate)]
|
|
||||||
struct ContactRequest {
|
|
||||||
#[validate(length(
|
|
||||||
min = 1,
|
|
||||||
max = "100",
|
|
||||||
message = "Name must be between 1 and 100 characters"
|
|
||||||
))]
|
|
||||||
name: String,
|
|
||||||
#[validate(email(message = "Invalid email address"))]
|
|
||||||
email: String,
|
|
||||||
#[validate(length(
|
|
||||||
min = 10,
|
|
||||||
max = 5000,
|
|
||||||
message = "Message must be between 10 and 5000 characters"
|
|
||||||
))]
|
|
||||||
message: String,
|
|
||||||
/// Honeypot field - should always be empty
|
|
||||||
#[oai(rename = "website")]
|
|
||||||
honeypot: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Object, serde::Deserialize)]
|
|
||||||
struct ContactResponse {
|
|
||||||
success: bool,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<ContactResponse> for Json<ContactResponse> {
|
|
||||||
fn from(value: ContactResponse) -> Self {
|
|
||||||
Self(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(ApiResponse)]
|
|
||||||
enum ContactApiResponse {
|
|
||||||
/// Success
|
|
||||||
#[oai(status = 200)]
|
|
||||||
Ok(Json<ContactResponse>),
|
|
||||||
/// Bad Request - validation failed
|
|
||||||
#[oai(status = 400)]
|
|
||||||
BadRequest(Json<ContactResponse>),
|
|
||||||
/// Too Many Requests - rate limit exceeded
|
|
||||||
#[oai(status = 429)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
TooManyRequests,
|
|
||||||
/// Internal Server Error
|
|
||||||
#[oai(status = 500)]
|
|
||||||
InternalServerError(Json<ContactResponse>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// API for handling contact form submissions and sending emails.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ContactApi {
|
|
||||||
settings: EmailSettings,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<EmailSettings> for ContactApi {
|
|
||||||
fn from(settings: EmailSettings) -> Self {
|
|
||||||
Self { settings }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[OpenApi(tag = "ApiCategory::Contact")]
|
|
||||||
impl ContactApi {
|
|
||||||
/// Submit a contact form
|
|
||||||
///
|
|
||||||
/// Send a message through the contact form. Rate limited to prevent spam.
|
|
||||||
#[oai(path = "/contact", method = "post")]
|
|
||||||
async fn submit_contact(
|
|
||||||
&self,
|
|
||||||
body: Json<ContactRequest>,
|
|
||||||
remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>,
|
|
||||||
) -> ContactApiResponse {
|
|
||||||
let body = body.0;
|
|
||||||
if body.honeypot.is_some() {
|
|
||||||
tracing::event!(target: "backend::contact", tracing::Level::INFO, "Honeypot triggered, rejecting request silently. IP: {}", remote_addr.map_or_else(|| "No remote address found".to_owned(), |ip| ip.0.to_string()));
|
|
||||||
return ContactApiResponse::Ok(
|
|
||||||
ContactResponse {
|
|
||||||
success: true,
|
|
||||||
message: "Message sent successfully, but not really, you bot".to_owned(),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Err(e) = body.validate() {
|
|
||||||
return ContactApiResponse::BadRequest(
|
|
||||||
ContactResponse {
|
|
||||||
success: false,
|
|
||||||
message: format!("Validation error: {e}"),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match self.send_email(&body).await {
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::event!(target: "backend::contact", tracing::Level::INFO, "Message sent successfully from: {}", body.email);
|
|
||||||
ContactApiResponse::Ok(
|
|
||||||
ContactResponse {
|
|
||||||
success: true,
|
|
||||||
message: "Message sent successfully".to_owned(),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::event!(target: "backend::contact", tracing::Level::ERROR, "Failed to send email: {}", e);
|
|
||||||
ContactApiResponse::InternalServerError(
|
|
||||||
ContactResponse {
|
|
||||||
success: false,
|
|
||||||
message: "Failed to send message. Please try again later.".to_owned(),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_email(&self, request: &ContactRequest) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let email_body = format!(
|
|
||||||
r"New contact form submission:
|
|
||||||
|
|
||||||
Name: {}
|
|
||||||
Email: {},
|
|
||||||
|
|
||||||
Message:
|
|
||||||
{}",
|
|
||||||
request.name, request.email, request.message
|
|
||||||
);
|
|
||||||
tracing::event!(target: "email", tracing::Level::DEBUG, "Sending email content: {}", email_body);
|
|
||||||
let email = Message::builder()
|
|
||||||
.from(self.settings.from.parse()?)
|
|
||||||
.reply_to(format!("{} <{}>", request.name, request.email).parse()?)
|
|
||||||
.to(self.settings.recipient.parse()?)
|
|
||||||
.subject(format!("Contact Form: {}", request.name))
|
|
||||||
.header(ContentType::TEXT_PLAIN)
|
|
||||||
.body(email_body)?;
|
|
||||||
tracing::event!(target: "email", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}"));
|
|
||||||
|
|
||||||
let mailer = SmtpTransport::try_from(&self.settings)?;
|
|
||||||
mailer.send(&email)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Tests for ContactRequest validation
|
|
||||||
#[test]
|
|
||||||
fn contact_request_valid() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "John Doe".to_string(),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "This is a test message that is long enough.".to_string(),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_name_too_short() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: String::new(),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "This is a test message that is long enough.".to_string(),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_name_too_long() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "a".repeat(101),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "This is a test message that is long enough.".to_string(),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_name_at_max_length() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "a".repeat(100),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "This is a test message that is long enough.".to_string(),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_invalid_email() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "John Doe".to_string(),
|
|
||||||
email: "not-an-email".to_string(),
|
|
||||||
message: "This is a test message that is long enough.".to_string(),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_message_too_short() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "John Doe".to_string(),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "Short".to_string(),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_message_too_long() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "John Doe".to_string(),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "a".repeat(5001),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_message_at_min_length() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "John Doe".to_string(),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "a".repeat(10),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contact_request_message_at_max_length() {
|
|
||||||
let request = ContactRequest {
|
|
||||||
name: "John Doe".to_string(),
|
|
||||||
email: "john@example.com".to_string(),
|
|
||||||
message: "a".repeat(5000),
|
|
||||||
honeypot: None,
|
|
||||||
};
|
|
||||||
assert!(request.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tests for SmtpTransport TryFrom implementation
|
|
||||||
#[test]
|
|
||||||
fn smtp_transport_implicit_tls() {
|
|
||||||
let settings = EmailSettings {
|
|
||||||
host: "smtp.example.com".to_string(),
|
|
||||||
port: 465,
|
|
||||||
user: "user@example.com".to_string(),
|
|
||||||
password: "password".to_string(),
|
|
||||||
from: "from@example.com".to_string(),
|
|
||||||
recipient: "to@example.com".to_string(),
|
|
||||||
tls: true,
|
|
||||||
starttls: Starttls::Never,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = SmtpTransport::try_from(&settings);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smtp_transport_starttls_always() {
|
|
||||||
let settings = EmailSettings {
|
|
||||||
host: "smtp.example.com".to_string(),
|
|
||||||
port: 587,
|
|
||||||
user: "user@example.com".to_string(),
|
|
||||||
password: "password".to_string(),
|
|
||||||
from: "from@example.com".to_string(),
|
|
||||||
recipient: "to@example.com".to_string(),
|
|
||||||
tls: false,
|
|
||||||
starttls: Starttls::Always,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = SmtpTransport::try_from(&settings);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smtp_transport_starttls_opportunistic() {
|
|
||||||
let settings = EmailSettings {
|
|
||||||
host: "smtp.example.com".to_string(),
|
|
||||||
port: 587,
|
|
||||||
user: "user@example.com".to_string(),
|
|
||||||
password: "password".to_string(),
|
|
||||||
from: "from@example.com".to_string(),
|
|
||||||
recipient: "to@example.com".to_string(),
|
|
||||||
tls: false,
|
|
||||||
starttls: Starttls::Opportunistic,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = SmtpTransport::try_from(&settings);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smtp_transport_no_encryption_with_credentials() {
|
|
||||||
let settings = EmailSettings {
|
|
||||||
host: "localhost".to_string(),
|
|
||||||
port: 1025,
|
|
||||||
user: "user@example.com".to_string(),
|
|
||||||
password: "password".to_string(),
|
|
||||||
from: "from@example.com".to_string(),
|
|
||||||
recipient: "to@example.com".to_string(),
|
|
||||||
tls: false,
|
|
||||||
starttls: Starttls::Never,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = SmtpTransport::try_from(&settings);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smtp_transport_no_encryption_no_credentials() {
|
|
||||||
let settings = EmailSettings {
|
|
||||||
host: "localhost".to_string(),
|
|
||||||
port: 1025,
|
|
||||||
user: String::new(),
|
|
||||||
password: String::new(),
|
|
||||||
from: "from@example.com".to_string(),
|
|
||||||
recipient: "to@example.com".to_string(),
|
|
||||||
tls: false,
|
|
||||||
starttls: Starttls::Never,
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = SmtpTransport::try_from(&settings);
|
|
||||||
assert!(result.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Integration tests for contact API endpoint
|
|
||||||
#[tokio::test]
|
|
||||||
async fn contact_endpoint_honeypot_triggered() {
|
|
||||||
let app = crate::get_test_app();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "Bot Name",
|
|
||||||
"email": "bot@example.com",
|
|
||||||
"message": "This is a spam message from a bot.",
|
|
||||||
"website": "http://spam.com"
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = cli.post("/api/contact").body_json(&body).send().await;
|
|
||||||
resp.assert_status_is_ok();
|
|
||||||
|
|
||||||
let json_text = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
let json: ContactResponse = serde_json::from_str(&json_text).unwrap();
|
|
||||||
assert!(json.success);
|
|
||||||
assert!(json.message.contains("not really"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn contact_endpoint_validation_error_empty_name() {
|
|
||||||
let app = crate::get_test_app();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "",
|
|
||||||
"email": "test@example.com",
|
|
||||||
"message": "This is a valid message that is long enough."
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = cli.post("/api/contact").body_json(&body).send().await;
|
|
||||||
resp.assert_status(poem::http::StatusCode::BAD_REQUEST);
|
|
||||||
|
|
||||||
let json_text = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
let json: ContactResponse = serde_json::from_str(&json_text).unwrap();
|
|
||||||
assert!(!json.success);
|
|
||||||
assert!(json.message.contains("Validation error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn contact_endpoint_validation_error_invalid_email() {
|
|
||||||
let app = crate::get_test_app();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "Test User",
|
|
||||||
"email": "not-an-email",
|
|
||||||
"message": "This is a valid message that is long enough."
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = cli.post("/api/contact").body_json(&body).send().await;
|
|
||||||
resp.assert_status(poem::http::StatusCode::BAD_REQUEST);
|
|
||||||
|
|
||||||
let json_text = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
let json: ContactResponse = serde_json::from_str(&json_text).unwrap();
|
|
||||||
assert!(!json.success);
|
|
||||||
assert!(json.message.contains("Validation error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn contact_endpoint_validation_error_message_too_short() {
|
|
||||||
let app = crate::get_test_app();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "Test User",
|
|
||||||
"email": "test@example.com",
|
|
||||||
"message": "Short"
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = cli.post("/api/contact").body_json(&body).send().await;
|
|
||||||
resp.assert_status(poem::http::StatusCode::BAD_REQUEST);
|
|
||||||
|
|
||||||
let json_text = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
let json: ContactResponse = serde_json::from_str(&json_text).unwrap();
|
|
||||||
assert!(!json.success);
|
|
||||||
assert!(json.message.contains("Validation error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn contact_endpoint_validation_error_name_too_long() {
|
|
||||||
let app = crate::get_test_app();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "a".repeat(101),
|
|
||||||
"email": "test@example.com",
|
|
||||||
"message": "This is a valid message that is long enough."
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = cli.post("/api/contact").body_json(&body).send().await;
|
|
||||||
resp.assert_status(poem::http::StatusCode::BAD_REQUEST);
|
|
||||||
|
|
||||||
let json_text = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
let json: ContactResponse = serde_json::from_str(&json_text).unwrap();
|
|
||||||
assert!(!json.success);
|
|
||||||
assert!(json.message.contains("Validation error"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn contact_endpoint_validation_error_message_too_long() {
|
|
||||||
let app = crate::get_test_app();
|
|
||||||
let cli = poem::test::TestClient::new(app);
|
|
||||||
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"name": "Test User",
|
|
||||||
"email": "test@example.com",
|
|
||||||
"message": "a".repeat(5001)
|
|
||||||
});
|
|
||||||
|
|
||||||
let resp = cli.post("/api/contact").body_json(&body).send().await;
|
|
||||||
resp.assert_status(poem::http::StatusCode::BAD_REQUEST);
|
|
||||||
|
|
||||||
let json_text = resp.0.into_body().into_string().await.unwrap();
|
|
||||||
let json: ContactResponse = serde_json::from_str(&json_text).unwrap();
|
|
||||||
assert!(!json.success);
|
|
||||||
assert!(json.message.contains("Validation error"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
47
flake.lock
generated
47
flake.lock
generated
@@ -68,11 +68,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1761922975,
|
"lastModified": 1763136231,
|
||||||
"narHash": "sha256-j4EB5ku/gDm7h7W7A+k70RYj5nUiW/l9wQtXMJUD2hg=",
|
"narHash": "sha256-QVtIjPSQ/xVhuXSSENYOYZPfrjjc/W/djuxcJyKxGTw=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "c9f0b47815a4895fadac87812de8a4de27e0ace1",
|
"rev": "4b8c2bbdb4e01ef8c4093ee1224fe21ed5ea1a5e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -81,6 +81,18 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"devenv-root": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"narHash": "sha256-d6xi4mKdjkX2JFicDIv5niSzpyI0m/Hnm8GGAIU04kY=",
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "file",
|
||||||
|
"url": "file:///dev/null"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fenix": {
|
"fenix": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
@@ -140,6 +152,24 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flakeCompat": {
|
"flakeCompat": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
@@ -264,9 +294,10 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"alejandra": "alejandra",
|
"alejandra": "alejandra",
|
||||||
"devenv": "devenv",
|
"devenv": "devenv",
|
||||||
|
"devenv-root": "devenv-root",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"rust-overlay": "rust-overlay",
|
"rust-overlay": "rust-overlay"
|
||||||
"systems": "systems"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-analyzer-src": {
|
"rust-analyzer-src": {
|
||||||
@@ -293,11 +324,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1762223900,
|
"lastModified": 1763174172,
|
||||||
"narHash": "sha256-caxpESVH71mdrdihYvQZ9rTZPZqW0GyEG9un7MgpyRM=",
|
"narHash": "sha256-u6dcvXk2K6eYVYhmfiN3xmhIf3yUo5KPwm79UOD37Jo=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "cfe1598d69a42a5edb204770e71b8df77efef2c3",
|
"rev": "89af6762b01409edbb595888a69311e8e5954110",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
35
flake.nix
35
flake.nix
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
|
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
|
||||||
systems.url = "github:nix-systems/default";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
alejandra = {
|
alejandra = {
|
||||||
url = "github:kamadorueda/alejandra/4.0.0";
|
url = "github:kamadorueda/alejandra/4.0.0";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
devenv-root = {
|
||||||
|
url = "file+file:///dev/null";
|
||||||
|
flake = false;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
nixConfig = {
|
nixConfig = {
|
||||||
@@ -30,27 +34,26 @@
|
|||||||
outputs = {
|
outputs = {
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
devenv,
|
flake-utils,
|
||||||
systems,
|
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
alejandra,
|
alejandra,
|
||||||
...
|
...
|
||||||
} @ inputs: let
|
} @ inputs:
|
||||||
forEachSystem = nixpkgs.lib.genAttrs (import systems);
|
flake-utils.lib.eachDefaultSystem (
|
||||||
in {
|
|
||||||
formatter = forEachSystem (system: alejandra.defaultPackage.${system});
|
|
||||||
packages = forEachSystem (system: import ./backend/nix/package.nix { inherit rust-overlay inputs system; });
|
|
||||||
devShells = forEachSystem (
|
|
||||||
system: let
|
system: let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
overlays = [(import rust-overlay)];
|
||||||
in {
|
pkgs = import nixpkgs {inherit system overlays;};
|
||||||
backend = import ./backend/nix/shell.nix {
|
rustVersion = pkgs.rust-bin.stable.latest.default;
|
||||||
inherit inputs pkgs system self rust-overlay;
|
rustPlatform = pkgs.makeRustPlatform {
|
||||||
|
cargo = rustVersion;
|
||||||
|
rustc = rustVersion;
|
||||||
};
|
};
|
||||||
frontend = import ./frontend/shell.nix {
|
in {
|
||||||
inherit inputs pkgs self;
|
formatter = alejandra.defaultPackage.${system};
|
||||||
|
packages = import ./nix/package.nix {inherit pkgs rustPlatform;};
|
||||||
|
devShell = import ./nix/shell.nix {
|
||||||
|
inherit inputs pkgs self rustVersion;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"arrowParens": "always",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"experimentalOperatorPosition": "start",
|
|
||||||
"experimentalTernaries": true,
|
|
||||||
"jsxSingleQuote": true,
|
|
||||||
"printWidth": 120,
|
|
||||||
"proseWrap": "preserve",
|
|
||||||
"quoteProps": "as-needed",
|
|
||||||
"semi": true,
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"useTabs": false,
|
|
||||||
"vueIndentScriptAndStyle": false
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
#+title: phundrak.com frontend
|
|
||||||
#+author: Lucien Cartier-Tilet
|
|
||||||
#+email: lucien@phundrak.com
|
|
||||||
|
|
||||||
This is the frontend of =phundrak.com=, written with Nuxt.
|
|
||||||
|
|
||||||
* Setup
|
|
||||||
|
|
||||||
** Environment
|
|
||||||
*** Nix Environment
|
|
||||||
If you use Nix, you can set up your environment using the [[file:flake.nix][=flake.nix=]]
|
|
||||||
file, which will give you the exact same development environment as I
|
|
||||||
use.
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
nix develop
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
If you have [[https://direnv.net/][=direnv=]] installed, you can simply use it to automatically
|
|
||||||
enable this environment. However, I *strongly* recommend you to read the
|
|
||||||
content of the =flake.nix= file before doing so, as you should with any
|
|
||||||
Nix-defined environment you did not create.
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
direnv allow .
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*** Required Tools
|
|
||||||
To be able to work on this project, you need a Javascript package
|
|
||||||
manager, such as:
|
|
||||||
- =npm=
|
|
||||||
- =pnpm= (recommended)
|
|
||||||
- =yarn=
|
|
||||||
- =bun=
|
|
||||||
|
|
||||||
In my case, I use pnpm.
|
|
||||||
|
|
||||||
You can skip this if you are already using my Nix environment.
|
|
||||||
|
|
||||||
** Dependencies
|
|
||||||
Once you have your environment ready, you can now install the
|
|
||||||
project’s dependencies.
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
# npm
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun install
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
* Running the Project
|
|
||||||
You are now ready to start the development server on
|
|
||||||
=http://localhost:3000=.
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
# npm
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn dev
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run dev
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
* Production
|
|
||||||
Once you are satisfied with the project, you can build the application in production mode.
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
# npm
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run build
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
You can preview locally the production build too.
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
# npm
|
|
||||||
npm run preview
|
|
||||||
|
|
||||||
# pnpm
|
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# yarn
|
|
||||||
yarn preview
|
|
||||||
|
|
||||||
# bun
|
|
||||||
bun run preview
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
Check out the [[https://nuxt.com/docs/getting-started/deployment][deployment documentation]] for more information.
|
|
||||||
|
|
||||||
* Known Issues
|
|
||||||
** =better-sqlite3= self-registration error
|
|
||||||
If you encounter an error stating that =better-sqlite3= does not
|
|
||||||
self-register when running =pnpm run dev=, this is typically caused by
|
|
||||||
the native module being compiled for a different Node.js version.
|
|
||||||
|
|
||||||
*Solution:* Rebuild the native module for your current Node.js version:
|
|
||||||
|
|
||||||
#+begin_src bash
|
|
||||||
# Rebuild just better-sqlite3
|
|
||||||
pnpm rebuild better-sqlite3
|
|
||||||
|
|
||||||
# Or rebuild all native modules
|
|
||||||
pnpm rebuild
|
|
||||||
|
|
||||||
# Or reinstall everything (nuclear option)
|
|
||||||
rm -rf node_modules
|
|
||||||
pnpm install
|
|
||||||
#+end_src
|
|
||||||
|
|
||||||
*Why this happens:* =better-sqlite3= contains native C++ code that
|
|
||||||
needs to be compiled for each specific Node.js version. When you
|
|
||||||
update Node.js or switch between versions, native modules need to be
|
|
||||||
rebuilt.
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UApp :locale="locales[locale]">
|
|
||||||
<AppNavbar />
|
|
||||||
<UMain>
|
|
||||||
<NuxtPage />
|
|
||||||
</UMain>
|
|
||||||
<AppFooter />
|
|
||||||
</UApp>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import * as locales from '@nuxt/ui/locale';
|
|
||||||
const { locale } = useI18n();
|
|
||||||
const lang = computed(() => locales[locale.value].code);
|
|
||||||
const dir = computed(() => locales[locale.value].dir);
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
htmlAttrs: {
|
|
||||||
dir,
|
|
||||||
lang,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
:root {
|
|
||||||
--text-50: oklch(96.68% 0.005 95.1);
|
|
||||||
--text-100: oklch(93.31% 0.012 96.43);
|
|
||||||
--text-200: oklch(86.46% 0.023 98.68);
|
|
||||||
--text-300: oklch(79.55% 0.036 98.17);
|
|
||||||
--text-400: oklch(72.45% 0.047 99.12);
|
|
||||||
--text-500: oklch(65.27% 0.06 98.88);
|
|
||||||
--text-600: oklch(55.54% 0.05 99.33);
|
|
||||||
--text-700: oklch(45.43% 0.04 98.55);
|
|
||||||
--text-800: oklch(34.63% 0.028 99.26);
|
|
||||||
--text-900: oklch(22.99% 0.017 97.01);
|
|
||||||
--text: oklch(17.69% 0.01 97.92);
|
|
||||||
--text-950: oklch(16.34% 0.008 95.54);
|
|
||||||
|
|
||||||
--background: oklch(97.33% 0.007 88.64);
|
|
||||||
--background-50: oklch(96.7% 0.008 91.48);
|
|
||||||
--background-100: oklch(93.46% 0.017 88);
|
|
||||||
--background-200: oklch(86.85% 0.034 88.07);
|
|
||||||
--background-300: oklch(80.17% 0.051 88.07);
|
|
||||||
--background-400: oklch(73.62% 0.069 89.26);
|
|
||||||
--background-500: oklch(66.8% 0.085 88.59);
|
|
||||||
--background-600: oklch(56.88% 0.071 88.9);
|
|
||||||
--background-700: oklch(46.26% 0.056 87.6);
|
|
||||||
--background-800: oklch(35.24% 0.04 87.71);
|
|
||||||
--background-900: oklch(23.27% 0.023 87.9);
|
|
||||||
--background-950: oklch(16.86% 0.012 91.89);
|
|
||||||
|
|
||||||
--primary-50: oklch(97.22% 0.012 96.42);
|
|
||||||
--primary-100: oklch(94.41% 0.025 97.12);
|
|
||||||
--primary-200: oklch(88.75% 0.05 98.42);
|
|
||||||
--primary-300: oklch(83.15% 0.074 98.36);
|
|
||||||
--primary-400: oklch(77.55% 0.097 98.29);
|
|
||||||
--primary: oklch(74.12% 0.109 98.34);
|
|
||||||
--primary-500: oklch(72% 0.116 97.93);
|
|
||||||
--primary-600: oklch(61.14% 0.097 98.09);
|
|
||||||
--primary-700: oklch(49.77% 0.077 98.34);
|
|
||||||
--primary-800: oklch(37.71% 0.055 98.79);
|
|
||||||
--primary-900: oklch(24.68% 0.033 97.74);
|
|
||||||
--primary-950: oklch(17.23% 0.018 97.53);
|
|
||||||
|
|
||||||
--secondary-50: oklch(97.69% 0.019 100.12);
|
|
||||||
--secondary-100: oklch(95.28% 0.036 96.71);
|
|
||||||
--secondary-200: oklch(90.57% 0.07 97.74);
|
|
||||||
--secondary-300: oklch(86.23% 0.103 98.42);
|
|
||||||
--secondary: oklch(83.86% 0.116 98.04);
|
|
||||||
--secondary-400: oklch(81.72% 0.129 98.31);
|
|
||||||
--secondary-500: oklch(77.44% 0.146 97.07);
|
|
||||||
--secondary-600: oklch(65.69% 0.123 97.5);
|
|
||||||
--secondary-700: oklch(53.48% 0.099 97.52);
|
|
||||||
--secondary-800: oklch(40.18% 0.072 97.19);
|
|
||||||
--secondary-900: oklch(26.04% 0.043 96.76);
|
|
||||||
--secondary-950: oklch(18.17% 0.026 97.52);
|
|
||||||
|
|
||||||
--accent-50: oklch(97.77% 0.019 96.86);
|
|
||||||
--accent-100: oklch(95.53% 0.039 97.44);
|
|
||||||
--accent-200: oklch(91.16% 0.076 97.81);
|
|
||||||
--accent-300: oklch(86.92% 0.11 97.94);
|
|
||||||
--accent: oklch(82.74% 0.136 98);
|
|
||||||
--accent-400: oklch(82.74% 0.136 98);
|
|
||||||
--accent-500: oklch(78.81% 0.152 96.76);
|
|
||||||
--accent-600: oklch(66.8% 0.128 96.97);
|
|
||||||
--accent-700: oklch(54.33% 0.103 96.65);
|
|
||||||
--accent-800: oklch(40.98% 0.076 96.95);
|
|
||||||
--accent-900: oklch(26.42% 0.045 97.53);
|
|
||||||
--accent-950: oklch(18.44% 0.029 102.49);
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
--text-50: oklch(16.34% 0.008 95.54);
|
|
||||||
--text: oklch(96.05% 0.007 97.35);
|
|
||||||
--text-100: oklch(22.99% 0.017 97.01);
|
|
||||||
--text-200: oklch(34.63% 0.028 99.26);
|
|
||||||
--text-300: oklch(45.43% 0.04 98.55);
|
|
||||||
--text-400: oklch(55.54% 0.05 99.33);
|
|
||||||
--text-500: oklch(65.27% 0.06 98.88);
|
|
||||||
--text-600: oklch(72.45% 0.047 99.12);
|
|
||||||
--text-700: oklch(79.55% 0.036 98.17);
|
|
||||||
--text-800: oklch(86.46% 0.023 98.68);
|
|
||||||
--text-900: oklch(93.31% 0.012 96.43);
|
|
||||||
--text-950: oklch(96.68% 0.005 95.1);
|
|
||||||
|
|
||||||
--background-50: oklch(16.86% 0.012 91.89);
|
|
||||||
--background-100: oklch(23.27% 0.023 87.9);
|
|
||||||
--background-200: oklch(35.24% 0.04 87.71);
|
|
||||||
--background-300: oklch(46.26% 0.056 87.6);
|
|
||||||
--background-400: oklch(56.88% 0.071 88.9);
|
|
||||||
--background-500: oklch(66.8% 0.085 88.59);
|
|
||||||
--background-600: oklch(73.62% 0.069 89.26);
|
|
||||||
--background-700: oklch(80.17% 0.051 88.07);
|
|
||||||
--background-800: oklch(86.85% 0.034 88.07);
|
|
||||||
--background-900: oklch(93.46% 0.017 88);
|
|
||||||
--background-950: oklch(96.7% 0.008 91.48);
|
|
||||||
--background: oklch(15.48% 0.011 89.86);
|
|
||||||
|
|
||||||
--primary-50: oklch(17.23% 0.018 97.53);
|
|
||||||
--primary-100: oklch(24.68% 0.033 97.74);
|
|
||||||
--primary-200: oklch(37.71% 0.055 98.79);
|
|
||||||
--primary-300: oklch(49.77% 0.077 98.34);
|
|
||||||
--primary-400: oklch(61.14% 0.097 98.09);
|
|
||||||
--primary: oklch(67.74% 0.108 98.2);
|
|
||||||
--primary-500: oklch(72% 0.116 97.93);
|
|
||||||
--primary-600: oklch(77.55% 0.097 98.29);
|
|
||||||
--primary-700: oklch(83.15% 0.074 98.36);
|
|
||||||
--primary-800: oklch(88.75% 0.05 98.42);
|
|
||||||
--primary-900: oklch(94.41% 0.025 97.12);
|
|
||||||
--primary-950: oklch(97.22% 0.012 96.42);
|
|
||||||
|
|
||||||
--secondary-50: oklch(18.17% 0.026 97.52);
|
|
||||||
--secondary-100: oklch(26.04% 0.043 96.76);
|
|
||||||
--secondary-200: oklch(40.18% 0.072 97.19);
|
|
||||||
--secondary-300: oklch(53.48% 0.099 97.52);
|
|
||||||
--secondary: oklch(59.61% 0.111 97.84);
|
|
||||||
--secondary-400: oklch(65.69% 0.123 97.5);
|
|
||||||
--secondary-500: oklch(77.44% 0.146 97.07);
|
|
||||||
--secondary-600: oklch(81.72% 0.129 98.31);
|
|
||||||
--secondary-700: oklch(86.23% 0.103 98.42);
|
|
||||||
--secondary-800: oklch(90.57% 0.07 97.74);
|
|
||||||
--secondary-900: oklch(95.28% 0.036 96.71);
|
|
||||||
--secondary-950: oklch(97.69% 0.019 100.12);
|
|
||||||
|
|
||||||
--accent-50: oklch(18.44% 0.029 102.49);
|
|
||||||
--accent-100: oklch(26.42% 0.045 97.53);
|
|
||||||
--accent-200: oklch(40.98% 0.076 96.95);
|
|
||||||
--accent-300: oklch(54.33% 0.103 96.65);
|
|
||||||
--accent: oklch(66.8% 0.128 96.97);
|
|
||||||
--accent-400: oklch(66.8% 0.128 96.97);
|
|
||||||
--accent-500: oklch(78.81% 0.152 96.76);
|
|
||||||
--accent-600: oklch(82.74% 0.136 98);
|
|
||||||
--accent-700: oklch(86.92% 0.11 97.94);
|
|
||||||
--accent-800: oklch(91.16% 0.076 97.81);
|
|
||||||
--accent-900: oklch(95.53% 0.039 97.44);
|
|
||||||
--accent-950: oklch(97.77% 0.019 96.86);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
@import '@nuxt/ui';
|
|
||||||
@import './colors.css';
|
|
||||||
@import './ui/index.css';
|
|
||||||
@import './tailwind.css';
|
|
||||||
|
|
||||||
@source "../../../content/**/*";
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
@import 'tailwindcss';
|
|
||||||
@theme {
|
|
||||||
--color-text-50: var(--text-50);
|
|
||||||
--color-text: var(--text);
|
|
||||||
--color-text-100: var(--text-100);
|
|
||||||
--color-text-200: var(--text-200);
|
|
||||||
--color-text-300: var(--text-300);
|
|
||||||
--color-text-400: var(--text-400);
|
|
||||||
--color-text-500: var(--text-500);
|
|
||||||
--color-text-600: var(--text-600);
|
|
||||||
--color-text-700: var(--text-700);
|
|
||||||
--color-text-800: var(--text-800);
|
|
||||||
--color-text-900: var(--text-900);
|
|
||||||
--color-text-950: var(--text-950);
|
|
||||||
|
|
||||||
--color-background-50: var(--background-50);
|
|
||||||
--color-background-100: var(--background-100);
|
|
||||||
--color-background-200: var(--background-200);
|
|
||||||
--color-background-300: var(--background-300);
|
|
||||||
--color-background-400: var(--background-400);
|
|
||||||
--color-background-500: var(--background-500);
|
|
||||||
--color-background-600: var(--background-600);
|
|
||||||
--color-background-700: var(--background-700);
|
|
||||||
--color-background-800: var(--background-800);
|
|
||||||
--color-background-900: var(--background-900);
|
|
||||||
--color-background-950: var(--background-950);
|
|
||||||
--color-background: var(--background);
|
|
||||||
|
|
||||||
--color-primary-50: var(--primary-50);
|
|
||||||
--color-primary-100: var(--primary-100);
|
|
||||||
--color-primary-200: var(--primary-200);
|
|
||||||
--color-primary-300: var(--primary-300);
|
|
||||||
--color-primary-400: var(--primary-400);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-500: var(--primary-500);
|
|
||||||
--color-primary-600: var(--primary-600);
|
|
||||||
--color-primary-700: var(--primary-700);
|
|
||||||
--color-primary-800: var(--primary-800);
|
|
||||||
--color-primary-900: var(--primary-900);
|
|
||||||
--color-primary-950: var(--primary-950);
|
|
||||||
|
|
||||||
--color-secondary-50: var(--secondary-50);
|
|
||||||
--color-secondary-100: var(--secondary-100);
|
|
||||||
--color-secondary-200: var(--secondary-200);
|
|
||||||
--color-secondary-300: var(--secondary-300);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-400: var(--secondary-400);
|
|
||||||
--color-secondary-500: var(--secondary-500);
|
|
||||||
--color-secondary-600: var(--secondary-600);
|
|
||||||
--color-secondary-700: var(--secondary-700);
|
|
||||||
--color-secondary-800: var(--secondary-800);
|
|
||||||
--color-secondary-900: var(--secondary-900);
|
|
||||||
--color-secondary-950: var(--secondary-950);
|
|
||||||
|
|
||||||
--color-accent-50: var(--accent-50);
|
|
||||||
--color-accent-100: var(--accent-100);
|
|
||||||
--color-accent-200: var(--accent-200);
|
|
||||||
--color-accent-300: var(--accent-300);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-400: var(--accent-400);
|
|
||||||
--color-accent-500: var(--accent-500);
|
|
||||||
--color-accent-600: var(--accent-600);
|
|
||||||
--color-accent-700: var(--accent-700);
|
|
||||||
--color-accent-800: var(--accent-800);
|
|
||||||
--color-accent-900: var(--accent-900);
|
|
||||||
--color-accent-950: var(--accent-950);
|
|
||||||
|
|
||||||
--text-sm: 0.75rem;
|
|
||||||
--text-base: 1rem;
|
|
||||||
--text-xl: 1.333rem;
|
|
||||||
--text-2xl: 1.777rem;
|
|
||||||
--text-3xl: 2.369rem;
|
|
||||||
--text-4xl: 3.158rem;
|
|
||||||
--text-5xl: 4.21rem;
|
|
||||||
|
|
||||||
--text-weight-normal: 400;
|
|
||||||
--text-weight-bold: 700;
|
|
||||||
|
|
||||||
--font-sans:
|
|
||||||
Noto Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
|
||||||
'Noto Color Emoji';
|
|
||||||
--font-title:
|
|
||||||
Wittgenstein, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
|
||||||
'Noto Color Emoji';
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
:root {
|
|
||||||
--ui-bg: var(--background);
|
|
||||||
--ui-bg-muted: var(--background-300);
|
|
||||||
--ui-bg-elevated: var(--background-100);
|
|
||||||
--ui-bg-accented: var(--backgsound-200);
|
|
||||||
--ui-bg-inverted: var(--background-900);
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
--ui-bg: var(--background);
|
|
||||||
--ui-bg-muted: var(--background-100);
|
|
||||||
--ui-bg-elevated: var(--background-200);
|
|
||||||
--ui-bg-accented: var(--background-300);
|
|
||||||
--ui-bg-inverted: var(--background-900);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
:root {
|
|
||||||
--ui-border: var(--background-200);
|
|
||||||
--ui-border-muted: var(--background-200);
|
|
||||||
--ui-border-accented: var(--background-300);
|
|
||||||
--ui-border-inverted: var(--background-900);
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
--ui-border: var(--background-100);
|
|
||||||
--ui-border-muted: var(--background-200);
|
|
||||||
--ui-border-accented: var(--background-200);
|
|
||||||
--ui-border-inverted: var(--background-900);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
:root {
|
|
||||||
--ui-primary: var(--primary);
|
|
||||||
--ui-secondary: var(--secondary);
|
|
||||||
--ui-success: var(--accent);
|
|
||||||
--ui-info: var(--ui-color-info-500);
|
|
||||||
--ui-warning: var(--ui-color-warning-500);
|
|
||||||
--ui-error: var(--ui-color-error-500);
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
--ui-primary: var(--primary);
|
|
||||||
--ui-secondary: var(--secondary);
|
|
||||||
--ui-success: var(--accent);
|
|
||||||
--ui-info: var(--ui-color-info-400);
|
|
||||||
--ui-warning: var(--ui-color-warning-400);
|
|
||||||
--ui-error: var(--ui-color-error-400);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@import './colors.css';
|
|
||||||
@import './text.css';
|
|
||||||
@import './background.css';
|
|
||||||
@import './border.css';
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
:root {
|
|
||||||
--ui-text-dimmed: var(--text-800);
|
|
||||||
--ui-text-muted: var(--text-700);
|
|
||||||
--ui-text-toned: var(--text-600);
|
|
||||||
--ui-text: var(--text);
|
|
||||||
--ui-text-highlighted: var(--text-900);
|
|
||||||
--ui-text-inverted: var(--text-50);
|
|
||||||
}
|
|
||||||
.dark {
|
|
||||||
--ui-text-dimmed: var(--text-800);
|
|
||||||
--ui-text-muted: var(--text-700);
|
|
||||||
--ui-text-toned: var(--text-600);
|
|
||||||
--ui-text: var(--text);
|
|
||||||
--ui-text-highlighted: var(--text);
|
|
||||||
--ui-text-inverted: var(--text-50);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UFooter class="bg-background-200">
|
|
||||||
<template #left>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<p class="text-text-800 text-sm">Copyright © {{ new Date().getFullYear() }}</p>
|
|
||||||
<p class="text-text-800 text-sm">{{ $t('footer.versions.frontend') }}: {{ version }}</p>
|
|
||||||
<p class="text-text-800 text-sm">{{ $t('footer.versions.backend') }}: {{ meta?.version }}</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<UNavigationMenu :items="items" variant="link" :orientation="orientation" />
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<UButton
|
|
||||||
icon="i-simple-icons-github"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
to="https://github.com/Phundrak"
|
|
||||||
target="_blank"
|
|
||||||
aria-label="GitHub"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</UFooter>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
|
||||||
import { version } from '../../package.json';
|
|
||||||
|
|
||||||
const { isMobile } = useDevice();
|
|
||||||
const orientation = computed(() => (isMobile ? 'vertical' : 'horizontal'));
|
|
||||||
const { getMeta } = useBackend();
|
|
||||||
const meta = await getMeta();
|
|
||||||
const items = computed<NavigationMenuItem[]>(() => [
|
|
||||||
{
|
|
||||||
label: $t('footer.links.source'),
|
|
||||||
to: 'https://labs.phundrak.com/phundrak/phundrak.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: $t('footer.links.nuxt'),
|
|
||||||
to: 'https://nuxt.com/',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: $t('footer.links.rust'),
|
|
||||||
to: 'https://rust-lang.org/',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UHeader toggle-side="right" mode="drawer">
|
|
||||||
<template #title> Phundrak </template>
|
|
||||||
<UNavigationMenu :items="items" />
|
|
||||||
<template #right>
|
|
||||||
<NavbarLanguageSwitcher />
|
|
||||||
<NavbarThemeSwitcher />
|
|
||||||
</template>
|
|
||||||
<template #body>
|
|
||||||
<UNavigationMenu :items="items" orientation="vertical" class="-mx-2.5" />
|
|
||||||
</template>
|
|
||||||
</UHeader>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const route = useRoute();
|
|
||||||
const items = computed<NavigationMenuItem[]>(() => [
|
|
||||||
{
|
|
||||||
label: $t('pages.home.name'),
|
|
||||||
to: '/',
|
|
||||||
active: route.path == '/',
|
|
||||||
},
|
|
||||||
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
|
|
||||||
label: $t(`pages.${page}.name`),
|
|
||||||
to: `/${page}`,
|
|
||||||
active: route.path.startsWith(`/${page}`),
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="tools" class="flex flex-row gap-1 flex-wrap">
|
|
||||||
<UBadge v-for="tool in tools" :key="tool" size="md" variant="solid">
|
|
||||||
{{ tool }}
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { tools } = defineProps<{
|
|
||||||
tools: string[];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UPageCard class="bg-background-100 my-10">
|
|
||||||
<p class="text-xl">
|
|
||||||
<slot />
|
|
||||||
</p>
|
|
||||||
<UiBadgeList :tools="tools" />
|
|
||||||
</UPageCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { tools } = defineProps<{
|
|
||||||
tools: string[];
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UPageCard class="bg-background-100 my-10">
|
|
||||||
<p class="text-xl">
|
|
||||||
{{ $t('pages.vocal-synthesis.projects') }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col max-w gap-10">
|
|
||||||
<div v-for="project in data?.projects" :key="project.title" class="flex flex-row max-w gap-5">
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="bg-primary text-text-50 dark:bg-primary p-1 rounded-md min-w-13 w-13 h-13 min-h-13 flex justify-center my-2"
|
|
||||||
>
|
|
||||||
<UIcon :name="project.icon" class="size-11" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="flex flex-row gap-2 items-baseline">
|
|
||||||
<ULink :to="project.link" class="text-2xl">
|
|
||||||
{{ project.title }}
|
|
||||||
</ULink>
|
|
||||||
<UIcon v-if="external(project.link)" name="mdi:link" class="size-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ project.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UPageCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// Inject data provided by the page to avoid hydration issues with MDC components
|
|
||||||
const data = inject('pageData');
|
|
||||||
const external = (url: string) => url.startsWith('http');
|
|
||||||
</script>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UiBadgeListCard v-if="data" :tools="data.tools">{{ $t('pages.vocal-synthesis.tools') }}</UiBadgeListCard>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// Inject data provided by the page to avoid hydration issues with MDC components
|
|
||||||
const data = inject('pageData');
|
|
||||||
</script>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UDropdownMenu :key="locale" :items="availableLocales" :content="{ align: 'start' }">
|
|
||||||
<UButton color="neutral" variant="outline" icon="material-symbols:globe" :aria-label="$t('menu.language')" />
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
|
||||||
const { locale, locales, setLocale } = useI18n();
|
|
||||||
|
|
||||||
const availableLocales = computed(() => {
|
|
||||||
return locales.value.map(
|
|
||||||
(optionLocale) =>
|
|
||||||
({
|
|
||||||
label: optionLocale.name,
|
|
||||||
code: optionLocale.code,
|
|
||||||
type: 'checkbox' as const,
|
|
||||||
checked: optionLocale.code === locale.value,
|
|
||||||
onUpdateChecked: () => switchLocale(optionLocale.code),
|
|
||||||
}) as DropdownMenuItem,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const switchLocale = (newLocale: string) => {
|
|
||||||
setLocale(newLocale);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<UDropdownMenu :key="colorMode.preference" :items="themes" :content="{ align: 'start' }">
|
|
||||||
<UButton color="neutral" variant="outline" :icon="icons[currentColor]" :aria-label="$t('menu.theme')" />
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
|
||||||
const icons: Dictionary<Theme, string> = {
|
|
||||||
light: 'material-symbols:light-mode',
|
|
||||||
dark: 'material-symbols:dark-mode',
|
|
||||||
system: 'material-symbols:computer-outline',
|
|
||||||
};
|
|
||||||
const colorMode = useColorMode();
|
|
||||||
const currentColor = computed<Theme>(() => colorMode.preference ?? 'system');
|
|
||||||
const themes = computed<DropdownValue[]>(() =>
|
|
||||||
['light', 'dark', 'system'].map((theme) => ({
|
|
||||||
code: theme,
|
|
||||||
label: $t(`theme.${theme}`),
|
|
||||||
icon: icons[theme],
|
|
||||||
type: 'checkbox' as const,
|
|
||||||
checked: currentColor.value === theme,
|
|
||||||
onUpdateChecked: () => switchColor(theme),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const switchColor = (theme: Theme) => (colorMode.preference = theme);
|
|
||||||
</script>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { FetchOptions } from 'ofetch';
|
|
||||||
|
|
||||||
export const useApi = () => {
|
|
||||||
const config = useRuntimeConfig();
|
|
||||||
const apiFetch = $fetch.create({
|
|
||||||
baseURL: config.public.apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
const get = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'GET', ...options });
|
|
||||||
|
|
||||||
const post = <ResultT, PayloadT = Record<string, string | number | boolean>>(
|
|
||||||
url: string,
|
|
||||||
body?: PayloadT,
|
|
||||||
options?: FetchOptions,
|
|
||||||
) => apiFetch<ResultT>(url, { method: 'POST', body, ...options });
|
|
||||||
|
|
||||||
const put = <ResultT, PayloadT = Record<string, string | number | boolean>>(
|
|
||||||
url: string,
|
|
||||||
body?: PayloadT,
|
|
||||||
options?: FetchOptions,
|
|
||||||
) => apiFetch<ResultT>(url, { method: 'PUT', body, ...options });
|
|
||||||
|
|
||||||
const patch = <ResultT, PayloadT = Record<string, string | number | boolean>>(
|
|
||||||
url: string,
|
|
||||||
body?: PayloadT,
|
|
||||||
options?: FetchOptions,
|
|
||||||
) => apiFetch<ResultT>(url, { method: 'PATCH', body, ...options });
|
|
||||||
|
|
||||||
const del = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'DELETE', ...options });
|
|
||||||
|
|
||||||
return { get, post, put, patch, del };
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export const useBackend = () => {
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const getMeta = () => api.get<MetaResponse>('/meta');
|
|
||||||
const postContact = (contact: ContactRequest) => api.post<ContactRequest, ContactResponse>('/contact', contact);
|
|
||||||
|
|
||||||
return { getMeta, postContact };
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { withLeadingSlash } from 'ufo';
|
|
||||||
import type { Collections } from '@nuxt/content';
|
|
||||||
|
|
||||||
export const useDataJson = (prefix: string) => {
|
|
||||||
const route = useRoute();
|
|
||||||
const { locale } = useI18n();
|
|
||||||
const slug = computed(() => {
|
|
||||||
// Use route.params.slug for dynamic routes, or route.path for static routes
|
|
||||||
const slugValue = route.params.slug || route.path;
|
|
||||||
return withLeadingSlash(String(slugValue));
|
|
||||||
});
|
|
||||||
const key = computed(() => prefix + '-' + slug.value);
|
|
||||||
|
|
||||||
const getData = async <T>(
|
|
||||||
collectionPrefix: string,
|
|
||||||
options: {
|
|
||||||
useFilter?: boolean;
|
|
||||||
fallbackToEnglish?: boolean;
|
|
||||||
extractMeta?: boolean;
|
|
||||||
} = {},
|
|
||||||
) => {
|
|
||||||
const { useFilter = false, fallbackToEnglish = false, extractMeta = false } = options;
|
|
||||||
|
|
||||||
const { data } = await useAsyncData(
|
|
||||||
key.value,
|
|
||||||
async () => {
|
|
||||||
const collection = (collectionPrefix + locale.value) as keyof Collections;
|
|
||||||
|
|
||||||
let content;
|
|
||||||
if (useFilter) {
|
|
||||||
// For data collections, use .all() and filter
|
|
||||||
const allData = await queryCollection(collection).all();
|
|
||||||
content = allData.filter((source) => source.meta.path == slug.value)[0];
|
|
||||||
} else {
|
|
||||||
// For page collections, use .path().first()
|
|
||||||
content = await queryCollection(collection).path(slug.value).first();
|
|
||||||
if (!content && fallbackToEnglish && locale.value !== 'en') {
|
|
||||||
content = await queryCollection('content_en').path(slug.value).first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractMeta ? content?.meta : content;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
watch: [locale], // Automatically refresh when locale changes
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return data as Ref<T | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJsonData = async (collectionPrefix: string = 'content_data_') => {
|
|
||||||
return getData(collectionPrefix, { useFilter: true, extractMeta: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPageContent = async (collectionPrefix: string = 'content_', fallbackToEnglish: boolean = true) => {
|
|
||||||
return getData(collectionPrefix, { fallbackToEnglish });
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCachedData = () => {
|
|
||||||
const { data } = useNuxtData(key.value);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { getJsonData, getPageContent, getCachedData };
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export interface MetaImageOptions {
|
|
||||||
url: string;
|
|
||||||
alt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetaOptions {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image?: MetaImageOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useMeta = (options: MetaOptions) => {
|
|
||||||
const titleSuffix = ' – Lucien Cartier-Tilet';
|
|
||||||
useSeoMeta({
|
|
||||||
title: () => options.title + titleSuffix,
|
|
||||||
ogTitle: () => options.title + titleSuffix,
|
|
||||||
twitterTitle: () => options.title + titleSuffix,
|
|
||||||
description: () => options.description,
|
|
||||||
ogDescription: () => options.description,
|
|
||||||
twitterDescription: () => options.description,
|
|
||||||
twitterCard: options.image ? 'summary_large_image' : 'summary',
|
|
||||||
ogImage: () => options.image?.url,
|
|
||||||
ogImageAlt: () => options.image?.alt,
|
|
||||||
twitterImage: () => options.image?.url,
|
|
||||||
twitterImageAlt: () => options.image?.alt,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="text-center prose prose-lg mx-auto max-w-prose">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen mx-auto px-4 py-8 max-w-6xl">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NuxtLayout v-if="page" :name="page.meta?.layout ?? 'default'">
|
|
||||||
<ContentRenderer :value="page" />
|
|
||||||
</NuxtLayout>
|
|
||||||
<div v-else>
|
|
||||||
<h1>Page not found</h1>
|
|
||||||
<p>This page doesn't exist in {{ locale }} language.</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { getPageContent } = useDataJson('page');
|
|
||||||
const page = await getPageContent();
|
|
||||||
|
|
||||||
// Pre-fetch JSON data for MDC components to avoid hydration issues
|
|
||||||
const { getJsonData } = useDataJson('page-data');
|
|
||||||
const pageData = await getJsonData();
|
|
||||||
// Provide data to child MDC components
|
|
||||||
provide('pageData', pageData);
|
|
||||||
|
|
||||||
useMeta({ title: page.value?.title, description: page.value?.description });
|
|
||||||
</script>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NuxtLayout name="default">
|
|
||||||
<h1 class="text-4xl text-highlighted font-bold mb-8">
|
|
||||||
{{ $t('pages.resume.name') }}
|
|
||||||
</h1>
|
|
||||||
<UPageCard class="bg-background-100 my-10">
|
|
||||||
<p>
|
|
||||||
{{ $t('pages.resume.experience') }}
|
|
||||||
</p>
|
|
||||||
<UTimeline v-model="valueExp" reverse :items="resumeContent?.experience" class="w-full">
|
|
||||||
<template #description="{ item }">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<p>
|
|
||||||
{{ item.description }}
|
|
||||||
</p>
|
|
||||||
<UiBadgeList :tools="item.tools" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</UTimeline>
|
|
||||||
</UPageCard>
|
|
||||||
<UPageCard class="bg-background-100 my-10">
|
|
||||||
<p>
|
|
||||||
{{ $t('pages.resume.education') }}
|
|
||||||
</p>
|
|
||||||
<UTimeline v-model="valueEd" reverse :items="resumeContent?.education" class="w-full" />
|
|
||||||
</UPageCard>
|
|
||||||
<UiBadgeListCard :tools="resumeContent?.otherTools">{{ $t('pages.resume.tools') }}</UiBadgeListCard>
|
|
||||||
<UiBadgeListCard :tools="resumeContent?.devops">{{ $t('pages.resume.devops') }}</UiBadgeListCard>
|
|
||||||
<UiBadgeListCard :tools="resumeContent?.os">{{ $t('pages.resume.os') }}</UiBadgeListCard>
|
|
||||||
<UiBadgeListCard :tools="resumeContent?.programmingLanguages">{{
|
|
||||||
$t('pages.resume.programmingLanguages')
|
|
||||||
}}</UiBadgeListCard>
|
|
||||||
<UiBadgeListCard :tools="resumeContent?.frameworks">{{ $t('pages.resume.frameworks') }}</UiBadgeListCard>
|
|
||||||
</NuxtLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
useMeta({
|
|
||||||
title: $t('pages.resume.name'),
|
|
||||||
description: $t('pages.resume.description'),
|
|
||||||
});
|
|
||||||
const { getJsonData } = useDataJson('resume');
|
|
||||||
const resumeContent = await getJsonData();
|
|
||||||
const arrLength = (array?: T[]) => (array ? array.length - 1 : 0);
|
|
||||||
const valueExp = computed(() => arrLength(resumeContent.value?.experience));
|
|
||||||
const valueEd = computed(() => arrLength(resumeContent.value?.education));
|
|
||||||
</script>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export interface ContactRequest {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
message: string;
|
|
||||||
website?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContactResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface ApiError {
|
|
||||||
message: string;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface MetaResponse {
|
|
||||||
version: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface Dictionary<K, T> {
|
|
||||||
[key: K]: T;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export interface ResumeExperience extends TimelineItem {
|
|
||||||
tools: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResumeContent {
|
|
||||||
experience: ResumeExperience[];
|
|
||||||
education: TimelineItem[];
|
|
||||||
otherTools: string[];
|
|
||||||
devops: string[];
|
|
||||||
os: string[];
|
|
||||||
programmingLanguages: string[];
|
|
||||||
frameworks: string[];
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { defineCollection, defineContentConfig } from '@nuxt/content';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const commonSchema = z.object({
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
export default defineContentConfig({
|
|
||||||
collections: {
|
|
||||||
content_en: defineCollection({
|
|
||||||
type: 'page',
|
|
||||||
source: {
|
|
||||||
include: 'en/**/*.md',
|
|
||||||
prefix: '',
|
|
||||||
},
|
|
||||||
schema: commonSchema,
|
|
||||||
}),
|
|
||||||
content_fr: defineCollection({
|
|
||||||
type: 'page',
|
|
||||||
source: {
|
|
||||||
include: 'fr/**/*.md',
|
|
||||||
prefix: '',
|
|
||||||
},
|
|
||||||
schema: commonSchema,
|
|
||||||
}),
|
|
||||||
content_data_en: defineCollection({
|
|
||||||
type: 'data',
|
|
||||||
source: {
|
|
||||||
include: 'en/**/*.json',
|
|
||||||
prefix: ''
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
content_data_fr: defineCollection({
|
|
||||||
type: 'data',
|
|
||||||
source: {
|
|
||||||
include: 'fr/**/*.json',
|
|
||||||
prefix: ''
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
layout: centered
|
|
||||||
title: Home
|
|
||||||
description: Personal Website
|
|
||||||
---
|
|
||||||
|
|
||||||
# Welcome
|
|
||||||
|
|
||||||
Web Developer • Worldbuilder • Conlanger
|
|
||||||
|
|
||||||
Hi, I'm Lucien Cartier-Tilet. I work as a web developer and consultant. Outside of work, I spend time on worldbuilding
|
|
||||||
and constructed languages.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
"experience": [
|
|
||||||
{
|
|
||||||
"date": "Since Septembre 2023",
|
|
||||||
"title": "Consultant – Aubay",
|
|
||||||
"description": "Web development consultant working on enterprise applications. Continued focus on Angular front-end development and Java Spring Boot back-end services with PostgreSQL databases.",
|
|
||||||
"tools": [
|
|
||||||
"Angular",
|
|
||||||
"TypeScript",
|
|
||||||
"Java Spring Boot",
|
|
||||||
"Java Spring Batch",
|
|
||||||
"PostgreSQL",
|
|
||||||
"VS Code",
|
|
||||||
"Eclipse",
|
|
||||||
"IntelliJ Idea",
|
|
||||||
"Git"
|
|
||||||
],
|
|
||||||
"icon": "mdi:laptop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "February 2023 – August 2023",
|
|
||||||
"title": "Intern – Aubay",
|
|
||||||
"description": "Web application development internship focused on full-stack development. Worked on projects using Angular for front-end and Java Spring Boot for back-end, with PostgreSQL databases.",
|
|
||||||
"tools": ["Angular", "TypeScript", "Java Spring Boot", "PostgreSQL", "VS Code", "Eclipse", "Git"],
|
|
||||||
"icon": "mdi:book"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "October 2014 – July 2018",
|
|
||||||
"title": "CTO – Voxwave",
|
|
||||||
"description": "Co-founded a startup specialized in creating French virtual singers using vocal synthesis. Developed singing synthesis vocal libraries, conducted linguistic research, provided user support, and trained recruits in vocal library development. Led technical development of ALYS, the first professional French singing voice library.",
|
|
||||||
"tools": ["Alter/Ego", "UTAU", "FL Studio", "iZotope RX", "T-RackS CS"],
|
|
||||||
"icon": "mdi:waveform"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"education": [
|
|
||||||
{
|
|
||||||
"date": "September 2022 – September 2023",
|
|
||||||
"title": "Master's Degree in Hypermedia Technologies – University of Paris 8",
|
|
||||||
"description": "Obtained Master's degree in THYP (Hypermedia Technologies) on 11 September 2023. Repeated the year for health reasons without any lasting effects.",
|
|
||||||
"icon": "mdi:network"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "September 2020 – September 2021",
|
|
||||||
"title": "Master's Degree in Computer Science – University of Paris 8",
|
|
||||||
"description": "First year of my Master’s degree.",
|
|
||||||
"icon": "mdi:code-tags"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "September 2016 – July 2019",
|
|
||||||
"title": "Bachelor's Degree in Computer Science – University of Paris 8",
|
|
||||||
"description": "Bachelor's degree in Computer Science obtained in July 2019",
|
|
||||||
"icon": "mdi:school-outline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "Septembre 2013 – Décembre 2014",
|
|
||||||
"title": "English Literature – Université Lyon 2",
|
|
||||||
"description": "One and a half years of literary English studies in an LLCE English degree. Studies interrupted following the creation of VoxWave.",
|
|
||||||
"icon": "mdi:book-open-page-variant"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"otherTools": ["Emacs", "Vim", "jj", "PostgreSQL", "SQLite"],
|
|
||||||
"devops": ["GitHub", "Gitlab", "Gitea", "GitHub Actions", "Drone.io", "Docker", "Podman"],
|
|
||||||
"os": ["NixOS", "Debian", "Arch Linux", "Void Linux", "Alpine Linux", "Windows"],
|
|
||||||
"programmingLanguages": ["TypeScript", "Rust", "C", "EmacsLisp", "Bash/Zsh", "C++", "Python", "CommonLisp"],
|
|
||||||
"frameworks": ["Angular", "Vue", "Nuxt", "Spring Boot", "Poem (Rust)"]
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"projects": [
|
|
||||||
{
|
|
||||||
"title": "BSUP01 KEINE Tashi series",
|
|
||||||
"icon": "mdi:microphone",
|
|
||||||
"description": "Released starting October 2012. My second vocal library, recorded with better equipment than my first attempt. The series included several Japanese vocal libraries, with the Extend Power version being my best work in this series.",
|
|
||||||
"link": "/keine-tashi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "First Tibetan vocal libraries",
|
|
||||||
"icon": "mdi:earth",
|
|
||||||
"description": "BSUP01 KEINE Tashi and BSUP02 Drölma were the first Tibetan vocal libraries for singing synthesis worldwide.",
|
|
||||||
"link": "/keine-tashi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "ALYS prototypes for UTAU",
|
|
||||||
"icon": "mdi:flask",
|
|
||||||
"description": "Created ALYS 001 JPN, ALYS 001 FRA, and ALYS 002 FRA as test versions while working at VoxWave. Known as ALYS4UTAU.",
|
|
||||||
"link": "https://alys.phundrak.com/en/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "ALYS for Alter/Ego",
|
|
||||||
"icon": "mdi:package",
|
|
||||||
"description": "The first commercial vocal library for Alter/Ego, and the first professional French singing vocal library. Development took well over a year, with eight to nine additional months for the first major update. Now available free of charge.",
|
|
||||||
"link": "https://alys.phundrak.com/en/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "LEORA",
|
|
||||||
"description": "A French singing vocal library developed at VoxWave alongside ALYS.",
|
|
||||||
"icon": "mdi:music",
|
|
||||||
"link": "https://alys.phundrak.com/en/faq#are-there-any-plans-for-leora"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tools": ["Alter/Ego", "UTAU", "VOCALOID", "ChipSpeech", "FL Studio", "Audacity", "iZotope RX", "T-RackS CS", "C++"]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
title: Vocal Synthesis
|
|
||||||
description:
|
|
||||||
Vocal synthesis projects from 2011-2018, including ALYS, the first professional French singing voice library for
|
|
||||||
Alter/Ego.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Vocal Synthesis
|
|
||||||
|
|
||||||
I worked in singing vocal synthesis from 2011 to 2018. I created vocal libraries for UTAU and Alter/Ego, including the
|
|
||||||
first professional French singing voice library.
|
|
||||||
|
|
||||||
:::VocalSynthProjects
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::VocalSynthTools
|
|
||||||
:::
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
---
|
|
||||||
layout: centered
|
|
||||||
title: Accueil
|
|
||||||
description: Site web personnel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Bienvenue
|
|
||||||
|
|
||||||
Développeur web • Créateur d’univers • Idéolinguiste
|
|
||||||
|
|
||||||
Bonjour, je m'appelle Lucien Cartier-Tilet. Je travaille comme développeur web et consultant. En dehors du travail, je
|
|
||||||
consacre mon temps à la création d'univers et de langues construites.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
"experience": [
|
|
||||||
{
|
|
||||||
"date": "Depuis septembre 2023",
|
|
||||||
"title": "Consultant – Aubay",
|
|
||||||
"description": "Consultant en développement web travaillant sur des applications d'entreprise. Je continue à me concentrer sur le développement front-end Angular et les services back-end Java Spring Boot avec des bases de données PostgreSQL.",
|
|
||||||
"tools": [
|
|
||||||
"Angular",
|
|
||||||
"TypeScript",
|
|
||||||
"Java Spring Boot",
|
|
||||||
"Java Spring Batch",
|
|
||||||
"PostgreSQL",
|
|
||||||
"VS Code",
|
|
||||||
"Eclipse",
|
|
||||||
"IntelliJ Idea",
|
|
||||||
"Git"
|
|
||||||
],
|
|
||||||
"icon": "mdi:laptop"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "Février 2023 – Août 2023",
|
|
||||||
"title": "Stagiaire – Aubay",
|
|
||||||
"description": "Stage en développement d'applications web axé sur le développement full-stack. J'ai travaillé sur des projets utilisant Angular pour le front-end et Java Spring Boot pour le back-end, avec des bases de données PostgreSQL.",
|
|
||||||
"tools": ["Angular", "TypeScript", "Java Spring Boot", "PostgreSQL", "VS Code", "Eclipse", "Git"],
|
|
||||||
"icon": "mdi:book"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "Octobre 2014 – Juillet 2018",
|
|
||||||
"title": "Directeur technique – Voxwave",
|
|
||||||
"description": "Co-fondateur d'une start-up spécialisée dans la création de chanteurs virtuels français à l'aide de la synthèse vocale. Développement de banques vocales de synthèse chantée, recherche linguistique, assistance aux utilisateurs et formation des recrues au développement de banques vocales. Direction du développement technique d'ALYS, la première banques vocale professionnelle de chant en français.",
|
|
||||||
"tools": ["Alter/Ego", "UTAU", "FL Studio", "iZotope RX", "T-RackS CS"],
|
|
||||||
"icon": "mdi:waveform"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"education": [
|
|
||||||
{
|
|
||||||
"date": "Septembre 2022 – Septembre 2023",
|
|
||||||
"title": "Master 2 Technologies de l’Hypermédia – Université Paris 8",
|
|
||||||
"description": "Obtention du diplôme Master 2 THYP le 11 septembre 2023. Redoublement pour causes de santé sans séquelles.",
|
|
||||||
"icon": "mdi:network"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "Septembre 2020 – Septembre 2021",
|
|
||||||
"title": "Master 1 Informatique – Université Paris 8",
|
|
||||||
"description": "",
|
|
||||||
"icon": "mdi:code-tags"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "Septembre 2016 – Juillet 2019",
|
|
||||||
"title": "Licence Informatique – Université Paris 8",
|
|
||||||
"description": "Licence d’Informatique obtenue en Juillet 2019",
|
|
||||||
"icon": "mdi:school-outline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"date": "Septembre 2013 – Décembre 2014",
|
|
||||||
"title": "Anglais LLCE – Université Lyon 2",
|
|
||||||
"description": "Un an et demi d’études d’anglais littéraire en licence d’anglais LLCE. Études interrompues suite à la création de VoxWave.",
|
|
||||||
"icon": "mdi:book-open-page-variant"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"otherTools": ["Emacs", "Vim", "jj", "PostgreSQL", "SQLite"],
|
|
||||||
"devops": ["GitHub", "Gitlab", "Gitea", "GitHub Actions", "Drone.io", "Docker", "Podman"],
|
|
||||||
"os": ["NixOS", "Debian", "Arch Linux", "Void Linux", "Alpine Linux", "Windows"],
|
|
||||||
"programmingLanguages": ["TypeScript", "Rust", "C", "EmacsLisp", "Bash/Zsh", "C++", "Python", "CommonLisp"],
|
|
||||||
"frameworks": ["Angular", "Vue", "Nuxt", "Spring Boot", "Poem (Rust)"]
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"projects": [
|
|
||||||
{
|
|
||||||
"title": "BSUP01 KEINE Tashi",
|
|
||||||
"icon": "mdi:microphone",
|
|
||||||
"description": "Sortie en octobre 2012. Ma deuxième bibliothèque vocale, enregistrée avec un équipement de meilleure qualité que ma première tentative. La série comprenait plusieurs bibliothèques vocales japonaises, la version Extend Power étant ma meilleure réalisation dans cette série.",
|
|
||||||
"link": "/keine-tashi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Premières bibliothèques vocales tibétaines",
|
|
||||||
"icon": "mdi:earth",
|
|
||||||
"description": "BSUP01 KEINE Tashi et BSUP02 Drölma ont été les premières bibliothèques vocales tibétaines au monde dédiées à la synthèse vocale chantée.",
|
|
||||||
"link": "/keine-tashi"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Prototypes ALYS pour UTAU",
|
|
||||||
"icon": "mdi:flask",
|
|
||||||
"description": "Création des versions test ALYS 001 JPN, ALYS 001 FRA et ALYS 002 FRA chez VoxWave. Connue sous le nom d'ALYS4UTAU.",
|
|
||||||
"link": "https://alys.phundrak.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "ALYS pour Alter/Ego",
|
|
||||||
"icon": "mdi:package",
|
|
||||||
"description": "La première bibliothèque vocale commerciale pour Alter/Ego, et la première bibliothèque vocale professionnelle en français. Son développement a pris plus d'un an, avec huit à neuf mois supplémentaires pour la première mise à jour majeure. Elle est désormais disponible gratuitement.",
|
|
||||||
"link": "https://alys.phundrak.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "LEORA",
|
|
||||||
"description": "Une bibliothèque vocale française développée chez VoxWave en collaboration avec ALYS.",
|
|
||||||
"icon": "mdi:music",
|
|
||||||
"link": "https://alys.phundrak.com/faq#y-a-t-il-quelque-chose-de-prevu-pour-leora"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tools": ["Alter/Ego", "UTAU", "VOCALOID", "ChipSpeech", "FL Studio", "Audacity", "iZotope RX", "T-RackS CS", "C++"]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
title: Synthèse vocale
|
|
||||||
description:Projets de synthèse vocale de 2011 à 2018, dont ALYS, la première bibliothèque professionnelle de voix chantées en français pour Alter/Ego.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Synthèse vocale
|
|
||||||
|
|
||||||
J'ai travaillé dans le domaine de la synthèse vocale chantée de 2011 à 2018. J'ai créé des bibliothèques vocales pour
|
|
||||||
UTAU et Alter/Ego, notamment la première bibliothèque professionnelle de voix chantées en français.
|
|
||||||
|
|
||||||
:::VocalSynthProjects
|
|
||||||
:::
|
|
||||||
|
|
||||||
:::VocalSynthTools
|
|
||||||
:::
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
|
||||||
|
|
||||||
export default withNuxt(
|
|
||||||
// Your custom configs here
|
|
||||||
)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export default defineI18nConfig(() => ({
|
|
||||||
legacy: false,
|
|
||||||
locale: 'en',
|
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
welcome: 'Welcome',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
welcome: 'Bienvenue',
|
|
||||||
},
|
|
||||||
lfn: {
|
|
||||||
welcome: 'Bonveni',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
{
|
|
||||||
"website": {
|
|
||||||
"name": "Lucien Cartier-Tilet",
|
|
||||||
"langSwitch": "Language"
|
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"name": "Menu",
|
|
||||||
"language": "Language Selector",
|
|
||||||
"theme": "Theme Selector"
|
|
||||||
},
|
|
||||||
"theme": {
|
|
||||||
"name": "theme",
|
|
||||||
"dark": "Dark",
|
|
||||||
"light": "Light",
|
|
||||||
"system": "Auto"
|
|
||||||
},
|
|
||||||
"pages": {
|
|
||||||
"home": {
|
|
||||||
"name": "Home"
|
|
||||||
},
|
|
||||||
"resume": {
|
|
||||||
"name": "Resume",
|
|
||||||
"description": "",
|
|
||||||
"experience": "Experience",
|
|
||||||
"education": "Education",
|
|
||||||
"tools": "Tools",
|
|
||||||
"devops": "Devops Tools",
|
|
||||||
"os": "Operating Systems",
|
|
||||||
"programmingLanguages": "Programming Languages",
|
|
||||||
"frameworks": "Frameworks"
|
|
||||||
},
|
|
||||||
"vocal-synthesis": {
|
|
||||||
"name": "Vocal Synthesis",
|
|
||||||
"projects": "Key Projects",
|
|
||||||
"tools": "Tools"
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"name": "Languages & Worldbuilding"
|
|
||||||
},
|
|
||||||
"contact": {
|
|
||||||
"name": "Contact"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"links": {
|
|
||||||
"source": "Website’s source code",
|
|
||||||
"nuxt": "Frontend made with Nuxt",
|
|
||||||
"rust": "Backend made with Rust"
|
|
||||||
},
|
|
||||||
"versions": {
|
|
||||||
"frontend": "Frontend Version",
|
|
||||||
"backend": "Backend Version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
{
|
|
||||||
"website": {
|
|
||||||
"name": "Lucien Cartier-Tilet",
|
|
||||||
"langSwitch": "Language"
|
|
||||||
},
|
|
||||||
"menu": {
|
|
||||||
"name": "Menu",
|
|
||||||
"language": "Choix de la langue",
|
|
||||||
"theme": "Thème du site web"
|
|
||||||
},
|
|
||||||
"theme": {
|
|
||||||
"name": "Thème",
|
|
||||||
"dark": "Sombre",
|
|
||||||
"light": "Clair",
|
|
||||||
"system": "Auto"
|
|
||||||
},
|
|
||||||
"pages": {
|
|
||||||
"home": {
|
|
||||||
"name": "Accueil"
|
|
||||||
},
|
|
||||||
"resume": {
|
|
||||||
"name": "CV",
|
|
||||||
"description": "",
|
|
||||||
"experience": "Expérience",
|
|
||||||
"education": "Éducation",
|
|
||||||
"tools": "Outils",
|
|
||||||
"devops": "Outils Devops",
|
|
||||||
"os": "Systèmes d’exploitation",
|
|
||||||
"programmingLanguages": "Langages de programmation",
|
|
||||||
"frameworks": "Frameworks"
|
|
||||||
},
|
|
||||||
"vocal-synthesis": {
|
|
||||||
"name": "Synthèse Vocale",
|
|
||||||
"projects": "Projets principaux",
|
|
||||||
"tools": "Outils"
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"name": "Langues et Univers Fictifs"
|
|
||||||
},
|
|
||||||
"contact": {
|
|
||||||
"name": "Contact"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"footer": {
|
|
||||||
"links": {
|
|
||||||
"source": "Code source du site web",
|
|
||||||
"nuxt": "Frontend fait avec Nuxt",
|
|
||||||
"rust": "Backend fait avec Rust"
|
|
||||||
},
|
|
||||||
"versions": {
|
|
||||||
"frontend": "Frontend Version",
|
|
||||||
"backend": "Backend Version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
compatibilityDate: '2025-07-15',
|
|
||||||
devtools: {
|
|
||||||
enabled: true,
|
|
||||||
vueDevTools: true,
|
|
||||||
telemetry: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
modules: [
|
|
||||||
'@nuxt/eslint',
|
|
||||||
'@nuxt/image',
|
|
||||||
'@nuxt/test-utils',
|
|
||||||
'@nuxt/ui',
|
|
||||||
'@nuxt/content',
|
|
||||||
'@nuxtjs/i18n',
|
|
||||||
'@nuxtjs/turnstile',
|
|
||||||
'@nuxtjs/device',
|
|
||||||
'@nuxt/icon',
|
|
||||||
'@nuxt/fonts',
|
|
||||||
'@nuxtjs/color-mode',
|
|
||||||
'@nuxtjs/tailwindcss',
|
|
||||||
],
|
|
||||||
|
|
||||||
css: ['~/assets/css/main.css'],
|
|
||||||
content: {
|
|
||||||
database: {
|
|
||||||
type: 'sqlite',
|
|
||||||
filename: '.data/content/contents.sqlite',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
i18n: {
|
|
||||||
locales: [
|
|
||||||
{ code: 'en', name: 'English', language: 'en-UK', file: 'en.json' },
|
|
||||||
{ code: 'fr', name: 'Français', language: 'fr-FR', file: 'fr.json' },
|
|
||||||
// { code: 'lfn', name: 'Elefen', language: 'lfn', file: 'lfn.json' },
|
|
||||||
// { code: 'ei', name: 'Eittlandic', language: 'ei-ST', file: 'ei.json' },
|
|
||||||
],
|
|
||||||
strategy: 'no_prefix',
|
|
||||||
defaultLocale: 'en',
|
|
||||||
},
|
|
||||||
fonts: {
|
|
||||||
provider: 'google',
|
|
||||||
processCSSVariables: true,
|
|
||||||
defaults: {
|
|
||||||
weights: [400, 700],
|
|
||||||
styles: ['normal', 'italic'],
|
|
||||||
},
|
|
||||||
families: [
|
|
||||||
{ name: 'Noto Sans', provider: 'google' },
|
|
||||||
{ name: 'Wittgenstein', provider: 'google' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
serverBundle: {
|
|
||||||
collections: ['material-symbols', 'mdi']
|
|
||||||
},
|
|
||||||
clientBundle: {
|
|
||||||
scan: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
postcss: {
|
|
||||||
plugins: {
|
|
||||||
'@tailwindcss/postcss': {},
|
|
||||||
'autoprefixer': {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
turnstile: {
|
|
||||||
siteKey: '', // Overridden by NUXT_PUBLIC_TURNSTILE_SITE_KEY
|
|
||||||
addValidateEndpoint: true
|
|
||||||
},
|
|
||||||
runtimeConfig: {
|
|
||||||
turnstile: {
|
|
||||||
secretKey: '', // Overriden by NUXT_TURNSTILE_SECRET_KEY
|
|
||||||
},
|
|
||||||
public: {
|
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3100/api',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"scripts": {
|
|
||||||
"build": "nuxt build",
|
|
||||||
"dev": "nuxt dev",
|
|
||||||
"generate": "nuxt generate",
|
|
||||||
"preview": "nuxt preview",
|
|
||||||
"postinstall": "nuxt prepare",
|
|
||||||
"cleanup": "nuxt cleanup",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"format": "prettier --write app/ i18n/ content/",
|
|
||||||
"format-check": "prettier --check app/ i18n/ content/"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nuxt/content": "3.8.0",
|
|
||||||
"@nuxt/eslint": "1.10.0",
|
|
||||||
"@nuxt/fonts": "0.12.1",
|
|
||||||
"@nuxt/icon": "2.1.0",
|
|
||||||
"@nuxt/image": "1.11.0",
|
|
||||||
"@nuxt/scripts": "^0.12.2",
|
|
||||||
"@nuxt/test-utils": "3.20.1",
|
|
||||||
"@nuxt/ui": "4.1.0",
|
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
|
||||||
"@nuxtjs/device": "3.2.4",
|
|
||||||
"@nuxtjs/tailwindcss": "7.0.0-beta.0",
|
|
||||||
"@nuxtjs/turnstile": "1.1.1",
|
|
||||||
"better-sqlite3": "^12.4.1",
|
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"nitropack": "^2.12.9",
|
|
||||||
"nuxi": "^3.30.0",
|
|
||||||
"nuxt": "^4.2.0",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.1.12",
|
|
||||||
"vue": "^3.5.22",
|
|
||||||
"vue-router": "^4.6.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@iconify-json/material-symbols": "^1.2.44",
|
|
||||||
"@iconify-json/material-symbols-light": "^1.2.44",
|
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
|
||||||
"@nuxtjs/i18n": "^10.2.0",
|
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
|
||||||
"autoprefixer": "^10.4.22",
|
|
||||||
"less": "^4.4.2",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"zod": "^4.1.12"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"sharp": "0.33.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12952
frontend/pnpm-lock.yaml
generated
12952
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- '@parcel/watcher'
|
|
||||||
- better-sqlite3
|
|
||||||
- esbuild
|
|
||||||
- sharp
|
|
||||||
- unrs-resolver
|
|
||||||
- vue-demi
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,2 +0,0 @@
|
|||||||
User-Agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
inputs,
|
|
||||||
pkgs,
|
|
||||||
self,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
inputs.devenv.lib.mkShell {
|
|
||||||
inherit inputs pkgs;
|
|
||||||
modules = [
|
|
||||||
{
|
|
||||||
devenv.root = let
|
|
||||||
devenvRootFileContent = builtins.readFile "${self}/.devenv-root";
|
|
||||||
in
|
|
||||||
pkgs.lib.mkIf (devenvRootFileContent != "") devenvRootFileContent;
|
|
||||||
}
|
|
||||||
{
|
|
||||||
env.PNPM_HOME = "${self}/.pnpm-store";
|
|
||||||
|
|
||||||
packages = with pkgs; [
|
|
||||||
# LSP
|
|
||||||
marksman
|
|
||||||
# nodePackages."@tailwindcss/language-server"
|
|
||||||
# nodePackages."@vue/language-server"
|
|
||||||
# vscode-langservers-extracted
|
|
||||||
|
|
||||||
rustywind
|
|
||||||
nodePackages.prettier
|
|
||||||
nodePackages.eslint
|
|
||||||
|
|
||||||
# Node
|
|
||||||
nodejs_24
|
|
||||||
nodePackages.pnpm
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
# nodePackages.typescript-language-server
|
|
||||||
];
|
|
||||||
|
|
||||||
enterShell = ''
|
|
||||||
echo "🚀 Nuxt.js development environment loaded!"
|
|
||||||
echo "📦 Node.js version: $(node --version)"
|
|
||||||
echo "📦 pnpm version: $(pnpm --version)"
|
|
||||||
echo ""
|
|
||||||
echo "Available LSP servers:"
|
|
||||||
echo " - typescript-language-server (TypeScript)"
|
|
||||||
echo " - vue-language-server (Vue/Volar)"
|
|
||||||
echo " - tailwindcss-language-server (Tailwind CSS)"
|
|
||||||
echo " - vscode-langservers-extracted (HTML, CSS, JSON, ESLint)"
|
|
||||||
echo ""
|
|
||||||
echo "Run 'pnpm install' to install dependencies"
|
|
||||||
echo "Run 'pnpm dev' to start the development server"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "./.nuxt/tsconfig.app.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./.nuxt/tsconfig.server.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./.nuxt/tsconfig.shared.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./.nuxt/tsconfig.node.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
{
|
{
|
||||||
rust-overlay,
|
pkgs,
|
||||||
inputs,
|
rustPlatform,
|
||||||
system,
|
|
||||||
...
|
...
|
||||||
}: let
|
}: let
|
||||||
rust = import ./rust-version.nix { inherit rust-overlay inputs system; };
|
|
||||||
pkgs = rust.pkgs;
|
|
||||||
rustPlatform = pkgs.makeRustPlatform {
|
|
||||||
cargo = rust.version;
|
|
||||||
rustc = rust.version;
|
|
||||||
};
|
|
||||||
cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml);
|
cargoToml = builtins.fromTOML (builtins.readFile ../Cargo.toml);
|
||||||
name = cargoToml.package.name;
|
name = cargoToml.package.name;
|
||||||
version = cargoToml.package.version;
|
version = cargoToml.package.version;
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
{rust-overlay, inputs, system, ...}: let
|
{
|
||||||
overlays = [(import rust-overlay)];
|
rust-overlay,
|
||||||
|
inputs,
|
||||||
|
system,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
overlays = [(import rust-overlay)];
|
||||||
in rec {
|
in rec {
|
||||||
pkgs = import inputs.nixpkgs {inherit system overlays;};
|
pkgs = import inputs.nixpkgs {inherit system overlays;};
|
||||||
version = pkgs.rust-bin.stable.latest.default;
|
version = pkgs.rust-bin.stable.latest.default;
|
||||||
66
nix/shell.nix
Normal file
66
nix/shell.nix
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
inputs,
|
||||||
|
pkgs,
|
||||||
|
self,
|
||||||
|
rustVersion,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
inputs.devenv.lib.mkShell {
|
||||||
|
inherit inputs pkgs;
|
||||||
|
modules = [
|
||||||
|
{
|
||||||
|
packages = with pkgs; [
|
||||||
|
(rustVersion.override {
|
||||||
|
extensions = [
|
||||||
|
"clippy"
|
||||||
|
"rust-src"
|
||||||
|
"rust-analyzer"
|
||||||
|
"rustfmt"
|
||||||
|
];
|
||||||
|
})
|
||||||
|
bacon
|
||||||
|
cargo-deny
|
||||||
|
cargo-shuttle
|
||||||
|
cargo-tarpaulin
|
||||||
|
cargo-watch
|
||||||
|
flyctl
|
||||||
|
just
|
||||||
|
marksman
|
||||||
|
tombi # TOML lsp server
|
||||||
|
];
|
||||||
|
|
||||||
|
services.mailpit = {
|
||||||
|
enable = true;
|
||||||
|
# HTTP interface for viewing emails
|
||||||
|
uiListenAddress = "127.0.0.1:8025";
|
||||||
|
# SMTP server for receiving emails
|
||||||
|
smtpListenAddress = "127.0.0.1:1025";
|
||||||
|
};
|
||||||
|
|
||||||
|
processes.run.exec = "cargo watch -x run";
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
echo "🦀 Rust backend development environment loaded!"
|
||||||
|
echo "📦 Rust version: $(rustc --version)"
|
||||||
|
echo "📦 Cargo version: $(cargo --version)"
|
||||||
|
echo ""
|
||||||
|
echo "Available tools:"
|
||||||
|
echo " - rust-analyzer (LSP)"
|
||||||
|
echo " - clippy (linter)"
|
||||||
|
echo " - rustfmt (formatter)"
|
||||||
|
echo " - bacon (continuous testing/linting)"
|
||||||
|
echo " - cargo-deny (dependency checker)"
|
||||||
|
echo " - cargo-tarpaulin (code coverage)"
|
||||||
|
echo ""
|
||||||
|
echo "📧 Mailpit service:"
|
||||||
|
echo " - SMTP server: 127.0.0.1:1025"
|
||||||
|
echo " - Web UI: http://127.0.0.1:8025"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Quick start:"
|
||||||
|
echo " Run 'devenv up' to launch:"
|
||||||
|
echo " - Mailpit service (email testing)"
|
||||||
|
echo " - Backend with 'cargo watch -x run' (auto-reload)"
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ application:
|
|||||||
protocol: http
|
protocol: http
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
base_url: http://127.0.0.1:3100
|
base_url: http://127.0.0.1:3100
|
||||||
name: "com.phundrak.backend.dev"
|
name: "bakit-dev"
|
||||||
|
|
||||||
email:
|
email:
|
||||||
host: localhost
|
host: localhost
|
||||||
@@ -2,7 +2,7 @@ debug: false
|
|||||||
frontend_url: ""
|
frontend_url: ""
|
||||||
|
|
||||||
application:
|
application:
|
||||||
name: "com.phundrak.backend.prod"
|
name: "bakit-prod"
|
||||||
protocol: https
|
protocol: https
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
base_url: ""
|
base_url: ""
|
||||||
1
sonar-project.properties
Normal file
1
sonar-project.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
sonar.projectKey=bakit
|
||||||
1
src/errors.rs
Normal file
1
src/errors.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub use crate::route::ContactError;
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![allow(clippy::unused_async)]
|
#![allow(clippy::unused_async)]
|
||||||
|
|
||||||
|
/// Custom errors
|
||||||
|
pub mod errors;
|
||||||
/// Custom middleware implementations
|
/// Custom middleware implementations
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
/// API route handlers and endpoints
|
/// API route handlers and endpoints
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
#[cfg(not(tarpaulin_include))]
|
#[cfg(not(tarpaulin_include))]
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
async fn main() -> Result<(), std::io::Error> {
|
||||||
phundrak_dot_com_backend::run(None).await
|
bakit::run(None).await
|
||||||
}
|
}
|
||||||
@@ -4,21 +4,14 @@
|
|||||||
//! Algorithm (GCRA) via the governor crate. It stores rate limiters in memory
|
//! Algorithm (GCRA) via the governor crate. It stores rate limiters in memory
|
||||||
//! without requiring external dependencies like Redis.
|
//! without requiring external dependencies like Redis.
|
||||||
|
|
||||||
use std::{
|
use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration};
|
||||||
net::IpAddr,
|
|
||||||
num::NonZeroU32,
|
|
||||||
sync::Arc,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use governor::{
|
use governor::{
|
||||||
|
Quota, RateLimiter,
|
||||||
clock::DefaultClock,
|
clock::DefaultClock,
|
||||||
state::{InMemoryState, NotKeyed},
|
state::{InMemoryState, NotKeyed},
|
||||||
Quota, RateLimiter,
|
|
||||||
};
|
|
||||||
use poem::{
|
|
||||||
Endpoint, Error, IntoResponse, Middleware, Request, Response, Result,
|
|
||||||
};
|
};
|
||||||
|
use poem::{Endpoint, Error, IntoResponse, Middleware, Request, Response, Result};
|
||||||
|
|
||||||
/// Rate limiting configuration.
|
/// Rate limiting configuration.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -113,7 +106,9 @@ impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
|
|||||||
"Rate limit exceeded"
|
"Rate limit exceeded"
|
||||||
);
|
);
|
||||||
|
|
||||||
return Err(Error::from_status(poem::http::StatusCode::TOO_MANY_REQUESTS));
|
return Err(Error::from_status(
|
||||||
|
poem::http::StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the request
|
// Process the request
|
||||||
@@ -125,7 +120,9 @@ impl<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
|
|||||||
impl<E> RateLimitEndpoint<E> {
|
impl<E> RateLimitEndpoint<E> {
|
||||||
/// Extracts the client IP address from the request.
|
/// Extracts the client IP address from the request.
|
||||||
fn get_client_ip(req: &Request) -> Option<IpAddr> {
|
fn get_client_ip(req: &Request) -> Option<IpAddr> {
|
||||||
req.remote_addr().as_socket_addr().map(std::net::SocketAddr::ip)
|
req.remote_addr()
|
||||||
|
.as_socket_addr()
|
||||||
|
.map(std::net::SocketAddr::ip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +160,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rate_limit_middleware_allows_within_limit() {
|
async fn rate_limit_middleware_allows_within_limit() {
|
||||||
use poem::{handler, test::TestClient, EndpointExt, Route};
|
use poem::{EndpointExt, Route, handler, test::TestClient};
|
||||||
|
|
||||||
#[handler]
|
#[handler]
|
||||||
async fn index() -> String {
|
async fn index() -> String {
|
||||||
@@ -185,7 +182,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn rate_limit_middleware_blocks_over_limit() {
|
async fn rate_limit_middleware_blocks_over_limit() {
|
||||||
use poem::{handler, test::TestClient, EndpointExt, Route};
|
use poem::{EndpointExt, Route, handler, test::TestClient};
|
||||||
|
|
||||||
#[handler]
|
#[handler]
|
||||||
async fn index() -> String {
|
async fn index() -> String {
|
||||||
418
src/route/contact/errors.rs
Normal file
418
src/route/contact/errors.rs
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use lettre::address::AddressError;
|
||||||
|
use poem_openapi::payload::Json;
|
||||||
|
use validator::ValidationErrors;
|
||||||
|
|
||||||
|
use super::ContactResponse;
|
||||||
|
|
||||||
|
/// Errors that can occur during contact form processing and email sending.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ContactError {
|
||||||
|
/// The email address provided in the contact form request could not be parsed.
|
||||||
|
///
|
||||||
|
/// This typically indicates the user submitted an invalid email address format.
|
||||||
|
CouldNotParseRequestEmailAddress(String),
|
||||||
|
/// The email address configured in application settings could not be parsed.
|
||||||
|
///
|
||||||
|
/// This indicates a configuration error with the sender or recipient email addresses.
|
||||||
|
CouldNotParseSettingsEmail(String),
|
||||||
|
/// Failed to construct the email message.
|
||||||
|
///
|
||||||
|
/// This can occur due to invalid message content or headers.
|
||||||
|
FailedToBuildMessage(String),
|
||||||
|
/// Failed to send the email through the SMTP server.
|
||||||
|
///
|
||||||
|
/// This can occur due to network issues, authentication failures, or SMTP server errors.
|
||||||
|
CouldNotSendEmail(String),
|
||||||
|
/// A general validation error occurred that doesn't fit specific field validation.
|
||||||
|
///
|
||||||
|
/// This is used for validation errors that don't map to a specific form field.
|
||||||
|
ValidationError(String),
|
||||||
|
/// The name field in the contact form failed validation.
|
||||||
|
///
|
||||||
|
/// This typically occurs when the name is empty, too short, or contains invalid characters.
|
||||||
|
ValidationNameError(String),
|
||||||
|
/// The email field in the contact form failed validation.
|
||||||
|
///
|
||||||
|
/// This typically occurs when the email address format is invalid.
|
||||||
|
ValidationEmailError(String),
|
||||||
|
/// The message field in the contact form failed validation.
|
||||||
|
///
|
||||||
|
/// This typically occurs when the message is empty, too short.
|
||||||
|
ValidationMessageError(String),
|
||||||
|
/// An unspecified internal error occurred.
|
||||||
|
OtherError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for ContactError {}
|
||||||
|
|
||||||
|
/// Converts a lettre SMTP transport error into a `ContactError`.
|
||||||
|
///
|
||||||
|
/// SMTP errors are logged at ERROR level with full details, then
|
||||||
|
/// mapped to `OtherError` as they represent server-side or network
|
||||||
|
/// issues beyond the client's control.
|
||||||
|
impl From<lettre::transport::smtp::Error> for ContactError {
|
||||||
|
fn from(value: lettre::transport::smtp::Error) -> Self {
|
||||||
|
tracing::event!(target: "contact", tracing::Level::ERROR, "SMTP Error details: {}", format!("{value:?}"));
|
||||||
|
Self::OtherError(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ContactError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let message = match self {
|
||||||
|
Self::CouldNotParseRequestEmailAddress(e) => {
|
||||||
|
format!("Failed to parse requester's email address: {e:?}")
|
||||||
|
}
|
||||||
|
Self::CouldNotParseSettingsEmail(e) => {
|
||||||
|
format!("Failed to parse email address in settings: {e:?}")
|
||||||
|
}
|
||||||
|
Self::FailedToBuildMessage(e) => {
|
||||||
|
format!("Failed to build the message to be sent: {e:?}")
|
||||||
|
}
|
||||||
|
Self::CouldNotSendEmail(e) => format!("Failed to send the email: {e:?}"),
|
||||||
|
Self::ValidationError(e) => format!("Failed to validate request: {e:?}"),
|
||||||
|
Self::ValidationNameError(e) => format!("Failed to validate name: {e:?}"),
|
||||||
|
Self::ValidationEmailError(e) => format!("Failed to validate email: {e:?}"),
|
||||||
|
Self::ValidationMessageError(e) => format!("Failed to validate message: {e:?}"),
|
||||||
|
Self::OtherError(e) => format!("Other internal error: {e:?}"),
|
||||||
|
};
|
||||||
|
write!(f, "{message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts validation errors into a `ContactError`.
|
||||||
|
///
|
||||||
|
/// This implementation inspects the validation errors to determine which specific field
|
||||||
|
/// failed validation (name, email, or message) and returns the appropriate variant.
|
||||||
|
/// If no specific field can be identified, returns a generic `ValidationError`.
|
||||||
|
impl From<ValidationErrors> for ContactError {
|
||||||
|
fn from(value: ValidationErrors) -> Self {
|
||||||
|
if validator::ValidationErrors::has_error(&Err(value.clone()), "name") {
|
||||||
|
return Self::ValidationNameError("backend.contact.errors.validation.name".to_owned());
|
||||||
|
}
|
||||||
|
if validator::ValidationErrors::has_error(&Err(value.clone()), "email") {
|
||||||
|
return Self::ValidationEmailError("backend.contact.errors.validation.email".to_owned());
|
||||||
|
}
|
||||||
|
if validator::ValidationErrors::has_error(&Err(value), "message") {
|
||||||
|
return Self::ValidationMessageError("backend.contact.errors.validation.message".to_owned());
|
||||||
|
}
|
||||||
|
Self::ValidationError("backend.contact.errors.validation.other".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a `ContactError` into a `ContactResponse`.
|
||||||
|
///
|
||||||
|
/// This maps error variants to user-facing error message keys for internationalization.
|
||||||
|
/// Validation errors map to specific field error keys, while internal errors
|
||||||
|
/// (settings, email building, SMTP issues) all map to a generic internal error key.
|
||||||
|
impl From<ContactError> for ContactResponse {
|
||||||
|
fn from(value: ContactError) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
message: match value {
|
||||||
|
ContactError::CouldNotParseRequestEmailAddress(_)
|
||||||
|
| ContactError::ValidationEmailError(_) => "backend.contact.errors.validation.email",
|
||||||
|
ContactError::ValidationNameError(_) => "backend.contact.errors.validation.name",
|
||||||
|
ContactError::ValidationMessageError(_) => "backend.contact.errors.validation.message",
|
||||||
|
ContactError::CouldNotParseSettingsEmail(_)
|
||||||
|
| ContactError::FailedToBuildMessage(_)
|
||||||
|
| ContactError::CouldNotSendEmail(_)
|
||||||
|
| ContactError::OtherError(_) => "backend.contact.errors.internal",
|
||||||
|
ContactError::ValidationError(_) => "backend.contact.errors.validation.other",
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts validation errors directly into a `ContactResponse`.
|
||||||
|
///
|
||||||
|
/// This is a convenience implementation that first converts `ValidationErrors` to
|
||||||
|
/// `ContactError`, then converts that to `ContactResponse`. This allows validation
|
||||||
|
/// errors to be returned directly from handlers as responses.
|
||||||
|
impl From<ValidationErrors> for ContactResponse {
|
||||||
|
fn from(value: ValidationErrors) -> Self {
|
||||||
|
let error: ContactError = value.into();
|
||||||
|
error.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a lettre `AddressError` into a `ContactError`.
|
||||||
|
///
|
||||||
|
/// Address parsing errors from lettre are mapped to `CouldNotParseSettingsEmail`
|
||||||
|
/// as they typically occur when parsing email addresses from application settings.
|
||||||
|
impl From<AddressError> for ContactError {
|
||||||
|
fn from(value: AddressError) -> Self {
|
||||||
|
Self::CouldNotParseSettingsEmail(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a lettre `Error` into a `ContactError`.
|
||||||
|
///
|
||||||
|
/// Lettre errors during message construction are mapped to `FailedToBuildMessage`.
|
||||||
|
/// These errors typically occur when building email messages with invalid headers or content.
|
||||||
|
impl From<lettre::error::Error> for ContactError {
|
||||||
|
fn from(value: lettre::error::Error) -> Self {
|
||||||
|
Self::FailedToBuildMessage(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ContactError> for Json<ContactResponse> {
|
||||||
|
fn from(value: ContactError) -> Self {
|
||||||
|
let response: ContactResponse = value.into();
|
||||||
|
response.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use lettre::address::AddressError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_could_not_parse_request_email() {
|
||||||
|
let error = ContactError::CouldNotParseRequestEmailAddress("invalid".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to parse requester's email address"));
|
||||||
|
assert!(display.contains("invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_could_not_parse_settings_email() {
|
||||||
|
let error = ContactError::CouldNotParseSettingsEmail("invalid".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to parse email address in settings"));
|
||||||
|
assert!(display.contains("invalid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_failed_to_build_message() {
|
||||||
|
let error = ContactError::FailedToBuildMessage("build error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to build the message to be sent"));
|
||||||
|
assert!(display.contains("build error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_could_not_send_email() {
|
||||||
|
let error = ContactError::CouldNotSendEmail("send error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to send the email"));
|
||||||
|
assert!(display.contains("send error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_validation_error() {
|
||||||
|
let error = ContactError::ValidationError("validation error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to validate request"));
|
||||||
|
assert!(display.contains("validation error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_validation_name_error() {
|
||||||
|
let error = ContactError::ValidationNameError("name error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to validate name"));
|
||||||
|
assert!(display.contains("name error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_validation_email_error() {
|
||||||
|
let error = ContactError::ValidationEmailError("email error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to validate email"));
|
||||||
|
assert!(display.contains("email error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_validation_message_error() {
|
||||||
|
let error = ContactError::ValidationMessageError("message error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Failed to validate message"));
|
||||||
|
assert!(display.contains("message error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_display_other_error() {
|
||||||
|
let error = ContactError::OtherError("other error".to_string());
|
||||||
|
let display = format!("{error}");
|
||||||
|
assert!(display.contains("Other internal error"));
|
||||||
|
assert!(display.contains("other error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_address_error_creates_could_not_parse_settings_email() {
|
||||||
|
let address_error: Result<lettre::Address, AddressError> = "invalid email".parse();
|
||||||
|
let error: ContactError = address_error.unwrap_err().into();
|
||||||
|
match error {
|
||||||
|
ContactError::CouldNotParseSettingsEmail(_) => (),
|
||||||
|
_ => panic!("Expected CouldNotParseSettingsEmail variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_lettre_error_creates_failed_to_build_message() {
|
||||||
|
// Create an invalid message to trigger a lettre error
|
||||||
|
let result = lettre::Message::builder().body(String::new());
|
||||||
|
assert!(result.is_err());
|
||||||
|
let lettre_error = result.unwrap_err();
|
||||||
|
let error: ContactError = lettre_error.into();
|
||||||
|
match error {
|
||||||
|
ContactError::FailedToBuildMessage(_) => (),
|
||||||
|
_ => panic!("Expected FailedToBuildMessage variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_validation_errors_with_name_error() {
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Validate)]
|
||||||
|
struct TestStruct {
|
||||||
|
#[validate(length(min = 1))]
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let test = TestStruct {
|
||||||
|
name: String::new(),
|
||||||
|
};
|
||||||
|
let validation_errors = test.validate().unwrap_err();
|
||||||
|
let error: ContactError = validation_errors.into();
|
||||||
|
match error {
|
||||||
|
ContactError::ValidationNameError(msg) => {
|
||||||
|
assert_eq!(msg, "backend.contact.errors.validation.name");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected ValidationNameError variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_validation_errors_with_email_error() {
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Validate)]
|
||||||
|
struct TestStruct {
|
||||||
|
#[validate(email)]
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let test = TestStruct {
|
||||||
|
email: "invalid".to_string(),
|
||||||
|
};
|
||||||
|
let validation_errors = test.validate().unwrap_err();
|
||||||
|
let error: ContactError = validation_errors.into();
|
||||||
|
match error {
|
||||||
|
ContactError::ValidationEmailError(msg) => {
|
||||||
|
assert_eq!(msg, "backend.contact.errors.validation.email");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected ValidationEmailError variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_validation_errors_with_message_error() {
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Validate)]
|
||||||
|
struct TestStruct {
|
||||||
|
#[validate(length(min = 10))]
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let test = TestStruct {
|
||||||
|
message: "short".to_string(),
|
||||||
|
};
|
||||||
|
let validation_errors = test.validate().unwrap_err();
|
||||||
|
let error: ContactError = validation_errors.into();
|
||||||
|
match error {
|
||||||
|
ContactError::ValidationMessageError(msg) => {
|
||||||
|
assert_eq!(msg, "backend.contact.errors.validation.message");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected ValidationMessageError variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_to_response_email_validation() {
|
||||||
|
let error = ContactError::ValidationEmailError("test".to_string());
|
||||||
|
let response: ContactResponse = error.into();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(response.message, "backend.contact.errors.validation.email");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_to_response_name_validation() {
|
||||||
|
let error = ContactError::ValidationNameError("test".to_string());
|
||||||
|
let response: ContactResponse = error.into();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(response.message, "backend.contact.errors.validation.name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_to_response_message_validation() {
|
||||||
|
let error = ContactError::ValidationMessageError("test".to_string());
|
||||||
|
let response: ContactResponse = error.into();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(
|
||||||
|
response.message,
|
||||||
|
"backend.contact.errors.validation.message"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_to_response_internal_errors() {
|
||||||
|
let test_cases = vec![
|
||||||
|
ContactError::CouldNotParseSettingsEmail("test".to_string()),
|
||||||
|
ContactError::FailedToBuildMessage("test".to_string()),
|
||||||
|
ContactError::CouldNotSendEmail("test".to_string()),
|
||||||
|
ContactError::OtherError("test".to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for error in test_cases {
|
||||||
|
let response: ContactResponse = error.into();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(response.message, "backend.contact.errors.internal");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_to_response_other_validation() {
|
||||||
|
let error = ContactError::ValidationError("test".to_string());
|
||||||
|
let response: ContactResponse = error.into();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(response.message, "backend.contact.errors.validation.other");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contact_error_to_json_response() {
|
||||||
|
let error = ContactError::ValidationEmailError("test".to_string());
|
||||||
|
let json_response: Json<ContactResponse> = error.into();
|
||||||
|
assert!(!json_response.0.success);
|
||||||
|
assert_eq!(
|
||||||
|
json_response.0.message,
|
||||||
|
"backend.contact.errors.validation.email"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validation_errors_to_response() {
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
#[derive(Validate)]
|
||||||
|
struct TestStruct {
|
||||||
|
#[validate(email)]
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let test = TestStruct {
|
||||||
|
email: "invalid".to_string(),
|
||||||
|
};
|
||||||
|
let validation_errors = test.validate().unwrap_err();
|
||||||
|
let response: ContactResponse = validation_errors.into();
|
||||||
|
assert!(!response.success);
|
||||||
|
assert_eq!(response.message, "backend.contact.errors.validation.email");
|
||||||
|
}
|
||||||
|
}
|
||||||
1002
src/route/contact/mod.rs
Normal file
1002
src/route/contact/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
use poem_openapi::Tags;
|
use poem_openapi::Tags;
|
||||||
|
|
||||||
mod contact;
|
mod contact;
|
||||||
|
pub use contact::errors::ContactError;
|
||||||
mod health;
|
mod health;
|
||||||
mod meta;
|
mod meta;
|
||||||
|
|
||||||
@@ -143,6 +143,38 @@ pub struct EmailSettings {
|
|||||||
pub tls: bool,
|
pub tls: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl EmailSettings {
|
||||||
|
/// Parses the sender email address into a `Mailbox` for use with lettre.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `ContactError` if the email address in the `from` field cannot be parsed
|
||||||
|
/// into a valid mailbox. This can occur if:
|
||||||
|
/// - The email address format is invalid
|
||||||
|
/// - The email address contains invalid characters
|
||||||
|
/// - The email address structure is malformed
|
||||||
|
pub fn try_sender_into_mailbox(
|
||||||
|
&self,
|
||||||
|
) -> Result<lettre::message::Mailbox, crate::errors::ContactError> {
|
||||||
|
Ok(self.from.parse::<lettre::message::Mailbox>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the recipient email address into a `Mailbox` for use with lettre.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a `ContactError` if the email address in the `from` field cannot be parsed
|
||||||
|
/// into a valid mailbox. This can occur if:
|
||||||
|
/// - The email address format is invalid
|
||||||
|
/// - The email address contains invalid characters
|
||||||
|
/// - The email address structure is malformed
|
||||||
|
pub fn try_recpient_into_mailbox(
|
||||||
|
&self,
|
||||||
|
) -> Result<lettre::message::Mailbox, crate::errors::ContactError> {
|
||||||
|
Ok(self.recipient.parse::<lettre::message::Mailbox>()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for EmailSettings {
|
impl std::fmt::Debug for EmailSettings {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.debug_struct("EmailSettings")
|
f.debug_struct("EmailSettings")
|
||||||
@@ -466,9 +498,7 @@ mod tests {
|
|||||||
fn startls_try_from_str_invalid() {
|
fn startls_try_from_str_invalid() {
|
||||||
let result = Starttls::try_from("invalid");
|
let result = Starttls::try_from("invalid");
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result
|
assert!(result.unwrap_err().contains("not a supported option"));
|
||||||
.unwrap_err()
|
|
||||||
.contains("not a supported option"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -616,4 +646,76 @@ mod tests {
|
|||||||
assert!(debug_output.contains("smtp.example.com"));
|
assert!(debug_output.contains("smtp.example.com"));
|
||||||
assert!(debug_output.contains("user@example.com"));
|
assert!(debug_output.contains("user@example.com"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_settings_try_sender_into_mailbox_success() {
|
||||||
|
let settings = EmailSettings {
|
||||||
|
host: "smtp.example.com".to_string(),
|
||||||
|
port: 587,
|
||||||
|
user: "user@example.com".to_string(),
|
||||||
|
from: "sender@example.com".to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
|
recipient: "recipient@example.com".to_string(),
|
||||||
|
starttls: Starttls::Always,
|
||||||
|
tls: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = settings.try_sender_into_mailbox();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let mailbox = result.unwrap();
|
||||||
|
assert_eq!(mailbox.email.to_string(), "sender@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_settings_try_sender_into_mailbox_invalid() {
|
||||||
|
let settings = EmailSettings {
|
||||||
|
host: "smtp.example.com".to_string(),
|
||||||
|
port: 587,
|
||||||
|
user: "user@example.com".to_string(),
|
||||||
|
from: "invalid-email".to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
|
recipient: "recipient@example.com".to_string(),
|
||||||
|
starttls: Starttls::Always,
|
||||||
|
tls: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = settings.try_sender_into_mailbox();
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_settings_try_recipient_into_mailbox_success() {
|
||||||
|
let settings = EmailSettings {
|
||||||
|
host: "smtp.example.com".to_string(),
|
||||||
|
port: 587,
|
||||||
|
user: "user@example.com".to_string(),
|
||||||
|
from: "sender@example.com".to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
|
recipient: "recipient@example.com".to_string(),
|
||||||
|
starttls: Starttls::Always,
|
||||||
|
tls: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = settings.try_recpient_into_mailbox();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let mailbox = result.unwrap();
|
||||||
|
assert_eq!(mailbox.email.to_string(), "recipient@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_settings_try_recipient_into_mailbox_invalid() {
|
||||||
|
let settings = EmailSettings {
|
||||||
|
host: "smtp.example.com".to_string(),
|
||||||
|
port: 587,
|
||||||
|
user: "user@example.com".to_string(),
|
||||||
|
from: "sender@example.com".to_string(),
|
||||||
|
password: "password".to_string(),
|
||||||
|
recipient: "invalid-email".to_string(),
|
||||||
|
starttls: Starttls::Always,
|
||||||
|
tls: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = settings.try_recpient_into_mailbox();
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user