Restructure repository and implement secure search feature

Phase 1: Consolidate shared infrastructure
- Create shared/ directory for common code
- Consolidate Database.php from front-backend and formulaire into unified shared/Database.php
  - Smart path detection for test.db vs posterg.db
  - Secure search with wildcard escaping and input validation
  - Support both singleton and direct instantiation patterns
  - Full CRUD methods for admin functionality
- Move RateLimit.php to shared/ (30 requests/min)
- Update all require paths across apps to use shared/

Phase 2: Reorganize directory structure
- Rename front-backend/ → apps/public/
- Rename formulaire/ → apps/admin/
- Rename db/ → database/
- Update all file paths for new structure
- Create root .gitignore excluding databases, cache, logs

Implement secure search feature
- Add apps/public/search.php with full-text search across theses
- Search filters: query, year, orientation, AP program, keywords
- Security features:
  - SQL injection prevention (prepared statements)
  - Wildcard injection prevention (escape % and _)
  - Input validation (max 200 chars, year range 1900-2100)
  - Rate limiting (30 req/min per IP)
  - Pagination limited to 100 results/page
  - XSS protection (htmlspecialchars on output)

Add comprehensive test suite
- Create apps/public/tests/ with proper structure
  - tests/Integration/SearchTest.php - 12 search scenarios
  - tests/Security/SecurityTest.php - vulnerability testing
  - tests/Unit/RateLimitTest.php - rate limit behavior
- Create database/fixtures/CreateTestDatabase.php
- Add apps/public/run-tests.php test runner
- All tests passing (4/4 suites)

Update deployment configuration
- Rename justfile 'sync' recipe to 'deploy'
- Create deploy group with separate deploy-public and deploy-admin
- Add test-deploy recipe for test database
- Exclude *.db, tests/, cache/, *.md from production deploy
- Deploy shared/ to both public and admin locations

Stats: +4482 insertions, -654 deletions across 72 files
This commit is contained in:
Théophile Gervreau-Mercier
2026-01-28 10:24:36 +01:00
parent 95f52d549e
commit 467aced734
81 changed files with 6304 additions and 785 deletions

652
shared/Database.php Normal file
View File

