diff --git a/Makefile b/Makefile index b5eb1f4..92783fe 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,6 @@ #!make SHELL := /bin/bash -export API_VERSION = $(shell sed -n 's/^version *= *"\([^"]*\)".*/\1/p' api/Cargo.toml) -export UI_VERSION=$(shell sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' ui/package.json) - include .env -include .env.local export diff --git a/README.md b/README.md index 0d32dc3..07e771e 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ ## Makefile -`make help` to list all commands +* `make` or `make help` to list all commands +* `make docker-up` to start all containers +* `make docker-clean` to stop and delete all containers, volumes, and networks related +to the application ## Setup @@ -17,6 +20,31 @@ * Running just `make cert` will generate `localhost` certificates 4. Run the application with `make up` +### Production Environment +The most likely to change environment variables are the following: +* `UI_PORT` +* `API_PORT` +* `POSTGRES_PORT` +* `POSTGRES_PASSWORD` - Please change in production environments +* `MINIO_HOST` - Match to the `NGINX_HOST` value (see below) +* `MINIO_ROOT_PASSWORD` - Please change in production environments +* `NGINX_HOST` - The IP address of the system +* `NGINX_INTERNAL_HOST` - Typically `host.docker.internal` or `172.17.0.1` +to allow communication within the docker network +* `ENVIRONMENT` - Change to `production` +* `ADMIN_EMAIL` - Please change in production environments +* `ADMIN_PASSWORD` - Please change in production environments +* `VITE_API_URL` - Change to the FQDN of the URL that is reachable through the internet. +For example: `https://aviaation.bensherriff.com` +* `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` - Change to the domain of the `VITE_API_URL`. +For example: `aviation.bensherriff.com` + +If the App is not directly exposed to the internet (i.e., behind another reverse proxy or similar), +then `NGINX_SSL_ENABLED` most likely should be `false`. The `NGINX_SSL_ENABLED` should only be +enabled when you need to setup SSL directly. However, the SSL configuration is incomplete, and may +require additional configuration that is not included in this README. +* Additionally, run `make cert` to generate certificates. + ## Data Sources ### Airport Data diff --git a/api/Cargo.lock b/api/Cargo.lock index 6126900..3e35886 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "api" -version = "0.1.2" +version = "0.1.3" dependencies = [ "actix-cors", "actix-multipart", diff --git a/api/Cargo.toml b/api/Cargo.toml index bfe3cff..a0e231d 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "api" -version = "0.1.2" +version = "0.1.3" edition = "2021" -authors = ["Ben Sherriff "] -repository = "https://github.com/bensherriff/aviation-weather" +authors = ["Ben Sherriff "] +repository = "https://gitea.bensherriff.com/bsherriff/aviation" readme = "../README.md" license = "GPL-3.0-or-later" diff --git a/api/src/account/routes.rs b/api/src/account/routes.rs index 9d6ba7b..df4553a 100644 --- a/api/src/account/routes.rs +++ b/api/src/account/routes.rs @@ -140,7 +140,9 @@ async fn validate_session(req: HttpRequest) -> HttpResponse { session_id, ip_address ); - return ResponseError::error_response(&Error::new(500, err.to_string())); + return HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .finish(); } }; let email = &session.email; diff --git a/api/src/main.rs b/api/src/main.rs index e981d26..6e1aec7 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -59,20 +59,26 @@ async fn main() -> Result<(), Box> { } } - let certificate_path = env::var("SSL_CA_PATH")?; - let certificate_data = std::fs::read(certificate_path)?; - let certificate = Certificate::from_pem(&certificate_data)?; - - let client = reqwest::Client::builder() + let mut client_builder = reqwest::Client::builder() .timeout(Duration::from_secs(10)) - .add_root_certificate(certificate) - .tls_built_in_root_certs(true) + .tls_built_in_root_certs(true); + + if let Ok(val) = env::var("NGINX_SSL_ENABLED") { + if val == "true" { + let certificate_path = env::var("SSL_CA_PATH")?; + let certificate_data = std::fs::read(certificate_path)?; + let certificate = Certificate::from_pem(&certificate_data)?; + client_builder = client_builder.add_root_certificate(certificate); + } + } + + let client = client_builder .build() .expect("Failed to create reqwest client"); let state = AppState { client }; let host = "0.0.0.0"; - let port = "5000"; + let port = env::var("API_PORT").unwrap_or("5000".to_string()); let server = match HttpServer::new(move || { let cors = Cors::default() diff --git a/api/src/system/mod.rs b/api/src/system/mod.rs index b8e8565..4042fc6 100644 --- a/api/src/system/mod.rs +++ b/api/src/system/mod.rs @@ -11,7 +11,7 @@ pub struct SystemInfo { #[get("/info")] async fn info() -> HttpResponse { let mut healthy = true; - let version = match env::var("API_VERSION") { + let version = match env::var("CARGO_PKG_VERSION") { Ok(v) => v, Err(_) => { healthy = false; @@ -19,8 +19,6 @@ async fn info() -> HttpResponse { } }; - dbg!(&version); - let info = SystemInfo { version, healthy }; HttpResponse::Ok().json(info) diff --git a/docker-compose.yml b/docker-compose.yml index 96355bc..2c1a3fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,7 +87,7 @@ services: <<: *default_restart api: - image: gitea.bensherriff.com/bsherriff/aviation-api:0.1.2 + image: gitea.bensherriff.com/bsherriff/aviation-api:0.1.3 container_name: aviation-api build: context: ./api @@ -95,6 +95,7 @@ services: env_file: *env environment: SSL_CA_PATH: /ssl/ca.pem + API_PORT: 5000 POSTGRES_HOST: aviation-postgres POSTGRES_PORT: 5432 REDIS_HOST: aviation-redis @@ -117,7 +118,7 @@ services: <<: *default_restart ui: - image: gitea.bensherriff.com/bsherriff/aviation-ui:latest + image: gitea.bensherriff.com/bsherriff/aviation-ui:0.1.2 container_name: aviation-ui build: context: ./ui diff --git a/ui/package-lock.json b/ui/package-lock.json index 4067f81..9022bac 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -19,7 +19,8 @@ "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-leaflet": "^5.0.0" + "react-leaflet": "^5.0.0", + "react-router": "^7.5.0" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -1551,6 +1552,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2417,6 +2424,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4222,6 +4238,30 @@ } } }, + "node_modules/react-router": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz", + "integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -4446,6 +4486,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4573,6 +4619,12 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/ui/package.json b/ui/package.json index 5d664ef..7d7144d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,7 +22,8 @@ "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-leaflet": "^5.0.0" + "react-leaflet": "^5.0.0", + "react-router": "^7.5.0" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/ui/src/App.css b/ui/src/App.css index 57dd1da..2a4dee6 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -1,14 +1,3 @@ -/* Ensure that the html and body take up the full height */ -html, -body, -#root, -.App { - height: 100%; - margin: 0; - padding: 0; - overflow: hidden; -} - /* Set up Flexbox layout */ .App { display: flex; @@ -16,11 +5,6 @@ body, height: 100%; } -.app-header { - background-color: #333; - color: #fff; -} - .map-wrapper { flex: 1; } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8bf8a1c..1d2aa4e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -12,7 +12,6 @@ import { useEffect, useState } from 'react'; import { Airport } from '@lib/airport.types.ts'; import AirportDrawer from '@components/AirportDrawer.tsx'; import { getWeatherMapUrl } from '@lib/rainViewer.ts'; -// import { IconRadar } from '@tabler/icons-react'; import Cookies from 'js-cookie'; import { UnstyledButton } from '@mantine/core'; // Fix Leaflet's default icon path issues with Webpack @@ -107,7 +106,7 @@ function App() { style={{ bottom: '80px' }} className={`map-button ${showRadar ? 'active' : ''}`} > - Radar + R diff --git a/ui/src/components/Administration.tsx b/ui/src/components/Administration.tsx new file mode 100644 index 0000000..93441ba --- /dev/null +++ b/ui/src/components/Administration.tsx @@ -0,0 +1,18 @@ +import { Header } from '@components/Header'; +import { Navigate } from 'react-router'; +import { useUserContext } from '@components/context/UserContext.tsx'; + +export function Administration() { + const { user } = useUserContext(); + + if (user == undefined) { + return ; + } + + return ( + <> +
+ Todo: administration {user?.email} + + ); +} diff --git a/ui/src/components/AirportMarker.tsx b/ui/src/components/AirportMarker.tsx index f1ecc2d..09fc75c 100644 --- a/ui/src/components/AirportMarker.tsx +++ b/ui/src/components/AirportMarker.tsx @@ -34,18 +34,18 @@ export default function AirportMarker({ ); } -function getMarkerColor(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): string { +function getMarkerInfo(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): [string, number] { switch (flightCategory) { case 'IFR': - return '#ff0100'; + return ['#ff0100', 5]; case 'LIFR': - return '#7f007f'; + return ['#7f007f', 6]; case 'MVFR': - return '#00f'; + return ['#00f', 7]; case 'VFR': - return '#018000'; + return ['#018000', 8]; case 'UNKN': - return '#696969'; + return ['#696969', 4]; } } @@ -61,7 +61,8 @@ function createCustomIcon(airport: Airport): L.DivIcon { background-color: white; display: flex; align-items: center; - justify-content: center;"> + justify-content: center; + z-index: {info[1]}"> H `, @@ -72,15 +73,16 @@ function createCustomIcon(airport: Airport): L.DivIcon { } else { // Default to a filled circle. const flightCategory = airport.latest_metar?.flight_category || 'UNKN'; - const color = getMarkerColor(flightCategory); + const info = getMarkerInfo(flightCategory); if (flightCategory == 'UNKN') { return L.divIcon({ html: `
+ border-radius: 50%; + z-index: {info[1]}">
`, className: '', @@ -91,11 +93,12 @@ function createCustomIcon(airport: Airport): L.DivIcon { return L.divIcon({ html: `
+ border: 2px solid #fff; + z-index: {info[1]}">
`, className: '', diff --git a/ui/src/components/Header/HeaderModal.tsx b/ui/src/components/Header/HeaderModal.tsx index 89fb8f5..f6a7b21 100644 --- a/ui/src/components/Header/HeaderModal.tsx +++ b/ui/src/components/Header/HeaderModal.tsx @@ -157,7 +157,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) /> @@ -66,7 +69,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP - @@ -75,9 +78,9 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP Logout - {user.role == 'admin' && ( + {user.role == 'ADMIN' && ( - diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index f260820..6153af9 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -1,13 +1,12 @@ -import { useState } from 'react'; import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core'; import { useDisclosure, useToggle } from '@mantine/hooks'; import classes from './Header.module.css'; import { HeaderModal } from '@components/Header/HeaderModal.tsx'; import { notifications } from '@mantine/notifications'; -import Cookies from 'js-cookie'; -import { User } from '@lib/account.types.ts'; import { login, logout, register } from '@lib/account.ts'; import HeaderUser from '@components/Header/HeaderUser.tsx'; +import { useUserContext } from '@components/context/UserContext.tsx'; +import { Link } from 'react-router'; // const links = [ // { link: '/', label: 'Map' }, @@ -16,9 +15,9 @@ import HeaderUser from '@components/Header/HeaderUser.tsx'; // ]; export function Header() { + const { user, setUser } = useUserContext(); const [opened, { toggle }] = useDisclosure(false); const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']); - const [user, setUser] = useState(undefined); // const [active, setActive] = useState(links[0].link); // const navItems = links.map((link) => ( @@ -62,8 +61,8 @@ export function Header() { async function logoutUser(): Promise { await logout(); - Cookies.remove('logged_in'); setUser(undefined); + window.location.reload(); } async function registerUser({ @@ -131,12 +130,14 @@ export function Header() {
+ - - + + + Aviation Data - {/**/} + {/**/} {/* {navItems}*/} {/**/} diff --git a/ui/src/components/Profile.tsx b/ui/src/components/Profile.tsx new file mode 100644 index 0000000..160a3ae --- /dev/null +++ b/ui/src/components/Profile.tsx @@ -0,0 +1,18 @@ +import { Header } from '@components/Header'; +import { useUserContext } from '@components/context/UserContext.tsx'; +import { Navigate } from 'react-router'; + +export function Profile() { + const { user } = useUserContext(); + + if (user == undefined) { + return ; + } + + return ( + <> +
+ Todo: profile {user?.email} + + ); +} diff --git a/ui/src/components/context/UserContext.tsx b/ui/src/components/context/UserContext.tsx new file mode 100644 index 0000000..73f2e7d --- /dev/null +++ b/ui/src/components/context/UserContext.tsx @@ -0,0 +1,18 @@ +import { User } from '@lib/account.types.ts'; +import { createContext, useContext } from 'react'; + +interface UserContextType { + user?: User; + setUser: (user: User | undefined) => void; + loading: boolean; +} + +export const UserContext = createContext({ + user: undefined, + setUser: () => {}, + loading: true +}); + +export function useUserContext(): UserContextType { + return useContext(UserContext); +} diff --git a/ui/src/components/context/UserProvider.tsx b/ui/src/components/context/UserProvider.tsx new file mode 100644 index 0000000..8fd0f03 --- /dev/null +++ b/ui/src/components/context/UserProvider.tsx @@ -0,0 +1,33 @@ +import { ReactNode, useEffect, useState } from 'react'; +import { UserContext } from './UserContext.tsx'; +import { refresh } from '@lib/account.ts'; +import { User } from '@lib/account.types.ts'; +import { Center, Loader } from '@mantine/core'; + +export function UserProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(undefined); + const [loading, setLoading] = useState(true); + + useEffect(() => { + refresh().then((refreshUser) => { + if (refreshUser) { + setUser(refreshUser); + } else { + setUser(undefined); + } + setLoading(false); + }); + }, []); + + return ( + + {loading ? ( +
+ +
+ ) : ( + <>{children} + )} +
+ ); +} diff --git a/ui/src/index.css b/ui/src/index.css index e69de29..aabd639 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -0,0 +1,9 @@ +/* Ensure that the html and body take up the full height */ +html, +body, +#root { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} diff --git a/ui/src/lib/account.ts b/ui/src/lib/account.ts index a091af7..573eb32 100644 --- a/ui/src/lib/account.ts +++ b/ui/src/lib/account.ts @@ -1,6 +1,5 @@ -import Cookies from 'js-cookie'; import { getRequest, postRequest } from '.'; -import { RegisterUser, ResponseAuth, User } from './account.types'; +import { RegisterUser, User } from './account.types'; export async function login(email: string, password: string): Promise { const response = await postRequest('account/login', { email, password }); @@ -24,40 +23,11 @@ export async function logout() { return await postRequest('account/logout', {}); } -export async function refresh(refresh_token_rotation?: boolean): Promise { - const response = await getRequest('account/refresh', { refresh_token_rotation }); +export async function refresh(): Promise { + const response = await getRequest('account/session'); if (response?.status === 200) { return response.json(); } else { return undefined; } } - -export async function me(): Promise { - const response = await getRequest('account/me'); - if (response?.status === 200) { - return response.json(); - } else { - return undefined; - } -} - -/** - * Refreshes the logged_in cookie every interval. By default, the interval is 14 minutes. - * @param interval - * @returns interval id - */ -export function refreshLoggedIn(interval = 840000) { - let loggedIn = Cookies.get('logged_in'); - const id = setInterval(async () => { - const cookie = Cookies.get('logged_in'); - if (cookie != loggedIn) { - loggedIn = cookie; - const response = await refresh(true); - if (!response) { - Cookies.remove('logged_in'); - } - } - }, interval); - return id; -} diff --git a/ui/src/lib/account.types.ts b/ui/src/lib/account.types.ts index 76ac70a..24e0e61 100644 --- a/ui/src/lib/account.types.ts +++ b/ui/src/lib/account.types.ts @@ -1,8 +1,3 @@ -export interface ResponseAuth { - token: string; - user: User; -} - export interface RegisterUser { email: string; password: string; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 6e34171..d3eebe7 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -4,6 +4,10 @@ import './index.css'; import App from './App.tsx'; import { createTheme, MantineProvider } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; +import { UserProvider } from '@components/context/UserProvider.tsx'; +import { BrowserRouter, Route, Routes } from 'react-router'; +import { Profile } from '@components/Profile.tsx'; +import { Administration } from '@components/Administration.tsx'; const theme = createTheme({ fontFamily: 'Inter, sans-serif' @@ -11,9 +15,17 @@ const theme = createTheme({ createRoot(document.getElementById('root')!).render( - - - - + + + + + + } /> + } /> + } /> + + + + );