['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; } 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 = base_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 ? (base_url() . '/userportal/homedocuments/' . $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' => base_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; }