This commit is contained in:
2026-02-04 23:31:53 +01:00
commit cd59ccd99b
2401 changed files with 56808 additions and 0 deletions

163
lib/Service/AuthService.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Auth;
use MintyPHP\Router;
use MintyPHP\Support\Flash;
use MintyPHP\Service\UserService;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\RememberMeService;
use MintyPHP\Service\EmailVerificationService;
use MintyPHP\Repository\UserRepository;
class AuthService
{
public static function login(string $email, string $password): array
{
$email = trim($email);
// Check if email is verified before attempting login
$existingUser = UserRepository::findByEmail($email);
if ($existingUser && empty($existingUser['email_verified_at'])) {
return [
'ok' => false,
'message' => 'Please verify your email first',
'flash_key' => 'login_not_verified',
'needs_verification' => true,
'email' => $email,
];
}
$user = Auth::login($email, $password);
if (!$user) {
return [
'ok' => false,
'message' => 'Email/password not valid',
'flash_key' => 'login_invalid',
];
}
if (!($_SESSION['user']['active'] ?? 1)) {
Auth::logout();
return [
'ok' => false,
'message' => 'Account is inactive',
'flash_key' => 'login_inactive',
];
}
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId > 0) {
PermissionService::getUserPermissions($userId, true);
self::loadTenantDataIntoSession($userId);
if (!empty($_SESSION['no_active_tenant'])) {
Auth::logout();
return [
'ok' => false,
'message' => 'No active tenant assigned',
'flash_key' => 'login_no_active_tenant',
];
}
}
return ['ok' => true];
}
public static function loginAndRedirect(
string $email,
string $password,
string $successTarget = 'admin',
string $failTarget = 'login',
bool $remember = false
): void {
$result = self::login($email, $password);
if (!($result['ok'] ?? false)) {
// Check if user needs to verify email
if (!empty($result['needs_verification'])) {
$_SESSION['email_verification_email'] = $result['email'] ?? $email;
Flash::error(t('Please verify your email first'), 'verify-email', 'login_not_verified');
Router::redirect('verify-email');
}
$message = $result['message'] ?? 'Email/password not valid';
$key = $result['flash_key'] ?? 'login_invalid';
Flash::error($message, $failTarget, $key);
Router::redirect($failTarget);
}
if ($remember) {
$userId = (int) ($_SESSION['user']['id'] ?? 0);
if ($userId > 0) {
RememberMeService::rememberUser($userId);
}
}
Router::redirect($successTarget);
}
public static function register(array $data): array
{
$email = trim((string) ($data['email'] ?? ''));
$result = UserService::register($data);
if (!($result['ok'] ?? false)) {
return $result;
}
// Get the created user to send verification email
$createdUser = UserRepository::findByEmail($email);
if ($createdUser && isset($createdUser['id'])) {
$userId = (int) $createdUser['id'];
EmailVerificationService::sendVerification($userId);
}
// Don't login - user needs to verify email first
return ['ok' => true, 'email' => $email, 'needs_verification' => true];
}
public static function logout(): void
{
RememberMeService::forgetCurrentUser();
Auth::logout();
}
public static function logoutAndRedirect(string $target = 'login'): void
{
RememberMeService::forgetCurrentUser();
Auth::logout();
Router::redirect($target);
}
public static function loadTenantDataIntoSession(int $userId): void
{
if ($userId <= 0) {
return;
}
// Load available tenants first
$availableTenants = UserService::getAvailableTenants($userId);
$_SESSION['available_tenants'] = $availableTenants;
$_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId);
$hasTenantAdminAccess = PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE)
|| PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE);
if (!$availableTenants && !$hasTenantAdminAccess) {
$_SESSION['no_active_tenant'] = true;
unset($_SESSION['current_tenant']);
return;
}
$_SESSION['no_active_tenant'] = false;
// Load current tenant (with fallback to first available)
$currentTenant = UserService::getCurrentTenant($userId);
if (!$currentTenant && !empty($availableTenants)) {
// Fallback: use first available tenant
$currentTenant = $availableTenants[0];
}
if ($currentTenant) {
$_SESSION['current_tenant'] = $currentTenant;
}
}
}

View File

@@ -0,0 +1,238 @@
<?php
namespace MintyPHP\Service;
class BrandingFaviconService
{
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [
16 => 'favicon-16x16.png',
32 => 'favicon-32x32.png',
96 => 'favicon-96x96.png',
180 => 'apple-touch-icon.png',
192 => 'web-app-manifest-192x192.png',
512 => 'web-app-manifest-512x512.png',
];
public static function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim(APP_STORAGE_PATH, '/');
}
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
}
public static function storageDir(): string
{
return self::storageBase() . '/branding/favicon';
}
public static function publicDir(): string
{
return rtrim(dirname(__DIR__, 2) . '/web/favicon', '/');
}
public static function hasFavicon(): bool
{
$path = self::publicDir() . '/favicon-32x32.png';
return is_file($path);
}
public static function delete(): void
{
$dir = self::storageDir();
if (is_dir($dir)) {
$matches = glob($dir . '/*') ?: [];
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
$public = self::publicDir();
foreach (self::SIZES as $file) {
$target = $public . '/' . $file;
if (is_file($target)) {
@unlink($target);
}
}
}
public static function saveUpload(array $file): array
{
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = $file['tmp_name'];
$mime = self::detectMime($tmpPath);
if ($mime !== 'image/png') {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if (!self::canResize()) {
return ['ok' => false, 'error' => t('Upload failed')];
}
$dir = self::storageDir();
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete();
$originalPath = $dir . '/original.png';
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
$publicDir = self::publicDir();
if (!is_dir($publicDir) && !mkdir($publicDir, 0755, true) && !is_dir($publicDir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
foreach (self::SIZES as $size => $fileName) {
$target = $publicDir . '/' . $fileName;
if (!self::resizeSquare($originalPath, $target, $size)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($target, 0644);
}
self::updateManifest();
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public static function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
if (is_string($mime) && $mime !== '') {
return $mime;
}
}
}
return 'application/octet-stream';
}
private static function canResize(): bool
{
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
}
private static function loadImage(string $path)
{
if (function_exists('imagecreatefrompng')) {
return imagecreatefrompng($path);
}
return false;
}
private static function resizeSquare(string $sourcePath, string $targetPath, int $size): bool
{
$src = self::loadImage($sourcePath);
if (!$src) {
return false;
}
$srcWidth = imagesx($src);
$srcHeight = imagesy($src);
if ($srcWidth === 0 || $srcHeight === 0) {
imagedestroy($src);
return false;
}
$cropSize = min($srcWidth, $srcHeight);
$srcX = (int) round(($srcWidth - $cropSize) / 2);
$srcY = (int) round(($srcHeight - $cropSize) / 2);
$dst = imagecreatetruecolor($size, $size);
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $size, $size, $transparent);
imagecopyresampled(
$dst,
$src,
0,
0,
$srcX,
$srcY,
$size,
$size,
$cropSize,
$cropSize
);
$saved = false;
if (function_exists('imagepng')) {
$saved = imagepng($dst, $targetPath, 6);
}
imagedestroy($src);
imagedestroy($dst);
return $saved;
}
private static function updateManifest(): void
{
$manifestPath = self::publicDir() . '/site.webmanifest';
$data = [];
if (is_file($manifestPath)) {
$json = file_get_contents($manifestPath);
$decoded = json_decode((string) $json, true);
if (is_array($decoded)) {
$data = $decoded;
}
}
$title = null;
if (class_exists('MintyPHP\\Service\\SettingService')) {
$title = \MintyPHP\Service\SettingService::getAppTitle();
}
if ($title === null || $title === '') {
$title = defined('APP_NAME') ? APP_NAME : 'IMVS';
}
$data['name'] = $title;
$data['short_name'] = $title;
if (!isset($data['theme_color'])) {
$data['theme_color'] = '#ffffff';
}
if (!isset($data['background_color'])) {
$data['background_color'] = '#ffffff';
}
if (!isset($data['display'])) {
$data['display'] = 'standalone';
}
if (!isset($data['icons']) || !is_array($data['icons'])) {
$data['icons'] = [
[
'src' => '/favicon/web-app-manifest-192x192.png',
'sizes' => '192x192',
'type' => 'image/png',
'purpose' => 'maskable',
],
[
'src' => '/favicon/web-app-manifest-512x512.png',
'sizes' => '512x512',
'type' => 'image/png',
'purpose' => 'maskable',
],
];
}
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
file_put_contents($manifestPath, $encoded . "\n");
}
}
}

View File