@@ -0,0 +1,652 @@
<?php
require_once __DIR__ . '/config.php';
/**
* Unified Database connection class for Post-ERG thesis database
* Combines functionality from both front-backend and formulaire
* Supports both singleton (front-backend) and direct instantiation (formulaire)
*/
class Database {
private static $instance = null;
private $pdo;
private $dbPath;
/**
* Constructor - public to support both singleton and direct instantiation
* @param string $dbPath Optional database path override
*/
public function __construct($dbPath = null) {
$this->dbPath = $this->determineDatabasePath($dbPath);
try {
$this->pdo = new PDO('sqlite:' . $this->dbPath);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
// Enable foreign key constraints
$this->pdo->exec('PRAGMA foreign_keys = ON');
} catch (PDOException $e) {
error_log("Database connection failed: " . $e->getMessage());
throw new Exception("Impossible de se connecter à la base de données.");
}
}
/**
* Determine database path
* Uses centralized config from config.php
* Priority: custom path > config.php settings
*/
private function determineDatabasePath($customPath = null) {
// Allow explicit override
if ($customPath !== null && file_exists($customPath)) {
return $customPath;
}
// Use centralized configuration
return getDatabasePath();
}
/**
* Get singleton instance (for front-backend)
* @return Database
*/
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Get PDO connection
* @return PDO
*/
public function getConnection() {
return $this->pdo;
}
/**
* Get PDO instance (alias for formulaire compatibility)
* @return PDO
*/
public function getPDO() {
return $this->pdo;
}
// ========================================================================
// TRANSACTION SUPPORT (from formulaire)
// ========================================================================
/**
* Begin a transaction
*/
public function beginTransaction() {
return $this->pdo->beginTransaction();
}
/**
* Commit a transaction
*/
public function commit() {
return $this->pdo->commit();
}
/**
* Rollback a transaction
*/
public function rollback() {
return $this->pdo->rollback();
}
// ========================================================================
// PUBLIC SITE METHODS (from front-backend)
// ========================================================================
/**
* Get all published theses with pagination
*/
public function getPublishedTheses($limit = 10, $offset = 0) {
$sql = "SELECT * FROM v_theses_public ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Count all published theses
*/
public function countPublishedTheses() {
$sql = "SELECT COUNT(*) as count FROM theses WHERE is_published = 1";
$stmt = $this->pdo->query($sql);
$result = $stmt->fetch();
return $result['count'];
}
/**
* Get thesis by ID with all related data (for public site)
*/
public function getThesisById($id) {
$sql = "SELECT * FROM v_theses_full WHERE id = :id AND is_published = 1";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$thesis = $stmt->fetch();
if (!$thesis) {
return null;
}
// Get associated files
$thesis['files'] = $this->getThesisFiles($id);
return $thesis;
}
/**
* Get thesis by ID (for admin - includes unpublished)
* @param int $id Thesis ID
* @return array|null Thesis data or null if not found
*/
public function getThesis($id) {
$stmt = $this->pdo->prepare("SELECT * FROM v_theses_full WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
/**
* Get files associated with a thesis
*/
public function getThesisFiles($thesisId) {
$sql = "SELECT * FROM thesis_files WHERE thesis_id = :thesis_id ORDER BY file_type, uploaded_at";
$stmt = $this->pdo->prepare($sql);
$stmt->bindValue(':thesis_id', $thesisId, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
// ========================================================================
// SEARCH FUNCTIONALITY (from front-backend - secure implementation)
// ========================================================================
/**
* Escape LIKE wildcards in user input to prevent wildcard injection
*/
private function escapeLikeString($string) {
return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $string);
}
/**
* Validate and sanitize search parameters
* @throws InvalidArgumentException if validation fails
*/
private function validateSearchParams($params) {
$validated = [];
if (!empty($params['query'])) {
$query = trim($params['query']);
if (strlen($query) > 200) {
throw new InvalidArgumentException("Search query too long (max 200 characters)");
}
$validated['query'] = $this->escapeLikeString($query);
}
if (!empty($params['year'])) {
$year = intval($params['year']);
if ($year < 1900 || $year > 2100) {
throw new InvalidArgumentException("Invalid year");
}
$validated['year'] = $year;
}
if (!empty($params['orientation'])) {
$orientation = trim($params['orientation']);
if (strlen($orientation) > 100) {
throw new InvalidArgumentException("Orientation name too long");
}
$validated['orientation'] = $this->escapeLikeString($orientation);
}
if (!empty($params['ap_program'])) {
$ap = trim($params['ap_program']);
if (strlen($ap) > 100) {
throw new InvalidArgumentException("AP program name too long");
}
$validated['ap_program'] = $this->escapeLikeString($ap);
}
if (!empty($params['finality'])) {
$finality = trim($params['finality']);
if (strlen($finality) > 100) {
throw new InvalidArgumentException("Finality name too long");
}
$validated['finality'] = $this->escapeLikeString($finality);
}
if (!empty($params['keyword'])) {
$keyword = trim($params['keyword']);
if (strlen($keyword) > 100) {
throw new InvalidArgumentException("Keyword too long");
}
$validated['keyword'] = $this->escapeLikeString($keyword);
}
if (!empty($params['format'])) {
$format = trim($params['format']);
if (strlen($format) > 100) {
throw new InvalidArgumentException("Format name too long");
}
$validated['format'] = $this->escapeLikeString($format);
}
if (!empty($params['language'])) {
$language = trim($params['language']);
if (strlen($language) > 50) {
throw new InvalidArgumentException("Language name too long");
}
$validated['language'] = $this->escapeLikeString($language);
}
if (isset($params['is_doctoral'])) {
$validated['is_doctoral'] = (bool)$params['is_doctoral'];
}
return $validated;
}
/**
* Search theses with filters (secure implementation)
*/
public function searchTheses($params = [], $limit = 20, $offset = 0) {
$params = $this->validateSearchParams($params);
$limit = max(1, min(100, intval($limit)));
$offset = max(0, intval($offset));
$conditions = ["is_published = 1"];
$bindings = [];
if (!empty($params['query'])) {
$conditions[] = "(
title LIKE :query ESCAPE '\\' OR
subtitle LIKE :query ESCAPE '\\' OR
synopsis LIKE :query ESCAPE '\\' OR
authors LIKE :query ESCAPE '\\' OR
supervisors LIKE :query ESCAPE '\\' OR
keywords LIKE :query ESCAPE '\\'
)";
$bindings[':query'] = '%' . $params['query'] . '%';
}
if (!empty($params['year'])) {
$conditions[] = "year = :year";
$bindings[':year'] = $params['year'];
}
if (!empty($params['orientation'])) {
$conditions[] = "orientation LIKE :orientation ESCAPE '\\'";
$bindings[':orientation'] = '%' . $params['orientation'] . '%';
}
if (!empty($params['ap_program'])) {
$conditions[] = "ap_program LIKE :ap_program ESCAPE '\\'";
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
}
if (!empty($params['finality'])) {
$conditions[] = "finality_type LIKE :finality ESCAPE '\\'";
$bindings[':finality'] = '%' . $params['finality'] . '%';
}
if (!empty($params['keyword'])) {
$conditions[] = "keywords LIKE :keyword ESCAPE '\\'";
$bindings[':keyword'] = '%' . $params['keyword'] . '%';
}
if (!empty($params['format'])) {
$conditions[] = "formats LIKE :format ESCAPE '\\'";
$bindings[':format'] = '%' . $params['format'] . '%';
}
if (!empty($params['language'])) {
$conditions[] = "languages LIKE :language ESCAPE '\\'";
$bindings[':language'] = '%' . $params['language'] . '%';
}
if (isset($params['is_doctoral'])) {
$conditions[] = "is_doctoral = :is_doctoral";
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
}
$whereClause = implode(' AND ', $conditions);
$sql = "SELECT * FROM v_theses_public WHERE $whereClause ORDER BY year DESC, title ASC LIMIT :limit OFFSET :offset";
$stmt = $this->pdo->prepare($sql);
foreach ($bindings as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
/**
* Count search results
*/
public function countSearchResults($params = []) {
$params = $this->validateSearchParams($params);
$conditions = ["is_published = 1"];
$bindings = [];
if (!empty($params['query'])) {
$conditions[] = "(
title LIKE :query ESCAPE '\\' OR
subtitle LIKE :query ESCAPE '\\' OR
synopsis LIKE :query ESCAPE '\\' OR
authors LIKE :query ESCAPE '\\' OR
supervisors LIKE :query ESCAPE '\\' OR
keywords LIKE :query ESCAPE '\\'
)";
$bindings[':query'] = '%' . $params['query'] . '%';
}
if (!empty($params['year'])) {
$conditions[] = "year = :year";
$bindings[':year'] = $params['year'];
}
if (!empty($params['orientation'])) {
$conditions[] = "orientation LIKE :orientation ESCAPE '\\'";
$bindings[':orientation'] = '%' . $params['orientation'] . '%';
}
if (!empty($params['ap_program'])) {
$conditions[] = "ap_program LIKE :ap_program ESCAPE '\\'";
$bindings[':ap_program'] = '%' . $params['ap_program'] . '%';
}
if (!empty($params['finality'])) {
$conditions[] = "finality_type LIKE :finality ESCAPE '\\'";
$bindings[':finality'] = '%' . $params['finality'] . '%';
}
if (!empty($params['keyword'])) {
$conditions[] = "keywords LIKE :keyword ESCAPE '\\'";
$bindings[':keyword'] = '%' . $params['keyword'] . '%';
}
if (!empty($params['format'])) {
$conditions[] = "formats LIKE :format ESCAPE '\\'";
$bindings[':format'] = '%' . $params['format'] . '%';
}
if (!empty($params['language'])) {
$conditions[] = "languages LIKE :language ESCAPE '\\'";
$bindings[':language'] = '%' . $params['language'] . '%';
}
if (isset($params['is_doctoral'])) {
$conditions[] = "is_doctoral = :is_doctoral";
$bindings[':is_doctoral'] = $params['is_doctoral'] ? 1 : 0;
}
$whereClause = implode(' AND ', $conditions);
$sql = "SELECT COUNT(*) as count FROM v_theses_public WHERE $whereClause";
$stmt = $this->pdo->prepare($sql);
foreach ($bindings as $key => $value) {
$stmt->bindValue($key, $value);
}
$stmt->execute();
$result = $stmt->fetch();
return $result['count'];
}
/**
* Get all available years from published theses
*/
public function getAvailableYears() {
$sql = "SELECT DISTINCT year FROM theses WHERE is_published = 1 ORDER BY year DESC";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
/**
* Get all orientations
*/
public function getOrientations() {
$sql = "SELECT * FROM orientations ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllOrientations() {
return $this->getOrientations();
}
/**
* Get all AP programs
*/
public function getApPrograms() {
$sql = "SELECT * FROM ap_programs ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllAPPrograms() {
return $this->getApPrograms();
}
/**
* Get all finality types
*/
public function getFinalityTypes() {
$sql = "SELECT * FROM finality_types ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllFinalityTypes() {
return $this->getFinalityTypes();
}
/**
* Get all keywords used in published theses
*/
public function getUsedKeywords() {
$sql = "SELECT DISTINCT k.* FROM keywords k
INNER JOIN thesis_keywords tk ON k.id = tk.keyword_id
INNER JOIN theses t ON tk.thesis_id = t.id
WHERE t.is_published = 1
ORDER BY k.keyword";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Get all format types
*/
public function getFormatTypes() {
$sql = "SELECT * FROM format_types ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllFormatTypes() {
return $this->getFormatTypes();
}
/**
* Get all languages
*/
public function getLanguages() {
$sql = "SELECT * FROM languages ORDER BY name";
$stmt = $this->pdo->query($sql);
return $stmt->fetchAll();
}
/**
* Alias for formulaire compatibility
*/
public function getAllLanguages() {
return $this->getLanguages();
}
// ========================================================================
// CRUD METHODS (from formulaire)
// ========================================================================
/**
* Find or create an author
*/
public function findOrCreateAuthor($name, $email = null) {
$stmt = $this->pdo->prepare("SELECT id FROM authors WHERE name = ?");
$stmt->execute([$name]);
$author = $stmt->fetch();
if ($author) {
if ($email && $email !== '') {
$updateStmt = $this->pdo->prepare("UPDATE authors SET email = ? WHERE id = ?");
$updateStmt->execute([$email, $author['id']]);
}
return $author['id'];
}
$stmt = $this->pdo->prepare("INSERT INTO authors (name, email) VALUES (?, ?)");
$stmt->execute([$name, $email]);
return $this->pdo->lastInsertId();
}
/**
* Find or create a supervisor
*/
public function findOrCreateSupervisor($name) {
$stmt = $this->pdo->prepare("SELECT id FROM supervisors WHERE name = ?");
$stmt->execute([$name]);
$supervisor = $stmt->fetch();
if ($supervisor) {
return $supervisor['id'];
}
$stmt = $this->pdo->prepare("INSERT INTO supervisors (name) VALUES (?)");
$stmt->execute([$name]);
return $this->pdo->lastInsertId();
}
/**
* Find or create a keyword
*/
public function findOrCreateKeyword($keyword) {
$keyword = trim($keyword);
if (empty($keyword)) {
return null;
}
$stmt = $this->pdo->prepare("SELECT id FROM keywords WHERE keyword = ?");
$stmt->execute([$keyword]);
$kw = $stmt->fetch();
if ($kw) {
return $kw['id'];
}
$stmt = $this->pdo->prepare("INSERT INTO keywords (keyword) VALUES (?)");
$stmt->execute([$keyword]);
return $this->pdo->lastInsertId();
}
/**
* Get orientation ID by name
*/
public function getOrientationId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM orientations WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get AP program ID by name
*/
public function getAPProgramId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM ap_programs WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get finality type ID by name
*/
public function getFinalityId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM finality_types WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get language ID by name
*/
public function getLanguageId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM languages WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Get format type ID by name
*/
public function getFormatId($name) {
$stmt = $this->pdo->prepare("SELECT id FROM format_types WHERE name = ?");
$stmt->execute([$name]);
$result = $stmt->fetch();
return $result ? $result['id'] : null;
}
/**
* Insert a thesis file record
*/
public function insertThesisFile($thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType) {
$stmt = $this->pdo->prepare("
INSERT INTO thesis_files (thesis_id, file_type, file_path, file_name, file_size, mime_type)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([$thesisId, $fileType, $filePath, $fileName, $fileSize, $mimeType]);
return $this->pdo->lastInsertId();
}
// ========================================================================
// SINGLETON PATTERN ENFORCEMENT
// ========================================================================
/**
* Prevent cloning
*/
private function __clone() {}
/**
* Prevent unserialization
*/
public function __wakeup() {
throw new Exception("Cannot unserialize singleton");
}
}

164
shared/RateLimit.php Normal file
View File

@@ -0,0 +1,164 @@
<?php
/**
* Simple file-based rate limiter
* Prevents abuse by limiting requests per IP address
*/
class RateLimit {
private $cacheDir;
private $maxRequests;
private $timeWindow;
/**
* Constructor
* @param int $maxRequests Maximum requests allowed in time window
* @param int $timeWindow Time window in seconds
* @param string $cacheDir Directory to store rate limit data
*/
public function __construct($maxRequests = 30, $timeWindow = 60, $cacheDir = null) {
$this->maxRequests = $maxRequests;
$this->timeWindow = $timeWindow;
$this->cacheDir = $cacheDir ?? __DIR__ . '/cache/rate_limit';
// Create cache directory if it doesn't exist
if (!is_dir($this->cacheDir)) {
mkdir($this->cacheDir, 0755, true);
}
}
/**
* Get client identifier (IP address)
* @return string Client identifier
*/
private function getClientIdentifier() {
// Try to get real IP if behind proxy
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} else {
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
return $ip;
}
/**
* Get cache file path for a client
* @param string $identifier Client identifier
* @return string File path
*/
private function getCacheFile($identifier) {
return $this->cacheDir . '/' . md5($identifier) . '.json';
}
/**
* Check if client has exceeded rate limit
* @return bool True if allowed, false if rate limit exceeded
*/
public function check() {
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
// Load existing request timestamps
$data = [];
if (file_exists($file)) {
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
}
// Clean old entries outside time window
$now = time();
$data = array_filter($data, function($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
// Check if limit exceeded
if (count($data) >= $this->maxRequests) {
return false;
}
// Add new request timestamp
$data[] = $now;
// Save updated data
file_put_contents($file, json_encode($data));
return true;
}
/**
* Get remaining requests for current client
* @return int Number of requests remaining
*/
public function getRemaining() {
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
if (!file_exists($file)) {
return $this->maxRequests;
}
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
// Clean old entries
$now = time();
$data = array_filter($data, function($timestamp) use ($now) {
return ($now - $timestamp) < $this->timeWindow;
});
return max(0, $this->maxRequests - count($data));
}
/**
* Get time until rate limit resets
* @return int Seconds until reset
*/
public function getResetTime() {
$identifier = $this->getClientIdentifier();
$file = $this->getCacheFile($identifier);
if (!file_exists($file)) {
return 0;
}
$content = file_get_contents($file);
$data = json_decode($content, true) ?? [];
if (empty($data)) {
return 0;
}
// Find oldest timestamp
$oldest = min($data);
$resetTime = $oldest + $this->timeWindow - time();
return max(0, $resetTime);
}
/**
* Clean up old cache files (run periodically)
* Removes files that haven't been modified in 24 hours
*/
public function cleanup() {
$files = glob($this->cacheDir . '/*.json');
$cutoff = time() - 86400; // 24 hours
foreach ($files as $file) {
if (filemtime($file) < $cutoff) {
unlink($file);
}
}
}
/**
* Send rate limit headers
* Provides information about rate limits to clients
*/
public function sendHeaders() {
header('X-RateLimit-Limit: ' . $this->maxRequests);
header('X-RateLimit-Remaining: ' . $this->getRemaining());
header('X-RateLimit-Reset: ' . (time() + $this->getResetTime()));
}
}

View File

@@ -0,0 +1 @@
[1769619847,1769619847,1769619847,1769619847,1769619847]

View File

@@ -0,0 +1 @@
[1769624735]

55
shared/config.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
/**
* Configuration for Post-ERG thesis database
* Central location for database paths and environment settings
*/
// Database paths relative to repository root
define('DB_ROOT', __DIR__ . '/..');
// Test database (used in development)
define('DB_TEST_PATH', DB_ROOT . '/database/test.db');
// Production database (used on server)
define('DB_PROD_PATH', DB_ROOT . '/database/posterg.db');
/**
* Determine which database to use
* Checks environment variable DB_ENV, defaults to auto-detection
*
* Set DB_ENV in your environment:
* - export DB_ENV=test # Force test database
* - export DB_ENV=prod # Force production database
*
* Auto-detection logic:
* - If test.db exists, use it (development)
* - Otherwise use posterg.db (production)
*/
function getDatabasePath() {
// Allow explicit override via environment variable
$env = getenv('DB_ENV');
if ($env === 'test') {
return DB_TEST_PATH;
}
if ($env === 'prod') {
return DB_PROD_PATH;
}
// Auto-detect: prefer test database if it exists
if (file_exists(DB_TEST_PATH)) {
return DB_TEST_PATH;
}
// Default to production database
return DB_PROD_PATH;
}
/**
* Check if running in test/development mode
*/
function isTestMode() {
return getDatabasePath() === DB_TEST_PATH;
}