7 Commits

Author SHA1 Message Date
RMubarakzyanov 701b52e4c9 fix mail send 2026-08-09 08:22:33 +03:00
RMubarakzyanov 1ae5a444cf mail send debug 2026-08-08 23:17:53 +03:00
RMubarakzyanov 28fc484c9f mail send debug 2026-08-08 23:17:17 +03:00
RMubarakzyanov 3e01f4b391 fix .env path 2026-08-07 14:05:49 +03:00
RMubarakzyanov 0e12d7489f fix enc 2026-08-07 12:27:52 +03:00
RMubarakzyanov 5c6064ba9e fix upload path 2026-07-28 19:46:02 +03:00
RMubarakzyanov 6913024596 mobile app api 2026-07-27 21:04:07 +03:00
56 changed files with 5288 additions and 0 deletions
+2
View File
@@ -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=
+423
View File
@@ -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;
}
+83
View File
@@ -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;
}
+18
View File
@@ -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
);
}
+146
View File
@@ -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);
}
+244
View File
@@ -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
{
}
+106
View File
@@ -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();
+47
View File
@@ -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()));
+61
View File
@@ -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()));
+78
View File
@@ -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;
+97
View File
@@ -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();
+59
View File
@@ -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),
]);
+41
View File
@@ -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();
+37
View File
@@ -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()));
+62
View File
@@ -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));
+72
View File
@@ -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;
+87
View File
@@ -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));
+39
View File
@@ -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));
+35
View File
@@ -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);
+26
View File
@@ -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);
+68
View File
@@ -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),
]);
+20
View File
@@ -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();
+57
View File
@@ -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()));
+46
View File
@@ -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()));
+19
View File
@@ -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));
+31
View File
@@ -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
+39
View File
@@ -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);
+58
View File
@@ -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);
+80
View File
@@ -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()));
+36
View File
@@ -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));
+22
View File
@@ -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()));
+21
View File
@@ -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()));
+42
View File
@@ -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();
+32
View File
@@ -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();
+64
View File
@@ -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();
+83
View File
@@ -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]);
+40
View File
@@ -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()));
+32
View File
@@ -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();
+44
View File
@@ -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();
+77
View File
@@ -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);
+65
View File
@@ -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()));
+28
View File
@@ -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()));
+37
View File
@@ -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()));
+21
View File
@@ -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;
+15
View File
@@ -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;