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

22
.gitignore vendored Normal file
View File

@@ -0,0 +1,22 @@
# Binaries
bin/
*.exe
sogoctl
sogoway
sogoms-db
# IDE
.vscode/
.idea/
# OS
.DS_Store
# Logs
*.log
# Secrets (never commit)
/secrets/
# Temp
*.tar.gz

46
CLAUDE.md Executable file
View File

@@ -0,0 +1,46 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Règles de travail
- Soit concis
- Ne fais rien sans validation utilisateur
## Project Overview
SOGOMS (Service Oriented GO MicroServices) - plateforme SaaS modulaire, multi-tenant, configurable.
**Principes:** modularité (1 feature = 1 binaire Go), configuration YAML, multi-tenant natif, un container avec plusieurs processus supervisés.
## Architecture
| Binaire | Rôle | Port/Socket |
|---------|------|-------------|
| **sogoctl** | Superviseur PID 1, health checks, scaling | TCP :9000 |
| **sogoway** | Gateway HTTP, auth JWT, routing | TCP :8080 |
| **sogorch** | Orchestrateur scénarios YAML | Unix socket |
| **sogoms-db** | Accès MariaDB | Unix socket |
| **sogoms-pdf** | Génération PDF | Unix socket |
| **sogoms-email** | Envoi emails | Unix socket |
| **sogoms-storage** | Gestion fichiers | Unix socket |
**Flux:** Client → Nginx(:443) → Sogoway(:8080) → Sogorch → Sogoms-*
## Structure
```
cmd/{sogoctl,sogoway,sogorch}/main.go
cmd/sogoms/{db,pdf,email,storage}/main.go
internal/{protocol,pool,config,scenario,auth,registry}/
config/{sogoctl,sogoway}.yaml
config/{tenants,routes,scenarios}/*.yaml
```
## Communication
Unix socket JSON length-prefixed (4 bytes length + JSON payload).
## Dépendances
net/http standard (Go 1.22+), go-yaml/v3, go-sql-driver/mysql, chromedp (PDF)

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# SOGOMS
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
## Architecture
```
Client → Nginx(:443) → Sogoway(:8080) → Sogoms-db → MariaDB
Unix Socket
```
| Binaire | Rôle | Port/Socket |
|---------|------|-------------|
| `sogoctl` | Superviseur PID 1, health checks, restart auto | - |
| `sogoway` | Gateway HTTP, auth JWT, routing par hostname | TCP :8080 |
| `sogoms-db` | Accès MariaDB, pool par application | Unix socket |
## Déploiement
```bash
./deploy.sh
```
Déploie sur le container `gw3` (Alpine) via IN3.
## Lancement
Sur gw3 :
```bash
/opt/sogoms/bin/sogoctl
```
## Configuration
Chaque application cliente a son fichier dans `config/routes/` :
```yaml
# config/routes/prokov.yaml
app: prokov
hosts:
- prokov.unikoffice.com
database:
host: 13.23.33.4
user: prokov_user
password_file: /secrets/prokov_db_pass
name: prokov
auth:
jwt_secret_file: /secrets/prokov_jwt_secret
jwt_expiry: 24h
```
## Structure
```
cmd/
sogoctl/main.go # Superviseur
sogoway/main.go # Gateway HTTP
sogoms/db/main.go # Microservice DB
internal/
protocol/ # IPC Unix socket (JSON length-prefixed)
config/ # Chargement YAML, registry par host
auth/ # JWT (HS256), bcrypt passwords
config/
sogoctl.yaml # Services à superviser
routes/*.yaml # Config par application
scenarios/ # Scénarios YAML (V2)
```
## API Endpoints
```bash
# Health check
curl http://localhost:8080/health
# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Host: prokov.unikoffice.com" \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"secret"}'
# User info (avec token)
curl http://localhost:8080/api/auth/me \
-H "Host: prokov.unikoffice.com" \
-H "Authorization: Bearer <token>"
```
## Prérequis
- Go 1.22+
- MariaDB/MySQL
- Container Alpine (gw3)
## Licence
Propriétaire

92
TODO.md Executable file
View File

