Initial commit - SOGOMS v1.0.0

- sogoctl: supervisor avec health checks et restart auto
- sogoway: gateway HTTP, auth JWT, routing par hostname
- sogoms-db: microservice MariaDB avec pool par application
- Protocol IPC Unix socket JSON length-prefixed
- Config YAML multi-application (prokov)
- Deploy script pour container Alpine gw3

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 19:09:00 +01:00
commit 7e27f87d6f
64 changed files with 7951 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
<?php
/**
* Contrôleur de base
*/
declare(strict_types=1);
abstract class Controller
{
protected Request $request;
protected ?array $user = null;
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Requiert une authentification valide
*/
protected function requireAuth(): void
{
$sessionId = $this->request->getSessionId();
if (empty($sessionId)) {
Response::unauthorized('Session ID required');
}
$user = Session::validate($sessionId);
if ($user === null) {
Response::unauthorized('Invalid or expired session');
}
$this->user = $user;
}
/**
* Retourne l'ID de l'utilisateur authentifié
*/
protected function getUserId(): int
{
return $this->user['id'];
}
/**
* Valide les champs requis dans le body
*/
protected function validate(array $rules): array
{
$body = $this->request->getBody();
$errors = [];
$data = [];
foreach ($rules as $field => $rule) {
$value = $body[$field] ?? null;
$ruleList = explode('|', $rule);
foreach ($ruleList as $r) {
if ($r === 'required' && ($value === null || $value === '')) {
$errors[$field] = "Le champ {$field} est requis";
break;
}
if ($r === 'email' && $value !== null && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field] = "Le champ {$field} doit être un email valide";
break;
}
if (str_starts_with($r, 'min:')) {
$min = (int) substr($r, 4);
if ($value !== null && strlen($value) < $min) {
$errors[$field] = "Le champ {$field} doit contenir au moins {$min} caractères";
break;
}
}
if (str_starts_with($r, 'max:')) {
$max = (int) substr($r, 4);
if ($value !== null && strlen($value) > $max) {
$errors[$field] = "Le champ {$field} doit contenir au maximum {$max} caractères";
break;
}
}
if ($r === 'int' && $value !== null && !is_numeric($value)) {
$errors[$field] = "Le champ {$field} doit être un nombre entier";
break;
}
if ($r === 'numeric' && $value !== null && !is_numeric($value)) {
$errors[$field] = "Le champ {$field} doit être un nombre";
break;
}
}
if (!isset($errors[$field])) {
$data[$field] = $value;
}
}
if (!empty($errors)) {
Response::error('Validation failed', 422, $errors);
}
return $data;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/**
* Gestion de la requête entrante
*/
declare(strict_types=1);
class Request
{
private string $method;
private string $uri;
private array $params = [];
private array $body = [];
private array $headers = [];
public function __construct()
{
$this->method = $_SERVER['REQUEST_METHOD'];
$this->uri = $this->parseUri();
$this->headers = $this->parseHeaders();
$this->body = $this->parseBody();
}
private function parseUri(): string
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
// Retirer le query string
if (($pos = strpos($uri, '?')) !== false) {
$uri = substr($uri, 0, $pos);
}
// Retirer le préfixe /api si présent
$uri = preg_replace('#^/api#', '', $uri);
return '/' . trim($uri, '/');
}
private function parseHeaders(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (str_starts_with($key, 'HTTP_')) {
$name = str_replace('_', '-', substr($key, 5));
$headers[$name] = $value;
}
}
return $headers;
}
private function parseBody(): array
{
if (in_array($this->method, ['POST', 'PUT', 'PATCH'])) {
$input = file_get_contents('php://input');
if (!empty($input)) {
$decoded = json_decode($input, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
return $decoded;
}
}
// Fallback sur $_POST ou array vide
return $_POST ?: [];
}
return [];
}
public function getMethod(): string
{
return $this->method;
}
public function getUri(): string
{
return $this->uri;
}
public function getHeader(string $name): ?string
{
$name = strtoupper(str_replace('-', '_', $name));
return $this->headers[$name] ?? null;
}
public function getSessionId(): ?string
{
return $this->getHeader('X-SESSION-ID');
}
public function getBody(): array
{
return $this->body;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->body[$key] ?? $_GET[$key] ?? $default;
}
public function setParams(array $params): void
{
$this->params = $params;
}
public function getParam(string $key, mixed $default = null): mixed
{
return $this->params[$key] ?? $default;
}
public function getParams(): array
{
return $this->params;
}
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* Gestion des réponses JSON
*/
declare(strict_types=1);
class Response
{
public static function json(mixed $data, int $code = 200): void
{
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
public static function success(mixed $data = null, string $message = 'OK', int $code = 200): void
{
self::json([
'success' => true,
'message' => $message,
'data' => $data,
], $code);
}
public static function error(string $message, int $code = 400, mixed $errors = null): void
{
$response = [
'success' => false,
'message' => $message,
];
if ($errors !== null) {
$response['errors'] = $errors;
}
self::json($response, $code);
}
public static function notFound(string $message = 'Resource not found'): void
{
self::error($message, 404);
}
public static function unauthorized(string $message = 'Unauthorized'): void
{
self::error($message, 401);
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* Routeur simple pour API REST
*/
declare(strict_types=1);
class Router
{
private array $routes = [];
private Request $request;
public function __construct()
{
$this->request = new Request();
$this->registerRoutes();
}
private function registerRoutes(): void
{
// Auth (routes publiques)
$this->post('/auth/register', 'AuthController@register');
$this->post('/auth/login', 'AuthController@login');
$this->post('/auth/logout', 'AuthController@logout');
$this->get('/auth/me', 'AuthController@me');
// Projects
$this->get('/projects', 'ProjectController@index');
$this->get('/projects/{id}', 'ProjectController@show');
$this->post('/projects', 'ProjectController@store');
$this->put('/projects/{id}', 'ProjectController@update');
$this->delete('/projects/{id}', 'ProjectController@destroy');
// Tasks
$this->get('/tasks', 'TaskController@index');
$this->get('/tasks/{id}', 'TaskController@show');
$this->post('/tasks', 'TaskController@store');
$this->put('/tasks/{id}', 'TaskController@update');
$this->delete('/tasks/{id}', 'TaskController@destroy');
// Tags
$this->get('/tags', 'TagController@index');
$this->get('/tags/{id}', 'TagController@show');
$this->post('/tags', 'TagController@store');
$this->put('/tags/{id}', 'TagController@update');
$this->delete('/tags/{id}', 'TagController@destroy');
// Statuses
$this->get('/statuses', 'StatusController@index');
$this->get('/statuses/{id}', 'StatusController@show');
$this->post('/statuses', 'StatusController@store');
$this->put('/statuses/{id}', 'StatusController@update');
$this->delete('/statuses/{id}', 'StatusController@destroy');
}
private function addRoute(string $method, string $path, string $handler): void
{
$this->routes[] = [
'method' => $method,
'path' => $path,
'handler' => $handler,
];
}
public function get(string $path, string $handler): void
{
$this->addRoute('GET', $path, $handler);
}
public function post(string $path, string $handler): void
{
$this->addRoute('POST', $path, $handler);
}
public function put(string $path, string $handler): void
{
$this->addRoute('PUT', $path, $handler);
}
public function delete(string $path, string $handler): void
{
$this->addRoute('DELETE', $path, $handler);
}
public function dispatch(): void
{
$method = $this->request->getMethod();
$uri = $this->request->getUri();
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
$params = $this->matchRoute($route['path'], $uri);
if ($params !== false) {
$this->request->setParams($params);
$this->callHandler($route['handler']);
return;
}
}
Response::notFound('Route not found');
}
private function matchRoute(string $routePath, string $uri): array|false
{
// Convertir /projects/{id} en regex /projects/([^/]+)
$pattern = preg_replace('#\{(\w+)\}#', '([^/]+)', $routePath);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $uri, $matches)) {
array_shift($matches); // Retirer le match complet
// Extraire les noms des paramètres
preg_match_all('#\{(\w+)\}#', $routePath, $paramNames);
$params = [];
foreach ($paramNames[1] as $index => $name) {
$params[$name] = $matches[$index] ?? null;
}
return $params;
}
return false;
}
private function callHandler(string $handler): void
{
[$controllerName, $methodName] = explode('@', $handler);
if (!class_exists($controllerName)) {
Response::error("Controller {$controllerName} not found", 500);
}
$controller = new $controllerName($this->request);
if (!method_exists($controller, $methodName)) {
Response::error("Method {$methodName} not found", 500);
}
$controller->$methodName();
}
}

