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
+33
View File
@@ -0,0 +1,33 @@
# Secretos — NUNCA hacer commit
*.env
*.env.*
!*.env.example
config.local.php
*.local.php
# Backups
*.bak
*.orig
*~
.DS_Store
Thumbs.db
# Logs (los logs viven en /var/log/donna/, no en el repo)
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# PHP
vendor/
composer.lock
# Node (por si en el futuro hay tooling JS)
node_modules/
# Datos efímeros
/var/
/tmp/
+206
View File
@@ -0,0 +1,206 @@
# Donna — Portal interno AALN Law
Portal interno de la firma **Arias, Abrego, López & Noriega** (aalnlaw.com) que aloja herramientas internas para el equipo. Primer app embarcada: **Control de Escrituras Públicas**.
> Producción: <https://donna.aal-law.com>
---
## Arquitectura
```
Navegador
│ HTTPS
▼
Cloudflare → Caddy infra (SSL termination)
│ HTTP :80
▼
┌──────────────────────┐
│ Caddy (este server) │ /opt/donna/public
│ rewrite *.php │
└──────┬───────────────┘
│ unix socket
▼
┌──────────────────────┐
│ PHP-FPM 8.4 │
└──────┬───────────────┘
▼
┌──────────────────────┐
│ MariaDB 11.8 │ DB: donna
└──────────────────────┘
```
**Stack:**
- PHP 8.4-fpm (`/run/php/php8.4-fpm.sock`)
- Caddy 2.6 en `:80`
- MariaDB 11.8 (`127.0.0.1:3306`)
- Autenticación OAuth2 contra Mailcow (`mail.aal-law.com`)
- Backups diarios automáticos vía systemd timer
---
## Estructura del repo
```
donna/
├── public/ ← DocumentRoot
│ ├── index.php ← Landing + grid de apps
│ ├── auth/
│ │ ├── login.php ← Inicia OAuth flow
│ │ ├── callback.php ← Recibe code de Mailcow
│ │ └── logout.php
│ └── escrituras/
│ ├── index.php ← Auth-guard + sirve view
│ └── api.php ← CRUD trámites + historial
├── src/ ← Librerías PHP compartidas (fuera de DocumentRoot)
│ ├── Config.php ← Lee /etc/donna/donna.env
│ ├── Db.php ← PDO singleton
│ ├── Auth.php ← Sesión + OAuth guard + whitelist
│ ├── Oauth.php ← Cliente HTTP a Mailcow
│ └── Apps.php ← Catálogo de apps del menú
├── views/ ← Templates HTML servidos por PHP tras auth
│ └── escrituras.html
├── sql/ ← Migraciones (aplicar manualmente)
│ ├── 001_core.sql ← users
│ └── 002_escrituras.sql ← escrituras_tramites + escrituras_historial
├── bin/
│ └── donna-backup.sh ← mysqldump diario, retención 14 días
├── docs/ ← Diseños y archivos de infra de referencia
├── donna.env.example ← Plantilla del archivo de secrets
└── README.md
```
**Fuera del repo (en el servidor):**
- `/etc/donna/donna.env` — secrets reales (`640 root:www-data`)
- `/etc/caddy/Caddyfile` — config del Caddy local
- `/etc/systemd/system/donna-backup.{service,timer}` — backups
- `/var/log/donna/` — access logs
- `/var/backups/donna/` — dumps SQL
---
## Setup en un servidor nuevo
```bash
# 1. Paquetes del SO (Debian 13)
apt install -y caddy php8.4-fpm php8.4-mysql php8.4-curl \
php8.4-mbstring php8.4-intl php8.4-zip mariadb-server
# 2. Base de datos
mariadb -e "CREATE DATABASE donna CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mariadb -e "CREATE USER 'donna_app'@'localhost' IDENTIFIED BY 'XXX';
GRANT ALL ON donna.* TO 'donna_app'@'localhost';"
# 3. Código
git clone https://git.zhconsultores.com/ZH-Consultores/donna.git /opt/donna
chown -R www-data:www-data /opt/donna
# 4. Migraciones
mariadb donna < /opt/donna/sql/001_core.sql
mariadb donna < /opt/donna/sql/002_escrituras.sql
# 5. Secrets
mkdir -p /etc/donna
cp /opt/donna/donna.env.example /etc/donna/donna.env
# Editar /etc/donna/donna.env con valores reales (OAuth, DB_PASS, APP_SECRET)
chown root:www-data /etc/donna/donna.env
chmod 640 /etc/donna/donna.env
# 6. Caddy y backups: ver docs/infra/ para Caddyfile y systemd units
systemctl reload caddy
systemctl enable --now donna-backup.timer
```
---
## OAuth2 con Mailcow
1. En `https://mail.aal-law.com/admin` → **Access → OAuth2 Apps**
2. Crear app con `Redirect URI = https://donna.aal-law.com/auth/callback`
3. Copiar `Client ID` y `Client Secret` a `/etc/donna/donna.env`
El flujo:
```
/ ──login──▶ /auth/login.php ──redirect──▶ mail.aal-law.com/oauth/authorize
│
▼ ?code=...&state=...
/auth/callback.php ◀── redirect ◀── usuario autorizado
│
├── valida state (anti-CSRF)
├── intercambia code → token
├── lee profile (email, nombre)
├── valida dominio @aal-law.com o @aalnlaw.com
├── inserta/actualiza fila en `users`
└── redirige a /
```
Sesión: cookie `donna_session`, `path=/`, `Secure`, `HttpOnly`, `SameSite=Lax`, lifetime 8h. SSO automático entre todas las apps del portal.
---
## Apps
Las apps se registran en `src/Apps.php::catalog()`. El landing las renderiza automáticamente como cards.
| Slug | Estado | Ruta | Tablas DB |
|---|---|---|---|
| `escrituras` | Live | `/escrituras/` | `escrituras_tramites`, `escrituras_historial` |
| `casos` | Coming soon | — | — |
| `agenda` | Coming soon | — | — |
| `clientes` | Coming soon | — | — |
**Para agregar una app nueva:**
1. Crear `/opt/donna/public/<slug>/index.php` con `Auth::requireAuth()` arriba.
2. Crear `/opt/donna/public/<slug>/api.php` si necesita backend.
3. Prefijar todas las tablas DB con `<slug>_`.
4. Crear migración en `sql/00N_<slug>.sql`.
5. Registrar en `src/Apps.php::catalog()`.
6. Commit + push.
---
## Identidad visual
| Token | Valor |
|---|---|
| `--navy` | `#1B3A6B` |
| `--navy-d` | `#112446` |
| `--navy-l` | `#2D5090` |
| `--gold` | `#C9A84C` |
| `--gold-l` | `#E8C96A` |
| `--gold-d` | `#9E7C2E` |
| `--off` | `#F7F6F3` |
| Fuente headlines | DM Serif Display |
| Fuente UI | DM Sans |
Gradient hero del landing: `#0E1F3F → #1B3A6B → #2D5090`.
---
## Backups
`donna-backup.timer` ejecuta diariamente a las 03:00 (con jitter de ±5min) y vuelca `donna` comprimido a `/var/backups/donna/donna-YYYY-MM-DD_HHMMSS.sql.gz`. Retención 14 días.
Restaurar:
```bash
gunzip -c /var/backups/donna/donna-2026-MM-DD_HHMMSS.sql.gz | mariadb donna
```
---
## Operación
| Acción | Comando |
|---|---|
| Reload Caddy | `systemctl reload caddy` |
| Reload PHP-FPM | `systemctl reload php8.4-fpm` |
| Ver logs Caddy | `tail -f /var/log/donna/access.log` |
| Ver errores PHP | `journalctl -u php8.4-fpm -f` |
| Backup manual | `/opt/donna/bin/donna-backup.sh` |
| Próximo backup | `systemctl list-timers donna-backup.timer` |
---
## Licencia
Software interno — sin licencia pública. Propiedad de **Arias, Abrego, López & Noriega**.
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Donna — daily MariaDB backup with 14-day retention
set -euo pipefail
BACKUP_DIR="/var/backups/donna"
ENV_FILE="/etc/donna/donna.env"
RETENTION_DAYS=14
# shellcheck disable=SC1090
. <(grep -E '^(DB_NAME|DB_USER|DB_PASS|DB_HOST|DB_PORT)=' "$ENV_FILE")
mkdir -p "$BACKUP_DIR"
chmod 750 "$BACKUP_DIR"
STAMP=$(date +%F_%H%M%S)
OUT="$BACKUP_DIR/donna-$STAMP.sql.gz"
MYSQL_PWD="$DB_PASS" mariadb-dump \
--host="${DB_HOST:-127.0.0.1}" \
--port="${DB_PORT:-3306}" \
--user="$DB_USER" \
--single-transaction \
--quick \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$DB_NAME" \
| gzip -9 > "$OUT"
chmod 640 "$OUT"
find "$BACKUP_DIR" -type f -name 'donna-*.sql.gz' -mtime "+$RETENTION_DAYS" -delete
echo "[donna-backup] $(date -Is) wrote $OUT ($(du -h "$OUT" | cut -f1))"
+38
View File
@@ -0,0 +1,38 @@
# Donna Portal — HTTP only on :80
# El Caddy upstream hace terminación SSL y nos pasa X-Forwarded-Proto: https.
:80 {
root * /opt/donna/public
encode gzip zstd
# Bloquear acceso directo a vistas privadas, código fuente, secretos, SQL, etc.
@hidden {
path /src/* /sql/* /bin/* /views/* /.env* /.git*
}
respond @hidden 404
# Rewrite /foo → /foo.php cuando el .php existe (para URLs sin extensión: /auth/callback)
@extlessphp {
not path *.php
not path */
file {path}.php
}
rewrite @extlessphp {path}.php
php_fastcgi unix//run/php/php8.4-fpm.sock {
env HTTPS on
}
file_server {
index index.php
}
log {
output file /var/log/donna/access.log {
roll_size 50mb
roll_keep 5
}
format json
}
}
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Donna MariaDB daily backup
After=mariadb.service
Wants=mariadb.service
[Service]
Type=oneshot
User=root
ExecStart=/opt/donna/bin/donna-backup.sh
StandardOutput=journal
StandardError=journal
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run Donna backup daily at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
Unit=donna-backup.service
[Install]
WantedBy=timers.target
+31
View File
@@ -0,0 +1,31 @@
# Plantilla de /etc/donna/donna.env
# Copia este archivo a /etc/donna/donna.env y rellena con valores reales:
# sudo cp donna.env.example /etc/donna/donna.env
# sudo chown root:www-data /etc/donna/donna.env
# sudo chmod 640 /etc/donna/donna.env
# Nunca commitear el .env real.
# --- OAuth2 Mailcow ---
OAUTH_CLIENT_ID=pega_aqui
OAUTH_CLIENT_SECRET=pega_aqui
OAUTH_REDIRECT_URI=https://donna.aal-law.com/auth/callback
OAUTH_AUTH_URL=https://mail.aal-law.com/oauth/authorize
OAUTH_TOKEN_URL=https://mail.aal-law.com/oauth/token
OAUTH_PROFILE_URL=https://mail.aal-law.com/oauth/profile
OAUTH_SCOPE=profile
# --- App ---
APP_SECRET=genera_con_openssl_rand_hex_32
APP_BASE_URL=https://donna.aal-law.com
SESSION_LIFETIME=28800
# --- Base de datos (MariaDB local) ---
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=donna
DB_USER=donna_app
DB_PASS=password_local_seguro
# --- Autorización ---
# Dominios de correo permitidos para entrar (CSV)
ALLOWED_EMAIL_DOMAINS=aal-law.com,aalnlaw.com
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Oauth.php';
function deny(string $msg, int $code = 401): void {
http_response_code($code);
header('Content-Type: text/html; charset=utf-8');
$safe = htmlspecialchars($msg, ENT_QUOTES, 'UTF-8');
echo <<<HTML
<!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"><title>Acceso denegado — Donna</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body{font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#F7F6F3;}
.box{background:#fff;border:1px solid #eee;border-radius:14px;padding:44px 52px;text-align:center;max-width:440px;box-shadow:0 8px 32px rgba(0,0,0,0.06);}
.logo{font-family:'DM Serif Display',serif;font-size:24px;color:#1B3A6B;margin-bottom:6px;}
.tag{font-size:11px;color:#C9A84C;letter-spacing:.18em;text-transform:uppercase;margin-bottom:24px;font-weight:600;}
h2{color:#1B3A6B;margin:0 0 12px;font-family:'DM Serif Display',serif;font-weight:400;}
p{color:#666;font-size:14px;margin:0 0 24px;line-height:1.55;}
a{display:inline-block;padding:10px 24px;background:#1B3A6B;color:#fff;border-radius:8px;text-decoration:none;font-size:13px;font-weight:500;}
a:hover{background:#112446;}
</style></head><body>
<div class="box">
<div class="logo">Donna</div>
<div class="tag">AALN · Portal interno</div>
<h2>Acceso denegado</h2>
<p>{$safe}</p>
<a href="/">Volver al inicio</a>
</div></body></html>
HTML;
exit;
}
$state = $_GET['state'] ?? '';
if (!is_string($state) || !Auth::checkState($state)) {
deny('Token de seguridad inválido o expirado. Intenta iniciar sesión de nuevo.');
}
$code = $_GET['code'] ?? '';
if (!is_string($code) || $code === '') {
$err = $_GET['error_description'] ?? $_GET['error'] ?? 'Autorización cancelada.';
deny((string)$err);
}
$tokenResp = Oauth::exchangeCodeForToken($code);
if (empty($tokenResp['access_token'])) {
deny('No se pudo obtener el token de acceso. Reintenta más tarde.');
}
$profile = Oauth::fetchProfile($tokenResp['access_token']);
$email = $profile['email'] ?? $profile['username'] ?? '';
$name = $profile['full_name'] ?? $profile['name'] ?? $email;
if ($email === '') {
deny('No se pudo leer el perfil del usuario desde el servidor de correo.');
}
if (!Auth::emailDomainAllowed($email)) {
deny('Esta cuenta no está autorizada para Donna. Solo dominios corporativos de AALN.', 403);
}
Auth::completeLogin($email, $name);
$returnTo = Auth::popReturnTo();
if (!str_starts_with($returnTo, '/')) $returnTo = '/';
header('Location: ' . $returnTo);
exit;
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
$returnTo = $_GET['return_to'] ?? '/';
if (!is_string($returnTo) || !str_starts_with($returnTo, '/')) {
$returnTo = '/';
}
header('Location: ' . Auth::buildLoginUrl($returnTo));
exit;
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
Auth::logout();
header('Location: /');
exit;
+179
View File
@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
require_once __DIR__ . '/../../src/Db.php';
if (($_GET['action'] ?? '') === 'logout') {
header('Location: /auth/logout.php');
exit;
}
Auth::requireAuth();
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
function ok($data = null): void {
echo json_encode(['ok' => true, 'data' => $data, 'user' => Auth::user()], JSON_UNESCAPED_UNICODE);
exit;
}
function err(string $msg, int $code = 400): void {
http_response_code($code);
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
exit;
}
function body(): array {
return json_decode((string) file_get_contents('php://input'), true) ?? [];
}
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$user = Auth::user();
$pdo = Db::pdo();
try {
if ($method === 'GET' && $action === 'list') {
$rows = $pdo->query('SELECT * FROM escrituras_tramites ORDER BY id DESC')->fetchAll();
ok($rows);
}
if ($method === 'POST' && $action === 'create') {
$b = body();
$stmt = $pdo->prepare(
'INSERT INTO escrituras_tramites
(tramite, cliente, abogado, notaria, inscribe, altera_turno, instrucciones,
fecha_solicitud, estatus, observaciones, creado_por)
VALUES
(:tramite, :cliente, :abogado, :notaria, :inscribe, :altera, :instrucciones,
NOW(), :estatus, :obs, :user)'
);
$stmt->execute([
':tramite' => $b['tramite'] ?? '',
':cliente' => $b['cliente'] ?? '',
':abogado' => $b['abogado'] ?? '',
':notaria' => $b['notaria'] ?? '',
':inscribe' => $b['inscribe'] ?? 'Sí',
':altera' => $b['altera_turno'] ?? 'No',
':instrucciones' => $b['instrucciones'] ?? '',
':estatus' => $b['estatus'] ?? 'Solicitado',
':obs' => $b['observaciones'] ?? '',
':user' => $user['email'],
]);
$id = (int) $pdo->lastInsertId();
$pdo->prepare(
"INSERT INTO escrituras_historial
(tramite_id, modificado_por, campo, valor_anterior, valor_nuevo)
VALUES (?, ?, 'Trámite', '', 'Creado')"
)->execute([$id, $user['email']]);
$row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
$row->execute([':id' => $id]);
ok($row->fetch());
}
if ($method === 'PUT' && $action === 'update') {
$b = body();
$id = (int) ($b['id'] ?? 0);
if (!$id) err('ID requerido');
$beforeStmt = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
$beforeStmt->execute([':id' => $id]);
$before = $beforeStmt->fetch();
if (!$before) err('Trámite no encontrado', 404);
$fields = [
'tramite' => $b['tramite'] ?? '',
'cliente' => $b['cliente'] ?? '',
'abogado' => $b['abogado'] ?? '',
'notaria' => $b['notaria'] ?? '',
'inscribe' => $b['inscribe'] ?? 'Sí',
'altera_turno' => $b['altera_turno'] ?? 'No',
'instrucciones' => $b['instrucciones'] ?? '',
'estatus' => $b['estatus'] ?? 'Solicitado',
'observaciones' => $b['observaciones'] ?? '',
];
$stmt = $pdo->prepare(
'UPDATE escrituras_tramites SET
tramite=:tramite, cliente=:cliente, abogado=:abogado, notaria=:notaria,
inscribe=:inscribe, altera_turno=:altera_turno, instrucciones=:instrucciones,
estatus=:estatus, observaciones=:observaciones,
actualizado_por=:actualizado_por
WHERE id=:id'
);
$stmt->execute(array_merge(
array_combine(
array_map(fn($k) => ':' . $k, array_keys($fields)),
array_values($fields)
),
[':actualizado_por' => $user['email'], ':id' => $id]
));
$labels = [
'tramite' => 'Nombre del Trámite',
'cliente' => 'Cliente',
'abogado' => 'Abogado Solicitante',
'notaria' => 'Notaría',
'inscribe' => 'Se Inscribe',
'altera_turno' => 'Alt. de Turno',
'instrucciones' => 'Instrucciones Especiales',
'estatus' => 'Estatus',
'observaciones' => 'Observaciones',
];
$hist = $pdo->prepare(
'INSERT INTO escrituras_historial
(tramite_id, modificado_por, campo, valor_anterior, valor_nuevo)
VALUES (:tid, :por, :campo, :ant, :nvo)'
);
foreach ($fields as $campo => $nuevo) {
$anterior = $before[$campo] ?? '';
if ((string) $anterior !== (string) $nuevo) {
$hist->execute([
':tid' => $id,
':por' => $user['email'],
':campo' => $labels[$campo] ?? $campo,
':ant' => $anterior,
':nvo' => $nuevo,
]);
}
}
$row = $pdo->prepare('SELECT * FROM escrituras_tramites WHERE id = :id');
$row->execute([':id' => $id]);
ok($row->fetch());
}
if ($method === 'DELETE' && $action === 'delete') {
$id = (int) ($_GET['id'] ?? 0);
if (!$id) err('ID requerido');
$pdo->prepare('DELETE FROM escrituras_tramites WHERE id = :id')->execute([':id' => $id]);
ok(['deleted' => $id]);
}
if ($method === 'GET' && $action === 'historial') {
$tid = (int) ($_GET['id'] ?? 0);
if (!$tid) err('ID requerido');
$stmt = $pdo->prepare(
'SELECT * FROM escrituras_historial
WHERE tramite_id = :tid
ORDER BY modificado_en DESC
LIMIT 200'
);
$stmt->execute([':tid' => $tid]);
ok($stmt->fetchAll());
}
err('Acción no reconocida', 404);
} catch (PDOException $e) {
error_log('escrituras db error: ' . $e->getMessage());
err('Error de base de datos', 500);
} catch (Throwable $e) {
error_log('escrituras error: ' . $e->getMessage());
err('Error interno', 500);
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/Auth.php';
Auth::requireAuth();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
readfile(__DIR__ . '/../../views/escrituras.html');
+245
View File
@@ -0,0 +1,245 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/Auth.php';
require_once __DIR__ . '/../src/Apps.php';
$authed = Auth::isAuthenticated();
$user = $authed ? Auth::user() : ['email' => '', 'name' => ''];
$apps = $authed ? Apps::catalog() : [];
function initials(string $name): string {
$parts = preg_split('/\s+/u', trim($name));
$a = mb_substr($parts[0] ?? '', 0, 1);
$b = mb_substr($parts[1] ?? '', 0, 1);
return strtoupper($a . $b) ?: 'AA';
}
function icon(string $name): string {
$svgs = [
'document' => '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/>',
'briefcase' => '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>',
'calendar' => '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'users' => '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
];
return $svgs[$name] ?? $svgs['document'];
}
?><!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $authed ? 'Donna · AALN Portal' : 'Donna · AALN Law' ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
:root{
--navy:#1B3A6B;
--navy-d:#112446;
--navy-l:#2D5090;
--gold:#C9A84C;
--gold-l:#E8C96A;
--gold-d:#9E7C2E;
--off:#F7F6F3;
--gray-100:#EEEDE9;
--gray-200:#D6D4CC;
--gray-400:#9B9A96;
--gray-700:#4A4946;
--gray-900:#1A1916;
--shadow:0 1px 3px rgba(0,0,0,0.06),0 4px 16px rgba(0,0,0,0.06);
--shadow-lg:0 12px 40px rgba(17,36,70,0.18);
}
html,body{min-height:100%;}
body{font-family:'DM Sans',system-ui,sans-serif;color:var(--gray-900);background:var(--off);font-size:15px;-webkit-font-smoothing:antialiased;}
a{color:inherit;text-decoration:none;}
/* ───────── LANDING (anonymous) ───────── */
.landing{min-height:100vh;display:flex;flex-direction:column;background:linear-gradient(135deg,#0E1F3F 0%,#1B3A6B 55%,#2D5090 100%);color:#fff;position:relative;overflow:hidden;}
.landing::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 60% 50% at 80% 10%,rgba(201,168,76,0.22),transparent 60%),radial-gradient(ellipse 40% 40% at 10% 90%,rgba(201,168,76,0.12),transparent 60%);pointer-events:none;}
.landing-nav{padding:28px 48px;display:flex;align-items:center;justify-content:space-between;position:relative;z-index:2;}
.brand{display:flex;align-items:center;gap:14px;}
.brand-mark{width:42px;height:42px;background:var(--gold);border-radius:8px;display:flex;align-items:center;justify-content:center;font-family:'DM Serif Display',serif;font-size:20px;color:var(--navy);}
.brand-text{display:flex;flex-direction:column;line-height:1.15;}
.brand-firm{font-size:11px;font-weight:600;color:var(--gold);letter-spacing:.18em;text-transform:uppercase;}
.brand-firm a{color:inherit;border-bottom:1px dotted transparent;transition:border-color .2s;}
.brand-firm a:hover{border-color:var(--gold);}
.brand-name{font-family:'DM Serif Display',serif;font-size:22px;color:#fff;}
.landing-foot-link{font-size:12px;color:rgba(255,255,255,0.55);letter-spacing:.06em;}
.landing-foot-link a{color:rgba(255,255,255,0.8);border-bottom:1px solid rgba(255,255,255,0.2);padding-bottom:1px;}
.landing-foot-link a:hover{color:var(--gold);border-color:var(--gold);}
.hero{flex:1;display:grid;grid-template-columns:1.1fr 1fr;gap:48px;padding:40px 80px 80px;align-items:center;position:relative;z-index:2;}
@media (max-width:980px){.hero{grid-template-columns:1fr;padding:24px 32px 48px;}}
.hero-text{max-width:560px;}
.hero-eyebrow{font-size:12px;font-weight:600;color:var(--gold);letter-spacing:.22em;text-transform:uppercase;margin-bottom:24px;display:flex;align-items:center;gap:12px;}
.hero-eyebrow::before{content:'';width:32px;height:1px;background:var(--gold);display:inline-block;}
.hero-title{font-family:'DM Serif Display',serif;font-size:clamp(40px,5.6vw,68px);line-height:1.05;color:#fff;margin-bottom:24px;font-weight:400;}
.hero-title em{font-style:italic;color:var(--gold-l);}
.hero-sub{font-size:17px;line-height:1.6;color:rgba(255,255,255,0.78);margin-bottom:36px;font-weight:300;max-width:520px;}
.btn-login{display:inline-flex;align-items:center;gap:12px;background:var(--gold);color:var(--navy-d);padding:16px 32px;border-radius:10px;font-weight:600;font-size:15px;letter-spacing:.02em;border:none;cursor:pointer;transition:transform .15s,box-shadow .15s,background .15s;box-shadow:0 6px 18px rgba(201,168,76,0.35);}
.btn-login:hover{background:var(--gold-l);transform:translateY(-1px);box-shadow:0 10px 24px rgba(201,168,76,0.42);}
.btn-login:active{transform:translateY(0);}
.btn-login svg{width:18px;height:18px;}
.login-meta{margin-top:18px;font-size:12px;color:rgba(255,255,255,0.5);letter-spacing:.04em;}
.hero-art{position:relative;height:420px;display:flex;align-items:center;justify-content:center;}
@media (max-width:980px){.hero-art{display:none;}}
.card-stack{position:relative;width:100%;max-width:420px;}
.art-card{position:absolute;background:rgba(255,255,255,0.06);border:1px solid rgba(201,168,76,0.25);backdrop-filter:blur(14px);border-radius:14px;padding:22px;color:#fff;box-shadow:var(--shadow-lg);}
.art-card-1{top:0;left:0;width:280px;transform:rotate(-3deg);}
.art-card-2{top:90px;right:0;width:300px;transform:rotate(2deg);}
.art-card-3{bottom:0;left:40px;width:280px;transform:rotate(-1deg);}
.art-card-eyebrow{font-size:9.5px;color:var(--gold);letter-spacing:.16em;text-transform:uppercase;font-weight:600;margin-bottom:10px;}
.art-card h4{font-family:'DM Serif Display',serif;font-size:16px;font-weight:400;line-height:1.3;margin-bottom:8px;color:#fff;}
.art-card p{font-size:12px;color:rgba(255,255,255,0.65);line-height:1.5;}
.art-card .pill{display:inline-block;font-size:10px;font-weight:600;padding:2px 8px;border-radius:20px;background:rgba(201,168,76,0.2);color:var(--gold-l);margin-top:8px;letter-spacing:.04em;}
/* ───────── DASHBOARD (authenticated) ───────── */
.dash-header{background:var(--navy);border-bottom:3px solid var(--gold);padding:0 32px;display:flex;align-items:center;justify-content:space-between;height:64px;position:sticky;top:0;z-index:200;}
.dash-header .brand-mark{width:36px;height:36px;font-size:16px;}
.dash-header .brand-name{font-size:18px;}
.user-chip{display:flex;align-items:center;gap:10px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);border-radius:24px;padding:5px 6px 5px 6px;color:#fff;}
.user-avatar{width:30px;height:30px;border-radius:50%;background:var(--gold);color:var(--navy-d);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0;}
.user-info{display:flex;flex-direction:column;line-height:1.15;padding-right:6px;}
.user-name{font-size:12.5px;font-weight:500;color:#fff;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.user-email{font-size:10.5px;color:rgba(255,255,255,0.55);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.btn-logout{font-size:11px;padding:6px 12px;border:1px solid rgba(255,255,255,0.18);border-radius:18px;background:transparent;color:rgba(255,255,255,0.7);cursor:pointer;font-family:'DM Sans',sans-serif;transition:all .15s;}
.btn-logout:hover{background:rgba(255,255,255,0.12);color:#fff;border-color:rgba(255,255,255,0.3);}
.dash-main{max-width:1180px;margin:0 auto;padding:48px 32px 80px;}
.dash-hero{margin-bottom:40px;}
.dash-greeting{font-family:'DM Serif Display',serif;font-size:36px;color:var(--navy);font-weight:400;line-height:1.15;margin-bottom:8px;}
.dash-greeting em{color:var(--gold-d);font-style:italic;}
.dash-sub{font-size:15px;color:var(--gray-700);max-width:560px;line-height:1.55;}
.section-label{font-size:11px;font-weight:600;color:var(--gray-400);letter-spacing:.18em;text-transform:uppercase;margin-bottom:18px;display:flex;align-items:center;gap:14px;}
.section-label::after{content:'';flex:1;height:1px;background:var(--gray-200);}
.app-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;}
.app-card{background:#fff;border:1px solid var(--gray-100);border-radius:14px;padding:24px;display:flex;flex-direction:column;gap:14px;transition:all .2s;position:relative;box-shadow:var(--shadow);cursor:pointer;color:inherit;min-height:200px;}
.app-card.live:hover{transform:translateY(-3px);box-shadow:0 12px 32px rgba(17,36,70,0.10);border-color:var(--navy-l);}
.app-card.coming-soon{cursor:default;opacity:0.7;}
.app-card.coming-soon:hover{transform:none;}
.app-icon{width:48px;height:48px;border-radius:10px;background:rgba(27,58,107,0.08);color:var(--navy);display:flex;align-items:center;justify-content:center;}
.app-card.live .app-icon{background:linear-gradient(135deg,var(--navy) 0%,var(--navy-l) 100%);color:var(--gold-l);}
.app-icon svg{width:24px;height:24px;}
.app-title{font-family:'DM Serif Display',serif;font-size:22px;color:var(--navy);font-weight:400;line-height:1.2;}
.app-desc{font-size:13.5px;color:var(--gray-700);line-height:1.55;flex:1;}
.app-action{font-size:12px;font-weight:600;color:var(--gold-d);letter-spacing:.06em;text-transform:uppercase;display:flex;align-items:center;gap:6px;}
.app-action svg{width:14px;height:14px;transition:transform .2s;}
.app-card.live:hover .app-action svg{transform:translateX(4px);}
.badge-soon{position:absolute;top:18px;right:18px;font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;padding:3px 10px;border-radius:20px;background:var(--gray-100);color:var(--gray-700);}
.dash-foot{text-align:center;padding:24px;font-size:11px;color:var(--gray-400);}
.dash-foot a{color:var(--navy);border-bottom:1px dotted var(--gray-400);}
</style>
</head>
<body>
<?php if (!$authed): ?>
<div class="landing">
<nav class="landing-nav">
<div class="brand">
<div class="brand-mark">D</div>
<div class="brand-text">
<span class="brand-firm"><a href="https://www.aalnlaw.com" target="_blank" rel="noopener">AALN · Arias, Abrego, López &amp; Noriega</a></span>
<span class="brand-name">Donna</span>
</div>
</div>
<div class="landing-foot-link">
Portal interno · <a href="https://www.aalnlaw.com" target="_blank" rel="noopener">aalnlaw.com</a>
</div>
</nav>
<section class="hero">
<div class="hero-text">
<div class="hero-eyebrow">Plataforma interna AALN</div>
<h1 class="hero-title">Tu equipo de trabajo, <em>siempre un paso adelante</em>.</h1>
<p class="hero-sub">Donna es el portal interno de AALN: control de escrituras, expedientes, agenda y herramientas internas en un solo lugar. Inicia sesión con tu correo corporativo.</p>
<form method="get" action="/auth/login.php">
<button class="btn-login" type="submit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
Iniciar sesión con correo corporativo
</button>
</form>
<div class="login-meta">Autenticación segura vía Mailcow · mail.aal-law.com</div>
</div>
<div class="hero-art" aria-hidden="true">
<div class="card-stack">
<div class="art-card art-card-1">
<div class="art-card-eyebrow">Trámite #1284</div>
<h4>Constitución sociedad anónima</h4>
<p>Notaría Quinta · Lic. R. Abrego</p>
<span class="pill">En Registro</span>
</div>
<div class="art-card art-card-2">
<div class="art-card-eyebrow">Hoy · 14:30</div>
<h4>Audiencia preliminar</h4>
<p>Juzgado Primero Civil · Sala 3</p>
<span class="pill">Confirmada</span>
</div>
<div class="art-card art-card-3">
<div class="art-card-eyebrow">Cliente nuevo</div>
<h4>Tech Holdings, S.A.</h4>
<p>KYC pendiente · Asignado a M. Noriega</p>
<span class="pill">Onboarding</span>
</div>
</div>
</div>
</section>
</div>
<?php else: ?>
<header class="dash-header">
<div class="brand">
<div class="brand-mark">D</div>
<div class="brand-text">
<span class="brand-firm" style="color:var(--gold);">AALN · Donna</span>
<span class="brand-name" style="font-size:14px;color:rgba(255,255,255,0.7);">Portal interno</span>
</div>
</div>
<div class="user-chip">
<div class="user-avatar"><?= htmlspecialchars(initials($user['name'] ?: $user['email'])) ?></div>
<div class="user-info">
<span class="user-name"><?= htmlspecialchars($user['name'] ?: $user['email']) ?></span>
<?php if ($user['name']): ?><span class="user-email"><?= htmlspecialchars($user['email']) ?></span><?php endif; ?>
</div>
<a href="/auth/logout.php" class="btn-logout">Cerrar sesión</a>
</div>
</header>
<main class="dash-main">
<div class="dash-hero">
<h1 class="dash-greeting">Hola, <em><?= htmlspecialchars(explode(' ', $user['name'] ?: $user['email'])[0]) ?></em>.</h1>
<p class="dash-sub">Bienvenido a Donna. Elige una herramienta para empezar.</p>
</div>
<div class="section-label">Aplicaciones disponibles</div>
<div class="app-grid">
<?php foreach ($apps as $app): ?>
<?php $isLive = $app['status'] === 'live'; ?>
<a class="app-card <?= $isLive ? 'live' : 'coming-soon' ?>" href="<?= htmlspecialchars($app['href']) ?>"<?= $isLive ? '' : ' onclick="return false"' ?>>
<?php if (!$isLive): ?><span class="badge-soon">Próximamente</span><?php endif; ?>
<div class="app-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><?= icon($app['icon']) ?></svg>
</div>
<div class="app-title"><?= htmlspecialchars($app['title']) ?></div>
<div class="app-desc"><?= htmlspecialchars($app['description']) ?></div>
<?php if ($isLive): ?>
<div class="app-action">
Abrir
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</div>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</main>
<footer class="dash-foot">
Donna · AALN Law &mdash; <a href="https://www.aalnlaw.com" target="_blank" rel="noopener">aalnlaw.com</a>
</footer>
<?php endif; ?>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
-- ============================================================
-- Donna Portal — Schema 001: Core
-- ============================================================
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL DEFAULT '',
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+51
View File
@@ -0,0 +1,51 @@
-- ============================================================
-- Donna Portal — Schema 002: Control de Escrituras
-- (migrado del repo ZH-Consultores/control-escrituras)
-- ============================================================
CREATE TABLE IF NOT EXISTS escrituras_tramites (
id INT AUTO_INCREMENT PRIMARY KEY,
tramite VARCHAR(255) NOT NULL DEFAULT '',
cliente VARCHAR(255) NOT NULL DEFAULT '',
abogado VARCHAR(100) NOT NULL DEFAULT '',
notaria VARCHAR(100) NOT NULL DEFAULT '',
inscribe ENUM('Sí','No') NOT NULL DEFAULT 'Sí',
altera_turno ENUM('Sí','No') NOT NULL DEFAULT 'No',
instrucciones TEXT NOT NULL,
fecha_solicitud DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
estatus ENUM(
'Solicitado',
'Recibido',
'En Proceso',
'Protocolizado',
'En Registro',
'Inscrito'
) NOT NULL DEFAULT 'Solicitado',
ultima_actualizacion DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
observaciones TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
creado_por VARCHAR(255) NOT NULL DEFAULT '',
actualizado_por VARCHAR(255) NOT NULL DEFAULT '',
INDEX idx_estatus (estatus),
INDEX idx_abogado (abogado),
INDEX idx_inscribe (inscribe),
INDEX idx_altera (altera_turno),
INDEX idx_fecha (fecha_solicitud DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS escrituras_historial (
id INT AUTO_INCREMENT PRIMARY KEY,
tramite_id INT NOT NULL,
modificado_por VARCHAR(255) NOT NULL DEFAULT '',
modificado_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
campo VARCHAR(100) NOT NULL DEFAULT '',
valor_anterior TEXT NOT NULL,
valor_nuevo TEXT NOT NULL,
INDEX idx_tramite (tramite_id),
INDEX idx_fecha (modificado_en DESC),
CONSTRAINT fk_escrituras_historial_tramite
FOREIGN KEY (tramite_id) REFERENCES escrituras_tramites(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+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) ?? [];
}
}
File diff suppressed because it is too large Load Diff