Files
casadoc/public/userportal/api/_bootstrap.php
T
2026-07-28 19:46:02 +03:00

421 lines
15 KiB
PHP

<?php
// Mobile API bootstrap: PDO, bearer auth, JSON helpers, ownership checks, presenters.
// Included first in every endpoint. No Laravel/session.
declare(strict_types=1);
$config = require __DIR__ . '/config.php';
// Never leak PHP errors into the response; any uncaught throwable becomes a JSON 500.
ini_set('display_errors', '0');
set_exception_handler(function (Throwable $e): void {
error_log('[casadoc-api] ' . $e);
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode(['error' => ['code' => 'error', 'message' => 'Internal server error']]);
});
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Authorization, Content-Type');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
http_response_code(204);
exit;
}
try {
$pdo = new PDO(
"mysql:host={$config['db_host']};port={$config['db_port']};dbname={$config['db_name']};charset=utf8mb4",
$config['db_user'],
$config['db_pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => ['code' => 'error', 'message' => 'Database connection failed']]);
exit;
}
function json_data(mixed $data, int $code = 200): never
{
http_response_code($code);
echo json_encode(['data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function json_ok(): never
{
echo json_encode(['success' => true]);
exit;
}
/** $errorCode overrides the status-derived code, e.g. code_expired or email_not_verified. */
function json_error(int $code, string $message, ?array $fields = null, ?string $errorCode = null): never
{
static $codes = [
400 => 'bad_request', 401 => 'unauthorized', 403 => 'forbidden',
404 => 'not_found', 405 => 'method_not_allowed', 422 => 'validation',
];
http_response_code($code);
echo json_encode(['error' => array_filter([
'code' => $errorCode ?? ($codes[$code] ?? 'error'),
'message' => $message,
'fields' => $fields,
], fn ($v) => $v !== null)], JSON_UNESCAPED_UNICODE);
exit;
}
function require_method(string $method): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== $method) {
json_error(405, "Method not allowed, use {$method}");
}
}
// Request body: JSON or form-data.
function body(): array
{
$raw = json_decode(file_get_contents('php://input') ?: '', true);
if (is_array($raw)) {
return $raw;
}
return $_POST;
}
function query(string $key, mixed $default = null): mixed
{
return $_GET[$key] ?? $default;
}
function bearer_token(): ?string
{
$header = $_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '';
if (!$header && function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
if (strcasecmp($k, 'Authorization') === 0) {
$header = $v;
break;
}
}
}
return preg_match('/Bearer\s+(\S+)/i', $header, $m) ? $m[1] : null;
}
// Validate the token and return the auth_users row, or respond 401.
function require_auth(PDO $pdo): array
{
$token = bearer_token();
if (!$token) {
json_error(401, 'Missing bearer token');
}
$stmt = $pdo->prepare(
'SELECT u.* FROM api_tokens t
JOIN auth_users u ON u.id = t.user_id
WHERE t.token = ? AND (t.expires_at IS NULL OR t.expires_at > NOW())
LIMIT 1'
);
$stmt->execute([hash('sha256', $token)]);
$user = $stmt->fetch();
if (!$user) {
json_error(401, 'Invalid or expired token');
}
$pdo->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE token = ?')
->execute([hash('sha256', $token)]);
return $user;
}
/**
* Uploads are scans: PDF or images. The type is sniffed from the content, not from the
* client-declared Content-Type (mobile clients often send application/octet-stream).
* The extension fallback covers formats an older libmagic may not know, e.g. HEIC.
*/
function is_allowed_upload(string $tmpPath, string $originalName, bool $imagesOnly = false): bool
{
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpPath) ?: '';
if (str_starts_with($mime, 'image/') || (!$imagesOnly && $mime === 'application/pdf')) {
return true;
}
$images = ['jpg', 'jpeg', 'png', 'heic', 'heif', 'webp', 'gif', 'tif', 'tiff'];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
return in_array($ext, $imagesOnly ? $images : [...$images, 'pdf'], true);
}
function user_owns_home(PDO $pdo, array $user, int $idhome): bool
{
$stmt = $pdo->prepare('SELECT 1 FROM home WHERE idhome = ? AND iduser = ? LIMIT 1');
$stmt->execute([$idhome, $user['id']]);
return (bool) $stmt->fetchColumn();
}
// Ownership OR an accepted, non-expired share.
function user_can_access_home(PDO $pdo, array $user, int $idhome): bool
{
if (user_owns_home($pdo, $user, $idhome)) {
return true;
}
$stmt = $pdo->prepare(
"SELECT 1 FROM home_sharing
WHERE idhome = ? AND (idshareduser = ? OR shared_email = ?)
AND status = 'accepted'
AND (expiration_date IS NULL OR expiration_date >= CURDATE())
LIMIT 1"
);
$stmt->execute([$idhome, $user['id'], $user['email']]);
return (bool) $stmt->fetchColumn();
}
function user_owns_owner(PDO $pdo, array $user, int $ownerId): bool
{
$stmt = $pdo->prepare('SELECT 1 FROM property_owners WHERE owner_id = ? AND user_id = ? LIMIT 1');
$stmt->execute([$ownerId, $user['id']]);
return (bool) $stmt->fetchColumn();
}
// Absolute base URL taken from the current request so generated links match the
// host the client actually used; falls back to APP_URL (e.g. when run from CLI).
function base_url(): string
{
global $config;
$host = $_SERVER['HTTP_HOST'] ?? '';
if ($host === '') {
return $config['app_url'];
}
$https = ($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off';
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ($https ? 'https' : 'http');
return $proto . '://' . $host;
}
// URL for a public/ asset. Uses the configured public base (ASSETS_BASE_URL) so links
// point at the real https domain even behind a proxy; falls back to the request host.
function asset_url(string $path): string
{
global $config;
$base = $config['assets_base_url'] ?: base_url();
return $base . '/' . ltrim($path, '/');
}
function present_user(array $u): array
{
// Uploaded avatars are stored as a bare filename under public/upload/users;
// social logins already hold an absolute URL.
$avatar = $u['avatar'] ?? null;
if ($avatar && !str_starts_with($avatar, 'http')) {
$avatar = asset_url('/upload/users/' . $avatar);
}
return [
'id' => (int) $u['id'],
'first_name' => $u['first_name'] ?? null,
'last_name' => $u['last_name'] ?? null,
'email' => $u['email'] ?? null,
'username' => $u['username'] ?? null,
'phone' => $u['phone'] ?? null,
'address' => $u['address'] ?? null,
'avatar' => $avatar,
];
}
// Documents expiring within this many days (or already expired) count as "expiring".
const EXPIRING_SOON_DAYS = 60;
/** One query for a set of homes: idhome => [documents_count, expiring_count]. */
function home_counts(PDO $pdo, array $idhomes): array
{
$idhomes = array_values(array_unique(array_map('intval', $idhomes)));
if (!$idhomes) {
return [];
}
$in = implode(',', array_fill(0, count($idhomes), '?'));
$stmt = $pdo->prepare(
"SELECT idhome,
COUNT(*) AS documents_count,
SUM(expirydate IS NOT NULL
AND expirydate <= DATE_ADD(CURDATE(), INTERVAL " . EXPIRING_SOON_DAYS . " DAY)) AS expiring_count
FROM doc_storage
WHERE idhome IN ($in)
GROUP BY idhome"
);
$stmt->execute($idhomes);
$counts = [];
foreach ($stmt->fetchAll() as $row) {
$counts[(int) $row['idhome']] = [
'documents_count' => (int) $row['documents_count'],
'expiring_count' => (int) $row['expiring_count'],
];
}
return $counts;
}
function present_home(array $h, bool $isOwner = true, ?array $counts = null): array
{
$photo = $h['mainphoto'] ?? null;
return [
'idhome' => (int) $h['idhome'],
'name' => $h['name'] ?? null,
'comment' => $h['comment'] ?? null,
'fulladdress' => $h['fulladdress'] ?? null,
'address' => $h['address'] ?? null,
'zip' => $h['zip'] ?? null,
'city' => $h['city'] ?? null,
'country' => $h['country'] ?? null,
'latitude' => $h['latitude'] ?? null,
'longitude' => $h['longitude'] ?? null,
'mainphoto' => $photo,
'photo_url' => $photo ? asset_url('/userportal/mainphoto/' . $photo) : null,
'cadastral_municipality' => $h['cadastral_municipality'] ?? null,
'cadastral_section' => $h['cadastral_section'] ?? null,
'cadastral_sheet' => $h['cadastral_sheet'] ?? null,
'cadastral_particle' => $h['cadastral_particle'] ?? null,
'cadastral_sub' => $h['cadastral_sub'] ?? null,
'cadastral_category' => $h['cadastral_category'] ?? null,
'cadastral_class' => $h['cadastral_class'] ?? null,
'cadastral_surface' => $h['cadastral_surface'] ?? null,
'cadastral_rendita' => $h['cadastral_rendita'] ?? null,
'cadastral_notes' => $h['cadastral_notes'] ?? null,
'is_owner' => $isOwner,
'documents_count' => (int) ($counts['documents_count'] ?? 0),
'expiring_count' => (int) ($counts['expiring_count'] ?? 0),
];
}
function present_file(array $f): array
{
global $config;
$isPerson = ($f['entity_type'] ?? '') === 'person' || empty($f['idhome']);
$path = ($isPerson ? $config['persondocs_dir'] : $config['homedocs_dir'])
. '/' . basename((string) $f['filename']);
return [
'id' => (int) $f['id'],
'document_id' => (int) $f['document_id'],
'title' => $f['title'] ?? null,
'idhome' => isset($f['idhome']) ? (int) $f['idhome'] : null,
'owner_id' => isset($f['owner_id']) ? (int) $f['owner_id'] : null,
'filename' => $f['filename'],
'size' => is_file($path) ? filesize($path) : null,
'url' => asset_url('/userportal/api/document-file.php?id=' . (int) $f['id']),
'expiry_date' => $f['expirydate'] ?? null,
'expiry_status' => isset($f['expirystatus']) ? (int) $f['expirystatus'] : null,
'note' => $f['note'] ?? null,
'created_at' => $f['created_at'] ?? null,
];
}
function present_owner(array $o): array
{
return [
'owner_id' => (int) $o['owner_id'],
'owner_type' => $o['owner_type'] ?? null,
'first_name' => $o['first_name'] ?? null,
'last_name' => $o['last_name'] ?? null,
'company_name' => $o['company_name'] ?? null,
'tax_code' => $o['tax_code'] ?? null,
'email' => $o['email'] ?? null,
'phone' => $o['phone'] ?? null,
'address' => $o['address'] ?? null,
'postal_code' => $o['postal_code'] ?? null,
'city' => $o['city'] ?? null,
'province' => $o['province'] ?? null,
'country' => isset($o['country']) ? (int) $o['country'] : null,
'role' => $o['role'] ?? null,
];
}
// property_owners row plus home_owners fields (ownership_percentage, notes).
function present_home_owner(array $row): array
{
return [
'owner' => present_owner($row),
'ownership_percentage' => isset($row['ownership_percentage']) ? (float) $row['ownership_percentage'] : null,
'notes' => $row['notes'] ?? null,
];
}
function present_section(array $s): array
{
return [
'idsections' => (int) $s['idsections'],
'section_name' => $s['section_name'] ?? null,
'description' => $s['description'] ?? null,
];
}
function present_page(array $p): array
{
return [
'idpages' => (int) $p['idpages'],
'namepages' => $p['namepages'] ?? null,
'slug' => $p['slug'] ?? null,
'descriptionpages' => $p['descriptionpages'] ?? null,
];
}
function present_document_template(array $d): array
{
return [
'document_id' => (int) $d['document_id'],
'document_name' => $d['document_name'] ?? null,
'page_id' => isset($d['page_id']) ? (int) $d['page_id'] : null,
'idsections' => isset($d['idsections']) ? (int) $d['idsections'] : null,
'section_name' => $d['section_name'] ?? null,
'max_documents' => (int) ($d['max_documents'] ?? 0),
'is_required' => (bool) ($d['is_required'] ?? 0),
'notes' => $d['notes'] ?? null,
];
}
function present_sharing_role(array $r): array
{
$perms = json_decode((string) ($r['permissions'] ?? ''), true);
return [
'idrole' => (int) $r['idrole'],
'role_name' => $r['role_name'] ?? null,
'description' => $r['description'] ?? null,
'permissions' => is_array($perms) ? $perms : [],
];
}
// home_sharing row; role is joined via role_name/role_description/role_permissions aliases.
function present_share(array $s): array
{
$sections = json_decode((string) ($s['shared_sections'] ?? ''), true);
$role = null;
if (!empty($s['role_id'])) {
$perms = json_decode((string) ($s['role_permissions'] ?? ''), true);
$role = [
'idrole' => (int) $s['role_id'],
'role_name' => $s['role_name'] ?? null,
'description' => $s['role_description'] ?? null,
'permissions' => is_array($perms) ? $perms : [],
];
}
return [
'idsharing' => (int) $s['idsharing'],
'idhome' => (int) $s['idhome'],
'shared_email' => $s['shared_email'] ?? null,
'idshareduser' => isset($s['idshareduser']) ? (int) $s['idshareduser'] : null,
'role' => $role,
'sharing_type' => $s['sharing_type'] ?? null,
'shared_sections' => is_array($sections) ? array_map('intval', $sections) : [],
'expiration_date' => $s['expiration_date'] ?? null,
'status' => $s['status'] ?? null,
];
}
// Ownership of a share (I am the one who shared).
function user_owns_share(PDO $pdo, array $user, int $idsharing): ?array
{
$stmt = $pdo->prepare('SELECT * FROM home_sharing WHERE idsharing = ? AND iduser = ? LIMIT 1');
$stmt->execute([$idsharing, $user['id']]);
$row = $stmt->fetch();
return $row ?: null;
}