421 lines
15 KiB
PHP
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;
|
|
}
|