View File

@@ -0,0 +1,162 @@
<?php
/**
* Gestion des sessions en base de données
*/
declare(strict_types=1);
class Session
{
private static ?array $currentSession = null;
private static ?array $currentUser = null;
/**
* Récupérer l'IP réelle du client (derrière proxy)
*/
public static function getClientIp(): ?string
{
// Headers transmis par le proxy nginx
$headers = [
'HTTP_X_REAL_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_CLIENT_IP',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For peut contenir plusieurs IPs (client, proxy1, proxy2...)
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
return null;
}
/**
* Créer une nouvelle session pour un utilisateur
*/
public static function create(int $userId, ?string $ipAddress = null, ?string $userAgent = null): string
{
$db = Database::getInstance();
$sessionId = bin2hex(random_bytes(64)); // 128 caractères
$expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME);
$stmt = $db->prepare('
INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
VALUES (:id, :user_id, :ip_address, :user_agent, :expires_at)
');
$stmt->execute([
'id' => $sessionId,
'user_id' => $userId,
'ip_address' => $ipAddress ?? self::getClientIp(),
'user_agent' => $userAgent ?? $_SERVER['HTTP_USER_AGENT'] ?? null,
'expires_at' => $expiresAt,
]);
return $sessionId;
}
/**
* Valider une session et retourner l'utilisateur
*/
public static function validate(string $sessionId): ?array
{
if (self::$currentSession !== null && self::$currentSession['id'] === $sessionId) {
return self::$currentUser;
}
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT s.*, u.id as user_id, u.email, u.name
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.id = :id AND s.expires_at > NOW()
');
$stmt->execute(['id' => $sessionId]);
$result = $stmt->fetch();
if (!$result) {
return null;
}
self::$currentSession = [
'id' => $result['id'],
'user_id' => $result['user_id'],
'expires_at' => $result['expires_at'],
];
self::$currentUser = [
'id' => $result['user_id'],
'email' => $result['email'],
'name' => $result['name'],
];
return self::$currentUser;
}
/**
* Détruire une session
*/
public static function destroy(string $sessionId): bool
{
$db = Database::getInstance();
$stmt = $db->prepare('DELETE FROM sessions WHERE id = :id');
$stmt->execute(['id' => $sessionId]);
self::$currentSession = null;
self::$currentUser = null;
return $stmt->rowCount() > 0;
}
/**
* Nettoyer les sessions expirées
*/
public static function cleanup(): int
{
$db = Database::getInstance();
$stmt = $db->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
$stmt->execute();
return $stmt->rowCount();
}
/**
* Prolonger une session
*/
public static function extend(string $sessionId): bool
{
$db = Database::getInstance();
$expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME);
$stmt = $db->prepare('
UPDATE sessions SET expires_at = :expires_at WHERE id = :id
');
$stmt->execute([
'id' => $sessionId,
'expires_at' => $expiresAt,
]);
return $stmt->rowCount() > 0;
}
/**
* Obtenir l'utilisateur courant (depuis le cache)
*/
public static function getCurrentUser(): ?array
{
return self::$currentUser;
}
}