Initial import of fitme3070.pt platform

Plain-PHP fleet platform with id.andmore.pt SSO. Wires shared/platform-tools
as a submodule (platform_update, backups, git/migration tools) like the other
platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 4.8 committed 2026-06-12 10:44:58 +01:00
commit e68d357177
2330 files changed
+215968

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
<?php
// Auth guards — require functions.php (and session_start) before this file.
function require_login(): array {
if (empty($_SESSION['user_id'])) {
if (identity_is_federated()) {
sso_resolve_session($GLOBALS['pdo']);
}
if (empty($_SESSION['user_id'])) {
if (SSO_REQUIRED) {
// Bounce through this platform's local login stub instead of
// sending id.andmore.pt's return_to straight at a protected
// page. The stub (views/auth/login.php) carries the
// ?from=sso loop-guard and loads the full functions.php
// bootstrap before calling sso_resolve_session().
$_rl_return = APP_BASE_URL . 'views/auth/login.php?from=sso';
$_rl_qs = '?return_to=' . urlencode($_rl_return)
. '&platform=' . urlencode(PLATFORM_SLUG);
header('Location: ' . ANDMORE_ID_URL . 'auth/login.php' . $_rl_qs);
} else {
$return = $_SERVER['REQUEST_URI'] ?? '';
$qs = $return ? ('?return_to=' . urlencode($return)) : '';
header('Location: ' . base_url('views/auth/login.php' . $qs));
}
exit;
}
}
return $_SESSION['user'];
}
// ─── Role ladder ─────────────────────────────────────────────────────────────
// sysadmin — super-admin (backups, platform update, app_base_url).
// admin — full staff access except those three sysadmin-only items.
// staff — works only on items assigned to them (e.g. sessions where
// photographer_id = users.id). Cannot manage other users,
// templates, settings, etc.
// client — portal-only.
// is_admin()/require_admin() = sysadmin OR admin. is_staff()/require_staff()
// also includes the plain `staff` role.
// Resolves the dashboard path for a given role. Used by index.php, the auth
// pages, and require_admin()'s bounce fallback so all four agree on routing.
function post_login_destination(string $role): string {
return match ($role) {
'sysadmin', 'admin' => 'views/admin/dashboard.php',
'staff' => 'views/staff/dashboard.php',
'client' => 'views/user/dashboard.php',
default => 'index.php',
};
}
// Accepts sysadmin + admin only. Plain staff and clients are bounced to the
// dashboard appropriate for their role (avoids redirect loops when an admin-
// only page bounces a staff user).
function require_admin(): array {
$user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin'])) {
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit;
}
return $user;
}
// Accepts sysadmin + admin + staff — anyone who can act on assignments.
// Clients are redirected to the public index.
function require_staff(): array {
$user = require_login();
if (!in_array($user['role'], ['sysadmin', 'admin', 'staff'])) {
header('Location: ' . base_url('index.php'));
exit;
}
return $user;
}
// Ownership guard for staff-scoped pages (edit/update_status/send_email/etc).
// Sysadmin and admin pass for any session. Plain staff only pass when the
// session's photographer_id matches their user id. Mismatches bounce back to
// the role-appropriate sessions list.
function require_session_ownership(array $session): array {
$user = require_staff();
if (in_array($user['role'], ['sysadmin', 'admin'], true)) return $user;
if ((int) ($session['photographer_id'] ?? 0) !== (int) $user['id']) {
header('Location: ' . base_url('views/sessions/index.php'));
exit;
}
return $user;
}
// Sysadmin-only pages (backups, platform update, sensitive settings).
// Plain admins are sent back to their dashboard.
function require_sysadmin(): array {
$user = require_login();
if ($user['role'] !== 'sysadmin') {
header('Location: ' . base_url(post_login_destination($user['role'] ?? '')));
exit;
}
return $user;
}
function current_user(): ?array {
return $_SESSION['user'] ?? null;
}
// True for sysadmin + admin (NOT plain staff).
function is_admin(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin']);
}
// True for any backoffice user (sysadmin + admin + staff). Use this to show
// a UI element that any non-client backoffice user should see.
function is_staff(): bool {
return in_array(current_user()['role'] ?? '', ['sysadmin', 'admin', 'staff']);
}
function is_sysadmin(): bool {
return (current_user()['role'] ?? '') === 'sysadmin';
}
// True for plain admins (not sysadmin, not staff). Useful for "hide from
// sysadmin" edge cases; most code wants is_admin() or is_sysadmin().
function is_admin_role(): bool {
return (current_user()['role'] ?? '') === 'admin';
}
// True for plain staff only — the assignment-only role. Used to scope queries
// to "photographer_id = me" instead of returning all sessions.
function is_staff_role(): bool {
return (current_user()['role'] ?? '') === 'staff';
}
// Back-compat shim: every is_designer() caller now means "is plain admin".
function is_designer(): bool {
return is_admin_role();
}
function auth_set_session(array $user): void {
// Rotate the session id on every privilege change (login, SSO resolve,
// password reset). Defeats session fixation: an attacker who pre-seeded
// FOTO_SESS now ends up holding a dead id while the real user gets a fresh
// one. The CSRF token is rotated alongside the session id.
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
unset($_SESSION['csrf_token']);
}
$_SESSION['user_id'] = $user['id'];
$_SESSION['user'] = [
'id' => $user['id'],
'name' => $user['name'],
'email' => $user['email'],
'role' => $user['role'],
// Carried so sso_resolve_session() can detect when the am_sso cookie
// identifies a different person than the current local session.
'andmore_id' => isset($user['andmore_id']) ? (int) $user['andmore_id'] : null,
];
}
+2
View File
@@ -0,0 +1,2 @@
# Cache files are PHP includes — never serve directly over HTTP.
Require all denied
+78
View File
@@ -0,0 +1,78 @@
<?php
// ─── CodeIgniter 4 view-helper compatibility shim ────────────────────────────
// Lets the original fitme3070 view partials (site/partials/*) run byte-for-byte
// under the new plain-PHP structure. Only the helpers the ported templates
// actually use are implemented. base_url() comes from config/functions.php.
if (!defined('SITE_INCLUDES')) {
define('SITE_INCLUDES', dirname(__DIR__) . '/site/includes');
}
// <script src> / <link rel=stylesheet> tag builders (CI4 html helper).
function script_tag(string $src): string {
return '<script src="' . htmlspecialchars(base_url($src), ENT_QUOTES) . '"></script>' . "\n";
}
function link_tag(string $href, string $rel = 'stylesheet'): string {
return '<link rel="' . $rel . '" href="' . htmlspecialchars(base_url($href), ENT_QUOTES) . '">' . "\n";
}
function site_url(string $path = ''): string { return base_url($path); }
function esc($value, string $context = 'html'): string {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
// Render a partial like CI4's view(): extract $data + the shared page vars
// (info / navbar / current_uri_2 via $GLOBALS['_view_shared']) and return the
// buffered HTML. Lets partials read $info without threading it through callers.
function view(string $name, array $data = []): string {
$shared = $GLOBALS['_view_shared'] ?? [];
extract($shared);
extract($data);
ob_start();
include SITE_INCLUDES . '/' . $name . '.php';
return ob_get_clean();
}
// Minimal session() shim — the templates only read getFlashdata().
// Per-request singleton: the constructor's flash sweep therefore runs once per
// request, which is what makes flashdata actually "flash".
function session(): _FitSession {
static $s = null;
if ($s === null) $s = new _FitSession();
return $s;
}
class _FitSession {
// CI4-style flashdata lifecycle. A value set with setFlashdata() in request N
// (the POST handler, just before its redirect) must be readable in request
// N+1 (the redirect target) and then disappear — otherwise the banner sticks
// around forever, surviving reloads, because nothing ever clears it.
//
// We track an age marker per key: setFlashdata() marks it 'new'; on the next
// request the sweep ages 'new' → 'old' (still readable), and on the request
// after that it deletes anything 'old'. The sweep runs once, here, before any
// read or write this request (setFlashdata() is itself the first session()
// call in a POST handler, so the constructor always precedes it).
public function __construct() {
foreach ($_SESSION['_flash_age'] ?? [] as $k => $age) {
if ($age === 'old') {
unset($_SESSION['_flash'][$k], $_SESSION['_flash_age'][$k]);
} else {
$_SESSION['_flash_age'][$k] = 'old';
}
}
// Sweep orphans left by the previous (never-expiring) shim: any flash
// value with no age marker is stale and would otherwise stick forever.
foreach (array_keys($_SESSION['_flash'] ?? []) as $k) {
if (!isset($_SESSION['_flash_age'][$k])) unset($_SESSION['_flash'][$k]);
}
if (empty($_SESSION['_flash'])) unset($_SESSION['_flash']);
if (empty($_SESSION['_flash_age'])) unset($_SESSION['_flash_age']);
}
public function getFlashdata(string $key) { return $_SESSION['_flash'][$key] ?? null; }
public function setFlashdata(string $key, $val): void {
$_SESSION['_flash'][$key] = $val;
$_SESSION['_flash_age'][$key] = 'new';
}
public function get(string $key) { return $_SESSION[$key] ?? null; }
public function set(string $key, $val): void { $_SESSION[$key] = $val; }
}
+63
View File
@@ -0,0 +1,63 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// Static, secret-bearing configuration. Operational settings (APP_NAME, base
// URL, email-from headers, reminder_hours, Nextcloud URL/token, logo filenames)
// live in the `system_settings` table — see config/functions_settings.php and
// views/sysadmin/system_settings.php. Only credentials and tokens stay here so
// DB dumps never expose them.
//
// Values come from the .env file (loaded by config/db.php via config/env.php
// before this file runs). The fallback in each env() call keeps the platform
// running if .env is missing — never put real production secrets as defaults.
// ─────────────────────────────────────────────────────────────────────────────
// ─── Environment ──────────────────────────────────────────────────────────────
define('APP_ENV', env('APP_ENV', 'prod'));
// Email/SMS delivery — including the dev redirect/print safety net — now lives at
// id.andmore.pt (/notify). See config/functions_notify_client.php. NOTIFY_API_KEY
// (in .env) authenticates this app to the central service.
if (APP_ENV !== 'prod') {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(E_ALL); // still catch everything, just don't echo it
}
require_once __DIR__ . '/error_handler.php';
// ─── Identity / Andmore ID ────────────────────────────────────────────────────
// IDENTITY_MODE: 'standalone' (default) or 'federated'. In federated mode every
// register/login syncs the user to the central andmore_id DB so users.andmore_id
// is always populated and cross-platform relationships work by canonical id.
define('IDENTITY_MODE', strtolower(env('IDENTITY_MODE', 'standalone')) === 'federated' ? 'federated' : 'standalone');
// Slug identifying this install on andmore_id.linked_platforms. Used to scope
// link tokens and resolve the satellite's display name on the central verify page.
define('PLATFORM_SLUG', (string) env('PLATFORM_SLUG', 'fitme3070'));
define('ANDMORE_ID_URL', rtrim(env('ANDMORE_ID_URL', 'https://id.andmore.pt'), '/') . '/');
// Parent domain the am_sso cookie is scoped to (e.g. .andmore.pt). Empty in
// standalone mode — federated mode requires a non-empty value.
define('SSO_COOKIE_DOMAIN', (string) env('SSO_COOKIE_DOMAIN', ''));
// Cookie name. Override per environment when prod and dev share a parent
// domain (e.g. SSO_COOKIE_NAME=am_sso_dev in dev) so the prod cookie on
// .andmore.pt doesn't clobber the dev session and vice versa.
define('SSO_COOKIE_NAME', (string) env('SSO_COOKIE_NAME', 'am_sso'));
define('SSO_SESSION_TTL_DAYS', (int) env('SSO_SESSION_TTL_DAYS', 30));
// When true (federated mode only): local login/register pages redirect to
// Andmore ID; require_login() sends unauthenticated users directly there.
define('SSO_REQUIRED', filter_var(env('SSO_REQUIRED', 'false'), FILTER_VALIDATE_BOOLEAN));
// When true (federated mode only): a register/login attempt fails with a clear
// error if the central DB is unreachable, instead of silently falling through
// and creating an orphan local row. Standalone mode ignores this.
define('IDENTITY_REQUIRE_UPSTREAM', filter_var(env('IDENTITY_REQUIRE_UPSTREAM', 'false'), FILTER_VALIDATE_BOOLEAN));
function identity_is_federated(): bool { return IDENTITY_MODE === 'federated'; }
function identity_is_standalone(): bool { return IDENTITY_MODE === 'standalone'; }
function identity_require_upstream(): bool { return identity_is_federated() && IDENTITY_REQUIRE_UPSTREAM; }
+64
View File
@@ -0,0 +1,64 @@
<?php
require_once __DIR__ . '/env.php';
env_load(__DIR__ . '/../.env');
// The legacy CI4 front controller ran from the web root, so some ported view
// partials use root-relative paths (e.g. gallery's glob('uploads/gallery/...')).
// Pin the CWD to the project root so those keep resolving regardless of which
// script Apache invoked.
@chdir(dirname(__DIR__));
// ─── Bootstrap failure helper ────────────────────────────────────────────────
// Renders a generic 500 page. Never echoes exception details — they often
// contain the host:port, DB name, and (worst case) credentials. Real details
// land in logs/db_error.log; the page just says "something went wrong".
function _bootstrap_fail(string $logMsg): void {
@mkdir(__DIR__ . '/../logs', 0775, true);
@error_log('[' . date('c') . '] ' . $logMsg . "\n", 3, __DIR__ . '/../logs/db_error.log');
error_log('[fotografia bootstrap] ' . $logMsg);
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
$tpl = __DIR__ . '/../templates/error_500.php';
if (is_file($tpl)) {
require $tpl;
} else {
echo '<!doctype html><meta charset="utf-8"><title>Erro</title>'
. '<p style="font-family:sans-serif;text-align:center;margin:80px auto;">Algo correu mal. Tente novamente em instantes.</p>';
}
exit(1);
}
// Required env vars — fail loud if any is missing. Hard-coded fallback creds
// (especially `root` / `admin`) were a footgun: a missing .env would let the
// app boot with the wrong identity instead of refusing to start.
foreach (['DB_NAME', 'DB_USER'] as $req) {
if (getenv($req) === false || getenv($req) === '') {
_bootstrap_fail("required env var {$req} is not set");
}
}
if (getenv('DB_PASS') === false) {
_bootstrap_fail('required env var DB_PASS is not set (empty string is allowed)');
}
$host = env('DB_HOST', '127.0.0.1');
$port = (int) env('DB_PORT', 3306);
$db = env('DB_NAME');
$user = env('DB_USER');
$pass = env('DB_PASS');
$dsn = "mysql:host=$host;port=$port;dbname=$db;charset=utf8mb4";
try {
$pdo = new PDO($dsn, $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
_bootstrap_fail('PDO connect failed: ' . $e->getMessage());
}
// Distinct session cookie per app so installs that share a host (e.g.
// development.andmore.pt/id.andmore.pt/ and /fitme3070.pt/) don't overwrite
// each other's PHPSESSID. Must run before session_start().
if (session_status() === PHP_SESSION_NONE) {
session_name('FIT_SESS');
session_start();
}
+56
View File
@@ -0,0 +1,56 @@
<?php
// ─── .env loader + env() helper ──────────────────────────────────────────────
//
// Lightweight, dependency-free reader for KEY=value files. Loaded by
// config/db.php BEFORE any other include so credentials and APP_ENV are
// available everywhere. Real environment variables (PHP-FPM pool, Apache
// SetEnv, shell exports) win over .env values — never overwritten.
//
// Format: one KEY=value per line, # for comments, optional surrounding quotes.
function env_load(string $path): void {
if (!is_file($path)) return;
// No is_readable() pre-check: it returns false negatives on Windows
// network mounts. Attempt the read and report only a real failure.
$lines = @file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
error_log("env_load: .env file exists at {$path} but could not be read by the PHP process. Fix with: chmod 640 {$path}");
return;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
if (strpos($line, '=') === false) continue;
[$k, $v] = array_pad(explode('=', $line, 2), 2, '');
$k = trim($k);
if ($k === '') continue;
// Strip inline comments only when value isn't quoted.
$v = trim($v);
if ($v !== '' && $v[0] !== '"' && $v[0] !== "'") {
$hash = strpos($v, ' #');
if ($hash !== false) $v = rtrim(substr($v, 0, $hash));
}
$v = trim($v, " \"'");
// Don't overwrite real environment variables — they take precedence.
if (getenv($k) === false) {
putenv("$k=$v");
$_ENV[$k] = $v;
}
}
}
function env(string $key, $default = null) {
$v = getenv($key);
return $v === false ? $default : $v;
}
// True when the current request is HTTPS — including when TLS terminates at a
// reverse proxy. Behind a proxy you MUST set TRUSTED_PROXY=true in .env or the
// X-Forwarded-Proto header is ignored (otherwise any client could spoof it).
function request_is_https(): bool {
if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') return true;
if (($_SERVER['SERVER_PORT'] ?? '') === '443') return true;
$trusted = filter_var(env('TRUSTED_PROXY', 'false'), FILTER_VALIDATE_BOOLEAN);
if ($trusted && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') return true;
return false;
}
+153
View File
@@ -0,0 +1,153 @@
<?php
// ─── Global error / exception handler ────────────────────────────────────────
//
// Registered early in config/config.php (after APP_ENV is defined).
// Prod: renders a clean branded page, hides all technical detail.
// Dev: renders the same page but expands a debug panel with full trace.
// All errors are written to logs/php_errors.jsonl (max 500 entries, rotated).
// ─────────────────────────────────────────────────────────────────────────────
define('ERROR_LOG_FILE', __DIR__ . '/../logs/php_errors.jsonl');
define('ERROR_LOG_MAX', 500);
function _error_log_write(string $type, string $message, string $file = '', int $line = 0, string $trace = ''): void {
$userId = isset($_SESSION['user_id']) ? (int) $_SESSION['user_id'] : null;
$userName = isset($_SESSION['user']['name']) ? (string) $_SESSION['user']['name'] : null;
$url = (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '') ;
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$entry = json_encode([
'ts' => date('c'),
'type' => $type,
'message' => $message,
'file' => $file,
'line' => $line,
'trace' => $trace,
'user_id' => $userId,
'user_name' => $userName,
'url' => $url,
'ip' => $ip,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$logFile = ERROR_LOG_FILE;
// Append then rotate if over limit.
file_put_contents($logFile, $entry . "\n", FILE_APPEND | LOCK_EX);
// Trim to last ERROR_LOG_MAX lines when the file grows large.
if (filesize($logFile) > 256 * 1024) {
$lines = file($logFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (count($lines) > ERROR_LOG_MAX) {
$lines = array_slice($lines, -ERROR_LOG_MAX);
file_put_contents($logFile, implode("\n", $lines) . "\n", LOCK_EX);
}
}
}
function _error_page(string $title, string $detail, string $trace = ''): void {
$isProd = defined('APP_ENV') && APP_ENV === 'prod';
$cssBase = (defined('APP_BASE_URL') ? rtrim(APP_BASE_URL, '/') : '');
http_response_code(500);
if (ob_get_level()) ob_end_clean();
?>
<!doctype html>
<html lang="pt">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Erro — <?= htmlspecialchars(defined('APP_NAME') ? APP_NAME : 'Plataforma') ?></title>
<?php if ($cssBase):
$_eh_brand = (function_exists('env') ? env('BRAND', 'andmore') : 'andmore') ?: 'andmore';
?>
<link rel="stylesheet" href="<?= htmlspecialchars($cssBase) ?>/assets/css/branding-<?= htmlspecialchars($_eh_brand) ?>.css">
<link rel="stylesheet" href="<?= htmlspecialchars($cssBase) ?>/assets/css/style.css">
<?php endif; ?>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
.err-card { max-width: 540px; width: 100%; text-align: center; }
.err-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .6; }
.err-debug { text-align: left; font-size: .78rem; font-family: monospace;
white-space: pre-wrap; word-break: break-all;
max-height: 320px; overflow-y: auto;
background: rgba(0,0,0,.25); border-radius: 6px; padding: 1rem; margin-top: 1rem; }
</style>
</head>
<body>
<div class="err-card">
<div class="err-icon"><i class="bi bi-exclamation-triangle"></i></div>
<h2 class="mb-2">Algo correu mal</h2>
<?php if ($isProd): ?>
<p class="text-muted">Ocorreu um erro inesperado. Por favor tente novamente ou contacte o suporte.</p>
<?php else: ?>
<p class="text-muted"><?= htmlspecialchars($title) ?></p>
<?php if ($detail): ?>
<div class="err-debug"><?= htmlspecialchars($detail) ?></div>
<?php endif; ?>
<?php if ($trace): ?>
<div class="err-debug" style="margin-top:.5rem; opacity:.7;"><?= htmlspecialchars($trace) ?></div>
<?php endif; ?>
<?php endif; ?>
<div class="mt-4 d-flex gap-2 justify-content-center flex-wrap">
<button onclick="history.back()" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Voltar</button>
<a href="javascript:location.reload()" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Tentar novamente</a>
</div>
</div>
</body>
</html>
<?php
exit(1);
}
// ── Uncaught exceptions ───────────────────────────────────────────────────────
set_exception_handler(function (Throwable $e): void {
_error_log_write(
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
);
_error_page(
get_class($e) . ': ' . $e->getMessage(),
$e->getMessage() . "\n\nFicheiro: " . $e->getFile() . ':' . $e->getLine(),
$e->getTraceAsString()
);
});
// ── PHP errors ────────────────────────────────────────────────────────────────
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
if (!(error_reporting() & $errno)) return false;
$label = match(true) {
(bool)($errno & E_NOTICE) => 'Notice',
(bool)($errno & E_WARNING) => 'Warning',
(bool)($errno & E_DEPRECATED) => 'Deprecated',
(bool)($errno & E_USER_NOTICE) => 'User Notice',
(bool)($errno & E_USER_WARNING) => 'User Warning',
(bool)($errno & E_USER_DEPRECATED)=> 'User Deprecated',
default => "Error ($errno)",
};
_error_log_write($label, $errstr, $errfile, $errline);
// In prod, only show error page for fatal-level user errors.
if (defined('APP_ENV') && APP_ENV === 'prod') {
$fatal = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR;
if (!($errno & $fatal)) return false;
}
_error_page("PHP $label: $errstr", "$errstr\n\nFicheiro: $errfile:$errline");
return true;
});
// ── Fatal errors (parse errors, out-of-memory, etc.) ─────────────────────────
register_shutdown_function(function (): void {
$e = error_get_last();
if (!$e) return;
$fatal = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;
if (!($e['type'] & $fatal)) return;
_error_log_write('Fatal', $e['message'], $e['file'], $e['line']);
_error_page("Fatal Error: {$e['message']}", "{$e['message']}\n\nFicheiro: {$e['file']}:{$e['line']}");
});
+96
View File
@@ -0,0 +1,96 @@
<?php
// ─── Bootstrap chain ─────────────────────────────────────────────────────────
// Loaded by every page right after config/db.php (which opens $pdo and starts
// the FIT_SESS session). Mirrors the fotografia/Andmore fleet shape, trimmed to
// what fitme3070 needs this pass: settings, generic CRUD, auth + SSO, the
// migrations runner, and the centralized notify façade. Domain CRUD
// (plataforma_fitness, team, modalidades, horário, galerias) is added per
// feature in a follow-up under config/functions_*.php.
if (session_status() === PHP_SESSION_NONE) session_start();
require_once __DIR__ . '/config.php'; // APP_ENV + identity/SSO consts + error_handler
require_once __DIR__ . '/functions_settings.php'; // setting() + system_settings cache
require_once __DIR__ . '/functions_db.php'; // generic DB helpers
require_once __DIR__ . '/functions_user.php'; // backoffice user creators (setup + admin/staff)
require_once __DIR__ . '/auth.php'; // require_login/admin/staff/sysadmin(), role helpers
require_once __DIR__ . '/sso.php'; // sso_resolve_session() + upstream link/cookie
require_once __DIR__ . '/functions_migrations.php';// platform_update.php migrations runner
// Outbound delivery is centralized at id.andmore.pt — no local PHPMailer/SMTP.
require_once __DIR__ . '/functions_notify_client.php'; // notify_remote_send()
require_once __DIR__ . '/functions_queue.php'; // enqueue_*/flush_message façade → relay
require_once __DIR__ . '/functions_csrf.php';
require_once __DIR__ . '/ci_compat.php'; // CI4 view-helper shim (script_tag/link_tag/view/esc/session)
require_once __DIR__ . '/functions_site.php'; // public-site data (site_info/offer/team/modalidades/horario)
require_once __DIR__ . '/functions_images.php'; // upload resize/compress (gallery/team/modalidades caps)
require_once __DIR__ . '/functions_team.php'; // team CRUD
require_once __DIR__ . '/functions_modalidades.php'; // modalidades CRUD
require_once __DIR__ . '/functions_galerias.php'; // galleries + images CRUD
require_once __DIR__ . '/functions_work_hours.php'; // work hours CRUD
require_once __DIR__ . '/functions_horario.php'; // class schedule CRUD
// ─── DB-backed settings → legacy constants ───────────────────────────────────
// Resolved once from the settings cache so call sites read plain constants.
define('APP_NAME', (string) setting('app_name', 'FitMe 3070'));
// APP_BASE_URL is per-install (deployment topology). .env only — never the DB,
// so a reused dump can't drag another install's URL in.
// Auto-detect from the request if .env doesn't have it (dev/staging safety net).
$_aburl = env('APP_BASE_URL', '');
if ($_aburl === '' && isset($_SERVER['HTTP_HOST'])) {
$scriptDir = dirname($_SERVER['SCRIPT_NAME'] ?? '');
$_aburl = (request_is_https() ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . rtrim($scriptDir, '/') . '/';
}
define('APP_BASE_URL', $_aburl);
define('EMAIL_FROM', (string) setting('email_from', 'noreply@andmore.pt'));
define('EMAIL_FROM_NAME', (string) setting('email_from_name', 'FitMe 3070'));
define('EMAIL_SUBJECT_PREFIX', (string) setting('email_subject_prefix', '[FitMe 3070] '));
// ─── URL helper ───────────────────────────────────────────────────────────────
// Always use for Location: redirects and internal links.
function base_url(string $path = ''): string {
return APP_BASE_URL . ltrim($path, '/');
}
// ─── Date helper (PT-PT) ──────────────────────────────────────────────────────
function format_date_pt(string $date): string {
if (!$date) return '';
return (new DateTime($date))->format('d/m/Y');
}
// ─── Email template loader ────────────────────────────────────────────────────
// {{placeholder}} HTML bodies under templates/emails/. Built locally, delivered
// by id.andmore.pt via the enqueue_email()/flush_message() façade.
function load_email_template(string $template, array $vars): string {
$path = __DIR__ . '/../templates/emails/' . $template . '.html';
if (!is_file($path)) {
throw new RuntimeException("Email template not found: {$template}");
}
$html = file_get_contents($path);
$brand = htmlspecialchars(APP_NAME, ENT_QUOTES, 'UTF-8');
$logo = (string) setting('logo_light_filename', '');
$logoTag = $logo !== ''
? '<img src="' . htmlspecialchars(base_url('assets/img/logo/' . rawurlencode($logo)), ENT_QUOTES, 'UTF-8')
. '" alt="' . $brand . '" style="max-height:60px;border:0;">'
: '<p style="margin:0;color:#fff;font-size:22px;font-weight:bold;letter-spacing:1px;">' . $brand . '</p>';
$html = str_replace('{{logo_html}}', $logoTag, $html);
foreach ($vars as $key => $value) {
$html = str_replace('{{' . $key . '}}', htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'), $html);
}
return $html;
}
// ─── User-agent prettifier (last-login display) ──────────────────────────────
// Mirrors fotografia's helper so views/user/index.php can show a short label.
function parse_user_agent(string $ua): string {
if ($ua === '') return '—';
$browser = 'Navegador';
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Chrome' => 'Chrome', 'Firefox' => 'Firefox', 'Safari' => 'Safari'] as $needle => $label) {
if (stripos($ua, $needle) !== false) { $browser = $label; break; }
}
$os = 'SO';
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $label) {
if (stripos($ua, $needle) !== false) { $os = $label; break; }
}
return "$browser / $os";
}
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../shared/platform-tools/functions_backup.php';
+54
View File
@@ -0,0 +1,54 @@
<?php
// ─── CSRF protection ─────────────────────────────────────────────────────────
// One token per PHP session, embedded in every POST form. The token is also
// usable in AJAX via the X-CSRF-Token header.
//
// Usage in a view: emit csrf_field() inside any <form method="POST">, e.g.
// using a short echo tag right after the form opening. csrf_field() returns
// the full hidden input markup.
//
// Usage in a POST handler (before any side-effects): call csrf_check(). On
// failure it sends 403 and exits — never returns to the caller.
//
// SameSite=Lax already blocks the common "image-form" cross-site POST attack,
// but does NOT protect against same-site XSS or an attacker that controls any
// sibling subdomain under the .andmore.pt cookie scope. That's what this is
// for.
//
// NOTE: do NOT include the literal short-echo close-tag inside comments in
// this file. PHP closes the script block at the first such sequence even when
// it appears inside a // comment, and any whitespace after it gets emitted as
// HTML output — which then breaks header() with "headers already sent".
function csrf_token(): string {
if (empty($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string {
return '<input type="hidden" name="_csrf" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES) . '">';
}
function csrf_check(): void {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') return;
$supplied = $_POST['_csrf']
?? $_SERVER['HTTP_X_CSRF_TOKEN']
?? '';
$expected = $_SESSION['csrf_token'] ?? '';
if (!is_string($supplied) || $expected === '' || !hash_equals($expected, $supplied)) {
http_response_code(403);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><meta charset="utf-8"><title>Sessão expirada</title>'
. '<div style="font-family:sans-serif;max-width:520px;margin:80px auto;text-align:center;">'
. '<h2>Sessão expirada</h2>'
. '<p>Recarregue a página e tente novamente.</p>'
. '<p><a href="javascript:history.back()">← Voltar</a></p>'
. '</div>';
exit;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
// ─── Generic DB helpers ──────────────────────────────────────────────────────
// Per the fleet convention, page files never write raw SQL — they call helpers
// here (and in feature-specific config/functions_*.php files added later). This
// pass ships only the user/auth-adjacent helpers needed by the SSO shell and
// the admin/user dashboards; domain CRUD (plataforma_fitness, team, modalidades,
// horário, galerias) lands with the follow-up port.
// Fetch a single user row by id (NULL if missing).
function db_get_user(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
// Fetch a single user row by email (canonical SSO key).
function db_get_user_by_email(PDO $pdo, string $email): ?array {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
$stmt->execute([strtolower(trim($email))]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
// All backoffice users (sysadmin + admin + staff) for management lists.
function db_get_all_staff(PDO $pdo): array {
return $pdo->query(
"SELECT * FROM users
WHERE role IN ('sysadmin','admin','staff')
ORDER BY FIELD(role,'sysadmin','admin','staff'), name"
)->fetchAll(PDO::FETCH_ASSOC);
}
// Active admins (sysadmin + admin) — e.g. assignment dropdowns.
function db_get_active_admins(PDO $pdo): array {
return $pdo->query(
"SELECT * FROM users
WHERE role IN ('sysadmin','admin') AND is_active = 1
ORDER BY name"
)->fetchAll(PDO::FETCH_ASSOC);
}
// PT-PT label for the role ladder.
function role_label(string $role): string {
return [
'sysadmin' => 'Administrador de sistema',
'admin' => 'Administrador',
'staff' => 'Equipa',
'client' => 'Cliente',
][$role] ?? ucfirst($role);
}
+74
View File
@@ -0,0 +1,74 @@
<?php
// ─── Galleries & images CRUD helpers ───────────────────────────────────────────
// galleries — gallery names (id, name)
// gallery — images (i_id, i_file_name, i_file_type, i_project_id, i_created_at)
// Also uses `images` table for standalone uploaded images (img_id, img_file_name).
function db_gallery_list(PDO $pdo): array {
return $pdo->query("SELECT * FROM galleries ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
}
function db_gallery_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM galleries WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_gallery_create(PDO $pdo, string $name): int {
$stmt = $pdo->prepare("INSERT INTO galleries (name) VALUES (?)");
$stmt->execute([$name]);
return (int) $pdo->lastInsertId();
}
function db_gallery_update(PDO $pdo, int $id, string $name): bool {
$stmt = $pdo->prepare("UPDATE galleries SET name = ? WHERE id = ?");
$stmt->execute([$name, $id]);
return $stmt->rowCount() > 0;
}
function db_gallery_delete(PDO $pdo, int $id): bool {
// Delete associated images first, then the gallery record.
$pdo->prepare("DELETE FROM gallery WHERE i_project_id = ?")->execute([$id]);
$stmt = $pdo->prepare("DELETE FROM galleries WHERE id = ?");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
// Gallery images
function db_gallery_images(PDO $pdo, int $gallery_id): array {
$stmt = $pdo->prepare("SELECT * FROM gallery WHERE i_project_id = ? ORDER BY i_created_at DESC");
$stmt->execute([$gallery_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string {
if ($file['error'] !== UPLOAD_ERR_OK) return null;
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array($ext, $allowed)) return null;
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
$filename = bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) return null;
img_optimize($upload_dir . $filename, IMG_MAX_GALLERY);
$stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)");
$stmt->execute([$filename, $file['type'] ?? 'image/jpeg', $gallery_id]);
return $filename;
}
function db_gallery_image_delete(PDO $pdo, int $image_id, int $gallery_id): bool {
$stmt = $pdo->prepare("SELECT i_file_name FROM gallery WHERE i_id = ? AND i_project_id = ? LIMIT 1");
$stmt->execute([$image_id, $gallery_id]);
$img = $stmt->fetch(PDO::FETCH_ASSOC);
if ($img) {
$path = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/' . $img['i_file_name'];
if (is_file($path)) @unlink($path);
}
$stmt = $pdo->prepare("DELETE FROM gallery WHERE i_id = ? AND i_project_id = ?");
$stmt->execute([$image_id, $gallery_id]);
return $stmt->rowCount() > 0;
}
+86
View File
@@ -0,0 +1,86 @@
<?php
// ─── Class schedule (Horário de Aulas) CRUD helpers ────────────────────────────
// Joins horario → modalidades for display.
function db_horario_list(PDO $pdo): array {
return $pdo->query("
SELECT h.*,
m.nome AS modalidade_nome, m.cor AS modalidade_cor, m.icon AS modalidade_icon,
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio_fmt,
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade
ORDER BY FIELD(h.dia_semana,'segunda','terça','quarta','quinta','sexta','sábado','domingo'),
h.hora_inicio ASC
")->fetchAll(PDO::FETCH_ASSOC);
}
function db_horario_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("
SELECT h.*,
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio_fmt,
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim_fmt,
m.nome AS modalidade_nome
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade
WHERE h.id = ? LIMIT 1
");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_horario_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("
INSERT INTO horario (id_modalidade, dia_semana, hora_inicio, hora_fim, local)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([
(int) $data['id_modalidade'],
$data['dia_semana'],
$data['hora_inicio'],
$data['hora_fim'],
$data['local'] ?? '',
]);
return (int) $pdo->lastInsertId();
}
function db_horario_update(PDO $pdo, int $id, array $data): bool {
$stmt = $pdo->prepare("
UPDATE horario
SET id_modalidade = ?, dia_semana = ?, hora_inicio = ?, hora_fim = ?, local = ?
WHERE id = ?
");
$stmt->execute([
(int) $data['id_modalidade'],
$data['dia_semana'],
$data['hora_inicio'],
$data['hora_fim'],
$data['local'] ?? '',
$id,
]);
return $stmt->rowCount() > 0;
}
function db_horario_delete(PDO $pdo, int $id): bool {
$stmt = $pdo->prepare("DELETE FROM horario WHERE id = ?");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
// Reschedule only: day + start/end time (used by the calendar drag-and-drop).
// Modalidade and local are left untouched. Times must be HH:MM[:SS]; the caller
// is responsible for validating the day against the allowed set.
function db_horario_move(PDO $pdo, int $id, string $dia_semana, string $hora_inicio, string $hora_fim): bool {
$stmt = $pdo->prepare("
UPDATE horario SET dia_semana = ?, hora_inicio = ?, hora_fim = ? WHERE id = ?
");
$stmt->execute([$dia_semana, $hora_inicio, $hora_fim, $id]);
return $stmt->rowCount() >= 0;
}
// Modalidades dropdown (id => nome + cor) for horario create/edit forms.
function db_modalidades_options(PDO $pdo): array {
return $pdo->query("
SELECT m_id, nome, cor FROM modalidades WHERE deleted_at IS NULL ORDER BY nome
")->fetchAll(PDO::FETCH_ASSOC);
}
+154
View File
@@ -0,0 +1,154 @@
<?php
// ─── Image optimization helpers ────────────────────────────────────────────────
// Resizes raster images IN PLACE so the longest side fits the per-area cap and
// re-encodes at web quality. Called on every upload (galerias, team,
// modalidades) and by the "Otimizar" buttons in the backoffice, which run the
// same pass over files uploaded before this existed.
//
// Idempotent by design: images already within the cap are left untouched, so
// the bulk buttons can be pressed repeatedly without generation loss.
// SVGs and animated GIFs are skipped (GD would rasterize/flatten them).
// Requires the GD extension; degrades to a no-op (status "failed") without it,
// so an upload never breaks because optimization couldn't run.
// Caps per area — longest side, in pixels.
const IMG_MAX_GALLERY = 1920;
const IMG_MAX_TEAM = 512;
const IMG_MAX_MODALIDADE = 256;
const IMG_JPEG_QUALITY = 82;
const IMG_WEBP_QUALITY = 80;
// Resize one file in place. Returns ['status' => 'resized'|'skipped'|'failed',
// 'saved' => bytes freed (0 unless resized), 'reason' => short PT label].
function img_optimize(string $path, int $max_dim): array {
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
if (!is_file($path)) return $fail('ficheiro não encontrado');
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
$info = @getimagesize($path);
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
[$w, $h] = $info;
$type = $info[2];
$supported = [IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_GIF];
if (function_exists('imagecreatefromwebp')) $supported[] = IMAGETYPE_WEBP;
if (!in_array($type, $supported, true)) return $skip('formato não suportado');
// GIFs animados perdiam a animação no GD — não tocar.
if ($type === IMAGETYPE_GIF
&& substr_count((string) file_get_contents($path), "\x21\xF9\x04") > 1) {
return $skip('GIF animado');
}
// Orientação EXIF: fotos de telemóvel guardadas "deitadas" contam para o
// lado maior depois de rodadas, por isso resolve-se antes do teste de tamanho.
$rotation = 0;
if ($type === IMAGETYPE_JPEG && function_exists('exif_read_data')) {
$orientation = (int) (@exif_read_data($path)['Orientation'] ?? 1);
$rotation = [3 => 180, 6 => -90, 8 => 90][$orientation] ?? 0;
if (abs($rotation) === 90) [$w, $h] = [$h, $w];
}
if ($w <= $max_dim && $h <= $max_dim && $rotation === 0) {
return $skip('já dentro do limite');
}
// Imagens grandes podem exceder o memory_limit ao descodificar.
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
$src = match ($type) {
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
IMAGETYPE_PNG => @imagecreatefrompng($path),
IMAGETYPE_GIF => @imagecreatefromgif($path),
IMAGETYPE_WEBP => @imagecreatefromwebp($path),
default => false,
};
if (!$src) return $fail('imagem ilegível');
if ($rotation !== 0) {
$rotated = imagerotate($src, $rotation, 0);
if ($rotated) { imagedestroy($src); $src = $rotated; }
$w = imagesx($src); $h = imagesy($src);
}
$scale = min(1, $max_dim / max($w, $h, 1));
$nw = max(1, (int) round($w * $scale));
$nh = max(1, (int) round($h * $scale));
$dst = imagecreatetruecolor($nw, $nh);
if ($type === IMAGETYPE_PNG || $type === IMAGETYPE_WEBP) {
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
} elseif ($type === IMAGETYPE_GIF) {
$tidx = imagecolortransparent($src);
if ($tidx >= 0 && $tidx < imagecolorstotal($src)) {
$t = imagecolorsforindex($src, $tidx);
$newt = imagecolorallocate($dst, $t['red'], $t['green'], $t['blue']);
imagefilledrectangle($dst, 0, 0, $nw, $nh, $newt);
imagecolortransparent($dst, $newt);
}
}
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
imagedestroy($src);
$tmp = $path . '.opt.tmp';
if ($type === IMAGETYPE_JPEG) imageinterlace($dst, true); // JPEG progressivo
$ok = match ($type) {
IMAGETYPE_JPEG => imagejpeg($dst, $tmp, IMG_JPEG_QUALITY),
IMAGETYPE_PNG => imagepng($dst, $tmp, 9),
IMAGETYPE_GIF => imagegif($dst, $tmp),
IMAGETYPE_WEBP => imagewebp($dst, $tmp, IMG_WEBP_QUALITY),
default => false,
};
imagedestroy($dst);
if (!$ok || !is_file($tmp)) { @unlink($tmp); return $fail('falha ao gravar'); }
$before = (int) filesize($path);
$after = (int) filesize($tmp);
if (!@rename($tmp, $path)) { // alguns FS não substituem no rename
@unlink($path);
if (!@rename($tmp, $path)) { @unlink($tmp); return $fail('falha ao substituir'); }
}
return ['status' => 'resized', 'saved' => max(0, $before - $after), 'reason' => "{$w}×{$h} → {$nw}×{$nh}"];
}
// Run img_optimize() over every raster file directly inside $dir (no recursion).
// Returns aggregate counters: resized / skipped / failed / saved (bytes).
function img_optimize_dir(string $dir, int $max_dim): array {
@set_time_limit(0);
$stats = ['resized' => 0, 'skipped' => 0, 'failed' => 0, 'saved' => 0];
if (!is_dir($dir)) return $stats;
foreach (scandir($dir) ?: [] as $file) {
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue;
$r = img_optimize($path, $max_dim);
$stats[$r['status']]++;
$stats['saved'] += $r['saved'];
}
return $stats;
}
// PT-PT flash message for the bulk buttons.
function img_stats_message(array $s): string {
$parts = [];
if ($s['resized']) {
$msg = $s['resized'] . ' imagem(ns) redimensionada(s)';
if ($s['saved'] > 0) $msg .= ' — poupados ' . img_human_bytes($s['saved']);
$parts[] = $msg;
}
if ($s['skipped']) $parts[] = $s['skipped'] . ' já dentro do limite ou não aplicável';
if ($s['failed']) $parts[] = $s['failed'] . ' com falha';
return $parts ? 'Otimização concluída: ' . implode(', ', $parts) . '.' : 'Nenhuma imagem encontrada.';
}
function img_human_bytes(int $bytes): string {
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '') . ' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
return $bytes . ' B';
}
+2
View File
@@ -0,0 +1,2 @@
<?php
require_once __DIR__ . '/../shared/platform-tools/functions_migrations.php';
+134
View File
@@ -0,0 +1,134 @@
<?php
// ─── Modalidades CRUD helpers ──────────────────────────────────────────────────
// Soft-delete via deleted_at. Each row has a colour, icon (flaticon name),
// description, and item_order for sorting.
function db_modalidades_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM modalidades";
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
$sql .= " ORDER BY item_order DESC, nome";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
function db_modalidades_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM modalidades WHERE m_id = ? LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_modalidades_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("
INSERT INTO modalidades (nome, cor, icon, descricao, item_order)
VALUES (?, ?, ?, ?, ?)
");
$stmt->execute([
$data['nome'],
$data['cor'] ?? '#808080',
$data['icon'] ?? '',
$data['descricao'] ?? '',
(int) ($data['item_order'] ?? 0),
]);
return (int) $pdo->lastInsertId();
}
function db_modalidades_update(PDO $pdo, int $id, array $data): bool {
$stmt = $pdo->prepare("
UPDATE modalidades
SET nome = ?, cor = ?, icon = ?, descricao = ?, item_order = ?
WHERE m_id = ?
");
$stmt->execute([
$data['nome'],
$data['cor'] ?? '#808080',
$data['icon'] ?? '',
$data['descricao'] ?? '',
(int) ($data['item_order'] ?? 0),
$id,
]);
return $stmt->rowCount() > 0;
}
function db_modalidades_delete(PDO $pdo, int $id): bool {
$stmt = $pdo->prepare("UPDATE modalidades SET deleted_at = NOW() WHERE m_id = ? AND deleted_at IS NULL");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
function db_modalidades_restore(PDO $pdo, int $id): bool {
$stmt = $pdo->prepare("UPDATE modalidades SET deleted_at = NULL WHERE m_id = ? AND deleted_at IS NOT NULL");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
// All flaticon names (populated from the flaticon table).
function db_flaticon_list(PDO $pdo): array {
try {
return $pdo->query("SELECT flat_id, name FROM flaticon ORDER BY name")->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
// ─── Icon image upload / removal ───────────────────────────────────────────────
// The `icon` column holds a filename served from uploads/modalidades/icons/ on
// both the backoffice list and the public site. That folder doubles as a shared
// flaticon library (numeric-prefixed names like 012-yoga.png) referenced by the
// "Ícones disponíveis" picker, so this CRUD must never delete a file it didn't
// create. Custom uploads are saved with an `upl_` prefix; modalidades_icon_remove()
// only ever unlinks `upl_*` files, leaving the library untouched.
function modalidades_icon_dir(): string {
return __DIR__ . '/../uploads/modalidades/icons/';
}
// Moves an uploaded icon into the icons dir and returns the stored filename.
// Returns null when no file was submitted (not an error) or on failure — in the
// failure case $error is set with a user-facing reason.
function modalidades_icon_upload(array $file, ?string &$error = null): ?string {
$error = null;
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
return null; // nothing uploaded — leave the existing icon alone
}
if ($file['error'] !== UPLOAD_ERR_OK) {
$error = 'Falha no upload da imagem (código ' . (int) $file['error'] . ').';
return null;
}
if (($file['size'] ?? 0) > 2 * 1024 * 1024) {
$error = 'A imagem excede o limite de 2 MB.';
return null;
}
if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
$error = 'Upload inválido.';
return null;
}
$allowed = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) {
$error = 'Formato inválido. Permitidos: ' . implode(', ', $allowed) . '.';
return null;
}
$dir = modalidades_icon_dir();
if (!is_dir($dir)) @mkdir($dir, 0775, true);
// Readable, collision-proof, recognisable-as-custom filename.
$slug = preg_replace('/[^a-z0-9]+/', '-', strtolower(pathinfo($file['name'], PATHINFO_FILENAME)));
$slug = substr(trim((string) $slug, '-'), 0, 40);
if ($slug === '') $slug = 'icon';
$filename = 'upl_' . $slug . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $dir . $filename)) {
$error = 'Não foi possível guardar a imagem.';
return null;
}
img_optimize($dir . $filename, IMG_MAX_MODALIDADE); // no-op em SVG
return $filename;
}
// Deletes a custom-uploaded icon from disk. No-ops for empty names and for
// shared-library icons (only `upl_*` files are ever removed). basename() guards
// against path traversal in stored values.
function modalidades_icon_remove(?string $filename): void {
$filename = basename((string) $filename);
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
$path = modalidades_icon_dir() . $filename;
if (is_file($path)) @unlink($path);
}
+50
View File
@@ -0,0 +1,50 @@
<?php
// Client for the centralized notification service at id.andmore.pt (/notify).
//
// fotografia no longer sends mail/SMS itself — delivery is centralized. This is
// the single egress point: it POSTs a message to {ANDMORE_ID_URL}notify/send.php
// authenticated with the per-app key (NOTIFY_API_KEY, minted on id.andmore.pt →
// Notificações → Chaves de app). The legacy enqueue_email()/flush_message() pair
// in config/functions_queue.php is now a thin façade over this.
//
// The app key is sent in the JSON body (not a header) to dodge Apache stripping
// the Authorization header on some setups.
function notify_remote_send(array $payload): array {
$base = defined('ANDMORE_ID_URL') ? ANDMORE_ID_URL : rtrim((string) env('ANDMORE_ID_URL', ''), '/') . '/';
$key = (string) env('NOTIFY_API_KEY', '');
if ($base === '' || $key === '') {
error_log('notify_remote_send: ANDMORE_ID_URL or NOTIFY_API_KEY not configured');
return ['ok' => false, 'error' => 'notify_not_configured'];
}
$payload['app_key'] = $key;
$url = rtrim($base, '/') . '/notify/send.php';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$resp = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) {
error_log("notify_remote_send: transport error ($err) calling $url");
return ['ok' => false, 'error' => 'transport_error'];
}
$data = json_decode($resp, true);
if (!is_array($data)) {
error_log("notify_remote_send: bad response (HTTP $code) from $url: " . substr((string) $resp, 0, 300));
return ['ok' => false, 'error' => 'bad_response', 'http' => $code];
}
if (empty($data['ok'])) {
error_log("notify_remote_send: notify error (HTTP $code): " . ($data['error'] ?? '?'));
}
return $data;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
// Outbound delivery — now centralized at id.andmore.pt (/notify).
//
// fotografia no longer runs its own SMTP/n8n queue. The local `message_queue`
// table, the Monitor UI (views/sysadmin/messages.php) and the cron drainer
// (api/process_queue.php) were removed when delivery merged into id.andmore.pt.
//
// This file is a thin compatibility façade so the dozens of existing call sites
// keep working unchanged. They use the historical two-step contract:
// $id = enqueue_email([...]); // stage the message
// return flush_message($id); // send it (now: relay to id.andmore.pt)
//
// enqueue_*() stages the payload in-request; flush_message() ships it through
// notify_remote_send() (see config/functions_notify_client.php) and returns the
// real sent/failed result. Bulk callers that loop enqueue+flush still work.
$GLOBALS['_notify_staged'] = [];
$GLOBALS['_notify_seq'] = 0;
function enqueue_email(array $data): int { return _notify_stage('email', $data); }
function enqueue_sms(array $data): int { return _notify_stage('sms', $data); }
function _notify_stage(string $channel, array $data): int {
$id = ++$GLOBALS['_notify_seq'];
$GLOBALS['_notify_staged'][$id] = ['channel' => $channel] + $data;
return $id;
}
// Relay a staged message to id.andmore.pt and return true on success. The local
// `to_user_id` is intentionally NOT forwarded — it's a fotografia id, not a
// canonical id.andmore.pt user id (the central queue FK would reject it). The
// recipient address + this app's key are enough; the central log records origin.
function flush_message(int $id): bool {
$data = $GLOBALS['_notify_staged'][$id] ?? null;
if (!$data) return false;
unset($GLOBALS['_notify_staged'][$id]);
$channel = $data['channel'] ?? 'email';
$payload = [
'channel' => $channel,
'to' => $data['to_address'] ?? '',
'to_name' => $data['to_name'] ?? null,
'body' => $data['body'] ?? '',
];
if ($channel === 'email') {
// Preserve the historical subject prefix that the old SMTP path applied.
$prefix = defined('EMAIL_SUBJECT_PREFIX') ? EMAIL_SUBJECT_PREFIX : '';
$payload['subject'] = $prefix . ($data['subject'] ?? '');
$payload['alt_body'] = $data['alt_body'] ?? null;
}
if (!empty($data['context_type'])) {
$payload['context'] = ['type' => $data['context_type'], 'id' => $data['context_id'] ?? null];
}
if (!empty($data['scheduled_for'])) {
$payload['scheduled_for'] = $data['scheduled_for'];
}
$r = notify_remote_send($payload);
return !empty($r['ok']) && (($r['status'] ?? '') !== 'failed');
}
// No local queue to drain anymore. Kept as a harmless no-op so any stray caller
// (or scheduled task that wasn't updated) doesn't fatal.
function process_queue(int $limit = 20): array {
return ['sent' => 0, 'failed' => 0, 'total' => 0];
}
+91
View File
@@ -0,0 +1,91 @@
<?php
// ─── DB-backed system settings with file-based cache ─────────────────────────
//
// Settings live in the `system_settings` table (key→value). To avoid a query
// on every page load, the full set is cached as a PHP array literal under
// config/cache/settings.cache.php — read via require, served from opcache
// in memory. The cache is invalidated (file deleted) on every db_update_setting().
//
// Public API:
// setting($key, $default = null) — cached read
// settings_all() — cached array
// db_get_all_settings($pdo) — bypasses cache
// db_update_setting($pdo, $key, $value, $userId = null) — UPSERT + invalidate
//
// $pdo is read from the global scope only by db_*() helpers; setting() and
// settings_all() never touch the DB once the cache exists.
function _settings_cache_path(): string {
return __DIR__ . '/cache/settings.cache.php';
}
function settings_cache_clear(): void {
$path = _settings_cache_path();
if (is_file($path)) {
@unlink($path);
}
}
function settings_cache_write(array $data): void {
$path = _settings_cache_path();
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
$code = "<?php\nreturn " . var_export($data, true) . ";\n";
if (@file_put_contents($tmp, $code, LOCK_EX) === false) {
return;
}
@rename($tmp, $path);
if (function_exists('opcache_invalidate')) {
@opcache_invalidate($path, true);
}
}
function db_get_all_settings(PDO $pdo): array {
$rows = $pdo->query("SELECT setting_key, setting_value FROM system_settings")
->fetchAll(PDO::FETCH_KEY_PAIR);
return $rows ?: [];
}
function settings_cache_load(): array {
static $loaded = null;
if ($loaded !== null) {
return $loaded;
}
$path = _settings_cache_path();
if (is_file($path)) {
$data = @include $path;
if (is_array($data)) {
return $loaded = $data;
}
}
global $pdo;
if (!($pdo instanceof PDO)) {
return $loaded = [];
}
$data = db_get_all_settings($pdo);
settings_cache_write($data);
return $loaded = $data;
}
function settings_all(): array {
return settings_cache_load();
}
function setting(string $key, $default = null) {
$data = settings_cache_load();
return array_key_exists($key, $data) ? $data[$key] : $default;
}
function db_update_setting(PDO $pdo, string $key, string $value, ?int $userId = null): void {
$s = $pdo->prepare(
"INSERT INTO system_settings (setting_key, setting_value, updated_by)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value),
updated_by = VALUES(updated_by)"
);
$s->execute([$key, $value, $userId]);
settings_cache_clear();
}
+95
View File
@@ -0,0 +1,95 @@
<?php
// ─── Public-site data (plain-PDO replacements for the old CI4 models) ─────────
// The ported website/* partials read the same shapes the CI4 models returned.
// Every query is defensive — the gym-content tables can be empty on a fresh DB.
// fitme3070_info row (id=1). Always returns every key the views read, so empty
// installs render without warnings.
function site_info(PDO $pdo): array {
$cols = ['id','name','adress','phone','telephone','whatsapp','instagram',
'facebook','messenger','email','website','google_maps','google_maps_link'];
$row = [];
try {
$row = $pdo->query("SELECT * FROM fitme3070_info WHERE id = 1 LIMIT 1")
->fetch(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) { $row = []; }
foreach ($cols as $c) {
if (!array_key_exists($c, $row) || $row[$c] === null) $row[$c] = '';
}
return $row;
}
// Top navigation map (label => CI-style path). Kept identical to the original
// so navbar/footer markup is unchanged; .htaccess rewrites these to site/*.php.
function site_navbar(): array {
// One-page layout: Home, Equipa, Modalidades, Horário, Contactos are all
// anchor links on the single homepage. CrossFit is a separate page styled
// differently (see navbar.php for the 'crossfit_btn' flag).
return [
'Home' => '#home',
'Equipa' => '#equipa',
'Modalidades' => '#modalidades',
'Horário' => '#horario',
'Contactos' => '#contactos',
'CrossFit' => 'home/crossfit', // separate page, navbar styles it specially
];
}
function site_offer(PDO $pdo): array {
try {
return $pdo->query(
"SELECT * FROM website_offer
WHERE o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = ''"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
function site_work_hours(PDO $pdo): array {
try { return $pdo->query("SELECT * FROM work_hours")->fetchAll(PDO::FETCH_ASSOC); }
catch (Throwable $e) { return []; }
}
function site_team(PDO $pdo): array {
try { return $pdo->query("SELECT * FROM team WHERE deleted_at IS NULL")->fetchAll(PDO::FETCH_ASSOC); }
catch (Throwable $e) { return []; }
}
function site_modalidades(PDO $pdo): array {
try {
return $pdo->query(
"SELECT * FROM modalidades WHERE deleted_at IS NULL ORDER BY item_order DESC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (Throwable $e) { return []; }
}
// ─── Image-path resolver ──────────────────────────────────────────────────────
// DB stores either a bare filename ("1.jpg") or a full relative path
// ("uploads/team/foto/1.jpg"). This normalises both to a working URL.
// $base_dir is the upload subdirectory (e.g. "uploads/team/foto").
function site_img_url($value, $base_dir): string {
// Coerce null/empty to empty string
$value = (string) $value;
if ($value === '') return '';
// Already a full URL
if (substr($value, 0, 4) === 'http') return $value;
// Already has the uploads/ prefix — use as-is
if (substr($value, 0, 8) === 'uploads/') return base_url($value);
// Bare filename — prepend the base directory
return base_url(rtrim((string) $base_dir, '/') . '/' . ltrim($value, '/'));
}
// Schedule joined to modalidades (name/colour/icon), optionally filtered by
// local (e.g. 'crossfit'). Mirrors Horario_model::getAll().
function site_horario(PDO $pdo, string $local = ''): array {
$sql = "SELECT h.*,
TIME_FORMAT(h.hora_inicio, '%H:%i') AS hora_inicio,
TIME_FORMAT(h.hora_fim, '%H:%i') AS hora_fim,
m.nome, m.cor, m.icon
FROM horario h
JOIN modalidades m ON m.m_id = h.id_modalidade";
$args = [];
if ($local !== '') { $sql .= " WHERE h.local = ?"; $args[] = $local; }
$sql .= " ORDER BY h.hora_inicio ASC, m.nome ASC";
try { $st = $pdo->prepare($sql); $st->execute($args); return $st->fetchAll(PDO::FETCH_ASSOC); }
catch (Throwable $e) { return []; }
}
+64
View File
@@ -0,0 +1,64 @@
<?php
// ─── Team CRUD helpers ─────────────────────────────────────────────────────────
// Soft-delete via deleted_at. All functions accept PDO $pdo as first argument.
// Used by views/admin/team/*.php
function db_team_list(PDO $pdo, bool $include_deleted = false): array {
$sql = "SELECT * FROM team";
if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL";
$sql .= " ORDER BY FIELD(status,'ativo','inativo'), name";
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
function db_team_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM team WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_team_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("
INSERT INTO team (name, birthday, `at`, status, description, photo, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$stmt->execute([
$data['name'],
$data['birthday'] ?: null,
$data['at'] ?: null,
$data['status'] ?? 'ativo',
$data['description'] ?? null,
$data['photo'] ?? null,
]);
return (int) $pdo->lastInsertId();
}
function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null): bool {
$sets = ["name = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?", "updated_at = NOW()"];
$params = [
$data['name'],
$data['birthday'] ?: null,
$data['at'] ?: null,
$data['status'] ?? 'ativo',
$data['description'] ?? null,
];
if ($photo !== null) {
$sets[] = "photo = ?";
$params[] = $photo;
}
$params[] = $id;
$stmt = $pdo->prepare("UPDATE team SET " . implode(', ', $sets) . " WHERE id = ?");
$stmt->execute($params);
return $stmt->rowCount() > 0;
}
function db_team_delete(PDO $pdo, int $id): bool {
$stmt = $pdo->prepare("UPDATE team SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
function db_team_restore(PDO $pdo, int $id): bool {
$stmt = $pdo->prepare("UPDATE team SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
+81
View File
@@ -0,0 +1,81 @@
<?php
// ─── Backoffice user creation ────────────────────────────────────────────────
// This install is SSO-only — clients authenticate on id.andmore.pt and their
// local row is provisioned by sso_resolve_session() (config/sso.php). The only
// users created locally are backoffice accounts (sysadmin / admin / staff):
// - auth_create_sysadmin() → first-install bootstrap (views/auth/setup.php)
// - auth_create_admin()/staff() → admin-vouched, link the central Andmore ID
// row immediately so the new person can SSO on any sibling .andmore.pt site.
//
// parse_user_agent() lives in config/functions.php; db_get_all_staff() /
// db_get_active_admins() live in config/functions_db.php — not redefined here.
// True once any sysadmin/admin row exists — gates views/auth/setup.php.
function admin_exists(PDO $pdo): bool {
return (bool) $pdo->query(
"SELECT 1 FROM users WHERE role IN ('sysadmin','admin') LIMIT 1"
)->fetchColumn();
}
// Creates a sysadmin (full access incl. backups + platform update). Local-only
// password row; federated linkage happens on first SSO by email match.
function auth_create_sysadmin(PDO $pdo, string $name, string $email, string $password): int {
$stmt = $pdo->prepare("
INSERT INTO users (name, email, password_hash, role)
VALUES (?, ?, ?, 'sysadmin')
");
$stmt->execute([
$name,
strtolower(trim($email)),
password_hash($password, PASSWORD_DEFAULT),
]);
return (int) $pdo->lastInsertId();
}
// Returns ['id'=>int] on success, or ['error'=>'andmore_email_taken'|'unavailable'].
function auth_create_admin(PDO $pdo, string $name, string $email, string $phone, string $password): array {
return _create_backoffice_user($pdo, 'admin', $name, $email, $phone, $password);
}
function auth_create_staff(PDO $pdo, string $name, string $email, string $phone, string $password): array {
return _create_backoffice_user($pdo, 'staff', $name, $email, $phone, $password);
}
// Internal: create an admin/staff row and, in federated mode, link it to the
// central Andmore ID (admin-vouched — the local password is mirrored upstream
// so SSO_REQUIRED=true never locks out the new user). See config/sso.php
// identity_upstream_link_immediate() for the upstream rules.
function _create_backoffice_user(PDO $pdo, string $role, string $name, string $email, string $phone, string $password): array {
if (!in_array($role, ['admin', 'staff'], true)) {
throw new InvalidArgumentException("Invalid backoffice role: {$role}");
}
$email = strtolower(trim($email));
$canonicalId = null;
if (identity_is_federated()) {
$link = identity_upstream_link_immediate($email, $name, $phone, $password);
if (($link['error'] ?? '') === 'andmore_email_taken') {
return ['error' => 'andmore_email_taken'];
}
if (($link['error'] ?? '') === 'unavailable' && identity_require_upstream()) {
return ['error' => 'unavailable'];
}
if (isset($link['canonical_id'])) {
$canonicalId = (int) $link['canonical_id'];
}
}
$stmt = $pdo->prepare("
INSERT INTO users (name, email, phone, password_hash, role, andmore_id)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$name,
$email,
$phone ?: null,
password_hash($password, PASSWORD_DEFAULT),
$role,
$canonicalId,
]);
return ['id' => (int) $pdo->lastInsertId()];
}
+34
View File
@@ -0,0 +1,34 @@
<?php
// ─── Work hours (Horário de Funcionamento) CRUD helpers ────────────────────────
// Simple table: id, week_day (enum), open (time), close (time).
function db_whours_list(PDO $pdo): array {
return $pdo->query("
SELECT * FROM work_hours
ORDER BY FIELD(week_day,'segunda','terça','quarta','quinta','sexta','sabado','domingo','feriados')
")->fetchAll(PDO::FETCH_ASSOC);
}
function db_whours_get(PDO $pdo, int $id): ?array {
$stmt = $pdo->prepare("SELECT * FROM work_hours WHERE id = ? LIMIT 1");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function db_whours_create(PDO $pdo, array $data): int {
$stmt = $pdo->prepare("INSERT INTO work_hours (week_day, open, close) VALUES (?, ?, ?)");
$stmt->execute([$data['week_day'], $data['open'], $data['close']]);
return (int) $pdo->lastInsertId();
}
function db_whours_update(PDO $pdo, int $id, array $data): bool {
$stmt = $pdo->prepare("UPDATE work_hours SET week_day = ?, open = ?, close = ? WHERE id = ?");
$stmt->execute([$data['week_day'], $data['open'], $data['close'], $id]);
return $stmt->rowCount() > 0;
}
function db_whours_delete(PDO $pdo, int $id): bool {
$stmt = $pdo->prepare("DELETE FROM work_hours WHERE id = ?");
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
+286
View File
@@ -0,0 +1,286 @@
<?php
// Identity / SSO integration with id.andmore.pt.
//
// Two modes, picked by IDENTITY_MODE in .env:
//
// standalone — every function in this file is a no-op (returns null/false).
// Local users.id is the only identity that matters.
// federated — register/login flows on this install also write to the central
// andmore_id DB so users.andmore_id is always populated. The
// am_sso cookie is issued by this install so the user lands
// already-logged-in on any other .andmore.pt platform.
//
// Direct DB access is used rather than HTTP for now — easier and matches how
// sso_resolve_session() already works. Swap to an HTTP API later if the
// platforms stop sharing a DB host.
// SSO_COOKIE_NAME is defined in config/config.php (defaults to 'am_sso',
// override via env so prod and dev can coexist on the same parent domain).
// Cached central-DB connection — federated calls reuse it within a request.
function _sso_open_pdo(): ?PDO {
static $cached = null;
if ($cached !== null) return $cached ?: null; // false sentinel = tried and failed
if (!identity_is_federated()) { $cached = false; return null; }
$host = env('SSO_DB_HOST') ?: env('DB_HOST', '127.0.0.1');
$port = (int) (env('SSO_DB_PORT') ?: env('DB_PORT', 3306));
$name = env('SSO_DB_NAME', '');
$user = env('SSO_DB_USER') ?: env('DB_USER', 'root');
$pass = env('SSO_DB_PASS') ?: env('DB_PASS', '');
if (!$name) { $cached = false; return null; }
try {
$cached = new PDO(
"mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4",
$user, $pass,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
return $cached;
} catch (PDOException $e) {
error_log('[sso] DB connection failed: ' . $e->getMessage());
$cached = false;
return null;
}
}
// Called by require_login() (and login.php / register.php) before any
// redirect-to-login fallback. When a valid am_sso cookie is present it
// makes sure the local PHP session reflects that exact identity:
// - same andmore_id as the cookie → no-op (fast path).
// - different andmore_id (or no andmore_id) → clear the local session and
// re-provision from the SSO row so a stale Ana-Paula-style login doesn't
// mask the real federated user.
// No-op in standalone mode (the parent-domain cookie wouldn't reach this
// install anyway).
function sso_resolve_session(PDO $pdo): void {
if (!identity_is_federated()) return;
$token = $_COOKIE[SSO_COOKIE_NAME] ?? '';
if (strlen($token) !== 64 || !ctype_xdigit($token)) return;
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return;
try {
$stmt = $ssoPdo->prepare("
SELECT s.user_id, u.email, u.name, u.phone, u.is_active
FROM sso_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ?
AND s.expires_at > NOW()
AND u.is_active = 1
LIMIT 1
");
$stmt->execute([$token]);
$ssoRow = $stmt->fetch();
} catch (PDOException $e) {
error_log('[sso] token lookup failed: ' . $e->getMessage());
return;
}
if (!$ssoRow) return;
$canonicalId = (int) $ssoRow['user_id'];
$email = strtolower(trim((string) $ssoRow['email']));
// If a local session is already set, decide whether to keep it or replace.
if (!empty($_SESSION['user_id'])) {
$sessionAndmoreId = (int) ($_SESSION['user']['andmore_id'] ?? 0);
if ($sessionAndmoreId === $canonicalId) {
return; // Same person — keep the existing session.
}
// Either: (a) session is for an unrelated andmore_id (user switched
// identities at id.andmore.pt), or (b) session is for a local-only
// user that never got linked. In both cases the SSO cookie reflects
// the real intent — clear the stale identity bits and re-provision.
unset($_SESSION['user_id'], $_SESSION['user']);
}
// ─── Find or create the local row ────────────────────────────────────
// Lookup order: andmore_id (fast, set on prior logins) → email (canonical
// identity per the SSO-only model). The email branch claims any matching
// row regardless of password_hash — the central platform vouches for the
// email so there is no "another person owns this address" scenario to
// worry about. UNIQUE(email) on the users table guarantees at most one
// match.
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
$stmt->execute([$canonicalId]);
$user = $stmt->fetch();
if (!$user) {
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$user = $stmt->fetch();
if (!$user) {
// No local row yet — provision a fresh client row stamped with
// andmore_id. Role default = client; staff promotion happens by
// an admin editing the row later.
try {
$pdo->prepare("
INSERT INTO users (name, email, phone, role, andmore_id, is_active)
VALUES (?, ?, ?, 'client', ?, 1)
")->execute([
(string) $ssoRow['name'],
$email,
$ssoRow['phone'] ?: null,
$canonicalId,
]);
} catch (PDOException $e) {
error_log('[sso] provisioning insert failed: ' . $e->getMessage());
return;
}
$stmt = $pdo->prepare("SELECT * FROM users WHERE andmore_id = ? LIMIT 1");
$stmt->execute([$canonicalId]);
$user = $stmt->fetch();
}
}
if (!$user || !$user['is_active']) return;
// ─── Sync per-tenant row with central data ───────────────────────────
// Stamp the link if missing, refresh name/phone when the central record
// has a newer value. Local role is preserved — id.andmore.pt does not
// know about per-tenant roles.
$patch = [];
if (empty($user['andmore_id']) || (int) $user['andmore_id'] !== $canonicalId) {
$patch['andmore_id'] = $canonicalId;
}
if (!empty($ssoRow['name']) && $ssoRow['name'] !== $user['name']) {
$patch['name'] = (string) $ssoRow['name'];
}
if (!empty($ssoRow['phone']) && $ssoRow['phone'] !== ($user['phone'] ?? '')) {
$patch['phone'] = (string) $ssoRow['phone'];
}
if ($patch) {
$set = implode(', ', array_map(fn ($k) => "$k = ?", array_keys($patch)));
$pdo->prepare("UPDATE users SET $set WHERE id = ?")
->execute([...array_values($patch), (int) $user['id']]);
$user = array_merge($user, $patch);
}
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
$pdo->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ?, last_login_ua = ? WHERE id = ?")
->execute([$ip, $ua, $user['id']]);
auth_set_session($user);
}
// ─── Upstream sync (federated mode) ──────────────────────────────────────────
//
// Only the admin-vouched immediate link + the cookie issuer remain. The older
// two-phase verify-email flow (request_link / redeem_pending / has_pending)
// was used by the local register/login pages, which were removed when the
// platform became SSO-only. sso_resolve_session() does the email-match work
// directly now — no token round-trip needed.
// Admin-vouched immediate link. Used by auth_create_admin() / auth_create_staff()
// when a sysadmin/admin creates a backoffice user on this satellite. Instead of
// the verify-email round-trip, we trust the admin's vouching: the password the
// admin set on the local form is also written to the central row so the new
// person can immediately use SSO on any sibling .andmore.pt platform.
//
// - No central row for this email → create central row WITH the password
// (admin-set, email_verified_at stamped).
// - Passwordless central row exists → claim it AND set the password.
// - Passworded central row exists → refuse with 'andmore_email_taken'.
// An admin can't silently reset
// someone else's Andmore ID.
//
// Outcomes:
// ['canonical_id' => int] ok — stamp this on the local row
// ['error' => 'andmore_email_taken'] central row exists with a password
// ['error' => 'unavailable'] central DB unreachable
// ['error' => 'standalone'] IDENTITY_MODE != federated
function identity_upstream_link_immediate(string $email, string $name, ?string $phone, ?string $password = null): array {
if (!identity_is_federated()) return ['error' => 'standalone'];
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return ['error' => 'unavailable'];
$email = strtolower(trim($email));
$hash = ($password !== null && $password !== '')
? password_hash($password, PASSWORD_DEFAULT)
: null;
try {
$stmt = $ssoPdo->prepare("SELECT id, password_hash FROM users WHERE email = ? LIMIT 1");
$stmt->execute([$email]);
$row = $stmt->fetch();
if ($row) {
if (!empty($row['password_hash'])) {
return ['error' => 'andmore_email_taken'];
}
// Passwordless placeholder — claim it AND set the admin-vouched password.
if ($hash !== null) {
$ssoPdo->prepare("
UPDATE users
SET name = COALESCE(NULLIF(?, ''), name),
phone = COALESCE(NULLIF(?, ''), phone),
password_hash = ?,
email_verified_at = COALESCE(email_verified_at, NOW())
WHERE id = ?
")->execute([$name, (string) $phone, $hash, (int) $row['id']]);
}
return ['canonical_id' => (int) $row['id']];
}
$ssoPdo->prepare("
INSERT INTO users (name, email, phone, password_hash, email_verified_at)
VALUES (?, ?, ?, ?, ?)
")->execute([
$name,
$email,
$phone,
$hash,
$hash !== null ? date('Y-m-d H:i:s') : null,
]);
return ['canonical_id' => (int) $ssoPdo->lastInsertId()];
} catch (PDOException $e) {
error_log('[sso] upstream link-immediate failed: ' . $e->getMessage());
return ['error' => 'unavailable'];
}
}
// Issue an am_sso cookie scoped to the parent domain by inserting a row into
// the central sso_sessions table. Lets the user land already-logged-in on any
// other .andmore.pt platform. No-op when standalone or when SSO_COOKIE_DOMAIN
// isn't set.
function identity_upstream_issue_cookie(int $canonicalUserId): void {
if (!identity_is_federated() || SSO_COOKIE_DOMAIN === '') return;
$ssoPdo = _sso_open_pdo();
if (!$ssoPdo) return;
try {
$token = bin2hex(random_bytes(32));
$ttl = max(1, SSO_SESSION_TTL_DAYS) * 86400;
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 512) ?: null;
$ssoPdo->prepare("
INSERT INTO sso_sessions (token, user_id, expires_at, ip, ua, last_seen_at)
VALUES (?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND), ?, ?, NOW())
")->execute([$token, $canonicalUserId, $ttl, $ip, $ua]);
setcookie(SSO_COOKIE_NAME, $token, [
'expires' => time() + $ttl,
'path' => '/',
'domain' => SSO_COOKIE_DOMAIN,
// request_is_https() respects X-Forwarded-Proto when TRUSTED_PROXY=true
// — needed so the Secure flag isn't dropped behind nginx/Cloudflare.
'secure' => request_is_https(),
'httponly' => true,
'samesite' => 'Lax',
]);
} catch (PDOException $e) {
error_log('[sso] upstream cookie issue failed: ' . $e->getMessage());
}
}