sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal file
277
projects/msp-tools/quote-wizard/php-api/api/helpers.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared utility functions for the MSP Quote Wizard API.
|
||||
*
|
||||
* Provides UUID generation, token generation, JSON response helpers,
|
||||
* input validation, CORS headers, and logging.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// UUID / Token generation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a UUID v4 string (lowercase, 36 chars with hyphens).
|
||||
*
|
||||
* Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
* where y is one of 8, 9, a, b.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function generate_uuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
|
||||
// Set version to 4 (0100 in binary)
|
||||
$bytes[6] = chr(ord($bytes[6]) & 0x0f | 0x40);
|
||||
// Set variant to RFC 4122 (10xx in binary)
|
||||
$bytes[8] = chr(ord($bytes[8]) & 0x3f | 0x80);
|
||||
|
||||
return sprintf(
|
||||
'%s-%s-%s-%s-%s',
|
||||
bin2hex(substr($bytes, 0, 4)),
|
||||
bin2hex(substr($bytes, 4, 2)),
|
||||
bin2hex(substr($bytes, 6, 2)),
|
||||
bin2hex(substr($bytes, 8, 2)),
|
||||
bin2hex(substr($bytes, 10, 6))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-safe access token matching Python's secrets.token_urlsafe(32).
|
||||
*
|
||||
* Produces a 43-character base64url-encoded string (no padding) from 32
|
||||
* random bytes, exactly matching the Python implementation.
|
||||
*
|
||||
* @return string 43-character URL-safe token
|
||||
*/
|
||||
function generate_access_token(): string
|
||||
{
|
||||
$bytes = random_bytes(32);
|
||||
// base64url encode: replace +/ with -_, strip padding =
|
||||
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// JSON response helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Send a JSON response with the given data and HTTP status code.
|
||||
*
|
||||
* Sets Content-Type header, outputs JSON, and terminates the script.
|
||||
*
|
||||
* @param mixed $data Data to encode as JSON.
|
||||
* @param int $status HTTP status code (default 200).
|
||||
* @return never
|
||||
*/
|
||||
function json_response($data, int $status = 200): void
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON error response.
|
||||
*
|
||||
* @param string $message Error message.
|
||||
* @param int $status HTTP status code (default 400).
|
||||
* @param mixed|null $details Additional error details.
|
||||
* @return never
|
||||
*/
|
||||
function error_response(string $message, int $status = 400, $details = null): void
|
||||
{
|
||||
$body = ['detail' => $message];
|
||||
if ($details !== null) {
|
||||
$body['errors'] = $details;
|
||||
}
|
||||
json_response($body, $status);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Request parsing
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse the JSON request body.
|
||||
*
|
||||
* @return array Decoded JSON as an associative array.
|
||||
*/
|
||||
function get_json_body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if (empty($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
error_response('Invalid JSON in request body', 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client IP address, accounting for reverse proxies.
|
||||
*
|
||||
* Checks X-Forwarded-For first, then X-Real-IP, then REMOTE_ADDR.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
function get_client_ip(): ?string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$parts = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
return trim($parts[0]);
|
||||
}
|
||||
|
||||
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
|
||||
return trim($_SERVER['HTTP_X_REAL_IP']);
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the User-Agent header value.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
function get_user_agent(): ?string
|
||||
{
|
||||
return $_SERVER['HTTP_USER_AGENT'] ?? null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CORS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Emit CORS headers based on the configured allowed origins.
|
||||
*
|
||||
* For preflight (OPTIONS) requests, this also sets the allowed methods
|
||||
* and headers, then terminates the script with 204.
|
||||
*/
|
||||
function cors_headers(): void
|
||||
{
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed = array_map('trim', explode(',', CORS_ALLOWED_ORIGINS));
|
||||
|
||||
// Allow the origin if it matches our whitelist, or allow all if '*'
|
||||
if (in_array('*', $allowed, true) || in_array($origin, $allowed, true)) {
|
||||
$send_origin = in_array('*', $allowed, true) ? '*' : $origin;
|
||||
header("Access-Control-Allow-Origin: {$send_origin}");
|
||||
}
|
||||
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
|
||||
// Handle preflight
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Validation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate that all required fields are present and non-empty in the data.
|
||||
*
|
||||
* @param array $data Associative array of input data.
|
||||
* @param string[] $fields List of required field names.
|
||||
* @return string[] Array of error messages (empty if valid).
|
||||
*/
|
||||
function validate_required(array $data, array $fields): array
|
||||
{
|
||||
$errors = [];
|
||||
foreach ($fields as $field) {
|
||||
if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
|
||||
$errors[] = "Field '{$field}' is required.";
|
||||
}
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an email address.
|
||||
*
|
||||
* @param string $email Email address to validate.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
function validate_email(string $email): bool
|
||||
{
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Logging
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Append a message to the application log file.
|
||||
*
|
||||
* @param string $level Log level (INFO, WARNING, ERROR).
|
||||
* @param string $message Log message.
|
||||
*/
|
||||
function app_log(string $level, string $message): void
|
||||
{
|
||||
$dir = dirname(LOG_FILE);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
}
|
||||
|
||||
$timestamp = gmdate('Y-m-d\TH:i:s\Z');
|
||||
$line = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;
|
||||
@file_put_contents(LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Datetime helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format a datetime value for JSON output (ISO 8601 format).
|
||||
*
|
||||
* Accepts a datetime string from MySQL (Y-m-d H:i:s) and returns
|
||||
* an ISO 8601 string, or null if input is null/empty.
|
||||
*
|
||||
* @param string|null $dt MySQL datetime string.
|
||||
* @return string|null ISO 8601 formatted string.
|
||||
*/
|
||||
function format_datetime(?string $dt): ?string
|
||||
{
|
||||
if ($dt === null || $dt === '' || $dt === '0000-00-00 00:00:00') {
|
||||
return null;
|
||||
}
|
||||
// MySQL DATETIME is already in UTC for this application
|
||||
$ts = strtotime($dt);
|
||||
if ($ts === false) {
|
||||
return null;
|
||||
}
|
||||
return gmdate('Y-m-d\TH:i:s\Z', $ts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current UTC datetime in MySQL format.
|
||||
*
|
||||
* @return string Y-m-d H:i:s
|
||||
*/
|
||||
function utc_now(): string
|
||||
{
|
||||
return gmdate('Y-m-d H:i:s');
|
||||
}
|
||||
Reference in New Issue
Block a user