Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 701b52e4c9 | |||
| 1ae5a444cf | |||
| 28fc484c9f | |||
| 3e01f4b391 | |||
| 0e12d7489f | |||
| 5c6064ba9e | |||
| 6913024596 |
@@ -39,3 +39,5 @@ PUSHER_APP_CLUSTER=mt1
|
||||
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
|
||||
# ASSETS_BASE_URL=https://casadoc.cesoft.io/public
|
||||
ASSETS_BASE_URL=
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
// Mobile API bootstrap: PDO, bearer auth, JSON helpers, ownership checks, presenters.
|
||||
// Included first in every endpoint. No Laravel/session.
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
require_once __DIR__ . '/_debug.php';
|
||||
|
||||
// Never leak PHP errors into the response; any uncaught throwable becomes a JSON 500.
|
||||
ini_set('display_errors', '0');
|
||||
set_exception_handler(function (Throwable $e): void {
|
||||
error_log('[casadoc-api] ' . $e);
|
||||
// TEMPORARY.
|
||||
debug_log('[' . ($_SERVER['REQUEST_URI'] ?? '?') . '] uncaught: ' . $e);
|
||||
if (!headers_sent()) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
}
|
||||
echo json_encode(['error' => ['code' => 'error', 'message' => 'Internal server error']]);
|
||||
});
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type');
|
||||
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
"mysql:host={$config['db_host']};port={$config['db_port']};dbname={$config['db_name']};charset=utf8mb4",
|
||||
$config['db_user'],
|
||||
$config['db_pass'],
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => ['code' => 'error', 'message' => 'Database connection failed']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_data(mixed $data, int $code = 200): never
|
||||
{
|
||||
http_response_code($code);
|
||||
echo json_encode(['data' => $data], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function json_ok(): never
|
||||
{
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
}
|
||||
|
||||
/** $errorCode overrides the status-derived code, e.g. code_expired or email_not_verified. */
|
||||
function json_error(int $code, string $message, ?array $fields = null, ?string $errorCode = null): never
|
||||
{
|
||||
static $codes = [
|
||||
400 => 'bad_request', 401 => 'unauthorized', 403 => 'forbidden',
|
||||
404 => 'not_found', 405 => 'method_not_allowed', 422 => 'validation',
|
||||
];
|
||||
http_response_code($code);
|
||||
echo json_encode(['error' => array_filter([
|
||||
'code' => $errorCode ?? ($codes[$code] ?? 'error'),
|
||||
'message' => $message,
|
||||
'fields' => $fields,
|
||||
], fn ($v) => $v !== null)], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
function require_method(string $method): void
|
||||
{
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== $method) {
|
||||
json_error(405, "Method not allowed, use {$method}");
|
||||
}
|
||||
}
|
||||
|
||||
// Request body: JSON or form-data.
|
||||
function body(): array
|
||||
{
|
||||
$raw = json_decode(file_get_contents('php://input') ?: '', true);
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
return $_POST;
|
||||
}
|
||||
|
||||
function query(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $_GET[$key] ?? $default;
|
||||
}
|
||||
|
||||
function bearer_token(): ?string
|
||||
{
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION']
|
||||
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
|
||||
?? '';
|
||||
if (!$header && function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $k => $v) {
|
||||
if (strcasecmp($k, 'Authorization') === 0) {
|
||||
$header = $v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return preg_match('/Bearer\s+(\S+)/i', $header, $m) ? $m[1] : null;
|
||||
}
|
||||
|
||||
// Validate the token and return the auth_users row, or respond 401.
|
||||
function require_auth(PDO $pdo): array
|
||||
{
|
||||
$token = bearer_token();
|
||||
if (!$token) {
|
||||
json_error(401, 'Missing bearer token');
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT u.* FROM api_tokens t
|
||||
JOIN auth_users u ON u.id = t.user_id
|
||||
WHERE t.token = ? AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
||||
LIMIT 1'
|
||||
);
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user) {
|
||||
json_error(401, 'Invalid or expired token');
|
||||
}
|
||||
$pdo->prepare('UPDATE api_tokens SET last_used_at = NOW() WHERE token = ?')
|
||||
->execute([hash('sha256', $token)]);
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads are scans: PDF or images. The type is sniffed from the content, not from the
|
||||
* client-declared Content-Type (mobile clients often send application/octet-stream).
|
||||
* The extension fallback covers formats an older libmagic may not know, e.g. HEIC.
|
||||
*/
|
||||
function is_allowed_upload(string $tmpPath, string $originalName, bool $imagesOnly = false): bool
|
||||
{
|
||||
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($tmpPath) ?: '';
|
||||
if (str_starts_with($mime, 'image/') || (!$imagesOnly && $mime === 'application/pdf')) {
|
||||
return true;
|
||||
}
|
||||
$images = ['jpg', 'jpeg', 'png', 'heic', 'heif', 'webp', 'gif', 'tif', 'tiff'];
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
return in_array($ext, $imagesOnly ? $images : [...$images, 'pdf'], true);
|
||||
}
|
||||
|
||||
function user_owns_home(PDO $pdo, array $user, int $idhome): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM home WHERE idhome = ? AND iduser = ? LIMIT 1');
|
||||
$stmt->execute([$idhome, $user['id']]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// Ownership OR an accepted, non-expired share.
|
||||
function user_can_access_home(PDO $pdo, array $user, int $idhome): bool
|
||||
{
|
||||
if (user_owns_home($pdo, $user, $idhome)) {
|
||||
return true;
|
||||
}
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT 1 FROM home_sharing
|
||||
WHERE idhome = ? AND (idshareduser = ? OR shared_email = ?)
|
||||
AND status = 'accepted'
|
||||
AND (expiration_date IS NULL OR expiration_date >= CURDATE())
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$idhome, $user['id'], $user['email']]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function user_owns_owner(PDO $pdo, array $user, int $ownerId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT 1 FROM property_owners WHERE owner_id = ? AND user_id = ? LIMIT 1');
|
||||
$stmt->execute([$ownerId, $user['id']]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
// Absolute base URL taken from the current request so generated links match the
|
||||
// host the client actually used; falls back to APP_URL (e.g. when run from CLI).
|
||||
function base_url(): string
|
||||
{
|
||||
global $config;
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host === '') {
|
||||
return $config['app_url'];
|
||||
}
|
||||
$https = ($_SERVER['HTTPS'] ?? '') !== '' && ($_SERVER['HTTPS'] ?? '') !== 'off';
|
||||
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ($https ? 'https' : 'http');
|
||||
return $proto . '://' . $host;
|
||||
}
|
||||
|
||||
// URL for a public/ asset. Uses the configured public base (ASSETS_BASE_URL) so links
|
||||
// point at the real https domain even behind a proxy; falls back to the request host.
|
||||
function asset_url(string $path): string
|
||||
{
|
||||
global $config;
|
||||
$base = $config['assets_base_url'] ?: base_url();
|
||||
return $base . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function present_user(array $u): array
|
||||
{
|
||||
// Uploaded avatars are stored as a bare filename under public/upload/users;
|
||||
// social logins already hold an absolute URL.
|
||||
$avatar = $u['avatar'] ?? null;
|
||||
if ($avatar && !str_starts_with($avatar, 'http')) {
|
||||
$avatar = asset_url('/upload/users/' . $avatar);
|
||||
}
|
||||
return [
|
||||
'id' => (int) $u['id'],
|
||||
'first_name' => $u['first_name'] ?? null,
|
||||
'last_name' => $u['last_name'] ?? null,
|
||||
'email' => $u['email'] ?? null,
|
||||
'username' => $u['username'] ?? null,
|
||||
'phone' => $u['phone'] ?? null,
|
||||
'address' => $u['address'] ?? null,
|
||||
'avatar' => $avatar,
|
||||
];
|
||||
}
|
||||
|
||||
// Documents expiring within this many days (or already expired) count as "expiring".
|
||||
const EXPIRING_SOON_DAYS = 60;
|
||||
|
||||
/** One query for a set of homes: idhome => [documents_count, expiring_count]. */
|
||||
function home_counts(PDO $pdo, array $idhomes): array
|
||||
{
|
||||
$idhomes = array_values(array_unique(array_map('intval', $idhomes)));
|
||||
if (!$idhomes) {
|
||||
return [];
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($idhomes), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT idhome,
|
||||
COUNT(*) AS documents_count,
|
||||
SUM(expirydate IS NOT NULL
|
||||
AND expirydate <= DATE_ADD(CURDATE(), INTERVAL " . EXPIRING_SOON_DAYS . " DAY)) AS expiring_count
|
||||
FROM doc_storage
|
||||
WHERE idhome IN ($in)
|
||||
GROUP BY idhome"
|
||||
);
|
||||
$stmt->execute($idhomes);
|
||||
|
||||
$counts = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$counts[(int) $row['idhome']] = [
|
||||
'documents_count' => (int) $row['documents_count'],
|
||||
'expiring_count' => (int) $row['expiring_count'],
|
||||
];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
function present_home(array $h, bool $isOwner = true, ?array $counts = null): array
|
||||
{
|
||||
$photo = $h['mainphoto'] ?? null;
|
||||
return [
|
||||
'idhome' => (int) $h['idhome'],
|
||||
'name' => $h['name'] ?? null,
|
||||
'comment' => $h['comment'] ?? null,
|
||||
'fulladdress' => $h['fulladdress'] ?? null,
|
||||
'address' => $h['address'] ?? null,
|
||||
'zip' => $h['zip'] ?? null,
|
||||
'city' => $h['city'] ?? null,
|
||||
'country' => $h['country'] ?? null,
|
||||
'latitude' => $h['latitude'] ?? null,
|
||||
'longitude' => $h['longitude'] ?? null,
|
||||
'mainphoto' => $photo,
|
||||
'photo_url' => $photo ? asset_url('/userportal/mainphoto/' . $photo) : null,
|
||||
'cadastral_municipality' => $h['cadastral_municipality'] ?? null,
|
||||
'cadastral_section' => $h['cadastral_section'] ?? null,
|
||||
'cadastral_sheet' => $h['cadastral_sheet'] ?? null,
|
||||
'cadastral_particle' => $h['cadastral_particle'] ?? null,
|
||||
'cadastral_sub' => $h['cadastral_sub'] ?? null,
|
||||
'cadastral_category' => $h['cadastral_category'] ?? null,
|
||||
'cadastral_class' => $h['cadastral_class'] ?? null,
|
||||
'cadastral_surface' => $h['cadastral_surface'] ?? null,
|
||||
'cadastral_rendita' => $h['cadastral_rendita'] ?? null,
|
||||
'cadastral_notes' => $h['cadastral_notes'] ?? null,
|
||||
'is_owner' => $isOwner,
|
||||
'documents_count' => (int) ($counts['documents_count'] ?? 0),
|
||||
'expiring_count' => (int) ($counts['expiring_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
function present_file(array $f): array
|
||||
{
|
||||
global $config;
|
||||
$isPerson = ($f['entity_type'] ?? '') === 'person' || empty($f['idhome']);
|
||||
$path = ($isPerson ? $config['persondocs_dir'] : $config['homedocs_dir'])
|
||||
. '/' . basename((string) $f['filename']);
|
||||
return [
|
||||
'id' => (int) $f['id'],
|
||||
'document_id' => (int) $f['document_id'],
|
||||
'title' => $f['title'] ?? null,
|
||||
'idhome' => isset($f['idhome']) ? (int) $f['idhome'] : null,
|
||||
'owner_id' => isset($f['owner_id']) ? (int) $f['owner_id'] : null,
|
||||
'filename' => $f['filename'],
|
||||
'size' => is_file($path) ? filesize($path) : null,
|
||||
'url' => asset_url('/userportal/api/document-file.php?id=' . (int) $f['id']),
|
||||
'expiry_date' => $f['expirydate'] ?? null,
|
||||
'expiry_status' => isset($f['expirystatus']) ? (int) $f['expirystatus'] : null,
|
||||
'note' => $f['note'] ?? null,
|
||||
'created_at' => $f['created_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_owner(array $o): array
|
||||
{
|
||||
return [
|
||||
'owner_id' => (int) $o['owner_id'],
|
||||
'owner_type' => $o['owner_type'] ?? null,
|
||||
'first_name' => $o['first_name'] ?? null,
|
||||
'last_name' => $o['last_name'] ?? null,
|
||||
'company_name' => $o['company_name'] ?? null,
|
||||
'tax_code' => $o['tax_code'] ?? null,
|
||||
'email' => $o['email'] ?? null,
|
||||
'phone' => $o['phone'] ?? null,
|
||||
'address' => $o['address'] ?? null,
|
||||
'postal_code' => $o['postal_code'] ?? null,
|
||||
'city' => $o['city'] ?? null,
|
||||
'province' => $o['province'] ?? null,
|
||||
'country' => isset($o['country']) ? (int) $o['country'] : null,
|
||||
'role' => $o['role'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// property_owners row plus home_owners fields (ownership_percentage, notes).
|
||||
function present_home_owner(array $row): array
|
||||
{
|
||||
return [
|
||||
'owner' => present_owner($row),
|
||||
'ownership_percentage' => isset($row['ownership_percentage']) ? (float) $row['ownership_percentage'] : null,
|
||||
'notes' => $row['notes'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_section(array $s): array
|
||||
{
|
||||
return [
|
||||
'idsections' => (int) $s['idsections'],
|
||||
'section_name' => $s['section_name'] ?? null,
|
||||
'description' => $s['description'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_page(array $p): array
|
||||
{
|
||||
return [
|
||||
'idpages' => (int) $p['idpages'],
|
||||
'namepages' => $p['namepages'] ?? null,
|
||||
'slug' => $p['slug'] ?? null,
|
||||
'descriptionpages' => $p['descriptionpages'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_document_template(array $d): array
|
||||
{
|
||||
return [
|
||||
'document_id' => (int) $d['document_id'],
|
||||
'document_name' => $d['document_name'] ?? null,
|
||||
'page_id' => isset($d['page_id']) ? (int) $d['page_id'] : null,
|
||||
'idsections' => isset($d['idsections']) ? (int) $d['idsections'] : null,
|
||||
'section_name' => $d['section_name'] ?? null,
|
||||
'max_documents' => (int) ($d['max_documents'] ?? 0),
|
||||
'is_required' => (bool) ($d['is_required'] ?? 0),
|
||||
'notes' => $d['notes'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function present_sharing_role(array $r): array
|
||||
{
|
||||
$perms = json_decode((string) ($r['permissions'] ?? ''), true);
|
||||
return [
|
||||
'idrole' => (int) $r['idrole'],
|
||||
'role_name' => $r['role_name'] ?? null,
|
||||
'description' => $r['description'] ?? null,
|
||||
'permissions' => is_array($perms) ? $perms : [],
|
||||
];
|
||||
}
|
||||
|
||||
// home_sharing row; role is joined via role_name/role_description/role_permissions aliases.
|
||||
function present_share(array $s): array
|
||||
{
|
||||
$sections = json_decode((string) ($s['shared_sections'] ?? ''), true);
|
||||
$role = null;
|
||||
if (!empty($s['role_id'])) {
|
||||
$perms = json_decode((string) ($s['role_permissions'] ?? ''), true);
|
||||
$role = [
|
||||
'idrole' => (int) $s['role_id'],
|
||||
'role_name' => $s['role_name'] ?? null,
|
||||
'description' => $s['role_description'] ?? null,
|
||||
'permissions' => is_array($perms) ? $perms : [],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'idsharing' => (int) $s['idsharing'],
|
||||
'idhome' => (int) $s['idhome'],
|
||||
'shared_email' => $s['shared_email'] ?? null,
|
||||
'idshareduser' => isset($s['idshareduser']) ? (int) $s['idshareduser'] : null,
|
||||
'role' => $role,
|
||||
'sharing_type' => $s['sharing_type'] ?? null,
|
||||
'shared_sections' => is_array($sections) ? array_map('intval', $sections) : [],
|
||||
'expiration_date' => $s['expiration_date'] ?? null,
|
||||
'status' => $s['status'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
// Ownership of a share (I am the one who shared).
|
||||
function user_owns_share(PDO $pdo, array $user, int $idsharing): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM home_sharing WHERE idsharing = ? AND iduser = ? LIMIT 1');
|
||||
$stmt->execute([$idsharing, $user['id']]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
// Shared rules for the 6-digit codes used by e-mail verification and password reset.
|
||||
// Both tables (auth_email_verifications, auth_password_resets) behave identically:
|
||||
// sha256 of the code is stored, it lives 15 minutes, allows 5 attempts and may be
|
||||
// re-sent at most once per 60 seconds.
|
||||
|
||||
const CODE_TTL_MINUTES = 15;
|
||||
const CODE_MAX_ATTEMPTS = 5;
|
||||
const CODE_RESEND_SECONDS = 60;
|
||||
|
||||
function generate_code(): string
|
||||
{
|
||||
return (string) random_int(100000, 999999);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a fresh code, resetting attempts. Returns the plain code, or null when the
|
||||
* previous one was issued less than CODE_RESEND_SECONDS ago (caller stays silent).
|
||||
*/
|
||||
function issue_code(PDO $pdo, string $table, string $email): ?string
|
||||
{
|
||||
$stmt = $pdo->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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// TEMPORARY
|
||||
/** Appends one timestamped line. Silent if the file cannot be written. */
|
||||
function debug_log(string $message): void
|
||||
{
|
||||
$path = dirname(__DIR__, 3) . '/storage/logs/casadoc-api.log';
|
||||
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@file_put_contents(
|
||||
$path,
|
||||
'[' . date('Y-m-d H:i:s') . '] ' . rtrim($message) . "\n",
|
||||
FILE_APPEND | LOCK_EX
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
// SMTP delivery via PHPMailer (already a project dependency).
|
||||
|
||||
require_once __DIR__ . '/../../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/_debug.php';
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\SMTP;
|
||||
|
||||
Dotenv::createImmutable([dirname(__DIR__, 2), dirname(__DIR__, 3)])->safeLoad();
|
||||
|
||||
/** Treats an empty value and the literal "null" from .env as "not set". */
|
||||
function mail_env(string $key, string $default = ''): string
|
||||
{
|
||||
$value = (string) ($_ENV[$key] ?? getenv($key) ?: '');
|
||||
|
||||
return ($value === '' || $value === 'null') ? $default : $value;
|
||||
}
|
||||
|
||||
function mail_encryption(int $port): string
|
||||
{
|
||||
$enc = strtolower(mail_env('MAIL_ENCRYPTION'));
|
||||
if ($enc === 'ssl' || $enc === 'tls') {
|
||||
return $enc;
|
||||
}
|
||||
if ($enc === 'none') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return in_array($port, [465, 2465], true) ? 'ssl' : 'tls';
|
||||
}
|
||||
|
||||
/** Never surfaces the SMTP error to the caller: it can leak host and credentials. */
|
||||
function send_mail(string $to, string $subject, string $html): bool
|
||||
{
|
||||
$port = (int) mail_env('MAIL_PORT', '587');
|
||||
$enc = mail_encryption($port);
|
||||
$where = sprintf(
|
||||
'%s@%s:%s (%s)',
|
||||
mail_env('MAIL_USERNAME'),
|
||||
mail_env('MAIL_HOST', 'localhost'),
|
||||
$port,
|
||||
$enc ?: 'none'
|
||||
);
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
try {
|
||||
$mail->isSMTP();
|
||||
$mail->Host = mail_env('MAIL_HOST', 'localhost');
|
||||
$mail->Port = $port;
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = mail_env('MAIL_USERNAME');
|
||||
$mail->Password = mail_env('MAIL_PASSWORD');
|
||||
$mail->SMTPSecure = $enc;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Timeout = 10;
|
||||
$mail->getSMTPInstance()->Timelimit = 15;
|
||||
|
||||
$from = mail_env('MAIL_FROM_ADDRESS', 'noreply@casadoc.app');
|
||||
// Without this the Message-ID is generated as <...@localhost>, which some
|
||||
// providers treat as a spam signal.
|
||||
if ($domain = substr(strrchr($from, '@') ?: '', 1)) {
|
||||
$mail->Hostname = $domain;
|
||||
}
|
||||
|
||||
$mail->setFrom($from, mail_env('MAIL_FROM_NAME', 'CasaDoc'));
|
||||
$mail->addAddress($to);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = $subject;
|
||||
$mail->Body = $html;
|
||||
|
||||
// TEMPORARY
|
||||
$queued = '';
|
||||
$mail->SMTPDebug = SMTP::DEBUG_CONNECTION;
|
||||
$mail->Debugoutput = static function (string $str, int $level) use (&$queued): void {
|
||||
foreach (preg_split('/\R/', rtrim($str)) as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/SERVER -> CLIENT: (250 (?!SIZE)\S.*)$/', $line, $m)) {
|
||||
$queued = trim($m[1]);
|
||||
}
|
||||
debug_log(" smtp[{$level}] " . rtrim($line));
|
||||
}
|
||||
};
|
||||
debug_log(sprintf(
|
||||
'mail: sending to %s via %s | from=%s | subject=%s | %d bytes html',
|
||||
$to,
|
||||
$where,
|
||||
$from,
|
||||
$subject,
|
||||
strlen($html)
|
||||
));
|
||||
|
||||
$started = microtime(true);
|
||||
$mail->send();
|
||||
|
||||
debug_log(sprintf(
|
||||
'mail: SENT to %s in %d ms | message-id=%s | accepted by server: %s',
|
||||
$to,
|
||||
(int) round((microtime(true) - $started) * 1000),
|
||||
$mail->getLastMessageID() ?: '(none)',
|
||||
$queued ?: '(not captured)'
|
||||
));
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
// Without the connection details a failure is indistinguishable from a
|
||||
// wrong recipient address. The password is never logged.
|
||||
$reason = sprintf(
|
||||
'mail FAILED to %s via %s: %s',
|
||||
$to,
|
||||
$where,
|
||||
$mail->ErrorInfo ?: $e->getMessage()
|
||||
);
|
||||
error_log($reason);
|
||||
debug_log($reason);
|
||||
if ($e->getMessage() !== '' && $e->getMessage() !== $mail->ErrorInfo) {
|
||||
debug_log(' exception: ' . get_class($e) . ': ' . $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 = '<p>' . $intro . '</p>'
|
||||
. '<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' . htmlspecialchars($code) . '</p>'
|
||||
. '<p>The code expires in 15 minutes. If you did not request it, ignore this e-mail.</p>';
|
||||
|
||||
debug_log(sprintf(
|
||||
'code: %s code %s for %s',
|
||||
$isReset ? 'password reset' : 'verification',
|
||||
$code,
|
||||
$to
|
||||
));
|
||||
|
||||
return send_mail($to, $subject, $html);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
// OpenAPI metadata and component schemas for swagger-php.
|
||||
// Annotation-only; does nothing at runtime.
|
||||
|
||||
/**
|
||||
* @OA\OpenApi(
|
||||
* @OA\Info(
|
||||
* title="Casadoc Mobile API (plain PHP)",
|
||||
* version="1.0.0",
|
||||
* description="Mobile REST API in plain PHP. Bearer tokens, uniform JSON."
|
||||
* ),
|
||||
* @OA\Server(url="/userportal/api", description="Casadoc Mobile API")
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="bearerAuth",
|
||||
* type="http",
|
||||
* scheme="bearer",
|
||||
* description="Token from POST /login.php. Header: Authorization: Bearer <token>"
|
||||
* )
|
||||
*
|
||||
* @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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/account-delete.php",
|
||||
* 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.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"confirm"},
|
||||
* @OA\Property(property="confirm", type="boolean", example=true,
|
||||
* description="guard against an accidental call")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
if (empty(body()['confirm'])) {
|
||||
json_error(422, 'Validation failed', ['confirm' => ['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();
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// Mobile API config. Values come from the environment (same .env as Laravel).
|
||||
|
||||
// Real environment variables win; anything missing is filled from the project .env
|
||||
// (docker-compose only exports DB_*, while MAIL_* live in the .env file).
|
||||
(static function (): void {
|
||||
// config.php is required by several endpoints; parse .env only once per request.
|
||||
if (defined('CASADOC_ENV_LOADED')) {
|
||||
return;
|
||||
}
|
||||
define('CASADOC_ENV_LOADED', true);
|
||||
$envFile = __DIR__ . '/../../../.env';
|
||||
if (!is_readable($envFile)) {
|
||||
return;
|
||||
}
|
||||
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim(trim(trim($value), '"'), "'");
|
||||
if ($key !== '' && getenv($key) === false) {
|
||||
putenv("$key=$value");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return [
|
||||
'db_host' => 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',
|
||||
'mainphoto_dir' => __DIR__ . '/../mainphoto',
|
||||
'avatars_dir' => __DIR__ . '/../../upload/users',
|
||||
|
||||
'app_url' => rtrim(getenv('APP_URL') ?: '', '/'),
|
||||
|
||||
'assets_base_url' => rtrim(getenv('ASSETS_BASE_URL') ?: '', '/'),
|
||||
];
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-file-delete.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Delete an uploaded file",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"id"},
|
||||
* @OA\Property(property="id", type="integer", description="doc_storage.id")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$id = (int) (body()['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_error(422, 'id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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();
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-file-replace.php",
|
||||
* 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.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"id","file"},
|
||||
* @OA\Property(property="id", type="integer", description="doc_storage.id"),
|
||||
* @OA\Property(property="file", type="string", format="binary", description="PDF or image"),
|
||||
* @OA\Property(property="title", type="string", description="optional new display name")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Replaced", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
|
||||
$fields = [];
|
||||
if ($id <= 0) {
|
||||
$fields['id'] = ['Required'];
|
||||
}
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['file'] = ['Valid file is required'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$stmt = $pdo->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()));
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-file-update.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Update an uploaded file's metadata",
|
||||
* description="Partial update: only the fields present in the body are changed.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"id"},
|
||||
* @OA\Property(property="id", type="integer", description="doc_storage.id"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date", nullable=true),
|
||||
* @OA\Property(property="note", type="string", nullable=true),
|
||||
* @OA\Property(property="title", type="string", nullable=true,
|
||||
* description="display name; null resets it to the requirement name"),
|
||||
* @OA\Property(property="document_id", type="integer", description="move file to another requirement")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Updated", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
$id = (int) ($in['id'] ?? 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_error(422, 'id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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()));
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/document-file.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Download an uploaded file",
|
||||
* description="Access: home/owner owner or accepted share.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="query", required=true, @OA\Schema(type="integer"),
|
||||
* description="doc_storage.id"),
|
||||
* @OA\Response(response=200, description="Binary file",
|
||||
* @OA\MediaType(mediaType="application/octet-stream",
|
||||
* @OA\Schema(type="string", format="binary"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$id = (int) query('id', 0);
|
||||
|
||||
if ($id <= 0) {
|
||||
json_error(422, 'id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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;
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/document-templates.php",
|
||||
* tags={"Reference"},
|
||||
* summary="Document templates reference",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string"), description="filter by pages.slug"),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DocumentTemplate"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
require_auth($pdo);
|
||||
$slug = query('slug');
|
||||
|
||||
$sql = 'SELECT d.*, s.section_name
|
||||
FROM documents d
|
||||
LEFT JOIN sections s ON s.idsections = d.idsections';
|
||||
$params = [];
|
||||
if ($slug !== null && $slug !== '') {
|
||||
$sql .= ' JOIN pages p ON p.idpages = d.page_id WHERE p.slug = ?';
|
||||
$params[] = $slug;
|
||||
}
|
||||
$sql .= ' ORDER BY s.section_name, d.document_name';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
json_data(array_map('present_document_template', $stmt->fetchAll()));
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/document-upload.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Upload a document file for a home",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"idhome","document_id","file"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="file", type="string", format="binary", description="PDF or image"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date"),
|
||||
* @OA\Property(property="note", type="string"),
|
||||
* @OA\Property(property="title", type="string", description="optional display name")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=201, description="File uploaded", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$idhome = (int) ($_POST['idhome'] ?? 0);
|
||||
$documentId = (int) ($_POST['document_id'] ?? 0);
|
||||
$expiry = $_POST['expiry_date'] ?? null;
|
||||
$note = $_POST['note'] ?? null;
|
||||
$title = $_POST['title'] ?? null;
|
||||
|
||||
$fields = [];
|
||||
if ($idhome <= 0) { $fields['idhome'] = ['Required']; }
|
||||
if ($documentId <= 0) { $fields['document_id'] = ['Required']; }
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['file'] = ['Valid file is required'];
|
||||
} elseif (!is_allowed_upload($_FILES['file']['tmp_name'], $_FILES['file']['name'])) {
|
||||
$fields['file'] = ['Only PDF or image files are allowed'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
// Owner only (not shared access) may upload.
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$dir = $config['homedocs_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['file']['name']));
|
||||
$filename = $idhome . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store file');
|
||||
}
|
||||
|
||||
$expiryStatus = $expiry ? 1 : 0;
|
||||
|
||||
$stmt = $pdo->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);
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/documents-download.php",
|
||||
* tags={"Documents"},
|
||||
* summary="ZIP of uploaded home documents",
|
||||
* description="Owner gets all files; share recipient gets only allowed sections.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="ZIP",
|
||||
* @OA\MediaType(mediaType="application/zip", @OA\Schema(type="string", format="binary"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
if (!class_exists('ZipArchive')) {
|
||||
json_error(500, 'ZipArchive extension not available');
|
||||
}
|
||||
|
||||
$isOwner = user_owns_home($pdo, $user, $idhome);
|
||||
|
||||
// Share recipient: restrict to allowed sections.
|
||||
$allowedSections = null;
|
||||
if (!$isOwner) {
|
||||
$shareStmt = $pdo->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;
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/documents.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Home document requirements with uploaded files",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string", default="legal")),
|
||||
* @OA\Parameter(name="section_id", in="query", @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="only_required", in="query", @OA\Schema(type="boolean")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="home", ref="#/components/schemas/Home"),
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentRequirement"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
$slug = (string) query('slug', 'legal');
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$homeStmt = $pdo->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);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
require __DIR__ . '/_mail.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/email-verify-resend.php",
|
||||
* 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.",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email"},
|
||||
* @OA\Property(property="email", type="string", format="email")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$email = trim((string) (body()['email'] ?? ''));
|
||||
|
||||
if ($email !== '') {
|
||||
$stmt = $pdo->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();
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/email-verify.php",
|
||||
* 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).",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email","code","device_name"},
|
||||
* @OA\Property(property="email", type="string", format="email"),
|
||||
* @OA\Property(property="code", type="string", example="123456"),
|
||||
* @OA\Property(property="device_name", type="string", example="iPhone 15")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Verified", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="token", type="string"),
|
||||
* @OA\Property(property="user", ref="#/components/schemas/User")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$in = body();
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$code = trim((string) ($in['code'] ?? ''));
|
||||
$device = trim((string) ($in['device_name'] ?? ''));
|
||||
|
||||
$fields = [];
|
||||
if ($email === '') { $fields['email'] = ['Required']; }
|
||||
if ($code === '') { $fields['code'] = ['Required']; }
|
||||
if ($device === '') { $fields['device_name'] = ['Required']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$result = check_code($pdo, 'auth_email_verifications', $email, $code);
|
||||
if ($result !== 'ok') {
|
||||
json_error(422, $result === 'code_expired' ? 'Code expired, request a new one' : 'Invalid code', null, $result);
|
||||
}
|
||||
|
||||
$stmt = $pdo->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),
|
||||
]);
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-delete.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Delete property (cascade files and sharing)",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome"},
|
||||
* @OA\Property(property="idhome", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) (body()['idhome'] ?? 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$pdo->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();
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-owner-attach.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Attach owner to home with share",
|
||||
* description="Requires ownership of both home and owner. Ensures total share ≤ 100%.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome","owner_id","ownership_percentage"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="ownership_percentage", type="number", format="float"),
|
||||
* @OA\Property(property="notes", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
$ownerId = (int) ($in['owner_id'] ?? 0);
|
||||
$share = $in['ownership_percentage'] ?? null;
|
||||
$notes = $in['notes'] ?? null;
|
||||
|
||||
$fields = [];
|
||||
if ($idhome <= 0) { $fields['idhome'] = ['Required']; }
|
||||
if ($ownerId <= 0) { $fields['owner_id'] = ['Required']; }
|
||||
if ($share === null || !is_numeric($share)) { $fields['ownership_percentage'] = ['Required numeric']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
if (!user_owns_home($pdo, $user, $idhome) || !user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this home or owner');
|
||||
}
|
||||
|
||||
// Already attached?
|
||||
$exists = $pdo->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();
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-owner-detach.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Detach owner from home",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome","owner_id"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="owner_id", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
$ownerId = (int) ($in['owner_id'] ?? 0);
|
||||
|
||||
if ($idhome <= 0 || $ownerId <= 0) {
|
||||
json_error(422, 'idhome and owner_id are required');
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM home_owners WHERE home_id = ? AND owner_id = ?')
|
||||
->execute([$idhome, $ownerId]);
|
||||
|
||||
json_ok();
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/home-owners.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Home owners with shares",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/HomeOwner"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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()));
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-photo.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Upload property main photo",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"idhome","photo"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="photo", type="string", format="binary", description="image")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Home"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$idhome = (int) ($_POST['idhome'] ?? 0);
|
||||
$fields = [];
|
||||
if ($idhome <= 0) {
|
||||
$fields['idhome'] = ['Required'];
|
||||
}
|
||||
if (empty($_FILES['photo']) || $_FILES['photo']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['photo'] = ['Valid image is required'];
|
||||
} elseif (!is_allowed_upload($_FILES['photo']['tmp_name'], $_FILES['photo']['name'], true)) {
|
||||
$fields['photo'] = ['Only image files are allowed'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$dir = $config['mainphoto_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['photo']['name']));
|
||||
$filename = $idhome . '-' . $user['id'] . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['photo']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store photo');
|
||||
}
|
||||
|
||||
$pdo->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));
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/home-report.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Property PDF report",
|
||||
* description="Access: owner or accepted share. Requires TCPDF library (composer).",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="PDF",
|
||||
* @OA\MediaType(mediaType="application/pdf", @OA\Schema(type="string", format="binary"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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;
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/home-save.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Create or update property",
|
||||
* description="idhome missing/0 creates; otherwise updates own property.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="name", type="string"),
|
||||
* @OA\Property(property="comment", type="string"),
|
||||
* @OA\Property(property="fulladdress", type="string"),
|
||||
* @OA\Property(property="address", type="string"),
|
||||
* @OA\Property(property="zip", type="string"),
|
||||
* @OA\Property(property="city", type="string"),
|
||||
* @OA\Property(property="country", type="string"),
|
||||
* @OA\Property(property="latitude", type="string"),
|
||||
* @OA\Property(property="longitude", type="string"),
|
||||
* @OA\Property(property="cadastral_municipality", type="string"),
|
||||
* @OA\Property(property="cadastral_section", type="string"),
|
||||
* @OA\Property(property="cadastral_sheet", type="string"),
|
||||
* @OA\Property(property="cadastral_particle", type="string"),
|
||||
* @OA\Property(property="cadastral_sub", type="string"),
|
||||
* @OA\Property(property="cadastral_category", type="string"),
|
||||
* @OA\Property(property="cadastral_class", type="string"),
|
||||
* @OA\Property(property="cadastral_surface", type="string"),
|
||||
* @OA\Property(property="cadastral_rendita", type="string"),
|
||||
* @OA\Property(property="cadastral_notes", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Home"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$allowed = [
|
||||
'name', 'comment', 'fulladdress', 'address', 'zip', 'city', 'country',
|
||||
'latitude', 'longitude', 'cadastral_municipality', 'cadastral_section',
|
||||
'cadastral_sheet', 'cadastral_particle', 'cadastral_sub', 'cadastral_category',
|
||||
'cadastral_class', 'cadastral_surface', 'cadastral_rendita', 'cadastral_notes',
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($allowed as $col) {
|
||||
if (array_key_exists($col, $in)) {
|
||||
$data[$col] = $in[$col];
|
||||
}
|
||||
}
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
|
||||
if ($idhome > 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));
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/home.php",
|
||||
* tags={"Homes"},
|
||||
* summary="Property data",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Home")
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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));
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/homes-shared.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Homes shared with me (status=accepted)",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Home"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->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);
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/homes.php",
|
||||
* tags={"Homes"},
|
||||
* summary="My properties",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Home"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->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);
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/login.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Log in, issue bearer token",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"login","password","device_name"},
|
||||
* @OA\Property(property="login", type="string", description="e-mail or username"),
|
||||
* @OA\Property(property="password", type="string", format="password"),
|
||||
* @OA\Property(property="device_name", type="string", example="iPhone 15")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Success", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="token", type="string"),
|
||||
* @OA\Property(property="user", ref="#/components/schemas/User")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, description="E-mail not verified (code email_not_verified)",
|
||||
* @OA\JsonContent(ref="#/components/schemas/Error")),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$in = body();
|
||||
$login = trim((string) ($in['login'] ?? ''));
|
||||
$pass = (string) ($in['password'] ?? '');
|
||||
$device = trim((string) ($in['device_name'] ?? ''));
|
||||
|
||||
$fields = [];
|
||||
if ($login === '') { $fields['login'] = ['Required']; }
|
||||
if ($pass === '') { $fields['password'] = ['Required']; }
|
||||
if ($device === '') { $fields['device_name'] = ['Required']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$stmt = $pdo->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),
|
||||
]);
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/logout.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Revoke current token",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
require_auth($pdo);
|
||||
|
||||
$token = bearer_token();
|
||||
$pdo->prepare('DELETE FROM api_tokens WHERE token = ?')->execute([hash('sha256', (string) $token)]);
|
||||
|
||||
json_ok();
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/me-avatar.php",
|
||||
* 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.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"avatar"},
|
||||
* @OA\Property(property="avatar", type="string", format="binary", description="image")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"))),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
if (empty($_FILES['avatar']) || $_FILES['avatar']['error'] !== UPLOAD_ERR_OK) {
|
||||
json_error(422, 'Validation failed', ['avatar' => ['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()));
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/me-save.php",
|
||||
* 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.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* @OA\Property(property="first_name", type="string", nullable=true),
|
||||
* @OA\Property(property="last_name", type="string", nullable=true),
|
||||
* @OA\Property(property="phone", type="string", nullable=true),
|
||||
* @OA\Property(property="address", type="string", nullable=true)
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"))),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$data = [];
|
||||
foreach (['first_name', 'last_name', 'phone', 'address'] as $col) {
|
||||
if (array_key_exists($col, $in)) {
|
||||
$value = $in[$col];
|
||||
$data[$col] = ($value === null || trim((string) $value) === '') ? null : trim((string) $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$data) {
|
||||
json_error(422, 'Nothing to update');
|
||||
}
|
||||
|
||||
$set = implode(', ', array_map(fn ($c) => "$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()));
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/me.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Current user",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User")
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
json_data(present_user($user));
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// Generate the OpenAPI spec from @OA annotations in this directory.
|
||||
//
|
||||
// swagger-php is a build tool and is NOT a project dependency. Install it once, e.g.:
|
||||
// composer require --working-dir=/tmp/swg zircote/swagger-php:^4.7 doctrine/annotations
|
||||
// SWAGGER_AUTOLOAD=/tmp/swg/vendor/autoload.php php public/userportal/api/openapi-gen.php
|
||||
//
|
||||
// TokenAnalyser is used so docblock annotations in procedural (class-less) files are read.
|
||||
|
||||
$autoload = getenv('SWAGGER_AUTOLOAD') ?: __DIR__ . '/../../../vendor/autoload.php';
|
||||
require $autoload;
|
||||
|
||||
use OpenApi\Generator;
|
||||
use OpenApi\Analysers\TokenAnalyser;
|
||||
|
||||
if (!class_exists(Generator::class)) {
|
||||
fwrite(STDERR, "swagger-php is not installed. See the file header.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$generator = new Generator();
|
||||
if (class_exists(TokenAnalyser::class)) {
|
||||
$generator->setAnalyser(new TokenAnalyser());
|
||||
}
|
||||
|
||||
$openapi = $generator->generate([__DIR__]);
|
||||
|
||||
$out = __DIR__ . '/openapi.yaml';
|
||||
file_put_contents($out, $openapi->toYaml());
|
||||
|
||||
echo "OpenAPI written to: {$out}\n";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/owner-delete.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Delete owner",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"owner_id"},
|
||||
* @OA\Property(property="owner_id", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$ownerId = (int) (body()['owner_id'] ?? 0);
|
||||
|
||||
if ($ownerId <= 0) {
|
||||
json_error(422, 'owner_id is required');
|
||||
}
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
$pdo->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();
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/owner-document-upload.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Upload a document file for an owner",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\MediaType(mediaType="multipart/form-data",
|
||||
* @OA\Schema(
|
||||
* required={"owner_id","document_id","file"},
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="document_id", type="integer"),
|
||||
* @OA\Property(property="file", type="string", format="binary", description="PDF or image"),
|
||||
* @OA\Property(property="expiry_date", type="string", format="date"),
|
||||
* @OA\Property(property="note", type="string"),
|
||||
* @OA\Property(property="title", type="string", description="optional display name")
|
||||
* )
|
||||
* )),
|
||||
* @OA\Response(response=201, description="File uploaded", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/UploadedFile"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$config = require __DIR__ . '/config.php';
|
||||
|
||||
$ownerId = (int) ($_POST['owner_id'] ?? 0);
|
||||
$documentId = (int) ($_POST['document_id'] ?? 0);
|
||||
$expiry = $_POST['expiry_date'] ?? null;
|
||||
$note = $_POST['note'] ?? null;
|
||||
$title = $_POST['title'] ?? null;
|
||||
|
||||
$fields = [];
|
||||
if ($ownerId <= 0) { $fields['owner_id'] = ['Required']; }
|
||||
if ($documentId <= 0) { $fields['document_id'] = ['Required']; }
|
||||
if (empty($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||
$fields['file'] = ['Valid file is required'];
|
||||
} elseif (!is_allowed_upload($_FILES['file']['tmp_name'], $_FILES['file']['name'])) {
|
||||
$fields['file'] = ['Only PDF or image files are allowed'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
$dir = $config['persondocs_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['file']['name']));
|
||||
$filename = $ownerId . '-' . time() . '-' . $safe;
|
||||
|
||||
if (!move_uploaded_file($_FILES['file']['tmp_name'], $dir . '/' . $filename)) {
|
||||
json_error(500, 'Failed to store file');
|
||||
}
|
||||
|
||||
$expiryStatus = $expiry ? 1 : 0;
|
||||
|
||||
$stmt = $pdo->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);
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/owner-documents.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Owner personal documents with uploaded files",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="owner_id", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/DocumentRequirement"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$ownerId = (int) query('owner_id', 0);
|
||||
$slug = query('slug');
|
||||
|
||||
if ($ownerId <= 0) {
|
||||
json_error(422, 'owner_id is required');
|
||||
}
|
||||
if (!user_owns_owner($pdo, $user, $ownerId)) {
|
||||
json_error(403, 'No access to this owner');
|
||||
}
|
||||
|
||||
$sql = 'SELECT d.*, s.section_name
|
||||
FROM documents d
|
||||
LEFT JOIN sections s ON s.idsections = d.idsections';
|
||||
$params = [];
|
||||
if ($slug !== null && $slug !== '') {
|
||||
$sql .= ' JOIN pages p ON p.idpages = d.page_id WHERE p.slug = ?';
|
||||
$params[] = $slug;
|
||||
}
|
||||
$sql .= ' ORDER BY s.section_name, d.document_name';
|
||||
|
||||
$docStmt = $pdo->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);
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/owner-save.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Create or update owner",
|
||||
* description="owner_id missing/0 → create; otherwise update own owner.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"tax_code"},
|
||||
* @OA\Property(property="owner_id", type="integer"),
|
||||
* @OA\Property(property="owner_type", type="string", enum={"individual","company"}),
|
||||
* @OA\Property(property="first_name", type="string"),
|
||||
* @OA\Property(property="last_name", type="string"),
|
||||
* @OA\Property(property="company_name", type="string"),
|
||||
* @OA\Property(property="tax_code", type="string"),
|
||||
* @OA\Property(property="email", type="string"),
|
||||
* @OA\Property(property="phone", type="string"),
|
||||
* @OA\Property(property="address", type="string"),
|
||||
* @OA\Property(property="postal_code", type="string"),
|
||||
* @OA\Property(property="city", type="string"),
|
||||
* @OA\Property(property="province", type="string"),
|
||||
* @OA\Property(property="country", type="integer"),
|
||||
* @OA\Property(property="role", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Owner"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$allowed = [
|
||||
'owner_type', 'first_name', 'last_name', 'company_name', 'tax_code', 'email',
|
||||
'phone', 'address', 'postal_code', 'city', 'province', 'country', 'role',
|
||||
];
|
||||
|
||||
if (trim((string) ($in['tax_code'] ?? '')) === '') {
|
||||
json_error(422, 'Validation failed', ['tax_code' => ['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()));
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/owner.php",
|
||||
* tags={"Owners"},
|
||||
* summary="Owner details",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="owner_id", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Owner"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$ownerId = (int) query('owner_id', 0);
|
||||
|
||||
if ($ownerId <= 0) {
|
||||
json_error(422, 'owner_id is required');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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));
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/owners.php",
|
||||
* tags={"Owners"},
|
||||
* summary="My owners",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Owner"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->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()));
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/pages.php",
|
||||
* tags={"Reference"},
|
||||
* summary="Category pages reference",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Page"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->query('SELECT * FROM pages ORDER BY idpages');
|
||||
|
||||
json_data(array_map('present_page', $stmt->fetchAll()));
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/password-change.php",
|
||||
* tags={"Auth"},
|
||||
* summary="Change the password of the signed-in user",
|
||||
* description="Revokes every other token; the current device stays signed in.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"current_password","new_password"},
|
||||
* @OA\Property(property="current_password", type="string", format="password"),
|
||||
* @OA\Property(property="new_password", type="string", format="password", minLength=8)
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$in = body();
|
||||
$current = (string) ($in['current_password'] ?? '');
|
||||
$new = (string) ($in['new_password'] ?? '');
|
||||
|
||||
if (strlen($new) < 8) {
|
||||
json_error(422, 'Validation failed', ['new_password' => ['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();
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
require __DIR__ . '/_mail.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/password-forgot.php",
|
||||
* 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.",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email"},
|
||||
* @OA\Property(property="email", type="string", format="email")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$email = trim((string) (body()['email'] ?? ''));
|
||||
|
||||
if ($email !== '') {
|
||||
$stmt = $pdo->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();
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/password-reset.php",
|
||||
* 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.",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email","code","password"},
|
||||
* @OA\Property(property="email", type="string", format="email"),
|
||||
* @OA\Property(property="code", type="string", example="123456"),
|
||||
* @OA\Property(property="password", type="string", format="password", minLength=8)
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$in = body();
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$code = trim((string) ($in['code'] ?? ''));
|
||||
$password = (string) ($in['password'] ?? '');
|
||||
|
||||
$fields = [];
|
||||
if ($email === '') {
|
||||
$fields['email'] = ['Required'];
|
||||
}
|
||||
if ($code === '') {
|
||||
$fields['code'] = ['Required'];
|
||||
}
|
||||
if (strlen($password) < 8) {
|
||||
$fields['password'] = ['At least 8 characters'];
|
||||
}
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$result = check_code($pdo, 'auth_password_resets', $email, $code);
|
||||
if ($result !== 'ok') {
|
||||
json_error(422, $result === 'code_expired' ? 'Code expired, request a new one' : 'Invalid code', null, $result);
|
||||
}
|
||||
|
||||
$stmt = $pdo->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();
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
require __DIR__ . '/_codes.php';
|
||||
require __DIR__ . '/_mail.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/register.php",
|
||||
* 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.",
|
||||
* security={},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"email","password"},
|
||||
* @OA\Property(property="email", type="string", format="email"),
|
||||
* @OA\Property(property="password", type="string", format="password", minLength=8),
|
||||
* @OA\Property(property="first_name", type="string"),
|
||||
* @OA\Property(property="last_name", type="string")
|
||||
* )),
|
||||
* @OA\Response(response=201, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
|
||||
$in = body();
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$password = (string) ($in['password'] ?? '');
|
||||
|
||||
debug_log("register: {$email}");
|
||||
|
||||
$fields = [];
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$fields['email'] = ['Valid email required'];
|
||||
}
|
||||
if (strlen($password) < 8) {
|
||||
$fields['password'] = ['At least 8 characters'];
|
||||
}
|
||||
if ($fields) {
|
||||
debug_log('register: rejected, ' . json_encode($fields) . ' — no code is sent');
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
|
||||
$exists = $pdo->prepare('SELECT id, created_at, email_verified_at FROM auth_users WHERE email = ? LIMIT 1');
|
||||
$exists->execute([$email]);
|
||||
if ($row = $exists->fetch()) {
|
||||
debug_log(sprintf(
|
||||
'register: already registered — id=%s created_at=%s verified=%s. No code is sent; log in instead.',
|
||||
$row['id'],
|
||||
$row['created_at'],
|
||||
$row['email_verified_at'] ?: 'no'
|
||||
));
|
||||
json_error(422, 'Validation failed', ['email' => ['Already registered']]);
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
// 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,
|
||||
]);
|
||||
|
||||
$code = issue_code($pdo, 'auth_email_verifications', $email);
|
||||
$pdo->commit();
|
||||
debug_log('register: account created, code ' . ($code ? 'issued' : 'THROTTLED (one per 60s)'));
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
debug_log('register: rolled back — ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($code) {
|
||||
send_code_mail($email, $code);
|
||||
}
|
||||
|
||||
http_response_code(201);
|
||||
echo json_encode(['success' => true]);
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/sections.php",
|
||||
* tags={"Documents"},
|
||||
* summary="Home document sections",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Parameter(name="slug", in="query", @OA\Schema(type="string", default="legal")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Section"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
$slug = (string) query('slug', 'legal');
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_can_access_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$stmt = $pdo->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()));
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/share-delete.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Delete a sharing condition",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idsharing"},
|
||||
* @OA\Property(property="idsharing", type="integer")
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$idsharing = (int) (body()['idsharing'] ?? 0);
|
||||
|
||||
if ($idsharing <= 0) {
|
||||
json_error(422, 'idsharing is required');
|
||||
}
|
||||
if (!user_owns_share($pdo, $user, $idsharing)) {
|
||||
json_error(403, 'No access to this share');
|
||||
}
|
||||
|
||||
$pdo->prepare('DELETE FROM home_sharing WHERE idsharing = ? AND iduser = ?')
|
||||
->execute([$idsharing, $user['id']]);
|
||||
|
||||
json_ok();
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/share-respond.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Accept/decline an invitation",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idsharing","status"},
|
||||
* @OA\Property(property="idsharing", type="integer"),
|
||||
* @OA\Property(property="status", type="string", enum={"accepted","rejected"})
|
||||
* )),
|
||||
* @OA\Response(response=200, ref="#/components/responses/Success"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idsharing = (int) ($in['idsharing'] ?? 0);
|
||||
$status = (string) ($in['status'] ?? '');
|
||||
|
||||
if ($idsharing <= 0 || !in_array($status, ['accepted', 'rejected'], true)) {
|
||||
json_error(422, 'idsharing and valid status are required');
|
||||
}
|
||||
|
||||
// I am the recipient of this invitation.
|
||||
$stmt = $pdo->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();
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/share-save.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Share a home by e-mail",
|
||||
* description="status=accepted if the e-mail exists in auth_users, otherwise pending.",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idhome","shared_email","sharing_type","role_id"},
|
||||
* @OA\Property(property="idhome", type="integer"),
|
||||
* @OA\Property(property="shared_email", type="string", format="email"),
|
||||
* @OA\Property(property="role_id", type="integer"),
|
||||
* @OA\Property(property="sharing_type", type="string", enum={"read-only","add-documents","full-control"}),
|
||||
* @OA\Property(property="shared_sections", type="array", @OA\Items(type="integer")),
|
||||
* @OA\Property(property="expiration_date", type="string", format="date")
|
||||
* )),
|
||||
* @OA\Response(response=201, description="Created", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Share"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idhome = (int) ($in['idhome'] ?? 0);
|
||||
$email = trim((string) ($in['shared_email'] ?? ''));
|
||||
$type = trim((string) ($in['sharing_type'] ?? ''));
|
||||
|
||||
$roleId = (int) ($in['role_id'] ?? 0);
|
||||
|
||||
$fields = [];
|
||||
if ($idhome <= 0) { $fields['idhome'] = ['Required']; }
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $fields['shared_email'] = ['Valid email required']; }
|
||||
if ($type === '') { $fields['sharing_type'] = ['Required']; }
|
||||
if ($roleId <= 0) { $fields['role_id'] = ['Required']; }
|
||||
if ($fields) {
|
||||
json_error(422, 'Validation failed', $fields);
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$sections = isset($in['shared_sections']) && is_array($in['shared_sections'])
|
||||
? json_encode(array_map('intval', $in['shared_sections']))
|
||||
: null;
|
||||
$expiry = !empty($in['expiration_date']) ? $in['expiration_date'] : null;
|
||||
|
||||
// Is the recipient registered.
|
||||
$recv = $pdo->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);
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/share-update.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Update a sharing condition",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"idsharing"},
|
||||
* @OA\Property(property="idsharing", type="integer"),
|
||||
* @OA\Property(property="shared_email", type="string", format="email"),
|
||||
* @OA\Property(property="role_id", type="integer"),
|
||||
* @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")
|
||||
* )),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Share"))),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('POST');
|
||||
$user = require_auth($pdo);
|
||||
$in = body();
|
||||
|
||||
$idsharing = (int) ($in['idsharing'] ?? 0);
|
||||
if ($idsharing <= 0) {
|
||||
json_error(422, 'idsharing is required');
|
||||
}
|
||||
if (!user_owns_share($pdo, $user, $idsharing)) {
|
||||
json_error(403, 'No access to this share');
|
||||
}
|
||||
|
||||
$map = [
|
||||
'shared_email' => 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()));
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/shares-incoming.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Incoming invitations (shared with me)",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Share"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
|
||||
$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.idshareduser = ? OR hs.shared_email = ?
|
||||
ORDER BY hs.idsharing DESC'
|
||||
);
|
||||
$stmt->execute([$user['id'], $user['email']]);
|
||||
|
||||
json_data(array_map('present_share', $stmt->fetchAll()));
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/shares.php",
|
||||
* tags={"Sharing"},
|
||||
* summary="Sharing conditions for my home",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="idhome", in="query", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Share"))
|
||||
* )),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
$user = require_auth($pdo);
|
||||
$idhome = (int) query('idhome', 0);
|
||||
|
||||
if ($idhome <= 0) {
|
||||
json_error(422, 'idhome is required');
|
||||
}
|
||||
if (!user_owns_home($pdo, $user, $idhome)) {
|
||||
json_error(403, 'No access to this home');
|
||||
}
|
||||
|
||||
$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.idhome = ?
|
||||
ORDER BY hs.idsharing DESC'
|
||||
);
|
||||
$stmt->execute([$idhome]);
|
||||
|
||||
json_data(array_map('present_share', $stmt->fetchAll()));
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
require __DIR__ . '/_bootstrap.php';
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/sharing-roles.php",
|
||||
* tags={"Reference"},
|
||||
* summary="Sharing roles reference",
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Response(response=200, description="OK", @OA\JsonContent(
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/SharingRole"))
|
||||
* )),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
require_method('GET');
|
||||
require_auth($pdo);
|
||||
|
||||
$stmt = $pdo->query('SELECT * FROM sharing_roles ORDER BY idrole');
|
||||
|
||||
json_data(array_map('present_sharing_role', $stmt->fetchAll()));
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user