From 73ad49d2acbb683382aad0e0aec32ca154a28f85 Mon Sep 17 00:00:00 2001 From: Rita Rodrigues Date: Thu, 4 Jun 2026 11:03:56 +0100 Subject: [PATCH] feat: security refactor, UI improvements, and project setup Security/auth: - Refactor JWT handling: add role extraction from token, simplify JwtAuthenticationFilter - Consolidate frontend Auth.jsx + Login.jsx components - Update SecurityConfig, AuthController, UserService, UserRepository Backend cleanup: - Remove unused EmbeddingModelConfig, RepositoryConfig, SpringContextUtil - Expand DatabaseInitializer with seed data - Update build.gradle.kts dependencies Frontend: - ViewRecipe now fetches a single recipe by ID with full detail display - Navigation uses React Router Link instead of plain anchors - Update RecipeList, UserHome, AIRecipeCustomization, LiveCookingClasses Project setup: - Add .githooks/pre-commit hook - Add client/.env.example and application.properties.example - Add RecipeValidationTest, update BiteByteApplicationTests - Update README and .gitignore Co-authored-by: Cursor --- .githooks/pre-commit | 16 ++ .gitignore | 3 +- README.md | 204 ++++++++---------- build.gradle.kts | 26 +-- client/.env.example | 8 + client/src/App.jsx | 2 +- .../src/components/AIRecipeCustomization.jsx | 2 +- client/src/components/Auth.jsx | 84 -------- client/src/components/LiveCookingClasses.jsx | 20 +- client/src/components/Login.jsx | 70 ------ client/src/components/Navigation.jsx | 10 +- client/src/components/RecipeList.css | 6 + client/src/components/RecipeList.jsx | 8 +- client/src/components/UserHome.jsx | 10 +- client/src/components/ViewRecipe.jsx | 47 ++-- package-lock.json | 6 + .../main/bitebyte/BiteByteApplication.java | 5 +- .../ai/AIRecipeCustomizationController.java | 14 +- .../bitebyte/common/DatabaseInitializer.java | 194 ++++++++++++++++- .../bitebyte/common/EmbeddingModelConfig.java | 30 --- .../bitebyte/common/RepositoryConfig.java | 10 - .../bitebyte/recipe/RecipeController.java | 11 +- .../security/JwtAuthenticationFilter.java | 90 +------- .../com/main/bitebyte/security/JwtConfig.java | 4 +- .../com/main/bitebyte/security/JwtUtils.java | 37 +++- .../bitebyte/security/SecurityConfig.java | 17 +- .../main/bitebyte/user/AuthController.java | 15 +- .../main/bitebyte/user/UserRepository.java | 13 -- .../com/main/bitebyte/user/UserService.java | 6 +- .../main/bitebyte/util/SpringContextUtil.java | 22 -- .../resources/application.properties.example | 28 +++ .../bitebyte/BiteByteApplicationTests.java | 6 - .../bitebyte/recipe/RecipeValidationTest.java | 20 ++ 33 files changed, 540 insertions(+), 504 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 client/.env.example delete mode 100644 client/src/components/Auth.jsx delete mode 100644 client/src/components/Login.jsx create mode 100644 package-lock.json delete mode 100644 src/main/java/com/main/bitebyte/common/EmbeddingModelConfig.java delete mode 100644 src/main/java/com/main/bitebyte/common/RepositoryConfig.java delete mode 100644 src/main/java/com/main/bitebyte/util/SpringContextUtil.java create mode 100644 src/main/resources/application.properties.example create mode 100644 src/test/java/com/main/bitebyte/recipe/RecipeValidationTest.java diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..025014c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,16 @@ +#!/bin/sh + +blocked_files=" +src/main/resources/application.properties +src/main/resources/application.properties-local +" + +for file in $blocked_files; do + if git diff --cached --name-only --diff-filter=ACM | grep -Fx "$file" >/dev/null; then + echo "Blocked: $file must not be committed." + echo "Use src/main/resources/application.properties.example as the template instead." + exit 1 + fi +done + +exit 0 diff --git a/.gitignore b/.gitignore index c9f27dd..cedad91 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,10 @@ out/ ### VS Code ### .vscode/ -# Ignore application properties +# Local secrets — never commit; use application.properties.example as the template src/main/resources/application.properties src/main/resources/application.properties-local +!src/main/resources/application.properties.example # distribution folder /client/dist \ No newline at end of file diff --git a/README.md b/README.md index 2e3d8c9..3767262 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # BiteByte -BiteByte is a react web app powered by a Spring AI and MongoDB. +BiteByte is a full-stack recipe and live cooking platform built with React, Spring Boot, Spring AI and MongoDB. ![Demo of the app](/demobitebyte.gif) @@ -8,170 +8,152 @@ BiteByte is a react web app powered by a Spring AI and MongoDB. - [Features](#features) - [Architecture](#architecture) -- [Installation](#installation) -- [Usage](#usage) +- [Prerequisites](#prerequisites) +- [Setup](#setup) +- [Running the App](#running-the-app) - [API Endpoints](#api-endpoints) - [Contributing](#contributing) - [License](#license) ## Features -- AI Recipe Customization using OpenAI -- Add and View Recipes -- Participate in Live Cooking Classes -- User Authentication and Authorization -- Dark and Light Theme Toggle +- AI Recipe Customization powered by OpenAI +- Browse and add recipes with nutritional info +- Participate in live cooking classes +- User authentication and authorization (JWT) +- Dark and light theme toggle ## Architecture -The project is divided into two main parts: +The project is split into two parts: -1. **Backend**: A modular monolithic Spring Boot application that handles the API and business logic. The backend is divided into the following modules: - - **ai**: Handles AI-related functionalities using OpenAI. - - **common**: Contains common utilities and configurations used across other modules. - - **livecooking**: Manages live cooking classes. - - **recipe**: Manages recipe-related functionalities. - - **security**: Handles user authentication and authorization. - - **user**: Manages user-related functionalities. - - **util**: Contains utility classes and methods. +### Backend — Spring Boot (port 8080) -2. **Frontend**: A React application that provides the user interface. +A modular monolithic Spring Boot application with the following modules: -### Technologies Used +| Module | Responsibility | +|---|---| +| `ai` | OpenAI-powered recipe customization | +| `common` | Shared utilities, web config, DB initializer | +| `livecooking` | Live cooking class management | +| `recipe` | Recipe CRUD | +| `security` | JWT authentication filter | +| `user` | User registration and login | -- **Backend**: - - Java (Amazon Corretto) - - Spring Boot - - Spring AI - - MongoDB - - OpenAI API +### Frontend — React + Vite (port 5173) -- **Frontend**: - - React - - Vite +A React SPA that proxies all `/api` and `/auth` requests to the backend. -## Installation +### Technologies -### Prerequisites +- Java 21 (Amazon Corretto), Spring Boot 3, Spring AI, MongoDB +- React 18, Vite 4, React Router 6, Axios -- [Node.js](https://nodejs.org/) -- [npm](https://www.npmjs.com/) -- [Amazon Corretto](https://aws.amazon.com/corretto/) -- [Gradle](https://gradle.org/) -- [MongoDB](https://www.mongodb.com/) +## Prerequisites -### Backend Setup +- [Node.js](https://nodejs.org/) (v18+) and npm +- [Amazon Corretto 21](https://aws.amazon.com/corretto/) or any JDK 21 +- [MongoDB](https://www.mongodb.com/) running locally on port 27017 +- An [OpenAI API key](https://platform.openai.com/api-keys) -1. Clone the repository: - ```sh - git clone https://github.com/yourusername/BiteByte.git - cd BiteByte - ``` +## Setup -2. Navigate to the backend directory: - ```sh - cd src/main/java/com/main/bitebyte - ``` +### 1. Clone the repository -3. Build the project using Gradle: - ```sh - ./gradlew build - ``` +```sh +git clone https://github.com/yourusername/BiteByte.git +cd BiteByte +``` -4. Run the Spring Boot application: - ```sh - ./gradlew bootRun - ``` +### 2. Configure environment variables -5. Configure the application properties file: - ```properties - spring.data.mongodb.uri=mongodb+srv://:@.umrcw.mongodb.net/?retryWrites=true&w=majority&appName=bitebyte +Create the local env file (already gitignored): - spring.data.mongodb.database=bitebyte - spring.data.mongodb.auto-index-creation=false +```sh +mkdir -p .vscode +echo "OPENAI_API_KEY=your-key-here" > .vscode/.env +``` - spring.ai.openai.api-key= - spring.ai.openai.model=gpt-4o - spring.ai.openai.urls.base=https://api.openai.com - spring.ai.openai.urls.chat-completion=/v1/chat/completions +This file is read automatically by `./gradlew bootRun` and by the VS Code / Cursor debugger launch config. - # JWT Configuration - jwt.secret= - jwt.expiration=86400000 - jwt.expiration.ms=3600000 +### 3. Enable the pre-commit hook (optional but recommended) - # Add this line - app.jwtSecret=${jwt.secret} +Prevents local config files from being committed accidentally: - # Server configuration - server.port=8080 +```sh +git config core.hooksPath .githooks +``` - # Disable context path - server.servlet.context-path= +### 4. Install frontend dependencies - # Vector store configuration - spring.ai.vectorstore.name=vector_store - ``` +```sh +cd client && npm install && cd .. +``` -### Frontend Setup +## Running the App -1. Navigate to the client directory: - ```sh - cd client - ``` +Open two terminals from the project root: -2. Install the dependencies: - ```sh - npm install - ``` +**Terminal 1 — Backend** -3. Start the development server: - ```sh - npm start - ``` +```sh +./gradlew bootRun +``` -## Usage +Spring Boot starts on [http://localhost:8080](http://localhost:8080). On first run the database is seeded with sample recipes and live cooking classes. -1. Open your browser and navigate to `http://localhost:3000`. -2. Sign up or sign in to your account. -3. Use the navigation bar to access different features like adding recipes, viewing recipes, and participating in live cooking classes. -4. Customize recipes using the AI Recipe Customization feature. +**Terminal 2 — Frontend** + +```sh +cd client && npm start +``` + +Vite starts on [http://localhost:5173](http://localhost:5173) by default (increments automatically if the port is in use — check the terminal output for the exact URL). API calls are proxied automatically to the backend. ## API Endpoints ### Authentication -- `POST /api/auth/signup` - Sign up a new user -- `POST /api/auth/signin` - Sign in an existing user +| Method | Path | Description | +|---|---|---| +| POST | `/api/auth/signup` | Register a new user | +| POST | `/api/auth/signin` | Sign in and receive a JWT | ### Recipes -- `GET /api/recipes` - Get all recipes -- `GET /api/recipes/{id}` - Get a specific recipe by ID -- `POST /api/recipes` - Add a new recipe (Admin only) -- `PUT /api/recipes/{id}` - Update a recipe (Admin only) -- `DELETE /api/recipes/{id}` - Delete a recipe (Admin only) +| Method | Path | Description | +|---|---|---| +| GET | `/api/recipes/all` | List all recipes (public + personal) | +| GET | `/api/recipes/{id}` | Get a recipe by ID | +| POST | `/api/recipes` | Add a recipe _(admin only)_ | +| PUT | `/api/recipes/{id}` | Update a recipe _(admin only)_ | +| DELETE | `/api/recipes/{id}` | Delete a recipe _(admin only)_ | ### Live Cooking Classes -- `GET /api/live-classes` - Get all live cooking classes -- `POST /api/live-classes` - Add a new live cooking class (Admin only) +| Method | Path | Description | +|---|---|---| +| GET | `/api/live-cooking-classes` | List all classes | +| POST | `/api/live-cooking-classes` | Add a class _(admin only)_ | +| POST | `/api/live-cooking-classes/{id}/attend` | Attend a class | ### AI Recipe Customization -- `POST /api/ai-customization` - Customize a recipe using AI +| Method | Path | Description | +|---|---|---| +| POST | `/api/ai-recipe-customization` | Customise a recipe using AI | +| POST | `/api/ai-recipe-customization/save` | Save a customised recipe to your account | +| GET | `/api/ai-recipe-customization` | List all customisations | +| GET | `/api/ai-recipe-customization/{id}` | Get a customisation by ID | ## Contributing -Contributions are welcome! Please follow these steps to contribute: - 1. Fork the repository. -2. Create a new branch (`git checkout -b feature/your-feature`). -3. Make your changes. -4. Commit your changes (`git commit -m 'Add some feature'`). -5. Push to the branch (`git push origin feature/your-feature`). -6. Open a pull request. +2. Create a new branch: `git checkout -b feature/your-feature` +3. Commit your changes: `git commit -m 'Add some feature'` +4. Push to the branch: `git push origin feature/your-feature` +5. Open a pull request. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for details. diff --git a/build.gradle.kts b/build.gradle.kts index 4001dee..34782a8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,35 +19,37 @@ repositories { maven { url = uri("https://repo.spring.io/milestone") } } -val springAiVersion by extra { "1.0.0-M1" } - dependencies { implementation("org.springframework.boot:spring-boot-starter-data-mongodb") - implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive") - implementation("org.springframework.ai:spring-ai-mongodb-atlas-store-spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter:0.8.1") implementation("io.jsonwebtoken:jjwt-api:0.11.5") implementation("jakarta.annotation:jakarta.annotation-api:2.1.1") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") developmentOnly("org.springframework.boot:spring-boot-devtools") testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("io.projectreactor:reactor-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } -dependencyManagement { - imports { - mavenBom("org.springframework.ai:spring-ai-bom:${springAiVersion}") - } -} - tasks.withType { useJUnitPlatform() + testLogging { + events("passed", "failed", "skipped") + showStandardStreams = false + } } tasks.named("bootRun") { systemProperty("spring.devtools.restart.enabled", "false") + + val envFile = file(".vscode/.env") + if (envFile.exists()) { + envFile.readLines() + .filter { it.isNotBlank() && !it.startsWith("#") && it.contains("=") } + .forEach { line -> + val (key, value) = line.split("=", limit = 2) + environment(key.trim(), value.trim()) + } + } } \ No newline at end of file diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..1286f85 --- /dev/null +++ b/client/.env.example @@ -0,0 +1,8 @@ +# Copy this file to .env and fill in your local values. +# .env is gitignored and must not be committed. + +# Backend API base URL (no trailing slash). Defaults to http://localhost:8080 if unset. +VITE_API_URL=http://localhost:8080 + +# Set to "development" to enable dev-only features (e.g. Live Classes routes). +VITE_APP_ENV=development diff --git a/client/src/App.jsx b/client/src/App.jsx index a07ff64..2f4e2a8 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -12,7 +12,7 @@ import RecipeList from './components/RecipeList'; import LiveCookingClasses from './components/LiveCookingClasses'; import ViewRecipe from './components/ViewRecipe'; -axios.defaults.baseURL = 'http://localhost:8080'; +axios.defaults.baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8080'; function App() { const [isAuthenticated, setIsAuthenticated] = useState(false); diff --git a/client/src/components/AIRecipeCustomization.jsx b/client/src/components/AIRecipeCustomization.jsx index a2c9bad..b5fdb73 100644 --- a/client/src/components/AIRecipeCustomization.jsx +++ b/client/src/components/AIRecipeCustomization.jsx @@ -18,7 +18,7 @@ function AIRecipeCustomization() { const fetchRecipes = async () => { try { - const response = await axios.get('/api/recipes'); + const response = await axios.get('/api/recipes/all'); setRecipes(response.data); } catch (error) { console.error('Error fetching recipes:', error); diff --git a/client/src/components/Auth.jsx b/client/src/components/Auth.jsx deleted file mode 100644 index cd3efec..0000000 --- a/client/src/components/Auth.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState } from 'react'; -import axios from 'axios'; - -const Auth = ({ setIsAuthenticated, setUserRole }) => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [isLogin, setIsLogin] = useState(true); - - const handleSubmit = async (e) => { - e.preventDefault(); - if (isLogin) { - handleSignIn(); - } else { - handleSignUp(); - } - }; - - const handleSignIn = async () => { - try { - console.log('Attempting to sign in'); - const response = await axios.post('/api/auth/signin', { - username: username, - password: password - }); - console.log('Sign in response:', response.data); - if (response.data && response.data.token && response.data.roles) { - localStorage.setItem('token', response.data.token); - localStorage.setItem('userRole', response.data.roles[0]); - setIsAuthenticated(true); - setUserRole(response.data.roles[0]); - } else { - console.error('Invalid response format:', response.data); - } - } catch (error) { - console.error('Error signing in:', error.response || error); - if (error.response) { - console.error('Error status:', error.response.status); - console.error('Error data:', error.response.data); - } - // You might want to show an error message to the user here - } - }; - - const handleSignUp = async () => { - try { - console.log('Attempting to sign up'); - const response = await axios.post('/api/auth/signup', { - username: username, - password: password - }); - console.log('Sign up response:', response.data); - // After successful signup, you might want to automatically sign in the user - // or just show a message asking them to login - } catch (error) { - console.error('Error signing up:', error.response ? error.response.data : error.message); - } - }; - - return ( -
-

