Initial commit — Donna portal v1

Setup completo del portal interno donna.aal-law.com:

* Landing público con OAuth login + grid de apps autenticado
* Flujo OAuth2 contra Mailcow (mail.aal-law.com)
* Sesiones SSO compartidas en el dominio (cookie donna_session, 8h)
* Whitelist de dominios aal-law.com / aalnlaw.com
* App "Control de Escrituras" migrada desde ZH-Consultores/control-escrituras
  (mismo schema, mismo frontend, ahora con shared Auth/Db/Config libs)
* Migraciones SQL para users, escrituras_tramites, escrituras_historial
* Caddy on :80 con rewrite extless → .php
* Backup diario MariaDB (systemd timer + script) con retención 14d
* Documentación de arquitectura en README + infra files en docs/infra
This commit is contained in:
Soporte ZH Consultores
2026-05-11 19:10:36 +00:00
commit 4ee32aec6d
21 changed files with 2327 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
final class Apps {
public static function catalog(): array {
return [
[
'slug' => 'escrituras',
'title' => 'Control de Escrituras',
'description' => 'Seguimiento de trámites notariales: estado, abogado, notaría, historial y auditoría.',
'icon' => 'document',
'href' => '/escrituras/',
'status' => 'live',
],
[
'slug' => 'casos',
'title' => 'Gestión de Casos',
'description' => 'Expedientes, plazos procesales y notas de cada asunto.',
'icon' => 'briefcase',
'href' => '#',
'status' => 'coming-soon',
],
[
'slug' => 'agenda',
'title' => 'Agenda',
'description' => 'Calendario compartido de audiencias, reuniones y vencimientos.',
'icon' => 'calendar',
'href' => '#',
'status' => 'coming-soon',
],
[
'slug' => 'clientes',
'title' => 'Directorio de Clientes',
'description' => 'Datos de contacto, sociedades vinculadas y matriz de KYC.',
'icon' => 'users',
'href' => '#',
'status' => 'coming-soon',
],
];
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/Db.php';
final class Auth {
public static function startSession(): void {
if (session_status() === PHP_SESSION_ACTIVE) return;
$secure = ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'
|| (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
session_name('donna_session');
session_set_cookie_params([
'lifetime' => Config::int('SESSION_LIFETIME', 28800),
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
public static function isAuthenticated(): bool {
self::startSession();
return !empty($_SESSION['authenticated'])
&& !empty($_SESSION['auth_time'])
&& (time() - (int)$_SESSION['auth_time']) < Config::int('SESSION_LIFETIME', 28800);
}
public static function user(): array {
self::startSession();
return [
'email' => $_SESSION['user_email'] ?? '',
'name' => $_SESSION['user_name'] ?? '',
];
}
public static function buildLoginUrl(?string $returnTo = null): string {
self::startSession();
$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;
$_SESSION['oauth_return_to'] = $returnTo ?: ($_SERVER['REQUEST_URI'] ?? '/');
$params = http_build_query([
'response_type' => 'code',
'client_id' => Config::get('OAUTH_CLIENT_ID'),
'redirect_uri' => Config::get('OAUTH_REDIRECT_URI'),
'scope' => Config::get('OAUTH_SCOPE', 'profile'),
'state' => $state,
]);
return Config::get('OAUTH_AUTH_URL') . '?' . $params;
}
public static function requireAuth(): void {
if (self::isAuthenticated()) return;
$loginUrl = self::buildLoginUrl();
$accepts = $_SERVER['HTTP_ACCEPT'] ?? '';
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
|| str_contains($accepts, 'application/json');
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(401);
echo json_encode(['ok' => false, 'auth_required' => true, 'login_url' => $loginUrl]);
exit;
}
header('Location: ' . $loginUrl);
exit;
}
public static function emailDomainAllowed(string $email): bool {
$allowed = Config::list('ALLOWED_EMAIL_DOMAINS');
if (!$allowed) return true;
$parts = explode('@', strtolower(trim($email)));
if (count($parts) !== 2) return false;
return in_array($parts[1], array_map('strtolower', $allowed), true);
}
public static function completeLogin(string $email, string $name): void {
self::startSession();
session_regenerate_id(true);
$_SESSION['authenticated'] = true;
$_SESSION['user_email'] = $email;
$_SESSION['user_name'] = $name ?: $email;
$_SESSION['auth_time'] = time();
try {
$pdo = Db::pdo();
$stmt = $pdo->prepare(
'INSERT INTO users (email, name, first_seen_at, last_login_at)
VALUES (:email, :name, NOW(), NOW())
ON DUPLICATE KEY UPDATE name = VALUES(name), last_login_at = NOW()'
);
$stmt->execute([':email' => $email, ':name' => $name]);
} catch (Throwable $e) {
error_log('users upsert failed: ' . $e->getMessage());
}
}
public static function logout(): void {
self::startSession();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'] ?? '', $p['secure'], $p['httponly']);
}
session_destroy();
}
public static function popReturnTo(): string {
self::startSession();
$to = $_SESSION['oauth_return_to'] ?? '/';
unset($_SESSION['oauth_return_to']);
return $to;
}
public static function checkState(string $state): bool {
self::startSession();
$expected = $_SESSION['oauth_state'] ?? '';
unset($_SESSION['oauth_state']);
return $state !== '' && hash_equals((string)$expected, $state);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
final class Config {
private static ?array $env = null;
public static function load(string $path = '/etc/donna/donna.env'): void {
if (self::$env !== null) return;
if (!is_readable($path)) {
http_response_code(500);
exit('Config no disponible.');
}
$vars = parse_ini_file($path, false, INI_SCANNER_RAW);
if ($vars === false) {
http_response_code(500);
exit('Config inválida.');
}
self::$env = $vars;
}
public static function get(string $key, ?string $default = null): string {
self::load();
return self::$env[$key] ?? $default ?? '';
}
public static function int(string $key, int $default = 0): int {
return (int) self::get($key, (string) $default);
}
public static function list(string $key): array {
$v = self::get($key);
return $v === '' ? [] : array_map('trim', explode(',', $v));
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Config.php';
final class Db {
private static ?PDO $pdo = null;
public static function pdo(): PDO {
if (self::$pdo !== null) return self::$pdo;
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
Config::get('DB_HOST', '127.0.0.1'),
Config::get('DB_PORT', '3306'),
Config::get('DB_NAME')
);
self::$pdo = new PDO($dsn, Config::get('DB_USER'), Config::get('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return self::$pdo;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/Config.php';
final class Oauth {
public static function exchangeCodeForToken(string $code): array {
return self::httpPost(Config::get('OAUTH_TOKEN_URL'), [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => Config::get('OAUTH_REDIRECT_URI'),
'client_id' => Config::get('OAUTH_CLIENT_ID'),
'client_secret' => Config::get('OAUTH_CLIENT_SECRET'),
]);
}
public static function fetchProfile(string $accessToken): array {
return self::httpGetBearer(Config::get('OAUTH_PROFILE_URL'), $accessToken);
}
private static function httpPost(string $url, array $form): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($form),
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err || $body === false) {
error_log('oauth POST failed: ' . $err);
return [];
}
return json_decode((string)$body, true) ?? [];
}
private static function httpGetBearer(string $url, string $token): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Accept: application/json',
],
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$body = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err || $body === false) {
error_log('oauth GET failed: ' . $err);
return [];
}
return json_decode((string)$body, true) ?? [];
}
}