diff --git a/CLAUDE.md b/CLAUDE.md index d527de1..e51d2d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,12 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with: │ └── assets/ # Static assets (CSS, JS, images) ├── src/ │ ├── Bootstrap.php # Module initialization and DI -│ ├── GlobalsAccessor.php # Globals access wrapper +│ ├── ConfigAccessorInterface.php # Configuration access abstraction +│ ├── ConfigFactory.php # Factory for config accessor selection +│ ├── EnvironmentConfigAccessor.php # Env var config (for containers) +│ ├── GlobalsAccessor.php # Database-backed config (OpenEMR globals) +│ ├── GlobalConfig.php # Centralized configuration wrapper +│ ├── ModuleAccessGuard.php # Entry point access guard │ ├── Command/ # Console commands (removed after setup) │ │ └── SetupCommand.php │ ├── Controller/ # Request handlers @@ -101,11 +106,15 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with: │ ├── Service/ # Business logic │ │ ├── {Feature}Service.php │ │ └── ... -│ ├── Exception/ # Custom exception types -│ │ ├── {ModuleName}ExceptionInterface.php -│ │ ├── {ModuleName}Exception.php -│ │ └── {Specific}Exception.php -│ └── GlobalConfig.php # Configuration wrapper (you create this) +│ └── Exception/ # Custom exception types +│ ├── {ModuleName}ExceptionInterface.php +│ ├── {ModuleName}Exception.php +│ ├── {ModuleName}NotFoundException.php +│ ├── {ModuleName}UnauthorizedException.php +│ ├── {ModuleName}AccessDeniedException.php +│ ├── {ModuleName}ValidationException.php +│ ├── {ModuleName}ConfigurationException.php +│ └── {ModuleName}ApiException.php ├── templates/ │ └── {feature}/ │ ├── {view}.html.twig @@ -115,6 +124,92 @@ OpenEMR modules follow a **Symfony-inspired MVC architecture** with: └── openemr.bootstrap.php # Module loader ``` +## Configuration Abstraction Layer + +The template includes a flexible configuration system that supports both database-backed (OpenEMR globals) and environment variable configurations: + +### Key Components + +| File | Purpose | +|------|---------| +| `ConfigAccessorInterface` | Common interface for all config accessors | +| `GlobalsAccessor` | Reads config from OpenEMR database globals | +| `EnvironmentConfigAccessor` | Reads config from environment variables | +| `ConfigFactory` | Selects the appropriate accessor based on environment | +| `GlobalConfig` | Centralized wrapper providing typed access to all module config | + +### Usage Pattern + +```php +// In Bootstrap or entry points - factory determines config source +$configAccessor = ConfigFactory::createConfigAccessor(); +$config = new GlobalConfig($configAccessor); + +// Use typed getters +$isEnabled = $config->isEnabled(); // bool +$apiKey = $config->getApiKey(); // string (decrypted in DB mode) +``` + +### Environment Variable Mode + +Set `{VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1` to use environment variables instead of database: + +```bash +# Enable env config mode +export {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1 + +# Module configuration +export {VENDOR_PREFIX}_{MODULENAME}_ENABLED=true +export {VENDOR_PREFIX}_{MODULENAME}_API_KEY=your-api-key +``` + +Benefits: +- Container-friendly deployments (no database config needed) +- Secrets can be injected via environment +- Config is immutable (no admin UI editing) + +### Adding New Config Options + +1. Add constant in `GlobalConfig`: +```php +public const CONFIG_OPTION_API_KEY = '{vendor_prefix}_{modulename}_api_key'; +``` + +2. Add env var mapping in `EnvironmentConfigAccessor`: +```php +private const KEY_MAP = [ + GlobalConfig::CONFIG_OPTION_API_KEY => '{VENDOR_PREFIX}_{MODULENAME}_API_KEY', +]; +``` + +3. Add getter in `GlobalConfig`: +```php +public function getApiKey(): string +{ + return $this->configAccessor->getString(self::CONFIG_OPTION_API_KEY, ''); +} +``` + +4. Add to `getGlobalSettingSectionConfiguration()` for admin UI. + +## Module Access Guard + +The `ModuleAccessGuard` prevents access to module endpoints when: +1. Module is not registered in OpenEMR +2. Module is disabled in module management +3. Module's own 'enabled' setting is off + +```php +// At top of public entry points +$guardResponse = ModuleAccessGuard::check(Bootstrap::MODULE_NAME); +if ($guardResponse instanceof Response) { + $guardResponse->send(); + exit; +} +``` + +Returns 404 (not 403) to avoid leaking module presence. + ## Public Entry Point Pattern Public PHP files should be short! Just dispatch a controller and send a response. Follow this pattern: @@ -124,30 +219,68 @@ Public PHP files should be short! Just dispatch a controller and send a response /** * [Description of endpoint] * - * @package OpenCoreEMR + * @package {VendorName} * @link http://www.open-emr.org * @author [Author Name] - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 {VendorName} * @license GNU General Public License 3 */ +$sessionAllowWrite = true; + +// Load module autoloader before globals.php +require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../../../../globals.php'; use {VendorName}\Modules\{ModuleName}\Bootstrap; +use {VendorName}\Modules\{ModuleName}\ConfigFactory; +use {VendorName}\Modules\{ModuleName}\Exception\{ModuleName}ExceptionInterface; +use {VendorName}\Modules\{ModuleName}\GlobalsAccessor; +use {VendorName}\Modules\{ModuleName}\ModuleAccessGuard; +use Symfony\Component\HttpFoundation\Response; + +// Check if module is installed and enabled - return 404 if not +$guardResponse = ModuleAccessGuard::check(Bootstrap::MODULE_NAME); +if ($guardResponse instanceof Response) { + $guardResponse->send(); + exit; +} // Get kernel and bootstrap module -$kernel = $GLOBALS['kernel']; -$bootstrap = new Bootstrap($kernel->getEventDispatcher(), $kernel); +$globalsAccessor = new GlobalsAccessor(); +$kernel = $globalsAccessor->get('kernel'); +if (!$kernel instanceof \OpenEMR\Core\Kernel) { + throw new \RuntimeException('OpenEMR Kernel not available'); +} +$configAccessor = ConfigFactory::createConfigAccessor(); +$bootstrap = new Bootstrap($kernel->getEventDispatcher(), $kernel, $configAccessor); // Get controller $controller = $bootstrap->get{Feature}Controller(); // Determine action -$action = $_GET['action'] ?? $_POST['action'] ?? 'default'; +$actionParam = $_GET['action'] ?? $_POST['action'] ?? 'list'; +$action = is_string($actionParam) ? $actionParam : 'list'; // Dispatch to controller and send response -$response = $controller->dispatch($action, $_REQUEST); -$response->send(); +try { + $response = $controller->dispatch($action); + $response->send(); +} catch ({ModuleName}ExceptionInterface $e) { + error_log("Module error: " . $e->getMessage()); + $response = new Response( + "Error: " . htmlspecialchars($e->getMessage()), + $e->getStatusCode() + ); + $response->send(); +} catch (\Throwable $e) { + error_log("Unexpected error: " . $e->getMessage()); + $response = new Response( + "Error: An unexpected error occurred", + Response::HTTP_INTERNAL_SERVER_ERROR + ); + $response->send(); +} ``` ## Controller Pattern @@ -366,7 +499,7 @@ return new Response($content); ## Bootstrap Pattern -The `Bootstrap.php` class should provide factory methods for controllers: +The `Bootstrap.php` class should provide factory methods for controllers and accept an optional `ConfigAccessorInterface`: ```php globalsConfig = new GlobalConfig($this->globals); + // Use factory to determine config source if not provided + $configAccessor ??= ConfigFactory::createConfigAccessor(); + $this->globalsConfig = new GlobalConfig($configAccessor); $templatePath = \dirname(__DIR__) . DIRECTORY_SEPARATOR . "templates" . DIRECTORY_SEPARATOR; $twig = new TwigContainer($templatePath, $this->kernel); @@ -411,6 +546,27 @@ class Bootstrap } ``` +### Environment Config Mode in Admin UI + +When env config mode is enabled, the global settings section displays an informational message instead of editable fields: + +```php +// In addGlobalSettingsSection() +if ($this->globalsConfig->isEnvConfigMode()) { + $setting = new GlobalSetting( + xlt('Configuration Managed Externally'), + GlobalSetting::DATA_TYPE_HTML_DISPLAY_SECTION, + '', '', false + ); + $setting->addFieldOption( + GlobalSetting::DATA_TYPE_OPTION_RENDER_CALLBACK, + static fn() => xlt('This module is configured via environment variables.') + ); + $service->appendToSection($section, '{vendor_prefix}_{modulename}_env_config_notice', $setting); + return; +} +``` + ## Twig Template Pattern Templates should use OpenEMR's translation and sanitization filters: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d8fdce1..613a555 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -224,7 +224,7 @@ class YourFeatureService * @package OpenCoreEMR * @link http://www.open-emr.org * @author Your Name - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */ @@ -370,7 +370,7 @@ Create `openemr.bootstrap.php` for OpenEMR to discover your module: * @package OpenCoreEMR * @link http://www.open-emr.org * @author Your Name - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */ diff --git a/LICENSE b/LICENSE index 753663c..d94220e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 -Copyright (C) 2025 OpenCoreEMR Inc +Copyright (C) 2026 OpenCoreEMR Inc This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/docker/.env.dist b/docker/.env.dist new file mode 100644 index 0000000..3835642 --- /dev/null +++ b/docker/.env.dist @@ -0,0 +1,69 @@ +# Docker environment configuration for module development +# +# Copy this file to .env and customize as needed: +# cp .env.dist .env +# +# @package OpenCoreEMR +# @copyright Copyright (c) 2026 OpenCoreEMR Inc +# @license GNU General Public License 3 + +# ============================================================================= +# OpenEMR Configuration +# ============================================================================= + +# OpenEMR web ports +OPENEMR_PORT=8080 +OPENEMR_SSL_PORT=8443 + +# OpenEMR admin credentials (used during initial setup) +OPENEMR_ADMIN_USER=admin +OPENEMR_ADMIN_PASSWORD=pass + +# ============================================================================= +# MySQL/MariaDB Configuration +# ============================================================================= + +MYSQL_PORT=3306 +MYSQL_ROOT_PASSWORD=root +MYSQL_DATABASE=openemr +MYSQL_USER=openemr +MYSQL_PASSWORD=openemr + +# ============================================================================= +# Development Tools +# ============================================================================= + +# phpMyAdmin port (start with: docker compose --profile tools up -d) +PHPMYADMIN_PORT=8081 + +# ============================================================================= +# Module Configuration (Environment Mode) +# ============================================================================= +# Uncomment these to configure the module via environment variables +# instead of the OpenEMR admin UI. Useful for container deployments. + +# Enable environment-based configuration +# {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1 + +# Module settings +# {VENDOR_PREFIX}_{MODULENAME}_ENABLED=true +# {VENDOR_PREFIX}_{MODULENAME}_API_KEY=your-api-key-here + +# ============================================================================= +# Xdebug Configuration (Optional) +# ============================================================================= +# Uncomment and configure for IDE debugging + +# XDEBUG_MODE=debug +# XDEBUG_CLIENT_HOST=host.docker.internal +# XDEBUG_CLIENT_PORT=9003 + +# ============================================================================= +# Sibling OCE Module Paths (Optional) +# ============================================================================= +# If developing with other OCE modules, specify their paths here +# for integration testing. Paths are relative to this file. + +# OCE_AUTH_MODULE_PATH=../../oce-module-auth +# OCE_WEBPACK_MODULE_PATH=../../oce-module-webpack +# OCE_CARDINAL_UI_PATH=../../oce-module-cardinal-ui diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..38cbbf8 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,6 @@ +# Docker environment configuration +# Copy .env.dist to .env for local development +.env + +# Docker-generated files +.docker-sql-installed diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..cfcc866 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,91 @@ +# Docker Compose configuration for module development +# +# This provides a complete OpenEMR development environment with the module +# automatically mounted and registered. +# +# Usage: +# cp .env.dist .env # Configure environment +# docker compose up -d # Start services +# docker compose logs -f # View logs +# +# The module is mounted at: +# /var/www/localhost/htdocs/openemr/interface/modules/custom_modules/{vendor-prefix}-module-{modulename} +# +# @package OpenCoreEMR +# @copyright Copyright (c) 2026 OpenCoreEMR Inc +# @license GNU General Public License 3 + +services: + openemr: + image: openemr/openemr:7.0.2 + ports: + - "${OPENEMR_PORT:-8080}:80" + - "${OPENEMR_SSL_PORT:-8443}:443" + volumes: + # Mount module source into OpenEMR + - ..:/var/www/localhost/htdocs/openemr/interface/modules/custom_modules/{vendor-prefix}-module-{modulename}:ro + # Persist OpenEMR data + - openemr-sites:/var/www/localhost/htdocs/openemr/sites + - openemr-logs:/var/log + # Auto-registration script + - ./scripts/enable-module.sh:/var/www/localhost/htdocs/openemr/interface/modules/custom_modules/{vendor-prefix}-module-{modulename}/docker/scripts/enable-module.sh:ro + environment: + MYSQL_HOST: mysql + MYSQL_ROOT_PASS: ${MYSQL_ROOT_PASSWORD:-root} + MYSQL_USER: ${MYSQL_USER:-openemr} + MYSQL_PASS: ${MYSQL_PASSWORD:-openemr} + MYSQL_DATABASE: ${MYSQL_DATABASE:-openemr} + OE_USER: ${OPENEMR_ADMIN_USER:-admin} + OE_PASS: ${OPENEMR_ADMIN_PASSWORD:-pass} + # Module-specific environment config (optional) + # {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG: "1" + # {VENDOR_PREFIX}_{MODULENAME}_ENABLED: "true" + depends_on: + mysql: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + mysql: + image: mariadb:10.11 + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_general_ci + ports: + - "${MYSQL_PORT:-3306}:3306" + volumes: + - mysql-data:/var/lib/mysql + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} + MYSQL_DATABASE: ${MYSQL_DATABASE:-openemr} + MYSQL_USER: ${MYSQL_USER:-openemr} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-openemr} + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + # Optional: phpMyAdmin for database management + phpmyadmin: + image: phpmyadmin/phpmyadmin + profiles: + - tools + ports: + - "${PHPMYADMIN_PORT:-8081}:80" + environment: + PMA_HOST: mysql + PMA_USER: root + PMA_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root} + depends_on: + - mysql + +volumes: + mysql-data: + openemr-sites: + openemr-logs: diff --git a/docker/scripts/enable-module.sh b/docker/scripts/enable-module.sh new file mode 100755 index 0000000..d60d086 --- /dev/null +++ b/docker/scripts/enable-module.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Auto-registers the module in OpenEMR on first container boot. +# +# This script is run automatically when the Docker container starts, +# enabling the module without manual UI interaction. +# +# @package OpenCoreEMR +# @copyright Copyright (c) 2026 OpenCoreEMR Inc +# @license GNU General Public License 3 + +set -e + +MODULE_NAME="{vendor-prefix}-module-{modulename}" +MODULE_DIR="/var/www/localhost/htdocs/openemr/interface/modules/custom_modules/${MODULE_NAME}" + +# Database connection parameters (from environment) +DB_HOST="${MYSQL_HOST:-mysql}" +DB_USER="${MYSQL_USER:-openemr}" +DB_PASS="${MYSQL_PASS:-openemr}" +DB_NAME="${MYSQL_DATABASE:-openemr}" + +echo "==> Checking module registration for ${MODULE_NAME}..." + +# Wait for database to be ready +until mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" -e "SELECT 1" "$DB_NAME" &>/dev/null; do + echo "Waiting for database..." + sleep 2 +done + +# Check if module is already registered +REGISTERED=$(mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" -N -e \ + "SELECT COUNT(*) FROM modules WHERE mod_directory = '${MODULE_NAME}'" "$DB_NAME" 2>/dev/null || echo "0") + +if [ "$REGISTERED" = "0" ]; then + echo "==> Registering module ${MODULE_NAME}..." + + # Get next mod_id + NEXT_ID=$(mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" -N -e \ + "SELECT COALESCE(MAX(mod_id), 0) + 1 FROM modules" "$DB_NAME") + + # Register module + mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < Module registered successfully with mod_id ${NEXT_ID}" +else + echo "==> Module already registered, skipping..." +fi + +# Run module's install SQL if exists and not already run +INSTALL_SQL="${MODULE_DIR}/table.sql" +INSTALL_MARKER="${MODULE_DIR}/.docker-sql-installed" + +if [ -f "$INSTALL_SQL" ] && [ ! -f "$INSTALL_MARKER" ]; then + echo "==> Running module install SQL..." + mysql -h"$DB_HOST" -u"$DB_USER" -p"$DB_PASS" "$DB_NAME" < "$INSTALL_SQL" + touch "$INSTALL_MARKER" + echo "==> Install SQL completed" +fi + +echo "==> Module setup complete" diff --git a/openemr.bootstrap.php b/openemr.bootstrap.php index 256caa2..472d45d 100644 --- a/openemr.bootstrap.php +++ b/openemr.bootstrap.php @@ -9,17 +9,17 @@ * @package OpenCoreEMR * @link http://www.open-emr.org * @author Your Name - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */ -namespace {VendorName}\Modules\{ModuleName}; +namespace OpenCoreEMR\Modules\{ModuleName}; /** * @var \OpenEMR\Core\ModulesClassLoader $classLoader Injected by the OpenEMR module loader */ $classLoader->registerNamespaceIfNotExists( - '{VendorName}\\Modules\\{ModuleName}\\', + 'OpenCoreEMR\\Modules\\{ModuleName}\\', __DIR__ . DIRECTORY_SEPARATOR . 'src' ); diff --git a/public/.gitkeep b/public/.gitkeep deleted file mode 100644 index 7a7645a..0000000 --- a/public/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# This file ensures the public directory is tracked by git -# Delete this file once you add your own public entry points diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..b5bfb7c --- /dev/null +++ b/public/index.php @@ -0,0 +1,75 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +$sessionAllowWrite = true; + +// Load module autoloader before globals.php so our classes are available +// even when OpenEMR hasn't bootstrapped the module (e.g., module not registered) +require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../../../../globals.php'; + +use OpenCoreEMR\Modules\{ModuleName}\Bootstrap; +use OpenCoreEMR\Modules\{ModuleName}\ConfigFactory; +use OpenCoreEMR\Modules\{ModuleName}\Exception\{ModuleName}HttpExceptionInterface; +use OpenCoreEMR\Modules\{ModuleName}\GlobalsAccessor; +use OpenCoreEMR\Modules\{ModuleName}\ModuleAccessGuard; +use Symfony\Component\HttpFoundation\Response; + +// Check if module is installed and enabled - return 404 if not +$guardResponse = ModuleAccessGuard::check(Bootstrap::MODULE_NAME); +if ($guardResponse instanceof Response) { + $guardResponse->send(); + exit; +} + +// Get kernel and bootstrap module +$globalsAccessor = new GlobalsAccessor(); +$kernel = $globalsAccessor->get('kernel'); +if (!$kernel instanceof \OpenEMR\Core\Kernel) { + throw new \RuntimeException('OpenEMR Kernel not available'); +} +$configAccessor = ConfigFactory::createConfigAccessor(); +$bootstrap = new Bootstrap($kernel->getEventDispatcher(), $kernel, $configAccessor); + +// Get controller +$controller = $bootstrap->getExampleController(); + +// Determine action +$actionParam = $_GET['action'] ?? $_POST['action'] ?? 'list'; +$action = is_string($actionParam) ? $actionParam : 'list'; + +// Build params array from request +$params = array_merge($_GET, $_POST); +$params['_self'] = $_SERVER['PHP_SELF'] ?? '/'; + +// Dispatch to controller and send response +try { + $response = $controller->dispatch($action, $params); + $response->send(); +} catch ({ModuleName}HttpExceptionInterface $e) { + error_log("Module error: " . $e->getMessage()); + $response = new Response( + "Error: " . htmlspecialchars($e->getMessage()), + $e->getStatusCode() + ); + $response->send(); +} catch (\Throwable $e) { + error_log("Unexpected error: " . $e->getMessage()); + $response = new Response( + "Error: An unexpected error occurred", + Response::HTTP_INTERNAL_SERVER_ERROR + ); + $response->send(); +} diff --git a/src/AuthAdapter.php b/src/AuthAdapter.php new file mode 100644 index 0000000..5607741 --- /dev/null +++ b/src/AuthAdapter.php @@ -0,0 +1,260 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +use OpenEMR\Common\Logging\SystemLogger; + +/** + * Unified authentication adapter supporting OAuth 2.0 and legacy sessions. + * + * Usage: + * $auth = new AuthAdapter(); + * $auth->requireAuth(); // Redirects if not authenticated + * $user = $auth->getCurrentUser(); + * + * The adapter automatically detects whether OAuth (oce-module-auth) is available + * and uses the appropriate authentication mechanism. + */ +class AuthAdapter +{ + private readonly SystemLogger $logger; + private readonly GlobalsAccessor $globals; + + /** + * Cached authentication state + * + * @var array|null + */ + private ?array $currentUser = null; + + public function __construct(?GlobalsAccessor $globals = null) + { + $this->globals = $globals ?? new GlobalsAccessor(); + $this->logger = new SystemLogger(); + } + + /** + * Check if OAuth module (oce-module-auth) is available + * + * This checks if the auth module's SessionBridge class exists, + * indicating the OAuth infrastructure is installed. + */ + public function isOAuthAvailable(): bool + { + // Check for auth module's SessionBridge class + return class_exists('OpenCoreEMR\\Modules\\Auth\\SessionBridge'); + } + + /** + * Get current authenticated user information + * + * Returns user data from OAuth token claims or legacy session. + * Returns null if no user is authenticated. + * + * @return array|null User data with keys: id, username, site_id, etc. + */ + public function getCurrentUser(): ?array + { + if ($this->currentUser !== null) { + return $this->currentUser; + } + + // Try OAuth first if available + if ($this->isOAuthAvailable()) { + $user = $this->getUserFromOAuth(); + if ($user !== null) { + $this->currentUser = $user; + return $user; + } + } + + // Fall back to legacy session + $user = $this->getUserFromSession(); + $this->currentUser = $user; + return $user; + } + + /** + * Require authentication, redirecting if not authenticated + * + * Call this at the top of protected pages/endpoints. + * For API endpoints, throws an exception instead of redirecting. + * + * @param bool $isApi Whether this is an API endpoint (throws instead of redirects) + * @throws {ModuleName}UnauthorizedException When not authenticated and $isApi is true + */ + public function requireAuth(bool $isApi = false): void + { + $user = $this->getCurrentUser(); + + if ($user === null) { + $this->logger->debug('AuthAdapter: Authentication required but no user found'); + + if ($isApi) { + throw new Exception\{ModuleName}UnauthorizedException('Authentication required'); + } + + // Redirect to login page + $webroot = $this->globals->getString('webroot', ''); + $loginUrl = $webroot . '/interface/login/login.php?site=' . $this->getSiteId(); + header('Location: ' . $loginUrl); + exit; + } + } + + /** + * Check if user has a specific permission/scope + * + * Supports both OAuth scopes and legacy ACL checks. + * + * @param string $scope OAuth scope (e.g., 'api:module:example') or ACL section + * @param string|null $aclValue ACL value for legacy checks (e.g., 'read', 'write') + */ + public function hasPermission(string $scope, ?string $aclValue = null): bool + { + $user = $this->getCurrentUser(); + if ($user === null) { + return false; + } + + // Check OAuth scopes if available + if ($this->isOAuthAvailable() && isset($user['scopes'])) { + return in_array($scope, $user['scopes'], true); + } + + // Fall back to legacy ACL check + if ($aclValue !== null && function_exists('acl_check')) { + return acl_check($scope, $aclValue); + } + + return false; + } + + /** + * Get the current Bearer token (OAuth only) + * + * Returns null if using legacy session authentication. + */ + public function getApiToken(): ?string + { + if (!$this->isOAuthAvailable()) { + return null; + } + + // Check Authorization header + $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (str_starts_with($authHeader, 'Bearer ')) { + return substr($authHeader, 7); + } + + return null; + } + + /** + * Get the current site ID + */ + public function getSiteId(): string + { + $user = $this->getCurrentUser(); + return $user['site_id'] ?? $_SESSION['site_id'] ?? 'default'; + } + + /** + * Check if user is authenticated + */ + public function isAuthenticated(): bool + { + return $this->getCurrentUser() !== null; + } + + /** + * Get user from OAuth token + * + * @return array|null + */ + private function getUserFromOAuth(): ?array + { + $token = $this->getApiToken(); + if ($token === null) { + return null; + } + + try { + // Attempt to use SessionBridge if available + if (class_exists('OpenCoreEMR\\Modules\\Auth\\SessionBridge')) { + /** @var object $bridge */ + $bridge = new \OpenCoreEMR\Modules\Auth\SessionBridge(); + if (method_exists($bridge, 'validateToken')) { + $tokenData = $bridge->validateToken($token); + if ($tokenData !== null) { + return [ + 'id' => $tokenData['user_id'] ?? null, + 'username' => $tokenData['username'] ?? null, + 'site_id' => $tokenData['site_id'] ?? 'default', + 'scopes' => $tokenData['scopes'] ?? [], + 'auth_method' => 'oauth', + ]; + } + } + } + } catch (\Throwable $e) { + $this->logger->error('AuthAdapter: OAuth validation failed: ' . $e->getMessage()); + } + + return null; + } + + /** + * Get user from legacy PHP session + * + * @return array|null + */ + private function getUserFromSession(): ?array + { + // Ensure session is started + if (session_status() === PHP_SESSION_NONE) { + return null; + } + + // Check for authenticated session + $authUser = $_SESSION['authUser'] ?? null; + if ($authUser === null || $authUser === '') { + return null; + } + + return [ + 'id' => $_SESSION['authUserID'] ?? $_SESSION['authId'] ?? null, + 'username' => $authUser, + 'site_id' => $_SESSION['site_id'] ?? 'default', + 'scopes' => [], // Legacy sessions don't have scopes + 'auth_method' => 'session', + ]; + } + + /** + * Clear cached user data + * + * Call this after logout or when authentication state changes. + */ + public function clearCache(): void + { + $this->currentUser = null; + } +} diff --git a/src/Bootstrap.php b/src/Bootstrap.php index c521f59..d553641 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -13,16 +13,18 @@ * @package OpenCoreEMR * @link http://www.open-emr.org * @author Your Name - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */ -namespace {VendorName}\Modules\{ModuleName}; +namespace OpenCoreEMR\Modules\{ModuleName}; +use OpenCoreEMR\Modules\{ModuleName}\Controller\ExampleController; use OpenEMR\Common\Logging\SystemLogger; use OpenEMR\Common\Twig\TwigContainer; use OpenEMR\Core\Kernel; use OpenEMR\Events\Globals\GlobalsInitializedEvent; +use OpenEMR\Events\RestApiExtend\RestApiRouteEvent; use OpenEMR\Menu\MenuEvent; use OpenEMR\Services\Globals\GlobalSetting; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -38,9 +40,10 @@ class Bootstrap public function __construct( private readonly EventDispatcherInterface $eventDispatcher, private readonly Kernel $kernel = new Kernel(), - private readonly GlobalsAccessor $globals = new GlobalsAccessor() + ?ConfigAccessorInterface $configAccessor = null ) { - $this->globalsConfig = new GlobalConfig($this->globals); + $configAccessor ??= ConfigFactory::createConfigAccessor(); + $this->globalsConfig = new GlobalConfig($configAccessor); $templatePath = \dirname(__DIR__) . DIRECTORY_SEPARATOR . "templates" . DIRECTORY_SEPARATOR; $twig = new TwigContainer($templatePath, $this->kernel); @@ -74,10 +77,60 @@ public function subscribeToEvents(): void $this->logger->debug('Module is enabled and configured'); + // Register API routes + $this->addApiRoutes(); + // Add additional event listeners here // Example: $this->eventDispatcher->addListener(SomeEvent::class, $this->handleSomeEvent(...)); } + /** + * Register REST API routes for the module + */ + public function addApiRoutes(): void + { + $this->eventDispatcher->addListener( + RestApiRouteEvent::class, + $this->registerApiRoutes(...) + ); + } + + /** + * Register module API routes with OpenEMR's REST API + * + * Routes are registered under /api/{module-name}/ namespace. + * All routes require authentication via OAuth or session. + */ + public function registerApiRoutes(RestApiRouteEvent $event): void + { + // Skip if module is disabled + if (!$this->globalsConfig->isEnabled()) { + return; + } + + // Example API routes - uncomment and modify as needed: + // + // $event->addRoute( + // 'GET', + // '/api/{vendor-prefix}-{modulename}/items', + // [ExampleController::class, 'listItems'] + // ); + // + // $event->addRoute( + // 'GET', + // '/api/{vendor-prefix}-{modulename}/items/{id}', + // [ExampleController::class, 'getItem'] + // ); + // + // $event->addRoute( + // 'POST', + // '/api/{vendor-prefix}-{modulename}/items', + // [ExampleController::class, 'createItem'] + // ); + + $this->logger->debug('Module API routes registered'); + } + /** * Register global settings for the module */ @@ -98,17 +151,35 @@ public function addGlobalSettingsSection(GlobalsInitializedEvent $event): void $section = xlt("Your Module Name"); $service->createSection($section, 'Module'); + // In env config mode, show informational message instead of editable fields + if ($this->globalsConfig->isEnvConfigMode()) { + $setting = new GlobalSetting( + xlt('Configuration Managed Externally'), + GlobalSetting::DATA_TYPE_HTML_DISPLAY_SECTION, + '', + '', + false + ); + $setting->addFieldOption( + GlobalSetting::DATA_TYPE_OPTION_RENDER_CALLBACK, + static fn() => xlt( + 'This module is configured via environment variables by deployment administrators.' + ) + ); + $service->appendToSection($section, '{vendor_prefix}_{modulename}_env_config_notice', $setting); + return; + } + $settings = $this->globalsConfig->getGlobalSettingSectionConfiguration(); foreach ($settings as $key => $config) { - $value = $this->globals->get($key, $config['default']); $service->appendToSection( $section, $key, new GlobalSetting( xlt($config['title']), $config['type'], - $value, + $config['default'], xlt($config['description']), true ) @@ -132,6 +203,10 @@ public function addMenuItems(): void */ public function addModuleMenuItem(MenuEvent $event): void { + if (!$this->globalsConfig->isEnabled()) { + return; + } + $menu = $event->getMenu(); $menuItem = new \stdClass(); @@ -143,7 +218,6 @@ public function addModuleMenuItem(MenuEvent $event): void $menuItem->icon = 'fa-star'; // Change to appropriate FontAwesome icon $menuItem->children = []; $menuItem->acl_req = ["patients", "demo"]; // Adjust ACL requirements as needed - $menuItem->global_req = ["yourmodule_enabled"]; // Match your global setting name foreach ($menu as $item) { if ($item->menu_id == 'modimg') { @@ -154,7 +228,20 @@ public function addModuleMenuItem(MenuEvent $event): void } /** - * Factory method for your controllers + * Get ExampleController instance + * + * Factory method for creating the controller with all dependencies. + */ + public function getExampleController(): ExampleController + { + return new ExampleController( + $this->globalsConfig, + $this->twig + ); + } + + /** + * Factory method template for your controllers * * Add factory methods here to create controller instances with proper dependencies. * Example: diff --git a/src/Command/SetupCommand.php b/src/Command/SetupCommand.php index c01227d..b0b02c2 100644 --- a/src/Command/SetupCommand.php +++ b/src/Command/SetupCommand.php @@ -6,7 +6,7 @@ * @package OpenCoreEMR * @link http://www.open-emr.org * @author OpenCoreEMR - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */ diff --git a/src/ConfigAccessorInterface.php b/src/ConfigAccessorInterface.php new file mode 100644 index 0000000..60c4d5a --- /dev/null +++ b/src/ConfigAccessorInterface.php @@ -0,0 +1,52 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +use OpenEMR\Core\Kernel; + +/** + * Abstraction for configuration access, matching Symfony ParameterBag's typed accessor methods. + * Allows configuration to be read from either OpenEMR globals (database) or environment variables. + */ +interface ConfigAccessorInterface +{ + /** + * Get the OpenEMR Kernel instance + */ + public function getKernel(): ?Kernel; + + /** + * Get a configuration value + */ + public function get(string $key, mixed $default = null): mixed; + + /** + * Get a string configuration value + */ + public function getString(string $key, string $default = ''): string; + + /** + * Get a boolean configuration value + */ + public function getBoolean(string $key, bool $default = false): bool; + + /** + * Get an integer configuration value + */ + public function getInt(string $key, int $default = 0): int; + + /** + * Check if a configuration key exists + */ + public function has(string $key): bool; +} diff --git a/src/ConfigFactory.php b/src/ConfigFactory.php new file mode 100644 index 0000000..bc82ef3 --- /dev/null +++ b/src/ConfigFactory.php @@ -0,0 +1,54 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +/** + * Factory for creating the appropriate configuration accessor. + * + * When {VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG=1 is set, configuration is read from + * environment variables instead of the database-backed OpenEMR globals. + * + * This pattern allows modules to be configured via environment variables in + * containerized deployments while still supporting traditional database configuration. + */ +class ConfigFactory +{ + /** + * Environment variable that toggles environment-based configuration. + * Set to "1" or "true" to enable environment variable configuration mode. + */ + public const ENV_CONFIG_VAR = '{VENDOR_PREFIX}_{MODULENAME}_ENV_CONFIG'; + + /** + * Check if environment-only config mode is enabled + */ + public static function isEnvConfigMode(): bool + { + $value = getenv(self::ENV_CONFIG_VAR); + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Create the appropriate config accessor based on environment + * + * Returns EnvironmentConfigAccessor when ENV_CONFIG_VAR is set to true, + * otherwise returns GlobalsAccessor for database-backed configuration. + */ + public static function createConfigAccessor(): ConfigAccessorInterface + { + if (self::isEnvConfigMode()) { + return new EnvironmentConfigAccessor(); + } + return new GlobalsAccessor(); + } +} diff --git a/src/Console/Command/AbstractModuleCommand.php b/src/Console/Command/AbstractModuleCommand.php index 8789038..988c3fb 100644 --- a/src/Console/Command/AbstractModuleCommand.php +++ b/src/Console/Command/AbstractModuleCommand.php @@ -13,9 +13,9 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; -use {VendorName}\Modules\{ModuleName}\Console\ModuleInstaller; +use OpenCoreEMR\Modules\{ModuleName}\Console\ModuleInstaller; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -90,7 +90,7 @@ protected function bootstrapOpenEmr(InputInterface $input, OutputInterface $outp protected function getInstaller(): ModuleInstaller { // TEMPLATE: Update namespace in instanceof check - if (!$this->installer instanceof \{VendorName}\Modules\{ModuleName}\Console\ModuleInstaller) { + if (!$this->installer instanceof \OpenCoreEMR\Modules\{ModuleName}\Console\ModuleInstaller) { throw new \RuntimeException('Installer not initialized. Call bootstrapOpenEmr() first.'); } return $this->installer; diff --git a/src/Console/Command/ModuleDisableCommand.php b/src/Console/Command/ModuleDisableCommand.php index 18d2f0d..66c1659 100644 --- a/src/Console/Command/ModuleDisableCommand.php +++ b/src/Console/Command/ModuleDisableCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/Command/ModuleEnableCommand.php b/src/Console/Command/ModuleEnableCommand.php index 2e32e21..0cca693 100644 --- a/src/Console/Command/ModuleEnableCommand.php +++ b/src/Console/Command/ModuleEnableCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/Command/ModuleInstallCommand.php b/src/Console/Command/ModuleInstallCommand.php index d664611..44c20a0 100644 --- a/src/Console/Command/ModuleInstallCommand.php +++ b/src/Console/Command/ModuleInstallCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/Command/ModuleInstallEnableCommand.php b/src/Console/Command/ModuleInstallEnableCommand.php index ab50afe..bb83725 100644 --- a/src/Console/Command/ModuleInstallEnableCommand.php +++ b/src/Console/Command/ModuleInstallEnableCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/Command/ModuleListCommand.php b/src/Console/Command/ModuleListCommand.php index eceeb00..2f77e00 100644 --- a/src/Console/Command/ModuleListCommand.php +++ b/src/Console/Command/ModuleListCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/Command/ModuleRegisterCommand.php b/src/Console/Command/ModuleRegisterCommand.php index 5abee2b..4f3b670 100644 --- a/src/Console/Command/ModuleRegisterCommand.php +++ b/src/Console/Command/ModuleRegisterCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/Command/ModuleUnregisterCommand.php b/src/Console/Command/ModuleUnregisterCommand.php index fa3b9df..8e02fbd 100644 --- a/src/Console/Command/ModuleUnregisterCommand.php +++ b/src/Console/Command/ModuleUnregisterCommand.php @@ -11,7 +11,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console\Command; +namespace OpenCoreEMR\Modules\{ModuleName}\Console\Command; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; diff --git a/src/Console/ModuleInstaller.php b/src/Console/ModuleInstaller.php index 9839a61..f38aee4 100644 --- a/src/Console/ModuleInstaller.php +++ b/src/Console/ModuleInstaller.php @@ -13,7 +13,7 @@ */ // TEMPLATE: Update namespace to match your module -namespace {VendorName}\Modules\{ModuleName}\Console; +namespace OpenCoreEMR\Modules\{ModuleName}\Console; use OpenEMR\Common\Database\QueryUtils; use Symfony\Component\Console\Output\OutputInterface; diff --git a/src/Controller/ExampleController.php b/src/Controller/ExampleController.php new file mode 100644 index 0000000..92c920d --- /dev/null +++ b/src/Controller/ExampleController.php @@ -0,0 +1,141 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Controller; + +use OpenCoreEMR\Modules\{ModuleName}\Exception\{ModuleName}AccessDeniedException; +use OpenCoreEMR\Modules\{ModuleName}\Exception\{ModuleName}ValidationException; +use OpenCoreEMR\Modules\{ModuleName}\GlobalConfig; +use OpenEMR\Common\Csrf\CsrfUtils; +use OpenEMR\Common\Logging\SystemLogger; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use Twig\Environment; + +/** + * Example controller showing proper patterns for OpenEMR modules. + * + * Key patterns demonstrated: + * - Constructor dependency injection + * - Dispatch method routing actions to handlers + * - Returning Response objects (never void) + * - CSRF token validation on POST + * - Throwing custom exceptions (never die/exit) + * - Using Twig for HTML rendering + */ +class ExampleController +{ + private readonly SystemLogger $logger; + + public function __construct( + private readonly GlobalConfig $config, + private readonly Environment $twig + ) { + $this->logger = new SystemLogger(); + } + + /** + * Dispatch action to appropriate method + * + * @param array $params Request parameters (typically from $_GET + $_POST) + */ + public function dispatch(string $action, array $params = []): Response + { + return match ($action) { + 'view' => $this->showView($params), + 'create' => $this->handleCreate($params), + 'list' => $this->showList(), + default => $this->showList(), + }; + } + + /** + * Show the list view + */ + private function showList(): Response + { + $this->logger->debug('Showing list view'); + + $content = $this->twig->render('example/list.html.twig', [ + 'title' => 'Module Dashboard', + 'items' => [], + 'csrf_token' => CsrfUtils::collectCsrfToken(), + 'webroot' => $this->config->getWebroot(), + ]); + + return new Response($content); + } + + /** + * Show single item view + * + * @param array $params + */ + private function showView(array $params): Response + { + $id = $params['id'] ?? null; + + if (empty($id)) { + throw new {ModuleName}ValidationException('Item ID is required'); + } + + // Example: fetch item from database + // $item = $this->service->findById((int) $id); + // if (!$item) { + // throw new {ModuleName}NotFoundException("Item not found: {$id}"); + // } + + $content = $this->twig->render('example/view.html.twig', [ + 'title' => 'View Item', + 'item' => ['id' => $id, 'name' => 'Example Item'], + 'csrf_token' => CsrfUtils::collectCsrfToken(), + 'webroot' => $this->config->getWebroot(), + ]); + + return new Response($content); + } + + /** + * Handle create action (POST) + * + * @param array $params + */ + private function handleCreate(array $params): Response + { + // Validate CSRF token + $csrfToken = $params['csrf_token'] ?? ''; + if (!CsrfUtils::verifyCsrfToken($csrfToken)) { + throw new {ModuleName}AccessDeniedException('CSRF token verification failed'); + } + + // Validate input + $name = $params['name'] ?? ''; + if (empty($name)) { + throw new {ModuleName}ValidationException('Name is required'); + } + + // Process the request + try { + // Example: create item via service + // $this->service->create(['name' => $name]); + + $this->logger->debug("Created item: {$name}"); + + // Redirect back to list - use PHP_SELF from params for testability + $redirectUrl = $params['_self'] ?? '/'; + return new RedirectResponse($redirectUrl); + } catch (\Throwable $e) { + $this->logger->error("Error creating item: " . $e->getMessage()); + throw $e; + } + } +} diff --git a/src/EnvironmentConfigAccessor.php b/src/EnvironmentConfigAccessor.php new file mode 100644 index 0000000..fdc08ce --- /dev/null +++ b/src/EnvironmentConfigAccessor.php @@ -0,0 +1,130 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +use OpenEMR\Core\Kernel; +use Symfony\Component\HttpFoundation\ParameterBag; + +/** + * Reads module configuration from environment variables. + * + * This accessor is used when {VENDOR_PREFIX}_{MODULE_NAME}_ENV_CONFIG=1 is set, + * bypassing the database-backed globals system entirely for module config. + * OpenEMR system values (OE_SITE_DIR, webroot, etc.) are still delegated + * to GlobalsAccessor since they are not module configuration. + * + * @internal Use ConfigFactory::createConfigAccessor() instead of instantiating directly + */ +class EnvironmentConfigAccessor implements ConfigAccessorInterface +{ + /** + * Maps internal config keys ({vendor_prefix}_{modulename}_*) to env var names ({VENDOR_PREFIX}_{MODULENAME}_*) + * + * Override this in your implementation to map your specific config keys. + * Example: + * GlobalConfig::CONFIG_OPTION_ENABLED => '{VENDOR_PREFIX}_{MODULENAME}_ENABLED', + * GlobalConfig::CONFIG_OPTION_API_KEY => '{VENDOR_PREFIX}_{MODULENAME}_API_KEY', + * + * @var array + */ + private const KEY_MAP = [ + GlobalConfig::CONFIG_OPTION_ENABLED => '{VENDOR_PREFIX}_{MODULENAME}_ENABLED', + // Add your config option mappings here + // GlobalConfig::CONFIG_OPTION_API_KEY => '{VENDOR_PREFIX}_{MODULENAME}_API_KEY', + // GlobalConfig::CONFIG_OPTION_API_SECRET => '{VENDOR_PREFIX}_{MODULENAME}_API_SECRET', + ]; + + /** @var ParameterBag */ + private readonly ParameterBag $envBag; + private readonly GlobalsAccessor $globalsAccessor; + + public function __construct() + { + $this->globalsAccessor = new GlobalsAccessor(); + $this->envBag = $this->buildEnvBag(); + } + + /** + * Build a ParameterBag from environment variables + * + * @return ParameterBag + */ + private function buildEnvBag(): ParameterBag + { + $params = []; + foreach (self::KEY_MAP as $configKey => $envVar) { + $value = getenv($envVar); + if ($value !== false) { + $params[$configKey] = $value; + } + } + return new ParameterBag($params); + } + + public function get(string $key, mixed $default = null): mixed + { + // Check if this is a module config key + if (isset(self::KEY_MAP[$key])) { + return $this->envBag->get($key, $default); + } + + // For OpenEMR system values, delegate to GlobalsAccessor + return $this->globalsAccessor->get($key, $default); + } + + public function getString(string $key, string $default = ''): string + { + if (isset(self::KEY_MAP[$key])) { + return $this->envBag->getString($key, $default); + } + + return $this->globalsAccessor->getString($key, $default); + } + + public function getBoolean(string $key, bool $default = false): bool + { + if (isset(self::KEY_MAP[$key])) { + return $this->envBag->getBoolean($key, $default); + } + + return $this->globalsAccessor->getBoolean($key, $default); + } + + public function getInt(string $key, int $default = 0): int + { + if (isset(self::KEY_MAP[$key])) { + return $this->envBag->getInt($key, $default); + } + + return $this->globalsAccessor->getInt($key, $default); + } + + public function has(string $key): bool + { + if (isset(self::KEY_MAP[$key])) { + return $this->envBag->has($key); + } + + return $this->globalsAccessor->has($key); + } + + /** + * Get the OpenEMR Kernel instance + * + * Delegates to GlobalsAccessor since Kernel is always from OpenEMR globals. + */ + public function getKernel(): ?Kernel + { + return $this->globalsAccessor->getKernel(); + } +} diff --git a/src/Exception/{ModuleName}AccessDeniedException.php b/src/Exception/{ModuleName}AccessDeniedException.php new file mode 100644 index 0000000..dbfbf5a --- /dev/null +++ b/src/Exception/{ModuleName}AccessDeniedException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +class {ModuleName}AccessDeniedException extends {ModuleName}Exception +{ + public function getStatusCode(): int + { + return 403; + } +} diff --git a/src/Exception/{ModuleName}ApiException.php b/src/Exception/{ModuleName}ApiException.php new file mode 100644 index 0000000..c9732fd --- /dev/null +++ b/src/Exception/{ModuleName}ApiException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +class {ModuleName}ApiException extends {ModuleName}Exception +{ + public function getStatusCode(): int + { + return 502; + } +} diff --git a/src/Exception/{ModuleName}ConfigurationException.php b/src/Exception/{ModuleName}ConfigurationException.php new file mode 100644 index 0000000..a656baa --- /dev/null +++ b/src/Exception/{ModuleName}ConfigurationException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +class {ModuleName}ConfigurationException extends {ModuleName}Exception +{ + public function getStatusCode(): int + { + return 500; + } +} diff --git a/src/Exception/{ModuleName}Exception.php b/src/Exception/{ModuleName}Exception.php new file mode 100644 index 0000000..b478302 --- /dev/null +++ b/src/Exception/{ModuleName}Exception.php @@ -0,0 +1,27 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +/** + * Abstract base class for HTTP-aware module exceptions. + * + * Extend this class for exceptions that should map to specific HTTP status codes. + * For exceptions without HTTP semantics, implement {ModuleName}ExceptionInterface directly. + */ +abstract class {ModuleName}Exception extends \RuntimeException implements {ModuleName}HttpExceptionInterface +{ + /** + * Get the HTTP status code for this exception + */ + abstract public function getStatusCode(): int; +} diff --git a/src/Exception/{ModuleName}ExceptionInterface.php b/src/Exception/{ModuleName}ExceptionInterface.php new file mode 100644 index 0000000..ccc2578 --- /dev/null +++ b/src/Exception/{ModuleName}ExceptionInterface.php @@ -0,0 +1,24 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +/** + * Marker interface for all module exceptions. + * + * This interface identifies exceptions originating from this module without + * imposing any specific contract. Use {ModuleName}HttpExceptionInterface for + * exceptions that map to HTTP status codes. + */ +interface {ModuleName}ExceptionInterface extends \Throwable +{ +} diff --git a/src/Exception/{ModuleName}HttpExceptionInterface.php b/src/Exception/{ModuleName}HttpExceptionInterface.php new file mode 100644 index 0000000..797ccf5 --- /dev/null +++ b/src/Exception/{ModuleName}HttpExceptionInterface.php @@ -0,0 +1,27 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +/** + * Interface for module exceptions that map to HTTP status codes. + * + * Implement this interface for exceptions that should result in specific + * HTTP responses (e.g., 404 Not Found, 403 Forbidden). + */ +interface {ModuleName}HttpExceptionInterface extends {ModuleName}ExceptionInterface +{ + /** + * Get the HTTP status code for this exception + */ + public function getStatusCode(): int; +} diff --git a/src/Exception/{ModuleName}NotFoundException.php b/src/Exception/{ModuleName}NotFoundException.php new file mode 100644 index 0000000..8a40d85 --- /dev/null +++ b/src/Exception/{ModuleName}NotFoundException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +class {ModuleName}NotFoundException extends {ModuleName}Exception +{ + public function getStatusCode(): int + { + return 404; + } +} diff --git a/src/Exception/{ModuleName}UnauthorizedException.php b/src/Exception/{ModuleName}UnauthorizedException.php new file mode 100644 index 0000000..37da5c0 --- /dev/null +++ b/src/Exception/{ModuleName}UnauthorizedException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +class {ModuleName}UnauthorizedException extends {ModuleName}Exception +{ + public function getStatusCode(): int + { + return 401; + } +} diff --git a/src/Exception/{ModuleName}ValidationException.php b/src/Exception/{ModuleName}ValidationException.php new file mode 100644 index 0000000..3ad7825 --- /dev/null +++ b/src/Exception/{ModuleName}ValidationException.php @@ -0,0 +1,21 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Exception; + +class {ModuleName}ValidationException extends {ModuleName}Exception +{ + public function getStatusCode(): int + { + return 400; + } +} diff --git a/src/GlobalConfig.php b/src/GlobalConfig.php new file mode 100644 index 0000000..8cd9b76 --- /dev/null +++ b/src/GlobalConfig.php @@ -0,0 +1,145 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +use OpenEMR\Services\Globals\GlobalSetting; + +class GlobalConfig +{ + private readonly bool $isEnvConfigMode; + + public function __construct( + private readonly ConfigAccessorInterface $configAccessor = new GlobalsAccessor() + ) { + $this->isEnvConfigMode = ConfigFactory::isEnvConfigMode(); + } + + /** + * Configuration option constants. + * Define your module's configuration keys here. + */ + public const CONFIG_OPTION_ENABLED = '{vendor_prefix}_{modulename}_enabled'; + + // Add your configuration option constants here, for example: + // public const CONFIG_OPTION_API_KEY = '{vendor_prefix}_{modulename}_api_key'; + // public const CONFIG_OPTION_API_SECRET = '{vendor_prefix}_{modulename}_api_secret'; + + /** + * Check if configuration is managed via environment variables + */ + public function isEnvConfigMode(): bool + { + return $this->isEnvConfigMode; + } + + /** + * Check if the module is enabled + */ + public function isEnabled(): bool + { + return $this->configAccessor->getBoolean(self::CONFIG_OPTION_ENABLED, false); + } + + /** + * Check if the module is properly configured + * + * Override this method to add your module's configuration validation. + * Return true if all required configuration is present and valid. + */ + public function isConfigured(): bool + { + // Add your configuration validation logic here + // Example: + // return !empty($this->getApiKey()) && !empty($this->getApiSecret()); + return true; + } + + // Add your configuration getter methods here, for example: + // + // public function getApiKey(): string + // { + // return $this->configAccessor->getString(self::CONFIG_OPTION_API_KEY, ''); + // } + // + // /** + // * Get API secret with decryption support for database mode + // */ + // public function getApiSecret(): string + // { + // $value = $this->configAccessor->getString(self::CONFIG_OPTION_API_SECRET, ''); + // if ($value !== '' && $value !== '0') { + // // In env config mode, secrets are stored as plaintext (no encryption) + // if ($this->isEnvConfigMode) { + // return $value; + // } + // // In database mode, decrypt the value + // $cryptoGen = new \OpenEMR\Common\Crypto\CryptoGen(); + // $decrypted = $cryptoGen->decryptStandard($value); + // return $decrypted !== false ? $decrypted : ''; + // } + // return ''; + // } + + /** + * Get OpenEMR webroot path + */ + public function getWebroot(): string + { + return $this->configAccessor->getString('webroot', ''); + } + + /** + * Get assets static relative path + */ + public function getAssetsStaticRelative(): string + { + return $this->configAccessor->getString('assets_static_relative', ''); + } + + /** + * Get the global settings section configuration for the admin UI + * + * This method returns an array of configuration options that will be + * displayed in the OpenEMR admin settings page. + * + * @return array>> + */ + public function getGlobalSettingSectionConfiguration(): array + { + return [ + self::CONFIG_OPTION_ENABLED => [ + 'title' => 'Enable Module', + 'description' => 'Enable this module', + 'type' => GlobalSetting::DATA_TYPE_BOOL, + 'default' => false + ], + // Add your configuration options here, for example: + // + // self::CONFIG_OPTION_API_KEY => [ + // 'title' => 'API Key', + // 'description' => 'Your API key', + // 'type' => GlobalSetting::DATA_TYPE_TEXT, + // 'default' => '' + // ], + // self::CONFIG_OPTION_API_SECRET => [ + // 'title' => 'API Secret', + // 'description' => 'Your API secret (stored encrypted)', + // 'type' => GlobalSetting::DATA_TYPE_ENCRYPTED, + // 'default' => '' + // ], + ]; + } +} diff --git a/src/GlobalsAccessor.php b/src/GlobalsAccessor.php index 040d2a6..bf95507 100644 --- a/src/GlobalsAccessor.php +++ b/src/GlobalsAccessor.php @@ -12,18 +12,22 @@ * @package OpenCoreEMR * @link http://www.open-emr.org * @author Your Name - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */ -namespace {VendorName}\Modules\{ModuleName}; +namespace OpenCoreEMR\Modules\{ModuleName}; + +use OpenEMR\Core\Kernel; /** * Provides centralized access to OpenEMR globals. * This class serves as a single point of abstraction for globals access, * making it easier to update or refactor in the future. + * + * Implements ConfigAccessorInterface for database-backed configuration. */ -class GlobalsAccessor +class GlobalsAccessor implements ConfigAccessorInterface { /** * Get a value from globals @@ -89,4 +93,13 @@ public function all(): array { return $GLOBALS; } + + /** + * Get the OpenEMR Kernel instance + */ + public function getKernel(): ?Kernel + { + $kernel = $this->get('kernel'); + return $kernel instanceof Kernel ? $kernel : null; + } } diff --git a/src/ModuleAccessGuard.php b/src/ModuleAccessGuard.php new file mode 100644 index 0000000..fc87f22 --- /dev/null +++ b/src/ModuleAccessGuard.php @@ -0,0 +1,93 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}; + +use OpenEMR\Common\Database\QueryUtils; +use Symfony\Component\HttpFoundation\Response; + +/** + * Prevents access to module web endpoints when the module is not properly installed and enabled. + * + * This guard checks three conditions: + * 1. Module is registered in OpenEMR's modules table + * 2. Module is enabled in module management (mod_active = 1) + * 3. Module's own 'enabled' global setting is turned on + * + * If any condition fails, returns a 404 response to avoid leaking information + * about the module's presence. + */ +class ModuleAccessGuard +{ + /** + * Check if the module is accessible and return a 404 response if not. + * + * @param string $moduleDirectory The module directory name (e.g., '{vendor-prefix}-module-{modulename}') + * @param ConfigAccessorInterface|null $configAccessor Optional config accessor for testing + * @param (callable(string): bool)|null $moduleActiveChecker Optional callable for testing + * @return Response|null Returns 404 Response if access denied, null if access allowed + */ + public static function check( + string $moduleDirectory, + ?ConfigAccessorInterface $configAccessor = null, + ?callable $moduleActiveChecker = null + ): ?Response { + // Check module registration and enabled status in modules table + $isActive = $moduleActiveChecker !== null + ? $moduleActiveChecker($moduleDirectory) + : self::isModuleActive($moduleDirectory); + + if (!$isActive) { + return self::createNotFoundResponse(); + } + + // Check module's own enabled setting (respects env config mode) + $configAccessor ??= ConfigFactory::createConfigAccessor(); + if (!$configAccessor->getBoolean(GlobalConfig::CONFIG_OPTION_ENABLED, false)) { + return self::createNotFoundResponse(); + } + + return null; + } + + /** + * Check if module is registered and active in OpenEMR's modules table + */ + private static function isModuleActive(string $moduleDirectory): bool + { + try { + $sql = "SELECT mod_active FROM modules WHERE mod_directory = ?"; + $result = QueryUtils::fetchSingleValue($sql, 'mod_active', [$moduleDirectory]); + + // Module not found or not active + if ($result === null || $result === false) { + return false; + } + + // Check if active (mod_active = 1) + return (int) $result === 1; + } catch (\Throwable) { + // Database error - fail closed (deny access) + return false; + } + } + + /** + * Create a 404 Not Found response + */ + private static function createNotFoundResponse(): Response + { + return new Response('Not Found', Response::HTTP_NOT_FOUND, [ + 'Content-Type' => 'text/plain' + ]); + } +} diff --git a/templates/.gitkeep b/templates/.gitkeep deleted file mode 100644 index 6d20737..0000000 --- a/templates/.gitkeep +++ /dev/null @@ -1,12 +0,0 @@ -# This file ensures the templates directory is tracked by git -# Delete this file once you add your own Twig templates -# -# Organize templates by feature name: -# templates/ -# └── {feature}/ -# ├── list.html.twig -# ├── view.html.twig -# └── partials/ -# └── _component.html.twig -# -# Example: templates/patients/list.html.twig diff --git a/templates/example/list.html.twig b/templates/example/list.html.twig new file mode 100644 index 0000000..d21906f --- /dev/null +++ b/templates/example/list.html.twig @@ -0,0 +1,79 @@ +{# templates/example/list.html.twig #} +{# + # Example list template demonstrating Twig patterns for OpenEMR modules. + # + # Key patterns: + # - Use xlt filter for translatable text + # - Use text filter for sanitized output + # - Use attr filter for HTML attributes + # - Use xlj filter for JavaScript strings + # - Always include csrf_token in forms + #} + + + + + {{ 'Module Dashboard'|xlt }} + + + + +
+