@@ -0,0 +1,92 @@
# TODO - SOGOMS V1 Minimale
Objectif : valider l'architecture avec 2-3 microservices basiques.
## Phase 0 : Infrastructure
- [x] **Container gw3** : Alpine sur IN3 (13.23.33.5)
- [x] **Config Prokov** : routes + scénarios YAML (auth, projects, tasks, tags, statuses)
- [x] **Nginx host IN3** : routing /api/ → gw3:8080, / → dva-front
## Phase 1 : Protocole IPC
- [x] `internal/protocol/message.go` : structs Request/Response JSON
- [x] `internal/protocol/server.go` : listener Unix socket
- [x] `internal/protocol/client.go` : client pour appeler les services
## Phase 2 : Microservice DB
- [x] `cmd/sogoms/db/main.go` : point d'entrée
- [x] Connexion MariaDB (pool par application)
- [x] Action `query` : SELECT multi-résultats
- [x] Action `query_one` : SELECT un résultat
- [x] Action `insert` : INSERT retourne insert_id
- [x] Action `update` : UPDATE retourne affected_rows
- [x] Action `delete` : DELETE retourne affected_rows
- [x] Écoute sur `/run/sogoms-db.1.sock`
- [x] Test standalone sogoms-db
## Phase 3 : Config
- [x] `internal/config/config.go` : lecture YAML + registry par host
- [x] `internal/config/routes.go` : parser routes (intégré dans config.go)
## Phase 4 : Gateway HTTP
- [x] `cmd/sogoway/main.go` : serveur HTTP :8080
- [x] Routing par host → charge le bon fichier routes (prokov.yaml)
- [x] `internal/auth/jwt.go` : génération + validation JWT (HS256)
- [x] `internal/auth/password.go` : hash + verify password (bcrypt)
- [x] Endpoint `POST /api/auth/login` : vérifie credentials, retourne JWT
- [x] Endpoint `GET /api/auth/me` : valide JWT, retourne user
- [x] Endpoint `POST /api/auth/register` : crée user, retourne JWT
- [x] Communication avec sogoms-db via Unix socket
- [x] Test standalone sogoway
## Phase 5 : Superviseur
- [x] `cmd/sogoctl/main.go` : point d'entrée
- [x] Config `config/sogoctl.yaml` : services à lancer
- [x] Lancement sogoms-db + sogoway (avec dépendances)
- [x] Health check (socket + HTTP)
- [x] Redémarrage automatique si crash
## Phase 6 : Test de validation
```bash
# 1. Lancer sogoctl (démarre les services)
./sogoctl
# 2. Login
curl -X POST https://prokov.unikoffice.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"secret"}'
# → {"success":true,"data":{"token":"eyJ...","user":{...}}}
# 3. Vérifier le token
curl https://prokov.unikoffice.com/api/auth/me \
-H "Authorization: Bearer eyJ..."
# → {"success":true,"data":{"user":{...}}}
```
- [x] Test login OK
- [x] Test /me avec token valide OK
- [x] Test /me sans token → 401
## Phase 7 : Microservice Logs
- [ ] `cmd/sogoms/logs/main.go` : point d'entrée
- [ ] Écoute sur Unix socket `/run/sogoms-logs.1.sock`
- [ ] Actions `log_error`, `log_event` : écriture dans fichiers
- [ ] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log`
- [ ] Rotation automatique : suppression des fichiers > N jours (défaut 15)
- [ ] Paramètre `retention_days` dans config
- [ ] Intégration avec sogoway et sogoms-db
## Hors scope V1
- sogorch (orchestrateur scénarios)
- sogoms-pdf, sogoms-email, sogoms-storage
- Multi-tenant
- Rate limiting
- Exécution dynamique des scénarios YAML

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.0.0

View File

@@ -0,0 +1,39 @@
<?php
/**
* Connexion à la base de données (Singleton)
*/
declare(strict_types=1);
class Database
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
if (self::$instance === null) {
try {
self::$instance = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (PDOException $e) {
Response::error('Database connection failed', 500);
exit;
}
}
return self::$instance;
}
// Empêcher le clonage et la désérialisation
private function __construct() {}
private function __clone() {}
public function __wakeup() {}
}

View File

@@ -0,0 +1,27 @@
<?php
/**
* Configuration de l'application
*/
declare(strict_types=1);
// Environnement : 'dev' ou 'prod'
define('APP_ENV', 'dev');
// Base de données
define('DB_HOST', '13.23.33.4'); // container incus maria3
define('DB_NAME', 'prokov');
define('DB_USER', 'prokov_user');
define('DB_PASS', 'CHANGE_ME_PASSWORD');
// Session
define('SESSION_LIFETIME', 86400 * 7); // 7 jours
// Debug
if (APP_ENV === 'dev') {
error_reporting(E_ALL);
ini_set('display_errors', '1');
} else {
error_reporting(0);
ini_set('display_errors', '0');
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Contrôleur d'authentification
*/
declare(strict_types=1);
class AuthController extends Controller
{
/**
* POST /auth/register
*/
public function register(): void
{
$data = $this->validate([
'email' => 'required|email|max:255',
'password' => 'required|min:6|max:255',
'name' => 'required|min:2|max:100',
]);
$db = Database::getInstance();
// Vérifier si l'email existe déjà
$stmt = $db->prepare('SELECT id FROM users WHERE email = :email');
$stmt->execute(['email' => $data['email']]);
if ($stmt->fetch()) {
Response::error('Cet email est déjà utilisé', 409);
}
// Créer l'utilisateur
$hashedPassword = password_hash($data['password'], PASSWORD_DEFAULT);
$stmt = $db->prepare('
INSERT INTO users (email, password, name)
VALUES (:email, :password, :name)
');
$stmt->execute([
'email' => $data['email'],
'password' => $hashedPassword,
'name' => $data['name'],
]);
$userId = (int) $db->lastInsertId();
// Créer les statuts par défaut pour ce nouvel utilisateur
$this->createDefaultStatuses($userId);
// Créer une session
$sessionId = Session::create($userId);
Response::success([
'session_id' => $sessionId,
'user' => [
'id' => $userId,
'email' => $data['email'],
'name' => $data['name'],
],
], 'Inscription réussie', 201);
}
/**
* POST /auth/login
*/
public function login(): void
{
$data = $this->validate([
'email' => 'required|email',
'password' => 'required',
]);
$db = Database::getInstance();
$stmt = $db->prepare('SELECT id, email, name, password FROM users WHERE email = :email');
$stmt->execute(['email' => $data['email']]);
$user = $stmt->fetch();
if (!$user || !password_verify($data['password'], $user['password'])) {
Response::error('Email ou mot de passe incorrect', 401);
}
// Créer une session
$sessionId = Session::create($user['id']);
Response::success([
'session_id' => $sessionId,
'user' => [
'id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
],
], 'Connexion réussie');
}
/**
* POST /auth/logout
*/
public function logout(): void
{
$sessionId = $this->request->getSessionId();
if ($sessionId) {
Session::destroy($sessionId);
}
Response::success(null, 'Déconnexion réussie');
}
/**
* GET /auth/me
*/
public function me(): void
{
$this->requireAuth();
Response::success([
'user' => $this->user,
]);
}
/**
* Créer les statuts par défaut pour un nouvel utilisateur
*/
private function createDefaultStatuses(int $userId): void
{
$db = Database::getInstance();
$defaultStatuses = [
['code' => 10, 'name' => 'Backlog', 'color' => '#6B7280', 'position' => 10],
['code' => 20, 'name' => 'À faire', 'color' => '#3B82F6', 'position' => 20],
['code' => 30, 'name' => 'En cours', 'color' => '#F59E0B', 'position' => 30],
['code' => 40, 'name' => 'À tester', 'color' => '#8B5CF6', 'position' => 40],
['code' => 50, 'name' => 'Livré', 'color' => '#10B981', 'position' => 50],
['code' => 60, 'name' => 'Terminé', 'color' => '#059669', 'position' => 60],
['code' => 70, 'name' => 'Archivé', 'color' => '#9CA3AF', 'position' => 70],
];
$stmt = $db->prepare('
INSERT INTO statuses (user_id, project_id, code, name, color, position)
VALUES (:user_id, NULL, :code, :name, :color, :position)
');
foreach ($defaultStatuses as $status) {
$stmt->execute([
'user_id' => $userId,
'code' => $status['code'],
'name' => $status['name'],
'color' => $status['color'],
'position' => $status['position'],
]);
}
}
}

View File

@@ -0,0 +1,359 @@
<?php
/**
* Contrôleur des projets
*/
declare(strict_types=1);
class ProjectController extends Controller
{
/**
* GET /projects
* Liste tous les projets de l'utilisateur (arborescence)
*/
public function index(): void
{
$this->requireAuth();
$db = Database::getInstance();
// Récupérer tous les projets de l'utilisateur
$stmt = $db->prepare('
SELECT p.*,
GROUP_CONCAT(t.id) as tag_ids,
GROUP_CONCAT(t.name) as tag_names
FROM projects p
LEFT JOIN project_tags pt ON p.id = pt.project_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.user_id = :user_id
GROUP BY p.id
ORDER BY p.parent_id ASC, p.position ASC, p.name ASC
');
$stmt->execute(['user_id' => $this->getUserId()]);
$projects = $stmt->fetchAll();
// Construire l'arborescence
$tree = $this->buildTree($projects);
Response::success($tree);
}
/**
* GET /projects/{id}
*/
public function show(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$project = $this->findOrFail($id);
// Récupérer les tags
$project['tags'] = $this->getProjectTags($id);
// Récupérer les sous-projets
$project['children'] = $this->getChildren($id);
Response::success($project);
}
/**
* POST /projects
*/
public function store(): void
{
$this->requireAuth();
$data = $this->validate([
'name' => 'required|min:1|max:100',
'description' => 'max:65535',
'parent_id' => 'int',
'position' => 'int',
]);
// Vérifier que le parent appartient à l'utilisateur
if (!empty($data['parent_id'])) {
$this->findOrFail((int) $data['parent_id']);
}
$db = Database::getInstance();
$stmt = $db->prepare('
INSERT INTO projects (user_id, parent_id, name, description, position)
VALUES (:user_id, :parent_id, :name, :description, :position)
');
$stmt->execute([
'user_id' => $this->getUserId(),
'parent_id' => $data['parent_id'] ?: null,
'name' => $data['name'],
'description' => $data['description'] ?? null,
'position' => $data['position'] ?? 0,
]);
$projectId = (int) $db->lastInsertId();
// Gérer les tags si fournis
$tags = $this->request->get('tags');
if (is_array($tags)) {
$this->syncTags($projectId, $tags);
}
$project = $this->findOrFail($projectId);
$project['tags'] = $this->getProjectTags($projectId);
Response::success($project, 'Projet créé', 201);
}
/**
* PUT /projects/{id}
*/
public function update(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$data = $this->validate([
'name' => 'min:1|max:100',
'description' => 'max:65535',
'parent_id' => 'int',
'position' => 'int',
]);
// Vérifier que le parent n'est pas le projet lui-même ou un de ses enfants
if (!empty($data['parent_id'])) {
$parentId = (int) $data['parent_id'];
if ($parentId === $id) {
Response::error('Un projet ne peut pas être son propre parent', 422);
}
$this->findOrFail($parentId);
// Vérifier que le parent n'est pas un enfant du projet
if ($this->isDescendant($parentId, $id)) {
Response::error('Le parent ne peut pas être un sous-projet', 422);
}
}
$db = Database::getInstance();
$fields = [];
$params = ['id' => $id];
if (isset($data['name'])) {
$fields[] = 'name = :name';
$params['name'] = $data['name'];
}
if (array_key_exists('description', $data)) {
$fields[] = 'description = :description';
$params['description'] = $data['description'];
}
if (array_key_exists('parent_id', $data)) {
$fields[] = 'parent_id = :parent_id';
$params['parent_id'] = $data['parent_id'] ?: null;
}
if (isset($data['position'])) {
$fields[] = 'position = :position';
$params['position'] = $data['position'];
}
if (!empty($fields)) {
$sql = 'UPDATE projects SET ' . implode(', ', $fields) . ' WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
// Gérer les tags si fournis
$tags = $this->request->get('tags');
if (is_array($tags)) {
$this->syncTags($id, $tags);
}
$project = $this->findOrFail($id);
$project['tags'] = $this->getProjectTags($id);
Response::success($project, 'Projet mis à jour');
}
/**
* DELETE /projects/{id}
*/
public function destroy(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$db = Database::getInstance();
// Les sous-projets et tâches seront supprimés en cascade (FK)
$stmt = $db->prepare('DELETE FROM projects WHERE id = :id');
$stmt->execute(['id' => $id]);
Response::success(null, 'Projet supprimé');
}
/**
* Trouver un projet ou retourner 404
*/
private function findOrFail(int $id): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT * FROM projects
WHERE id = :id AND user_id = :user_id
');
$stmt->execute([
'id' => $id,
'user_id' => $this->getUserId(),
]);
$project = $stmt->fetch();
if (!$project) {
Response::notFound('Projet non trouvé');
}
return $project;
}
/**
* Construire l'arborescence des projets
*/
private function buildTree(array $projects, ?int $parentId = null): array
{
$tree = [];
foreach ($projects as $project) {
if ($project['parent_id'] == $parentId) {
// Parser les tags
$project['tags'] = [];
if (!empty($project['tag_ids'])) {
$ids = explode(',', $project['tag_ids']);
$names = explode(',', $project['tag_names']);
foreach ($ids as $i => $tagId) {
$project['tags'][] = [
'id' => (int) $tagId,
'name' => $names[$i] ?? '',
];
}
}
unset($project['tag_ids'], $project['tag_names']);
// Récursion pour les enfants
$project['children'] = $this->buildTree($projects, (int) $project['id']);
$tree[] = $project;
}
}
return $tree;
}
/**
* Récupérer les tags d'un projet
*/
private function getProjectTags(int $projectId): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT t.id, t.name, t.color
FROM tags t
JOIN project_tags pt ON t.id = pt.tag_id
WHERE pt.project_id = :project_id
');
$stmt->execute(['project_id' => $projectId]);
return $stmt->fetchAll();
}
/**
* Récupérer les sous-projets directs
*/
private function getChildren(int $projectId): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT * FROM projects
WHERE parent_id = :parent_id AND user_id = :user_id
ORDER BY position ASC, name ASC
');
$stmt->execute([
'parent_id' => $projectId,
'user_id' => $this->getUserId(),
]);
return $stmt->fetchAll();
}
/**
* Vérifier si un projet est un descendant d'un autre
*/
private function isDescendant(int $projectId, int $ancestorId): bool
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT parent_id FROM projects
WHERE id = :id AND user_id = :user_id
');
$stmt->execute([
'id' => $projectId,
'user_id' => $this->getUserId(),
]);
$project = $stmt->fetch();
if (!$project || $project['parent_id'] === null) {
return false;
}
if ((int) $project['parent_id'] === $ancestorId) {
return true;
}
return $this->isDescendant((int) $project['parent_id'], $ancestorId);
}
/**
* Synchroniser les tags d'un projet
*/
private function syncTags(int $projectId, array $tagIds): void
{
$db = Database::getInstance();
// Supprimer les associations existantes
$stmt = $db->prepare('DELETE FROM project_tags WHERE project_id = :project_id');
$stmt->execute(['project_id' => $projectId]);
// Ajouter les nouvelles associations
if (!empty($tagIds)) {
$stmt = $db->prepare('
INSERT INTO project_tags (project_id, tag_id)
SELECT :project_id, id FROM tags
WHERE id = :tag_id AND user_id = :user_id
');
foreach ($tagIds as $tagId) {
$stmt->execute([
'project_id' => $projectId,
'tag_id' => (int) $tagId,
'user_id' => $this->getUserId(),
]);
}
}
}
}

View File

@@ -0,0 +1,231 @@
<?php
/**
* Contrôleur des statuts
*/
declare(strict_types=1);
class StatusController extends Controller
{
/**
* GET /statuses
* ?project_id=X - statuts d'un projet spécifique
* ?global=1 - uniquement les statuts globaux
*/
public function index(): void
{
$this->requireAuth();
$db = Database::getInstance();
$where = ['user_id = :user_id'];
$params = ['user_id' => $this->getUserId()];
$projectId = $this->request->get('project_id');
$globalOnly = $this->request->get('global');
if ($projectId !== null) {
// Statuts du projet + statuts globaux
$where = ['user_id = :user_id AND (project_id = :project_id OR project_id IS NULL)'];
$params['project_id'] = (int) $projectId;
} elseif ($globalOnly !== null) {
$where[] = 'project_id IS NULL';
}
$sql = '
SELECT s.*,
(SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count
FROM statuses s
WHERE ' . implode(' AND ', $where) . '
ORDER BY s.position ASC, s.code ASC
';
$stmt = $db->prepare($sql);
$stmt->execute($params);
$statuses = $stmt->fetchAll();
Response::success($statuses);
}
/**
* GET /statuses/{id}
*/
public function show(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$status = $this->findOrFail($id);
// Nombre de tâches avec ce statut
$db = Database::getInstance();
$stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id');
$stmt->execute(['id' => $id]);
$status['task_count'] = (int) $stmt->fetch()['count'];
Response::success($status);
}
/**
* POST /statuses
*/
public function store(): void
{
$this->requireAuth();
$data = $this->validate([
'code' => 'required|int',
'name' => 'required|min:1|max:50',
'color' => 'max:7',
'project_id' => 'int',
'position' => 'int',
]);
// Si project_id fourni, vérifier qu'il appartient à l'utilisateur
if (!empty($data['project_id'])) {
$this->verifyProject((int) $data['project_id']);
}
$db = Database::getInstance();
$stmt = $db->prepare('
INSERT INTO statuses (user_id, project_id, code, name, color, position)
VALUES (:user_id, :project_id, :code, :name, :color, :position)
');
$stmt->execute([
'user_id' => $this->getUserId(),
'project_id' => $data['project_id'] ?: null,
'code' => $data['code'],
'name' => $data['name'],
'color' => $data['color'] ?? '#6B7280',
'position' => $data['position'] ?? $data['code'],
]);
$statusId = (int) $db->lastInsertId();
$status = $this->findOrFail($statusId);
Response::success($status, 'Statut créé', 201);
}
/**
* PUT /statuses/{id}
*/
public function update(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$data = $this->validate([
'code' => 'int',
'name' => 'min:1|max:50',
'color' => 'max:7',
'position' => 'int',
]);
$db = Database::getInstance();
$fields = [];
$params = ['id' => $id];
if (isset($data['code'])) {
$fields[] = 'code = :code';
$params['code'] = $data['code'];
}
if (isset($data['name'])) {
$fields[] = 'name = :name';
$params['name'] = $data['name'];
}
if (isset($data['color'])) {
$fields[] = 'color = :color';
$params['color'] = $data['color'];
}
if (isset($data['position'])) {
$fields[] = 'position = :position';
$params['position'] = $data['position'];
}
if (!empty($fields)) {
$sql = 'UPDATE statuses SET ' . implode(', ', $fields) . ' WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
$status = $this->findOrFail($id);
Response::success($status, 'Statut mis à jour');
}
/**
* DELETE /statuses/{id}
*/
public function destroy(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$status = $this->findOrFail($id);
$db = Database::getInstance();
// Vérifier qu'aucune tâche n'utilise ce statut
$stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id');
$stmt->execute(['id' => $id]);
$count = (int) $stmt->fetch()['count'];
if ($count > 0) {
Response::error("Impossible de supprimer : {$count} tâche(s) utilisent ce statut", 409);
}
$stmt = $db->prepare('DELETE FROM statuses WHERE id = :id');
$stmt->execute(['id' => $id]);
Response::success(null, 'Statut supprimé');
}
/**
* Trouver un statut ou retourner 404
*/
private function findOrFail(int $id): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT * FROM statuses
WHERE id = :id AND user_id = :user_id
');
$stmt->execute([
'id' => $id,
'user_id' => $this->getUserId(),
]);
$status = $stmt->fetch();
if (!$status) {
Response::notFound('Statut non trouvé');
}
return $status;
}
/**
* Vérifier qu'un projet appartient à l'utilisateur
*/
private function verifyProject(int $projectId): void
{
$db = Database::getInstance();
$stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id');
$stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]);
if (!$stmt->fetch()) {
Response::error('Projet invalide', 422);
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
/**
* Contrôleur des tags
*/
declare(strict_types=1);
class TagController extends Controller
{
/**
* GET /tags
*/
public function index(): void
{
$this->requireAuth();
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT t.*,
(SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count,
(SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count
FROM tags t
WHERE t.user_id = :user_id
ORDER BY t.name ASC
');
$stmt->execute(['user_id' => $this->getUserId()]);
$tags = $stmt->fetchAll();
Response::success($tags);
}
/**
* GET /tags/{id}
*/
public function show(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$tag = $this->findOrFail($id);
// Récupérer les projets associés
$tag['projects'] = $this->getTagProjects($id);
// Récupérer les tâches associées
$tag['tasks'] = $this->getTagTasks($id);
Response::success($tag);
}
/**
* POST /tags
*/
public function store(): void
{
$this->requireAuth();
$data = $this->validate([
'name' => 'required|min:1|max:50',
'color' => 'max:7',
]);
$db = Database::getInstance();
// Vérifier unicité du nom pour cet utilisateur
$stmt = $db->prepare('SELECT id FROM tags WHERE user_id = :user_id AND name = :name');
$stmt->execute(['user_id' => $this->getUserId(), 'name' => $data['name']]);
if ($stmt->fetch()) {
Response::error('Ce tag existe déjà', 409);
}
$stmt = $db->prepare('
INSERT INTO tags (user_id, name, color)
VALUES (:user_id, :name, :color)
');
$stmt->execute([
'user_id' => $this->getUserId(),
'name' => $data['name'],
'color' => $data['color'] ?? '#3B82F6',
]);
$tagId = (int) $db->lastInsertId();
$tag = $this->findOrFail($tagId);
Response::success($tag, 'Tag créé', 201);
}
/**
* PUT /tags/{id}
*/
public function update(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$data = $this->validate([
'name' => 'min:1|max:50',
'color' => 'max:7',
]);
$db = Database::getInstance();
// Vérifier unicité du nom si modifié
if (!empty($data['name'])) {
$stmt = $db->prepare('
SELECT id FROM tags
WHERE user_id = :user_id AND name = :name AND id != :id
');
$stmt->execute([
'user_id' => $this->getUserId(),
'name' => $data['name'],
'id' => $id,
]);
if ($stmt->fetch()) {
Response::error('Ce tag existe déjà', 409);
}
}
$fields = [];
$params = ['id' => $id];
if (isset($data['name'])) {
$fields[] = 'name = :name';
$params['name'] = $data['name'];
}
if (isset($data['color'])) {
$fields[] = 'color = :color';
$params['color'] = $data['color'];
}
if (!empty($fields)) {
$sql = 'UPDATE tags SET ' . implode(', ', $fields) . ' WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
$tag = $this->findOrFail($id);
Response::success($tag, 'Tag mis à jour');
}
/**
* DELETE /tags/{id}
*/
public function destroy(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$db = Database::getInstance();
// Les associations seront supprimées en cascade (FK)
$stmt = $db->prepare('DELETE FROM tags WHERE id = :id');
$stmt->execute(['id' => $id]);
Response::success(null, 'Tag supprimé');
}
/**
* Trouver un tag ou retourner 404
*/
private function findOrFail(int $id): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT * FROM tags
WHERE id = :id AND user_id = :user_id
');
$stmt->execute([
'id' => $id,
'user_id' => $this->getUserId(),
]);
$tag = $stmt->fetch();
if (!$tag) {
Response::notFound('Tag non trouvé');
}
return $tag;
}
/**
* Récupérer les projets associés à un tag
*/
private function getTagProjects(int $tagId): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT p.id, p.name
FROM projects p
JOIN project_tags pt ON p.id = pt.project_id
WHERE pt.tag_id = :tag_id
ORDER BY p.name ASC
');
$stmt->execute(['tag_id' => $tagId]);
return $stmt->fetchAll();
}
/**
* Récupérer les tâches associées à un tag
*/
private function getTagTasks(int $tagId): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT t.id, t.title, t.status_id, s.name as status_name
FROM tasks t
JOIN task_tags tt ON t.id = tt.task_id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE tt.tag_id = :tag_id
ORDER BY t.created_at DESC
');
$stmt->execute(['tag_id' => $tagId]);
return $stmt->fetchAll();
}
}

View File

@@ -0,0 +1,399 @@
<?php
/**
* Contrôleur des tâches
*/
declare(strict_types=1);
class TaskController extends Controller
{
/**
* GET /tasks
* Liste les tâches avec filtres optionnels
* ?project_id=X - filtrer par projet
* ?status_id=X - filtrer par statut
* ?tag_id=X - filtrer par tag
* ?date_start=YYYY-MM-DD - tâches commençant après
* ?date_end=YYYY-MM-DD - tâches finissant avant
*/
public function index(): void
{
$this->requireAuth();
$db = Database::getInstance();
$where = ['t.user_id = :user_id'];
$params = ['user_id' => $this->getUserId()];
// Filtre par projet
$projectId = $this->request->get('project_id');
if ($projectId !== null) {
$where[] = 't.project_id = :project_id';
$params['project_id'] = (int) $projectId;
}
// Filtre par statut
$statusId = $this->request->get('status_id');
if ($statusId !== null) {
$where[] = 't.status_id = :status_id';
$params['status_id'] = (int) $statusId;
}
// Filtre par date de début
$dateStart = $this->request->get('date_start');
if ($dateStart !== null) {
$where[] = 't.date_start >= :date_start';
$params['date_start'] = $dateStart;
}
// Filtre par date de fin
$dateEnd = $this->request->get('date_end');
if ($dateEnd !== null) {
$where[] = 't.date_end <= :date_end';
$params['date_end'] = $dateEnd;
}
$sql = '
SELECT t.*,
p.name as project_name,
s.name as status_name,
s.color as status_color,
GROUP_CONCAT(tg.id) as tag_ids,
GROUP_CONCAT(tg.name) as tag_names,
GROUP_CONCAT(tg.color) as tag_colors
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
LEFT JOIN task_tags tt ON t.id = tt.task_id
LEFT JOIN tags tg ON tt.tag_id = tg.id
WHERE ' . implode(' AND ', $where) . '
GROUP BY t.id
ORDER BY t.position ASC, t.priority DESC, t.created_at DESC
';
$stmt = $db->prepare($sql);
$stmt->execute($params);
$tasks = $stmt->fetchAll();
// Filtre par tag (après GROUP BY)
$tagId = $this->request->get('tag_id');
// Parser les tags
foreach ($tasks as &$task) {
$task['tags'] = $this->parseTags($task);
unset($task['tag_ids'], $task['tag_names'], $task['tag_colors']);
}
// Appliquer filtre tag si nécessaire
if ($tagId !== null) {
$tagId = (int) $tagId;
$tasks = array_filter($tasks, function ($task) use ($tagId) {
foreach ($task['tags'] as $tag) {
if ($tag['id'] === $tagId) {
return true;
}
}
return false;
});
$tasks = array_values($tasks);
}
Response::success($tasks);
}
/**
* GET /tasks/{id}
*/
public function show(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$task = $this->findOrFail($id);
$task['tags'] = $this->getTaskTags($id);
Response::success($task);
}
/**
* POST /tasks
*/
public function store(): void
{
$this->requireAuth();
$data = $this->validate([
'project_id' => 'required|int',
'status_id' => 'required|int',
'title' => 'required|min:1|max:255',
'description' => 'max:65535',
'priority' => 'int',
'date_start' => 'max:10',
'date_end' => 'max:10',
'time_estimated' => 'int',
'time_spent' => 'int',
'billing' => 'numeric',
'position' => 'int',
]);
// Vérifier que le projet appartient à l'utilisateur
$this->verifyProject((int) $data['project_id']);
// Vérifier que le statut appartient à l'utilisateur
$this->verifyStatus((int) $data['status_id']);
$db = Database::getInstance();
$stmt = $db->prepare('
INSERT INTO tasks (user_id, project_id, status_id, title, description, priority,
date_start, date_end, time_estimated, time_spent, billing, position)
VALUES (:user_id, :project_id, :status_id, :title, :description, :priority,
:date_start, :date_end, :time_estimated, :time_spent, :billing, :position)
');
$stmt->execute([
'user_id' => $this->getUserId(),
'project_id' => $data['project_id'],
'status_id' => $data['status_id'],
'title' => $data['title'],
'description' => $data['description'] ?? null,
'priority' => $data['priority'] ?? 5,
'date_start' => $data['date_start'] ?: null,
'date_end' => $data['date_end'] ?: null,
'time_estimated' => $data['time_estimated'] ?? 0,
'time_spent' => $data['time_spent'] ?? 0,
'billing' => $data['billing'] ?? 0,
'position' => $data['position'] ?? 0,
]);
$taskId = (int) $db->lastInsertId();
// Gérer les tags si fournis
$tags = $this->request->get('tags');
if (is_array($tags)) {
$this->syncTags($taskId, $tags);
}
$task = $this->findOrFail($taskId);
$task['tags'] = $this->getTaskTags($taskId);
Response::success($task, 'Tâche créée', 201);
}
/**
* PUT /tasks/{id}
*/
public function update(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$data = $this->validate([
'project_id' => 'int',
'status_id' => 'int',
'title' => 'min:1|max:255',
'description' => 'max:65535',
'priority' => 'int',
'date_start' => 'max:10',
'date_end' => 'max:10',
'time_estimated' => 'int',
'time_spent' => 'int',
'billing' => 'numeric',
'position' => 'int',
]);
if (!empty($data['project_id'])) {
$this->verifyProject((int) $data['project_id']);
}
if (!empty($data['status_id'])) {
$this->verifyStatus((int) $data['status_id']);
}
$db = Database::getInstance();
$fields = [];
$params = ['id' => $id];
$allowedFields = [
'project_id', 'status_id', 'title', 'description', 'priority',
'date_start', 'date_end', 'time_estimated', 'time_spent', 'billing', 'position'
];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$fields[] = "{$field} = :{$field}";
$value = $data[$field];
// Convertir les chaînes vides en null pour les dates
if (in_array($field, ['date_start', 'date_end']) && $value === '') {
$value = null;
}
$params[$field] = $value;
}
}
if (!empty($fields)) {
$sql = 'UPDATE tasks SET ' . implode(', ', $fields) . ' WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($params);
}
// Gérer les tags si fournis
$tags = $this->request->get('tags');
if (is_array($tags)) {
$this->syncTags($id, $tags);
}
$task = $this->findOrFail($id);
$task['tags'] = $this->getTaskTags($id);
Response::success($task, 'Tâche mise à jour');
}
/**
* DELETE /tasks/{id}
*/
public function destroy(): void
{
$this->requireAuth();
$id = (int) $this->request->getParam('id');
$this->findOrFail($id);
$db = Database::getInstance();
$stmt = $db->prepare('DELETE FROM tasks WHERE id = :id');
$stmt->execute(['id' => $id]);
Response::success(null, 'Tâche supprimée');
}
/**
* Trouver une tâche ou retourner 404
*/
private function findOrFail(int $id): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE t.id = :id AND t.user_id = :user_id
');
$stmt->execute([
'id' => $id,
'user_id' => $this->getUserId(),
]);
$task = $stmt->fetch();
if (!$task) {
Response::notFound('Tâche non trouvée');
}
return $task;
}
/**
* Vérifier qu'un projet appartient à l'utilisateur
*/
private function verifyProject(int $projectId): void
{
$db = Database::getInstance();
$stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id');
$stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]);
if (!$stmt->fetch()) {
Response::error('Projet invalide', 422);
}
}
/**
* Vérifier qu'un statut appartient à l'utilisateur
*/
private function verifyStatus(int $statusId): void
{
$db = Database::getInstance();
$stmt = $db->prepare('SELECT id FROM statuses WHERE id = :id AND user_id = :user_id');
$stmt->execute(['id' => $statusId, 'user_id' => $this->getUserId()]);
if (!$stmt->fetch()) {
Response::error('Statut invalide', 422);
}
}
/**
* Parser les tags depuis le GROUP_CONCAT
*/
private function parseTags(array $task): array
{
$tags = [];
if (!empty($task['tag_ids'])) {
$ids = explode(',', $task['tag_ids']);
$names = explode(',', $task['tag_names']);
$colors = explode(',', $task['tag_colors']);
foreach ($ids as $i => $tagId) {
$tags[] = [
'id' => (int) $tagId,
'name' => $names[$i] ?? '',
'color' => $colors[$i] ?? '#3B82F6',
];
}
}
return $tags;
}
/**
* Récupérer les tags d'une tâche
*/
private function getTaskTags(int $taskId): array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT t.id, t.name, t.color
FROM tags t
JOIN task_tags tt ON t.id = tt.tag_id
WHERE tt.task_id = :task_id
');
$stmt->execute(['task_id' => $taskId]);
return $stmt->fetchAll();
}
/**
* Synchroniser les tags d'une tâche
*/
private function syncTags(int $taskId, array $tagIds): void
{
$db = Database::getInstance();
$stmt = $db->prepare('DELETE FROM task_tags WHERE task_id = :task_id');
$stmt->execute(['task_id' => $taskId]);
if (!empty($tagIds)) {
$stmt = $db->prepare('
INSERT INTO task_tags (task_id, tag_id)
SELECT :task_id, id FROM tags
WHERE id = :tag_id AND user_id = :user_id
');
foreach ($tagIds as $tagId) {
$stmt->execute([
'task_id' => $taskId,
'tag_id' => (int) $tagId,
'user_id' => $this->getUserId(),
]);
}
}
}
}

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;
}
}

147
clients/prokov/api/deploy-api.sh Executable file
View File

@@ -0,0 +1,147 @@
#!/bin/bash
# Script de déploiement pour PROKOV API
# Version: 1.0 (12 décembre 2025)
# Auteur: Pierre (avec l'aide de Claude)
set -euo pipefail
ENV=DEV
JUMP_USER="root"
JUMP_HOST="195.154.80.116"
JUMP_PORT="22"
JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi"
INCUS_PROJECT=default
INCUS_CONTAINER=dva-front
# Paramètres du container Incus
CONTAINER_USER=root
CONTAINER_IP="13.23.33.42"
# Paramètres de déploiement
FINAL_PATH="/var/www/prokov/api"
FINAL_OWNER="nginx"
FINAL_GROUP="nginx"
FINAL_OWNER_LOGS="nobody"
# Couleurs pour les messages
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Fonction pour afficher les messages d'étape
echo_step() {
echo -e "${GREEN}==>${NC} $1"
}
# Fonction pour afficher les informations
echo_info() {
echo -e "${BLUE}Info:${NC} $1"
}
# Fonction pour afficher les avertissements
echo_warning() {
echo -e "${YELLOW}Warning:${NC} $1"
}
# Fonction pour afficher les erreurs
echo_error() {
echo -e "${RED}Error:${NC} $1"
exit 1
}
# Vérification de l'environnement
echo_step "Verifying environment..."
echo_info "Deploying PROKOV API to $ENV environment"
echo_info "Container: $INCUS_CONTAINER (IP: $CONTAINER_IP)"
echo_info "Target path: $FINAL_PATH"
# Vérification des fichiers requis
if [ ! -f "public/index.php" ]; then
echo_error "public/index.php missing - are you in the api directory?"
fi
if [ ! -d "core" ] || [ ! -d "controllers" ]; then
echo_error "API structure incomplete (core/ or controllers/ missing)"
fi
# Étape 0: Définir le nom de l'archive
ARCHIVE_NAME="prokov-api-${ENV}-$(date +%s).tar.gz"
ARCHIVE_PATH="/tmp/${ARCHIVE_NAME}"
echo_info "Archive name will be: $ARCHIVE_NAME"
# Étape 1: Créer une archive du projet
echo_step "Creating project archive..."
tar --exclude='.git' \
--exclude='.gitignore' \
--exclude='.vscode' \
--exclude='logs' \
--exclude='*.template' \
--exclude='*.sh' \
--exclude='.env' \
--exclude='*.log' \
--exclude='.DS_Store' \
--exclude='README.md' \
--exclude="*.tar.gz" \
--no-xattrs \
-czf "${ARCHIVE_PATH}" . || echo_error "Failed to create archive"
# Vérifier la taille de l'archive
ARCHIVE_SIZE=$(du -h "${ARCHIVE_PATH}" | cut -f1)
SSH_JUMP_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}"
# Étape 2: Copier l'archive vers le serveur de saut (IN3)
echo_step "Copying archive to jump server (IN3)..."
echo_info "Archive size: $ARCHIVE_SIZE"
scp -i "${JUMP_KEY}" -P "${JUMP_PORT}" "${ARCHIVE_PATH}" "${JUMP_USER}@${JUMP_HOST}:/tmp/${ARCHIVE_NAME}" || echo_error "Failed to copy archive to jump server"
# Étape 3: Exécuter les commandes sur IN3 pour déployer dans le container Incus dva-front
echo_step "Deploying to Incus container ($INCUS_CONTAINER)..."
$SSH_JUMP_CMD "
set -euo pipefail
echo '✅ Passage au projet Incus...'
incus project switch ${INCUS_PROJECT} || exit 1
echo '📦 Poussée de l archive dans le conteneur...'
incus file push /tmp/${ARCHIVE_NAME} ${INCUS_CONTAINER}/tmp/${ARCHIVE_NAME} || exit 1
echo '📁 Préparation du dossier final...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH} || exit 1
incus exec ${INCUS_CONTAINER} -- rm -rf ${FINAL_PATH}/* || exit 1
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${FINAL_PATH}/ || exit 1
echo '🔧 Réglage des permissions...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH}/logs || exit 1
incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${FINAL_PATH} || exit 1
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type d -exec chmod 755 {} \; || exit 1
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type f -exec chmod 644 {} \; || exit 1
# Permissions spéciales pour le dossier logs
incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${FINAL_PATH}/logs || exit 1
incus exec ${INCUS_CONTAINER} -- chmod -R 775 ${FINAL_PATH}/logs || exit 1
echo '🧹 Nettoyage...'
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} || exit 1
rm -f /tmp/${ARCHIVE_NAME} || exit 1
"
# Nettoyage local
rm -f "${ARCHIVE_PATH}"
# Résumé final
echo_step "Deployment completed successfully."
echo ""
echo_info "PROKOV API deployed to $ENV environment"
echo_info " Host: IN3 ($JUMP_HOST)"
echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)"
echo_info " Path: $FINAL_PATH"
echo_info " Deployment time: $(date)"
echo ""
echo_info "API should be accessible at: https://prokov.unikoffice.com/api/"
# Journaliser le déploiement
echo "$(date '+%Y-%m-%d %H:%M:%S') - PROKOV API deployed to ${ENV} (${INCUS_CONTAINER}:${FINAL_PATH})" >> ~/.prokov_deploy_history

View File

@@ -0,0 +1,74 @@
<?php
/**
* Modèle User
*/
declare(strict_types=1);
class User
{
public static function findById(int $id): ?array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT id, email, name, created_at, updated_at
FROM users
WHERE id = :id
');
$stmt->execute(['id' => $id]);
$user = $stmt->fetch();
return $user ?: null;
}
public static function findByEmail(string $email): ?array
{
$db = Database::getInstance();
$stmt = $db->prepare('
SELECT id, email, name, password, created_at, updated_at
FROM users
WHERE email = :email
');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
return $user ?: null;
}
public static function update(int $id, array $data): bool
{
$db = Database::getInstance();
$fields = [];
$params = ['id' => $id];
if (isset($data['name'])) {
$fields[] = 'name = :name';
$params['name'] = $data['name'];
}
if (isset($data['email'])) {
$fields[] = 'email = :email';
$params['email'] = $data['email'];
}
if (isset($data['password'])) {
$fields[] = 'password = :password';
$params['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
}
if (empty($fields)) {
return false;
}
$sql = 'UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount() > 0;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* PROKOV API - Point d'entrée
*/
declare(strict_types=1);
// Chemin racine de l'API (dossier parent)
define('API_ROOT', dirname(__DIR__));
// Headers CORS et JSON
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Session-Id');
header('Access-Control-Allow-Credentials: true');
// Preflight OPTIONS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// Autoload simple
spl_autoload_register(function (string $class): void {
$paths = [
'config/',
'core/',
'controllers/',
'models/',
];
foreach ($paths as $path) {
$file = API_ROOT . '/' . $path . $class . '.php';
if (file_exists($file)) {
require_once $file;
return;
}
}
});
// Chargement config
require_once API_ROOT . '/config/config.php';
// Initialisation
$router = new Router();
$router->dispatch();

403
cmd/sogoms/db/main.go Executable file
View File

@@ -0,0 +1,403 @@
// sogoms-db : Microservice d'accès à MariaDB.
// Chaque application cliente a sa propre base de données.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"syscall"
_ "github.com/go-sql-driver/mysql"
"sogoms.com/internal/config"
"sogoms.com/internal/protocol"
)
var (
socketPath = flag.String("socket", "/run/sogoms-db.1.sock", "Unix socket path")
configDir = flag.String("config", "/config", "Configuration directory")
)
// DBPool gère les connexions DB par application.
type DBPool struct {
registry *config.Registry
pools map[string]*sql.DB
mu sync.RWMutex
}
func NewDBPool(registry *config.Registry) *DBPool {
return &DBPool{
registry: registry,
pools: make(map[string]*sql.DB),
}
}
// GetDB retourne une connexion DB pour l'application spécifiée.
func (p *DBPool) GetDB(appID string) (*sql.DB, error) {
p.mu.RLock()
db, ok := p.pools[appID]
p.mu.RUnlock()
if ok {
return db, nil
}
// Créer une nouvelle connexion
p.mu.Lock()
defer p.mu.Unlock()
// Double-check après le lock
if db, ok := p.pools[appID]; ok {
return db, nil
}
cfg, ok := p.registry.GetByApp(appID)
if !ok {
return nil, fmt.Errorf("unknown app: %s", appID)
}
db, err := sql.Open("mysql", cfg.Database.DSN())
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// Configuration du pool
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
// Test de connexion
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
p.pools[appID] = db
log.Printf("[db] connected to database for app: %s", appID)
return db, nil
}
// Close ferme toutes les connexions.
func (p *DBPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for appID, db := range p.pools {
db.Close()
log.Printf("[db] closed connection for app: %s", appID)
}
}
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lshortfile)
// Charger les configurations
registry := config.NewRegistry(*configDir)
if err := registry.Load(); err != nil {
log.Fatalf("load config: %v", err)
}
log.Printf("[db] loaded apps: %v", registry.Apps())
// Pool de connexions DB
dbPool := NewDBPool(registry)
defer dbPool.Close()
// Handler des requêtes
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
return handleRequest(ctx, req, dbPool)
}
// Démarrer le serveur
server := protocol.NewServer(*socketPath, handler)
if err := server.Start(); err != nil {
log.Fatalf("start server: %v", err)
}
log.Printf("[db] sogoms-db started on %s", *socketPath)
// Attendre signal d'arrêt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Printf("[db] shutting down...")
server.Stop()
}
func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response {
// L'app_id doit être fourni
appID, ok := req.Params["app_id"].(string)
if !ok || appID == "" {
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
}
db, err := dbPool.GetDB(appID)
if err != nil {
return protocol.Failure(req.ID, "DB_ERROR", err.Error())
}
switch req.Action {
case "query":
return handleQuery(req, db)
case "query_one":
return handleQueryOne(req, db)
case "insert":
return handleInsert(req, db)
case "update":
return handleUpdate(req, db)
case "delete":
return handleDelete(req, db)
case "health":
return handleHealth(req, db)
default:
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
}
}
// handleQuery exécute un SELECT et retourne plusieurs lignes.
func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
query, args, err := extractQueryParams(req.Params)
if err != nil {
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
}
rows, err := db.Query(query, args...)
if err != nil {
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
}
defer rows.Close()
results, err := scanRows(rows)
if err != nil {
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
}
return protocol.Success(req.ID, results)
}
// handleQueryOne exécute un SELECT et retourne une seule ligne.
func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
query, args, err := extractQueryParams(req.Params)
if err != nil {
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
}
rows, err := db.Query(query, args...)
if err != nil {
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
}
defer rows.Close()
results, err := scanRows(rows)
if err != nil {
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
}
if len(results) == 0 {
return protocol.Failure(req.ID, "NOT_FOUND", "no rows found")
}
return protocol.Success(req.ID, results[0])
}
// handleInsert exécute un INSERT et retourne l'ID inséré.
func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
}
data, ok := req.Params["data"].(map[string]any)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "data is required")
}
// Construire la requête INSERT
columns := make([]string, 0, len(data))
placeholders := make([]string, 0, len(data))
values := make([]any, 0, len(data))
for col, val := range data {
columns = append(columns, col)
placeholders = append(placeholders, "?")
values = append(values, val)
}
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(columns, ", "),
strings.Join(placeholders, ", "))
result, err := db.Exec(query, values...)
if err != nil {
return protocol.Failure(req.ID, "INSERT_ERROR", err.Error())
}
insertID, _ := result.LastInsertId()
return protocol.Success(req.ID, map[string]any{
"insert_id": insertID,
})
}
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
}
data, ok := req.Params["data"].(map[string]any)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "data is required")
}
where, ok := req.Params["where"].(map[string]any)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
}
// Construire SET
setClauses := make([]string, 0, len(data))
values := make([]any, 0, len(data)+len(where))
for col, val := range data {
setClauses = append(setClauses, col+" = ?")
values = append(values, val)
}
// Construire WHERE
whereClauses := make([]string, 0, len(where))
for col, val := range where {
whereClauses = append(whereClauses, col+" = ?")
values = append(values, val)
}
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s",
table,
strings.Join(setClauses, ", "),
strings.Join(whereClauses, " AND "))
result, err := db.Exec(query, values...)
if err != nil {
return protocol.Failure(req.ID, "UPDATE_ERROR", err.Error())
}
affected, _ := result.RowsAffected()
return protocol.Success(req.ID, map[string]any{
"affected_rows": affected,
})
}
// handleDelete exécute un DELETE et retourne le nombre de lignes affectées.
func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response {
table, ok := req.Params["table"].(string)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
}
where, ok := req.Params["where"].(map[string]any)
if !ok {
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
}
// Construire WHERE
whereClauses := make([]string, 0, len(where))
values := make([]any, 0, len(where))
for col, val := range where {
whereClauses = append(whereClauses, col+" = ?")
values = append(values, val)
}
query := fmt.Sprintf("DELETE FROM %s WHERE %s",
table,
strings.Join(whereClauses, " AND "))
result, err := db.Exec(query, values...)
if err != nil {
return protocol.Failure(req.ID, "DELETE_ERROR", err.Error())
}
affected, _ := result.RowsAffected()
return protocol.Success(req.ID, map[string]any{
"affected_rows": affected,
})
}
// handleHealth vérifie la connexion à la DB.
func handleHealth(req *protocol.Request, db *sql.DB) *protocol.Response {
if err := db.Ping(); err != nil {
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
}
return protocol.Success(req.ID, map[string]any{"status": "ok"})
}
// extractQueryParams extrait query et args des paramètres.
func extractQueryParams(params map[string]any) (string, []any, error) {
query, ok := params["query"].(string)
if !ok {
return "", nil, fmt.Errorf("query is required")
}
var args []any
if argsRaw, ok := params["args"]; ok {
switch v := argsRaw.(type) {
case []any:
args = v
default:
// Essayer de convertir via JSON
data, _ := json.Marshal(argsRaw)
json.Unmarshal(data, &args)
}
}
return query, args, nil
}
// scanRows convertit les résultats SQL en slice de maps.
func scanRows(rows *sql.Rows) ([]map[string]any, error) {
columns, err := rows.Columns()
if err != nil {
return nil, err
}
var results []map[string]any
for rows.Next() {
// Créer des pointeurs pour scanner
values := make([]any, len(columns))
valuePtrs := make([]any, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, err
}
// Construire la map
row := make(map[string]any)
for i, col := range columns {
val := values[i]
// Convertir []byte en string
if b, ok := val.([]byte); ok {
row[col] = string(b)
} else {
row[col] = val
}
}
results = append(results, row)
}
if results == nil {
results = []map[string]any{}
}
return results, rows.Err()
}

4
cmd/sogoms/email/main.go Executable file
View File

@@ -0,0 +1,4 @@
package main
func main() {
}

4
cmd/sogoms/pdf/main.go Executable file
View File

@@ -0,0 +1,4 @@
package main
func main() {
}

4
cmd/sogoms/storage/main.go Executable file
View File

@@ -0,0 +1,4 @@
package main
func main() {
}

4
cmd/sogorch/main.go Executable file
View File

@@ -0,0 +1,4 @@
package main
func main() {
}

129
config/routes/prokov.yaml Normal file
View File

@@ -0,0 +1,129 @@
# Routes API Prokov
# Gestion de projets et tâches
app: prokov
version: "1.0"
base_path: /api
# Identification par hostname
hosts:
- prokov.unikoffice.com
- prokov.sogoms.com
# Base de données
database:
host: 13.23.33.4
port: 3306
user: prokov_user
password_file: /secrets/prokov_db_pass
name: prokov
# Authentification
auth:
jwt_secret_file: /secrets/prokov_jwt_secret
jwt_expiry: 24h
# Routes
routes:
# === AUTH ===
- path: /auth/register
method: POST
scenario: prokov/auth/register
auth: false
- path: /auth/login
method: POST
scenario: prokov/auth/login
auth: false
- path: /auth/logout
method: POST
scenario: prokov/auth/logout
- path: /auth/me
method: GET
scenario: prokov/auth/me
# === PROJECTS ===
- path: /projects
method: GET
scenario: prokov/projects/list
- path: /projects
method: POST
scenario: prokov/projects/create
- path: /projects/{id}
method: GET
scenario: prokov/projects/show
- path: /projects/{id}
method: PUT
scenario: prokov/projects/update
- path: /projects/{id}
method: DELETE
scenario: prokov/projects/delete
# === TASKS ===
- path: /tasks
method: GET
scenario: prokov/tasks/list
- path: /tasks
method: POST
scenario: prokov/tasks/create
- path: /tasks/{id}
method: GET
scenario: prokov/tasks/show
- path: /tasks/{id}
method: PUT
scenario: prokov/tasks/update
- path: /tasks/{id}
method: DELETE
scenario: prokov/tasks/delete
# === TAGS ===
- path: /tags
method: GET
scenario: prokov/tags/list
- path: /tags
method: POST
scenario: prokov/tags/create
- path: /tags/{id}
method: GET
scenario: prokov/tags/show
- path: /tags/{id}
method: PUT
scenario: prokov/tags/update
- path: /tags/{id}
method: DELETE
scenario: prokov/tags/delete
# === STATUSES ===
- path: /statuses
method: GET
scenario: prokov/statuses/list
- path: /statuses
method: POST
scenario: prokov/statuses/create
- path: /statuses/{id}
method: GET
scenario: prokov/statuses/show
- path: /statuses/{id}
method: PUT
scenario: prokov/statuses/update
- path: /statuses/{id}
method: DELETE
scenario: prokov/statuses/delete

View File

@@ -0,0 +1,58 @@
# Scénario: Connexion utilisateur
name: login
version: "1.0"
description: Authentifie un utilisateur et retourne un JWT
input:
required:
- email
- password
validation:
email:
type: string
format: email
password:
type: string
min_length: 1
steps:
- id: get_user
service: db
action: query_one
params:
query: "SELECT id, email, name, password FROM users WHERE email = ?"
args: ["{{input.email}}"]
on_error: abort
error_message: "Email ou mot de passe incorrect"
error_status: 401
- id: verify_password
service: auth
action: verify_password
params:
hash: "{{steps.get_user.result.password}}"
password: "{{input.password}}"
on_error: abort
error_message: "Email ou mot de passe incorrect"
error_status: 401
- id: generate_token
service: auth
action: generate_jwt
params:
claims:
sub: "{{steps.get_user.result.id}}"
email: "{{steps.get_user.result.email}}"
name: "{{steps.get_user.result.name}}"
output:
status: 200
body:
success: true
message: "Connexion réussie"
data:
token: "{{steps.generate_token.result.token}}"
user:
id: "{{steps.get_user.result.id}}"
email: "{{steps.get_user.result.email}}"
name: "{{steps.get_user.result.name}}"

View File

@@ -0,0 +1,13 @@
# Scénario: Déconnexion
name: logout
version: "1.0"
description: Déconnecte l'utilisateur (côté client, invalide le JWT)
# Avec JWT stateless, le logout est géré côté client
# Ce endpoint existe pour la compatibilité API
output:
status: 200
body:
success: true
message: "Déconnexion réussie"

View File

@@ -0,0 +1,22 @@
# Scénario: Récupérer l'utilisateur connecté
name: me
version: "1.0"
description: Retourne les informations de l'utilisateur authentifié
steps:
- id: get_user
service: db
action: query_one
params:
query: "SELECT id, email, name, created_at FROM users WHERE id = ?"
args: ["{{auth.user_id}}"]
on_error: abort
error_message: "Utilisateur non trouvé"
error_status: 404
output:
status: 200
body:
success: true
data:
user: "{{steps.get_user.result}}"

View File

@@ -0,0 +1,93 @@
# Scénario: Inscription utilisateur
name: register
version: "1.0"
description: Crée un nouvel utilisateur
input:
required:
- email
- password
- name
validation:
email:
type: string
format: email
max_length: 255
password:
type: string
min_length: 6
max_length: 255
name:
type: string
min_length: 2
max_length: 100
steps:
- id: check_email
service: db
action: query_one
params:
query: "SELECT id FROM users WHERE email = ?"
args: ["{{input.email}}"]
on_success: abort
error_message: "Cet email est déjà utilisé"
error_status: 409
- id: hash_password
service: auth
action: hash_password
params:
password: "{{input.password}}"
- id: create_user
service: db
action: insert
params:
table: users
data:
email: "{{input.email}}"
password: "{{steps.hash_password.result.hash}}"
name: "{{input.name}}"
- id: create_default_statuses
service: db
action: exec
params:
query: |
INSERT INTO statuses (user_id, project_id, code, name, color, position) VALUES
(?, NULL, 10, 'Backlog', '#6B7280', 10),
(?, NULL, 20, 'À faire', '#3B82F6', 20),
(?, NULL, 30, 'En cours', '#F59E0B', 30),
(?, NULL, 40, 'À tester', '#8B5CF6', 40),
(?, NULL, 50, 'Livré', '#10B981', 50),
(?, NULL, 60, 'Terminé', '#059669', 60),
(?, NULL, 70, 'Archivé', '#9CA3AF', 70)
args:
- "{{steps.create_user.insert_id}}"
- "{{steps.create_user.insert_id}}"
- "{{steps.create_user.insert_id}}"
- "{{steps.create_user.insert_id}}"
- "{{steps.create_user.insert_id}}"
- "{{steps.create_user.insert_id}}"
- "{{steps.create_user.insert_id}}"
- id: generate_token
service: auth
action: generate_jwt
params:
claims:
sub: "{{steps.create_user.insert_id}}"
email: "{{input.email}}"
name: "{{input.name}}"
output:
status: 201
body:
success: true
message: "Inscription réussie"
data:
token: "{{steps.generate_token.result.token}}"
user:
id: "{{steps.create_user.insert_id}}"
email: "{{input.email}}"
name: "{{input.name}}"

View File

@@ -0,0 +1,95 @@
# Scénario: Créer un projet
name: projects_create
version: "1.0"
description: Crée un nouveau projet
input:
required:
- name
optional:
- description
- parent_id
- position
- tags
defaults:
position: 0
validation:
name:
type: string
min_length: 1
max_length: 100
description:
type: string
max_length: 65535
parent_id:
type: int
position:
type: int
steps:
- id: check_parent
service: db
action: query_one
condition: "{{input.parent_id != null}}"
params:
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.parent_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet parent non trouvé"
error_status: 422
- id: insert_project
service: db
action: insert
params:
table: projects
data:
user_id: "{{auth.user_id}}"
parent_id: "{{input.parent_id}}"
name: "{{input.name}}"
description: "{{input.description}}"
position: "{{input.position}}"
- id: sync_tags
service: db
action: exec
condition: "{{input.tags != null && input.tags | length > 0}}"
foreach: "{{input.tags}}"
foreach_as: tag_id
params:
query: |
INSERT INTO project_tags (project_id, tag_id)
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
args: ["{{steps.insert_project.insert_id}}", "{{tag_id}}", "{{auth.user_id}}"]
- id: get_project
service: db
action: query_one
params:
query: "SELECT * FROM projects WHERE id = ?"
args: ["{{steps.insert_project.insert_id}}"]
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.id, t.name, t.color
FROM tags t
JOIN project_tags pt ON t.id = pt.tag_id
WHERE pt.project_id = ?
args: ["{{steps.insert_project.insert_id}}"]
output:
status: 201
body:
success: true
message: "Projet créé"
data:
id: "{{steps.get_project.result.id}}"
name: "{{steps.get_project.result.name}}"
description: "{{steps.get_project.result.description}}"
parent_id: "{{steps.get_project.result.parent_id}}"
position: "{{steps.get_project.result.position}}"
created_at: "{{steps.get_project.result.created_at}}"
tags: "{{steps.get_tags.result}}"

View File

@@ -0,0 +1,36 @@
# Scénario: Supprimer un projet
name: projects_delete
version: "1.0"
description: Supprime un projet (cascade sur sous-projets et tâches)
input:
required:
- id
validation:
id:
type: int
steps:
- id: check_project
service: db
action: query_one
params:
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet non trouvé"
error_status: 404
- id: delete_project
service: db
action: delete
params:
table: projects
where:
id: "{{input.id}}"
output:
status: 200
body:
success: true
message: "Projet supprimé"

View File

@@ -0,0 +1,27 @@
# Scénario: Liste des projets (arborescence)
name: projects_list
version: "1.0"
description: Retourne tous les projets de l'utilisateur en arborescence
steps:
- id: get_projects
service: db
action: query
params:
query: |
SELECT p.*,
GROUP_CONCAT(t.id) as tag_ids,
GROUP_CONCAT(t.name) as tag_names
FROM projects p
LEFT JOIN project_tags pt ON p.id = pt.project_id
LEFT JOIN tags t ON pt.tag_id = t.id
WHERE p.user_id = ?
GROUP BY p.id
ORDER BY p.parent_id ASC, p.position ASC, p.name ASC
args: ["{{auth.user_id}}"]
output:
status: 200
body:
success: true
data: "{{steps.get_projects.result | tree}}"

View File

@@ -0,0 +1,57 @@
# Scénario: Détail d'un projet
name: projects_show
version: "1.0"
description: Retourne un projet avec ses tags et sous-projets
input:
required:
- id
validation:
id:
type: int
steps:
- id: get_project
service: db
action: query_one
params:
query: "SELECT * FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet non trouvé"
error_status: 404
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.id, t.name, t.color
FROM tags t
JOIN project_tags pt ON t.id = pt.tag_id
WHERE pt.project_id = ?
args: ["{{input.id}}"]
- id: get_children
service: db
action: query
params:
query: |
SELECT * FROM projects
WHERE parent_id = ? AND user_id = ?
ORDER BY position ASC, name ASC
args: ["{{input.id}}", "{{auth.user_id}}"]
output:
status: 200
body:
success: true
data:
id: "{{steps.get_project.result.id}}"
name: "{{steps.get_project.result.name}}"
description: "{{steps.get_project.result.description}}"
parent_id: "{{steps.get_project.result.parent_id}}"
position: "{{steps.get_project.result.position}}"
created_at: "{{steps.get_project.result.created_at}}"
tags: "{{steps.get_tags.result}}"
children: "{{steps.get_children.result}}"

View File

@@ -0,0 +1,115 @@
# Scénario: Modifier un projet
name: projects_update
version: "1.0"
description: Met à jour un projet existant
input:
required:
- id
optional:
- name
- description
- parent_id
- position
- tags
validation:
id:
type: int
name:
type: string
min_length: 1
max_length: 100
description:
type: string
max_length: 65535
parent_id:
type: int
position:
type: int
steps:
- id: get_project
service: db
action: query_one
params:
query: "SELECT * FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet non trouvé"
error_status: 404
- id: check_parent
service: db
action: query_one
condition: "{{input.parent_id != null && input.parent_id != input.id}}"
params:
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.parent_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet parent invalide"
error_status: 422
- id: update_project
service: db
action: update
params:
table: projects
where:
id: "{{input.id}}"
data:
name: "{{input.name ?? steps.get_project.result.name}}"
description: "{{input.description ?? steps.get_project.result.description}}"
parent_id: "{{input.parent_id ?? steps.get_project.result.parent_id}}"
position: "{{input.position ?? steps.get_project.result.position}}"
- id: clear_tags
service: db
action: delete
condition: "{{input.tags != null}}"
params:
table: project_tags
where:
project_id: "{{input.id}}"
- id: sync_tags
service: db
action: exec
condition: "{{input.tags != null && input.tags | length > 0}}"
foreach: "{{input.tags}}"
foreach_as: tag_id
params:
query: |
INSERT INTO project_tags (project_id, tag_id)
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
args: ["{{input.id}}", "{{tag_id}}", "{{auth.user_id}}"]
- id: get_updated
service: db
action: query_one
params:
query: "SELECT * FROM projects WHERE id = ?"
args: ["{{input.id}}"]
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.id, t.name, t.color
FROM tags t
JOIN project_tags pt ON t.id = pt.tag_id
WHERE pt.project_id = ?
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
message: "Projet mis à jour"
data:
id: "{{steps.get_updated.result.id}}"
name: "{{steps.get_updated.result.name}}"
description: "{{steps.get_updated.result.description}}"
parent_id: "{{steps.get_updated.result.parent_id}}"
position: "{{steps.get_updated.result.position}}"
tags: "{{steps.get_tags.result}}"

View File

@@ -0,0 +1,68 @@
# Scénario: Créer un statut
name: statuses_create
version: "1.0"
description: Crée un nouveau statut
input:
required:
- code
- name
optional:
- color
- project_id
- position
defaults:
color: "#6B7280"
validation:
code:
type: int
name:
type: string
min_length: 1
max_length: 50
color:
type: string
max_length: 7
project_id:
type: int
position:
type: int
steps:
- id: check_project
service: db
action: query_one
condition: "{{input.project_id != null}}"
params:
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.project_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet invalide"
error_status: 422
- id: insert_status
service: db
action: insert
params:
table: statuses
data:
user_id: "{{auth.user_id}}"
project_id: "{{input.project_id}}"
code: "{{input.code}}"
name: "{{input.name}}"
color: "{{input.color}}"
position: "{{input.position ?? input.code}}"
- id: get_status
service: db
action: query_one
params:
query: "SELECT * FROM statuses WHERE id = ?"
args: ["{{steps.insert_status.insert_id}}"]
output:
status: 201
body:
success: true
message: "Statut créé"
data: "{{steps.get_status.result}}"

View File

@@ -0,0 +1,51 @@
# Scénario: Supprimer un statut
name: statuses_delete
version: "1.0"
description: Supprime un statut (si aucune tâche ne l'utilise)
input:
required:
- id
validation:
id:
type: int
steps:
- id: check_status
service: db
action: query_one
params:
query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Statut non trouvé"
error_status: 404
- id: count_tasks
service: db
action: query_one
params:
query: "SELECT COUNT(*) as count FROM tasks WHERE status_id = ?"
args: ["{{input.id}}"]
- id: check_usage
service: system
action: assert
params:
condition: "{{steps.count_tasks.result.count == 0}}"
error_message: "Impossible de supprimer : {{steps.count_tasks.result.count}} tâche(s) utilisent ce statut"
error_status: 409
- id: delete_status
service: db
action: delete
params:
table: statuses
where:
id: "{{input.id}}"
output:
status: 200
body:
success: true
message: "Statut supprimé"

View File

@@ -0,0 +1,44 @@
# Scénario: Liste des statuts
name: statuses_list
version: "1.0"
description: Retourne les statuts avec filtres optionnels
input:
optional:
- project_id
- global
validation:
project_id:
type: int
global:
type: bool
steps:
- id: get_statuses
service: db
action: query
params:
query: |
SELECT s.*,
(SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count
FROM statuses s
WHERE s.user_id = ?
AND (
(? IS NOT NULL AND (s.project_id = ? OR s.project_id IS NULL))
OR (? IS NOT NULL AND s.project_id IS NULL)
OR (? IS NULL AND ? IS NULL)
)
ORDER BY s.position ASC, s.code ASC
args:
- "{{auth.user_id}}"
- "{{input.project_id}}"
- "{{input.project_id}}"
- "{{input.global}}"
- "{{input.project_id}}"
- "{{input.global}}"
output:
status: 200
body:
success: true
data: "{{steps.get_statuses.result}}"

View File

@@ -0,0 +1,42 @@
# Scénario: Détail d'un statut
name: statuses_show
version: "1.0"
description: Retourne un statut avec le nombre de tâches
input:
required:
- id
validation:
id:
type: int
steps:
- id: get_status
service: db
action: query_one
params:
query: "SELECT * FROM statuses WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Statut non trouvé"
error_status: 404
- id: count_tasks
service: db
action: query_one
params:
query: "SELECT COUNT(*) as count FROM tasks WHERE status_id = ?"
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
data:
id: "{{steps.get_status.result.id}}"
code: "{{steps.get_status.result.code}}"
name: "{{steps.get_status.result.name}}"
color: "{{steps.get_status.result.color}}"
project_id: "{{steps.get_status.result.project_id}}"
position: "{{steps.get_status.result.position}}"
task_count: "{{steps.count_tasks.result.count}}"

View File

@@ -0,0 +1,65 @@
# Scénario: Modifier un statut
name: statuses_update
version: "1.0"
description: Met à jour un statut existant
input:
required:
- id
optional:
- code
- name
- color
- position
validation:
id:
type: int
code:
type: int
name:
type: string
min_length: 1
max_length: 50
color:
type: string
max_length: 7
position:
type: int
steps:
- id: get_status
service: db
action: query_one
params:
query: "SELECT * FROM statuses WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Statut non trouvé"
error_status: 404
- id: update_status
service: db
action: update
params:
table: statuses
where:
id: "{{input.id}}"
data:
code: "{{input.code ?? steps.get_status.result.code}}"
name: "{{input.name ?? steps.get_status.result.name}}"
color: "{{input.color ?? steps.get_status.result.color}}"
position: "{{input.position ?? steps.get_status.result.position}}"
- id: get_updated
service: db
action: query_one
params:
query: "SELECT * FROM statuses WHERE id = ?"
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
message: "Statut mis à jour"
data: "{{steps.get_updated.result}}"

View File

@@ -0,0 +1,55 @@
# Scénario: Créer un tag
name: tags_create
version: "1.0"
description: Crée un nouveau tag
input:
required:
- name
optional:
- color
defaults:
color: "#3B82F6"
validation:
name:
type: string
min_length: 1
max_length: 50
color:
type: string
max_length: 7
steps:
- id: check_unique
service: db
action: query_one
params:
query: "SELECT id FROM tags WHERE user_id = ? AND name = ?"
args: ["{{auth.user_id}}", "{{input.name}}"]
on_success: abort
error_message: "Ce tag existe déjà"
error_status: 409
- id: insert_tag
service: db
action: insert
params:
table: tags
data:
user_id: "{{auth.user_id}}"
name: "{{input.name}}"
color: "{{input.color}}"
- id: get_tag
service: db
action: query_one
params:
query: "SELECT * FROM tags WHERE id = ?"
args: ["{{steps.insert_tag.insert_id}}"]
output:
status: 201
body:
success: true
message: "Tag créé"
data: "{{steps.get_tag.result}}"

View File

@@ -0,0 +1,36 @@
# Scénario: Supprimer un tag
name: tags_delete
version: "1.0"
description: Supprime un tag (les associations sont supprimées en cascade)
input:
required:
- id
validation:
id:
type: int
steps:
- id: check_tag
service: db
action: query_one
params:
query: "SELECT id FROM tags WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Tag non trouvé"
error_status: 404
- id: delete_tag
service: db
action: delete
params:
table: tags
where:
id: "{{input.id}}"
output:
status: 200
body:
success: true
message: "Tag supprimé"

View File

@@ -0,0 +1,24 @@
# Scénario: Liste des tags
name: tags_list
version: "1.0"
description: Retourne tous les tags de l'utilisateur avec compteurs
steps:
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.*,
(SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count,
(SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count
FROM tags t
WHERE t.user_id = ?
ORDER BY t.name ASC
args: ["{{auth.user_id}}"]
output:
status: 200
body:
success: true
data: "{{steps.get_tags.result}}"

View File

@@ -0,0 +1,58 @@
# Scénario: Détail d'un tag
name: tags_show
version: "1.0"
description: Retourne un tag avec ses projets et tâches associés
input:
required:
- id
validation:
id:
type: int
steps:
- id: get_tag
service: db
action: query_one
params:
query: "SELECT * FROM tags WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Tag non trouvé"
error_status: 404
- id: get_projects
service: db
action: query
params:
query: |
SELECT p.id, p.name
FROM projects p
JOIN project_tags pt ON p.id = pt.project_id
WHERE pt.tag_id = ?
ORDER BY p.name ASC
args: ["{{input.id}}"]
- id: get_tasks
service: db
action: query
params:
query: |
SELECT t.id, t.title, t.status_id, s.name as status_name
FROM tasks t
JOIN task_tags tt ON t.id = tt.task_id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE tt.tag_id = ?
ORDER BY t.created_at DESC
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
data:
id: "{{steps.get_tag.result.id}}"
name: "{{steps.get_tag.result.name}}"
color: "{{steps.get_tag.result.color}}"
projects: "{{steps.get_projects.result}}"
tasks: "{{steps.get_tasks.result}}"

View File

@@ -0,0 +1,68 @@
# Scénario: Modifier un tag
name: tags_update
version: "1.0"
description: Met à jour un tag existant
input:
required:
- id
optional:
- name
- color
validation:
id:
type: int
name:
type: string
min_length: 1
max_length: 50
color:
type: string
max_length: 7
steps:
- id: get_tag
service: db
action: query_one
params:
query: "SELECT * FROM tags WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Tag non trouvé"
error_status: 404
- id: check_unique
service: db
action: query_one
condition: "{{input.name != null}}"
params:
query: "SELECT id FROM tags WHERE user_id = ? AND name = ? AND id != ?"
args: ["{{auth.user_id}}", "{{input.name}}", "{{input.id}}"]
on_success: abort
error_message: "Ce tag existe déjà"
error_status: 409
- id: update_tag
service: db
action: update
params:
table: tags
where:
id: "{{input.id}}"
data:
name: "{{input.name ?? steps.get_tag.result.name}}"
color: "{{input.color ?? steps.get_tag.result.color}}"
- id: get_updated
service: db
action: query_one
params:
query: "SELECT * FROM tags WHERE id = ?"
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
message: "Tag mis à jour"
data: "{{steps.get_updated.result}}"

View File

@@ -0,0 +1,135 @@
# Scénario: Créer une tâche
name: tasks_create
version: "1.0"
description: Crée une nouvelle tâche
input:
required:
- project_id
- status_id
- title
optional:
- description
- priority
- date_start
- date_end
- time_estimated
- time_spent
- billing
- position
- tags
defaults:
priority: 5
time_estimated: 0
time_spent: 0
billing: 0
position: 0
validation:
project_id:
type: int
status_id:
type: int
title:
type: string
min_length: 1
max_length: 255
description:
type: string
max_length: 65535
priority:
type: int
date_start:
type: string
format: date
date_end:
type: string
format: date
steps:
- id: check_project
service: db
action: query_one
params:
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.project_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet invalide"
error_status: 422
- id: check_status
service: db
action: query_one
params:
query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?"
args: ["{{input.status_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Statut invalide"
error_status: 422
- id: insert_task
service: db
action: insert
params:
table: tasks
data:
user_id: "{{auth.user_id}}"
project_id: "{{input.project_id}}"
status_id: "{{input.status_id}}"
title: "{{input.title}}"
description: "{{input.description}}"
priority: "{{input.priority}}"
date_start: "{{input.date_start}}"
date_end: "{{input.date_end}}"
time_estimated: "{{input.time_estimated}}"
time_spent: "{{input.time_spent}}"
billing: "{{input.billing}}"
position: "{{input.position}}"
- id: sync_tags
service: db
action: exec
condition: "{{input.tags != null && input.tags | length > 0}}"
foreach: "{{input.tags}}"
foreach_as: tag_id
params:
query: |
INSERT INTO task_tags (task_id, tag_id)
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
args: ["{{steps.insert_task.insert_id}}", "{{tag_id}}", "{{auth.user_id}}"]
- id: get_task
service: db
action: query_one
params:
query: |
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE t.id = ?
args: ["{{steps.insert_task.insert_id}}"]
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.id, t.name, t.color
FROM tags t
JOIN task_tags tt ON t.id = tt.tag_id
WHERE tt.task_id = ?
args: ["{{steps.insert_task.insert_id}}"]
output:
status: 201
body:
success: true
message: "Tâche créée"
data:
id: "{{steps.get_task.result.id}}"
project_id: "{{steps.get_task.result.project_id}}"
project_name: "{{steps.get_task.result.project_name}}"
status_id: "{{steps.get_task.result.status_id}}"
status_name: "{{steps.get_task.result.status_name}}"
title: "{{steps.get_task.result.title}}"
tags: "{{steps.get_tags.result}}"

View File

@@ -0,0 +1,36 @@
# Scénario: Supprimer une tâche
name: tasks_delete
version: "1.0"
description: Supprime une tâche
input:
required:
- id
validation:
id:
type: int
steps:
- id: check_task
service: db
action: query_one
params:
query: "SELECT id FROM tasks WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Tâche non trouvée"
error_status: 404
- id: delete_task
service: db
action: delete
params:
table: tasks
where:
id: "{{input.id}}"
output:
status: 200
body:
success: true
message: "Tâche supprimée"

View File

@@ -0,0 +1,67 @@
# Scénario: Liste des tâches
name: tasks_list
version: "1.0"
description: Retourne les tâches avec filtres optionnels
input:
optional:
- project_id
- status_id
- tag_id
- date_start
- date_end
validation:
project_id:
type: int
status_id:
type: int
tag_id:
type: int
date_start:
type: string
format: date
date_end:
type: string
format: date
steps:
- id: get_tasks
service: db
action: query
params:
query: |
SELECT t.*,
p.name as project_name,
s.name as status_name,
s.color as status_color,
GROUP_CONCAT(tg.id) as tag_ids,
GROUP_CONCAT(tg.name) as tag_names,
GROUP_CONCAT(tg.color) as tag_colors
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
LEFT JOIN task_tags tt ON t.id = tt.task_id
LEFT JOIN tags tg ON tt.tag_id = tg.id
WHERE t.user_id = ?
AND (? IS NULL OR t.project_id = ?)
AND (? IS NULL OR t.status_id = ?)
AND (? IS NULL OR t.date_start >= ?)
AND (? IS NULL OR t.date_end <= ?)
GROUP BY t.id
ORDER BY t.position ASC, t.priority DESC, t.created_at DESC
args:
- "{{auth.user_id}}"
- "{{input.project_id}}"
- "{{input.project_id}}"
- "{{input.status_id}}"
- "{{input.status_id}}"
- "{{input.date_start}}"
- "{{input.date_start}}"
- "{{input.date_end}}"
- "{{input.date_end}}"
output:
status: 200
body:
success: true
data: "{{steps.get_tasks.result | parse_tags}}"

View File

@@ -0,0 +1,61 @@
# Scénario: Détail d'une tâche
name: tasks_show
version: "1.0"
description: Retourne une tâche avec ses tags
input:
required:
- id
validation:
id:
type: int
steps:
- id: get_task
service: db
action: query_one
params:
query: |
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE t.id = ? AND t.user_id = ?
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Tâche non trouvée"
error_status: 404
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.id, t.name, t.color
FROM tags t
JOIN task_tags tt ON t.id = tt.tag_id
WHERE tt.task_id = ?
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
data:
id: "{{steps.get_task.result.id}}"
project_id: "{{steps.get_task.result.project_id}}"
project_name: "{{steps.get_task.result.project_name}}"
status_id: "{{steps.get_task.result.status_id}}"
status_name: "{{steps.get_task.result.status_name}}"
status_color: "{{steps.get_task.result.status_color}}"
title: "{{steps.get_task.result.title}}"
description: "{{steps.get_task.result.description}}"
priority: "{{steps.get_task.result.priority}}"
date_start: "{{steps.get_task.result.date_start}}"
date_end: "{{steps.get_task.result.date_end}}"
time_estimated: "{{steps.get_task.result.time_estimated}}"
time_spent: "{{steps.get_task.result.time_spent}}"
billing: "{{steps.get_task.result.billing}}"
position: "{{steps.get_task.result.position}}"
created_at: "{{steps.get_task.result.created_at}}"
tags: "{{steps.get_tags.result}}"

View File

@@ -0,0 +1,140 @@
# Scénario: Modifier une tâche
name: tasks_update
version: "1.0"
description: Met à jour une tâche existante
input:
required:
- id
optional:
- project_id
- status_id
- title
- description
- priority
- date_start
- date_end
- time_estimated
- time_spent
- billing
- position
- tags
validation:
id:
type: int
project_id:
type: int
status_id:
type: int
title:
type: string
min_length: 1
max_length: 255
steps:
- id: get_task
service: db
action: query_one
params:
query: "SELECT * FROM tasks WHERE id = ? AND user_id = ?"
args: ["{{input.id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Tâche non trouvée"
error_status: 404
- id: check_project
service: db
action: query_one
condition: "{{input.project_id != null}}"
params:
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
args: ["{{input.project_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Projet invalide"
error_status: 422
- id: check_status
service: db
action: query_one
condition: "{{input.status_id != null}}"
params:
query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?"
args: ["{{input.status_id}}", "{{auth.user_id}}"]
on_error: abort
error_message: "Statut invalide"
error_status: 422
- id: update_task
service: db
action: update
params:
table: tasks
where:
id: "{{input.id}}"
data:
project_id: "{{input.project_id ?? steps.get_task.result.project_id}}"
status_id: "{{input.status_id ?? steps.get_task.result.status_id}}"
title: "{{input.title ?? steps.get_task.result.title}}"
description: "{{input.description ?? steps.get_task.result.description}}"
priority: "{{input.priority ?? steps.get_task.result.priority}}"
date_start: "{{input.date_start ?? steps.get_task.result.date_start}}"
date_end: "{{input.date_end ?? steps.get_task.result.date_end}}"
time_estimated: "{{input.time_estimated ?? steps.get_task.result.time_estimated}}"
time_spent: "{{input.time_spent ?? steps.get_task.result.time_spent}}"
billing: "{{input.billing ?? steps.get_task.result.billing}}"
position: "{{input.position ?? steps.get_task.result.position}}"
- id: clear_tags
service: db
action: delete
condition: "{{input.tags != null}}"
params:
table: task_tags
where:
task_id: "{{input.id}}"
- id: sync_tags
service: db
action: exec
condition: "{{input.tags != null && input.tags | length > 0}}"
foreach: "{{input.tags}}"
foreach_as: tag_id
params:
query: |
INSERT INTO task_tags (task_id, tag_id)
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
args: ["{{input.id}}", "{{tag_id}}", "{{auth.user_id}}"]
- id: get_updated
service: db
action: query_one
params:
query: |
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN statuses s ON t.status_id = s.id
WHERE t.id = ?
args: ["{{input.id}}"]
- id: get_tags
service: db
action: query
params:
query: |
SELECT t.id, t.name, t.color
FROM tags t
JOIN task_tags tt ON t.id = tt.tag_id
WHERE tt.task_id = ?
args: ["{{input.id}}"]
output:
status: 200
body:
success: true
message: "Tâche mise à jour"
data:
id: "{{steps.get_updated.result.id}}"
title: "{{steps.get_updated.result.title}}"
status_name: "{{steps.get_updated.result.status_name}}"
tags: "{{steps.get_tags.result}}"

29
config/sogoctl.yaml Normal file
View File

@@ -0,0 +1,29 @@
# Configuration du superviseur sogoctl
supervisor:
health_interval: 10s
restart_delay: 2s
max_restarts: 5
services:
sogoms-db:
binary: /opt/sogoms/bin/sogoms-db
args:
- "-config"
- "/config"
- "-socket"
- "/run/sogoms-db.1.sock"
health_socket: /run/sogoms-db.1.sock
sogoway:
binary: /opt/sogoms/bin/sogoway
args:
- "-config"
- "/config"
- "-port"
- "8080"
- "-db-socket"
- "/run/sogoms-db.1.sock"
health_url: http://localhost:8080/health
depends_on:
- sogoms-db

154
deploy.sh Executable file
View File

@@ -0,0 +1,154 @@
#!/bin/bash
# Script de déploiement pour SOGOMS
# Version: 1.0 (15 décembre 2025)
# Auteur: Pierre (avec l'aide de Claude)
set -euo pipefail
# Configuration SSH
JUMP_USER="root"
JUMP_HOST="195.154.80.116"
JUMP_PORT="22"
JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi"
# Configuration Incus
INCUS_PROJECT="default"
INCUS_CONTAINER="gw3"
CONTAINER_IP="13.23.33.5"
# Chemins sur le container
REMOTE_BIN="/opt/sogoms/bin"
REMOTE_CONFIG="/config"
REMOTE_SECRETS="/secrets"
# Couleurs pour les messages
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Fonctions d'affichage
echo_step() {
echo -e "${GREEN}==>${NC} $1"
}
echo_info() {
echo -e "${BLUE}Info:${NC} $1"
}
echo_warning() {
echo -e "${YELLOW}Warning:${NC} $1"
}
echo_error() {
echo -e "${RED}Error:${NC} $1"
exit 1
}
# Répertoire du script
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# Vérification de l'environnement
echo_step "Verifying environment..."
echo_info "Deploying SOGOMS to container $INCUS_CONTAINER ($CONTAINER_IP)"
echo_info "Jump host: $JUMP_HOST"
if [ ! -d "cmd/sogoms/db" ] || [ ! -d "cmd/sogoway" ] || [ ! -d "cmd/sogoctl" ]; then
echo_error "Source directories missing - are you in the sogoms directory?"
fi
if [ ! -d "config/routes" ]; then
echo_error "config/routes missing"
fi
# Commande SSH vers IN3
SSH_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}"
SCP_CMD="scp -i ${JUMP_KEY} -P ${JUMP_PORT}"
# Lire la version
VERSION=$(cat VERSION | tr -d '\n')
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS="-X sogoms.com/internal/version.Version=${VERSION} -X sogoms.com/internal/version.BuildTime=${BUILD_TIME}"
# Étape 1: Build des binaires
echo_step "Building binaries v${VERSION} (linux/amd64)..."
mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl"
echo_info "Built: sogoms-db, sogoway, sogoctl (v${VERSION})"
# Étape 2: Créer les archives
echo_step "Creating archives..."
TIMESTAMP=$(date +%s)
BIN_ARCHIVE="sogoms-bin-${TIMESTAMP}.tar.gz"
CONFIG_ARCHIVE="sogoms-config-${TIMESTAMP}.tar.gz"
tar -czf "/tmp/${BIN_ARCHIVE}" -C bin . || echo_error "Failed to create bin archive"
tar -czf "/tmp/${CONFIG_ARCHIVE}" -C config . || echo_error "Failed to create config archive"
BIN_SIZE=$(du -h "/tmp/${BIN_ARCHIVE}" | cut -f1)
CONFIG_SIZE=$(du -h "/tmp/${CONFIG_ARCHIVE}" | cut -f1)
echo_info "Binaries archive: $BIN_SIZE"
echo_info "Config archive: $CONFIG_SIZE"
# Étape 3: Copier vers IN3
echo_step "Copying archives to jump server (IN3)..."
$SCP_CMD "/tmp/${BIN_ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" || echo_error "Failed to copy bin archive"
$SCP_CMD "/tmp/${CONFIG_ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" || echo_error "Failed to copy config archive"
# Étape 4: Déployer dans le container
echo_step "Deploying to Incus container ($INCUS_CONTAINER)..."
$SSH_CMD "
set -euo pipefail
echo '📦 Switching to Incus project...'
incus project switch ${INCUS_PROJECT} || exit 1
echo '📦 Pushing archives to container...'
incus file push /tmp/${BIN_ARCHIVE} ${INCUS_CONTAINER}/tmp/ || exit 1
incus file push /tmp/${CONFIG_ARCHIVE} ${INCUS_CONTAINER}/tmp/ || exit 1
echo '📁 Deploying binaries...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
echo '📁 Deploying config...'
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/
echo '📁 Setting up run directory...'
incus exec ${INCUS_CONTAINER} -- mkdir -p /run
echo '🧹 Cleanup...'
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE}
rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE}
"
# Nettoyage local
rm -f "/tmp/${BIN_ARCHIVE}" "/tmp/${CONFIG_ARCHIVE}"
# Résumé final
echo_step "Deployment completed successfully!"
echo ""
echo_info "SOGOMS v${VERSION} deployed"
echo_info " Host: IN3 ($JUMP_HOST)"
echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)"
echo_info " Binaries: $REMOTE_BIN"
echo_info " Config: $REMOTE_CONFIG"
echo_info " Deployment time: $(date)"
echo ""
echo_warning "Next steps on gw3:"
echo_info " 1. Edit /secrets/prokov_db_pass with real DB password"
echo_info " 2. Start services: /opt/sogoms/bin/sogoctl"
echo ""
echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'"
# Journaliser le déploiement
echo "$(date '+%Y-%m-%d %H:%M:%S') - SOGOMS v${VERSION} deployed to ${INCUS_CONTAINER} (${CONTAINER_IP})" >> ~/.sogoms_deploy_history

12
go.mod Executable file
View File

@@ -0,0 +1,12 @@
module sogoms.com
go 1.24.0
toolchain go1.24.11
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
golang.org/x/crypto v0.46.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

9
go.sum Normal file
View File

@@ -0,0 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

136
internal/auth/jwt.go Normal file
View File

@@ -0,0 +1,136 @@
// Package auth gère l'authentification JWT et les mots de passe.
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("token expired")
)
// Claims représente les claims du JWT.
type Claims struct {
Sub int64 `json:"sub"` // User ID
Email string `json:"email"` // Email
Name string `json:"name"` // Nom
App string `json:"app"` // Application ID
Exp int64 `json:"exp"` // Expiration (Unix timestamp)
Iat int64 `json:"iat"` // Issued at
}
// JWT gère la génération et validation des tokens.
type JWT struct {
secret []byte
expiration time.Duration
}
// NewJWT crée un nouveau gestionnaire JWT.
func NewJWT(secret string, expiration time.Duration) *JWT {
return &JWT{
secret: []byte(secret),
expiration: expiration,
}
}
// Generate génère un nouveau token JWT.
func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) {
now := time.Now()
claims := Claims{
Sub: userID,
Email: email,
Name: name,
App: appID,
Iat: now.Unix(),
Exp: now.Add(j.expiration).Unix(),
}
return j.encode(claims)
}
// Validate valide un token et retourne les claims.
func (j *JWT) Validate(token string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrInvalidToken
}
// Vérifier la signature
signatureInput := parts[0] + "." + parts[1]
expectedSig := j.sign(signatureInput)
if parts[2] != expectedSig {
return nil, ErrInvalidToken
}
// Décoder les claims
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrInvalidToken
}
var claims Claims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
return nil, ErrInvalidToken
}
// Vérifier l'expiration
if time.Now().Unix() > claims.Exp {
return nil, ErrExpiredToken
}
return &claims, nil
}
// encode encode les claims en JWT.
func (j *JWT) encode(claims Claims) (string, error) {
// Header
header := map[string]string{
"alg": "HS256",
"typ": "JWT",
}
headerJSON, _ := json.Marshal(header)
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// Payload
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Signature
signatureInput := headerB64 + "." + claimsB64
signature := j.sign(signatureInput)
return signatureInput + "." + signature, nil
}
// sign signe les données avec HMAC-SHA256.
func (j *JWT) sign(data string) string {
h := hmac.New(sha256.New, j.secret)
h.Write([]byte(data))
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
}
// ExtractToken extrait le token du header Authorization.
func ExtractToken(authHeader string) (string, error) {
if authHeader == "" {
return "", fmt.Errorf("missing authorization header")
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return "", fmt.Errorf("invalid authorization header format")
}
return parts[1], nil
}

20
internal/auth/password.go Normal file
View File

@@ -0,0 +1,20 @@
package auth
import (
"golang.org/x/crypto/bcrypt"
)
// HashPassword génère un hash bcrypt du mot de passe.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword vérifie si le mot de passe correspond au hash.
func VerifyPassword(hash, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

182
internal/config/config.go Normal file
View File

@@ -0,0 +1,182 @@
// Package config gère le chargement des configurations YAML.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// AppConfig représente la configuration d'une application cliente.
type AppConfig struct {
App string `yaml:"app"`
Version string `yaml:"version"`
BasePath string `yaml:"base_path"`
Hosts []string `yaml:"hosts"`
Database Database `yaml:"database"`
Auth Auth `yaml:"auth"`
Routes []Route `yaml:"routes"`
}
// Auth contient la configuration d'authentification.
type Auth struct {
JWTSecretFile string `yaml:"jwt_secret_file"`
JWTExpiry string `yaml:"jwt_expiry"`
jwtSecret string // Chargé depuis le fichier
}
// JWTSecret retourne le secret JWT (chargé depuis le fichier).
func (a *Auth) JWTSecret() string {
return a.jwtSecret
}
// Database contient la configuration de connexion à la base de données.
type Database struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
User string `yaml:"user"`
PasswordFile string `yaml:"password_file"`
Name string `yaml:"name"`
password string // Chargé depuis le fichier
}
// Password retourne le mot de passe (chargé depuis le fichier).
func (d *Database) Password() string {
return d.password
}
// DSN retourne la chaîne de connexion MySQL/MariaDB.
func (d *Database) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4",
d.User, d.password, d.Host, d.Port, d.Name)
}
// Route représente une route API.
type Route struct {
Path string `yaml:"path"`
Method string `yaml:"method"`
Scenario string `yaml:"scenario"`
Auth *bool `yaml:"auth,omitempty"`
}
// Registry stocke les configurations des applications.
type Registry struct {
configDir string
apps map[string]*AppConfig // Par app_id
byHost map[string]*AppConfig // Par hostname
mu sync.RWMutex
}
// NewRegistry crée un nouveau registre de configurations.
func NewRegistry(configDir string) *Registry {
return &Registry{
configDir: configDir,
apps: make(map[string]*AppConfig),
byHost: make(map[string]*AppConfig),
}
}
// Load charge toutes les configurations depuis le répertoire routes.
func (r *Registry) Load() error {
r.mu.Lock()
defer r.mu.Unlock()
routesDir := filepath.Join(r.configDir, "routes")
entries, err := os.ReadDir(routesDir)
if err != nil {
return fmt.Errorf("read routes dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
continue
}
path := filepath.Join(routesDir, entry.Name())
cfg, err := r.loadAppConfig(path)
if err != nil {
return fmt.Errorf("load %s: %w", entry.Name(), err)
}
r.apps[cfg.App] = cfg
for _, host := range cfg.Hosts {
r.byHost[host] = cfg
}
}
return nil
}
// loadAppConfig charge une configuration d'application depuis un fichier YAML.
func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg AppConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Charger le mot de passe DB depuis le fichier
if cfg.Database.PasswordFile != "" {
passData, err := os.ReadFile(cfg.Database.PasswordFile)
if err != nil {
return nil, fmt.Errorf("read db password file: %w", err)
}
cfg.Database.password = strings.TrimSpace(string(passData))
}
// Charger le secret JWT depuis le fichier
if cfg.Auth.JWTSecretFile != "" {
secretData, err := os.ReadFile(cfg.Auth.JWTSecretFile)
if err != nil {
return nil, fmt.Errorf("read jwt secret file: %w", err)
}
cfg.Auth.jwtSecret = strings.TrimSpace(string(secretData))
}
// Port DB par défaut
if cfg.Database.Port == 0 {
cfg.Database.Port = 3306
}
// Expiry JWT par défaut
if cfg.Auth.JWTExpiry == "" {
cfg.Auth.JWTExpiry = "24h"
}
return &cfg, nil
}
// GetByApp retourne la configuration d'une application par son ID.
func (r *Registry) GetByApp(appID string) (*AppConfig, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
cfg, ok := r.apps[appID]
return cfg, ok
}
// GetByHost retourne la configuration d'une application par son hostname.
func (r *Registry) GetByHost(host string) (*AppConfig, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
cfg, ok := r.byHost[host]
return cfg, ok
}
// Apps retourne la liste des IDs d'applications chargées.
func (r *Registry) Apps() []string {
r.mu.RLock()
defer r.mu.RUnlock()
apps := make([]string, 0, len(r.apps))
for app := range r.apps {
apps = append(apps, app)
}
return apps
}

169
internal/protocol/client.go Normal file
View File

@@ -0,0 +1,169 @@
package protocol
import (
"context"
"fmt"
"net"
"sync"
"time"
)
// Client permet d'appeler un microservice via Unix socket.
type Client struct {
socketPath string
conn net.Conn
mu sync.Mutex
}
// NewClient crée un nouveau client.
func NewClient(socketPath string) *Client {
return &Client{
socketPath: socketPath,
}
}
// Connect établit la connexion au socket.
func (c *Client) Connect() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil // Déjà connecté
}
conn, err := net.Dial("unix", c.socketPath)
if err != nil {
return fmt.Errorf("dial %s: %w", c.socketPath, err)
}
c.conn = conn
return nil
}
// Close ferme la connexion.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
err := c.conn.Close()
c.conn = nil
return err
}
return nil
}
// Call envoie une requête et attend la réponse.
func (c *Client) Call(ctx context.Context, req *Request) (*Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return nil, fmt.Errorf("not connected")
}
// Timeout
timeout := time.Duration(req.TimeoutMs) * time.Millisecond
if timeout == 0 {
timeout = 5 * time.Second
}
deadline := time.Now().Add(timeout)
c.conn.SetDeadline(deadline)
// Encoder et envoyer la requête
data, err := Encode(req)
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
if err := writeMessage(c.conn, data); err != nil {
c.conn = nil // Connexion cassée
return nil, fmt.Errorf("write request: %w", err)
}
// Lire la réponse
respData, err := readMessage(c.conn)
if err != nil {
c.conn = nil // Connexion cassée
return nil, fmt.Errorf("read response: %w", err)
}
resp, err := DecodeResponse(respData)
if err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return resp, nil
}
// CallAction raccourci pour appeler une action.
func (c *Client) CallAction(ctx context.Context, action string, params map[string]any) (*Response, error) {
req := NewRequest(action, params)
return c.Call(ctx, req)
}
// Pool gère un pool de connexions vers un service.
type Pool struct {
socketPath string
clients chan *Client
maxSize int
}
// NewPool crée un pool de connexions.
func NewPool(socketPath string, size int) *Pool {
return &Pool{
socketPath: socketPath,
clients: make(chan *Client, size),
maxSize: size,
}
}
// Get obtient un client du pool.
func (p *Pool) Get() (*Client, error) {
select {
case client := <-p.clients:
return client, nil
default:
// Créer un nouveau client
client := NewClient(p.socketPath)
if err := client.Connect(); err != nil {
return nil, err
}
return client, nil
}
}
// Put remet un client dans le pool.
func (p *Pool) Put(client *Client) {
select {
case p.clients <- client:
// OK, remis dans le pool
default:
// Pool plein, fermer le client
client.Close()
}
}
// Call obtient un client, exécute l'appel, et remet le client.
func (p *Pool) Call(ctx context.Context, req *Request) (*Response, error) {
client, err := p.Get()
if err != nil {
return nil, err
}
resp, err := client.Call(ctx, req)
if err != nil {
client.Close()
return nil, err
}
p.Put(client)
return resp, nil
}
// Close ferme tous les clients du pool.
func (p *Pool) Close() {
close(p.clients)
for client := range p.clients {
client.Close()
}
}

View File

@@ -0,0 +1,90 @@
// Package protocol définit le protocole de communication IPC via Unix sockets.
// Format: 4 bytes (big-endian length) + JSON payload
package protocol
import (
"encoding/json"
"time"
)
// Request représente une requête envoyée à un microservice.
type Request struct {
ID string `json:"id"`
Action string `json:"action"`
TenantID string `json:"tenant_id,omitempty"`
Params map[string]any `json:"params,omitempty"`
TimeoutMs int `json:"timeout_ms,omitempty"`
}
// Response représente la réponse d'un microservice.
type Response struct {
ID string `json:"id"`
Status string `json:"status"` // "success" ou "error"
Result any `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
// Error détaille une erreur.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewRequest crée une nouvelle requête avec un ID unique.
func NewRequest(action string, params map[string]any) *Request {
return &Request{
ID: generateID(),
Action: action,
Params: params,
TimeoutMs: 5000, // 5s par défaut
}
}
// Success crée une réponse de succès.
func Success(reqID string, result any) *Response {
return &Response{
ID: reqID,
Status: "success",
Result: result,
}
}
// Failure crée une réponse d'erreur.
func Failure(reqID string, code, message string) *Response {
return &Response{
ID: reqID,
Status: "error",
Error: &Error{
Code: code,
Message: message,
},
}
}
// Encode sérialise un message en JSON.
func Encode(v any) ([]byte, error) {
return json.Marshal(v)
}
// DecodeRequest désérialise une requête JSON.
func DecodeRequest(data []byte) (*Request, error) {
var req Request
if err := json.Unmarshal(data, &req); err != nil {
return nil, err
}
return &req, nil
}
// DecodeResponse désérialise une réponse JSON.
func DecodeResponse(data []byte) (*Response, error) {
var resp Response
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// generateID génère un ID unique basé sur le timestamp.
func generateID() string {
return "req_" + time.Now().Format("20060102150405.000000")
}

174
internal/protocol/server.go Normal file
View File

@@ -0,0 +1,174 @@
package protocol
import (
"context"
"encoding/binary"
"fmt"
"io"
"log"
"net"
"os"
"sync"
)
// Handler traite une requête et retourne une réponse.
type Handler func(ctx context.Context, req *Request) *Response
// Server écoute sur un Unix socket et dispatch les requêtes.
type Server struct {
socketPath string
handler Handler
listener net.Listener
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
}
// NewServer crée un nouveau serveur.
func NewServer(socketPath string, handler Handler) *Server {
ctx, cancel := context.WithCancel(context.Background())
return &Server{
socketPath: socketPath,
handler: handler,
ctx: ctx,
cancel: cancel,
}
}
// Start démarre le serveur.
func (s *Server) Start() error {
// Supprimer le socket existant
if err := os.RemoveAll(s.socketPath); err != nil {
return fmt.Errorf("remove socket: %w", err)
}
listener, err := net.Listen("unix", s.socketPath)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
s.listener = listener
// Permissions socket
if err := os.Chmod(s.socketPath, 0660); err != nil {
return fmt.Errorf("chmod socket: %w", err)
}
log.Printf("[server] listening on %s", s.socketPath)
go s.acceptLoop()
return nil
}
// Stop arrête le serveur proprement.
func (s *Server) Stop() {
s.cancel()
if s.listener != nil {
s.listener.Close()
}
s.wg.Wait()
os.RemoveAll(s.socketPath)
log.Printf("[server] stopped")
}
// acceptLoop accepte les connexions entrantes.
func (s *Server) acceptLoop() {
for {
conn, err := s.listener.Accept()
if err != nil {
select {
case <-s.ctx.Done():
return
default:
log.Printf("[server] accept error: %v", err)
continue
}
}
s.wg.Add(1)
go s.handleConn(conn)
}
}
// handleConn gère une connexion client.
func (s *Server) handleConn(conn net.Conn) {
defer s.wg.Done()
defer conn.Close()
for {
select {
case <-s.ctx.Done():
return
default:
}
// Lire la requête
data, err := readMessage(conn)
if err != nil {
if err != io.EOF {
log.Printf("[server] read error: %v", err)
}
return
}
// Décoder la requête
req, err := DecodeRequest(data)
if err != nil {
resp := Failure("", "DECODE_ERROR", err.Error())
writeResponse(conn, resp)
continue
}
// Traiter la requête
resp := s.handler(s.ctx, req)
// Envoyer la réponse
if err := writeResponse(conn, resp); err != nil {
log.Printf("[server] write error: %v", err)
return
}
}
}
// readMessage lit un message length-prefixed.
func readMessage(r io.Reader) ([]byte, error) {
// Lire les 4 bytes de longueur
lengthBuf := make([]byte, 4)
if _, err := io.ReadFull(r, lengthBuf); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(lengthBuf)
if length == 0 || length > 10*1024*1024 { // Max 10MB
return nil, fmt.Errorf("invalid message length: %d", length)
}
// Lire le payload
data := make([]byte, length)
if _, err := io.ReadFull(r, data); err != nil {
return nil, err
}
return data, nil
}
// writeResponse écrit une réponse.
func writeResponse(w io.Writer, resp *Response) error {
data, err := Encode(resp)
if err != nil {
return err
}
return writeMessage(w, data)
}
// writeMessage écrit un message length-prefixed.
func writeMessage(w io.Writer, data []byte) error {
// Écrire la longueur
lengthBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lengthBuf, uint32(len(data)))
if _, err := w.Write(lengthBuf); err != nil {
return err
}
// Écrire le payload
_, err := w.Write(data)
return err
}

View File

@@ -0,0 +1,8 @@
// Package version contient les informations de version.
package version
// Set via ldflags: -ldflags "-X sogoms.com/internal/version.Version=1.0.0"
var (
Version = "dev"
BuildTime = "unknown"
)

0
prompts.txt Normal file
View File

1213
sogoms-vigil.md Executable file

File diff suppressed because it is too large Load Diff

1195
sogoms.md Executable file

File diff suppressed because it is too large Load Diff