{isLogin ? 'Login' : 'Sign Up'}

-
- setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> - -
- -
- ); -}; - -export default Auth; \ No newline at end of file diff --git a/client/src/components/LiveCookingClasses.jsx b/client/src/components/LiveCookingClasses.jsx index adb330d..961e8f3 100644 --- a/client/src/components/LiveCookingClasses.jsx +++ b/client/src/components/LiveCookingClasses.jsx @@ -3,23 +3,35 @@ import axios from 'axios'; const LiveCookingClasses = () => { const [classes, setClasses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { - axios.get('/api/live-classes') + axios.get('/api/live-cooking-classes') .then(response => { setClasses(response.data); + setError(null); }) .catch(error => { console.error('There was an error fetching the live cooking classes!', error); - }); + if (error.response && error.response.status === 401) { + setError('Your session has expired. Please log in again.'); + } else { + setError('An error occurred while fetching live cooking classes. Please try again later.'); + } + }) + .finally(() => setLoading(false)); }, []); return (

Live Cooking Classes

- {classes.length === 0 ? ( + {loading &&

Loading classes...

} + {error &&
{error}
} + {!loading && !error && classes.length === 0 && (

No live cooking classes available.

- ) : ( + )} + {!loading && !error && classes.length > 0 && (
    {classes.map(liveClass => (
  • diff --git a/client/src/components/Login.jsx b/client/src/components/Login.jsx deleted file mode 100644 index a56b65f..0000000 --- a/client/src/components/Login.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState } from 'react'; -import axios from 'axios'; -import { useHistory } from 'react-router-dom'; - -const Login = ({ setIsAuthenticated, setUserRole }) => { - const [isSignUp, setIsSignUp] = useState(false); - const [form, setForm] = useState({ username: '', password: '', email: '' }); - const history = useHistory(); - - const handleSubmit = async (e) => { - e.preventDefault(); - try { - const endpoint = isSignUp ? '/api/auth/signup' : '/api/auth/signin'; - const response = await axios.post(endpoint, form); - - if (isSignUp) { - alert('Sign up successful! Please sign in.'); - setIsSignUp(false); - } else { - const { token, roles } = response.data; - localStorage.setItem('token', token); - localStorage.setItem('userRole', roles[0]); - axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; - setIsAuthenticated(true); - setUserRole(roles[0]); - history.push('/'); - } - } catch (error) { - console.error('Error:', error.response || error); - alert(isSignUp ? 'Error signing up. Please try again.' : 'Error signing in. Please check your credentials.'); - } - }; - - return ( -
    -

    {isSignUp ? 'Sign Up' : 'Sign In'}

    -
    - setForm({ ...form, username: e.target.value })} - placeholder="Username" - required - /> - setForm({ ...form, password: e.target.value })} - placeholder="Password" - required - /> - {isSignUp && ( - setForm({ ...form, email: e.target.value })} - placeholder="Email" - required - /> - )} - -
    - -
    - ); -}; - -export default Login; \ No newline at end of file diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index bdc4338..6c318c6 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import './Navigation.css'; const Navigation = ({ onLogout, onToggleTheme }) => { @@ -7,13 +8,12 @@ const Navigation = ({ onLogout, onToggleTheme }) => { return (