@@ -0,0 +1,293 @@
<?php
namespace MintyPHP\Service;
class BrandingLogoService
{
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [64, 128, 256];
private const DEFAULT_SIZE = 128;
public static function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim(APP_STORAGE_PATH, '/');
}
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
}
public static function brandingDir(): string
{
return self::storageBase() . '/branding/logo';
}
public static function findLogoPath(?int $size = null): ?string
{
$dir = self::brandingDir();
if (!is_dir($dir)) {
return null;
}
if ($size) {
$size = self::normalizeSize($size);
$variant = self::findVariantPath($dir, $size);
if ($variant) {
return $variant;
}
}
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
if ($defaultVariant) {
return $defaultVariant;
}
$original = self::findOriginalPath($dir);
return $original ?: null;
}
public static function hasLogo(): bool
{
$path = self::findLogoPath();
return $path ? is_file($path) : false;
}
public static function delete(): bool
{
$dir = self::brandingDir();
if (!is_dir($dir)) {
return true;
}
$matches = array_merge(
glob($dir . '/logo-*.*') ?: [],
glob($dir . '/logo.*') ?: [],
glob($dir . '/original.*') ?: []
);
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
return true;
}
public static function saveUpload(array $file): array
{
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = $file['tmp_name'];
$mime = self::detectMime($tmpPath);
$isSvg = self::isSvgUpload($mime, $tmpPath);
$ext = self::extensionForMime($mime, $isSvg);
if (!$ext) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if ($isSvg && !self::isSafeSvg($tmpPath)) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = self::brandingDir();
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete();
$originalPath = $dir . '/original.' . $ext;
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
if (!$isSvg && self::canResize()) {
foreach (self::SIZES as $size) {
$target = $dir . '/logo-' . $size . '.' . $variantExt;
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
}
}
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public static function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
if (is_string($mime) && $mime !== '') {
if (self::isSvgUpload($mime, $path)) {
return 'image/svg+xml';
}
return $mime;
}
}
}
if (self::isSvgUpload('application/octet-stream', $path)) {
return 'image/svg+xml';
}
return 'application/octet-stream';
}
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
{
if ($isSvg) {
return 'svg';
}
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
return $map[$mime] ?? null;
}
private static function isSvgUpload(string $mime, string $path): bool
{
if ($mime === 'image/svg+xml') {
return true;
}
$head = @file_get_contents($path, false, null, 0, 1024);
if (!is_string($head) || $head === '') {
return false;
}
return stripos($head, '<svg') !== false;
}
private static function isSafeSvg(string $path): bool
{
$contents = @file_get_contents($path);
if (!is_string($contents) || $contents === '') {
return false;
}
$lower = strtolower($contents);
if (strpos($lower, '<script') !== false) {
return false;
}
if (strpos($lower, 'onload=') !== false) {
return false;
}
if (strpos($lower, 'javascript:') !== false) {
return false;
}
if (strpos($lower, '<foreignobject') !== false) {
return false;
}
return true;
}
private static function normalizeSize(int $size): int
{
if (in_array($size, self::SIZES, true)) {
return $size;
}
return self::DEFAULT_SIZE;
}
private static function findVariantPath(string $dir, int $size): ?string
{
$matches = glob($dir . '/logo-' . $size . '.*');
if (!$matches) {
return null;
}
usort($matches, static function ($a, $b) {
return filemtime($b) <=> filemtime($a);
});
return $matches[0] ?? null;
}
private static function findOriginalPath(string $dir): ?string
{
$matches = glob($dir . '/original.*');
if (!$matches) {
return null;
}
usort($matches, static function ($a, $b) {
return filemtime($b) <=> filemtime($a);
});
return $matches[0] ?? null;
}
private static function canResize(): bool
{
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
}
private static function createImageResource(string $path, string $mime)
{
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
return imagecreatefromjpeg($path);
}
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
return imagecreatefrompng($path);
}
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
}
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
{
$mime = self::detectMime($sourcePath);
$src = self::createImageResource($sourcePath, $mime);
if (!$src) {
return false;
}
$srcWidth = imagesx($src);
$srcHeight = imagesy($src);
if ($srcWidth === 0 || $srcHeight === 0) {
imagedestroy($src);
return false;
}
$scale = min($width / $srcWidth, $height / $srcHeight);
$dstWidth = (int) round($srcWidth * $scale);
$dstHeight = (int) round($srcHeight * $scale);
if ($dstWidth < 1) $dstWidth = 1;
if ($dstHeight < 1) $dstHeight = 1;
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
if ($ext === 'png' || $ext === 'webp') {
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
}
imagecopyresampled(
$dst,
$src,
0,
0,
0,
0,
$dstWidth,
$dstHeight,
$srcWidth,
$srcHeight
);
$saved = false;
if ($ext === 'webp' && function_exists('imagewebp')) {
$saved = imagewebp($dst, $targetPath, 82);
} elseif ($ext === 'png' && function_exists('imagepng')) {
$saved = imagepng($dst, $targetPath, 6);
} else {
$saved = imagejpeg($dst, $targetPath, 85);
}
imagedestroy($src);
imagedestroy($dst);
if ($saved) {
@chmod($targetPath, 0644);
}
return $saved;
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\DepartmentRepository;
use MintyPHP\Repository\TenantDepartmentRepository;
use MintyPHP\Repository\UserDepartmentRepository;
use MintyPHP\Service\SettingService;
use MintyPHP\Service\TenantScopeService;
class DepartmentService
{
public static function list(): array
{
return DepartmentRepository::list();
}
public static function listPaged(array $options): array
{
if (!empty($options['tenantUserId'])) {
$tenantUserId = (int) $options['tenantUserId'];
if ($tenantUserId > 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) {
unset($options['tenantUserId'], $options['tenantIds']);
}
}
return DepartmentRepository::listPaged($options);
}
public static function listByTenantIds(array $tenantIds): array
{
return DepartmentRepository::listByTenantIds($tenantIds);
}
public static function listByIds(array $departmentIds): array
{
return DepartmentRepository::listByIds($departmentIds);
}
public static function listForUserAssignments(array $tenantIds, array $selectedDepartmentIds): array
{
$departments = $tenantIds ? self::listByTenantIds($tenantIds) : self::list();
$extraDepartments = $selectedDepartmentIds ? self::listByIds($selectedDepartmentIds) : [];
if (!$extraDepartments) {
return $departments;
}
$departmentMap = [];
foreach ($departments as $department) {
if (isset($department['id'])) {
$departmentMap[(int) $department['id']] = $department;
}
}
foreach ($extraDepartments as $department) {
if (isset($department['id'])) {
$departmentMap[(int) $department['id']] = $department;
}
}
return array_values($departmentMap);
}
public static function findByUuid(string $uuid): ?array
{
return DepartmentRepository::findByUuid($uuid);
}
public static function findById(int $id): ?array
{
return DepartmentRepository::find($id);
}
public static function createFromAdmin(array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$createdId = DepartmentRepository::create([
'description' => $form['description'],
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$createdId) {
return ['ok' => false, 'errors' => [t('Department can not be created')], 'form' => $form];
}
$createdDepartment = DepartmentRepository::find((int) $createdId);
$uuid = $createdDepartment['uuid'] ?? null;
if (!empty($input['is_default']) && $createdId) {
SettingService::setDefaultDepartmentId((int) $createdId);
}
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
}
public static function updateFromAdmin(int $departmentId, array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$updated = DepartmentRepository::update($departmentId, [
'description' => $form['description'],
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Department can not be updated')], 'form' => $form];
}
return ['ok' => true, 'form' => $form];
}
public static function syncTenants(int $departmentId, array $tenantIds): int
{
$updated = TenantDepartmentRepository::replaceForDepartment($departmentId, $tenantIds);
if (!$updated) {
return -1;
}
return UserDepartmentRepository::removeInvalidForDepartment($departmentId);
}
public static function deleteByUuid(string $uuid): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$department = DepartmentRepository::findByUuid($uuid);
if (!$department || !isset($department['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$deleted = DepartmentRepository::delete((int) $department['id']);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'department' => $department];
}
private static function sanitizeBase(array $input): array
{
return [
'description' => trim((string) ($input['description'] ?? '')),
];
}
private static function validateBase(array $form): array
{
$errors = [];
if ($form['description'] === '') {
$errors[] = t('Description cannot be empty');
}
return $errors;
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\EmailVerificationRepository;
use MintyPHP\Repository\UserRepository;
use MintyPHP\I18n;
use MintyPHP\Http\Request;
class EmailVerificationService
{
private const CODE_LENGTH = 6;
private const EXPIRY_MINUTES = 30;
private const MAX_ATTEMPTS = 5;
public static function sendVerification(int $userId, ?string $locale = null): array
{
$user = UserRepository::find($userId);
if (!$user || !isset($user['id'])) {
return ['ok' => false, 'error' => 'user_not_found'];
}
$email = (string) ($user['email'] ?? '');
if ($email === '') {
return ['ok' => false, 'error' => 'email_required'];
}
EmailVerificationRepository::invalidateForUser($userId);
$code = self::generateCode();
$codeHash = password_hash($code, PASSWORD_DEFAULT);
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
$verificationId = EmailVerificationRepository::create($userId, $codeHash, $expiresAt);
if (!$verificationId) {
return ['ok' => false, 'error' => 'create_failed'];
}
$locale = $locale ?: ($user['locale'] ?? null) ?: (I18n::$locale ?? I18n::$defaultLocale);
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$isGerman = strpos((string) $locale, 'de') === 0;
$greeting = $isGerman ? 'Hallo' : 'Hello';
if ($name !== '') {
$greeting .= ' ' . $name;
}
$greeting .= ',';
$verifyPath = Request::withLocale('verify-email', $locale);
$verifyUrl = appUrl($verifyPath);
$previousLocale = I18n::$locale ?? null;
I18n::$locale = $locale;
$subject = t('Email verification code');
I18n::$locale = $previousLocale;
$vars = [
'app_name' => appTitle(),
'app_logo_url' => appLogoUrlAbsolute(128),
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
'code' => $code,
'expires_minutes' => self::EXPIRY_MINUTES,
'verify_url' => $verifyUrl,
'greeting' => $greeting,
];
MailService::sendTemplate('email_verification', $vars, $email, $subject, $locale);
return ['ok' => true];
}
public static function verifyCode(string $email, string $code): array
{
$email = trim($email);
$code = trim($code);
if ($email === '' || $code === '') {
return ['ok' => false, 'error' => 'invalid'];
}
$user = UserRepository::findByEmail($email);
if (!$user || !isset($user['id'])) {
return ['ok' => false, 'error' => 'invalid'];
}
$userId = (int) $user['id'];
// Check if already verified
if (!empty($user['email_verified_at'])) {
return ['ok' => false, 'error' => 'already_verified'];
}
$verification = EmailVerificationRepository::findActiveByUserId($userId);
if (!$verification || !isset($verification['id'])) {
return ['ok' => false, 'error' => 'invalid'];
}
$attempts = (int) ($verification['attempts'] ?? 0);
if ($attempts >= self::MAX_ATTEMPTS) {
return ['ok' => false, 'error' => 'too_many_attempts'];
}
$hash = (string) ($verification['code_hash'] ?? '');
if ($hash === '' || !password_verify($code, $hash)) {
EmailVerificationRepository::incrementAttempts((int) $verification['id']);
return ['ok' => false, 'error' => 'invalid'];
}
// Mark verification as used
EmailVerificationRepository::markUsed((int) $verification['id']);
// Mark user email as verified
UserRepository::setEmailVerified($userId);
return ['ok' => true, 'user_id' => $userId];
}
public static function resendVerification(string $email, ?string $locale = null): array
{
$email = trim($email);
if ($email === '') {
return ['ok' => false, 'error' => 'email_required'];
}
$user = UserRepository::findByEmail($email);
if (!$user || !isset($user['id'])) {
// Don't reveal if user exists
return ['ok' => true];
}
// Check if already verified
if (!empty($user['email_verified_at'])) {
return ['ok' => false, 'error' => 'already_verified'];
}
return self::sendVerification((int) $user['id'], $locale);
}
public static function getExpiryMinutes(): int
{
return self::EXPIRY_MINUTES;
}
private static function generateCode(): string
{
$max = (10 ** self::CODE_LENGTH) - 1;
$code = (string) random_int(0, $max);
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\MailLogRepository;
class MailLogService
{
public static function listPaged(array $options): array
{
return MailLogRepository::listPaged($options);
}
public static function find(int $id): ?array
{
return MailLogRepository::find($id);
}
}

153
lib/Service/MailService.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\MailLogRepository;
use MintyPHP\I18n;
use MintyPHP\Service\SettingService;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailerException;
class MailService
{
public static function sendTemplate(
string $template,
array $vars,
string $to,
string $subject,
?string $locale = null
): array {
$locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale);
[$html, $text] = self::renderTemplate($template, $vars, $locale);
return self::send($to, $subject, $html, $text, [
'template' => $template,
]);
}
public static function send(
string $to,
string $subject,
string $html,
string $text,
array $meta = []
): array {
$logId = MailLogRepository::create([
'to_email' => $to,
'subject' => $subject,
'template' => $meta['template'] ?? null,
'status' => 'queued',
]);
if (!class_exists(PHPMailer::class)) {
if ($logId) {
MailLogRepository::markFailed($logId, 'PHPMailer is not installed');
}
return ['ok' => false, 'error' => 'mailer_missing'];
}
try {
$mailer = self::createMailer();
$mailer->addAddress($to);
$mailer->Subject = $subject;
$mailer->Body = $html;
$mailer->AltBody = $text;
$mailer->isHTML(true);
$mailer->send();
if ($logId) {
MailLogRepository::markSent($logId, $mailer->getLastMessageID() ?: null);
}
return ['ok' => true];
} catch (MailerException $e) {
if ($logId) {
MailLogRepository::markFailed($logId, $e->getMessage());
}
return ['ok' => false, 'error' => 'send_failed'];
}
}
private static function renderTemplate(string $template, array $vars, string $locale): array
{
$base = dirname(__DIR__, 2) . '/templates/emails';
$htmlPath = $base . '/' . $locale . '/' . $template . '.html';
$textPath = $base . '/' . $locale . '/' . $template . '.txt';
$htmlHeaderPath = $base . '/' . $locale . '/partials/header.html';
$htmlFooterPath = $base . '/' . $locale . '/partials/footer.html';
$textHeaderPath = $base . '/' . $locale . '/partials/header.txt';
$textFooterPath = $base . '/' . $locale . '/partials/footer.txt';
if (!is_file($htmlPath)) {
$htmlPath = $base . '/' . $template . '.html';
}
if (!is_file($textPath)) {
$textPath = $base . '/' . $template . '.txt';
}
if (!is_file($htmlHeaderPath)) {
$htmlHeaderPath = $base . '/partials/header.html';
}
if (!is_file($htmlFooterPath)) {
$htmlFooterPath = $base . '/partials/footer.html';
}
if (!is_file($textHeaderPath)) {
$textHeaderPath = $base . '/partials/header.txt';
}
if (!is_file($textFooterPath)) {
$textFooterPath = $base . '/partials/footer.txt';
}
$html = is_file($htmlPath) ? file_get_contents($htmlPath) : '';
$text = is_file($textPath) ? file_get_contents($textPath) : '';
if (!isset($vars['email_header'])) {
$vars['email_header'] = is_file($htmlHeaderPath) ? file_get_contents($htmlHeaderPath) : '';
}
if (!isset($vars['email_footer'])) {
$vars['email_footer'] = is_file($htmlFooterPath) ? file_get_contents($htmlFooterPath) : '';
}
if (!isset($vars['email_header_text'])) {
$vars['email_header_text'] = is_file($textHeaderPath) ? file_get_contents($textHeaderPath) : '';
}
if (!isset($vars['email_footer_text'])) {
$vars['email_footer_text'] = is_file($textFooterPath) ? file_get_contents($textFooterPath) : '';
}
foreach ($vars as $key => $value) {
$placeholder = '{{' . $key . '}}';
$html = str_replace($placeholder, (string) $value, $html);
$text = str_replace($placeholder, (string) $value, $text);
}
// Second pass to resolve placeholders inside injected header/footer content.
foreach ($vars as $key => $value) {
$placeholder = '{{' . $key . '}}';
$html = str_replace($placeholder, (string) $value, $html);
$text = str_replace($placeholder, (string) $value, $text);
}
return [$html, $text];
}
private static function createMailer(): PHPMailer
{
$mailer = new PHPMailer(true);
$mailer->isSMTP();
$host = SettingService::getSmtpHost() ?? (getenv('SMTP_HOST') ?: '');
$port = SettingService::getSmtpPort() ?? (int) (getenv('SMTP_PORT') ?: 587);
$user = SettingService::getSmtpUser() ?? (getenv('SMTP_USER') ?: '');
$pass = SettingService::getSmtpPassword() ?? (getenv('SMTP_PASS') ?: '');
$secure = SettingService::getSmtpSecure() ?? strtolower((string) (getenv('SMTP_SECURE') ?: 'tls'));
$from = SettingService::getSmtpFrom() ?? (getenv('SMTP_FROM') ?: ($user ?: 'no-reply@localhost'));
$fromName = SettingService::getSmtpFromName() ?? (getenv('SMTP_FROM_NAME') ?: appTitle());
$mailer->Host = $host;
$mailer->Port = $port;
$mailer->SMTPAuth = $user !== '' || $pass !== '';
$mailer->Username = $user;
$mailer->Password = $pass;
if (in_array($secure, ['tls', 'ssl'], true)) {
$mailer->SMTPSecure = $secure;
}
$mailer->setFrom($from, $fromName);
$mailer->CharSet = 'UTF-8';
return $mailer;
}
}

161
lib/Service/PageService.php Normal file
View File

@@ -0,0 +1,161 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\PageRepository;
use MintyPHP\Repository\PageContentRepository;
use MintyPHP\I18n;
class PageService
{
public static function findBySlug(string $slug): ?array
{
$slug = trim($slug);
if ($slug === '') {
return null;
}
return PageRepository::findBySlug($slug);
}
public static function findBySlugWithLocale(string $slug, ?string $locale = null): ?array
{
$page = self::findBySlug($slug);
if (!$page || !isset($page['id'])) {
return null;
}
$locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale);
$content = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $locale);
$usedLocale = $locale;
$fallbackLocale = I18n::$defaultLocale;
if (defined('APP_LOCALES') && is_array(APP_LOCALES) && count(APP_LOCALES) > 0) {
$fallbackLocale = APP_LOCALES[0];
}
if (!$content && $fallbackLocale !== $locale) {
$content = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $fallbackLocale);
if ($content) {
$usedLocale = $fallbackLocale;
}
}
if (!$content) {
$content = [
'locale' => $usedLocale,
'content' => null,
];
}
return [
'page' => $page,
'content' => $content,
'locale' => $usedLocale,
];
}
public static function updateContentBySlug(
string $slug,
string $content,
int $currentUserId = 0,
?string $locale = null
): array
{
$slug = trim($slug);
if ($slug === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$page = PageRepository::findBySlug($slug);
if (!$page || !isset($page['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale);
$content = trim($content);
$normalized = null;
if ($content !== '') {
$decoded = json_decode($content, true);
if (!is_array($decoded)) {
return ['ok' => false, 'errors' => [t('Content is invalid')]];
}
if (!isset($decoded['blocks']) || !is_array($decoded['blocks'])) {
$decoded['blocks'] = [];
}
$normalized = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$existing = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $locale);
$updated = false;
if ($existing && isset($existing['id'])) {
$updated = PageContentRepository::update((int) $existing['id'], [
'content' => $normalized,
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
]);
} else {
$createdId = PageContentRepository::create([
'page_id' => (int) $page['id'],
'locale' => $locale,
'content' => $normalized,
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
$updated = (bool) $createdId;
}
if (!$updated) {
return ['ok' => false, 'errors' => [t('Page can not be updated')]];
}
return ['ok' => true, 'page' => $page];
}
public static function copyContentToLocale(
string $slug,
string $fromLocale,
string $toLocale,
int $currentUserId = 0
): array {
$slug = trim($slug);
$fromLocale = trim($fromLocale);
$toLocale = trim($toLocale);
if ($slug === '' || $fromLocale === '' || $toLocale === '') {
return ['ok' => false, 'errors' => [t('Target language required')]];
}
$page = PageRepository::findBySlug($slug);
if (!$page || !isset($page['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$source = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $fromLocale);
if (!$source) {
return ['ok' => false, 'errors' => [t('Source content not found')]];
}
$target = PageContentRepository::findByPageIdAndLocale((int) $page['id'], $toLocale);
$content = $source['content'] ?? null;
$updated = false;
if ($target && isset($target['id'])) {
$updated = PageContentRepository::update((int) $target['id'], [
'content' => $content,
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
]);
} else {
$createdId = PageContentRepository::create([
'page_id' => (int) $page['id'],
'locale' => $toLocale,
'content' => $content,
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
$updated = (bool) $createdId;
}
if (!$updated) {
return ['ok' => false, 'errors' => [t('Page can not be updated')]];
}
return ['ok' => true];
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\PasswordResetRepository;
use MintyPHP\Repository\UserRepository;
use MintyPHP\I18n;
use MintyPHP\Http\Request;
use MintyPHP\Service\RememberMeService;
class PasswordResetService
{
private const CODE_LENGTH = 6;
private const EXPIRY_MINUTES = 15;
private const MAX_ATTEMPTS = 5;
public static function requestReset(string $email, ?string $locale = null): array
{
$email = trim($email);
if ($email === '') {
return ['ok' => false, 'error' => 'email_required'];
}
$user = UserRepository::findByEmail($email);
if (!$user || !isset($user['id'])) {
return ['ok' => true];
}
$userId = (int) $user['id'];
PasswordResetRepository::invalidateForUser($userId);
$code = self::generateCode();
$codeHash = password_hash($code, PASSWORD_DEFAULT);
$expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60));
$resetId = PasswordResetRepository::create($userId, $codeHash, $expiresAt);
if (!$resetId) {
return ['ok' => false, 'error' => 'create_failed'];
}
$locale = $locale ?: ($user['locale'] ?? null) ?: (I18n::$locale ?? I18n::$defaultLocale);
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$isGerman = strpos((string) $locale, 'de') === 0;
$greeting = $isGerman ? 'Hallo' : 'Hello';
if ($name !== '') {
$greeting .= ' ' . $name;
}
$greeting .= ',';
$verifyPath = Request::withLocale('password/verify', $locale);
$verifyUrl = appUrl($verifyPath);
$previousLocale = I18n::$locale ?? null;
I18n::$locale = $locale;
$subject = t('Password reset code');
I18n::$locale = $previousLocale;
$vars = [
'app_name' => appTitle(),
'app_logo_url' => appLogoUrlAbsolute(128),
'imprint_url' => appUrl(Request::withLocale('imprint', $locale)),
'privacy_url' => appUrl(Request::withLocale('privacy', $locale)),
'code' => $code,
'expires_minutes' => self::EXPIRY_MINUTES,
'verify_url' => $verifyUrl,
'greeting' => $greeting,
];
MailService::sendTemplate('reset_code', $vars, $email, $subject, $locale);
return ['ok' => true];
}
public static function verifyCode(string $email, string $code): array
{
$email = trim($email);
$code = trim($code);
if ($email === '' || $code === '') {
return ['ok' => false, 'error' => 'invalid'];
}
$user = UserRepository::findByEmail($email);
if (!$user || !isset($user['id'])) {
return ['ok' => false, 'error' => 'invalid'];
}
$reset = PasswordResetRepository::findActiveByUserId((int) $user['id']);
if (!$reset || !isset($reset['id'])) {
return ['ok' => false, 'error' => 'invalid'];
}
$attempts = (int) ($reset['attempts'] ?? 0);
if ($attempts >= self::MAX_ATTEMPTS) {
return ['ok' => false, 'error' => 'too_many_attempts'];
}
$hash = (string) ($reset['code_hash'] ?? '');
if ($hash === '' || !password_verify($code, $hash)) {
PasswordResetRepository::incrementAttempts((int) $reset['id']);
return ['ok' => false, 'error' => 'invalid'];
}
return ['ok' => true, 'reset_id' => (int) $reset['id'], 'user_id' => (int) $user['id']];
}
public static function resetPassword(int $resetId, string $password, string $password2): array
{
$reset = PasswordResetRepository::findById($resetId);
if (!$reset || !isset($reset['id'])) {
return ['ok' => false, 'errors' => [t('Reset request not found')]];
}
if (!empty($reset['used_at'])) {
return ['ok' => false, 'errors' => [t('Reset request already used')]];
}
$expiresAt = (string) ($reset['expires_at'] ?? '');
if ($expiresAt !== '') {
try {
$expiry = new \DateTimeImmutable($expiresAt, new \DateTimeZone('UTC'));
if ($expiry->getTimestamp() <= time()) {
return ['ok' => false, 'errors' => [t('Reset request expired')]];
}
} catch (\Exception $e) {
return ['ok' => false, 'errors' => [t('Reset request expired')]];
}
}
$userId = (int) ($reset['user_id'] ?? 0);
if ($userId <= 0) {
return ['ok' => false, 'errors' => [t('Reset request not found')]];
}
$result = UserService::resetPassword($userId, $password, $password2);
if (!($result['ok'] ?? false)) {
return $result;
}
PasswordResetRepository::markUsed($resetId);
RememberMeService::forgetAllForUser($userId);
return ['ok' => true];
}
private static function generateCode(): string
{
$max = (10 ** self::CODE_LENGTH) - 1;
$code = (string) random_int(0, $max);
return str_pad($code, self::CODE_LENGTH, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\RolePermissionRepository;
use MintyPHP\Repository\UserRoleRepository;
use MintyPHP\Repository\PermissionRepository;
class PermissionService
{
public const USERS_CREATE = 'users.create';
public const USERS_DELETE = 'users.delete';
public const USERS_VIEW = 'users.view';
public const USERS_UPDATE = 'users.update';
public const USERS_SELF_UPDATE = 'users.self_update';
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
public const ADDRESS_BOOK_VIEW = 'address_book.view';
public const TENANTS_VIEW = 'tenants.view';
public const TENANTS_CREATE = 'tenants.create';
public const TENANTS_UPDATE = 'tenants.update';
public const TENANTS_DELETE = 'tenants.delete';
public const DEPARTMENTS_VIEW = 'departments.view';
public const DEPARTMENTS_CREATE = 'departments.create';
public const DEPARTMENTS_UPDATE = 'departments.update';
public const DEPARTMENTS_DELETE = 'departments.delete';
public const ROLES_VIEW = 'roles.view';
public const ROLES_CREATE = 'roles.create';
public const ROLES_UPDATE = 'roles.update';
public const ROLES_DELETE = 'roles.delete';
public const PERMISSIONS_VIEW = 'permissions.view';
public const PERMISSIONS_CREATE = 'permissions.create';
public const PERMISSIONS_UPDATE = 'permissions.update';
public const PERMISSIONS_DELETE = 'permissions.delete';
public const SETTINGS_VIEW = 'settings.view';
public const SETTINGS_UPDATE = 'settings.update';
public const MAIL_LOG_VIEW = 'mail_log.view';
public const STATS_VIEW = 'stats.view';
public static function userHas(int $userId, string $permissionKey): bool
{
$permissionKey = trim((string) $permissionKey);
if ($userId <= 0 || $permissionKey === '') {
return false;
}
$keys = self::getUserPermissions($userId);
return in_array($permissionKey, $keys, true);
}
public static function getUserPermissions(int $userId, bool $refresh = false): array
{
if ($userId <= 0) {
return [];
}
$cache = $_SESSION['permissions'] ?? null;
if ($refresh) {
$cache = null;
}
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
$keys = $cache['keys'] ?? [];
if (is_array($keys)) {
return $keys;
}
}
$roleIds = UserRoleRepository::listRoleIdsByUserId($userId);
$keys = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds);
$_SESSION['permissions'] = [
'user_id' => $userId,
'keys' => $keys,
];
return $keys;
}
public static function getCachedPermissions(int $userId): array
{
if ($userId <= 0) {
return [];
}
$cache = $_SESSION['permissions'] ?? null;
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
$keys = $cache['keys'] ?? [];
return is_array($keys) ? $keys : [];
}
return [];
}
public static function clearUserCache(int $userId): void
{
$cache = $_SESSION['permissions'] ?? null;
if (is_array($cache) && (int) ($cache['user_id'] ?? 0) === $userId) {
unset($_SESSION['permissions']);
}
}
public static function list(): array
{
return PermissionRepository::list();
}
public static function listPaged(array $options): array
{
return PermissionRepository::listPaged($options);
}
public static function find(int $id): ?array
{
return PermissionRepository::find($id);
}
public static function findByKey(string $key): ?array
{
return PermissionRepository::findByKey($key);
}
public static function createFromAdmin(array $input): array
{
$form = self::sanitizeBase($input);
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
if (PermissionRepository::findByKey($form['key'])) {
return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form];
}
$createdId = PermissionRepository::create($form);
if (!$createdId) {
return ['ok' => false, 'errors' => [t('Permission can not be created')], 'form' => $form];
}
return ['ok' => true, 'form' => $form, 'id' => (int) $createdId];
}
public static function updateFromAdmin(int $id, array $input): array
{
$form = self::sanitizeBase($input);
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$existing = PermissionRepository::findByKey($form['key']);
if ($existing && (int) ($existing['id'] ?? 0) !== $id) {
return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form];
}
$updated = PermissionRepository::update($id, $form);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Permission can not be updated')], 'form' => $form];
}
return ['ok' => true, 'form' => $form];
}
public static function deleteById(int $id): array
{
$permission = PermissionRepository::find($id);
if (!$permission) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$deleted = PermissionRepository::delete($id);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'permission' => $permission];
}
private static function sanitizeBase(array $input): array
{
return [
'key' => trim((string) ($input['key'] ?? '')),
'description' => trim((string) ($input['description'] ?? '')),
];
}
private static function validateBase(array $form): array
{
$errors = [];
if ($form['key'] === '') {
$errors[] = t('Permission key cannot be empty');
} elseif (!preg_match('/^[a-z0-9._-]+$/i', $form['key'])) {
$errors[] = t('Permission key is invalid');
}
return $errors;
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Session;
use MintyPHP\Repository\RememberTokenRepository;
use MintyPHP\Repository\UserRepository;
use MintyPHP\I18n;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\AuthService;
class RememberMeService
{
private const COOKIE_NAME = 'remember';
private const LIFETIME_DAYS = 30;
public static function rememberUser(int $userId): void
{
$selector = bin2hex(random_bytes(12));
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expiresAt = gmdate('Y-m-d H:i:s', time() + self::lifetimeSeconds());
RememberTokenRepository::create($userId, $selector, $tokenHash, $expiresAt);
self::setCookie($selector, $token);
}
public static function autoLoginFromCookie(): bool
{
if (!empty($_SESSION['user']['id'])) {
return false;
}
$value = $_COOKIE[self::cookieName()] ?? '';
if ($value === '' || strpos($value, ':') === false) {
return false;
}
[$selector, $token] = explode(':', $value, 2);
$selector = trim($selector);
$token = trim($token);
if ($selector === '' || $token === '') {
self::clearCookie();
return false;
}
$record = RememberTokenRepository::findBySelector($selector);
if (!$record) {
self::clearCookie();
return false;
}
$expiresAt = (string) ($record['expires_at'] ?? '');
if ($expiresAt !== '' && strtotime($expiresAt . ' UTC') <= time()) {
RememberTokenRepository::deleteById((int) $record['id']);
self::clearCookie();
return false;
}
$hash = (string) ($record['token_hash'] ?? '');
if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) {
RememberTokenRepository::deleteById((int) $record['id']);
self::clearCookie();
return false;
}
$userId = (int) ($record['user_id'] ?? 0);
if ($userId <= 0) {
self::clearCookie();
return false;
}
$user = UserRepository::find($userId);
if (!$user || empty($user['id']) || !($user['active'] ?? 1)) {
RememberTokenRepository::deleteById((int) $record['id']);
self::clearCookie();
return false;
}
Session::regenerate();
$_SESSION['user'] = $user;
if (!empty($user['locale'])) {
I18n::$locale = (string) $user['locale'];
}
$userId = (int) ($user['id'] ?? 0);
if ($userId > 0) {
PermissionService::getUserPermissions($userId, true);
AuthService::loadTenantDataIntoSession($userId);
}
$newToken = bin2hex(random_bytes(32));
$newHash = hash('sha256', $newToken);
$newExpires = gmdate('Y-m-d H:i:s', time() + self::lifetimeSeconds());
RememberTokenRepository::updateToken((int) $record['id'], $newHash, $newExpires);
self::setCookie($selector, $newToken);
return true;
}
public static function forgetCurrentUser(): void
{
$value = $_COOKIE[self::cookieName()] ?? '';
if ($value !== '' && strpos($value, ':') !== false) {
[$selector] = explode(':', $value, 2);
$selector = trim($selector);
if ($selector !== '') {
$record = RememberTokenRepository::findBySelector($selector);
if ($record && isset($record['id'])) {
RememberTokenRepository::deleteById((int) $record['id']);
}
}
}
self::clearCookie();
}
public static function forgetAllForUser(int $userId): void
{
RememberTokenRepository::deleteByUserId($userId);
}
private static function setCookie(string $selector, string $token): void
{
$value = $selector . ':' . $token;
$expires = time() + self::lifetimeSeconds();
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
setcookie(self::cookieName(), $value, [
'expires' => $expires,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
private static function clearCookie(): void
{
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
setcookie(self::cookieName(), '', [
'expires' => time() - 3600,
'path' => '/',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
private static function lifetimeSeconds(): int
{
return self::LIFETIME_DAYS * 24 * 60 * 60;
}
private static function cookieName(): string
{
$name = getenv('REMEMBER_COOKIE_NAME') ?: self::COOKIE_NAME;
return trim($name) !== '' ? $name : self::COOKIE_NAME;
}
}

116
lib/Service/RoleService.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\RoleRepository;
use MintyPHP\Service\SettingService;
class RoleService
{
public static function list(): array
{
return RoleRepository::list();
}
public static function listPaged(array $options): array
{
return RoleRepository::listPaged($options);
}
public static function findByUuid(string $uuid): ?array
{
return RoleRepository::findByUuid($uuid);
}
public static function findById(int $id): ?array
{
return RoleRepository::find($id);
}
public static function createFromAdmin(array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$createdId = RoleRepository::create([
'description' => $form['description'],
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$createdId) {
return ['ok' => false, 'errors' => [t('Role can not be created')], 'form' => $form];
}
$createdRole = RoleRepository::find((int) $createdId);
$uuid = $createdRole['uuid'] ?? null;
if (!empty($input['is_default']) && $createdId) {
SettingService::setDefaultRoleId((int) $createdId);
}
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
}
public static function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$updated = RoleRepository::update($roleId, [
'description' => $form['description'],
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Role can not be updated')], 'form' => $form];
}
return ['ok' => true, 'form' => $form];
}
public static function deleteByUuid(string $uuid): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$role = RoleRepository::findByUuid($uuid);
if (!$role || !isset($role['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
if (($role['description'] ?? '') === 'Admin') {
return ['ok' => false, 'status' => 403, 'error' => 'admin_role_protected'];
}
$deleted = RoleRepository::delete((int) $role['id']);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'role' => $role];
}
private static function sanitizeBase(array $input): array
{
return [
'description' => trim((string) ($input['description'] ?? '')),
];
}
private static function validateBase(array $form): array
{
$errors = [];
if ($form['description'] === '') {
$errors[] = t('Description cannot be empty');
}
return $errors;
}
}

View File

@@ -0,0 +1,340 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\SettingRepository;
use MintyPHP\Repository\TenantRepository;
use MintyPHP\Repository\RoleRepository;
use MintyPHP\Repository\DepartmentRepository;
class SettingService
{
public const DEFAULT_TENANT_KEY = 'default_tenant_id';
public const DEFAULT_ROLE_KEY = 'default_role_id';
public const DEFAULT_DEPARTMENT_KEY = 'default_department_id';
public const APP_TITLE_KEY = 'app_title';
public const APP_LOCALE_KEY = 'app_locale';
public const APP_THEME_KEY = 'app_theme';
public const APP_THEME_USER_KEY = 'app_theme_user';
public const APP_PRIMARY_COLOR_KEY = 'app_primary_color';
public const SMTP_HOST_KEY = 'smtp_host';
public const SMTP_PORT_KEY = 'smtp_port';
public const SMTP_USER_KEY = 'smtp_user';
public const SMTP_PASSWORD_KEY = 'smtp_password';
public const SMTP_SECURE_KEY = 'smtp_secure';
public const SMTP_FROM_KEY = 'smtp_from';
public const SMTP_FROM_NAME_KEY = 'smtp_from_name';
public static function get(string $key): ?array
{
return SettingRepository::find($key);
}
public static function getValue(string $key): ?string
{
return SettingRepository::getValue($key);
}
public static function getInt(string $key): ?int
{
$value = SettingRepository::getValue($key);
if ($value === null || $value === '') {
return null;
}
return (int) $value;
}
public static function set(string $key, ?string $value, ?string $description = null): bool
{
return SettingRepository::set($key, $value, $description);
}
public static function getDefaultTenantId(): ?int
{
$id = self::getInt(self::DEFAULT_TENANT_KEY);
return $id && $id > 0 ? $id : null;
}
public static function setDefaultTenantId(?int $tenantId, ?string $description = null): bool
{
$value = $tenantId && $tenantId > 0 ? (string) $tenantId : null;
if ($value !== null) {
$exists = TenantRepository::find($tenantId ?? 0);
if (!$exists) {
return false;
}
}
$desc = $description ?? 'setting.default_tenant';
return SettingRepository::set(self::DEFAULT_TENANT_KEY, $value, $desc);
}
public static function getDefaultRoleId(): ?int
{
$id = self::getInt(self::DEFAULT_ROLE_KEY);
return $id && $id > 0 ? $id : null;
}
public static function setDefaultRoleId(?int $roleId, ?string $description = null): bool
{
$value = $roleId && $roleId > 0 ? (string) $roleId : null;
if ($value !== null) {
$exists = RoleRepository::find($roleId ?? 0);
if (!$exists) {
return false;
}
}
$desc = $description ?? 'setting.default_role';
return SettingRepository::set(self::DEFAULT_ROLE_KEY, $value, $desc);
}
public static function getDefaultDepartmentId(): ?int
{
$id = self::getInt(self::DEFAULT_DEPARTMENT_KEY);
return $id && $id > 0 ? $id : null;
}
public static function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool
{
$value = $departmentId && $departmentId > 0 ? (string) $departmentId : null;
if ($value !== null) {
$exists = DepartmentRepository::find($departmentId ?? 0);
if (!$exists) {
return false;
}
}
$desc = $description ?? 'setting.default_department';
return SettingRepository::set(self::DEFAULT_DEPARTMENT_KEY, $value, $desc);
}
public static function getAppTitle(): ?string
{
$value = SettingRepository::getValue(self::APP_TITLE_KEY);
$value = $value !== null ? trim($value) : null;
return $value !== '' ? $value : null;
}
public static function setAppTitle(?string $title, ?string $description = null): bool
{
$value = $title !== null ? trim($title) : null;
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.app_title';
return SettingRepository::set(self::APP_TITLE_KEY, $value, $desc);
}
public static function getAppLocale(): ?string
{
$value = SettingRepository::getValue(self::APP_LOCALE_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setAppLocale(?string $locale, ?string $description = null): bool
{
$value = $locale !== null ? trim((string) $locale) : '';
if ($value === '') {
$value = null;
} else {
$allowed = defined('APP_LOCALES') ? APP_LOCALES : [];
if ($allowed && !in_array($value, $allowed, true)) {
return false;
}
}
$desc = $description ?? 'setting.app_locale';
return SettingRepository::set(self::APP_LOCALE_KEY, $value, $desc);
}
public static function getAppTheme(): ?string
{
$value = SettingRepository::getValue(self::APP_THEME_KEY);
$value = $value !== null ? strtolower(trim((string) $value)) : '';
if (!in_array($value, ['light', 'dark'], true)) {
return null;
}
return $value;
}
public static function setAppTheme(?string $theme, ?string $description = null): bool
{
$value = $theme !== null ? strtolower(trim((string) $theme)) : '';
if ($value === '' || !in_array($value, ['light', 'dark'], true)) {
$value = null;
}
$desc = $description ?? 'setting.app_theme';
return SettingRepository::set(self::APP_THEME_KEY, $value, $desc);
}
public static function isUserThemeAllowed(): bool
{
$value = SettingRepository::getValue(self::APP_THEME_USER_KEY);
if ($value === null || $value === '') {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
public static function setUserThemeAllowed(bool $allowed, ?string $description = null): bool
{
$value = $allowed ? '1' : '0';
$desc = $description ?? 'setting.app_theme_user';
return SettingRepository::set(self::APP_THEME_USER_KEY, $value, $desc);
}
public static function getAppPrimaryColor(): ?string
{
$value = SettingRepository::getValue(self::APP_PRIMARY_COLOR_KEY);
$value = $value !== null ? strtolower(trim((string) $value)) : '';
if ($value === '') {
return null;
}
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value)) {
return null;
}
return $value;
}
public static function setAppPrimaryColor(?string $color, ?string $description = null): bool
{
$value = $color !== null ? strtolower(trim((string) $color)) : '';
if ($value !== '' && $value[0] !== '#') {
if (preg_match('/^[0-9a-f]{3}$/i', $value)
|| preg_match('/^[0-9a-f]{4}$/i', $value)
|| preg_match('/^[0-9a-f]{6}$/i', $value)
|| preg_match('/^[0-9a-f]{8}$/i', $value)) {
$value = '#' . $value;
}
}
if ($value === '') {
$value = null;
} elseif (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i', $value)) {
return false;
}
$desc = $description ?? 'setting.app_primary_color';
return SettingRepository::set(self::APP_PRIMARY_COLOR_KEY, $value, $desc);
}
public static function getSmtpHost(): ?string
{
$value = SettingRepository::getValue(self::SMTP_HOST_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpHost(?string $host, ?string $description = null): bool
{
$value = $host !== null ? trim((string) $host) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_host';
return SettingRepository::set(self::SMTP_HOST_KEY, $value, $desc);
}
public static function getSmtpPort(): ?int
{
$value = SettingRepository::getValue(self::SMTP_PORT_KEY);
if ($value === null || $value === '') {
return null;
}
$port = (int) $value;
return $port > 0 ? $port : null;
}
public static function setSmtpPort(?int $port, ?string $description = null): bool
{
$value = $port && $port > 0 ? (string) $port : null;
$desc = $description ?? 'setting.smtp_port';
return SettingRepository::set(self::SMTP_PORT_KEY, $value, $desc);
}
public static function getSmtpUser(): ?string
{
$value = SettingRepository::getValue(self::SMTP_USER_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpUser(?string $user, ?string $description = null): bool
{
$value = $user !== null ? trim((string) $user) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_user';
return SettingRepository::set(self::SMTP_USER_KEY, $value, $desc);
}
public static function getSmtpPassword(): ?string
{
$value = SettingRepository::getValue(self::SMTP_PASSWORD_KEY);
$value = $value !== null ? (string) $value : '';
return $value !== '' ? $value : null;
}
public static function setSmtpPassword(?string $password, ?string $description = null): bool
{
$value = $password !== null ? (string) $password : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_password';
return SettingRepository::set(self::SMTP_PASSWORD_KEY, $value, $desc);
}
public static function getSmtpSecure(): ?string
{
$value = SettingRepository::getValue(self::SMTP_SECURE_KEY);
$value = $value !== null ? strtolower(trim((string) $value)) : '';
if (!in_array($value, ['tls', 'ssl', 'none'], true)) {
return null;
}
return $value === 'none' ? null : $value;
}
public static function setSmtpSecure(?string $secure, ?string $description = null): bool
{
$value = $secure !== null ? strtolower(trim((string) $secure)) : '';
if ($value === '' || $value === 'none') {
$value = null;
} elseif (!in_array($value, ['tls', 'ssl'], true)) {
return false;
}
$desc = $description ?? 'setting.smtp_secure';
return SettingRepository::set(self::SMTP_SECURE_KEY, $value, $desc);
}
public static function getSmtpFrom(): ?string
{
$value = SettingRepository::getValue(self::SMTP_FROM_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpFrom(?string $from, ?string $description = null): bool
{
$value = $from !== null ? trim((string) $from) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_from';
return SettingRepository::set(self::SMTP_FROM_KEY, $value, $desc);
}
public static function getSmtpFromName(): ?string
{
$value = SettingRepository::getValue(self::SMTP_FROM_NAME_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpFromName(?string $fromName, ?string $description = null): bool
{
$value = $fromName !== null ? trim((string) $fromName) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_from_name';
return SettingRepository::set(self::SMTP_FROM_NAME_KEY, $value, $desc);
}
}

View File

@@ -0,0 +1,328 @@
<?php
namespace MintyPHP\Service;
class TenantAvatarService
{
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [64, 128, 256];
private const DEFAULT_SIZE = 128;
public static function isValidUuid(string $uuid): bool
{
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
}
public static function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim(APP_STORAGE_PATH, '/');
}
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
}
public static function tenantDir(string $uuid): string
{
return self::storageBase() . '/tenants/' . $uuid . '/avatar';
}
public static function findAvatarPath(string $uuid, ?int $size = null): ?string
{
if (!self::isValidUuid($uuid)) {
return null;
}
$dirs = self::avatarDirs($uuid);
foreach ($dirs as $dir) {
if (!is_dir($dir)) {
continue;
}
if ($size) {
$size = self::normalizeSize($size);
$variant = self::findVariantPath($dir, $size);
if ($variant) {
return $variant;
}
}
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
if ($defaultVariant) {
return $defaultVariant;
}
$original = self::findOriginalPath($dir);
if ($original) {
return $original;
}
}
return null;
}
public static function hasAvatar(string $uuid): bool
{
$path = self::findAvatarPath($uuid);
return $path ? is_file($path) : false;
}
public static function delete(string $uuid): bool
{
if (!self::isValidUuid($uuid)) {
return false;
}
foreach (self::avatarDirs($uuid) as $dir) {
if (!is_dir($dir)) {
continue;
}
$matches = array_merge(
glob($dir . '/avatar-*.*') ?: [],
glob($dir . '/avatar.*') ?: [],
glob($dir . '/original.*') ?: []
);
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
return true;
}
public static function saveUpload(string $uuid, array $file): array
{
if (!self::isValidUuid($uuid)) {
return ['ok' => false, 'error' => t('Tenant not found')];
}
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = $file['tmp_name'];
$mime = self::detectMime($tmpPath);
$isSvg = self::isSvgUpload($mime, $tmpPath);
$ext = self::extensionForMime($mime, $isSvg);
if (!$ext) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if ($isSvg && !self::isSafeSvg($tmpPath)) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = self::tenantDir($uuid);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete($uuid);
$originalPath = $dir . '/original.' . $ext;
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
if (!$isSvg && self::canResize()) {
foreach (self::SIZES as $size) {
$target = $dir . '/avatar-' . $size . '.' . $variantExt;
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
}
}
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public static function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
if (is_string($mime) && $mime !== '') {
if (self::isSvgUpload($mime, $path)) {
return 'image/svg+xml';
}
return $mime;
}
}
}
if (self::isSvgUpload('application/octet-stream', $path)) {
return 'image/svg+xml';
}
return 'application/octet-stream';
}
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
{
if ($isSvg) {
return 'svg';
}
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
return $map[$mime] ?? null;
}
private static function isSvgUpload(string $mime, string $path): bool
{
if ($mime === 'image/svg+xml') {
return true;
}
$head = @file_get_contents($path, false, null, 0, 1024);
if (!is_string($head) || $head === '') {
return false;
}
return stripos($head, '<svg') !== false;
}
private static function isSafeSvg(string $path): bool
{
$contents = @file_get_contents($path);
if (!is_string($contents) || $contents === '') {
return false;
}
$lower = strtolower($contents);
if (strpos($lower, '<script') !== false) {
return false;
}
if (strpos($lower, 'onload=') !== false) {
return false;
}
if (strpos($lower, 'javascript:') !== false) {
return false;
}
if (strpos($lower, '<foreignobject') !== false) {
return false;
}
return true;
}
private static function normalizeSize(int $size): int
{
if (in_array($size, self::SIZES, true)) {
return $size;
}
return self::DEFAULT_SIZE;
}
private static function findVariantPath(string $dir, int $size): ?string
{
$matches = glob($dir . '/avatar-' . $size . '.*');
if (!$matches) {
return null;
}
usort($matches, static function ($a, $b) {
return filemtime($b) <=> filemtime($a);
});
return $matches[0] ?? null;
}
private static function findOriginalPath(string $dir): ?string
{
$matches = glob($dir . '/original.*');
if (!$matches) {
return null;
}
usort($matches, static function ($a, $b) {
return filemtime($b) <=> filemtime($a);
});
return $matches[0] ?? null;
}
private static function canResize(): bool
{
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
}
private static function createImageResource(string $path, string $mime)
{
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
return imagecreatefromjpeg($path);
}
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
return imagecreatefrompng($path);
}
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
}
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
{
$mime = self::detectMime($sourcePath);
$src = self::createImageResource($sourcePath, $mime);
if (!$src) {
return false;
}
$srcWidth = imagesx($src);
$srcHeight = imagesy($src);
if ($srcWidth === 0 || $srcHeight === 0) {
imagedestroy($src);
return false;
}
$scale = min($width / $srcWidth, $height / $srcHeight);
$dstWidth = (int) round($srcWidth * $scale);
$dstHeight = (int) round($srcHeight * $scale);
if ($dstWidth < 1) $dstWidth = 1;
if ($dstHeight < 1) $dstHeight = 1;
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
if ($ext === 'png' || $ext === 'webp') {
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
}
imagecopyresampled(
$dst,
$src,
0,
0,
0,
0,
$dstWidth,
$dstHeight,
$srcWidth,
$srcHeight
);
$saved = false;
if ($ext === 'webp' && function_exists('imagewebp')) {
$saved = imagewebp($dst, $targetPath, 82);
} elseif ($ext === 'png' && function_exists('imagepng')) {
$saved = imagepng($dst, $targetPath, 6);
} else {
$saved = imagejpeg($dst, $targetPath, 85);
}
imagedestroy($src);
imagedestroy($dst);
if ($saved) {
@chmod($targetPath, 0644);
}
return $saved;
}
private static function avatarDirs(string $uuid): array
{
$dirs = [self::tenantDir($uuid)];
$legacy = self::legacyTenantDir($uuid);
if ($legacy !== $dirs[0]) {
$dirs[] = $legacy;
}
return $dirs;
}
private static function legacyTenantDir(string $uuid): string
{
return self::storageBase() . '/tenants/' . $uuid;
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace MintyPHP\Service;
class TenantFaviconService
{
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [
16 => 'favicon-16x16.png',
32 => 'favicon-32x32.png',
96 => 'favicon-96x96.png',
180 => 'apple-touch-icon.png',
192 => 'web-app-manifest-192x192.png',
512 => 'web-app-manifest-512x512.png',
];
public static function isValidUuid(string $uuid): bool
{
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
}
public static function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim(APP_STORAGE_PATH, '/');
}
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
}
public static function storageDir(string $uuid): string
{
return self::storageBase() . '/tenants/' . $uuid . '/favicon';
}
public static function publicDir(string $uuid): string
{
return rtrim(dirname(__DIR__, 2) . '/web/favicon/tenants/' . $uuid, '/');
}
public static function hasFavicon(string $uuid): bool
{
if (!self::isValidUuid($uuid)) {
return false;
}
$path = self::publicDir($uuid) . '/favicon-32x32.png';
return is_file($path);
}
public static function delete(string $uuid): void
{
if (!self::isValidUuid($uuid)) {
return;
}
foreach (self::storageDirs($uuid) as $dir) {
if (!is_dir($dir)) {
continue;
}
$matches = glob($dir . '/*') ?: [];
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
$public = self::publicDir($uuid);
foreach (self::SIZES as $file) {
$target = $public . '/' . $file;
if (is_file($target)) {
@unlink($target);
}
}
}
public static function saveUpload(string $uuid, array $file): array
{
if (!self::isValidUuid($uuid)) {
return ['ok' => false, 'error' => t('Tenant not found')];
}
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = $file['tmp_name'];
$mime = self::detectMime($tmpPath);
if ($mime !== 'image/png') {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if (!self::canResize()) {
return ['ok' => false, 'error' => t('Upload failed')];
}
$dir = self::storageDir($uuid);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete($uuid);
$originalPath = $dir . '/original.png';
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
$publicDir = self::publicDir($uuid);
if (!is_dir($publicDir) && !mkdir($publicDir, 0755, true) && !is_dir($publicDir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
foreach (self::SIZES as $size => $fileName) {
$target = $publicDir . '/' . $fileName;
if (!self::resizeSquare($originalPath, $target, $size)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($target, 0644);
}
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public static function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
if (is_string($mime) && $mime !== '') {
return $mime;
}
}
}
return 'application/octet-stream';
}
private static function canResize(): bool
{
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
}
private static function loadImage(string $path)
{
if (function_exists('imagecreatefrompng')) {
return imagecreatefrompng($path);
}
return false;
}
private static function resizeSquare(string $sourcePath, string $targetPath, int $size): bool
{
$src = self::loadImage($sourcePath);
if (!$src) {
return false;
}
$srcWidth = imagesx($src);
$srcHeight = imagesy($src);
if ($srcWidth === 0 || $srcHeight === 0) {
imagedestroy($src);
return false;
}
$cropSize = min($srcWidth, $srcHeight);
$srcX = (int) round(($srcWidth - $cropSize) / 2);
$srcY = (int) round(($srcHeight - $cropSize) / 2);
$dst = imagecreatetruecolor($size, $size);
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $size, $size, $transparent);
imagecopyresampled(
$dst,
$src,
0,
0,
$srcX,
$srcY,
$size,
$size,
$cropSize,
$cropSize
);
$saved = false;
if (function_exists('imagepng')) {
$saved = imagepng($dst, $targetPath, 6);
}
imagedestroy($src);
imagedestroy($dst);
return $saved;
}
private static function storageDirs(string $uuid): array
{
$dirs = [self::storageDir($uuid)];
$legacy = self::legacyStorageDir($uuid);
if ($legacy !== $dirs[0]) {
$dirs[] = $legacy;
}
return $dirs;
}
private static function legacyStorageDir(string $uuid): string
{
return self::storageBase() . '/branding/tenants/' . $uuid . '/favicon';
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\TenantDepartmentRepository;
use MintyPHP\Repository\UserTenantRepository;
use MintyPHP\Repository\TenantRepository;
use MintyPHP\Service\PermissionService;
class TenantScopeService
{
public static function isStrict(): bool
{
return defined('TENANT_SCOPE_STRICT') ? (bool) TENANT_SCOPE_STRICT : true;
}
public static function getUserTenantIds(int $userId, bool $onlyActive = true): array
{
if ($userId <= 0) {
return [];
}
if (self::canBypass($userId)) {
$ids = TenantRepository::listIds();
} else {
$ids = array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId))));
}
if ($onlyActive) {
return self::filterActiveTenantIds($ids);
}
return $ids;
}
public static function canAccess(string $resource, int $resourceId, int $userId): bool
{
if ($resourceId <= 0 || $userId <= 0) {
return false;
}
if (self::canBypass($userId)) {
return true;
}
$resourceTenantIdsRaw = self::getResourceTenantIds($resource, $resourceId);
$userTenantIdsRaw = self::getUserTenantIds($userId, false);
$resourceTenantIds = self::filterActiveTenantIds($resourceTenantIdsRaw);
$userTenantIds = self::filterActiveTenantIds($userTenantIdsRaw);
if ($resourceTenantIdsRaw && !$resourceTenantIds) {
return false;
}
if (!$resourceTenantIds) {
return self::isStrict() ? false : true;
}
if (!$userTenantIds) {
return false;
}
return (bool) array_intersect($resourceTenantIds, $userTenantIds);
}
public static function filterTenantIdsForUser(array $tenantIds, int $userId): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$userTenantIds = self::getUserTenantIds($userId);
if (!$userTenantIds) {
return [];
}
return array_values(array_intersect($tenantIds, $userTenantIds));
}
public static function mergeTenantIdsPreservingOutOfScope(array $requestedTenantIds, array $existingTenantIds, array $allowedTenantIds): array
{
$requestedTenantIds = array_values(array_unique(array_map('intval', $requestedTenantIds)));
$existingTenantIds = array_values(array_unique(array_map('intval', $existingTenantIds)));
$allowedTenantIds = array_values(array_unique(array_map('intval', $allowedTenantIds)));
if (!$allowedTenantIds) {
return $existingTenantIds;
}
$outOfScope = array_values(array_diff($existingTenantIds, $allowedTenantIds));
return array_values(array_unique(array_merge($requestedTenantIds, $outOfScope)));
}
public static function hasGlobalAccess(int $userId): bool
{
return self::canBypass($userId);
}
private static function getResourceTenantIds(string $resource, int $resourceId): array
{
$resource = strtolower(trim($resource));
if ($resource === 'tenants') {
return $resourceId > 0 ? [$resourceId] : [];
}
if ($resource === 'users') {
return array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($resourceId))));
}
if ($resource === 'departments') {
return array_values(array_unique(array_map('intval', TenantDepartmentRepository::listTenantIdsByDepartmentId($resourceId))));
}
return [];
}
private static function filterActiveTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
if (!$tenantIds) {
return [];
}
return TenantRepository::listActiveIdsByIds($tenantIds);
}
private static function canBypass(int $userId): bool
{
if ($userId <= 0) {
return false;
}
return PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE)
|| PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE);
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\Repository\TenantRepository;
use MintyPHP\Service\SettingService;
class TenantService
{
public static function list(): array
{
return TenantRepository::list();
}
public static function listPaged(array $options): array
{
return TenantRepository::listPaged($options);
}
public static function findByUuid(string $uuid): ?array
{
return TenantRepository::findByUuid($uuid);
}
public static function findById(int $id): ?array
{
return TenantRepository::find($id);
}
public static function createFromAdmin(array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$form['status'] = self::normalizeStatus($form['status'] ?? '');
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$statusChangedAt = gmdate('Y-m-d H:i:s');
$createdId = TenantRepository::create([
'description' => $form['description'],
'address' => $form['address'],
'postal_code' => $form['postal_code'],
'city' => $form['city'],
'country' => $form['country'],
'region' => $form['region'],
'vat_id' => $form['vat_id'],
'tax_number' => $form['tax_number'],
'phone' => $form['phone'],
'fax' => $form['fax'],
'email' => $form['email'],
'support_email' => $form['support_email'],
'support_phone' => $form['support_phone'],
'billing_email' => $form['billing_email'],
'website' => $form['website'],
'privacy_url' => $form['privacy_url'],
'imprint_url' => $form['imprint_url'],
'primary_color' => $form['primary_color_use_default']
? null
: ($form['primary_color'] !== '' ? $form['primary_color'] : null),
'status' => $form['status'],
'status_changed_at' => $statusChangedAt,
'status_changed_by' => $currentUserId > 0 ? $currentUserId : null,
'created_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$createdId) {
return ['ok' => false, 'errors' => [t('Tenant can not be created')], 'form' => $form];
}
$createdTenant = TenantRepository::find((int) $createdId);
$uuid = $createdTenant['uuid'] ?? null;
if (!empty($input['is_default']) && $createdId) {
SettingService::setDefaultTenantId((int) $createdId);
}
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
}
public static function updateFromAdmin(int $tenantId, array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$form['status'] = self::normalizeStatus($form['status'] ?? '');
$errors = self::validateBase($form);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$existing = TenantRepository::find($tenantId);
$statusChanged = false;
if ($existing && isset($existing['status'])) {
$statusChanged = (string) $existing['status'] !== $form['status'];
}
$updateData = [
'description' => $form['description'],
'address' => $form['address'],
'postal_code' => $form['postal_code'],
'city' => $form['city'],
'country' => $form['country'],
'region' => $form['region'],
'vat_id' => $form['vat_id'],
'tax_number' => $form['tax_number'],
'phone' => $form['phone'],
'fax' => $form['fax'],
'email' => $form['email'],
'support_email' => $form['support_email'],
'support_phone' => $form['support_phone'],
'billing_email' => $form['billing_email'],
'website' => $form['website'],
'privacy_url' => $form['privacy_url'],
'imprint_url' => $form['imprint_url'],
'primary_color' => $form['primary_color_use_default']
? null
: ($form['primary_color'] !== '' ? $form['primary_color'] : null),
'status' => $form['status'],
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
];
if ($statusChanged) {
$updateData['status_changed_at'] = gmdate('Y-m-d H:i:s');
$updateData['status_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
}
$updated = TenantRepository::update($tenantId, $updateData);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form];
}
return ['ok' => true, 'form' => $form];
}
public static function deleteByUuid(string $uuid): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$tenant = TenantRepository::findByUuid($uuid);
if (!$tenant || !isset($tenant['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$deleted = TenantRepository::delete((int) $tenant['id']);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'tenant' => $tenant];
}
private static function sanitizeBase(array $input): array
{
return [
'description' => trim((string) ($input['description'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'postal_code' => trim((string) ($input['postal_code'] ?? '')),
'city' => trim((string) ($input['city'] ?? '')),
'country' => trim((string) ($input['country'] ?? '')),
'region' => trim((string) ($input['region'] ?? '')),
'vat_id' => trim((string) ($input['vat_id'] ?? '')),
'tax_number' => trim((string) ($input['tax_number'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'fax' => trim((string) ($input['fax'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'support_email' => trim((string) ($input['support_email'] ?? '')),
'support_phone' => trim((string) ($input['support_phone'] ?? '')),
'billing_email' => trim((string) ($input['billing_email'] ?? '')),
'website' => trim((string) ($input['website'] ?? '')),
'privacy_url' => trim((string) ($input['privacy_url'] ?? '')),
'imprint_url' => trim((string) ($input['imprint_url'] ?? '')),
'primary_color' => trim((string) ($input['primary_color'] ?? '')),
'primary_color_use_default' => !empty($input['primary_color_use_default']),
'status' => trim((string) ($input['status'] ?? '')),
];
}
private static function validateBase(array $form): array
{
$errors = [];
if ($form['description'] === '') {
$errors[] = t('Description cannot be empty');
}
if (!in_array($form['status'], ['active', 'inactive'], true)) {
$errors[] = t('Status is invalid');
}
if (
!$form['primary_color_use_default']
&& $form['primary_color'] !== ''
&& !preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $form['primary_color'])
) {
$errors[] = t('Primary color is invalid');
}
return $errors;
}
private static function normalizeStatus(string $status): string
{
$status = strtolower(trim($status));
if (!in_array($status, ['active', 'inactive'], true)) {
return 'active';
}
return $status;
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace MintyPHP\Service;
class UserAvatarService
{
private const MAX_SIZE = 5242880; // 5 MB
private const SIZES = [64, 128, 256];
private const DEFAULT_SIZE = 128;
public static function isValidUuid(string $uuid): bool
{
return (bool) preg_match('/^[a-f0-9-]{36}$/i', $uuid);
}
public static function storageBase(): string
{
if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) {
return rtrim(APP_STORAGE_PATH, '/');
}
return rtrim(dirname(__DIR__, 2) . '/storage', '/');
}
public static function userDir(string $uuid): string
{
return self::storageBase() . '/users/' . $uuid;
}
public static function findAvatarPath(string $uuid, ?int $size = null): ?string
{
if (!self::isValidUuid($uuid)) {
return null;
}
$dir = self::userDir($uuid);
if (!is_dir($dir)) {
return null;
}
if ($size) {
$size = self::normalizeSize($size);
$variant = self::findVariantPath($dir, $size);
if ($variant) {
return $variant;
}
}
$defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE);
if ($defaultVariant) {
return $defaultVariant;
}
$original = self::findOriginalPath($dir);
return $original ?: null;
}
public static function hasAvatar(string $uuid): bool
{
$path = self::findAvatarPath($uuid);
return $path ? is_file($path) : false;
}
public static function delete(string $uuid): bool
{
if (!self::isValidUuid($uuid)) {
return false;
}
$dir = self::userDir($uuid);
if (!is_dir($dir)) {
return true;
}
$matches = array_merge(
glob($dir . '/avatar-*.*') ?: [],
glob($dir . '/avatar.*') ?: [],
glob($dir . '/original.*') ?: []
);
foreach ($matches as $file) {
if (is_file($file)) {
@unlink($file);
}
}
return true;
}
public static function saveUpload(string $uuid, array $file): array
{
if (!self::isValidUuid($uuid)) {
return ['ok' => false, 'error' => t('User not found')];
}
if (empty($file) || !isset($file['tmp_name'])) {
return ['ok' => false, 'error' => t('No file uploaded')];
}
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => t('Upload failed')];
}
if (($file['size'] ?? 0) > self::MAX_SIZE) {
return ['ok' => false, 'error' => t('File is too large')];
}
$tmpPath = $file['tmp_name'];
$mime = self::detectMime($tmpPath);
$isSvg = self::isSvgUpload($mime, $tmpPath);
$ext = self::extensionForMime($mime, $isSvg);
if (!$ext) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
if ($isSvg && !self::isSafeSvg($tmpPath)) {
return ['ok' => false, 'error' => t('Invalid image file')];
}
$dir = self::userDir($uuid);
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
self::delete($uuid);
$originalPath = $dir . '/original.' . $ext;
if (!move_uploaded_file($tmpPath, $originalPath)) {
return ['ok' => false, 'error' => t('Upload failed')];
}
@chmod($originalPath, 0644);
$variantExt = function_exists('imagewebp') ? 'webp' : 'jpg';
if (!$isSvg && self::canResize()) {
foreach (self::SIZES as $size) {
$target = $dir . '/avatar-' . $size . '.' . $variantExt;
self::resizeAndFit($originalPath, $target, $size, $size, $variantExt);
}
}
return ['ok' => true, 'path' => $originalPath, 'mime' => $mime];
}
public static function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $path);
if (is_string($mime) && $mime !== '') {
if (self::isSvgUpload($mime, $path)) {
return 'image/svg+xml';
}
return $mime;
}
}
}
if (self::isSvgUpload('application/octet-stream', $path)) {
return 'image/svg+xml';
}
return 'application/octet-stream';
}
private static function extensionForMime(string $mime, bool $isSvg = false): ?string
{
if ($isSvg) {
return 'svg';
}
$map = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
return $map[$mime] ?? null;
}
private static function isSvgUpload(string $mime, string $path): bool
{
if ($mime === 'image/svg+xml') {
return true;
}
$head = @file_get_contents($path, false, null, 0, 1024);
if (!is_string($head) || $head === '') {
return false;
}
return stripos($head, '<svg') !== false;
}
private static function isSafeSvg(string $path): bool
{
$contents = @file_get_contents($path);
if (!is_string($contents) || $contents === '') {
return false;
}
$lower = strtolower($contents);
if (strpos($lower, '<script') !== false) {
return false;
}
if (strpos($lower, 'onload=') !== false) {
return false;
}
if (strpos($lower, 'javascript:') !== false) {
return false;
}
if (strpos($lower, '<foreignobject') !== false) {
return false;
}
return true;
}
private static function normalizeSize(int $size): int
{
if (in_array($size, self::SIZES, true)) {
return $size;
}
return self::DEFAULT_SIZE;
}
private static function findVariantPath(string $dir, int $size): ?string
{
$matches = glob($dir . '/avatar-' . $size . '.*');
if (!$matches) {
return null;
}
usort($matches, static function ($a, $b) {
return filemtime($b) <=> filemtime($a);
});
return $matches[0] ?? null;
}
private static function findOriginalPath(string $dir): ?string
{
$matches = glob($dir . '/original.*');
if (!$matches) {
return null;
}
usort($matches, static function ($a, $b) {
return filemtime($b) <=> filemtime($a);
});
return $matches[0] ?? null;
}
private static function canResize(): bool
{
return function_exists('imagecreatetruecolor') && function_exists('imagecopyresampled');
}
private static function createImageResource(string $path, string $mime)
{
if ($mime === 'image/jpeg' && function_exists('imagecreatefromjpeg')) {
return imagecreatefromjpeg($path);
}
if ($mime === 'image/png' && function_exists('imagecreatefrompng')) {
return imagecreatefrompng($path);
}
if ($mime === 'image/webp' && function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
}
private static function resizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool
{
$mime = self::detectMime($sourcePath);
$src = self::createImageResource($sourcePath, $mime);
if (!$src) {
return false;
}
$srcWidth = imagesx($src);
$srcHeight = imagesy($src);
if ($srcWidth === 0 || $srcHeight === 0) {
imagedestroy($src);
return false;
}
$scale = min($width / $srcWidth, $height / $srcHeight);
$dstWidth = (int) round($srcWidth * $scale);
$dstHeight = (int) round($srcHeight * $scale);
if ($dstWidth < 1) $dstWidth = 1;
if ($dstHeight < 1) $dstHeight = 1;
$dst = imagecreatetruecolor($dstWidth, $dstHeight);
if ($ext === 'png' || $ext === 'webp') {
imagealphablending($dst, false);
imagesavealpha($dst, true);
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefilledrectangle($dst, 0, 0, $dstWidth, $dstHeight, $transparent);
}
imagecopyresampled(
$dst,
$src,
0,
0,
0,
0,
$dstWidth,
$dstHeight,
$srcWidth,
$srcHeight
);
$saved = false;
if ($ext === 'webp' && function_exists('imagewebp')) {
$saved = imagewebp($dst, $targetPath, 82);
} elseif ($ext === 'png' && function_exists('imagepng')) {
$saved = imagepng($dst, $targetPath, 6);
} else {
$saved = imagejpeg($dst, $targetPath, 85);
}
imagedestroy($src);
imagedestroy($dst);
if ($saved) {
@chmod($targetPath, 0644);
}
return $saved;
}
}

804
lib/Service/UserService.php Normal file
View File

@@ -0,0 +1,804 @@
<?php
namespace MintyPHP\Service;
use MintyPHP\I18n;
use MintyPHP\Repository\DepartmentRepository;
use MintyPHP\Repository\RoleRepository;
use MintyPHP\Repository\TenantRepository;
use MintyPHP\Repository\UserRepository;
use MintyPHP\Repository\UserDepartmentRepository;
use MintyPHP\Repository\UserRoleRepository;
use MintyPHP\Repository\UserTenantRepository;
use MintyPHP\Repository\TenantDepartmentRepository;
use MintyPHP\Service\PermissionService;
use MintyPHP\Service\TenantScopeService;
class UserService
{
private const PASSWORD_MIN_LENGTH = 12;
public static function list(): array
{
return UserRepository::list();
}
public static function listPaged(array $options): array
{
if (!empty($options['tenantUserId'])) {
$tenantUserId = (int) $options['tenantUserId'];
if ($tenantUserId > 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) {
unset($options['tenantUserId']);
}
}
return UserRepository::listPaged($options);
}
public static function findByUuid(string $uuid): ?array
{
return UserRepository::findByUuid($uuid);
}
public static function findById(int $id): ?array
{
return UserRepository::find($id);
}
public static function findByEmail(string $email): ?array
{
return UserRepository::findByEmail($email);
}
public static function setLocale(int $userId, string $locale): bool
{
return UserRepository::setLocale($userId, $locale);
}
public static function setTheme(int $userId, string $theme): bool
{
$theme = self::normalizeTheme($theme);
return UserRepository::setTheme($userId, $theme);
}
public static function deleteByUuid(string $uuid, int $currentUserId = 0): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$user = UserRepository::findByUuid($uuid);
if (!$user || !isset($user['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$userId = (int) $user['id'];
if ($currentUserId && $currentUserId === $userId) {
return [
'ok' => false,
'status' => 400,
'error' => 'self_delete',
'message' => t('You cannot delete your own account'),
];
}
$deleted = UserRepository::delete($userId);
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
return ['ok' => true, 'user' => $user];
}
public static function deleteByUuids(array $uuids, int $currentUserId = 0): array
{
$uuids = array_values(array_filter(array_map('trim', $uuids)));
if (!$uuids) {
return ['ok' => false, 'error' => 'no_selection'];
}
if ($currentUserId > 0) {
$uuids = self::filterUuidsByTenantScope($uuids, $currentUserId);
if (!$uuids) {
return ['ok' => false, 'error' => 'permission_denied'];
}
}
if ($currentUserId > 0) {
$currentUser = UserRepository::find($currentUserId);
$currentUuid = $currentUser['uuid'] ?? '';
if ($currentUuid !== '') {
$uuids = array_values(array_filter($uuids, static fn ($uuid) => $uuid !== $currentUuid));
}
if (!$uuids) {
return ['ok' => false, 'error' => 'self_delete'];
}
}
$deleted = UserRepository::deleteByUuids($uuids);
if (!$deleted) {
return ['ok' => false, 'error' => 'delete_failed'];
}
return ['ok' => true, 'count' => count($uuids)];
}
public static function createFromAdmin(array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
if (array_key_exists('active', $input)) {
$form['active'] = isset($input['active']) ? 1 : 0;
} else {
$form['active'] = 1;
}
$password = (string) ($input['password'] ?? '');
$password2 = (string) ($input['password2'] ?? '');
$errors = self::validateBase($form);
$errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email']));
$tenantIds = $input['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
$tenantIds = [$tenantIds];
}
$tenantIds = self::normalizeTenantIds($tenantIds);
if (!$tenantIds) {
$defaultTenantId = SettingService::getDefaultTenantId();
if ($defaultTenantId) {
$tenantIds = [$defaultTenantId];
}
}
[$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds);
$errors = array_merge($errors, $primaryErrors);
if ($errors) {
$form['primary_tenant_id'] = $primaryTenantId;
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
$activeChangedAt = gmdate('Y-m-d H:i:s');
$created = UserRepository::create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
'address' => $form['address'] !== '' ? $form['address'] : null,
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
'city' => $form['city'] !== '' ? $form['city'] : null,
'country' => $form['country'] !== '' ? $form['country'] : null,
'region' => $form['region'] !== '' ? $form['region'] : null,
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
'password' => $password,
'locale' => $form['locale'],
'totp_secret' => $form['totp_secret'],
'theme' => $form['theme'],
'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null,
'active' => $form['active'],
'created_by' => $currentUserId > 0 ? $currentUserId : null,
'active_changed_at' => $activeChangedAt,
'active_changed_by' => $currentUserId > 0 ? $currentUserId : null,
]);
if (!$created) {
return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form];
}
$createdUser = UserRepository::findByEmail($form['email']);
$uuid = $createdUser['uuid'] ?? null;
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0 && $tenantIds) {
self::syncTenants($userId, $tenantIds);
}
$roleIds = self::normalizeIdInput($input['role_ids'] ?? []);
if (!$roleIds) {
$defaultRoleId = SettingService::getDefaultRoleId();
if ($defaultRoleId) {
$roleIds = [$defaultRoleId];
}
}
if ($userId > 0 && $roleIds) {
self::syncRoles($userId, $roleIds);
}
$departmentIds = self::normalizeIdInput($input['department_ids'] ?? []);
if (!$departmentIds) {
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
if ($defaultDepartmentId) {
$departmentIds = [$defaultDepartmentId];
}
}
if ($userId > 0 && $departmentIds) {
self::syncDepartments($userId, $departmentIds);
}
return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
}
public static function updateFromAdmin(int $userId, array $input, int $currentUserId = 0): array
{
$form = self::sanitizeBase($input);
$form['totp_secret'] = trim((string) ($input['totp_secret'] ?? ''));
$form['theme'] = self::normalizeTheme($input['theme'] ?? null);
$form['locale'] = self::normalizeLocale($input['locale'] ?? null);
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
$tenantIdsProvided = array_key_exists('tenant_ids', $input);
$primaryProvided = array_key_exists('primary_tenant_id', $input);
if ($tenantIdsProvided) {
$tenantIds = $input['tenant_ids'] ?? [];
if (!is_array($tenantIds)) {
$tenantIds = [$tenantIds];
}
$tenantIds = self::normalizeTenantIds($tenantIds);
} else {
$tenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
}
if (array_key_exists('active', $input)) {
$form['active'] = isset($input['active']) ? 1 : 0;
} else {
$existing = UserRepository::find($userId);
$form['active'] = (int) ($existing['active'] ?? 1);
}
$password = (string) ($input['password'] ?? '');
$password2 = (string) ($input['password2'] ?? '');
$errors = self::validateBase($form, $userId);
if ($userId === $currentUserId && !$form['active']) {
$errors[] = t('You cannot deactivate your own account');
}
$errors = array_merge($errors, self::validatePassword($password, $password2, false, $form['email']));
if ($tenantIds && ($tenantIdsProvided || $primaryProvided)) {
[$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds);
$errors = array_merge($errors, $primaryErrors);
}
if ($errors) {
if ($tenantIdsProvided || $primaryProvided) {
$form['primary_tenant_id'] = $primaryTenantId;
}
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
if ($tenantIdsProvided || $primaryProvided) {
$form['primary_tenant_id'] = $primaryTenantId > 0 ? $primaryTenantId : null;
}
$updateData = [
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null,
'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null,
'phone' => $form['phone'] !== '' ? $form['phone'] : null,
'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null,
'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null,
'address' => $form['address'] !== '' ? $form['address'] : null,
'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null,
'city' => $form['city'] !== '' ? $form['city'] : null,
'country' => $form['country'] !== '' ? $form['country'] : null,
'region' => $form['region'] !== '' ? $form['region'] : null,
'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null,
'totp_secret' => $form['totp_secret'],
'theme' => $form['theme'],
'locale' => $form['locale'],
'active' => $form['active'],
'password' => $password,
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
];
if ($tenantIdsProvided || $primaryProvided) {
$updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null;
}
if ((int) ($existing['active'] ?? 1) !== (int) $form['active']) {
$updateData['active_changed_at'] = gmdate('Y-m-d H:i:s');
$updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
}
$updated = UserRepository::update($userId, $updateData);
if (!$updated) {
return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form];
}
return ['ok' => true, 'form' => $form];
}
public static function setActiveByUuid(string $uuid, bool $active, int $currentUserId = 0): array
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$user = UserRepository::findByUuid($uuid);
if (!$user || !isset($user['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$userId = (int) $user['id'];
if (!$active && $currentUserId && $currentUserId === $userId) {
return [
'ok' => false,
'status' => 400,
'error' => 'self_deactivate',
'message' => t('You cannot deactivate your own account'),
];
}
$updated = UserRepository::setActive($userId, $active, $currentUserId > 0 ? $currentUserId : null);
if (!$updated) {
return ['ok' => false, 'status' => 500, 'error' => 'update_failed'];
}
return ['ok' => true, 'user' => $user];
}
public static function setActiveByUuids(array $uuids, bool $active, int $currentUserId = 0): array
{
$uuids = array_values(array_filter(array_map('trim', $uuids)));
if (!$uuids) {
return ['ok' => false, 'error' => 'no_selection'];
}
if ($currentUserId > 0) {
$uuids = self::filterUuidsByTenantScope($uuids, $currentUserId);
if (!$uuids) {
return ['ok' => false, 'error' => 'permission_denied'];
}
}
$updated = UserRepository::setActiveByUuids($uuids, $active, $currentUserId > 0 ? $currentUserId : null);
if (!$updated) {
return ['ok' => false, 'error' => 'update_failed'];
}
return ['ok' => true, 'count' => count($uuids)];
}
private static function filterUuidsByTenantScope(array $uuids, int $currentUserId): array
{
$allowed = [];
foreach ($uuids as $uuid) {
$user = UserRepository::findByUuid((string) $uuid);
if (!$user || !isset($user['id'])) {
continue;
}
$userId = (int) $user['id'];
if ($userId === $currentUserId) {
$allowed[] = (string) $uuid;
continue;
}
if (TenantScopeService::canAccess('users', $userId, $currentUserId)) {
$allowed[] = (string) $uuid;
}
}
return array_values(array_unique($allowed));
}
public static function register(array $input): array
{
$form = self::sanitizeBase($input);
$password = (string) ($input['password'] ?? '');
$password2 = (string) ($input['password2'] ?? '');
$errors = self::validateBase($form);
$errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email']));
if ($errors) {
return ['ok' => false, 'error' => $errors[0]];
}
$defaultTheme = SettingService::getAppTheme();
$defaultTenantId = SettingService::getDefaultTenantId();
$activeChangedAt = gmdate('Y-m-d H:i:s');
$created = UserRepository::create([
'first_name' => $form['first_name'],
'last_name' => $form['last_name'],
'email' => $form['email'],
'password' => $password,
'locale' => I18n::$locale,
'totp_secret' => '',
'theme' => self::normalizeTheme($defaultTheme ?? 'light'),
'primary_tenant_id' => $defaultTenantId ?: null,
'active' => 1,
'created_by' => null,
'active_changed_at' => $activeChangedAt,
'active_changed_by' => null,
]);
if (!$created) {
return ['ok' => false, 'error' => t('User can not be registered')];
}
$createdUser = UserRepository::findByEmail($form['email']);
$userId = (int) ($createdUser['id'] ?? 0);
if ($userId > 0) {
if ($defaultTenantId) {
self::syncTenants($userId, [$defaultTenantId]);
}
$defaultRoleId = SettingService::getDefaultRoleId();
if ($defaultRoleId) {
self::syncRoles($userId, [$defaultRoleId]);
}
$defaultDepartmentId = SettingService::getDefaultDepartmentId();
if ($defaultDepartmentId) {
self::syncDepartments($userId, [$defaultDepartmentId]);
}
}
return ['ok' => true];
}
public static function syncTenants(int $userId, array $tenantIds): bool
{
$ids = self::normalizeTenantIds($tenantIds);
return UserTenantRepository::replaceForUser($userId, $ids);
}
public static function syncRoles(int $userId, array $roleIds): bool
{
$ids = array_values(array_unique(array_map('intval', $roleIds)));
$ids = array_filter($ids, static fn ($id) => $id > 0);
$validIds = RoleRepository::listIds();
if ($validIds) {
$validMap = array_fill_keys($validIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
}
$result = UserRoleRepository::replaceForUser($userId, $ids);
PermissionService::clearUserCache($userId);
return $result;
}
public static function syncDepartments(int $userId, array $departmentIds): bool
{
$ids = array_values(array_unique(array_map('intval', $departmentIds)));
$ids = array_filter($ids, static fn ($id) => $id > 0);
$userTenantIds = TenantScopeService::getUserTenantIds($userId);
if (!$userTenantIds) {
$ids = [];
} else {
$allowedIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds($userTenantIds);
if ($allowedIds) {
$allowedMap = array_fill_keys($allowedIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id])));
} else {
$ids = [];
}
}
return UserDepartmentRepository::replaceForUser($userId, $ids);
}
private static function sanitizeBase(array $input): array
{
return [
'first_name' => trim((string) ($input['first_name'] ?? '')),
'last_name' => trim((string) ($input['last_name'] ?? '')),
'email' => trim((string) ($input['email'] ?? '')),
'profile_description' => trim((string) ($input['profile_description'] ?? '')),
'job_title' => trim((string) ($input['job_title'] ?? '')),
'phone' => trim((string) ($input['phone'] ?? '')),
'mobile' => trim((string) ($input['mobile'] ?? '')),
'short_dial' => trim((string) ($input['short_dial'] ?? '')),
'address' => trim((string) ($input['address'] ?? '')),
'postal_code' => trim((string) ($input['postal_code'] ?? '')),
'city' => trim((string) ($input['city'] ?? '')),
'country' => trim((string) ($input['country'] ?? '')),
'region' => trim((string) ($input['region'] ?? '')),
'hire_date' => trim((string) ($input['hire_date'] ?? '')),
];
}
public static function normalizeIdInput($value): array
{
if (!is_array($value)) {
$value = [$value];
}
$ids = array_values(array_unique(array_map('intval', $value)));
return array_values(array_filter($ids, static fn ($id) => $id > 0));
}
private static function normalizeTenantIds(array $tenantIds): array
{
$ids = array_values(array_unique(array_map('intval', $tenantIds)));
$ids = array_filter($ids, static fn ($id) => $id > 0);
$validIds = TenantRepository::listIds();
if ($validIds) {
$validMap = array_fill_keys($validIds, true);
$ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id])));
}
return $ids;
}
private static function normalizeTheme($value): string
{
$theme = strtolower(trim((string) $value));
if (!in_array($theme, ['dark', 'light'], true)) {
return 'light';
}
return $theme;
}
private static function normalizeLocale($value): string
{
$locale = strtolower(trim((string) $value));
$available = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale];
if ($locale === '' || !in_array($locale, $available, true)) {
return I18n::$locale ?? I18n::$defaultLocale;
}
return $locale;
}
private static function validateBase(array $form, ?int $excludeId = null): array
{
$errors = [];
if ($form['first_name'] === '') {
$errors[] = t('First name cannot be empty');
}
if ($form['last_name'] === '') {
$errors[] = t('Last name cannot be empty');
}
if ($form['email'] === '') {
$errors[] = t('Email cannot be empty');
} elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
$errors[] = t('Email is not valid');
} else {
$existing = UserRepository::findByEmail($form['email']);
if ($existing && (!isset($existing['id']) || (int) $existing['id'] !== (int) $excludeId)) {
$errors[] = t('Email is already taken');
}
}
return $errors;
}
private static function normalizePrimaryTenant(int $primaryTenantId, array $tenantIds): array
{
if (!$tenantIds) {
return [0, []];
}
if ($primaryTenantId > 0 && !in_array($primaryTenantId, $tenantIds, true)) {
return [$primaryTenantId, [t('Primary tenant must be one of the assigned tenants')]];
}
if ($primaryTenantId === 0 && count($tenantIds) > 1) {
return [0, [t('Please select a primary tenant')]];
}
if ($primaryTenantId === 0 && count($tenantIds) === 1) {
return [(int) $tenantIds[0], []];
}
return [$primaryTenantId, []];
}
private static function validatePassword(
string $password,
string $password2,
bool $required,
?string $email
): array {
return self::validatePasswordStrength($password, $password2, $required, $email);
}
public static function passwordHints(): array
{
return [
['rule' => 'min', 'text' => t('At least %d characters', self::PASSWORD_MIN_LENGTH)],
['rule' => 'upper', 'text' => t('At least one uppercase letter')],
['rule' => 'lower', 'text' => t('At least one lowercase letter')],
['rule' => 'number', 'text' => t('At least one number')],
['rule' => 'symbol', 'text' => t('At least one symbol')],
['rule' => 'email', 'text' => t('Must not contain your email')],
];
}
public static function passwordMinLength(): int
{
return self::PASSWORD_MIN_LENGTH;
}
public static function resetPassword(int $userId, string $password, string $password2): array
{
$errors = self::validatePasswordStrength($password, $password2, true, '');
if ($errors) {
return ['ok' => false, 'errors' => $errors];
}
$updated = UserRepository::setPassword($userId, $password);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Password can not be updated')]];
}
return ['ok' => true];
}
private static function validatePasswordStrength(
string $password,
string $password2,
bool $required,
?string $email
): array {
$errors = [];
if ($required && $password === '') {
$errors[] = t('Password cannot be empty');
return $errors;
}
if ($password !== '' && $password !== $password2) {
$errors[] = t('Passwords must match');
}
if ($password === '') {
return $errors;
}
if (strlen($password) < self::PASSWORD_MIN_LENGTH) {
$errors[] = t('Password must be at least %d characters', self::PASSWORD_MIN_LENGTH);
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = t('Password must include an uppercase letter');
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = t('Password must include a lowercase letter');
}
if (!preg_match('/\\d/', $password)) {
$errors[] = t('Password must include a number');
}
if (!preg_match('/[^a-zA-Z0-9]/', $password)) {
$errors[] = t('Password must include a symbol');
}
if ($email) {
$email = trim((string) $email);
if ($email !== '' && stripos($password, $email) !== false) {
$errors[] = t('Password must not contain your email');
}
}
return $errors;
}
/**
* Get the current tenant ID for a user (with fallback to primary tenant)
*/
public static function getCurrentTenantId(int $userId): ?int
{
$user = UserRepository::find($userId);
if (!$user) {
return null;
}
$activeTenantIds = TenantScopeService::getUserTenantIds($userId);
if (!$activeTenantIds) {
return null;
}
$currentTenantId = (int) ($user['current_tenant_id'] ?? 0);
if ($currentTenantId > 0 && in_array($currentTenantId, $activeTenantIds, true)) {
return $currentTenantId;
}
$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0);
if ($primaryTenantId > 0 && in_array($primaryTenantId, $activeTenantIds, true)) {
return $primaryTenantId;
}
return $activeTenantIds[0] ?? null;
}
/**
* Get the current tenant data for a user
*/
public static function getCurrentTenant(int $userId): ?array
{
$currentTenantId = self::getCurrentTenantId($userId);
if (!$currentTenantId) {
return null;
}
return TenantRepository::find($currentTenantId);
}
/**
* Get all tenants the user has access to
*/
public static function getAvailableTenants(int $userId): array
{
$tenantIds = TenantScopeService::getUserTenantIds($userId);
if (!$tenantIds) {
return [];
}
$tenants = [];
foreach ($tenantIds as $tenantId) {
$tenant = TenantRepository::find($tenantId);
if ($tenant && (($tenant['status'] ?? 'active') === 'active')) {
$tenants[] = $tenant;
}
}
// Sort by description
usort($tenants, static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});
return $tenants;
}
/**
* Get available departments grouped by tenant for a user.
* Returns an array of groups: ['tenant' => [...], 'departments' => [...]]
*/
public static function getAvailableDepartmentsByTenant(int $userId): array
{
$tenants = self::getAvailableTenants($userId);
if (!$tenants) {
return [];
}
$groups = [];
foreach ($tenants as $tenant) {
$tenantId = (int) ($tenant['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$departmentIds = TenantDepartmentRepository::listDepartmentIdsByTenantIds([$tenantId]);
$departments = $departmentIds ? DepartmentRepository::listByIds($departmentIds) : [];
usort($departments, static function ($a, $b) {
return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''));
});
$departments = array_map(static function (array $department): array {
return [
'id' => (int) ($department['id'] ?? 0),
'uuid' => $department['uuid'] ?? '',
'description' => $department['description'] ?? '',
];
}, $departments);
$groups[] = [
'tenant' => [
'id' => (int) ($tenant['id'] ?? 0),
'uuid' => $tenant['uuid'] ?? '',
'description' => $tenant['description'] ?? '',
],
'departments' => $departments,
];
}
return $groups;
}
/**
* Set the current tenant for a user (with validation)
*/
public static function setCurrentTenant(int $userId, int $tenantId): array
{
if ($tenantId <= 0) {
return ['ok' => false, 'error' => 'invalid_tenant'];
}
// Verify user has access to this tenant
$userTenantIds = UserTenantRepository::listTenantIdsByUserId($userId);
if (!in_array($tenantId, $userTenantIds, true)) {
return ['ok' => false, 'error' => 'tenant_not_assigned'];
}
// Verify tenant exists
$tenant = TenantRepository::find($tenantId);
if (!$tenant) {
return ['ok' => false, 'error' => 'tenant_not_found'];
}
if (($tenant['status'] ?? 'active') !== 'active') {
return ['ok' => false, 'error' => 'tenant_inactive'];
}
// Update current_tenant_id
$updated = UserRepository::setCurrentTenant($userId, $tenantId);
if (!$updated) {
return ['ok' => false, 'error' => 'update_failed'];
}
return ['ok' => true, 'tenant' => $tenant];
}
}