From 0083f20e8c31f9bcd2c19830f4d443276dba27ea Mon Sep 17 00:00:00 2001 From: Michael Staake Date: Mon, 3 Nov 2025 11:04:46 -0800 Subject: [PATCH] Initial version upload --- DOCKER.md | 147 +++++++++ Dockerfile | 31 ++ README.md | 39 +++ config.php | 35 +++ controllers/AuthController.php | 41 +++ controllers/MaintenanceController.php | 119 ++++++++ controllers/SettingsController.php | 99 ++++++ controllers/SetupController.php | 49 +++ controllers/VehicleController.php | 150 +++++++++ core/Controller.php | 41 +++ core/Database.php | 130 ++++++++ core/Router.php | 74 +++++ docker-compose.yml | 15 + example.htaccess | 14 + index.php | 71 +++++ models/Maintenance.php | 88 ++++++ models/QuickTask.php | 33 ++ models/User.php | 36 +++ models/Vehicle.php | 70 +++++ robots.txt | 2 + views/export.php | 114 +++++++ views/index.php | 210 +++++++++++++ views/login.php | 41 +++ views/settings.php | 129 ++++++++ views/setup.php | 78 +++++ views/vehicle.php | 425 ++++++++++++++++++++++++++ 26 files changed, 2281 insertions(+) create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config.php create mode 100644 controllers/AuthController.php create mode 100644 controllers/MaintenanceController.php create mode 100644 controllers/SettingsController.php create mode 100644 controllers/SetupController.php create mode 100644 controllers/VehicleController.php create mode 100644 core/Controller.php create mode 100644 core/Database.php create mode 100644 core/Router.php create mode 100644 docker-compose.yml create mode 100644 example.htaccess create mode 100644 index.php create mode 100644 models/Maintenance.php create mode 100644 models/QuickTask.php create mode 100644 models/User.php create mode 100644 models/Vehicle.php create mode 100644 robots.txt create mode 100644 views/export.php create mode 100644 views/index.php create mode 100644 views/login.php create mode 100644 views/settings.php create mode 100644 views/setup.php create mode 100644 views/vehicle.php diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..c23f6af --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,147 @@ +# Docker Setup for Mainty + +## Quick Start + +### Build and Run + +```bash +# Build the Docker image +docker-compose build + +# Start the container +docker-compose up -d + +# View logs +docker-compose logs -f +``` + +The application will be available at: **http://localhost:8080** + +### Stop the Application + +```bash +docker-compose down +``` + +## What's Included + +- **PHP 8.4** with Apache +- **SQLite** with PDO extension +- **mod_rewrite** enabled for pretty URLs +- **Persistent data** volume for the database + +## Configuration + +### Change Port + +Edit `docker-compose.yml` and modify the ports section: + +```yaml +ports: + - "3000:80" # Access on port 3000 instead +``` + +### Development Mode + +To sync code changes without rebuilding, uncomment this line in `docker-compose.yml`: + +```yaml +volumes: + - ./data:/var/www/html/data + - .:/var/www/html # Uncomment this line +``` + +Then restart: +```bash +docker-compose restart +``` + +## Data Persistence + +The SQLite database is stored in the `./data` directory on your host machine, which is mounted to the container. This means: + +- ✅ Your data persists even if you stop/remove the container +- ✅ You can backup by copying the `data` folder +- ✅ Database survives container rebuilds + +## Useful Commands + +```bash +# Rebuild after code changes +docker-compose up -d --build + +# View container logs +docker-compose logs -f mainty + +# Access container shell +docker-compose exec mainty bash + +# Stop and remove everything +docker-compose down -v + +# Check container status +docker-compose ps +``` + +## Troubleshooting + +### Permission Issues + +If you get permission errors with the database: + +```bash +chmod -R 755 data +``` + +### Port Already in Use + +If port 8080 is already in use, change it in `docker-compose.yml`: + +```yaml +ports: + - "8081:80" # Use a different port +``` + +### Reset Everything + +To start fresh: + +```bash +docker-compose down -v +rm -rf data/mainty.db +docker-compose up -d +``` + +Then access http://localhost:8080 to run setup again. + +## Production Deployment + +For production: + +1. Set `DEBUG` to `false` in `config.php` +2. Use environment variables for sensitive data +3. Consider using a reverse proxy (nginx) in front +4. Enable HTTPS + +Example production `docker-compose.yml`: + +```yaml +version: '3.8' + +services: + mainty: + build: . + container_name: mainty-app + ports: + - "8080:80" + volumes: + - ./data:/var/www/html/data + environment: + - APACHE_DOCUMENT_ROOT=/var/www/html + restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bf7b409 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM php:8.4-apache + +# Enable Apache mod_rewrite +RUN a2enmod rewrite + +# Install SQLite extensions +RUN apt-get update && apt-get install -y \ + sqlite3 \ + libsqlite3-dev \ + && docker-php-ext-install pdo_sqlite \ + && rm -rf /var/lib/apt/lists/* + +# Set the working directory +WORKDIR /var/www/html + +# Copy application files +COPY . /var/www/html/ + +# Create data directory and set permissions +RUN mkdir -p /var/www/html/data && \ + chown -R www-data:www-data /var/www/html/data && \ + chmod -R 755 /var/www/html/data + +# Configure Apache to allow .htaccess +RUN sed -i '//,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf + +# Expose port 80 +EXPOSE 80 + +# Start Apache +CMD ["apache2-foreground"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6e9ee76 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Mainty + +A simple PHP web app for tracking vehicle maintenance records. Free, simple, open source, and self-hosted. Runs on any Apache/PHP web server, or use Docker. Uses SQLite for easy backup, with built-in Export via JSON or HTML so you can import that data into something else or print records for your mechanic or the next owner of your vehicle. + +## Requirements + +- Apache web server +- PHP 8 or higher +- SQLite extension + +## Installation + +### Option 1: Traditional Web Server + +1. Upload the entire folder to your web server +2. Rename `example.htaccess` to `.htaccess` +3. If the app is not in the root directory, edit `.htaccess` and set the `RewriteBase`: + ```apache + RewriteBase /subfolder/ + ``` +4. Navigate to the app URL in your browser +5. If everything is configured correctly, you'll see the setup page +6. Set your password to initialize the database + +### Option 2: Docker + +```bash +docker-compose up -d +``` + +Then open http://localhost:8080 + +## First Time Setup + +When you first access the app, you'll be prompted to: +1. Create a password +2. Initialize the database + +That's it! You're ready to start tracking your vehicle maintenance. diff --git a/config.php b/config.php new file mode 100644 index 0000000..48e610a --- /dev/null +++ b/config.php @@ -0,0 +1,35 @@ +requireSetup(); + + if (isset($_SESSION['user_id'])) { + $this->redirect('/home'); + return; + } + + $this->view('login'); + } + + public function authenticate(): void { + $this->requireSetup(); + + $password = $_POST['password'] ?? ''; + + if (empty($password)) { + $_SESSION['error'] = 'Password is required'; + $this->redirect('/login'); + return; + } + + $user = new User(); + + if ($user->verifyPassword($password)) { + $_SESSION['user_id'] = $user->getId(); + $this->redirect('/home'); + } else { + $_SESSION['error'] = 'Invalid password'; + $this->redirect('/login'); + } + } + + public function logout(): void { + session_destroy(); + $this->redirect('/login'); + } +} diff --git a/controllers/MaintenanceController.php b/controllers/MaintenanceController.php new file mode 100644 index 0000000..4b7f01c --- /dev/null +++ b/controllers/MaintenanceController.php @@ -0,0 +1,119 @@ +maintenanceModel = new Maintenance(); + } + + public function add(): void { + $this->requireSetup(); + $this->requireAuth(); + + $vehicleId = (int)($_POST['vehicle_id'] ?? 0); + $name = trim($_POST['name'] ?? ''); + $date = trim($_POST['date'] ?? ''); + $mileage = str_replace(',', '', trim($_POST['mileage'] ?? '')); + + if (empty($name) || empty($date) || empty($mileage)) { + $_SESSION['error'] = 'Name, date, and mileage are required'; + $this->redirect('/vehicles/' . $vehicleId); + return; + } + + $data = [ + 'vehicle_id' => $vehicleId, + 'name' => $name, + 'date' => $date, + 'mileage' => (int)$mileage, + 'description' => trim($_POST['description'] ?? ''), + 'cost' => !empty($_POST['cost']) ? (float)$_POST['cost'] : null, + 'parts_list' => trim($_POST['parts_list'] ?? ''), + 'performed_by' => trim($_POST['performed_by'] ?? '') + ]; + + $this->maintenanceModel->create($data); + $_SESSION['success'] = 'Maintenance item added successfully'; + $this->redirect('/vehicles/' . $vehicleId); + } + + public function edit(string $id): void { + $this->requireSetup(); + $this->requireAuth(); + + $item = $this->maintenanceModel->getById((int)$id); + + if (!$item) { + $_SESSION['error'] = 'Maintenance item not found'; + $this->redirect('/home'); + return; + } + + $name = trim($_POST['name'] ?? ''); + $date = trim($_POST['date'] ?? ''); + $mileage = str_replace(',', '', trim($_POST['mileage'] ?? '')); + + if (empty($name) || empty($date) || empty($mileage)) { + $_SESSION['error'] = 'Name, date, and mileage are required'; + $this->redirect('/vehicles/' . $item['vehicle_id']); + return; + } + + $data = [ + 'name' => $name, + 'date' => $date, + 'mileage' => (int)$mileage, + 'description' => trim($_POST['description'] ?? ''), + 'cost' => !empty($_POST['cost']) ? (float)$_POST['cost'] : null, + 'parts_list' => trim($_POST['parts_list'] ?? ''), + 'performed_by' => trim($_POST['performed_by'] ?? '') + ]; + + if ($this->maintenanceModel->update((int)$id, $data)) { + $_SESSION['success'] = 'Maintenance item updated successfully'; + } else { + $_SESSION['error'] = 'Failed to update maintenance item'; + } + + $this->redirect('/vehicles/' . $item['vehicle_id']); + } + + public function delete(string $id): void { + $this->requireSetup(); + $this->requireAuth(); + + $item = $this->maintenanceModel->getById((int)$id); + + if (!$item) { + $_SESSION['error'] = 'Maintenance item not found'; + $this->redirect('/home'); + return; + } + + $vehicleId = $item['vehicle_id']; + + if ($this->maintenanceModel->delete((int)$id)) { + $_SESSION['success'] = 'Maintenance item deleted successfully'; + } else { + $_SESSION['error'] = 'Failed to delete maintenance item'; + } + + $this->redirect('/vehicles/' . $vehicleId); + } + + public function search(): void { + $this->requireSetup(); + $this->requireAuth(); + + $query = $_GET['q'] ?? ''; + + if (strlen($query) < 2) { + $this->json([]); + return; + } + + $results = $this->maintenanceModel->searchByName($query); + $this->json($results); + } +} diff --git a/controllers/SettingsController.php b/controllers/SettingsController.php new file mode 100644 index 0000000..9ea888e --- /dev/null +++ b/controllers/SettingsController.php @@ -0,0 +1,99 @@ +quickTaskModel = new QuickTask(); + $this->userModel = new User(); + } + + public function index(): void { + $this->requireSetup(); + $this->requireAuth(); + + $quickTasks = $this->quickTaskModel->getAll(); + + $this->view('settings', [ + 'quickTasks' => $quickTasks + ]); + } + + public function changePassword(): void { + $this->requireSetup(); + $this->requireAuth(); + + $currentPassword = $_POST['current_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + if (empty($currentPassword) || empty($newPassword) || empty($confirmPassword)) { + $_SESSION['error'] = 'All password fields are required'; + $this->redirect('/settings'); + return; + } + + if (!$this->userModel->verifyPassword($currentPassword)) { + $_SESSION['error'] = 'Current password is incorrect'; + $this->redirect('/settings'); + return; + } + + if ($newPassword !== $confirmPassword) { + $_SESSION['error'] = 'New passwords do not match'; + $this->redirect('/settings'); + return; + } + + if (strlen($newPassword) < 6) { + $_SESSION['error'] = 'New password must be at least 6 characters'; + $this->redirect('/settings'); + return; + } + + if ($this->userModel->updatePassword($newPassword)) { + $_SESSION['success'] = 'Password changed successfully'; + } else { + $_SESSION['error'] = 'Failed to change password'; + } + + $this->redirect('/settings'); + } + + public function addQuickTask(): void { + $this->requireSetup(); + $this->requireAuth(); + + $name = trim($_POST['name'] ?? ''); + + if (empty($name)) { + $_SESSION['error'] = 'Task name is required'; + $this->redirect('/settings'); + return; + } + + $id = $this->quickTaskModel->create($name); + + if ($id > 0) { + $_SESSION['success'] = 'Quick task added successfully'; + } else { + $_SESSION['error'] = 'Task already exists or could not be added'; + } + + $this->redirect('/settings'); + } + + public function deleteQuickTask(string $id): void { + $this->requireSetup(); + $this->requireAuth(); + + if ($this->quickTaskModel->delete((int)$id)) { + $_SESSION['success'] = 'Quick task deleted successfully'; + } else { + $_SESSION['error'] = 'Failed to delete quick task'; + } + + $this->redirect('/settings'); + } +} diff --git a/controllers/SetupController.php b/controllers/SetupController.php new file mode 100644 index 0000000..92ed3f1 --- /dev/null +++ b/controllers/SetupController.php @@ -0,0 +1,49 @@ +redirect('/home'); + return; + } + + $this->view('setup'); + } + + public function setup(): void { + if (Database::isInitialized()) { + $this->redirect('/home'); + return; + } + + $password = $_POST['password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + if (empty($password)) { + $_SESSION['error'] = 'Password is required'; + $this->redirect('/setup'); + return; + } + + if ($password !== $confirmPassword) { + $_SESSION['error'] = 'Passwords do not match'; + $this->redirect('/setup'); + return; + } + + if (strlen($password) < 6) { + $_SESSION['error'] = 'Password must be at least 6 characters'; + $this->redirect('/setup'); + return; + } + + if (Database::initialize($password)) { + $_SESSION['success'] = 'Setup completed successfully! Please login.'; + $this->redirect('/login'); + } else { + $_SESSION['error'] = 'Setup failed. Please try again.'; + $this->redirect('/setup'); + } + } +} diff --git a/controllers/VehicleController.php b/controllers/VehicleController.php new file mode 100644 index 0000000..de1c74f --- /dev/null +++ b/controllers/VehicleController.php @@ -0,0 +1,150 @@ +vehicleModel = new Vehicle(); + $this->maintenanceModel = new Maintenance(); + } + + public function index(): void { + $this->requireSetup(); + $this->requireAuth(); + + $vehicles = $this->vehicleModel->getAll(); + $viewMode = $_GET['view'] ?? 'grid'; // grid or list + + $this->view('index', [ + 'vehicles' => $vehicles, + 'viewMode' => $viewMode + ]); + } + + public function show(string $id): void { + $this->requireSetup(); + $this->requireAuth(); + + $vehicle = $this->vehicleModel->getById((int)$id); + + if (!$vehicle) { + $_SESSION['error'] = 'Vehicle not found'; + $this->redirect('/home'); + return; + } + + $maintenanceItems = $this->maintenanceModel->getByVehicleId((int)$id); + + $this->view('vehicle', [ + 'vehicle' => $vehicle, + 'maintenanceItems' => $maintenanceItems + ]); + } + + public function add(): void { + $this->requireSetup(); + $this->requireAuth(); + + $name = trim($_POST['name'] ?? ''); + + if (empty($name)) { + $_SESSION['error'] = 'Vehicle name is required'; + $this->redirect('/home'); + return; + } + + $data = [ + 'name' => $name, + 'year' => trim($_POST['year'] ?? ''), + 'make' => trim($_POST['make'] ?? ''), + 'model' => trim($_POST['model'] ?? ''), + 'color' => trim($_POST['color'] ?? ''), + 'license_plate' => trim($_POST['license_plate'] ?? '') + ]; + + $id = $this->vehicleModel->create($data); + $_SESSION['success'] = 'Vehicle added successfully'; + $this->redirect('/vehicles/' . $id); + } + + public function edit(string $id): void { + $this->requireSetup(); + $this->requireAuth(); + + $name = trim($_POST['name'] ?? ''); + + if (empty($name)) { + $_SESSION['error'] = 'Vehicle name is required'; + $this->redirect('/vehicles/' . $id); + return; + } + + $data = [ + 'name' => $name, + 'year' => trim($_POST['year'] ?? ''), + 'make' => trim($_POST['make'] ?? ''), + 'model' => trim($_POST['model'] ?? ''), + 'color' => trim($_POST['color'] ?? ''), + 'license_plate' => trim($_POST['license_plate'] ?? '') + ]; + + if ($this->vehicleModel->update((int)$id, $data)) { + $_SESSION['success'] = 'Vehicle updated successfully'; + } else { + $_SESSION['error'] = 'Failed to update vehicle'; + } + + $this->redirect('/vehicles/' . $id); + } + + public function delete(string $id): void { + $this->requireSetup(); + $this->requireAuth(); + + if ($this->vehicleModel->delete((int)$id)) { + $_SESSION['success'] = 'Vehicle deleted successfully'; + } else { + $_SESSION['error'] = 'Failed to delete vehicle'; + } + + $this->redirect('/home'); + } + + public function export(string $id, string $format): void { + $this->requireSetup(); + $this->requireAuth(); + + $vehicle = $this->vehicleModel->getById((int)$id); + + if (!$vehicle) { + $_SESSION['error'] = 'Vehicle not found'; + $this->redirect('/home'); + return; + } + + $maintenanceItems = $this->maintenanceModel->getByVehicleId((int)$id); + + if ($format === 'json') { + header('Content-Type: application/json'); + header('Content-Disposition: attachment; filename="vehicle-' . $id . '-export.json"'); + echo json_encode([ + 'vehicle' => $vehicle, + 'maintenance_items' => $maintenanceItems + ], JSON_PRETTY_PRINT); + exit; + } elseif ($format === 'html') { + header('Content-Type: text/html'); + header('Content-Disposition: attachment; filename="vehicle-' . $id . '-export.html"'); + + $this->view('export', [ + 'vehicle' => $vehicle, + 'maintenanceItems' => $maintenanceItems + ]); + exit; + } else { + $_SESSION['error'] = 'Invalid export format'; + $this->redirect('/vehicles/' . $id); + } + } +} diff --git a/core/Controller.php b/core/Controller.php new file mode 100644 index 0000000..140362e --- /dev/null +++ b/core/Controller.php @@ -0,0 +1,41 @@ +redirect('/login'); + } + } + + protected function requireSetup(): void { + if (!Database::isInitialized()) { + $this->redirect('/setup'); + } + } +} diff --git a/core/Database.php b/core/Database.php new file mode 100644 index 0000000..a3a678d --- /dev/null +++ b/core/Database.php @@ -0,0 +1,130 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + self::$instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + } catch (PDOException $e) { + die('Database connection failed: ' . $e->getMessage()); + } + } + + return self::$instance; + } + + public static function isInitialized(): bool { + if (!file_exists(DB_PATH)) { + return false; + } + + try { + $db = self::getInstance(); + $result = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='users'"); + return $result->fetch() !== false; + } catch (PDOException $e) { + return false; + } + } + + public static function initialize(string $password): bool { + try { + $db = self::getInstance(); + + // Create users table + $db->exec(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password_hash TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + + // Create vehicles table + $db->exec(" + CREATE TABLE IF NOT EXISTS vehicles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + year TEXT, + make TEXT, + model TEXT, + color TEXT, + license_plate TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + + // Create maintenance_items table + $db->exec(" + CREATE TABLE IF NOT EXISTS maintenance_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vehicle_id INTEGER NOT NULL, + name TEXT NOT NULL, + date DATE NOT NULL, + mileage INTEGER NOT NULL, + description TEXT, + cost REAL, + parts_list TEXT, + performed_by TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE + ) + "); + + // Create quick_tasks table (predefined maintenance items) + $db->exec(" + CREATE TABLE IF NOT EXISTS quick_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + "); + + // Insert default password + $stmt = $db->prepare("INSERT INTO users (password_hash) VALUES (?)"); + $stmt->execute([password_hash($password, PASSWORD_DEFAULT)]); + + // Insert default quick tasks + $defaultTasks = [ + 'Oil Change - Oil and Filter', + 'Oil Change - Oil Only', + 'Tire Rotation', + 'Air Filter Replacement', + 'Battery Replacement', + 'Spark Plug Replacement', + 'Wiper Blade Replacement - Front', + 'Wiper Blade Replacement - Rear', + 'Wiper Blade Replacement - Front and Rear', + 'Cabin Air Filter Replacement', + 'Wheel Alignment', + 'Tire Replacement', + 'Brake Fluid Change', + 'Power Steering Fluid Change', + 'Fuel Filter Replacement', + 'Serpentine Belt Replacement', + 'Timing Belt Replacement', + 'Inspection/Emissions Test', + 'Registration Renewed', + ]; + + $stmt = $db->prepare("INSERT INTO quick_tasks (name) VALUES (?)"); + foreach ($defaultTasks as $task) { + $stmt->execute([$task]); + } + + return true; + } catch (PDOException $e) { + error_log('Database initialization failed: ' . $e->getMessage()); + return false; + } + } +} diff --git a/core/Router.php b/core/Router.php new file mode 100644 index 0000000..c930bbf --- /dev/null +++ b/core/Router.php @@ -0,0 +1,74 @@ +addRoute('GET', $path, $handler); + } + + public function post(string $path, string $handler): void { + $this->addRoute('POST', $path, $handler); + } + + private function addRoute(string $method, string $path, string $handler): void { + $this->routes[] = [ + 'method' => $method, + 'path' => $path, + 'handler' => $handler + ]; + } + + public function dispatch(string $uri, string $method): void { + // Remove query string + $uri = parse_url($uri, PHP_URL_PATH); + + // Remove base directory from URI if running in subdirectory + $scriptName = dirname($_SERVER['SCRIPT_NAME']); + if ($scriptName !== '/' && strpos($uri, $scriptName) === 0) { + $uri = substr($uri, strlen($scriptName)); + } + + // Ensure URI starts with / + if (empty($uri) || $uri[0] !== '/') { + $uri = '/' . $uri; + } + + // Remove trailing slash except for root + if ($uri !== '/' && str_ends_with($uri, '/')) { + $uri = rtrim($uri, '/'); + } + + foreach ($this->routes as $route) { + if ($route['method'] !== $method) { + continue; + } + + $pattern = $this->convertToRegex($route['path']); + + if (preg_match($pattern, $uri, $matches)) { + array_shift($matches); // Remove full match + $this->callHandler($route['handler'], $matches); + return; + } + } + + // 404 Not Found + http_response_code(404); + echo '404 - Page Not Found
'; + echo 'Requested URI: ' . htmlspecialchars($uri) . '
'; + echo 'Request Method: ' . htmlspecialchars($method); + } + + private function convertToRegex(string $path): string { + $path = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([a-zA-Z0-9_-]+)', $path); + return '#^' . $path . '$#'; + } + + private function callHandler(string $handler, array $params): void { + [$controller, $method] = explode('@', $handler); + + $controllerInstance = new $controller(); + call_user_func_array([$controllerInstance, $method], $params); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4c24227 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.8' + +services: + mainty: + build: . + container_name: mainty-app + ports: + - "8080:80" + volumes: + - ./data:/var/www/html/data + # Uncomment below to sync code changes in development + # - .:/var/www/html + environment: + - APACHE_DOCUMENT_ROOT=/var/www/html + restart: unless-stopped diff --git a/example.htaccess b/example.htaccess new file mode 100644 index 0000000..c8fd1a9 --- /dev/null +++ b/example.htaccess @@ -0,0 +1,14 @@ +RewriteEngine On +#if this is in a folder, edit this. example.com/mainty/ would be /mainty/ +RewriteBase / + +# Redirect to HTTPS (optional, uncomment if needed) +# RewriteCond %{HTTPS} off +# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Don't rewrite files or directories +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# Rewrite everything else to index.php +RewriteRule ^(.*)$ index.php [L,QSA] diff --git a/index.php b/index.php new file mode 100644 index 0000000..8b5f878 --- /dev/null +++ b/index.php @@ -0,0 +1,71 @@ +get('/', 'SetupController@index'); +$router->get('/setup', 'SetupController@index'); +$router->post('/setup', 'SetupController@setup'); + +$router->get('/login', 'AuthController@login'); +$router->post('/login', 'AuthController@authenticate'); +$router->get('/logout', 'AuthController@logout'); + +$router->get('/home', 'VehicleController@index'); +$router->get('/vehicles', 'VehicleController@index'); +$router->post('/vehicles/add', 'VehicleController@add'); +$router->get('/vehicles/{id}', 'VehicleController@show'); +$router->post('/vehicles/{id}/edit', 'VehicleController@edit'); +$router->post('/vehicles/{id}/delete', 'VehicleController@delete'); +$router->get('/vehicles/{id}/export/{format}', 'VehicleController@export'); + +$router->post('/maintenance/add', 'MaintenanceController@add'); +$router->post('/maintenance/{id}/edit', 'MaintenanceController@edit'); +$router->post('/maintenance/{id}/delete', 'MaintenanceController@delete'); +$router->get('/maintenance/search', 'MaintenanceController@search'); + +$router->get('/settings', 'SettingsController@index'); +$router->post('/settings/password', 'SettingsController@changePassword'); +$router->post('/settings/quick-tasks/add', 'SettingsController@addQuickTask'); +$router->post('/settings/quick-tasks/{id}/delete', 'SettingsController@deleteQuickTask'); + +// Dispatch the request +try { + $router->dispatch($_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD']); +} catch (Exception $e) { + http_response_code(500); + echo "Error: " . $e->getMessage(); +} diff --git a/models/Maintenance.php b/models/Maintenance.php new file mode 100644 index 0000000..0896ee4 --- /dev/null +++ b/models/Maintenance.php @@ -0,0 +1,88 @@ +db = Database::getInstance(); + } + + public function getByVehicleId(int $vehicleId): array { + $stmt = $this->db->prepare(" + SELECT * FROM maintenance_items + WHERE vehicle_id = ? + ORDER BY date DESC, id DESC + "); + $stmt->execute([$vehicleId]); + return $stmt->fetchAll(); + } + + public function getById(int $id): ?array { + $stmt = $this->db->prepare("SELECT * FROM maintenance_items WHERE id = ?"); + $stmt->execute([$id]); + $item = $stmt->fetch(); + return $item ?: null; + } + + public function create(array $data): int { + $stmt = $this->db->prepare(" + INSERT INTO maintenance_items + (vehicle_id, name, date, mileage, description, cost, parts_list, performed_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "); + + $stmt->execute([ + $data['vehicle_id'], + $data['name'], + $data['date'], + $data['mileage'], + $data['description'] ?? null, + $data['cost'] ?? null, + $data['parts_list'] ?? null, + $data['performed_by'] ?? null + ]); + + return (int)$this->db->lastInsertId(); + } + + public function update(int $id, array $data): bool { + $stmt = $this->db->prepare(" + UPDATE maintenance_items + SET name = ?, date = ?, mileage = ?, description = ?, cost = ?, parts_list = ?, performed_by = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + + return $stmt->execute([ + $data['name'], + $data['date'], + $data['mileage'], + $data['description'] ?? null, + $data['cost'] ?? null, + $data['parts_list'] ?? null, + $data['performed_by'] ?? null, + $id + ]); + } + + public function delete(int $id): bool { + $stmt = $this->db->prepare("DELETE FROM maintenance_items WHERE id = ?"); + return $stmt->execute([$id]); + } + + public function searchByName(string $query): array { + // Search in both quick_tasks and existing maintenance items + $stmt = $this->db->prepare(" + SELECT DISTINCT name FROM ( + SELECT name FROM quick_tasks WHERE name LIKE ? + UNION + SELECT DISTINCT name FROM maintenance_items WHERE name LIKE ? + ) + ORDER BY name ASC + LIMIT 10 + "); + + $searchTerm = '%' . $query . '%'; + $stmt->execute([$searchTerm, $searchTerm]); + return $stmt->fetchAll(PDO::FETCH_COLUMN); + } +} diff --git a/models/QuickTask.php b/models/QuickTask.php new file mode 100644 index 0000000..449f558 --- /dev/null +++ b/models/QuickTask.php @@ -0,0 +1,33 @@ +db = Database::getInstance(); + } + + public function getAll(): array { + $stmt = $this->db->query("SELECT * FROM quick_tasks ORDER BY name ASC"); + return $stmt->fetchAll(); + } + + public function create(string $name): int { + try { + $stmt = $this->db->prepare("INSERT INTO quick_tasks (name) VALUES (?)"); + $stmt->execute([$name]); + return (int)$this->db->lastInsertId(); + } catch (PDOException $e) { + // Handle duplicate entry + if ($e->getCode() == 23000) { + return 0; + } + throw $e; + } + } + + public function delete(int $id): bool { + $stmt = $this->db->prepare("DELETE FROM quick_tasks WHERE id = ?"); + return $stmt->execute([$id]); + } +} diff --git a/models/User.php b/models/User.php new file mode 100644 index 0000000..b93add6 --- /dev/null +++ b/models/User.php @@ -0,0 +1,36 @@ +db = Database::getInstance(); + } + + public function verifyPassword(string $password): bool { + $stmt = $this->db->query("SELECT password_hash FROM users LIMIT 1"); + $user = $stmt->fetch(); + + if (!$user) { + return false; + } + + return password_verify($password, $user['password_hash']); + } + + public function updatePassword(string $newPassword): bool { + try { + $stmt = $this->db->prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP"); + return $stmt->execute([password_hash($newPassword, PASSWORD_DEFAULT)]); + } catch (PDOException $e) { + error_log('Password update failed: ' . $e->getMessage()); + return false; + } + } + + public function getId(): ?int { + $stmt = $this->db->query("SELECT id FROM users LIMIT 1"); + $user = $stmt->fetch(); + return $user ? (int)$user['id'] : null; + } +} diff --git a/models/Vehicle.php b/models/Vehicle.php new file mode 100644 index 0000000..5524ca8 --- /dev/null +++ b/models/Vehicle.php @@ -0,0 +1,70 @@ +db = Database::getInstance(); + } + + public function getAll(): array { + $stmt = $this->db->query(" + SELECT v.*, + COUNT(m.id) as maintenance_count, + MAX(m.date) as last_maintenance_date + FROM vehicles v + LEFT JOIN maintenance_items m ON v.id = m.vehicle_id + GROUP BY v.id + ORDER BY v.name ASC + "); + return $stmt->fetchAll(); + } + + public function getById(int $id): ?array { + $stmt = $this->db->prepare("SELECT * FROM vehicles WHERE id = ?"); + $stmt->execute([$id]); + $vehicle = $stmt->fetch(); + return $vehicle ?: null; + } + + public function create(array $data): int { + $stmt = $this->db->prepare(" + INSERT INTO vehicles (name, year, make, model, color, license_plate) + VALUES (?, ?, ?, ?, ?, ?) + "); + + $stmt->execute([ + $data['name'], + $data['year'] ?? null, + $data['make'] ?? null, + $data['model'] ?? null, + $data['color'] ?? null, + $data['license_plate'] ?? null + ]); + + return (int)$this->db->lastInsertId(); + } + + public function update(int $id, array $data): bool { + $stmt = $this->db->prepare(" + UPDATE vehicles + SET name = ?, year = ?, make = ?, model = ?, color = ?, license_plate = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "); + + return $stmt->execute([ + $data['name'], + $data['year'] ?? null, + $data['make'] ?? null, + $data['model'] ?? null, + $data['color'] ?? null, + $data['license_plate'] ?? null, + $id + ]); + } + + public function delete(int $id): bool { + $stmt = $this->db->prepare("DELETE FROM vehicles WHERE id = ?"); + return $stmt->execute([$id]); + } +} diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/views/export.php b/views/export.php new file mode 100644 index 0000000..be958ee --- /dev/null +++ b/views/export.php @@ -0,0 +1,114 @@ + + + + + + <?php echo htmlspecialchars($vehicle['name']); ?> - Maintenance Export + + + + +
+
+

+

Vehicle Maintenance Export

+
+ + +
+

Vehicle Information

+
+
+ Name: + +
+ +
+ Year: + +
+ + +
+ Make: + +
+ + +
+ Model: + +
+ + +
+ Color: + +
+ + +
+ License Plate: + +
+ +
+
+ + +
+

Maintenance History

+ + +

No maintenance records available.

+ +
+ +
+
+

+ + + $ + + +
+ +
+
+ Date: +
+
+ Mileage: miles +
+ +
+ Performed By: +
+ + +
+ Parts: +
+ +
+ + +
+ Description:
+ +
+ +
+ +
+ +
+ +
+

Exported on

+

Generated by

+
+
+ + diff --git a/views/index.php b/views/index.php new file mode 100644 index 0000000..3efe5b3 --- /dev/null +++ b/views/index.php @@ -0,0 +1,210 @@ + + + + + + <?php echo APP_NAME; ?> - Vehicles + + + + + +
+
+ +
+ + + +
+
+
+ +
+ +
+ +
+ + + +
+ +
+ + + +
+

My Vehicles

+
+ + +
+
+ + + +
+ +

No vehicles yet

+

Get started by adding your first vehicle

+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + +
VehicleDetailsLicense PlateRecordsLast Service
+
+
+ +
+
+
+
+
+
+ + + + + + + +
+
+ + +
+ + + + + + + diff --git a/views/login.php b/views/login.php new file mode 100644 index 0000000..ac19a3f --- /dev/null +++ b/views/login.php @@ -0,0 +1,41 @@ + + + + + + <?php echo APP_NAME; ?> - Login + + + + +
+

+ + +
+ +
+ + + +
+ +
+ + +
+
+ + +
+ + +
+
+ + diff --git a/views/settings.php b/views/settings.php new file mode 100644 index 0000000..aba0189 --- /dev/null +++ b/views/settings.php @@ -0,0 +1,129 @@ + + + + + + <?php echo APP_NAME; ?> - Settings + + + + + +
+
+ +
+ + + +
+
+
+ +
+ + + + +
+ +
+ + + +
+ +
+ + +

Settings

+ +
+ +
+

+ + Quick Tasks +

+

Predefined maintenance items for quick selection when adding records.

+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+ +
+
+ + +
+

+ + Change Password +

+

Update your login password.

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ + +
+

+ + Account +

+ + Logout + +
+
+ + diff --git a/views/setup.php b/views/setup.php new file mode 100644 index 0000000..86b5ccc --- /dev/null +++ b/views/setup.php @@ -0,0 +1,78 @@ + + + + + + <?php echo APP_NAME; ?> - Setup + + + + +
+

Setup

+ + +
+ +
+ + +
+

System Requirements

+
    +
  • + + PHP Version: + =')): ?> + (✓ Meets requirement) + + (✗ Requires 8.0+) + + +
  • +
  • + + + SQLite: Available (✓) + + + SQLite: Not Available (✗) + +
  • +
+
+ + =') && extension_loaded('pdo_sqlite'); + ?> + + +
+

System requirements not met!

+

Please ensure PHP 8.0+ and SQLite extension are available.

+
+ +
+
+ + +
+ +
+ + +
+ + +
+ +
+ + diff --git a/views/vehicle.php b/views/vehicle.php new file mode 100644 index 0000000..7f621fa --- /dev/null +++ b/views/vehicle.php @@ -0,0 +1,425 @@ + + + + + + <?php echo APP_NAME; ?> - <?php echo htmlspecialchars($vehicle['name']); ?> + + + + + +
+
+ +
+ + + +
+
+
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ +
+
+

+
+ +

+ + +

+ + +

+ + +

+ + +

+ + +

+ +
+
+
+
+ + + +
+
+
+ + +
+

Add Maintenance Record

+
+ +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+

Maintenance History ()

+ + +
+ +

No maintenance records yet

+
+ +
+ +
+
+
+
+

+ + + $ + + +
+
+
+ + +
+
+ + miles +
+ +
+ + +
+ + +
+ + +
+ +
+ +

+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+ + + + + + + + + + + + +