diff --git a/public/userportal/api/_bootstrap.php b/public/userportal/api/_bootstrap.php new file mode 100644 index 0000000..44c857c --- /dev/null +++ b/public/userportal/api/_bootstrap.php @@ -0,0 +1,411 @@ + ['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; +} diff --git a/public/userportal/api/_codes.php b/public/userportal/api/_codes.php new file mode 100644 index 0000000..1e2b9af --- /dev/null +++ b/public/userportal/api/_codes.php @@ -0,0 +1,83 @@ +prepare("SELECT TIMESTAMPDIFF(SECOND, created_at, NOW()) FROM {$table} WHERE email = ? LIMIT 1"); + $stmt->execute([$email]); + $age = $stmt->fetchColumn(); + if ($age !== false && (int) $age < CODE_RESEND_SECONDS) { + return null; + } + + $code = generate_code(); + $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); + $pdo->prepare("INSERT INTO {$table} (email, token, attempts, created_at) VALUES (?, ?, 0, NOW())") + ->execute([$email, hash('sha256', $code)]); + + return $code; +} + +/** + * Checks a code and consumes an attempt. Returns 'ok', 'code_invalid' or 'code_expired'. + * A spent or exhausted record is deleted, so the caller must request a new code. + */ +function check_code(PDO $pdo, string $table, string $email, string $code): string +{ + $stmt = $pdo->prepare( + "SELECT token, attempts, TIMESTAMPDIFF(MINUTE, created_at, NOW()) AS age + FROM {$table} WHERE email = ? LIMIT 1" + ); + $stmt->execute([$email]); + $row = $stmt->fetch(); + + if (!$row) { + return 'code_invalid'; + } + if ((int) $row['age'] >= CODE_TTL_MINUTES) { + $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); + return 'code_expired'; + } + if (!hash_equals((string) $row['token'], hash('sha256', $code))) { + $attempts = (int) $row['attempts'] + 1; + if ($attempts >= CODE_MAX_ATTEMPTS) { + $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); + return 'code_expired'; + } + $pdo->prepare("UPDATE {$table} SET attempts = ? WHERE email = ?")->execute([$attempts, $email]); + return 'code_invalid'; + } + + $pdo->prepare("DELETE FROM {$table} WHERE email = ?")->execute([$email]); + return 'ok'; +} + +/** Issues a bearer token exactly like login.php does. */ +function issue_token(PDO $pdo, int $userId, string $deviceName): string +{ + $config = require __DIR__ . '/config.php'; + $plain = bin2hex(random_bytes(32)); + $expires = (new DateTimeImmutable("+{$config['token_ttl_days']} days"))->format('Y-m-d H:i:s'); + + $pdo->prepare( + 'INSERT INTO api_tokens (user_id, name, token, expires_at, created_at) VALUES (?, ?, ?, ?, NOW())' + )->execute([$userId, $deviceName, hash('sha256', $plain), $expires]); + + return $plain; +} diff --git a/public/userportal/api/_mail.php b/public/userportal/api/_mail.php new file mode 100644 index 0000000..85c310f --- /dev/null +++ b/public/userportal/api/_mail.php @@ -0,0 +1,69 @@ +isSMTP(); + $mail->Host = mail_env('MAIL_HOST') ?: 'localhost'; + $mail->Port = (int) (mail_env('MAIL_PORT') ?: 587); + $mail->CharSet = 'UTF-8'; + // Default is 300s: an unreachable SMTP would otherwise stall registration. + $mail->Timeout = 10; + + if ($user = mail_env('MAIL_USERNAME')) { + $mail->SMTPAuth = true; + $mail->Username = $user; + $mail->Password = mail_env('MAIL_PASSWORD'); + } + if ($enc = mail_env('MAIL_ENCRYPTION')) { + $mail->SMTPSecure = $enc; + } + + $mail->setFrom( + mail_env('MAIL_FROM_ADDRESS') ?: 'noreply@casadoc.app', + mail_env('MAIL_FROM_NAME') ?: 'CasaDoc' + ); + $mail->addAddress($to); + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $html; + + return $mail->send(); + } catch (Throwable $e) { + error_log('mail failed: ' . $e->getMessage()); + return false; + } +} + +function send_code_mail(string $to, string $code, bool $isReset = false): bool +{ + $subject = $isReset ? 'CasaDoc password reset code' : 'CasaDoc verification code'; + $intro = $isReset + ? 'Use this code to reset your CasaDoc password:' + : 'Use this code to confirm your e-mail address:'; + + $html = '

' . $intro . '

' + . '

' . htmlspecialchars($code) . '

' + . '

The code expires in 15 minutes. If you did not request it, ignore this e-mail.

'; + + return send_mail($to, $subject, $html); +} diff --git a/public/userportal/api/_openapi.php b/public/userportal/api/_openapi.php new file mode 100644 index 0000000..9e6052f --- /dev/null +++ b/public/userportal/api/_openapi.php @@ -0,0 +1,244 @@ +" + * ) + * + * @OA\Tag(name="Auth", description="Login, tokens, current user") + * @OA\Tag(name="Homes", description="Properties") + * @OA\Tag(name="Documents", description="Document requirements, files, sections") + * @OA\Tag(name="Owners", description="Owners and home links") + * @OA\Tag(name="Sharing", description="Home sharing and invitations") + * @OA\Tag(name="Reference", description="Read-only reference data") + * + * @OA\Response(response="Unauthorized", description="Missing/invalid token", + * @OA\JsonContent(ref="#/components/schemas/Error")) + * @OA\Response(response="Forbidden", description="No access to resource", + * @OA\JsonContent(ref="#/components/schemas/Error")) + * @OA\Response(response="NotFound", description="Not found", + * @OA\JsonContent(ref="#/components/schemas/Error")) + * @OA\Response(response="ValidationError", description="Validation error", + * @OA\JsonContent(ref="#/components/schemas/Error")) + * @OA\Response(response="Success", description="Success", + * @OA\JsonContent(@OA\Property(property="success", type="boolean", example=true))) + */ +final class OpenApiMeta +{ +} + +/** + * @OA\Schema(schema="Error", + * @OA\Property(property="error", type="object", + * @OA\Property(property="code", type="string", example="forbidden"), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="fields", type="object", nullable=true, + * description="field -> array of errors (422 only)") + * ) + * ) + */ +final class ErrorSchema +{ +} + +/** + * @OA\Schema(schema="User", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="first_name", type="string", nullable=true), + * @OA\Property(property="last_name", type="string", nullable=true), + * @OA\Property(property="email", type="string", format="email"), + * @OA\Property(property="username", type="string", nullable=true), + * @OA\Property(property="phone", type="string", nullable=true), + * @OA\Property(property="address", type="string", nullable=true), + * @OA\Property(property="avatar", type="string", nullable=true, description="absolute URL") + * ) + */ +final class UserSchema +{ +} + +/** + * @OA\Schema(schema="Home", + * @OA\Property(property="idhome", type="integer"), + * @OA\Property(property="name", type="string", nullable=true), + * @OA\Property(property="comment", type="string", nullable=true), + * @OA\Property(property="fulladdress", type="string", nullable=true), + * @OA\Property(property="address", type="string", nullable=true), + * @OA\Property(property="zip", type="string", nullable=true), + * @OA\Property(property="city", type="string", nullable=true), + * @OA\Property(property="country", type="string", nullable=true), + * @OA\Property(property="latitude", type="string", nullable=true), + * @OA\Property(property="longitude", type="string", nullable=true), + * @OA\Property(property="mainphoto", type="string", nullable=true), + * @OA\Property(property="photo_url", type="string", nullable=true), + * @OA\Property(property="cadastral_municipality", type="string", nullable=true), + * @OA\Property(property="cadastral_section", type="string", nullable=true), + * @OA\Property(property="cadastral_sheet", type="string", nullable=true), + * @OA\Property(property="cadastral_particle", type="string", nullable=true), + * @OA\Property(property="cadastral_sub", type="string", nullable=true), + * @OA\Property(property="cadastral_category", type="string", nullable=true), + * @OA\Property(property="cadastral_class", type="string", nullable=true), + * @OA\Property(property="cadastral_surface", type="string", nullable=true), + * @OA\Property(property="cadastral_rendita", type="string", nullable=true), + * @OA\Property(property="cadastral_notes", type="string", nullable=true), + * @OA\Property(property="is_owner", type="boolean"), + * @OA\Property(property="documents_count", type="integer", description="uploaded files for the property"), + * @OA\Property(property="expiring_count", type="integer", description="expiring within 60 days or already expired") + * ) + */ +final class HomeSchema +{ +} + +/** + * @OA\Schema(schema="Section", + * @OA\Property(property="idsections", type="integer"), + * @OA\Property(property="section_name", type="string"), + * @OA\Property(property="description", type="string", nullable=true) + * ) + */ +final class SectionSchema +{ +} + +/** + * @OA\Schema(schema="UploadedFile", + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="document_id", type="integer"), + * @OA\Property(property="title", type="string", nullable=true, description="user-defined display name; falls back to the requirement name"), + * @OA\Property(property="idhome", type="integer", nullable=true), + * @OA\Property(property="owner_id", type="integer", nullable=true), + * @OA\Property(property="filename", type="string"), + * @OA\Property(property="size", type="integer", nullable=true, description="bytes on disk, null if missing"), + * @OA\Property(property="url", type="string"), + * @OA\Property(property="expiry_date", type="string", format="date", nullable=true), + * @OA\Property(property="expiry_status", type="integer", nullable=true, description="tinyint 0/1"), + * @OA\Property(property="note", type="string", nullable=true), + * @OA\Property(property="created_at", type="string", nullable=true) + * ) + */ +final class UploadedFileSchema +{ +} + +/** + * @OA\Schema(schema="DocumentRequirement", + * description="Document template (documents) plus its uploaded files", + * @OA\Property(property="document_id", type="integer"), + * @OA\Property(property="document_name", type="string"), + * @OA\Property(property="page_id", type="integer", nullable=true), + * @OA\Property(property="idsections", type="integer", nullable=true), + * @OA\Property(property="section_name", type="string", nullable=true), + * @OA\Property(property="max_documents", type="integer"), + * @OA\Property(property="is_required", type="boolean"), + * @OA\Property(property="notes", type="string", nullable=true), + * @OA\Property(property="files", type="array", @OA\Items(ref="#/components/schemas/UploadedFile")) + * ) + */ +final class DocumentRequirementSchema +{ +} + +/** + * @OA\Schema(schema="DocumentTemplate", + * @OA\Property(property="document_id", type="integer"), + * @OA\Property(property="document_name", type="string"), + * @OA\Property(property="page_id", type="integer", nullable=true), + * @OA\Property(property="idsections", type="integer", nullable=true), + * @OA\Property(property="section_name", type="string", nullable=true), + * @OA\Property(property="max_documents", type="integer"), + * @OA\Property(property="is_required", type="boolean"), + * @OA\Property(property="notes", type="string", nullable=true) + * ) + */ +final class DocumentTemplateSchema +{ +} + +/** + * @OA\Schema(schema="Page", + * @OA\Property(property="idpages", type="integer"), + * @OA\Property(property="namepages", type="string"), + * @OA\Property(property="slug", type="string"), + * @OA\Property(property="descriptionpages", type="string", nullable=true) + * ) + */ +final class PageSchema +{ +} + +/** + * @OA\Schema(schema="Owner", + * @OA\Property(property="owner_id", type="integer"), + * @OA\Property(property="owner_type", type="string", enum={"individual","company"}), + * @OA\Property(property="first_name", type="string", nullable=true), + * @OA\Property(property="last_name", type="string", nullable=true), + * @OA\Property(property="company_name", type="string", nullable=true), + * @OA\Property(property="tax_code", type="string"), + * @OA\Property(property="email", type="string", nullable=true), + * @OA\Property(property="phone", type="string", nullable=true), + * @OA\Property(property="address", type="string", nullable=true), + * @OA\Property(property="postal_code", type="string", nullable=true), + * @OA\Property(property="city", type="string", nullable=true), + * @OA\Property(property="province", type="string", nullable=true), + * @OA\Property(property="country", type="integer", nullable=true), + * @OA\Property(property="role", type="string", nullable=true) + * ) + */ +final class OwnerSchema +{ +} + +/** + * @OA\Schema(schema="HomeOwner", + * @OA\Property(property="owner", ref="#/components/schemas/Owner"), + * @OA\Property(property="ownership_percentage", type="number", format="float"), + * @OA\Property(property="notes", type="string", nullable=true) + * ) + */ +final class HomeOwnerSchema +{ +} + +/** + * @OA\Schema(schema="SharingRole", + * @OA\Property(property="idrole", type="integer"), + * @OA\Property(property="role_name", type="string"), + * @OA\Property(property="description", type="string", nullable=true), + * @OA\Property(property="permissions", type="array", @OA\Items(type="string")) + * ) + */ +final class SharingRoleSchema +{ +} + +/** + * @OA\Schema(schema="Share", + * @OA\Property(property="idsharing", type="integer"), + * @OA\Property(property="idhome", type="integer"), + * @OA\Property(property="shared_email", type="string", format="email"), + * @OA\Property(property="idshareduser", type="integer", nullable=true), + * @OA\Property(property="role", ref="#/components/schemas/SharingRole", nullable=true), + * @OA\Property(property="sharing_type", type="string"), + * @OA\Property(property="shared_sections", type="array", @OA\Items(type="integer")), + * @OA\Property(property="expiration_date", type="string", format="date", nullable=true), + * @OA\Property(property="status", type="string", enum={"pending","accepted","rejected"}) + * ) + */ +final class ShareSchema +{ +} diff --git a/public/userportal/api/account-delete.php b/public/userportal/api/account-delete.php new file mode 100644 index 0000000..5a337e3 --- /dev/null +++ b/public/userportal/api/account-delete.php @@ -0,0 +1,106 @@ + ['Required']]); +} + +$userId = (int) $user['id']; +$email = (string) $user['email']; + +// Ids owned by the user. +$stmt = $pdo->prepare('SELECT idhome FROM home WHERE iduser = ?'); +$stmt->execute([$userId]); +$homeIds = $stmt->fetchAll(PDO::FETCH_COLUMN); + +$stmt = $pdo->prepare('SELECT owner_id FROM property_owners WHERE user_id = ?'); +$stmt->execute([$userId]); +$ownerIds = $stmt->fetchAll(PDO::FETCH_COLUMN); + +// Collect files before the rows disappear; they are unlinked after a successful commit. +$files = []; +if ($homeIds || $ownerIds) { + $where = []; + $params = []; + if ($homeIds) { + $where[] = 'idhome IN (' . implode(',', array_fill(0, count($homeIds), '?')) . ')'; + $params = array_merge($params, $homeIds); + } + if ($ownerIds) { + $where[] = 'owner_id IN (' . implode(',', array_fill(0, count($ownerIds), '?')) . ')'; + $params = array_merge($params, $ownerIds); + } + $stmt = $pdo->prepare('SELECT filename, entity_type, idhome FROM doc_storage WHERE ' . implode(' OR ', $where)); + $stmt->execute($params); + foreach ($stmt->fetchAll() as $f) { + $isPerson = ($f['entity_type'] ?? '') === 'person' || empty($f['idhome']); + $files[] = ($isPerson ? $config['persondocs_dir'] : $config['homedocs_dir']) + . '/' . basename((string) $f['filename']); + } +} + +$in = fn (array $ids) => implode(',', array_fill(0, count($ids), '?')); + +$pdo->beginTransaction(); +try { + // Sharing granted by the user, received by the user, or addressed to their e-mail. + $pdo->prepare('DELETE FROM home_sharing WHERE iduser = ? OR idshareduser = ? OR shared_email = ?') + ->execute([$userId, $userId, $email]); + + if ($homeIds) { + $pdo->prepare('DELETE FROM doc_storage WHERE idhome IN (' . $in($homeIds) . ')')->execute($homeIds); + $pdo->prepare('DELETE FROM home_owners WHERE home_id IN (' . $in($homeIds) . ')')->execute($homeIds); + $pdo->prepare('DELETE FROM home_sharing WHERE idhome IN (' . $in($homeIds) . ')')->execute($homeIds); + } + if ($ownerIds) { + $pdo->prepare('DELETE FROM doc_storage WHERE owner_id IN (' . $in($ownerIds) . ')')->execute($ownerIds); + $pdo->prepare('DELETE FROM home_owners WHERE owner_id IN (' . $in($ownerIds) . ')')->execute($ownerIds); + } + + $pdo->prepare('DELETE FROM home WHERE iduser = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM property_owners WHERE user_id = ?')->execute([$userId]); + + // Credentials and sessions. + $pdo->prepare('DELETE FROM api_tokens WHERE user_id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM auth_sessions WHERE user_id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM auth_personal_access_tokens WHERE tokenable_id = ?')->execute([$userId]); + $pdo->prepare('DELETE FROM auth_password_resets WHERE email = ?')->execute([$email]); + + // Cascades auth_social_logins, auth_user_activity, auth_announcements. + $pdo->prepare('DELETE FROM auth_users WHERE id = ?')->execute([$userId]); + + $pdo->commit(); +} catch (Throwable $e) { + $pdo->rollBack(); + json_error(500, 'Account deletion failed'); +} + +// Files last: an orphaned file is harmless, a lost file after a rollback is not. +foreach ($files as $path) { + if (is_file($path)) { + @unlink($path); + } +} + +json_ok(); diff --git a/public/userportal/api/config.php b/public/userportal/api/config.php new file mode 100644 index 0000000..b2dd179 --- /dev/null +++ b/public/userportal/api/config.php @@ -0,0 +1,44 @@ + getenv('DB_HOST') ?: '127.0.0.1', + 'db_port' => getenv('DB_PORT') ?: '3306', + 'db_name' => getenv('DB_DATABASE') ?: 'casadocdb', + 'db_user' => getenv('DB_USERNAME') ?: 'root', + 'db_pass' => getenv('DB_PASSWORD') ?: '', + + 'token_ttl_days' => 30, + + 'homedocs_dir' => __DIR__ . '/../homedocuments', + 'persondocs_dir' => __DIR__ . '/../persondocuments', + 'avatars_dir' => __DIR__ . '/../../upload/users', + + 'app_url' => rtrim(getenv('APP_URL') ?: '', '/'), +]; diff --git a/public/userportal/api/document-file-delete.php b/public/userportal/api/document-file-delete.php new file mode 100644 index 0000000..ec098e0 --- /dev/null +++ b/public/userportal/api/document-file-delete.php @@ -0,0 +1,54 @@ +prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1'); +$stmt->execute([$id]); +$file = $stmt->fetch(); +if (!$file) { + json_error(404, 'File not found'); +} + +$allowed = false; +$baseDir = null; +if (!empty($file['idhome'])) { + $allowed = user_owns_home($pdo, $user, (int) $file['idhome']); + $baseDir = $config['homedocs_dir']; +} elseif (!empty($file['owner_id'])) { + $allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']); + $baseDir = $config['persondocs_dir']; +} +if (!$allowed) { + json_error(403, 'No access to this file'); +} + +$path = $baseDir . '/' . basename((string) $file['filename']); +if (is_file($path)) { + @unlink($path); +} +$pdo->prepare('DELETE FROM doc_storage WHERE id = ?')->execute([$id]); + +json_ok(); diff --git a/public/userportal/api/document-file-replace.php b/public/userportal/api/document-file-replace.php new file mode 100644 index 0000000..fb4be74 --- /dev/null +++ b/public/userportal/api/document-file-replace.php @@ -0,0 +1,110 @@ +prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1'); +$stmt->execute([$id]); +$file = $stmt->fetch(); +if (!$file) { + json_error(404, 'File not found'); +} + +$allowed = false; +if (!empty($file['idhome'])) { + $allowed = user_owns_home($pdo, $user, (int) $file['idhome']); +} elseif (!empty($file['owner_id'])) { + $allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']); +} +if (!$allowed) { + json_error(403, 'No access to this file'); +} + +if (!is_allowed_upload($_FILES['file']['tmp_name'], $_FILES['file']['name'])) { + json_error(422, 'Validation failed', ['file' => ['Only PDF or image files are allowed']]); +} + +$isPerson = ($file['entity_type'] ?? '') === 'person' || empty($file['idhome']); +$dir = $isPerson ? $config['persondocs_dir'] : $config['homedocs_dir']; +$prefix = $isPerson ? (int) $file['owner_id'] : (int) $file['idhome']; + +if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { + json_error(500, 'Storage directory unavailable'); +} + +$oldName = basename((string) $file['filename']); +$oldPath = $dir . '/' . $oldName; + +// Never reuse the current name: writing over it would destroy the original +// before the row is repointed. +$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['file']['name'])); +$newName = $prefix . '-' . time() . '-' . $safe; +while ($newName === $oldName || is_file($dir . '/' . $newName)) { + $newName = $prefix . '-' . time() . '-' . bin2hex(random_bytes(3)) . '-' . $safe; +} +$newPath = $dir . '/' . $newName; + +if (!move_uploaded_file($_FILES['file']['tmp_name'], $newPath)) { + json_error(500, 'Failed to store file'); +} + +// Repoint the row; on failure drop the new file so the old one stays authoritative. +try { + if (array_key_exists('title', $_POST)) { + $title = $_POST['title'] !== '' ? $_POST['title'] : null; + $pdo->prepare('UPDATE doc_storage SET filename = ?, title = ? WHERE id = ?') + ->execute([$newName, $title, $id]); + } else { + $pdo->prepare('UPDATE doc_storage SET filename = ? WHERE id = ?')->execute([$newName, $id]); + } +} catch (Throwable $e) { + @unlink($newPath); + json_error(500, 'Failed to update document'); +} + +if ($oldPath !== $newPath && is_file($oldPath)) { + @unlink($oldPath); +} + +$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1'); +$row->execute([$id]); + +json_data(present_file($row->fetch())); diff --git a/public/userportal/api/document-file-update.php b/public/userportal/api/document-file-update.php new file mode 100644 index 0000000..7f0b75e --- /dev/null +++ b/public/userportal/api/document-file-update.php @@ -0,0 +1,94 @@ +prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1'); +$stmt->execute([$id]); +$file = $stmt->fetch(); +if (!$file) { + json_error(404, 'File not found'); +} + +$allowed = false; +if (!empty($file['idhome'])) { + $allowed = user_owns_home($pdo, $user, (int) $file['idhome']); +} elseif (!empty($file['owner_id'])) { + $allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']); +} +if (!$allowed) { + json_error(403, 'No access to this file'); +} + +$data = []; + +if (array_key_exists('expiry_date', $in)) { + $expiry = $in['expiry_date'] !== '' ? $in['expiry_date'] : null; + $data['expirydate'] = $expiry; + $data['expirystatus'] = $expiry ? 1 : 0; +} + +if (array_key_exists('note', $in)) { + $data['note'] = $in['note'] !== '' ? $in['note'] : null; +} + +// null or "" resets the display name back to the requirement's name. +if (array_key_exists('title', $in)) { + $data['title'] = ($in['title'] !== null && $in['title'] !== '') ? $in['title'] : null; +} + +if (array_key_exists('document_id', $in)) { + $documentId = (int) $in['document_id']; + if ($documentId <= 0) { + json_error(422, 'Validation failed', ['document_id' => ['Must be a positive integer']]); + } + $exists = $pdo->prepare('SELECT 1 FROM documents WHERE document_id = ? LIMIT 1'); + $exists->execute([$documentId]); + if (!$exists->fetchColumn()) { + json_error(422, 'Validation failed', ['document_id' => ['Unknown document']]); + } + $data['document_id'] = $documentId; +} + +if (!$data) { + json_error(422, 'Nothing to update'); +} + +$set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data))); +$pdo->prepare("UPDATE doc_storage SET $set WHERE id = ?") + ->execute([...array_values($data), $id]); + +$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1'); +$row->execute([$id]); + +json_data(present_file($row->fetch())); diff --git a/public/userportal/api/document-file.php b/public/userportal/api/document-file.php new file mode 100644 index 0000000..7afac5e --- /dev/null +++ b/public/userportal/api/document-file.php @@ -0,0 +1,61 @@ +prepare('SELECT * FROM doc_storage WHERE id = ? LIMIT 1'); +$stmt->execute([$id]); +$file = $stmt->fetch(); + +if (!$file) { + json_error(404, 'File not found'); +} + +// Access check: home file or owner file. +$allowed = false; +if (!empty($file['idhome'])) { + $allowed = user_can_access_home($pdo, $user, (int) $file['idhome']); + $baseDir = $config['homedocs_dir']; +} elseif (!empty($file['owner_id'])) { + $allowed = user_owns_owner($pdo, $user, (int) $file['owner_id']); + $baseDir = $config['persondocs_dir']; +} + +if (!$allowed) { + json_error(403, 'No access to this file'); +} + +$path = ($baseDir ?? '') . '/' . basename((string) $file['filename']); +if (!is_file($path)) { + json_error(404, 'File missing on disk'); +} + +// Serve the binary file (override bootstrap JSON header). +header('Content-Type: ' . (mime_content_type($path) ?: 'application/octet-stream')); +header('Content-Disposition: attachment; filename="' . basename((string) $file['filename']) . '"'); +header('Content-Length: ' . filesize($path)); +readfile($path); +exit; diff --git a/public/userportal/api/document-templates.php b/public/userportal/api/document-templates.php new file mode 100644 index 0000000..263752c --- /dev/null +++ b/public/userportal/api/document-templates.php @@ -0,0 +1,34 @@ +prepare($sql); +$stmt->execute($params); + +json_data(array_map('present_document_template', $stmt->fetchAll())); diff --git a/public/userportal/api/document-upload.php b/public/userportal/api/document-upload.php new file mode 100644 index 0000000..f05de98 --- /dev/null +++ b/public/userportal/api/document-upload.php @@ -0,0 +1,78 @@ +prepare( + "INSERT INTO doc_storage (idhome, entity_type, document_id, title, filename, expirystatus, expirydate, note, created_at, updated_at) + VALUES (?, 'home', ?, ?, ?, ?, ?, ?, NOW(), NOW())" +); +$stmt->execute([$idhome, $documentId, $title ?: null, $filename, $expiryStatus, $expiry ?: null, $note ?: null]); +$id = (int) $pdo->lastInsertId(); + +$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ?'); +$row->execute([$id]); + +json_data(present_file($row->fetch()), 201); diff --git a/public/userportal/api/documents-download.php b/public/userportal/api/documents-download.php new file mode 100644 index 0000000..9c677a8 --- /dev/null +++ b/public/userportal/api/documents-download.php @@ -0,0 +1,85 @@ +prepare( + "SELECT shared_sections FROM home_sharing + WHERE idhome = ? AND (idshareduser = ? OR shared_email = ?) AND status = 'accepted' + LIMIT 1" + ); + $shareStmt->execute([$idhome, $user['id'], $user['email']]); + $decoded = json_decode((string) $shareStmt->fetchColumn(), true); + $allowedSections = is_array($decoded) ? array_map('intval', $decoded) : []; +} + +$sql = 'SELECT ds.filename, d.document_name, s.section_name, d.idsections + FROM doc_storage ds + JOIN documents d ON d.document_id = ds.document_id + LEFT JOIN sections s ON s.idsections = d.idsections + WHERE ds.idhome = ?'; +$params = [$idhome]; +if ($allowedSections !== null) { + if (!$allowedSections) { + json_error(403, 'No shared sections'); + } + $in = implode(',', array_fill(0, count($allowedSections), '?')); + $sql .= " AND d.idsections IN ($in)"; + $params = array_merge($params, $allowedSections); +} + +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +$zipPath = tempnam(sys_get_temp_dir(), 'casadoc_') . '.zip'; +$zip = new ZipArchive(); +if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + json_error(500, 'Cannot create archive'); +} +foreach ($rows as $r) { + $path = $config['homedocs_dir'] . '/' . basename((string) $r['filename']); + if (is_file($path)) { + $folder = ($r['section_name'] ?: 'Senza sezione') . '/' . ($r['document_name'] ?: 'documento'); + $zip->addFile($path, $folder . '/' . basename((string) $r['filename'])); + } +} +$zip->close(); + +header('Content-Type: application/zip'); +header('Content-Disposition: attachment; filename="Documenti_' . $idhome . '.zip"'); +header('Content-Length: ' . filesize($zipPath)); +readfile($zipPath); +@unlink($zipPath); +exit; diff --git a/public/userportal/api/documents.php b/public/userportal/api/documents.php new file mode 100644 index 0000000..1b8cec2 --- /dev/null +++ b/public/userportal/api/documents.php @@ -0,0 +1,97 @@ +prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1'); +$homeStmt->execute([$idhome]); +$home = $homeStmt->fetch(); +if (!$home) { + json_error(404, 'Home not found'); +} + +// Category page by slug. +$pageStmt = $pdo->prepare('SELECT idpages FROM pages WHERE slug = ? LIMIT 1'); +$pageStmt->execute([$slug]); +$pageId = $pageStmt->fetchColumn(); +if ($pageId === false) { + json_error(404, 'Page not found'); +} + +// Requirements with section. +$sql = 'SELECT d.*, s.section_name + FROM documents d + LEFT JOIN sections s ON s.idsections = d.idsections + WHERE d.page_id = ?'; +$params = [$pageId]; + +if (query('only_required') === 'true' || query('only_required') === '1') { + $sql .= ' AND d.is_required = 1'; +} +if (($sectionId = (int) query('section_id', 0)) > 0) { + $sql .= ' AND d.idsections = ?'; + $params[] = $sectionId; +} +$sql .= ' ORDER BY s.section_name, d.document_name'; + +$docStmt = $pdo->prepare($sql); +$docStmt->execute($params); +$documents = $docStmt->fetchAll(); + +// Uploaded files for this home, grouped by document_id. +$filesStmt = $pdo->prepare('SELECT * FROM doc_storage WHERE idhome = ?'); +$filesStmt->execute([$idhome]); +$filesByDoc = []; +foreach ($filesStmt->fetchAll() as $f) { + $filesByDoc[(int) $f['document_id']][] = present_file($f); +} + +$result = array_map(function ($d) use ($filesByDoc) { + $id = (int) $d['document_id']; + return [ + 'document_id' => $id, + 'document_name' => $d['document_name'], + '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, + 'files' => $filesByDoc[$id] ?? [], + ]; +}, $documents); + +http_response_code(200); +echo json_encode([ + 'home' => present_home($home, (int) $home['iduser'] === (int) $user['id'], + home_counts($pdo, [$idhome])[$idhome] ?? null), + 'data' => $result, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/public/userportal/api/email-verify-resend.php b/public/userportal/api/email-verify-resend.php new file mode 100644 index 0000000..fb67a1f --- /dev/null +++ b/public/userportal/api/email-verify-resend.php @@ -0,0 +1,32 @@ +prepare('SELECT id FROM auth_users WHERE email = ? AND email_verified_at IS NULL LIMIT 1'); + $stmt->execute([$email]); + if ($stmt->fetchColumn() && $code = issue_code($pdo, 'auth_email_verifications', $email)) { + send_code_mail($email, $code); + } +} + +json_ok(); diff --git a/public/userportal/api/email-verify.php b/public/userportal/api/email-verify.php new file mode 100644 index 0000000..57555d8 --- /dev/null +++ b/public/userportal/api/email-verify.php @@ -0,0 +1,59 @@ +prepare('SELECT * FROM auth_users WHERE email = ? LIMIT 1'); +$stmt->execute([$email]); +$user = $stmt->fetch(); +if (!$user) { + json_error(422, 'Invalid code', null, 'code_invalid'); +} + +$pdo->prepare('UPDATE auth_users SET email_verified_at = NOW() WHERE id = ?')->execute([$user['id']]); + +json_data([ + 'token' => issue_token($pdo, (int) $user['id'], $device), + 'user' => present_user($user), +]); diff --git a/public/userportal/api/home-delete.php b/public/userportal/api/home-delete.php new file mode 100644 index 0000000..462c690 --- /dev/null +++ b/public/userportal/api/home-delete.php @@ -0,0 +1,41 @@ +beginTransaction(); +try { + $pdo->prepare('DELETE FROM doc_storage WHERE idhome = ?')->execute([$idhome]); + $pdo->prepare('DELETE FROM home_sharing WHERE idhome = ?')->execute([$idhome]); + $pdo->prepare('DELETE FROM home_owners WHERE home_id = ?')->execute([$idhome]); + $pdo->prepare('DELETE FROM home WHERE idhome = ? AND iduser = ?')->execute([$idhome, $user['id']]); + $pdo->commit(); +} catch (Throwable $e) { + $pdo->rollBack(); + json_error(500, 'Delete failed'); +} + +json_ok(); diff --git a/public/userportal/api/home-owner-attach.php b/public/userportal/api/home-owner-attach.php new file mode 100644 index 0000000..f03bffa --- /dev/null +++ b/public/userportal/api/home-owner-attach.php @@ -0,0 +1,63 @@ +prepare('SELECT 1 FROM home_owners WHERE home_id = ? AND owner_id = ? LIMIT 1'); +$exists->execute([$idhome, $ownerId]); +if ($exists->fetchColumn()) { + json_error(422, 'Owner already attached', ['owner_id' => ['Already attached']]); +} + +// Total share ≤ 100. +$sumStmt = $pdo->prepare('SELECT COALESCE(SUM(ownership_percentage), 0) FROM home_owners WHERE home_id = ?'); +$sumStmt->execute([$idhome]); +if ((float) $sumStmt->fetchColumn() + (float) $share > 100.0) { + json_error(422, 'Total ownership exceeds 100%', ['ownership_percentage' => ['Sum exceeds 100']]); +} + +$pdo->prepare( + 'INSERT INTO home_owners (home_id, owner_id, ownership_percentage, notes, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW())' +)->execute([$idhome, $ownerId, (float) $share, $notes ?: null]); + +json_ok(); diff --git a/public/userportal/api/home-owner-detach.php b/public/userportal/api/home-owner-detach.php new file mode 100644 index 0000000..708374a --- /dev/null +++ b/public/userportal/api/home-owner-detach.php @@ -0,0 +1,36 @@ +prepare('DELETE FROM home_owners WHERE home_id = ? AND owner_id = ?') + ->execute([$idhome, $ownerId]); + +json_ok(); diff --git a/public/userportal/api/home-owners.php b/public/userportal/api/home-owners.php new file mode 100644 index 0000000..fe09cda --- /dev/null +++ b/public/userportal/api/home-owners.php @@ -0,0 +1,37 @@ +prepare( + 'SELECT po.*, ho.ownership_percentage, ho.notes + FROM home_owners ho + JOIN property_owners po ON po.owner_id = ho.owner_id + WHERE ho.home_id = ? + ORDER BY po.last_name, po.company_name' +); +$stmt->execute([$idhome]); + +json_data(array_map('present_home_owner', $stmt->fetchAll())); diff --git a/public/userportal/api/home-photo.php b/public/userportal/api/home-photo.php new file mode 100644 index 0000000..ee72679 --- /dev/null +++ b/public/userportal/api/home-photo.php @@ -0,0 +1,62 @@ +prepare('UPDATE home SET mainphoto = ? WHERE idhome = ? AND iduser = ?') + ->execute([$filename, $idhome, $user['id']]); + +$stmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1'); +$stmt->execute([$idhome]); + +json_data(present_home($stmt->fetch(), true, home_counts($pdo, [$idhome])[$idhome] ?? null)); diff --git a/public/userportal/api/home-report.php b/public/userportal/api/home-report.php new file mode 100644 index 0000000..0df7158 --- /dev/null +++ b/public/userportal/api/home-report.php @@ -0,0 +1,72 @@ +prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1'); +$stmt->execute([$idhome]); +$home = $stmt->fetch(); +if (!$home) { + json_error(404, 'Home not found'); +} + +// TCPDF is provided by the main app composer autoload. +$autoload = __DIR__ . '/../../../vendor/autoload.php'; +if (is_file($autoload)) { + require_once $autoload; +} +if (!class_exists('TCPDF')) { + json_error(501, 'PDF library (TCPDF) not installed'); +} + +$pdf = new TCPDF(); +$pdf->SetCreator('Casadoc'); +$pdf->SetTitle('Report immobile'); +$pdf->AddPage(); +$pdf->SetFont('helvetica', 'B', 16); +$pdf->Cell(0, 10, (string) ($home['name'] ?? 'Immobile'), 0, 1); +$pdf->SetFont('helvetica', '', 11); + +$rows = [ + 'Indirizzo' => trim(($home['address'] ?? '') . ', ' . ($home['zip'] ?? '') . ' ' . ($home['city'] ?? '')), + 'Comune cat.'=> $home['cadastral_municipality'] ?? '', + 'Foglio' => $home['cadastral_sheet'] ?? '', + 'Particella' => $home['cadastral_particle'] ?? '', + 'Categoria' => $home['cadastral_category'] ?? '', + 'Superficie' => $home['cadastral_surface'] ?? '', + 'Rendita' => $home['cadastral_rendita'] ?? '', +]; +foreach ($rows as $label => $value) { + $pdf->Cell(45, 8, $label . ':', 0, 0); + $pdf->Cell(0, 8, (string) $value, 0, 1); +} + +$body = $pdf->Output('report.pdf', 'S'); + +header('Content-Type: application/pdf'); +header('Content-Disposition: attachment; filename="Report_' . $idhome . '.pdf"'); +header('Content-Length: ' . strlen($body)); +echo $body; +exit; diff --git a/public/userportal/api/home-save.php b/public/userportal/api/home-save.php new file mode 100644 index 0000000..20521ba --- /dev/null +++ b/public/userportal/api/home-save.php @@ -0,0 +1,87 @@ + 0) { + // Update: own property only. + if (!user_owns_home($pdo, $user, $idhome)) { + json_error(403, 'No access to this home'); + } + if ($data) { + $set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data))); + $stmt = $pdo->prepare("UPDATE home SET $set WHERE idhome = ? AND iduser = ?"); + $stmt->execute([...array_values($data), $idhome, $user['id']]); + } +} else { + // Create. + // These columns are NOT NULL without a default in the legacy schema. + foreach (['name', 'address', 'zip', 'city', 'country', 'cadastral_municipality', + 'latitude', 'longitude', 'fulladdress'] as $col) { + $data[$col] ??= ''; + } + $data['iduser'] = $user['id']; + $cols = implode(', ', array_keys($data)); + $ph = implode(', ', array_fill(0, count($data), '?')); + $stmt = $pdo->prepare("INSERT INTO home ($cols) VALUES ($ph)"); + $stmt->execute(array_values($data)); + $idhome = (int) $pdo->lastInsertId(); +} + +$stmt = $pdo->prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1'); +$stmt->execute([$idhome]); + +json_data(present_home($stmt->fetch(), true, home_counts($pdo, [$idhome])[$idhome] ?? null)); diff --git a/public/userportal/api/home.php b/public/userportal/api/home.php new file mode 100644 index 0000000..8bf51c1 --- /dev/null +++ b/public/userportal/api/home.php @@ -0,0 +1,39 @@ +prepare('SELECT * FROM home WHERE idhome = ? LIMIT 1'); +$stmt->execute([$idhome]); +$home = $stmt->fetch(); + +if (!$home) { + json_error(404, 'Home not found'); +} + +$counts = home_counts($pdo, [$idhome]); + +json_data(present_home($home, (int) $home['iduser'] === (int) $user['id'], $counts[$idhome] ?? null)); diff --git a/public/userportal/api/homes-shared.php b/public/userportal/api/homes-shared.php new file mode 100644 index 0000000..5c94df0 --- /dev/null +++ b/public/userportal/api/homes-shared.php @@ -0,0 +1,35 @@ +prepare( + "SELECT h.* FROM home_sharing hs + JOIN home h ON h.idhome = hs.idhome + WHERE (hs.idshareduser = ? OR hs.shared_email = ?) + AND hs.status = 'accepted' + AND (hs.expiration_date IS NULL OR hs.expiration_date >= CURDATE()) + GROUP BY h.idhome + ORDER BY h.idhome DESC" +); +$stmt->execute([$user['id'], $user['email']]); + +$rows = $stmt->fetchAll(); + +$counts = home_counts($pdo, array_column($rows, 'idhome')); +$homes = array_map(fn ($h) => present_home($h, false, $counts[(int) $h['idhome']] ?? null), $rows); + +json_data($homes); diff --git a/public/userportal/api/homes.php b/public/userportal/api/homes.php new file mode 100644 index 0000000..3d2269d --- /dev/null +++ b/public/userportal/api/homes.php @@ -0,0 +1,26 @@ +prepare('SELECT * FROM home WHERE iduser = ? ORDER BY idhome DESC'); +$stmt->execute([$user['id']]); +$rows = $stmt->fetchAll(); + +$counts = home_counts($pdo, array_column($rows, 'idhome')); +$homes = array_map(fn ($h) => present_home($h, true, $counts[(int) $h['idhome']] ?? null), $rows); + +json_data($homes); diff --git a/public/userportal/api/login.php b/public/userportal/api/login.php new file mode 100644 index 0000000..56ce529 --- /dev/null +++ b/public/userportal/api/login.php @@ -0,0 +1,68 @@ +prepare('SELECT * FROM auth_users WHERE email = ? OR username = ? LIMIT 1'); +$stmt->execute([$login, $login]); +$user = $stmt->fetch(); + +if (!$user || !password_verify($pass, (string) $user['password'])) { + json_error(401, 'Invalid credentials'); +} + +// Distinct code so the app opens the verification screen instead of blaming the password. +if (empty($user['email_verified_at'])) { + json_error(403, 'E-mail is not verified', null, 'email_not_verified'); +} + +$plain = bin2hex(random_bytes(32)); +$config = require __DIR__ . '/config.php'; +$expires = (new DateTimeImmutable("+{$config['token_ttl_days']} days"))->format('Y-m-d H:i:s'); + +$pdo->prepare( + 'INSERT INTO api_tokens (user_id, name, token, expires_at, created_at) + VALUES (?, ?, ?, ?, NOW())' +)->execute([$user['id'], $device, hash('sha256', $plain), $expires]); + +json_data([ + 'token' => $plain, + 'user' => present_user($user), +]); diff --git a/public/userportal/api/logout.php b/public/userportal/api/logout.php new file mode 100644 index 0000000..accfaf5 --- /dev/null +++ b/public/userportal/api/logout.php @@ -0,0 +1,20 @@ +prepare('DELETE FROM api_tokens WHERE token = ?')->execute([hash('sha256', (string) $token)]); + +json_ok(); diff --git a/public/userportal/api/me-avatar.php b/public/userportal/api/me-avatar.php new file mode 100644 index 0000000..d981d26 --- /dev/null +++ b/public/userportal/api/me-avatar.php @@ -0,0 +1,57 @@ + ['Valid image is required']]); +} +if (!is_allowed_upload($_FILES['avatar']['tmp_name'], $_FILES['avatar']['name'], true)) { + json_error(422, 'Validation failed', ['avatar' => ['Only image files are allowed']]); +} + +$dir = $config['avatars_dir']; +if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { + json_error(500, 'Storage directory unavailable'); +} + +$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($_FILES['avatar']['name'])); +$filename = $user['id'] . '-' . time() . '-' . $safe; + +if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $dir . '/' . $filename)) { + json_error(500, 'Failed to store avatar'); +} + +// Drop the previous upload; an external (social) avatar is just a URL, nothing to delete. +$old = (string) ($user['avatar'] ?? ''); +if ($old !== '' && !str_starts_with($old, 'http') && is_file($dir . '/' . basename($old))) { + @unlink($dir . '/' . basename($old)); +} + +$pdo->prepare('UPDATE auth_users SET avatar = ? WHERE id = ?')->execute([$filename, $user['id']]); + +$stmt = $pdo->prepare('SELECT * FROM auth_users WHERE id = ? LIMIT 1'); +$stmt->execute([$user['id']]); + +json_data(present_user($stmt->fetch())); diff --git a/public/userportal/api/me-save.php b/public/userportal/api/me-save.php new file mode 100644 index 0000000..267645f --- /dev/null +++ b/public/userportal/api/me-save.php @@ -0,0 +1,46 @@ + "$c = ?", array_keys($data))); +$pdo->prepare("UPDATE auth_users SET $set WHERE id = ?") + ->execute([...array_values($data), $user['id']]); + +$stmt = $pdo->prepare('SELECT * FROM auth_users WHERE id = ? LIMIT 1'); +$stmt->execute([$user['id']]); + +json_data(present_user($stmt->fetch())); diff --git a/public/userportal/api/me.php b/public/userportal/api/me.php new file mode 100644 index 0000000..e1761af --- /dev/null +++ b/public/userportal/api/me.php @@ -0,0 +1,19 @@ +setAnalyser(new TokenAnalyser()); +} + +$openapi = $generator->generate([__DIR__]); + +$out = __DIR__ . '/openapi.yaml'; +file_put_contents($out, $openapi->toYaml()); + +echo "OpenAPI written to: {$out}\n"; diff --git a/public/userportal/api/openapi.yaml b/public/userportal/api/openapi.yaml new file mode 100644 index 0000000..85336de --- /dev/null +++ b/public/userportal/api/openapi.yaml @@ -0,0 +1,1846 @@ +openapi: 3.0.0 +info: + title: 'Casadoc Mobile API (plain PHP)' + description: 'Mobile REST API in plain PHP. Bearer tokens, uniform JSON.' + version: 1.0.0 +servers: + - + url: /userportal/api + description: 'Casadoc Mobile API' +paths: + /account-delete.php: + post: + tags: + - Auth + summary: 'Delete the account and all related data' + description: 'Immediate hard delete: documents on disk, homes, owners, sharing, tokens and the user row. Irreversible.' + requestBody: + required: true + content: + application/json: + schema: + required: + - confirm + properties: + confirm: + description: 'guard against an accidental call' + type: boolean + example: true + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /document-file-delete.php: + post: + tags: + - Documents + summary: 'Delete an uploaded file' + requestBody: + required: true + content: + application/json: + schema: + required: + - id + properties: + id: + description: doc_storage.id + type: integer + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + security: + - + bearerAuth: [] + /document-file-replace.php: + post: + tags: + - Documents + summary: 'Replace the file of an existing document' + description: 'Atomic swap: the new file is written first, the row is repointed, then the old file is removed. Metadata (document_id, expiry, note, created_at) is kept and max_documents is not checked.' + requestBody: + required: true + content: + multipart/form-data: + schema: + required: + - id + - file + properties: + id: + description: doc_storage.id + type: integer + file: + description: 'PDF or image' + type: string + format: binary + title: + description: 'optional new display name' + type: string + type: object + responses: + '200': + description: Replaced + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/UploadedFile' } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /document-file-update.php: + post: + tags: + - Documents + summary: "Update an uploaded file's metadata" + description: 'Partial update: only the fields present in the body are changed.' + requestBody: + required: true + content: + application/json: + schema: + required: + - id + properties: + id: + description: doc_storage.id + type: integer + expiry_date: + type: string + format: date + nullable: true + note: + type: string + nullable: true + title: + description: 'display name; null resets it to the requirement name' + type: string + nullable: true + document_id: + description: 'move file to another requirement' + type: integer + type: object + responses: + '200': + description: Updated + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/UploadedFile' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /document-file.php: + get: + tags: + - Documents + summary: 'Download an uploaded file' + description: 'Access: home/owner owner or accepted share.' + parameters: + - + name: id + in: query + description: doc_storage.id + required: true + schema: + type: integer + responses: + '200': + description: 'Binary file' + content: + application/octet-stream: + schema: + type: string + format: binary + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + security: + - + bearerAuth: [] + /document-templates.php: + get: + tags: + - Reference + summary: 'Document templates reference' + parameters: + - + name: slug + in: query + description: 'filter by pages.slug' + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/DocumentTemplate' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /document-upload.php: + post: + tags: + - Documents + summary: 'Upload a document file for a home' + requestBody: + required: true + content: + multipart/form-data: + schema: + required: + - idhome + - document_id + - file + properties: + idhome: + type: integer + document_id: + type: integer + file: + description: 'PDF or image' + type: string + format: binary + expiry_date: + type: string + format: date + note: + type: string + title: + description: 'optional display name' + type: string + type: object + responses: + '201': + description: 'File uploaded' + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/UploadedFile' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /documents-download.php: + get: + tags: + - Documents + summary: 'ZIP of uploaded home documents' + description: 'Owner gets all files; share recipient gets only allowed sections.' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + responses: + '200': + description: ZIP + content: + application/zip: + schema: + type: string + format: binary + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /documents.php: + get: + tags: + - Documents + summary: 'Home document requirements with uploaded files' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + - + name: slug + in: query + schema: + type: string + default: legal + - + name: section_id + in: query + schema: + type: integer + - + name: only_required + in: query + schema: + type: boolean + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + home: { $ref: '#/components/schemas/Home' } + data: { type: array, items: { $ref: '#/components/schemas/DocumentRequirement' } } + type: object + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /email-verify-resend.php: + post: + tags: + - Auth + summary: 'Re-send the e-mail verification code' + description: 'Always answers 200 so the endpoint cannot be used to probe which e-mails are registered. Silently ignored more often than once per 60 seconds.' + requestBody: + required: true + content: + application/json: + schema: + required: + - email + properties: + email: + type: string + format: email + type: object + responses: + '200': + $ref: '#/components/responses/Success' + security: [] + /email-verify.php: + post: + tags: + - Auth + summary: 'Confirm the e-mail with a 6-digit code and log in' + description: 'On success a bearer token is issued, so the app enters without a second login. Error codes: code_invalid (retry) or code_expired (request a new code).' + requestBody: + required: true + content: + application/json: + schema: + required: + - email + - code + - device_name + properties: + email: + type: string + format: email + code: + type: string + example: '123456' + device_name: + type: string + example: 'iPhone 15' + type: object + responses: + '200': + description: Verified + content: + application/json: + schema: + properties: + data: { properties: { token: { type: string }, user: { $ref: '#/components/schemas/User' } }, type: object } + type: object + '422': + $ref: '#/components/responses/ValidationError' + security: [] + /home-delete.php: + post: + tags: + - Homes + summary: 'Delete property (cascade files and sharing)' + requestBody: + required: true + content: + application/json: + schema: + required: + - idhome + properties: + idhome: + type: integer + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /home-owner-attach.php: + post: + tags: + - Owners + summary: 'Attach owner to home with share' + description: 'Requires ownership of both home and owner. Ensures total share ≤ 100%.' + requestBody: + required: true + content: + application/json: + schema: + required: + - idhome + - owner_id + - ownership_percentage + properties: + idhome: + type: integer + owner_id: + type: integer + ownership_percentage: + type: number + format: float + notes: + type: string + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /home-owner-detach.php: + post: + tags: + - Owners + summary: 'Detach owner from home' + requestBody: + required: true + content: + application/json: + schema: + required: + - idhome + - owner_id + properties: + idhome: + type: integer + owner_id: + type: integer + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /home-owners.php: + get: + tags: + - Owners + summary: 'Home owners with shares' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/HomeOwner' } } + type: object + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /home-photo.php: + post: + tags: + - Homes + summary: 'Upload property main photo' + requestBody: + required: true + content: + multipart/form-data: + schema: + required: + - idhome + - photo + properties: + idhome: + type: integer + photo: + description: image + type: string + format: binary + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Home' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /home-report.php: + get: + tags: + - Homes + summary: 'Property PDF report' + description: 'Access: owner or accepted share. Requires TCPDF library (composer).' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + responses: + '200': + description: PDF + content: + application/pdf: + schema: + type: string + format: binary + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /home-save.php: + post: + tags: + - Homes + summary: 'Create or update property' + description: 'idhome missing/0 creates; otherwise updates own property.' + requestBody: + required: true + content: + application/json: + schema: + properties: + idhome: + type: integer + name: + type: string + comment: + type: string + fulladdress: + type: string + address: + type: string + zip: + type: string + city: + type: string + country: + type: string + latitude: + type: string + longitude: + type: string + cadastral_municipality: + type: string + cadastral_section: + type: string + cadastral_sheet: + type: string + cadastral_particle: + type: string + cadastral_sub: + type: string + cadastral_category: + type: string + cadastral_class: + type: string + cadastral_surface: + type: string + cadastral_rendita: + type: string + cadastral_notes: + type: string + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Home' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /home.php: + get: + tags: + - Homes + summary: 'Property data' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Home' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + security: + - + bearerAuth: [] + /homes-shared.php: + get: + tags: + - Sharing + summary: 'Homes shared with me (status=accepted)' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Home' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /homes.php: + get: + tags: + - Homes + summary: 'My properties' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Home' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /login.php: + post: + tags: + - Auth + summary: 'Log in, issue bearer token' + requestBody: + required: true + content: + application/json: + schema: + required: + - login + - password + - device_name + properties: + login: + description: 'e-mail or username' + type: string + password: + type: string + format: password + device_name: + type: string + example: 'iPhone 15' + type: object + responses: + '200': + description: Success + content: + application/json: + schema: + properties: + data: { properties: { token: { type: string }, user: { $ref: '#/components/schemas/User' } }, type: object } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: 'E-mail not verified (code email_not_verified)' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + $ref: '#/components/responses/ValidationError' + security: [] + /logout.php: + post: + tags: + - Auth + summary: 'Revoke current token' + responses: + '200': + $ref: '#/components/responses/Success' + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /me-avatar.php: + post: + tags: + - Auth + summary: "Upload the current user's avatar" + description: 'Stored under public/upload/users; the User.avatar field is returned as an absolute URL.' + requestBody: + required: true + content: + multipart/form-data: + schema: + required: + - avatar + properties: + avatar: + description: image + type: string + format: binary + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/User' } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /me-save.php: + post: + tags: + - Auth + summary: "Update the current user's profile" + description: 'Partial update: only the keys present in the body are changed. E-mail is read-only here.' + requestBody: + required: true + content: + application/json: + schema: + properties: + first_name: + type: string + nullable: true + last_name: + type: string + nullable: true + phone: + type: string + nullable: true + address: + type: string + nullable: true + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/User' } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /me.php: + get: + tags: + - Auth + summary: 'Current user' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/User' } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /owner-delete.php: + post: + tags: + - Owners + summary: 'Delete owner' + requestBody: + required: true + content: + application/json: + schema: + required: + - owner_id + properties: + owner_id: + type: integer + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /owner-document-upload.php: + post: + tags: + - Documents + summary: 'Upload a document file for an owner' + requestBody: + required: true + content: + multipart/form-data: + schema: + required: + - owner_id + - document_id + - file + properties: + owner_id: + type: integer + document_id: + type: integer + file: + description: 'PDF or image' + type: string + format: binary + expiry_date: + type: string + format: date + note: + type: string + title: + description: 'optional display name' + type: string + type: object + responses: + '201': + description: 'File uploaded' + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/UploadedFile' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /owner-documents.php: + get: + tags: + - Documents + summary: 'Owner personal documents with uploaded files' + parameters: + - + name: owner_id + in: query + required: true + schema: + type: integer + - + name: slug + in: query + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/DocumentRequirement' } } + type: object + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /owner-save.php: + post: + tags: + - Owners + summary: 'Create or update owner' + description: 'owner_id missing/0 → create; otherwise update own owner.' + requestBody: + required: true + content: + application/json: + schema: + required: + - tax_code + properties: + owner_id: + type: integer + owner_type: + type: string + enum: [individual, company] + first_name: + type: string + last_name: + type: string + company_name: + type: string + tax_code: + type: string + email: + type: string + phone: + type: string + address: + type: string + postal_code: + type: string + city: + type: string + province: + type: string + country: + type: integer + role: + type: string + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Owner' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /owner.php: + get: + tags: + - Owners + summary: 'Owner details' + parameters: + - + name: owner_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Owner' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + security: + - + bearerAuth: [] + /owners.php: + get: + tags: + - Owners + summary: 'My owners' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Owner' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /pages.php: + get: + tags: + - Reference + summary: 'Category pages reference' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Page' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /password-change.php: + post: + tags: + - Auth + summary: 'Change the password of the signed-in user' + description: 'Revokes every other token; the current device stays signed in.' + requestBody: + required: true + content: + application/json: + schema: + required: + - current_password + - new_password + properties: + current_password: + type: string + format: password + new_password: + type: string + format: password + minLength: 8 + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '401': + $ref: '#/components/responses/Unauthorized' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /password-forgot.php: + post: + tags: + - Auth + summary: 'Send a password reset code' + description: 'Always answers 200, even for an unknown e-mail, so the endpoint cannot be used to enumerate accounts.' + requestBody: + required: true + content: + application/json: + schema: + required: + - email + properties: + email: + type: string + format: email + type: object + responses: + '200': + $ref: '#/components/responses/Success' + security: [] + /password-reset.php: + post: + tags: + - Auth + summary: 'Reset the password with a 6-digit code' + description: 'Revokes every token, so all devices are signed out. Error codes: code_invalid or code_expired.' + requestBody: + required: true + content: + application/json: + schema: + required: + - email + - code + - password + properties: + email: + type: string + format: email + code: + type: string + example: '123456' + password: + type: string + format: password + minLength: 8 + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '422': + $ref: '#/components/responses/ValidationError' + security: [] + /register.php: + post: + tags: + - Auth + summary: 'Register an account and send a verification code' + description: 'No token is issued: the e-mail must be confirmed via email-verify.php first.' + requestBody: + required: true + content: + application/json: + schema: + required: + - email + - password + properties: + email: + type: string + format: email + password: + type: string + format: password + minLength: 8 + first_name: + type: string + last_name: + type: string + type: object + responses: + '201': + $ref: '#/components/responses/Success' + '422': + $ref: '#/components/responses/ValidationError' + security: [] + /sections.php: + get: + tags: + - Documents + summary: 'Home document sections' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + - + name: slug + in: query + schema: + type: string + default: legal + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Section' } } + type: object + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /share-delete.php: + post: + tags: + - Sharing + summary: 'Delete a sharing condition' + requestBody: + required: true + content: + application/json: + schema: + required: + - idsharing + properties: + idsharing: + type: integer + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /share-respond.php: + post: + tags: + - Sharing + summary: 'Accept/decline an invitation' + requestBody: + required: true + content: + application/json: + schema: + required: + - idsharing + - status + properties: + idsharing: + type: integer + status: + type: string + enum: [accepted, rejected] + type: object + responses: + '200': + $ref: '#/components/responses/Success' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /share-save.php: + post: + tags: + - Sharing + summary: 'Share a home by e-mail' + description: 'status=accepted if the e-mail exists in auth_users, otherwise pending.' + requestBody: + required: true + content: + application/json: + schema: + required: + - idhome + - shared_email + - sharing_type + - role_id + properties: + idhome: + type: integer + shared_email: + type: string + format: email + role_id: + type: integer + sharing_type: + type: string + enum: [read-only, add-documents, full-control] + shared_sections: + type: array + items: { type: integer } + expiration_date: + type: string + format: date + type: object + responses: + '201': + description: Created + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Share' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/ValidationError' + security: + - + bearerAuth: [] + /share-update.php: + post: + tags: + - Sharing + summary: 'Update a sharing condition' + requestBody: + required: true + content: + application/json: + schema: + required: + - idsharing + properties: + idsharing: + type: integer + shared_email: + type: string + format: email + role_id: + type: integer + sharing_type: + type: string + shared_sections: + type: array + items: { type: integer } + expiration_date: + type: string + format: date + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { $ref: '#/components/schemas/Share' } + type: object + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /shares-incoming.php: + get: + tags: + - Sharing + summary: 'Incoming invitations (shared with me)' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Share' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] + /shares.php: + get: + tags: + - Sharing + summary: 'Sharing conditions for my home' + parameters: + - + name: idhome + in: query + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/Share' } } + type: object + '403': + $ref: '#/components/responses/Forbidden' + security: + - + bearerAuth: [] + /sharing-roles.php: + get: + tags: + - Reference + summary: 'Sharing roles reference' + responses: + '200': + description: OK + content: + application/json: + schema: + properties: + data: { type: array, items: { $ref: '#/components/schemas/SharingRole' } } + type: object + '401': + $ref: '#/components/responses/Unauthorized' + security: + - + bearerAuth: [] +components: + schemas: + Error: + properties: + error: + properties: + code: + type: string + example: forbidden + message: + type: string + fields: + description: 'field -> array of errors (422 only)' + type: object + nullable: true + type: object + type: object + User: + properties: + id: + type: integer + first_name: + type: string + nullable: true + last_name: + type: string + nullable: true + email: + type: string + format: email + username: + type: string + nullable: true + phone: + type: string + nullable: true + address: + type: string + nullable: true + avatar: + description: 'absolute URL' + type: string + nullable: true + type: object + Home: + properties: + idhome: + type: integer + name: + type: string + nullable: true + comment: + type: string + nullable: true + fulladdress: + type: string + nullable: true + address: + type: string + nullable: true + zip: + type: string + nullable: true + city: + type: string + nullable: true + country: + type: string + nullable: true + latitude: + type: string + nullable: true + longitude: + type: string + nullable: true + mainphoto: + type: string + nullable: true + photo_url: + type: string + nullable: true + cadastral_municipality: + type: string + nullable: true + cadastral_section: + type: string + nullable: true + cadastral_sheet: + type: string + nullable: true + cadastral_particle: + type: string + nullable: true + cadastral_sub: + type: string + nullable: true + cadastral_category: + type: string + nullable: true + cadastral_class: + type: string + nullable: true + cadastral_surface: + type: string + nullable: true + cadastral_rendita: + type: string + nullable: true + cadastral_notes: + type: string + nullable: true + is_owner: + type: boolean + documents_count: + description: 'uploaded files for the property' + type: integer + expiring_count: + description: 'expiring within 60 days or already expired' + type: integer + type: object + Section: + properties: + idsections: + type: integer + section_name: + type: string + description: + type: string + nullable: true + type: object + UploadedFile: + properties: + id: + type: integer + document_id: + type: integer + title: + description: 'user-defined display name; falls back to the requirement name' + type: string + nullable: true + idhome: + type: integer + nullable: true + owner_id: + type: integer + nullable: true + filename: + type: string + size: + description: 'bytes on disk, null if missing' + type: integer + nullable: true + url: + type: string + expiry_date: + type: string + format: date + nullable: true + expiry_status: + description: 'tinyint 0/1' + type: integer + nullable: true + note: + type: string + nullable: true + created_at: + type: string + nullable: true + type: object + DocumentRequirement: + description: 'Document template (documents) plus its uploaded files' + properties: + document_id: + type: integer + document_name: + type: string + page_id: + type: integer + nullable: true + idsections: + type: integer + nullable: true + section_name: + type: string + nullable: true + max_documents: + type: integer + is_required: + type: boolean + notes: + type: string + nullable: true + files: + type: array + items: + $ref: '#/components/schemas/UploadedFile' + type: object + DocumentTemplate: + properties: + document_id: + type: integer + document_name: + type: string + page_id: + type: integer + nullable: true + idsections: + type: integer + nullable: true + section_name: + type: string + nullable: true + max_documents: + type: integer + is_required: + type: boolean + notes: + type: string + nullable: true + type: object + Page: + properties: + idpages: + type: integer + namepages: + type: string + slug: + type: string + descriptionpages: + type: string + nullable: true + type: object + Owner: + properties: + owner_id: + type: integer + owner_type: + type: string + enum: + - individual + - company + first_name: + type: string + nullable: true + last_name: + type: string + nullable: true + company_name: + type: string + nullable: true + tax_code: + type: string + email: + type: string + nullable: true + phone: + type: string + nullable: true + address: + type: string + nullable: true + postal_code: + type: string + nullable: true + city: + type: string + nullable: true + province: + type: string + nullable: true + country: + type: integer + nullable: true + role: + type: string + nullable: true + type: object + HomeOwner: + properties: + owner: + $ref: '#/components/schemas/Owner' + ownership_percentage: + type: number + format: float + notes: + type: string + nullable: true + type: object + SharingRole: + properties: + idrole: + type: integer + role_name: + type: string + description: + type: string + nullable: true + permissions: + type: array + items: + type: string + type: object + Share: + properties: + idsharing: + type: integer + idhome: + type: integer + shared_email: + type: string + format: email + idshareduser: + type: integer + nullable: true + role: + oneOf: + - + $ref: '#/components/schemas/SharingRole' + nullable: true + sharing_type: + type: string + shared_sections: + type: array + items: + type: integer + expiration_date: + type: string + format: date + nullable: true + status: + type: string + enum: + - pending + - accepted + - rejected + type: object + responses: + Unauthorized: + description: 'Missing/invalid token' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: 'No access to resource' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: 'Not found' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ValidationError: + description: 'Validation error' + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Success: + description: Success + content: + application/json: + schema: + properties: + success: + type: boolean + example: true + type: object + securitySchemes: + bearerAuth: + type: http + description: 'Token from POST /login.php. Header: Authorization: Bearer ' + scheme: bearer +tags: + - + name: Auth + description: 'Login, tokens, current user' + - + name: Homes + description: Properties + - + name: Documents + description: 'Document requirements, files, sections' + - + name: Owners + description: 'Owners and home links' + - + name: Sharing + description: 'Home sharing and invitations' + - + name: Reference + description: 'Read-only reference data' diff --git a/public/userportal/api/owner-delete.php b/public/userportal/api/owner-delete.php new file mode 100644 index 0000000..bc3747d --- /dev/null +++ b/public/userportal/api/owner-delete.php @@ -0,0 +1,39 @@ +beginTransaction(); +try { + $pdo->prepare('DELETE FROM home_owners WHERE owner_id = ?')->execute([$ownerId]); + $pdo->prepare('DELETE FROM property_owners WHERE owner_id = ? AND user_id = ?')->execute([$ownerId, $user['id']]); + $pdo->commit(); +} catch (Throwable $e) { + $pdo->rollBack(); + json_error(500, 'Delete failed'); +} + +json_ok(); diff --git a/public/userportal/api/owner-document-upload.php b/public/userportal/api/owner-document-upload.php new file mode 100644 index 0000000..98cb739 --- /dev/null +++ b/public/userportal/api/owner-document-upload.php @@ -0,0 +1,76 @@ +prepare( + "INSERT INTO doc_storage (idhome, entity_type, owner_id, document_id, title, filename, expirystatus, expirydate, note, created_at, updated_at) + VALUES (NULL, 'person', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())" +); +$stmt->execute([$ownerId, $documentId, $title ?: null, $filename, $expiryStatus, $expiry ?: null, $note ?: null]); +$id = (int) $pdo->lastInsertId(); + +$row = $pdo->prepare('SELECT * FROM doc_storage WHERE id = ?'); +$row->execute([$id]); + +json_data(present_file($row->fetch()), 201); diff --git a/public/userportal/api/owner-documents.php b/public/userportal/api/owner-documents.php new file mode 100644 index 0000000..2e8ffd2 --- /dev/null +++ b/public/userportal/api/owner-documents.php @@ -0,0 +1,58 @@ +prepare($sql); +$docStmt->execute($params); +$documents = $docStmt->fetchAll(); + +$filesStmt = $pdo->prepare('SELECT * FROM doc_storage WHERE owner_id = ?'); +$filesStmt->execute([$ownerId]); +$filesByDoc = []; +foreach ($filesStmt->fetchAll() as $f) { + $filesByDoc[(int) $f['document_id']][] = present_file($f); +} + +$result = array_map(function ($d) use ($filesByDoc) { + $tpl = present_document_template($d); + $tpl['files'] = $filesByDoc[(int) $d['document_id']] ?? []; + return $tpl; +}, $documents); + +json_data($result); diff --git a/public/userportal/api/owner-save.php b/public/userportal/api/owner-save.php new file mode 100644 index 0000000..7879b81 --- /dev/null +++ b/public/userportal/api/owner-save.php @@ -0,0 +1,80 @@ + ['Required']]); +} + +$data = []; +foreach ($allowed as $col) { + if (array_key_exists($col, $in)) { + $data[$col] = $in[$col] !== '' ? $in[$col] : null; + } +} + +$ownerId = (int) ($in['owner_id'] ?? 0); + +if ($ownerId > 0) { + if (!user_owns_owner($pdo, $user, $ownerId)) { + json_error(403, 'No access to this owner'); + } + if ($data) { + $set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data))); + $pdo->prepare("UPDATE property_owners SET $set WHERE owner_id = ? AND user_id = ?") + ->execute([...array_values($data), $ownerId, $user['id']]); + } +} else { + $data['user_id'] = $user['id']; + // NOT NULL without a default in the legacy schema. + $data['owner_type'] ??= 'individual'; + $data['email'] ??= ''; + $cols = implode(', ', array_keys($data)); + $ph = implode(', ', array_fill(0, count($data), '?')); + $pdo->prepare("INSERT INTO property_owners ($cols) VALUES ($ph)") + ->execute(array_values($data)); + $ownerId = (int) $pdo->lastInsertId(); +} + +$stmt = $pdo->prepare('SELECT * FROM property_owners WHERE owner_id = ? LIMIT 1'); +$stmt->execute([$ownerId]); + +json_data(present_owner($stmt->fetch())); diff --git a/public/userportal/api/owner.php b/public/userportal/api/owner.php new file mode 100644 index 0000000..ec92eb6 --- /dev/null +++ b/public/userportal/api/owner.php @@ -0,0 +1,36 @@ +prepare('SELECT * FROM property_owners WHERE owner_id = ? LIMIT 1'); +$stmt->execute([$ownerId]); +$owner = $stmt->fetch(); + +if (!$owner) { + json_error(404, 'Owner not found'); +} +if ((int) $owner['user_id'] !== (int) $user['id']) { + json_error(403, 'No access to this owner'); +} + +json_data(present_owner($owner)); diff --git a/public/userportal/api/owners.php b/public/userportal/api/owners.php new file mode 100644 index 0000000..4d5775d --- /dev/null +++ b/public/userportal/api/owners.php @@ -0,0 +1,22 @@ +prepare('SELECT * FROM property_owners WHERE user_id = ? ORDER BY owner_id DESC'); +$stmt->execute([$user['id']]); + +json_data(array_map('present_owner', $stmt->fetchAll())); diff --git a/public/userportal/api/pages.php b/public/userportal/api/pages.php new file mode 100644 index 0000000..2563bde --- /dev/null +++ b/public/userportal/api/pages.php @@ -0,0 +1,21 @@ +query('SELECT * FROM pages ORDER BY idpages'); + +json_data(array_map('present_page', $stmt->fetchAll())); diff --git a/public/userportal/api/password-change.php b/public/userportal/api/password-change.php new file mode 100644 index 0000000..ebd4042 --- /dev/null +++ b/public/userportal/api/password-change.php @@ -0,0 +1,42 @@ + ['At least 8 characters']]); +} +if (!password_verify($current, (string) $user['password'])) { + json_error(422, 'Validation failed', ['current_password' => ['Wrong password']]); +} + +$pdo->prepare('UPDATE auth_users SET password = ? WHERE id = ?') + ->execute([password_hash($new, PASSWORD_DEFAULT), $user['id']]); + +// Keep the device that just changed the password signed in. +$pdo->prepare('DELETE FROM api_tokens WHERE user_id = ? AND token <> ?') + ->execute([$user['id'], hash('sha256', (string) bearer_token())]); + +json_ok(); diff --git a/public/userportal/api/password-forgot.php b/public/userportal/api/password-forgot.php new file mode 100644 index 0000000..a061245 --- /dev/null +++ b/public/userportal/api/password-forgot.php @@ -0,0 +1,32 @@ +prepare('SELECT id FROM auth_users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + if ($stmt->fetchColumn() && $code = issue_code($pdo, 'auth_password_resets', $email)) { + send_code_mail($email, $code, true); + } +} + +json_ok(); diff --git a/public/userportal/api/password-reset.php b/public/userportal/api/password-reset.php new file mode 100644 index 0000000..f72cbe9 --- /dev/null +++ b/public/userportal/api/password-reset.php @@ -0,0 +1,64 @@ +prepare('SELECT id FROM auth_users WHERE email = ? LIMIT 1'); +$stmt->execute([$email]); +$userId = $stmt->fetchColumn(); +if (!$userId) { + json_error(422, 'Invalid code', null, 'code_invalid'); +} + +// Receiving the code proves the address, so the e-mail counts as verified too. +$pdo->prepare( + 'UPDATE auth_users + SET password = ?, email_verified_at = COALESCE(email_verified_at, NOW()) + WHERE id = ?' +)->execute([password_hash($password, PASSWORD_DEFAULT), $userId]); + +$pdo->prepare('DELETE FROM api_tokens WHERE user_id = ?')->execute([$userId]); + +json_ok(); diff --git a/public/userportal/api/register.php b/public/userportal/api/register.php new file mode 100644 index 0000000..caf1f01 --- /dev/null +++ b/public/userportal/api/register.php @@ -0,0 +1,63 @@ +prepare('SELECT 1 FROM auth_users WHERE email = ? LIMIT 1'); +$exists->execute([$email]); +if ($exists->fetchColumn()) { + json_error(422, 'Validation failed', ['email' => ['Already registered']]); +} + +// role_id 2 is "User"; 1 is "Admin" and must never be handed out here. +$pdo->prepare( + "INSERT INTO auth_users (email, password, first_name, last_name, role_id, status, email_verified_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 2, 'Active', NULL, NOW(), NOW())" +)->execute([ + $email, + password_hash($password, PASSWORD_DEFAULT), + trim((string) ($in['first_name'] ?? '')) ?: null, + trim((string) ($in['last_name'] ?? '')) ?: null, +]); + +if ($code = issue_code($pdo, 'auth_email_verifications', $email)) { + send_code_mail($email, $code); +} + +http_response_code(201); +echo json_encode(['success' => true]); diff --git a/public/userportal/api/sections.php b/public/userportal/api/sections.php new file mode 100644 index 0000000..e57debc --- /dev/null +++ b/public/userportal/api/sections.php @@ -0,0 +1,40 @@ +prepare( + 'SELECT DISTINCT s.idsections, s.section_name, s.description + FROM documents d + JOIN sections s ON s.idsections = d.idsections + JOIN pages p ON p.idpages = d.page_id + WHERE p.slug = ? + ORDER BY s.section_name' +); +$stmt->execute([$slug]); + +json_data(array_map('present_section', $stmt->fetchAll())); diff --git a/public/userportal/api/share-delete.php b/public/userportal/api/share-delete.php new file mode 100644 index 0000000..37bde51 --- /dev/null +++ b/public/userportal/api/share-delete.php @@ -0,0 +1,32 @@ +prepare('DELETE FROM home_sharing WHERE idsharing = ? AND iduser = ?') + ->execute([$idsharing, $user['id']]); + +json_ok(); diff --git a/public/userportal/api/share-respond.php b/public/userportal/api/share-respond.php new file mode 100644 index 0000000..4cdb519 --- /dev/null +++ b/public/userportal/api/share-respond.php @@ -0,0 +1,44 @@ +prepare( + 'SELECT * FROM home_sharing WHERE idsharing = ? AND (idshareduser = ? OR shared_email = ?) LIMIT 1' +); +$stmt->execute([$idsharing, $user['id'], $user['email']]); +if (!$stmt->fetch()) { + json_error(403, 'No access to this invitation'); +} + +// Set status and bind the recipient if they were invited by e-mail. +$pdo->prepare('UPDATE home_sharing SET status = ?, idshareduser = ? WHERE idsharing = ?') + ->execute([$status, $user['id'], $idsharing]); + +json_ok(); diff --git a/public/userportal/api/share-save.php b/public/userportal/api/share-save.php new file mode 100644 index 0000000..9478bda --- /dev/null +++ b/public/userportal/api/share-save.php @@ -0,0 +1,77 @@ +prepare('SELECT id FROM auth_users WHERE email = ? LIMIT 1'); +$recv->execute([$email]); +$sharedUserId = $recv->fetchColumn(); +$status = $sharedUserId !== false ? 'accepted' : 'pending'; + +$pdo->prepare( + 'INSERT INTO home_sharing (idhome, iduser, shared_email, idshareduser, role_id, sharing_type, shared_sections, expiration_date, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' +)->execute([ + $idhome, $user['id'], $email, $sharedUserId ?: null, $roleId, $type, $sections, $expiry, $status, +]); +$idsharing = (int) $pdo->lastInsertId(); + +// TODO: send e-mail to the recipient (legacy: tools/mailer.php). + +$stmt = $pdo->prepare( + 'SELECT hs.*, sr.role_name, sr.description AS role_description, sr.permissions AS role_permissions + FROM home_sharing hs + LEFT JOIN sharing_roles sr ON sr.idrole = hs.role_id + WHERE hs.idsharing = ? LIMIT 1' +); +$stmt->execute([$idsharing]); + +json_data(present_share($stmt->fetch()), 201); diff --git a/public/userportal/api/share-update.php b/public/userportal/api/share-update.php new file mode 100644 index 0000000..3cbaf41 --- /dev/null +++ b/public/userportal/api/share-update.php @@ -0,0 +1,65 @@ + fn ($v) => (string) $v, + 'role_id' => fn ($v) => $v !== '' ? (int) $v : null, + 'sharing_type' => fn ($v) => (string) $v, + 'expiration_date' => fn ($v) => $v !== '' ? $v : null, + 'shared_sections' => fn ($v) => is_array($v) ? json_encode(array_map('intval', $v)) : null, +]; + +$data = []; +foreach ($map as $col => $cast) { + if (array_key_exists($col, $in)) { + $data[$col] = $cast($in[$col]); + } +} + +if ($data) { + $set = implode(', ', array_map(fn ($c) => "$c = ?", array_keys($data))); + $pdo->prepare("UPDATE home_sharing SET $set WHERE idsharing = ? AND iduser = ?") + ->execute([...array_values($data), $idsharing, $user['id']]); +} + +$stmt = $pdo->prepare( + 'SELECT hs.*, sr.role_name, sr.description AS role_description, sr.permissions AS role_permissions + FROM home_sharing hs + LEFT JOIN sharing_roles sr ON sr.idrole = hs.role_id + WHERE hs.idsharing = ? LIMIT 1' +); +$stmt->execute([$idsharing]); + +json_data(present_share($stmt->fetch())); diff --git a/public/userportal/api/shares-incoming.php b/public/userportal/api/shares-incoming.php new file mode 100644 index 0000000..70e2d5e --- /dev/null +++ b/public/userportal/api/shares-incoming.php @@ -0,0 +1,28 @@ +prepare( + 'SELECT hs.*, sr.role_name, sr.description AS role_description, sr.permissions AS role_permissions + FROM home_sharing hs + LEFT JOIN sharing_roles sr ON sr.idrole = hs.role_id + WHERE hs.idshareduser = ? OR hs.shared_email = ? + ORDER BY hs.idsharing DESC' +); +$stmt->execute([$user['id'], $user['email']]); + +json_data(array_map('present_share', $stmt->fetchAll())); diff --git a/public/userportal/api/shares.php b/public/userportal/api/shares.php new file mode 100644 index 0000000..9903586 --- /dev/null +++ b/public/userportal/api/shares.php @@ -0,0 +1,37 @@ +prepare( + 'SELECT hs.*, sr.role_name, sr.description AS role_description, sr.permissions AS role_permissions + FROM home_sharing hs + LEFT JOIN sharing_roles sr ON sr.idrole = hs.role_id + WHERE hs.idhome = ? + ORDER BY hs.idsharing DESC' +); +$stmt->execute([$idhome]); + +json_data(array_map('present_share', $stmt->fetchAll())); diff --git a/public/userportal/api/sharing-roles.php b/public/userportal/api/sharing-roles.php new file mode 100644 index 0000000..d0b5abd --- /dev/null +++ b/public/userportal/api/sharing-roles.php @@ -0,0 +1,21 @@ +query('SELECT * FROM sharing_roles ORDER BY idrole'); + +json_data(array_map('present_sharing_role', $stmt->fetchAll())); diff --git a/public/userportal/api/sql/account_features.sql b/public/userportal/api/sql/account_features.sql new file mode 100644 index 0000000..cd82ca7 --- /dev/null +++ b/public/userportal/api/sql/account_features.sql @@ -0,0 +1,18 @@ +-- Casadoc Mobile API - account, profile and document-title features. + +-- 6-digit e-mail verification codes (sha256 of the code, never the code itself). +CREATE TABLE IF NOT EXISTS auth_email_verifications ( + email VARCHAR(191) NOT NULL, + token VARCHAR(191) NOT NULL, + attempts TINYINT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Same attempt limiting for password reset codes. +ALTER TABLE auth_password_resets + ADD COLUMN attempts TINYINT UNSIGNED NOT NULL DEFAULT 0; + +-- User-defined display name for an uploaded file. +ALTER TABLE doc_storage + ADD COLUMN title VARCHAR(255) NULL AFTER document_id; diff --git a/public/userportal/api/sql/api_tokens.sql b/public/userportal/api/sql/api_tokens.sql new file mode 100644 index 0000000..11f7645 --- /dev/null +++ b/public/userportal/api/sql/api_tokens.sql @@ -0,0 +1,15 @@ +-- Casadoc Mobile API - bearer token table. + +CREATE TABLE IF NOT EXISTS api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, -- auth_users.id + name VARCHAR(255) NOT NULL, -- device_name + token CHAR(64) NOT NULL, -- sha256(plaintext) + + last_used_at TIMESTAMP NULL DEFAULT NULL, + expires_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_api_tokens_token (token), + KEY idx_api_tokens_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;