{{ title|text }}

+ +
+
+
{{ 'Items'|xlt }}
+
+
+ {% if items is empty %} +

{{ 'No items found.'|xlt }}

+ {% else %} + + + + + + + + + + {% for item in items %} + + + + + + {% endfor %} + +
{{ 'ID'|xlt }}{{ 'Name'|xlt }}{{ 'Actions'|xlt }}
{{ item.id|text }}{{ item.name|text }} + + {{ 'View'|xlt }} + +
+ {% endif %} +
+
+ +
+
+
{{ 'Create New Item'|xlt }}
+
+
+
+ + +
+ + +
+ + +
+
+
+
+ + diff --git a/templates/example/view.html.twig b/templates/example/view.html.twig new file mode 100644 index 0000000..3f533e8 --- /dev/null +++ b/templates/example/view.html.twig @@ -0,0 +1,49 @@ +{# templates/example/view.html.twig #} +{# + # Example detail view template. + #} + + + + + {{ 'View Item'|xlt }} + + + + +
+ + +

{{ title|text }}

+ +
+
+
{{ 'Item Details'|xlt }}
+
+
+
+
{{ 'ID'|xlt }}
+
{{ item.id|text }}
+ +
{{ 'Name'|xlt }}
+
{{ item.name|text }}
+
+
+ +
+
+ + diff --git a/tests/Mocks/MockEnvironmentConfigAccessor.php b/tests/Mocks/MockEnvironmentConfigAccessor.php new file mode 100644 index 0000000..4fee7d6 --- /dev/null +++ b/tests/Mocks/MockEnvironmentConfigAccessor.php @@ -0,0 +1,86 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Tests\Mocks; + +use OpenCoreEMR\Modules\{ModuleName}\ConfigAccessorInterface; +use OpenEMR\Core\Kernel; + +/** + * Mock implementation of ConfigAccessorInterface for testing environment config mode + */ +class MockEnvironmentConfigAccessor implements ConfigAccessorInterface +{ + /** + * @var array + */ + private array $data = []; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->data = $data; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + public function getString(string $key, string $default = ''): string + { + $value = $this->data[$key] ?? $default; + return is_string($value) ? $value : (is_scalar($value) ? (string)$value : $default); + } + + public function getBoolean(string $key, bool $default = false): bool + { + $value = $this->data[$key] ?? $default; + if (is_bool($value)) { + return $value; + } + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + public function getInt(string $key, int $default = 0): int + { + $value = $this->data[$key] ?? $default; + return is_numeric($value) ? (int)$value : $default; + } + + public function has(string $key): bool + { + return isset($this->data[$key]); + } + + /** + * Set a value for testing + */ + public function set(string $key, mixed $value): void + { + $this->data[$key] = $value; + } + + /** + * Get the OpenEMR Kernel instance + * + * Returns null since environment config mode doesn't provide a real Kernel. + * Tests that need a Kernel should mock it via the data array. + */ + public function getKernel(): ?Kernel + { + $kernel = $this->get('kernel'); + return $kernel instanceof Kernel ? $kernel : null; + } +} diff --git a/tests/Mocks/MockGlobalsAccessor.php b/tests/Mocks/MockGlobalsAccessor.php new file mode 100644 index 0000000..472ee09 --- /dev/null +++ b/tests/Mocks/MockGlobalsAccessor.php @@ -0,0 +1,92 @@ + + * @copyright Copyright (c) 2026 OpenCoreEMR Inc + * @license GNU General Public License 3 + */ + +namespace OpenCoreEMR\Modules\{ModuleName}\Tests\Mocks; + +use OpenCoreEMR\Modules\{ModuleName}\ConfigAccessorInterface; +use OpenCoreEMR\Modules\{ModuleName}\GlobalsAccessor; +use OpenEMR\Core\Kernel; + +class MockGlobalsAccessor extends GlobalsAccessor implements ConfigAccessorInterface +{ + /** + * @var array + */ + private array $mockData = []; + + /** + * @param array $data + */ + public function __construct(array $data = []) + { + $this->mockData = $data; + } + + /** + * @param string $key + * @param mixed $default + * @return mixed + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->mockData[$key] ?? $default; + } + + /** + * @param string $key + * @param string $default + */ + public function getString(string $key, string $default = ''): string + { + return (string)($this->mockData[$key] ?? $default); + } + + /** + * @param string $key + * @param int $default + */ + public function getInt(string $key, int $default = 0): int + { + return (int)($this->mockData[$key] ?? $default); + } + + /** + * @param string $key + * @param bool $default + */ + public function getBoolean(string $key, bool $default = false): bool + { + return (bool)($this->mockData[$key] ?? $default); + } + + public function has(string $key): bool + { + return isset($this->mockData[$key]); + } + + /** + * Set a value for testing + */ + public function set(string $key, mixed $value): void + { + $this->mockData[$key] = $value; + } + + /** + * Get the OpenEMR Kernel instance + */ + public function getKernel(): ?Kernel + { + $kernel = $this->get('kernel'); + return $kernel instanceof Kernel ? $kernel : null; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 072696e..bd91883 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -30,6 +30,8 @@ require_once __DIR__ . '/Mocks/MockGlobalsInitializedEvent.php'; require_once __DIR__ . '/Mocks/MockMenuEvent.php'; require_once __DIR__ . '/Mocks/MockPatientDocumentEvent.php'; +require_once __DIR__ . '/Mocks/MockGlobalsAccessor.php'; +require_once __DIR__ . '/Mocks/MockEnvironmentConfigAccessor.php'; // Define OpenEMR global functions used in controllers if (!function_exists('xlt')) { diff --git a/version.php b/version.php index f39f183..347d6b8 100644 --- a/version.php +++ b/version.php @@ -9,7 +9,7 @@ * @package OpenCoreEMR * @link http://www.open-emr.org * @author Your Name - * @copyright Copyright (c) 2025 OpenCoreEMR Inc + * @copyright Copyright (c) 2026 OpenCoreEMR Inc * @license GNU General Public License 3 */