Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- src/Bootstrap.php +513 -0
- src/EmailService.php +168 -0
- src/ReactLoader.php +0 -0
- src/User.php +458 -0
- src/_init_.php +153 -0
src/Bootstrap.php
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
declare(strict_types=1);
|
| 3 |
+
|
| 4 |
+
namespace SoftEdge;
|
| 5 |
+
|
| 6 |
+
use PDO;
|
| 7 |
+
use PDOException;
|
| 8 |
+
use Throwable;
|
| 9 |
+
|
| 10 |
+
// Try to load Composer autoload if not already loaded
|
| 11 |
+
if (!class_exists(\Composer\Autoload\ClassLoader::class)) {
|
| 12 |
+
$autoload = __DIR__ . '/../vendor/autoload.php';
|
| 13 |
+
if (is_file($autoload)) {
|
| 14 |
+
require_once $autoload;
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Env loader with Dotenv support and fallback parser
|
| 20 |
+
*/
|
| 21 |
+
final class Env
|
| 22 |
+
{
|
| 23 |
+
private static bool $loaded = false;
|
| 24 |
+
private static array $cache = [];
|
| 25 |
+
|
| 26 |
+
public static function load(?string $dir = null): void
|
| 27 |
+
{
|
| 28 |
+
if (self::$loaded) return;
|
| 29 |
+
$dir = $dir ?? dirname(__DIR__);
|
| 30 |
+
|
| 31 |
+
// Load from $_ENV/$_SERVER first
|
| 32 |
+
self::$cache = array_merge($_SERVER, $_ENV);
|
| 33 |
+
|
| 34 |
+
// Prefer vlucas/phpdotenv when available
|
| 35 |
+
if (class_exists(\Dotenv\Dotenv::class)) {
|
| 36 |
+
try {
|
| 37 |
+
$dotenv = \Dotenv\Dotenv::createImmutable($dir, ['.env']);
|
| 38 |
+
$dotenv->safeLoad();
|
| 39 |
+
self::$cache = array_merge(self::$cache, $_ENV);
|
| 40 |
+
} catch (Throwable $e) {
|
| 41 |
+
// ignore, fallback to manual parse
|
| 42 |
+
}
|
| 43 |
+
} else {
|
| 44 |
+
// Fallback: manual parse basic .env
|
| 45 |
+
$envFile = $dir . DIRECTORY_SEPARATOR . '.env';
|
| 46 |
+
if (is_file($envFile)) {
|
| 47 |
+
$lines = @file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
| 48 |
+
foreach ($lines as $line) {
|
| 49 |
+
if (preg_match('/^\s*#/',$line)) continue;
|
| 50 |
+
if (!str_contains($line, '=')) continue;
|
| 51 |
+
[$k,$v] = array_map('trim', explode('=', $line, 2));
|
| 52 |
+
$v = trim($v, "\"' ");
|
| 53 |
+
self::$cache[$k] = $v;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Defaults
|
| 59 |
+
self::$cache['APP_ENV'] = self::$cache['APP_ENV'] ?? 'production';
|
| 60 |
+
self::$cache['TIMEZONE'] = self::$cache['TIMEZONE'] ?? 'Africa/Luanda';
|
| 61 |
+
self::$cache['JWT_SECRET'] = self::$cache['JWT_SECRET'] ?? base64_encode(random_bytes(32));
|
| 62 |
+
self::$cache['APP_KEY'] = self::$cache['APP_KEY'] ?? base64_encode(random_bytes(32));
|
| 63 |
+
|
| 64 |
+
self::$loaded = true;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
public static function get(string $key, ?string $default = null): ?string
|
| 68 |
+
{
|
| 69 |
+
return self::$cache[$key] ?? $default;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
public static function bool(string $key, bool $default = false): bool
|
| 73 |
+
{
|
| 74 |
+
$v = self::get($key);
|
| 75 |
+
if ($v === null) return $default;
|
| 76 |
+
$v = strtolower($v);
|
| 77 |
+
return in_array($v, ['1','true','yes','on'], true);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
public static function int(string $key, int $default = 0): int
|
| 81 |
+
{
|
| 82 |
+
$v = self::get($key);
|
| 83 |
+
return $v !== null && is_numeric($v) ? (int)$v : $default;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Security headers
|
| 89 |
+
*/
|
| 90 |
+
final class SecurityHeaders
|
| 91 |
+
{
|
| 92 |
+
public static function apply(): void
|
| 93 |
+
{
|
| 94 |
+
// Only add if headers not sent
|
| 95 |
+
if (headers_sent()) return;
|
| 96 |
+
|
| 97 |
+
$csp = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
| 98 |
+
header('Content-Security-Policy: ' . $csp);
|
| 99 |
+
header('X-Frame-Options: DENY');
|
| 100 |
+
header('X-Content-Type-Options: nosniff');
|
| 101 |
+
header('Referrer-Policy: strict-origin-when-cross-origin');
|
| 102 |
+
header('Permissions-Policy: geolocation=(), microphone=(), camera=()');
|
| 103 |
+
|
| 104 |
+
// HSTS only when HTTPS
|
| 105 |
+
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
| 106 |
+
header('Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/**
|
| 112 |
+
* Standardized JSON responses
|
| 113 |
+
*/
|
| 114 |
+
final class Response
|
| 115 |
+
{
|
| 116 |
+
public static function json(array $data, int $status = 200): void
|
| 117 |
+
{
|
| 118 |
+
if (!headers_sent()) {
|
| 119 |
+
http_response_code($status);
|
| 120 |
+
header('Content-Type: application/json; charset=utf-8');
|
| 121 |
+
}
|
| 122 |
+
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Simple validation helpers
|
| 128 |
+
*/
|
| 129 |
+
final class Validation
|
| 130 |
+
{
|
| 131 |
+
public static function required(array $data, array $fields): array
|
| 132 |
+
{
|
| 133 |
+
$errors = [];
|
| 134 |
+
foreach ($fields as $f) {
|
| 135 |
+
if (!isset($data[$f]) || $data[$f] === '') {
|
| 136 |
+
$errors[$f] = 'Campo obrigatório';
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
return $errors;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
public static function email(string $email): bool
|
| 143 |
+
{
|
| 144 |
+
return (bool)filter_var($email, FILTER_VALIDATE_EMAIL);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
* Very light file/APCu based rate limiter
|
| 150 |
+
*/
|
| 151 |
+
final class RateLimiter
|
| 152 |
+
{
|
| 153 |
+
public static function allow(string $key, int $maxRequests, int $windowSeconds): bool
|
| 154 |
+
{
|
| 155 |
+
$now = time();
|
| 156 |
+
$id = 'rate_' . sha1($key);
|
| 157 |
+
|
| 158 |
+
if (function_exists('apcu_fetch')) {
|
| 159 |
+
$entry = apcu_fetch($id, $success);
|
| 160 |
+
if (!$success || !is_array($entry) || $entry['reset'] <= $now) {
|
| 161 |
+
apcu_store($id, ['count' => 1, 'reset' => $now + $windowSeconds], $windowSeconds);
|
| 162 |
+
return true;
|
| 163 |
+
}
|
| 164 |
+
if ($entry['count'] < $maxRequests) {
|
| 165 |
+
$entry['count']++;
|
| 166 |
+
apcu_store($id, $entry, $entry['reset'] - $now);
|
| 167 |
+
return true;
|
| 168 |
+
}
|
| 169 |
+
return false;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// Fallback: file-based
|
| 173 |
+
$dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'storage';
|
| 174 |
+
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
| 175 |
+
$file = $dir . DIRECTORY_SEPARATOR . $id . '.json';
|
| 176 |
+
$entry = ['count' => 0, 'reset' => $now + $windowSeconds];
|
| 177 |
+
if (is_file($file)) {
|
| 178 |
+
$content = json_decode((string)@file_get_contents($file), true) ?: [];
|
| 179 |
+
if (isset($content['reset']) && $content['reset'] > $now) {
|
| 180 |
+
$entry = $content;
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
if ($entry['reset'] <= $now) {
|
| 184 |
+
$entry = ['count' => 1, 'reset' => $now + $windowSeconds];
|
| 185 |
+
@file_put_contents($file, json_encode($entry));
|
| 186 |
+
return true;
|
| 187 |
+
}
|
| 188 |
+
if ($entry['count'] < $maxRequests) {
|
| 189 |
+
$entry['count']++;
|
| 190 |
+
@file_put_contents($file, json_encode($entry));
|
| 191 |
+
return true;
|
| 192 |
+
}
|
| 193 |
+
return false;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* CSRF token utilities for forms and fetch requests
|
| 199 |
+
*/
|
| 200 |
+
final class Csrf
|
| 201 |
+
{
|
| 202 |
+
private const KEY = '_csrf';
|
| 203 |
+
|
| 204 |
+
public static function ensure(): void
|
| 205 |
+
{
|
| 206 |
+
if (!isset($_SESSION[self::KEY])) {
|
| 207 |
+
$_SESSION[self::KEY] = bin2hex(random_bytes(32));
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
public static function token(): string
|
| 212 |
+
{
|
| 213 |
+
return (string)($_SESSION[self::KEY] ?? '');
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
public static function validate(?string $token): bool
|
| 217 |
+
{
|
| 218 |
+
return is_string($token) && hash_equals(self::token(), $token);
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* JWT service using firebase/php-jwt when available
|
| 224 |
+
*/
|
| 225 |
+
final class JwtService
|
| 226 |
+
{
|
| 227 |
+
public static function sign(array $payload, ?int $ttlSeconds = 3600): string
|
| 228 |
+
{
|
| 229 |
+
$now = time();
|
| 230 |
+
$payload = array_merge([
|
| 231 |
+
'iat' => $now,
|
| 232 |
+
'nbf' => $now,
|
| 233 |
+
'exp' => $now + (int)$ttlSeconds,
|
| 234 |
+
'iss' => $_SERVER['HTTP_HOST'] ?? 'localhost',
|
| 235 |
+
], $payload);
|
| 236 |
+
|
| 237 |
+
$secret = Env::get('JWT_SECRET');
|
| 238 |
+
if (class_exists(\Firebase\JWT\JWT::class)) {
|
| 239 |
+
return \Firebase\JWT\JWT::encode($payload, $secret, 'HS256');
|
| 240 |
+
}
|
| 241 |
+
// Minimal fallback: base64 signature (NOT recommended for production if library missing)
|
| 242 |
+
$header = base64_encode(json_encode(['alg' => 'HS256','typ' => 'JWT']));
|
| 243 |
+
$body = base64_encode(json_encode($payload));
|
| 244 |
+
$sig = rtrim(strtr(base64_encode(hash_hmac('sha256', "$header.$body", $secret, true)), '+/', '-_'), '=');
|
| 245 |
+
return "$header.$body.$sig";
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/**
|
| 250 |
+
* PDO connection provider + migrations
|
| 251 |
+
*/
|
| 252 |
+
final class DB
|
| 253 |
+
{
|
| 254 |
+
private static ?PDO $pdo = null;
|
| 255 |
+
|
| 256 |
+
public static function pdo(): PDO
|
| 257 |
+
{
|
| 258 |
+
if (self::$pdo) return self::$pdo;
|
| 259 |
+
$url = Env::get('DATABASE_URL');
|
| 260 |
+
|
| 261 |
+
try {
|
| 262 |
+
if ($url && str_starts_with($url, 'mysql://')) {
|
| 263 |
+
// mysql://user:pass@host:port/db
|
| 264 |
+
$parts = parse_url($url);
|
| 265 |
+
$user = $parts['user'] ?? '';
|
| 266 |
+
$pass = $parts['pass'] ?? '';
|
| 267 |
+
$host = $parts['host'] ?? '127.0.0.1';
|
| 268 |
+
$port = $parts['port'] ?? 3306;
|
| 269 |
+
$db = ltrim($parts['path'] ?? '/softedge', '/');
|
| 270 |
+
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4";
|
| 271 |
+
$pdo = new PDO($dsn, $user, $pass, [
|
| 272 |
+
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
| 273 |
+
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
| 274 |
+
PDO::ATTR_EMULATE_PREPARES => false,
|
| 275 |
+
]);
|
| 276 |
+
self::$pdo = $pdo;
|
| 277 |
+
} else {
|
| 278 |
+
// Default to SQLite file in /storage
|
| 279 |
+
$dir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'storage';
|
| 280 |
+
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
| 281 |
+
$path = $dir . DIRECTORY_SEPARATOR . 'softedge.sqlite';
|
| 282 |
+
$dsn = 'sqlite:' . $path;
|
| 283 |
+
$pdo = new PDO($dsn, null, null, [
|
| 284 |
+
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
| 285 |
+
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
| 286 |
+
PDO::ATTR_EMULATE_PREPARES => false,
|
| 287 |
+
]);
|
| 288 |
+
// Ensures WAL for better concurrency
|
| 289 |
+
$pdo->exec('PRAGMA journal_mode = WAL');
|
| 290 |
+
$pdo->exec('PRAGMA foreign_keys = ON');
|
| 291 |
+
self::$pdo = $pdo;
|
| 292 |
+
}
|
| 293 |
+
} catch (PDOException $e) {
|
| 294 |
+
http_response_code(500);
|
| 295 |
+
die('Database connection error');
|
| 296 |
+
}
|
| 297 |
+
return self::$pdo;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
public static function migrate(): void
|
| 301 |
+
{
|
| 302 |
+
$pdo = self::pdo();
|
| 303 |
+
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
| 304 |
+
|
| 305 |
+
if ($driver === 'sqlite') {
|
| 306 |
+
$pdo->exec(<<<SQL
|
| 307 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 308 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 309 |
+
email TEXT NOT NULL UNIQUE,
|
| 310 |
+
password TEXT NOT NULL,
|
| 311 |
+
name TEXT,
|
| 312 |
+
role TEXT DEFAULT 'user',
|
| 313 |
+
status TEXT DEFAULT 'active',
|
| 314 |
+
last_login TEXT,
|
| 315 |
+
last_ip TEXT,
|
| 316 |
+
login_attempts INTEGER DEFAULT 0,
|
| 317 |
+
locked_until TEXT,
|
| 318 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 319 |
+
);
|
| 320 |
+
SQL);
|
| 321 |
+
|
| 322 |
+
$pdo->exec(<<<SQL
|
| 323 |
+
CREATE TABLE IF NOT EXISTS contact_submissions (
|
| 324 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 325 |
+
name TEXT,
|
| 326 |
+
email TEXT,
|
| 327 |
+
phone TEXT,
|
| 328 |
+
company TEXT,
|
| 329 |
+
subject TEXT,
|
| 330 |
+
message TEXT NOT NULL,
|
| 331 |
+
status TEXT DEFAULT 'new',
|
| 332 |
+
created_at TEXT DEFAULT (datetime('now'))
|
| 333 |
+
);
|
| 334 |
+
SQL);
|
| 335 |
+
} else {
|
| 336 |
+
// Basic MySQL-compatible schema (minimal)
|
| 337 |
+
$pdo->exec(<<<SQL
|
| 338 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 339 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 340 |
+
email VARCHAR(255) NOT NULL UNIQUE,
|
| 341 |
+
password VARCHAR(255) NOT NULL,
|
| 342 |
+
name VARCHAR(255),
|
| 343 |
+
role VARCHAR(50) DEFAULT 'user',
|
| 344 |
+
status VARCHAR(50) DEFAULT 'active',
|
| 345 |
+
last_login DATETIME NULL,
|
| 346 |
+
last_ip VARCHAR(45) NULL,
|
| 347 |
+
login_attempts INT DEFAULT 0,
|
| 348 |
+
locked_until DATETIME NULL,
|
| 349 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 350 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
| 351 |
+
SQL);
|
| 352 |
+
|
| 353 |
+
$pdo->exec(<<<SQL
|
| 354 |
+
CREATE TABLE IF NOT EXISTS contact_submissions (
|
| 355 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 356 |
+
name VARCHAR(255),
|
| 357 |
+
email VARCHAR(255),
|
| 358 |
+
phone VARCHAR(20),
|
| 359 |
+
company VARCHAR(255),
|
| 360 |
+
subject VARCHAR(500),
|
| 361 |
+
message TEXT NOT NULL,
|
| 362 |
+
status VARCHAR(50) DEFAULT 'new',
|
| 363 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 364 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
| 365 |
+
SQL);
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
public static function seedAdmin(): void
|
| 370 |
+
{
|
| 371 |
+
$pdo = self::pdo();
|
| 372 |
+
$email = Env::get('ADMIN_EMAIL', 'admin@softedge.com');
|
| 373 |
+
$name = Env::get('ADMIN_NAME', 'Admin');
|
| 374 |
+
$pass = Env::get('ADMIN_PASSWORD', 'Admin@123456');
|
| 375 |
+
$hash = password_hash($pass, PASSWORD_BCRYPT, ['cost' => 10]);
|
| 376 |
+
|
| 377 |
+
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
| 378 |
+
$stmt->execute([':email' => $email]);
|
| 379 |
+
if (!$stmt->fetch()) {
|
| 380 |
+
$ins = $pdo->prepare('INSERT INTO users (email, password, name, role, status) VALUES (:email,:password,:name,\'super_admin\',\'active\')');
|
| 381 |
+
$ins->execute([':email' => $email, ':password' => $hash, ':name' => $name]);
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
/**
|
| 387 |
+
* Authentication service
|
| 388 |
+
*/
|
| 389 |
+
final class AuthService
|
| 390 |
+
{
|
| 391 |
+
public static function login(string $email, string $password): array
|
| 392 |
+
{
|
| 393 |
+
$pdo = DB::pdo();
|
| 394 |
+
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
| 395 |
+
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
| 396 |
+
|
| 397 |
+
// Rate limit by IP & email
|
| 398 |
+
$key = 'login:' . $ip . ':' . strtolower($email);
|
| 399 |
+
if (!RateLimiter::allow($key, Env::int('RATE_LIMIT_LOGIN', 5), 60)) {
|
| 400 |
+
return ['ok' => false, 'status' => 429, 'error' => 'Muitas tentativas. Tente novamente mais tarde.'];
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
|
| 404 |
+
$stmt->execute([':email' => $email]);
|
| 405 |
+
$user = $stmt->fetch();
|
| 406 |
+
if (!$user) {
|
| 407 |
+
return ['ok' => false, 'status' => 401, 'error' => 'Credenciais inválidas'];
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// Check lockout
|
| 411 |
+
if (!empty($user['locked_until'])) {
|
| 412 |
+
$now = time();
|
| 413 |
+
$locked = strtotime((string)$user['locked_until']);
|
| 414 |
+
if ($locked && $locked > $now) {
|
| 415 |
+
return ['ok' => false, 'status' => 423, 'error' => 'Conta temporariamente bloqueada'];
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
if (!password_verify($password, (string)$user['password'])) {
|
| 420 |
+
// increment attempts
|
| 421 |
+
$attempts = (int)$user['login_attempts'] + 1;
|
| 422 |
+
$lockFor = 0;
|
| 423 |
+
$max = Env::int('MAX_LOGIN_ATTEMPTS', 5);
|
| 424 |
+
if ($attempts >= $max) {
|
| 425 |
+
$lockSeconds = Env::int('LOCKOUT_SECONDS', 900);
|
| 426 |
+
$lockFor = time() + $lockSeconds;
|
| 427 |
+
}
|
| 428 |
+
$pdo->prepare('UPDATE users SET login_attempts = :a, locked_until = :l WHERE id = :id')
|
| 429 |
+
->execute([':a' => $attempts, ':l' => $lockFor ? date('Y-m-d H:i:s', $lockFor) : null, ':id' => $user['id']]);
|
| 430 |
+
return ['ok' => false, 'status' => 401, 'error' => 'Credenciais inválidas'];
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
// Reset attempts and update metadata
|
| 434 |
+
$pdo->prepare('UPDATE users SET login_attempts = 0, locked_until = NULL, last_login = :ll, last_ip = :ip WHERE id = :id')
|
| 435 |
+
->execute([':ll' => date('Y-m-d H:i:s'), ':ip' => $ip, ':id' => $user['id']]);
|
| 436 |
+
|
| 437 |
+
// Session security
|
| 438 |
+
self::secureSession();
|
| 439 |
+
$_SESSION['uid'] = (int)$user['id'];
|
| 440 |
+
$_SESSION['role'] = (string)$user['role'];
|
| 441 |
+
session_regenerate_id(true);
|
| 442 |
+
|
| 443 |
+
// Optional JWT for API usage
|
| 444 |
+
$jwt = JwtService::sign(['sub' => $user['id'], 'email' => $user['email'], 'role' => $user['role']], Env::int('JWT_TTL', 3600));
|
| 445 |
+
|
| 446 |
+
return [
|
| 447 |
+
'ok' => true,
|
| 448 |
+
'status' => 200,
|
| 449 |
+
'user' => [
|
| 450 |
+
'id' => (int)$user['id'],
|
| 451 |
+
'email' => (string)$user['email'],
|
| 452 |
+
'name' => (string)($user['name'] ?? ''),
|
| 453 |
+
'role' => (string)$user['role'],
|
| 454 |
+
],
|
| 455 |
+
'token' => $jwt,
|
| 456 |
+
];
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
public static function logout(): void
|
| 460 |
+
{
|
| 461 |
+
if (session_status() === PHP_SESSION_ACTIVE) {
|
| 462 |
+
$_SESSION = [];
|
| 463 |
+
if (ini_get('session.use_cookies')) {
|
| 464 |
+
$params = session_get_cookie_params();
|
| 465 |
+
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
| 466 |
+
}
|
| 467 |
+
session_destroy();
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
public static function secureSession(): void
|
| 472 |
+
{
|
| 473 |
+
if (session_status() === PHP_SESSION_NONE) {
|
| 474 |
+
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
| 475 |
+
session_set_cookie_params([
|
| 476 |
+
'lifetime' => Env::int('SESSION_LIFETIME', 86400),
|
| 477 |
+
'path' => '/',
|
| 478 |
+
'domain' => '',
|
| 479 |
+
'secure' => $secure,
|
| 480 |
+
'httponly' => true,
|
| 481 |
+
'samesite' => 'Lax',
|
| 482 |
+
]);
|
| 483 |
+
session_start();
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/**
|
| 489 |
+
* Application bootstrapper
|
| 490 |
+
*/
|
| 491 |
+
final class Bootstrap
|
| 492 |
+
{
|
| 493 |
+
public static function init(): void
|
| 494 |
+
{
|
| 495 |
+
// Timezone
|
| 496 |
+
date_default_timezone_set(Env::get('TIMEZONE', 'Africa/Luanda'));
|
| 497 |
+
|
| 498 |
+
// Start secure session
|
| 499 |
+
AuthService::secureSession();
|
| 500 |
+
|
| 501 |
+
// CSRF token
|
| 502 |
+
Csrf::ensure();
|
| 503 |
+
|
| 504 |
+
// Security headers
|
| 505 |
+
SecurityHeaders::apply();
|
| 506 |
+
|
| 507 |
+
// Database and migrations
|
| 508 |
+
DB::migrate();
|
| 509 |
+
|
| 510 |
+
// Seed admin user if missing
|
| 511 |
+
DB::seedAdmin();
|
| 512 |
+
}
|
| 513 |
+
}
|
src/EmailService.php
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
|
| 3 |
+
namespace SoftEdge;
|
| 4 |
+
|
| 5 |
+
use PHPMailer\PHPMailer\PHPMailer;
|
| 6 |
+
use PHPMailer\PHPMailer\Exception;
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Email Service for SoftEdge Corporation
|
| 10 |
+
* Handles contact form submissions and email sending
|
| 11 |
+
*/
|
| 12 |
+
class EmailService
|
| 13 |
+
{
|
| 14 |
+
private PHPMailer $mailer;
|
| 15 |
+
private array $config;
|
| 16 |
+
|
| 17 |
+
public function __construct()
|
| 18 |
+
{
|
| 19 |
+
$this->config = $this->loadConfig();
|
| 20 |
+
$this->setupMailer();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Load email configuration from environment
|
| 25 |
+
*/
|
| 26 |
+
private function loadConfig(): array
|
| 27 |
+
{
|
| 28 |
+
return [
|
| 29 |
+
'host' => $_ENV['SMTP_HOST'] ?? 'smtp.gmail.com',
|
| 30 |
+
'port' => (int)($_ENV['SMTP_PORT'] ?? 587),
|
| 31 |
+
'username' => $_ENV['SMTP_USERNAME'] ?? '',
|
| 32 |
+
'password' => $_ENV['SMTP_PASSWORD'] ?? '',
|
| 33 |
+
'encryption' => $_ENV['SMTP_ENCRYPTION'] ?? 'tls',
|
| 34 |
+
'from_email' => $_ENV['SMTP_FROM_EMAIL'] ?? 'softedgecorporation@gmail.com',
|
| 35 |
+
'from_name' => $_ENV['SMTP_FROM_NAME'] ?? 'SoftEdge Corporation'
|
| 36 |
+
];
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Setup PHPMailer instance
|
| 41 |
+
*/
|
| 42 |
+
private function setupMailer(): void
|
| 43 |
+
{
|
| 44 |
+
$this->mailer = new PHPMailer(true);
|
| 45 |
+
|
| 46 |
+
// Server settings
|
| 47 |
+
$this->mailer->isSMTP();
|
| 48 |
+
$this->mailer->Host = $this->config['host'];
|
| 49 |
+
$this->mailer->SMTPAuth = true;
|
| 50 |
+
$this->mailer->Username = $this->config['username'];
|
| 51 |
+
$this->mailer->Password = $this->config['password'];
|
| 52 |
+
$this->mailer->SMTPSecure = $this->config['encryption'];
|
| 53 |
+
$this->mailer->Port = $this->config['port'];
|
| 54 |
+
|
| 55 |
+
// Sender
|
| 56 |
+
$this->mailer->setFrom($this->config['from_email'], $this->config['from_name']);
|
| 57 |
+
|
| 58 |
+
// Encoding and charset
|
| 59 |
+
$this->mailer->CharSet = 'UTF-8';
|
| 60 |
+
$this->mailer->Encoding = 'base64';
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Send contact email
|
| 65 |
+
*/
|
| 66 |
+
public function sendContactEmail(array $data): bool
|
| 67 |
+
{
|
| 68 |
+
try {
|
| 69 |
+
// Validate required fields
|
| 70 |
+
$this->validateContactData($data);
|
| 71 |
+
|
| 72 |
+
// Recipients
|
| 73 |
+
$this->mailer->addAddress('softedgecorporation@gmail.com', 'SoftEdge Corporation');
|
| 74 |
+
|
| 75 |
+
// Content
|
| 76 |
+
$this->mailer->isHTML(false);
|
| 77 |
+
$this->mailer->Subject = '🚀 Novo Contato do Site - ' . $data['nome'];
|
| 78 |
+
$this->mailer->Body = $this->buildContactEmailBody($data);
|
| 79 |
+
|
| 80 |
+
// Send email
|
| 81 |
+
$result = $this->mailer->send();
|
| 82 |
+
|
| 83 |
+
// Log success
|
| 84 |
+
error_log("Email sent successfully to: softedgecorporation@gmail.com from: {$data['email']}");
|
| 85 |
+
|
| 86 |
+
return $result;
|
| 87 |
+
|
| 88 |
+
} catch (Exception $e) {
|
| 89 |
+
error_log("Email sending failed: " . $this->mailer->ErrorInfo);
|
| 90 |
+
throw new \RuntimeException('Erro ao enviar email: ' . $e->getMessage());
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Validate contact form data
|
| 96 |
+
*/
|
| 97 |
+
private function validateContactData(array $data): void
|
| 98 |
+
{
|
| 99 |
+
$required = ['nome', 'email', 'mensagem'];
|
| 100 |
+
$missing = [];
|
| 101 |
+
|
| 102 |
+
foreach ($required as $field) {
|
| 103 |
+
if (empty(trim($data[$field] ?? ''))) {
|
| 104 |
+
$missing[] = $field;
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if (!empty($missing)) {
|
| 109 |
+
throw new \InvalidArgumentException('Campos obrigatórios não preenchidos: ' . implode(', ', $missing));
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
| 113 |
+
throw new \InvalidArgumentException('E-mail inválido');
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
// Additional validation
|
| 117 |
+
if (strlen($data['nome']) < 2) {
|
| 118 |
+
throw new \InvalidArgumentException('Nome deve ter pelo menos 2 caracteres');
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (strlen($data['mensagem']) < 10) {
|
| 122 |
+
throw new \InvalidArgumentException('Mensagem deve ter pelo menos 10 caracteres');
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Build contact email body
|
| 128 |
+
*/
|
| 129 |
+
private function buildContactEmailBody(array $data): string
|
| 130 |
+
{
|
| 131 |
+
$empresa = $data['empresa'] ?? '(não informado)';
|
| 132 |
+
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
| 133 |
+
$timestamp = date('d/m/Y H:i:s');
|
| 134 |
+
|
| 135 |
+
return "═══════════════════════════════════════\n" .
|
| 136 |
+
" NOVO CONTATO DO SITE SOFTEDGE\n" .
|
| 137 |
+
"═══════════════════════════════════════\n\n" .
|
| 138 |
+
"👤 NOME:\n {$data['nome']}\n\n" .
|
| 139 |
+
"📧 E-MAIL:\n {$data['email']}\n\n" .
|
| 140 |
+
"🏢 EMPRESA/PROJETO:\n {$empresa}\n\n" .
|
| 141 |
+
"💬 MENSAGEM:\n " . str_replace("\n", "\n ", $data['mensagem']) . "\n\n" .
|
| 142 |
+
"═══════════════════════════════════════\n" .
|
| 143 |
+
"📅 Data: {$timestamp}\n" .
|
| 144 |
+
"🌐 IP: {$ip}\n" .
|
| 145 |
+
"═══════════════════════════════════════\n";
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/**
|
| 149 |
+
* Send notification email (for internal use)
|
| 150 |
+
*/
|
| 151 |
+
public function sendNotification(string $subject, string $message): bool
|
| 152 |
+
{
|
| 153 |
+
try {
|
| 154 |
+
$this->mailer->clearAddresses();
|
| 155 |
+
$this->mailer->addAddress('softedgecorporation@gmail.com', 'SoftEdge Corporation');
|
| 156 |
+
|
| 157 |
+
$this->mailer->isHTML(false);
|
| 158 |
+
$this->mailer->Subject = '🔔 ' . $subject;
|
| 159 |
+
$this->mailer->Body = $message;
|
| 160 |
+
|
| 161 |
+
return $this->mailer->send();
|
| 162 |
+
|
| 163 |
+
} catch (Exception $e) {
|
| 164 |
+
error_log("Notification email failed: " . $this->mailer->ErrorInfo);
|
| 165 |
+
return false;
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
}
|
src/ReactLoader.php
ADDED
|
File without changes
|
src/User.php
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
|
| 3 |
+
namespace SoftEdge;
|
| 4 |
+
|
| 5 |
+
use PDO;
|
| 6 |
+
use PDOException;
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* User Management Class
|
| 10 |
+
* Handles user registration, authentication, and profile management
|
| 11 |
+
*/
|
| 12 |
+
class User
|
| 13 |
+
{
|
| 14 |
+
private PDO $db;
|
| 15 |
+
private array $config;
|
| 16 |
+
|
| 17 |
+
public function __construct(PDO $db = null)
|
| 18 |
+
{
|
| 19 |
+
$this->config = $this->loadConfig();
|
| 20 |
+
$this->db = $db ?? $this->getDatabaseConnection();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* Load configuration
|
| 25 |
+
*/
|
| 26 |
+
private function loadConfig(): array
|
| 27 |
+
{
|
| 28 |
+
return [
|
| 29 |
+
'db_host' => $_ENV['DB_HOST'] ?? 'localhost',
|
| 30 |
+
'db_name' => $_ENV['DB_NAME'] ?? 'softedge_db',
|
| 31 |
+
'db_user' => $_ENV['DB_USER'] ?? 'root',
|
| 32 |
+
'db_pass' => $_ENV['DB_PASS'] ?? '',
|
| 33 |
+
'jwt_secret' => $_ENV['JWT_SECRET'] ?? 'your-jwt-secret-key',
|
| 34 |
+
'google_client_id' => $_ENV['GOOGLE_CLIENT_ID'] ?? '',
|
| 35 |
+
'google_client_secret' => $_ENV['GOOGLE_CLIENT_SECRET'] ?? '',
|
| 36 |
+
'github_client_id' => $_ENV['GITHUB_CLIENT_ID'] ?? '',
|
| 37 |
+
'github_client_secret' => $_ENV['GITHUB_CLIENT_SECRET'] ?? ''
|
| 38 |
+
];
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Get database connection
|
| 43 |
+
*/
|
| 44 |
+
private function getDatabaseConnection(): PDO
|
| 45 |
+
{
|
| 46 |
+
try {
|
| 47 |
+
$dsn = "mysql:host={$this->config['db_host']};dbname={$this->config['db_name']};charset=utf8mb4";
|
| 48 |
+
$pdo = new PDO($dsn, $this->config['db_user'], $this->config['db_pass']);
|
| 49 |
+
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
| 50 |
+
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
| 51 |
+
return $pdo;
|
| 52 |
+
} catch (PDOException $e) {
|
| 53 |
+
error_log("Database connection failed: " . $e->getMessage());
|
| 54 |
+
throw new \RuntimeException('Database connection failed');
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Create users table if it doesn't exist
|
| 60 |
+
*/
|
| 61 |
+
public function createTables(): void
|
| 62 |
+
{
|
| 63 |
+
try {
|
| 64 |
+
// Users table
|
| 65 |
+
$this->db->exec("
|
| 66 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 67 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 68 |
+
name VARCHAR(255) NOT NULL,
|
| 69 |
+
email VARCHAR(255) UNIQUE NOT NULL,
|
| 70 |
+
password VARCHAR(255),
|
| 71 |
+
avatar VARCHAR(500),
|
| 72 |
+
provider VARCHAR(50) DEFAULT 'local',
|
| 73 |
+
provider_id VARCHAR(255),
|
| 74 |
+
role ENUM('user', 'admin') DEFAULT 'user',
|
| 75 |
+
email_verified BOOLEAN DEFAULT FALSE,
|
| 76 |
+
verification_token VARCHAR(255),
|
| 77 |
+
reset_token VARCHAR(255),
|
| 78 |
+
reset_expires DATETIME,
|
| 79 |
+
last_login DATETIME,
|
| 80 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 81 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
| 82 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
| 83 |
+
");
|
| 84 |
+
|
| 85 |
+
// Page visits table
|
| 86 |
+
$this->db->exec("
|
| 87 |
+
CREATE TABLE IF NOT EXISTS page_visits (
|
| 88 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 89 |
+
user_id INT,
|
| 90 |
+
page VARCHAR(255) NOT NULL,
|
| 91 |
+
ip_address VARCHAR(45),
|
| 92 |
+
user_agent TEXT,
|
| 93 |
+
referrer VARCHAR(500),
|
| 94 |
+
session_id VARCHAR(255),
|
| 95 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 96 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
| 97 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
| 98 |
+
");
|
| 99 |
+
|
| 100 |
+
// User sessions table
|
| 101 |
+
$this->db->exec("
|
| 102 |
+
CREATE TABLE IF NOT EXISTS user_sessions (
|
| 103 |
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
| 104 |
+
user_id INT NOT NULL,
|
| 105 |
+
session_token VARCHAR(255) UNIQUE NOT NULL,
|
| 106 |
+
ip_address VARCHAR(45),
|
| 107 |
+
user_agent TEXT,
|
| 108 |
+
expires_at DATETIME NOT NULL,
|
| 109 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 110 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 111 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
| 112 |
+
");
|
| 113 |
+
|
| 114 |
+
} catch (PDOException $e) {
|
| 115 |
+
error_log("Table creation failed: " . $e->getMessage());
|
| 116 |
+
throw new \RuntimeException('Failed to create database tables');
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Register a new user
|
| 122 |
+
*/
|
| 123 |
+
public function register(array $data): array
|
| 124 |
+
{
|
| 125 |
+
$this->validateRegistrationData($data);
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
$this->db->beginTransaction();
|
| 129 |
+
|
| 130 |
+
// Check if email already exists
|
| 131 |
+
$stmt = $this->db->prepare("SELECT id FROM users WHERE email = ?");
|
| 132 |
+
$stmt->execute([$data['email']]);
|
| 133 |
+
if ($stmt->fetch()) {
|
| 134 |
+
throw new \InvalidArgumentException('Email já cadastrado');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Hash password
|
| 138 |
+
$hashedPassword = password_hash($data['password'], PASSWORD_ARGON2ID);
|
| 139 |
+
|
| 140 |
+
// Generate verification token
|
| 141 |
+
$verificationToken = bin2hex(random_bytes(32));
|
| 142 |
+
|
| 143 |
+
// Insert user
|
| 144 |
+
$stmt = $this->db->prepare("
|
| 145 |
+
INSERT INTO users (name, email, password, verification_token, created_at)
|
| 146 |
+
VALUES (?, ?, ?, ?, NOW())
|
| 147 |
+
");
|
| 148 |
+
$stmt->execute([
|
| 149 |
+
$data['name'],
|
| 150 |
+
$data['email'],
|
| 151 |
+
$hashedPassword,
|
| 152 |
+
$verificationToken
|
| 153 |
+
]);
|
| 154 |
+
|
| 155 |
+
$userId = $this->db->lastInsertId();
|
| 156 |
+
|
| 157 |
+
$this->db->commit();
|
| 158 |
+
|
| 159 |
+
return [
|
| 160 |
+
'success' => true,
|
| 161 |
+
'user_id' => $userId,
|
| 162 |
+
'verification_token' => $verificationToken,
|
| 163 |
+
'message' => 'Usuário registrado com sucesso. Verifique seu email.'
|
| 164 |
+
];
|
| 165 |
+
|
| 166 |
+
} catch (PDOException $e) {
|
| 167 |
+
$this->db->rollBack();
|
| 168 |
+
error_log("Registration failed: " . $e->getMessage());
|
| 169 |
+
throw new \RuntimeException('Erro ao registrar usuário');
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/**
|
| 174 |
+
* Authenticate user
|
| 175 |
+
*/
|
| 176 |
+
public function login(string $email, string $password): array
|
| 177 |
+
{
|
| 178 |
+
try {
|
| 179 |
+
$stmt = $this->db->prepare("
|
| 180 |
+
SELECT id, name, email, password, role, email_verified
|
| 181 |
+
FROM users
|
| 182 |
+
WHERE email = ? AND provider = 'local'
|
| 183 |
+
");
|
| 184 |
+
$stmt->execute([$email]);
|
| 185 |
+
$user = $stmt->fetch();
|
| 186 |
+
|
| 187 |
+
if (!$user || !password_verify($password, $user['password'])) {
|
| 188 |
+
throw new \InvalidArgumentException('Email ou senha incorretos');
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
if (!$user['email_verified']) {
|
| 192 |
+
throw new \InvalidArgumentException('Email não verificado. Verifique sua caixa de entrada.');
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
// Update last login
|
| 196 |
+
$stmt = $this->db->prepare("UPDATE users SET last_login = NOW() WHERE id = ?");
|
| 197 |
+
$stmt->execute([$user['id']]);
|
| 198 |
+
|
| 199 |
+
// Create session
|
| 200 |
+
$sessionToken = $this->createSession($user['id']);
|
| 201 |
+
|
| 202 |
+
// Log page visit
|
| 203 |
+
$this->logPageVisit($user['id'], 'login', $_SERVER['HTTP_USER_AGENT'] ?? '');
|
| 204 |
+
|
| 205 |
+
return [
|
| 206 |
+
'success' => true,
|
| 207 |
+
'user' => [
|
| 208 |
+
'id' => $user['id'],
|
| 209 |
+
'name' => $user['name'],
|
| 210 |
+
'email' => $user['email'],
|
| 211 |
+
'role' => $user['role']
|
| 212 |
+
],
|
| 213 |
+
'session_token' => $sessionToken
|
| 214 |
+
];
|
| 215 |
+
|
| 216 |
+
} catch (PDOException $e) {
|
| 217 |
+
error_log("Login failed: " . $e->getMessage());
|
| 218 |
+
throw new \RuntimeException('Erro ao fazer login');
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/**
|
| 223 |
+
* Social login (Google, GitHub)
|
| 224 |
+
*/
|
| 225 |
+
public function socialLogin(string $provider, array $profile): array
|
| 226 |
+
{
|
| 227 |
+
try {
|
| 228 |
+
$this->db->beginTransaction();
|
| 229 |
+
|
| 230 |
+
// Check if user exists
|
| 231 |
+
$stmt = $this->db->prepare("
|
| 232 |
+
SELECT id, name, email, role, email_verified
|
| 233 |
+
FROM users
|
| 234 |
+
WHERE provider = ? AND provider_id = ?
|
| 235 |
+
");
|
| 236 |
+
$stmt->execute([$provider, $profile['id']]);
|
| 237 |
+
$user = $stmt->fetch();
|
| 238 |
+
|
| 239 |
+
if (!$user) {
|
| 240 |
+
// Create new user
|
| 241 |
+
$stmt = $this->db->prepare("
|
| 242 |
+
INSERT INTO users (name, email, avatar, provider, provider_id, email_verified, created_at)
|
| 243 |
+
VALUES (?, ?, ?, ?, ?, TRUE, NOW())
|
| 244 |
+
");
|
| 245 |
+
$stmt->execute([
|
| 246 |
+
$profile['name'],
|
| 247 |
+
$profile['email'],
|
| 248 |
+
$profile['avatar'] ?? null,
|
| 249 |
+
$provider,
|
| 250 |
+
$profile['id']
|
| 251 |
+
]);
|
| 252 |
+
|
| 253 |
+
$userId = $this->db->lastInsertId();
|
| 254 |
+
|
| 255 |
+
$user = [
|
| 256 |
+
'id' => $userId,
|
| 257 |
+
'name' => $profile['name'],
|
| 258 |
+
'email' => $profile['email'],
|
| 259 |
+
'role' => 'user',
|
| 260 |
+
'email_verified' => true
|
| 261 |
+
];
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
// Update last login
|
| 265 |
+
$stmt = $this->db->prepare("UPDATE users SET last_login = NOW() WHERE id = ?");
|
| 266 |
+
$stmt->execute([$user['id']]);
|
| 267 |
+
|
| 268 |
+
// Create session
|
| 269 |
+
$sessionToken = $this->createSession($user['id']);
|
| 270 |
+
|
| 271 |
+
// Log page visit
|
| 272 |
+
$this->logPageVisit($user['id'], 'social_login', $_SERVER['HTTP_USER_AGENT'] ?? '');
|
| 273 |
+
|
| 274 |
+
$this->db->commit();
|
| 275 |
+
|
| 276 |
+
return [
|
| 277 |
+
'success' => true,
|
| 278 |
+
'user' => $user,
|
| 279 |
+
'session_token' => $sessionToken
|
| 280 |
+
];
|
| 281 |
+
|
| 282 |
+
} catch (PDOException $e) {
|
| 283 |
+
$this->db->rollBack();
|
| 284 |
+
error_log("Social login failed: " . $e->getMessage());
|
| 285 |
+
throw new \RuntimeException('Erro ao fazer login social');
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
/**
|
| 290 |
+
* Create user session
|
| 291 |
+
*/
|
| 292 |
+
private function createSession(int $userId): string
|
| 293 |
+
{
|
| 294 |
+
$sessionToken = bin2hex(random_bytes(32));
|
| 295 |
+
$expiresAt = date('Y-m-d H:i:s', strtotime('+24 hours'));
|
| 296 |
+
|
| 297 |
+
$stmt = $this->db->prepare("
|
| 298 |
+
INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, expires_at, created_at)
|
| 299 |
+
VALUES (?, ?, ?, ?, ?, NOW())
|
| 300 |
+
");
|
| 301 |
+
$stmt->execute([
|
| 302 |
+
$userId,
|
| 303 |
+
$sessionToken,
|
| 304 |
+
$_SERVER['REMOTE_ADDR'] ?? '',
|
| 305 |
+
$_SERVER['HTTP_USER_AGENT'] ?? '',
|
| 306 |
+
$expiresAt
|
| 307 |
+
]);
|
| 308 |
+
|
| 309 |
+
return $sessionToken;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
/**
|
| 313 |
+
* Validate session
|
| 314 |
+
*/
|
| 315 |
+
public function validateSession(string $sessionToken): ?array
|
| 316 |
+
{
|
| 317 |
+
try {
|
| 318 |
+
$stmt = $this->db->prepare("
|
| 319 |
+
SELECT u.id, u.name, u.email, u.role, u.email_verified, s.expires_at
|
| 320 |
+
FROM user_sessions s
|
| 321 |
+
JOIN users u ON s.user_id = u.id
|
| 322 |
+
WHERE s.session_token = ? AND s.expires_at > NOW()
|
| 323 |
+
");
|
| 324 |
+
$stmt->execute([$sessionToken]);
|
| 325 |
+
$result = $stmt->fetch();
|
| 326 |
+
|
| 327 |
+
return $result ?: null;
|
| 328 |
+
|
| 329 |
+
} catch (PDOException $e) {
|
| 330 |
+
error_log("Session validation failed: " . $e->getMessage());
|
| 331 |
+
return null;
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
/**
|
| 336 |
+
* Log page visit
|
| 337 |
+
*/
|
| 338 |
+
public function logPageVisit(?int $userId, string $page, string $userAgent = ''): void
|
| 339 |
+
{
|
| 340 |
+
try {
|
| 341 |
+
$stmt = $this->db->prepare("
|
| 342 |
+
INSERT INTO page_visits (user_id, page, ip_address, user_agent, referrer, session_id, created_at)
|
| 343 |
+
VALUES (?, ?, ?, ?, ?, ?, NOW())
|
| 344 |
+
");
|
| 345 |
+
$stmt->execute([
|
| 346 |
+
$userId,
|
| 347 |
+
$page,
|
| 348 |
+
$_SERVER['REMOTE_ADDR'] ?? '',
|
| 349 |
+
$userAgent,
|
| 350 |
+
$_SERVER['HTTP_REFERER'] ?? '',
|
| 351 |
+
session_id()
|
| 352 |
+
]);
|
| 353 |
+
} catch (PDOException $e) {
|
| 354 |
+
error_log("Page visit logging failed: " . $e->getMessage());
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/**
|
| 359 |
+
* Get admin statistics
|
| 360 |
+
*/
|
| 361 |
+
public function getAdminStats(): array
|
| 362 |
+
{
|
| 363 |
+
try {
|
| 364 |
+
// Total users
|
| 365 |
+
$stmt = $this->db->query("SELECT COUNT(*) as total FROM users");
|
| 366 |
+
$totalUsers = $stmt->fetch()['total'];
|
| 367 |
+
|
| 368 |
+
// Total page visits
|
| 369 |
+
$stmt = $this->db->query("SELECT COUNT(*) as total FROM page_visits");
|
| 370 |
+
$totalVisits = $stmt->fetch()['total'];
|
| 371 |
+
|
| 372 |
+
// Recent visits (last 30 days)
|
| 373 |
+
$stmt = $this->db->prepare("
|
| 374 |
+
SELECT COUNT(*) as total
|
| 375 |
+
FROM page_visits
|
| 376 |
+
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
|
| 377 |
+
");
|
| 378 |
+
$stmt->execute();
|
| 379 |
+
$recentVisits = $stmt->fetch()['total'];
|
| 380 |
+
|
| 381 |
+
// Top pages
|
| 382 |
+
$stmt = $this->db->prepare("
|
| 383 |
+
SELECT page, COUNT(*) as visits
|
| 384 |
+
FROM page_visits
|
| 385 |
+
GROUP BY page
|
| 386 |
+
ORDER BY visits DESC
|
| 387 |
+
LIMIT 10
|
| 388 |
+
");
|
| 389 |
+
$stmt->execute();
|
| 390 |
+
$topPages = $stmt->fetchAll();
|
| 391 |
+
|
| 392 |
+
// Recent users
|
| 393 |
+
$stmt = $this->db->prepare("
|
| 394 |
+
SELECT id, name, email, created_at
|
| 395 |
+
FROM users
|
| 396 |
+
ORDER BY created_at DESC
|
| 397 |
+
LIMIT 10
|
| 398 |
+
");
|
| 399 |
+
$stmt->execute();
|
| 400 |
+
$recentUsers = $stmt->fetchAll();
|
| 401 |
+
|
| 402 |
+
return [
|
| 403 |
+
'total_users' => $totalUsers,
|
| 404 |
+
'total_visits' => $totalVisits,
|
| 405 |
+
'recent_visits' => $recentVisits,
|
| 406 |
+
'top_pages' => $topPages,
|
| 407 |
+
'recent_users' => $recentUsers
|
| 408 |
+
];
|
| 409 |
+
|
| 410 |
+
} catch (PDOException $e) {
|
| 411 |
+
error_log("Admin stats failed: " . $e->getMessage());
|
| 412 |
+
return [];
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
/**
|
| 417 |
+
* Validate registration data
|
| 418 |
+
*/
|
| 419 |
+
private function validateRegistrationData(array $data): void
|
| 420 |
+
{
|
| 421 |
+
$required = ['name', 'email', 'password'];
|
| 422 |
+
foreach ($required as $field) {
|
| 423 |
+
if (empty($data[$field])) {
|
| 424 |
+
throw new \InvalidArgumentException("Campo {$field} é obrigatório");
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
|
| 429 |
+
throw new \InvalidArgumentException('Email inválido');
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
if (strlen($data['name']) < 2) {
|
| 433 |
+
throw new \InvalidArgumentException('Nome deve ter pelo menos 2 caracteres');
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
if (strlen($data['password']) < 8) {
|
| 437 |
+
throw new \InvalidArgumentException('Senha deve ter pelo menos 8 caracteres');
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/**
|
| 442 |
+
* Check if user is admin
|
| 443 |
+
*/
|
| 444 |
+
public function isAdmin(int $userId): bool
|
| 445 |
+
{
|
| 446 |
+
try {
|
| 447 |
+
$stmt = $this->db->prepare("SELECT role FROM users WHERE id = ?");
|
| 448 |
+
$stmt->execute([$userId]);
|
| 449 |
+
$user = $stmt->fetch();
|
| 450 |
+
|
| 451 |
+
return $user && $user['role'] === 'admin';
|
| 452 |
+
|
| 453 |
+
} catch (PDOException $e) {
|
| 454 |
+
error_log("Admin check failed: " . $e->getMessage());
|
| 455 |
+
return false;
|
| 456 |
+
}
|
| 457 |
+
}
|
| 458 |
+
}
|
src/_init_.php
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?php
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* SoftEdge Corporation - Initialization File
|
| 5 |
+
* Sets up the application environment and autoloading
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
// Start session if not already started
|
| 9 |
+
if (session_status() === PHP_SESSION_NONE) {
|
| 10 |
+
session_start();
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// Set timezone
|
| 14 |
+
date_default_timezone_set('Africa/Luanda');
|
| 15 |
+
|
| 16 |
+
// Error handling for production
|
| 17 |
+
if (getenv('APP_ENV') === 'production') {
|
| 18 |
+
ini_set('display_errors', 0);
|
| 19 |
+
ini_set('display_startup_errors', 0);
|
| 20 |
+
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
|
| 21 |
+
} else {
|
| 22 |
+
ini_set('display_errors', 1);
|
| 23 |
+
ini_set('display_startup_errors', 1);
|
| 24 |
+
error_reporting(E_ALL);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Load environment variables if .env exists
|
| 28 |
+
if (file_exists(__DIR__ . '/../.env')) {
|
| 29 |
+
$dotenv = \Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
|
| 30 |
+
$dotenv->load();
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Set default environment variables
|
| 34 |
+
$_ENV['APP_NAME'] = $_ENV['APP_NAME'] ?? 'SoftEdge Corporation';
|
| 35 |
+
$_ENV['APP_URL'] = $_ENV['APP_URL'] ?? 'https://softedge-corporation.up.railway.app';
|
| 36 |
+
$_ENV['APP_ENV'] = $_ENV['APP_ENV'] ?? 'production';
|
| 37 |
+
|
| 38 |
+
// Security headers
|
| 39 |
+
header('X-Content-Type-Options: nosniff');
|
| 40 |
+
header('X-Frame-Options: DENY');
|
| 41 |
+
header('X-XSS-Protection: 1; mode=block');
|
| 42 |
+
header('Referrer-Policy: strict-origin-when-cross-origin');
|
| 43 |
+
|
| 44 |
+
// Content Security Policy
|
| 45 |
+
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'");
|
| 46 |
+
|
| 47 |
+
// Create logs directory if it doesn't exist
|
| 48 |
+
$logsDir = __DIR__ . '/../logs';
|
| 49 |
+
if (!is_dir($logsDir)) {
|
| 50 |
+
mkdir($logsDir, 0755, true);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
// Set custom error log
|
| 54 |
+
ini_set('error_log', $logsDir . '/php_errors.log');
|
| 55 |
+
|
| 56 |
+
// Function to get base URL
|
| 57 |
+
function getBaseUrl(): string
|
| 58 |
+
{
|
| 59 |
+
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
|
| 60 |
+
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
| 61 |
+
$port = $_SERVER['SERVER_PORT'] ?? 80;
|
| 62 |
+
|
| 63 |
+
// Don't include port if it's the default for the protocol
|
| 64 |
+
if (($protocol === 'http' && $port == 80) || ($protocol === 'https' && $port == 443)) {
|
| 65 |
+
return $protocol . '://' . $host;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
return $protocol . '://' . $host . ':' . $port;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
// Function to get current URL
|
| 72 |
+
function getCurrentUrl(): string
|
| 73 |
+
{
|
| 74 |
+
return getBaseUrl() . $_SERVER['REQUEST_URI'];
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Function to redirect
|
| 78 |
+
function redirect(string $url, int $statusCode = 302): void
|
| 79 |
+
{
|
| 80 |
+
header('Location: ' . $url, true, $statusCode);
|
| 81 |
+
exit;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Function to sanitize input
|
| 85 |
+
function sanitizeInput(string $input): string
|
| 86 |
+
{
|
| 87 |
+
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// Function to generate CSRF token
|
| 91 |
+
function generateCsrfToken(): string
|
| 92 |
+
{
|
| 93 |
+
if (empty($_SESSION['csrf_token'])) {
|
| 94 |
+
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
| 95 |
+
}
|
| 96 |
+
return $_SESSION['csrf_token'];
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Function to validate CSRF token
|
| 100 |
+
function validateCsrfToken(string $token): bool
|
| 101 |
+
{
|
| 102 |
+
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Function to log activity
|
| 106 |
+
function logActivity(string $message, string $level = 'INFO'): void
|
| 107 |
+
{
|
| 108 |
+
$logFile = __DIR__ . '/../logs/activity.log';
|
| 109 |
+
$timestamp = date('Y-m-d H:i:s');
|
| 110 |
+
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
| 111 |
+
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
|
| 112 |
+
|
| 113 |
+
$logEntry = "[$timestamp] [$level] [$ip] [$userAgent] $message" . PHP_EOL;
|
| 114 |
+
|
| 115 |
+
file_put_contents($logFile, $logEntry, FILE_APPEND | LOCK_EX);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// Function to check if user is rate limited
|
| 119 |
+
function isRateLimited(string $identifier, int $maxRequests = 5, int $timeWindow = 300): bool
|
| 120 |
+
{
|
| 121 |
+
$rateLimitFile = __DIR__ . "/../logs/rate_limit_{$identifier}.txt";
|
| 122 |
+
$currentTime = time();
|
| 123 |
+
|
| 124 |
+
// Read existing requests
|
| 125 |
+
$requests = [];
|
| 126 |
+
if (file_exists($rateLimitFile)) {
|
| 127 |
+
$requests = json_decode(file_get_contents($rateLimitFile), true) ?? [];
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Filter out old requests
|
| 131 |
+
$requests = array_filter($requests, function($timestamp) use ($currentTime, $timeWindow) {
|
| 132 |
+
return ($currentTime - $timestamp) < $timeWindow;
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
// Check if rate limit exceeded
|
| 136 |
+
if (count($requests) >= $maxRequests) {
|
| 137 |
+
return true;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Add current request
|
| 141 |
+
$requests[] = $currentTime;
|
| 142 |
+
|
| 143 |
+
// Save updated requests
|
| 144 |
+
file_put_contents($rateLimitFile, json_encode($requests));
|
| 145 |
+
|
| 146 |
+
return false;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// Initialize application
|
| 150 |
+
logActivity("Application initialized");
|
| 151 |
+
|
| 152 |
+
// Autoload classes (Composer will handle this)
|
| 153 |
+
require_once __DIR__ . '/../vendor/autoload.php';
|