baseline
This commit is contained in:
BIN
lib/.DS_Store
vendored
Normal file
BIN
lib/.DS_Store
vendored
Normal file
Binary file not shown.
96
lib/Http/AccessControl.php
Normal file
96
lib/Http/AccessControl.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles public path detection and authentication guards.
|
||||
*/
|
||||
class AccessControl
|
||||
{
|
||||
/** Paths that are always public (no auth required) */
|
||||
private const ALWAYS_PUBLIC_PATHS = [
|
||||
'login',
|
||||
'register',
|
||||
'verify-email',
|
||||
];
|
||||
|
||||
/** Prefixes that are always public */
|
||||
private const ALWAYS_PUBLIC_PREFIXES = [
|
||||
'password/',
|
||||
'branding/',
|
||||
'flash/',
|
||||
];
|
||||
|
||||
private array $configuredPublicPaths;
|
||||
|
||||
public function __construct(?array $publicPaths = null)
|
||||
{
|
||||
$this->configuredPublicPaths = $publicPaths ?? (defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is publicly accessible (no authentication required).
|
||||
*/
|
||||
public function isPublicPath(string $path): bool
|
||||
{
|
||||
$normalizedPath = $this->normalizePath($path);
|
||||
|
||||
// Root path check
|
||||
if ($normalizedPath === '/') {
|
||||
return in_array('/', $this->configuredPublicPaths, true);
|
||||
}
|
||||
|
||||
// Check configured public paths
|
||||
if (in_array($normalizedPath, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check page/ prefix with slug lookup
|
||||
if (str_starts_with($normalizedPath, 'page/')) {
|
||||
$pageSlug = substr($normalizedPath, strlen('page/'));
|
||||
if (in_array($pageSlug, $this->configuredPublicPaths, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check always-public paths
|
||||
if (in_array($normalizedPath, self::ALWAYS_PUBLIC_PATHS, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check always-public prefixes
|
||||
foreach (self::ALWAYS_PUBLIC_PREFIXES as $prefix) {
|
||||
if (str_starts_with($normalizedPath, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login if the path requires authentication and user is not logged in.
|
||||
*
|
||||
* @return bool True if access is allowed, false if redirected
|
||||
*/
|
||||
public function requireAuthOrRedirect(string $path, bool $isLoggedIn): bool
|
||||
{
|
||||
if ($this->isPublicPath($path) || $isLoggedIn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$locale = I18n::$locale ?? I18n::$defaultLocale;
|
||||
Router::redirect(Request::withLocale('login', $locale));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
$normalized = trim($path, '/');
|
||||
return $normalized === '' ? '/' : $normalized;
|
||||
}
|
||||
}
|
||||
56
lib/Http/AssetDetector.php
Normal file
56
lib/Http/AssetDetector.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
/**
|
||||
* Detects static asset requests that should bypass locale redirects.
|
||||
*/
|
||||
class AssetDetector
|
||||
{
|
||||
/** File extensions that indicate static assets */
|
||||
private const ASSET_EXTENSIONS = [
|
||||
'css', 'js', 'mjs', 'map',
|
||||
'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp',
|
||||
'woff', 'woff2', 'ttf', 'eot',
|
||||
'webmanifest',
|
||||
];
|
||||
|
||||
/** Path prefixes that indicate static asset directories */
|
||||
private const ASSET_PREFIXES = [
|
||||
'vendor/',
|
||||
'css/',
|
||||
'js/',
|
||||
'favicon/',
|
||||
'brand/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the given path is a static asset request.
|
||||
*/
|
||||
public static function isAssetRequest(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (self::hasAssetExtension($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check directory prefix
|
||||
foreach (self::ASSET_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function hasAssetExtension(string $path): bool
|
||||
{
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
return $extension !== '' && in_array($extension, self::ASSET_EXTENSIONS, true);
|
||||
}
|
||||
}
|
||||
142
lib/Http/LocaleResolver.php
Normal file
142
lib/Http/LocaleResolver.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use MintyPHP\Router;
|
||||
|
||||
/**
|
||||
* Handles locale detection and URL manipulation for multi-language support.
|
||||
*/
|
||||
class LocaleResolver
|
||||
{
|
||||
private array $availableLocales;
|
||||
private string $defaultLocale;
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(?array $availableLocales = null, ?string $defaultLocale = null)
|
||||
{
|
||||
$this->defaultLocale = $defaultLocale ?? I18n::$defaultLocale;
|
||||
$this->availableLocales = $availableLocales ?? (defined('APP_LOCALES') ? APP_LOCALES : [$this->defaultLocale]);
|
||||
$this->basePath = trim(parse_url(Router::getBaseUrl(), PHP_URL_PATH) ?: '/', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request URI and extract locale information.
|
||||
*
|
||||
* @return array{
|
||||
* locale: string,
|
||||
* pathWithoutLocale: string,
|
||||
* query: string,
|
||||
* hadLocaleInUrl: bool,
|
||||
* hadInvalidLocale: bool
|
||||
* }
|
||||
*/
|
||||
public function parseUri(string $uri): array
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$query = parse_url($uri, PHP_URL_QUERY) ?: '';
|
||||
|
||||
$relativePath = $this->stripBasePath($path);
|
||||
$segments = $relativePath === '' ? [] : explode('/', $relativePath);
|
||||
|
||||
$localeCandidate = $segments[0] ?? '';
|
||||
$hadLocaleInUrl = false;
|
||||
$hadInvalidLocale = false;
|
||||
$locale = '';
|
||||
|
||||
if ($localeCandidate !== '' && in_array($localeCandidate, $this->availableLocales, true)) {
|
||||
$locale = array_shift($segments);
|
||||
$hadLocaleInUrl = true;
|
||||
} elseif ($localeCandidate !== '' && $this->looksLikeLocale($localeCandidate)) {
|
||||
array_shift($segments);
|
||||
$hadInvalidLocale = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'locale' => $locale,
|
||||
'pathWithoutLocale' => implode('/', $segments),
|
||||
'query' => $query,
|
||||
'hadLocaleInUrl' => $hadLocaleInUrl,
|
||||
'hadInvalidLocale' => $hadInvalidLocale,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective locale from multiple sources (priority order):
|
||||
* 1. URL segment (explicit)
|
||||
* 2. User session preference
|
||||
* 3. Cookie preference
|
||||
* 4. Default locale
|
||||
*/
|
||||
public function resolveLocale(string $urlLocale, ?string $userLocale = null, ?string $cookieLocale = null): string
|
||||
{
|
||||
if ($urlLocale !== '' && $this->isValidLocale($urlLocale)) {
|
||||
return $urlLocale;
|
||||
}
|
||||
|
||||
if ($userLocale !== null && $userLocale !== '' && $this->isValidLocale($userLocale)) {
|
||||
return $userLocale;
|
||||
}
|
||||
|
||||
if ($cookieLocale !== null && $cookieLocale !== '' && $this->isValidLocale($cookieLocale)) {
|
||||
return $cookieLocale;
|
||||
}
|
||||
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a new REQUEST_URI without the locale segment.
|
||||
*/
|
||||
public function buildUriWithoutLocale(string $pathWithoutLocale, string $query): string
|
||||
{
|
||||
$newPath = $this->basePath !== '' ? '/' . $this->basePath : '';
|
||||
|
||||
if ($pathWithoutLocale !== '') {
|
||||
$newPath .= '/' . $pathWithoutLocale;
|
||||
} elseif ($newPath === '') {
|
||||
$newPath = '/';
|
||||
}
|
||||
|
||||
return $newPath . ($query !== '' ? '?' . $query : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a locale string is valid (exists in available locales).
|
||||
*/
|
||||
public function isValidLocale(string $locale): bool
|
||||
{
|
||||
return in_array($locale, $this->availableLocales, true);
|
||||
}
|
||||
|
||||
public function getAvailableLocales(): array
|
||||
{
|
||||
return $this->availableLocales;
|
||||
}
|
||||
|
||||
public function getDefaultLocale(): string
|
||||
{
|
||||
return $this->defaultLocale;
|
||||
}
|
||||
|
||||
private function stripBasePath(string $path): string
|
||||
{
|
||||
$relativePath = ltrim($path, '/');
|
||||
|
||||
if ($this->basePath !== '' && strpos($relativePath, $this->basePath . '/') === 0) {
|
||||
return substr($relativePath, strlen($this->basePath) + 1);
|
||||
}
|
||||
|
||||
if ($this->basePath !== '' && $relativePath === $this->basePath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
private function looksLikeLocale(string $candidate): bool
|
||||
{
|
||||
return preg_match('/^[a-z]{2}(?:-[a-z]{2})?$/i', $candidate) === 1;
|
||||
}
|
||||
}
|
||||
102
lib/Http/Request.php
Normal file
102
lib/Http/Request.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\I18n;
|
||||
|
||||
class Request
|
||||
{
|
||||
public static function path(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public static function safeReturnTarget(string $returnParam = ''): string
|
||||
{
|
||||
if ($returnParam !== '') {
|
||||
$parts = parse_url($returnParam);
|
||||
if (($parts['scheme'] ?? '') === '' && ($parts['host'] ?? '') === '') {
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
if ($referer !== '') {
|
||||
$parts = parse_url($referer);
|
||||
$host = $parts['host'] ?? '';
|
||||
$currentHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host === '' || $host === $currentHost) {
|
||||
$path = $parts['path'] ?? '';
|
||||
$query = isset($parts['query']) ? '?' . $parts['query'] : '';
|
||||
$base = trim(Router::getBaseUrl(), '/');
|
||||
$path = ltrim($path, '/');
|
||||
if ($base !== '' && strpos($path, $base . '/') === 0) {
|
||||
$path = substr($path, strlen($base) + 1);
|
||||
} elseif ($base !== '' && $path === $base) {
|
||||
$path = '';
|
||||
}
|
||||
return $path . $query;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function pathWithQuery(): string
|
||||
{
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
$path = self::path();
|
||||
$query = parse_url($uri, PHP_URL_QUERY);
|
||||
|
||||
return $path . ($query ? '?' . $query : '');
|
||||
}
|
||||
|
||||
public static function stripLocale(string $path, ?array $locales = null): string
|
||||
{
|
||||
$locales = $locales ?? (defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]);
|
||||
$path = ltrim($path, '/');
|
||||
if ($path === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = explode('/', $path);
|
||||
while ($parts && $parts[0] !== '' && in_array($parts[0], $locales, true)) {
|
||||
array_shift($parts);
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
public static function withLocale(string $path = '', ?string $locale = null): string
|
||||
{
|
||||
$locale = $locale ?? (I18n::$locale ?? I18n::$defaultLocale);
|
||||
$path = ltrim($path, '/');
|
||||
return ($locale !== '' ? $locale . '/' : '') . $path;
|
||||
}
|
||||
|
||||
public static function wantsJson(): bool
|
||||
{
|
||||
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
||||
return stripos($accept, 'application/json') !== false || $requestedWith !== '';
|
||||
}
|
||||
}
|
||||
307
lib/Repository/DepartmentRepository.php
Normal file
307
lib/Repository/DepartmentRepository.php
Normal file
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class DepartmentRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['departments'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$department = $row['departments'] ?? null;
|
||||
if (is_array($department)) {
|
||||
$list[] = $department;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from departments');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select departments.id, departments.uuid, departments.description, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
|
||||
'from departments departments join tenant_departments td on td.department_id = departments.id ' .
|
||||
'where td.tenant_id in (' . $placeholders . ') ' .
|
||||
'group by departments.id, departments.uuid, departments.description, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
|
||||
'order by departments.description asc',
|
||||
...array_map('strval', $tenantIds)
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listByIds(array $departmentIds): array
|
||||
{
|
||||
$departmentIds = array_values(array_unique(array_map('intval', $departmentIds)));
|
||||
$departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0);
|
||||
if (!$departmentIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments where id in (' . $placeholders . ') order by description asc',
|
||||
...array_map('strval', $departmentIds)
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(description like ? or uuid like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if ($tenant !== '') {
|
||||
$where[] = "exists (select 1 from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' where td.department_id = departments.id and t.uuid = ?)";
|
||||
$params[] = $tenant;
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from tenant_departments td ' .
|
||||
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
||||
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
|
||||
'where ut.user_id = ? and td.department_id = departments.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
|
||||
'or exists (select 1 from tenant_departments td ' .
|
||||
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
||||
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
|
||||
'where ut.user_id = ? and td.department_id = departments.id))';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
} elseif (array_key_exists('tenantIds', $options)) {
|
||||
$tenantIds = $options['tenantIds'];
|
||||
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
|
||||
if ($tenantIds) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))';
|
||||
$params[] = $tenantIds;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
|
||||
'or exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???)))';
|
||||
$params[] = $tenantIds;
|
||||
}
|
||||
} else {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = '1=0';
|
||||
} else {
|
||||
$where[] = 'not exists (select 1 from tenant_departments td where td.department_id = departments.id)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, created_by, modified_by, created, modified from departments' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
$list = self::unwrapList($rows);
|
||||
$ids = [];
|
||||
foreach ($list as $department) {
|
||||
if (isset($department['id'])) {
|
||||
$ids[] = (int) $department['id'];
|
||||
}
|
||||
}
|
||||
$tenantLabelsByDepartment = [];
|
||||
if ($ids) {
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$labelRows = DB::select(
|
||||
"select td.department_id as department_id, t.description as description from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' " .
|
||||
'where td.department_id in (' . $placeholders . ') order by t.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
$collectLabels = static function (array $rows): array {
|
||||
$labelsByDepartment = [];
|
||||
foreach ($rows as $row) {
|
||||
$departmentId = 0;
|
||||
$label = '';
|
||||
if (isset($row['td']) && is_array($row['td'])) {
|
||||
$departmentId = (int) ($row['td']['department_id'] ?? 0);
|
||||
}
|
||||
if (isset($row['t']) && is_array($row['t'])) {
|
||||
$label = (string) ($row['t']['description'] ?? '');
|
||||
}
|
||||
if ($departmentId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($departmentId === 0 && isset($value['department_id'])) {
|
||||
$departmentId = (int) $value['department_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($departmentId === 0 && isset($row['department_id'])) {
|
||||
$departmentId = (int) $row['department_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($departmentId > 0 && $label !== '') {
|
||||
$labelsByDepartment[$departmentId] ??= [];
|
||||
$labelsByDepartment[$departmentId][] = $label;
|
||||
}
|
||||
}
|
||||
return $labelsByDepartment;
|
||||
};
|
||||
$tenantLabelsByDepartment = $collectLabels($labelRows);
|
||||
}
|
||||
foreach ($list as &$department) {
|
||||
$departmentId = (int) ($department['id'] ?? 0);
|
||||
$department['tenant_labels'] = $tenantLabelsByDepartment[$departmentId] ?? [];
|
||||
}
|
||||
unset($department);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from departments where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into departments (uuid, description, created_by, created) values (?,?,?,NOW())',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['description'],
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update departments set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from departments where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
71
lib/Repository/EmailVerificationRepository.php
Normal file
71
lib/Repository/EmailVerificationRepository.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class EmailVerificationRepository
|
||||
{
|
||||
public static function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into email_verifications (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public static function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
142
lib/Repository/MailLogRepository.php
Normal file
142
lib/Repository/MailLogRepository.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class MailLogRepository
|
||||
{
|
||||
public static function create(array $data): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into mail_log (to_email, subject, template, status, created_at) values (?,?,?,?,NOW())',
|
||||
$data['to_email'],
|
||||
$data['subject'],
|
||||
$data['template'] ?? null,
|
||||
$data['status'] ?? 'queued'
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function markSent(int $id, ?string $providerMessageId = null): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update mail_log set status = ?, sent_at = NOW(), provider_message_id = ?, error_message = NULL where id = ?',
|
||||
'sent',
|
||||
$providerMessageId,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function markFailed(int $id, string $errorMessage): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update mail_log set status = ?, error_message = ? where id = ?',
|
||||
'failed',
|
||||
$errorMessage,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['mail_log'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$mailLog = $row['mail_log'] ?? null;
|
||||
if (is_array($mailLog)) {
|
||||
$list[] = $mailLog;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'created_at');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$status = trim((string) ($options['status'] ?? ''));
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
|
||||
$allowedOrder = ['id', 'created_at', 'sent_at', 'to_email', 'subject', 'status'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'created_at';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(to_email like ? or subject like ? or template like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = ?';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($createdFrom !== '') {
|
||||
$where[] = 'created_at >= ?';
|
||||
$params[] = $createdFrom . ' 00:00:00';
|
||||
}
|
||||
if ($createdTo !== '') {
|
||||
$where[] = 'created_at <= ?';
|
||||
$params[] = $createdTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from mail_log' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => self::unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
}
|
||||
59
lib/Repository/PageContentRepository.php
Normal file
59
lib/Repository/PageContentRepository.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PageContentRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['page_contents'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findByPageIdAndLocale(int $pageId, string $locale): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, page_id, locale, content, created_by, modified_by, created, modified from page_contents where page_id = ? and locale = ? limit 1',
|
||||
(string) $pageId,
|
||||
$locale
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into page_contents (page_id, locale, content, created_by, created) values (?,?,?,?,NOW())',
|
||||
(string) $data['page_id'],
|
||||
$data['locale'],
|
||||
$data['content'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'content' => $data['content'] ?? null,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update page_contents set ' . implode(', ', $setParts) . ', modified = NOW() where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
25
lib/Repository/PageRepository.php
Normal file
25
lib/Repository/PageRepository.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PageRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['pages'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findBySlug(string $slug): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, slug, created_by, modified_by, created, modified from pages where slug = ? limit 1',
|
||||
$slug
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
}
|
||||
71
lib/Repository/PasswordResetRepository.php
Normal file
71
lib/Repository/PasswordResetRepository.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PasswordResetRepository
|
||||
{
|
||||
public static function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into password_resets (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public static function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
104
lib/Repository/PermissionRepository.php
Normal file
104
lib/Repository/PermissionRepository.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class PermissionRepository
|
||||
{
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select('select id, `key`, description, created from permissions order by `key` asc');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['permissions'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'key');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$allowedOrder = ['key' => '`key`', 'description' => 'description', 'created' => 'created'];
|
||||
$orderBy = $allowedOrder[$order] ?? $allowedOrder['key'];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$where[] = '(permissions.`key` like ? or permissions.description like ?)';
|
||||
$params[] = '%' . $search . '%';
|
||||
$params[] = '%' . $search . '%';
|
||||
}
|
||||
$whereSql = $where ? ' where ' . implode(' and ', $where) : '';
|
||||
|
||||
$total = DB::selectValue('select count(*) from permissions' . $whereSql, ...$params);
|
||||
$query = 'select id, `key`, description, created from permissions' . $whereSql .
|
||||
' order by ' . $orderBy . ' ' . $dir . ' limit ? offset ?';
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = DB::select($query, ...$queryParams);
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['permissions'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return ['data' => $list, 'total' => (int) $total];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne('select id, `key`, description, created from permissions where id = ? limit 1', (string) $id);
|
||||
$data = $row['permissions'] ?? $row;
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public static function findByKey(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne('select id, `key`, description, created from permissions where `key` = ? limit 1', $key);
|
||||
$data = $row['permissions'] ?? $row;
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
public static function create(array $data): ?int
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$result = DB::insert(
|
||||
'insert into permissions (`key`, description, created) values (?,?,NOW())',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null
|
||||
);
|
||||
return $result ? (int) $result : null;
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$result = DB::update(
|
||||
'update permissions set `key` = ?, description = ? where id = ?',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from permissions where id = ?', (string) $id);
|
||||
return (bool) $result;
|
||||
}
|
||||
}
|
||||
55
lib/Repository/RememberTokenRepository.php
Normal file
55
lib/Repository/RememberTokenRepository.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class RememberTokenRepository
|
||||
{
|
||||
public static function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into user_remember_tokens (user_id, selector, token_hash, expires_at, created) values (?,?,?,?,NOW())',
|
||||
(string) $userId,
|
||||
$selector,
|
||||
$tokenHash,
|
||||
$expiresAt
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public static function findBySelector(string $selector): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, selector, token_hash, expires_at, last_used from user_remember_tokens where selector = ? limit 1',
|
||||
$selector
|
||||
);
|
||||
if (!$row || !isset($row['user_remember_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_remember_tokens'];
|
||||
}
|
||||
|
||||
public static function updateToken(int $id, string $tokenHash, string $expiresAt): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_remember_tokens set token_hash = ?, expires_at = ?, last_used = NOW() where id = ?',
|
||||
$tokenHash,
|
||||
$expiresAt,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function deleteById(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function deleteByUserId(int $userId): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where user_id = ?', (string) $userId);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
147
lib/Repository/RolePermissionRepository.php
Normal file
147
lib/Repository/RolePermissionRepository.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class RolePermissionRepository
|
||||
{
|
||||
public static function listPermissionIdsByRoleId(int $roleId): array
|
||||
{
|
||||
$rows = DB::select('select permission_id from role_permissions where role_id = ?', (string) $roleId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['permission_id'])) {
|
||||
$ids[] = (int) $data['permission_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForRole(int $roleId, array $permissionIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $permissionIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $permissionId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function listRoleIdsByPermissionId(int $permissionId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['role_id'])) {
|
||||
$ids[] = (int) $data['role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForPermission(int $permissionId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $roleId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function listPermissionKeysByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select p.`key` as `key` from role_permissions rp join permissions p on p.id = rp.permission_id ' .
|
||||
'where rp.role_id in (???)',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$keys = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['p'] ?? $row;
|
||||
if (is_array($data) && isset($data['key'])) {
|
||||
$keys[] = (string) $data['key'];
|
||||
} elseif (is_array($row) && isset($row['key'])) {
|
||||
$keys[] = (string) $row['key'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public static function listPermissionsWithRolesByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select rp.permission_id as permission_id, p.`key` as `key`, p.description as `description`, r.description as role ' .
|
||||
'from role_permissions rp join permissions p on p.id = rp.permission_id ' .
|
||||
'join roles r on r.id = rp.role_id where rp.role_id in (???) ' .
|
||||
'order by p.`key` asc, r.description asc',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$permMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowPerm = $row['p'] ?? ($row['permissions'] ?? $row);
|
||||
$rowRole = $row['r'] ?? ($row['roles'] ?? $row);
|
||||
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
|
||||
|
||||
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
|
||||
$key = (string) (($rowPerm['key'] ?? $row['key'] ?? '') ?? '');
|
||||
if ($permId === null || $key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($permMap[$permId])) {
|
||||
$desc = (string) (($rowPerm['description'] ?? $row['description'] ?? '') ?? '');
|
||||
$permMap[$permId] = [
|
||||
'key' => $key,
|
||||
'description' => $desc,
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
$roleLabel = (string) (($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '') ?? '');
|
||||
if ($roleLabel !== '') {
|
||||
$permMap[$permId]['roles'][] = $roleLabel;
|
||||
}
|
||||
}
|
||||
foreach ($permMap as &$item) {
|
||||
$item['roles'] = array_values(array_unique($item['roles'] ?? []));
|
||||
}
|
||||
unset($item);
|
||||
return array_values($permMap);
|
||||
}
|
||||
}
|
||||
170
lib/Repository/RoleRepository.php
Normal file
170
lib/Repository/RoleRepository.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class RoleRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['roles'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$role = $row['roles'] ?? null;
|
||||
if (is_array($role)) {
|
||||
$list[] = $role;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from roles order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from roles');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(description like ? or uuid like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from roles' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, created_by, modified_by, created, modified from roles' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => self::unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from roles where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, created_by, modified_by, created, modified from roles where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into roles (uuid, description, created_by, created) values (?,?,?,NOW())',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['description'],
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update roles set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from roles where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
35
lib/Repository/SettingRepository.php
Normal file
35
lib/Repository/SettingRepository.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class SettingRepository
|
||||
{
|
||||
public static function find(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne('select `key`, `value`, `description`, `updated_at` from settings where `key` = ? limit 1', $key);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['settings'] ?? $row;
|
||||
}
|
||||
|
||||
public static function getValue(string $key): ?string
|
||||
{
|
||||
$row = self::find($key);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return array_key_exists('value', $row) ? (string) $row['value'] : null;
|
||||
}
|
||||
|
||||
public static function set(string $key, ?string $value, ?string $description = null): bool
|
||||
{
|
||||
$query = 'insert into settings (`key`, `value`, `description`) values (?, ?, ?) '
|
||||
. 'on duplicate key update `value` = values(`value`), '
|
||||
. '`description` = ifnull(values(`description`), `description`)';
|
||||
$result = DB::update($query, $key, $value, $description);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
69
lib/Repository/TenantDepartmentRepository.php
Normal file
69
lib/Repository/TenantDepartmentRepository.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class TenantDepartmentRepository
|
||||
{
|
||||
public static function listTenantIdsByDepartmentId(int $departmentId): array
|
||||
{
|
||||
$rows = DB::select('select tenant_id from tenant_departments where department_id = ?', (string) $departmentId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenant_departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['tenant_id'])) {
|
||||
$ids[] = (int) $data['tenant_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listDepartmentIdsByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select department_id from tenant_departments where tenant_id in (' . $placeholders . ')',
|
||||
...array_map('strval', $tenantIds)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenant_departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['department_id'])) {
|
||||
$ids[] = (int) $data['department_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForDepartment(int $departmentId, array $tenantIds): bool
|
||||
{
|
||||
DB::delete('delete from tenant_departments where department_id = ?', (string) $departmentId);
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
|
||||
if (!$tenantIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::insert(
|
||||
'insert into tenant_departments (tenant_id, department_id, created) values (?,?,NOW())',
|
||||
(string) $tenantId,
|
||||
(string) $departmentId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
249
lib/Repository/TenantRepository.php
Normal file
249
lib/Repository/TenantRepository.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class TenantRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['tenants'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$tenant = $row['tenants'] ?? null;
|
||||
if (is_array($tenant)) {
|
||||
$list[] = $tenant;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from tenants');
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listActiveIdsByIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$params = array_map('strval', $tenantIds);
|
||||
$rows = call_user_func_array(
|
||||
['MintyPHP\\DB', 'select'],
|
||||
array_merge(
|
||||
["select id from tenants where status = 'active' and id in ($placeholders)"],
|
||||
$params
|
||||
)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['id'])) {
|
||||
$ids[] = (int) $data['id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(description like ? or uuid like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut where ut.user_id = ? and ut.tenant_id = tenants.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => self::unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['description'],
|
||||
$data['address'] ?? null,
|
||||
$data['postal_code'] ?? null,
|
||||
$data['city'] ?? null,
|
||||
$data['country'] ?? null,
|
||||
$data['region'] ?? null,
|
||||
$data['vat_id'] ?? null,
|
||||
$data['tax_number'] ?? null,
|
||||
$data['phone'] ?? null,
|
||||
$data['fax'] ?? null,
|
||||
$data['email'] ?? null,
|
||||
$data['support_email'] ?? null,
|
||||
$data['support_phone'] ?? null,
|
||||
$data['billing_email'] ?? null,
|
||||
$data['website'] ?? null,
|
||||
$data['privacy_url'] ?? null,
|
||||
$data['imprint_url'] ?? null,
|
||||
$data['primary_color'] ?? null,
|
||||
$data['status'] ?? 'active',
|
||||
$data['status_changed_at'] ?? null,
|
||||
$data['status_changed_by'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
'address' => $data['address'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'country' => $data['country'] ?? null,
|
||||
'region' => $data['region'] ?? null,
|
||||
'vat_id' => $data['vat_id'] ?? null,
|
||||
'tax_number' => $data['tax_number'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'fax' => $data['fax'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'support_email' => $data['support_email'] ?? null,
|
||||
'support_phone' => $data['support_phone'] ?? null,
|
||||
'billing_email' => $data['billing_email'] ?? null,
|
||||
'website' => $data['website'] ?? null,
|
||||
'privacy_url' => $data['privacy_url'] ?? null,
|
||||
'imprint_url' => $data['imprint_url'] ?? null,
|
||||
'primary_color' => $data['primary_color'] ?? null,
|
||||
'status' => $data['status'] ?? 'active',
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
if (array_key_exists('status_changed_at', $data)) {
|
||||
$fields['status_changed_at'] = $data['status_changed_at'];
|
||||
}
|
||||
if (array_key_exists('status_changed_by', $data)) {
|
||||
$fields['status_changed_by'] = $data['status_changed_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update tenants set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from tenants where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
54
lib/Repository/UserDepartmentRepository.php
Normal file
54
lib/Repository/UserDepartmentRepository.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserDepartmentRepository
|
||||
{
|
||||
public static function listDepartmentIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select department_id from user_departments where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_departments'] ?? $row;
|
||||
if (is_array($data) && isset($data['department_id'])) {
|
||||
$ids[] = (int) $data['department_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForUser(int $userId, array $departmentIds): bool
|
||||
{
|
||||
DB::delete('delete from user_departments where user_id = ?', (string) $userId);
|
||||
if (!$departmentIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
DB::insert(
|
||||
'insert into user_departments (user_id, department_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $departmentId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function removeInvalidForDepartment(int $departmentId): int
|
||||
{
|
||||
$query = 'delete ud from user_departments ud ' .
|
||||
'where ud.department_id = ? and not exists (' .
|
||||
'select 1 from user_tenants ut ' .
|
||||
'join tenant_departments td on td.tenant_id = ut.tenant_id ' .
|
||||
'where ut.user_id = ud.user_id and td.department_id = ud.department_id' .
|
||||
')';
|
||||
$result = DB::delete($query, (string) $departmentId);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
}
|
||||
534
lib/Repository/UserRepository.php
Normal file
534
lib/Repository/UserRepository.php
Normal file
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['users'] ?? null;
|
||||
}
|
||||
|
||||
private static function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$user = $row['users'] ?? null;
|
||||
if (is_array($user)) {
|
||||
foreach ($row as $key => $value) {
|
||||
if ($key === 'users' || is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
$user[$key] = $value;
|
||||
}
|
||||
if (isset($row['pt']) && is_array($row['pt'])) {
|
||||
$label = (string) ($row['pt']['description'] ?? '');
|
||||
if ($label !== '') {
|
||||
$user['primary_tenant_label'] = $label;
|
||||
}
|
||||
}
|
||||
$list[] = $user;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users order by id desc'
|
||||
);
|
||||
return self::unwrapList($rows);
|
||||
}
|
||||
|
||||
public static function listPaged(array $options): array
|
||||
{
|
||||
$limit = (int) ($options['limit'] ?? 10);
|
||||
if ($limit < 1) {
|
||||
$limit = 10;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? 0);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$active = $options['active'] ?? null;
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
|
||||
$roleIds = self::normalizeIdList($options['roles'] ?? []);
|
||||
$departmentIds = self::normalizeIdList($options['departments'] ?? []);
|
||||
$emailVerified = $options['email_verified'] ?? null;
|
||||
$order = (string) ($options['order'] ?? 'id');
|
||||
$dir = strtolower((string) ($options['dir'] ?? 'desc'));
|
||||
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'email', 'created', 'modified', 'active'];
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = 'id';
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = 'desc';
|
||||
}
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($search !== '') {
|
||||
$like = '%' . $search . '%';
|
||||
$where[] = '(users.first_name like ? or users.last_name like ? or users.email like ?)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
$activeFilter = null;
|
||||
if ($active !== null && $active !== '' && $active !== 'all') {
|
||||
$activeValue = strtolower((string) $active);
|
||||
if (in_array($activeValue, ['1', 'true', 'active'], true)) {
|
||||
$activeFilter = 1;
|
||||
} elseif (in_array($activeValue, ['0', 'false', 'inactive'], true)) {
|
||||
$activeFilter = 0;
|
||||
}
|
||||
}
|
||||
if ($activeFilter !== null) {
|
||||
$where[] = 'users.active = ?';
|
||||
$params[] = (string) $activeFilter;
|
||||
}
|
||||
|
||||
if ($createdFrom !== '') {
|
||||
$where[] = 'users.created >= ?';
|
||||
$params[] = $createdFrom . ' 00:00:00';
|
||||
}
|
||||
if ($createdTo !== '') {
|
||||
$where[] = 'users.created <= ?';
|
||||
$params[] = $createdTo . ' 23:59:59';
|
||||
}
|
||||
if ($tenantUuids) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid in (???))';
|
||||
$params[] = array_values($tenantUuids);
|
||||
} elseif ($tenant !== '') {
|
||||
$where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid = ?)';
|
||||
$params[] = $tenant;
|
||||
}
|
||||
if ($roleIds) {
|
||||
$where[] = 'exists (select 1 from user_roles ur2 where ur2.user_id = users.id and ur2.role_id in (???))';
|
||||
$params[] = array_map('strval', $roleIds);
|
||||
}
|
||||
if ($departmentIds) {
|
||||
$where[] = 'exists (select 1 from user_departments ud2 where ud2.user_id = users.id and ud2.department_id in (???))';
|
||||
$params[] = array_map('strval', $departmentIds);
|
||||
}
|
||||
if ($emailVerified !== null && $emailVerified !== '' && $emailVerified !== 'all') {
|
||||
$emailVerifiedValue = strtolower((string) $emailVerified);
|
||||
if (in_array($emailVerifiedValue, ['1', 'true', 'verified', 'yes'], true)) {
|
||||
$where[] = 'users.email_verified_at is not null';
|
||||
} elseif (in_array($emailVerifiedValue, ['0', 'false', 'unverified', 'no'], true)) {
|
||||
$where[] = 'users.email_verified_at is null';
|
||||
}
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' .
|
||||
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||
'where ut2.user_id = ? and ut.user_id = users.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from user_tenants ut where ut.user_id = users.id) ' .
|
||||
'or exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' .
|
||||
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||
'where ut2.user_id = ? and ut.user_id = users.id))';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from users' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select users.id, users.uuid, users.first_name, users.last_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.active, ' .
|
||||
'pt.description as primary_tenant_label ' .
|
||||
"from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" .
|
||||
$whereSql .
|
||||
sprintf(' order by `users`.`%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
$list = self::unwrapList($rows);
|
||||
$labelRows = [];
|
||||
$tenantMapByUser = [];
|
||||
$roleLabelRows = [];
|
||||
$departmentLabelRows = [];
|
||||
|
||||
$ids = [];
|
||||
foreach ($list as $user) {
|
||||
if (isset($user['id'])) {
|
||||
$ids[] = (int) $user['id'];
|
||||
}
|
||||
}
|
||||
if ($ids) {
|
||||
$scopeToActiveTenants = !empty($options['tenantUserId']);
|
||||
$tenantLabelJoin = $scopeToActiveTenants
|
||||
? "join tenants t on t.id = ut.tenant_id and t.status = 'active' "
|
||||
: 'join tenants t on t.id = ut.tenant_id ';
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$labelRows = DB::select(
|
||||
'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin .
|
||||
'where ut.user_id in (' . $placeholders . ') order by t.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
$roleLabelRows = DB::select(
|
||||
'select ur.user_id as user_id, r.description as description from user_roles ur join roles r on r.id = ur.role_id ' .
|
||||
'where ur.user_id in (' . $placeholders . ') order by r.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
$departmentLabelRows = DB::select(
|
||||
'select ud.user_id as user_id, d.description as description from user_departments ud join departments d on d.id = ud.department_id ' .
|
||||
'where ud.user_id in (' . $placeholders . ') order by d.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
|
||||
$collectLabels = static function (array $rows, string $userKey, string $labelKey): array {
|
||||
$labelsByUser = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = 0;
|
||||
$label = '';
|
||||
if (isset($row[$userKey]) && is_array($row[$userKey])) {
|
||||
$userId = (int) ($row[$userKey]['user_id'] ?? 0);
|
||||
}
|
||||
if (isset($row[$labelKey]) && is_array($row[$labelKey])) {
|
||||
$label = (string) ($row[$labelKey]['description'] ?? '');
|
||||
}
|
||||
if ($userId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($userId === 0 && isset($value['user_id'])) {
|
||||
$userId = (int) $value['user_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($userId === 0 && isset($row['user_id'])) {
|
||||
$userId = (int) $row['user_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($userId > 0 && $label !== '') {
|
||||
$labelsByUser[$userId] ??= [];
|
||||
$labelsByUser[$userId][] = $label;
|
||||
}
|
||||
}
|
||||
return $labelsByUser;
|
||||
};
|
||||
|
||||
foreach ($labelRows as $row) {
|
||||
$userId = 0;
|
||||
$tenantId = 0;
|
||||
$label = '';
|
||||
if (isset($row['ut']) && is_array($row['ut'])) {
|
||||
$userId = (int) ($row['ut']['user_id'] ?? 0);
|
||||
}
|
||||
if (isset($row['t']) && is_array($row['t'])) {
|
||||
$tenantId = (int) ($row['t']['tenant_id'] ?? 0);
|
||||
$label = (string) ($row['t']['description'] ?? '');
|
||||
}
|
||||
if ($userId === 0 || $tenantId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($userId === 0 && isset($value['user_id'])) {
|
||||
$userId = (int) $value['user_id'];
|
||||
}
|
||||
if ($tenantId === 0 && isset($value['tenant_id'])) {
|
||||
$tenantId = (int) $value['tenant_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($userId === 0 && isset($row['user_id'])) {
|
||||
$userId = (int) $row['user_id'];
|
||||
}
|
||||
if ($tenantId === 0 && isset($row['tenant_id'])) {
|
||||
$tenantId = (int) $row['tenant_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($userId > 0 && $tenantId > 0 && $label !== '') {
|
||||
$tenantMapByUser[$userId] ??= [];
|
||||
$tenantMapByUser[$userId][$tenantId] = $label;
|
||||
}
|
||||
}
|
||||
$tenantLabelsByUser = [];
|
||||
foreach ($tenantMapByUser as $userId => $map) {
|
||||
$tenantLabelsByUser[$userId] = array_values($map);
|
||||
}
|
||||
$roleLabelsByUser = $collectLabels($roleLabelRows, 'ur', 'r');
|
||||
$departmentLabelsByUser = $collectLabels($departmentLabelRows, 'ud', 'd');
|
||||
|
||||
foreach ($list as &$user) {
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
$primaryId = (int) ($user['primary_tenant_id'] ?? 0);
|
||||
$user['tenant_labels'] = $tenantLabelsByUser[$userId] ?? [];
|
||||
if ($primaryId > 0 && isset($tenantMapByUser[$userId][$primaryId])) {
|
||||
$user['primary_tenant_label'] = $tenantMapByUser[$userId][$primaryId];
|
||||
}
|
||||
$user['role_labels'] = $roleLabelsByUser[$userId] ?? [];
|
||||
$user['department_labels'] = $departmentLabelsByUser[$userId] ?? [];
|
||||
}
|
||||
unset($user);
|
||||
}
|
||||
|
||||
$result = [
|
||||
'total' => $total,
|
||||
'rows' => $list,
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where email = ? limit 1',
|
||||
$email
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
private static function uuidV4(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
||||
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
return DB::insert(
|
||||
'insert into users (uuid, first_name, last_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, created, active, active_changed_at, active_changed_by) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?,?,?)',
|
||||
$data['uuid'] ?? self::uuidV4(),
|
||||
$data['first_name'],
|
||||
$data['last_name'],
|
||||
$data['email'],
|
||||
$data['profile_description'] ?? null,
|
||||
$data['job_title'] ?? null,
|
||||
$data['phone'] ?? null,
|
||||
$data['mobile'] ?? null,
|
||||
$data['short_dial'] ?? null,
|
||||
$data['address'] ?? null,
|
||||
$data['postal_code'] ?? null,
|
||||
$data['city'] ?? null,
|
||||
$data['country'] ?? null,
|
||||
$data['region'] ?? null,
|
||||
$data['hire_date'] ?? null,
|
||||
$hash,
|
||||
$data['locale'] ?? null,
|
||||
$data['totp_secret'],
|
||||
$data['theme'] ?? 'light',
|
||||
$data['primary_tenant_id'] ?? null,
|
||||
$data['current_tenant_id'] ?? $data['primary_tenant_id'] ?? null,
|
||||
$data['created_by'] ?? null,
|
||||
$data['active'],
|
||||
$data['active_changed_at'] ?? null,
|
||||
$data['active_changed_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'email' => $data['email'],
|
||||
'profile_description' => $data['profile_description'] ?? null,
|
||||
'job_title' => $data['job_title'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'mobile' => $data['mobile'] ?? null,
|
||||
'short_dial' => $data['short_dial'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'country' => $data['country'] ?? null,
|
||||
'region' => $data['region'] ?? null,
|
||||
'hire_date' => $data['hire_date'] ?? null,
|
||||
'totp_secret' => $data['totp_secret'],
|
||||
'active' => $data['active'],
|
||||
];
|
||||
if (array_key_exists('locale', $data)) {
|
||||
$fields['locale'] = $data['locale'];
|
||||
}
|
||||
if (array_key_exists('theme', $data)) {
|
||||
$fields['theme'] = $data['theme'];
|
||||
}
|
||||
if (array_key_exists('primary_tenant_id', $data)) {
|
||||
$fields['primary_tenant_id'] = $data['primary_tenant_id'];
|
||||
}
|
||||
if (array_key_exists('current_tenant_id', $data)) {
|
||||
$fields['current_tenant_id'] = $data['current_tenant_id'];
|
||||
}
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
if (array_key_exists('active_changed_at', $data)) {
|
||||
$fields['active_changed_at'] = $data['active_changed_at'];
|
||||
}
|
||||
if (array_key_exists('active_changed_by', $data)) {
|
||||
$fields['active_changed_by'] = $data['active_changed_by'];
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update users set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setActive(int $id, bool $active, ?int $changedBy = null): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update users set active = ?, active_changed_at = NOW(), active_changed_by = ? where id = ?',
|
||||
$active ? 1 : 0,
|
||||
$changedBy,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setCurrentTenant(int $id, int $tenantId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update users set current_tenant_id = ? where id = ?',
|
||||
(string) $tenantId,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setActiveByUuids(array $uuids, bool $active, ?int $modifiedBy = null): bool
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return false;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($uuids), '?'));
|
||||
$query = "update users set active = ?, modified_by = ?, active_changed_at = NOW(), active_changed_by = ? where uuid in ($placeholders)";
|
||||
$params = array_merge([$active ? 1 : 0, $modifiedBy, $modifiedBy], $uuids);
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setLocale(int $id, string $locale): bool
|
||||
{
|
||||
$result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setTheme(int $id, string $theme): bool
|
||||
{
|
||||
$result = DB::update('update users set theme = ? where id = ?', $theme, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setPassword(int $id, string $password): bool
|
||||
{
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$result = DB::update('update users set password = ?, modified_by = NULL where id = ?', $hash, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function setEmailVerified(int $id): bool
|
||||
{
|
||||
$result = DB::update('update users set email_verified_at = NOW() where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from users where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public static function deleteByUuids(array $uuids): bool
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return false;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($uuids), '?'));
|
||||
$query = "delete from users where uuid in ($placeholders)";
|
||||
$result = DB::delete($query, ...$uuids);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private static function normalizeIdList($value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = array_filter(array_map('trim', explode(',', $value)));
|
||||
} elseif (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($value as $item) {
|
||||
if ($item === '' || $item === null) {
|
||||
continue;
|
||||
}
|
||||
$ids[] = (int) $item;
|
||||
}
|
||||
$ids = array_values(array_filter(array_unique($ids), static function ($id) {
|
||||
return $id > 0;
|
||||
}));
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
42
lib/Repository/UserRoleRepository.php
Normal file
42
lib/Repository/UserRoleRepository.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserRoleRepository
|
||||
{
|
||||
public static function listRoleIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from user_roles where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['role_id'])) {
|
||||
$ids[] = (int) $data['role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForUser(int $userId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from user_roles where user_id = ?', (string) $userId);
|
||||
if (!$roleIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($roleIds as $roleId) {
|
||||
DB::insert(
|
||||
'insert into user_roles (user_id, role_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $roleId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
42
lib/Repository/UserTenantRepository.php
Normal file
42
lib/Repository/UserTenantRepository.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserTenantRepository
|
||||
{
|
||||
public static function listTenantIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select tenant_id from user_tenants where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['tenant_id'])) {
|
||||
$ids[] = (int) $data['tenant_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function replaceForUser(int $userId, array $tenantIds): bool
|
||||
{
|
||||
DB::delete('delete from user_tenants where user_id = ?', (string) $userId);
|
||||
if (!$tenantIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::insert(
|
||||
'insert into user_tenants (user_id, tenant_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $tenantId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
163
lib/Service/AuthService.php
Normal file
163
lib/Service/AuthService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
lib/Service/BrandingFaviconService.php
Normal file
238
lib/Service/BrandingFaviconService.php
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
293
lib/Service/BrandingLogoService.php
Normal file
293
lib/Service/BrandingLogoService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
161
lib/Service/DepartmentService.php
Normal file
161
lib/Service/DepartmentService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
146
lib/Service/EmailVerificationService.php
Normal file
146
lib/Service/EmailVerificationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
18
lib/Service/MailLogService.php
Normal file
18
lib/Service/MailLogService.php
Normal 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
153
lib/Service/MailService.php
Normal 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
161
lib/Service/PageService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
147
lib/Service/PasswordResetService.php
Normal file
147
lib/Service/PasswordResetService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
180
lib/Service/PermissionService.php
Normal file
180
lib/Service/PermissionService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
157
lib/Service/RememberMeService.php
Normal file
157
lib/Service/RememberMeService.php
Normal 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
116
lib/Service/RoleService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
340
lib/Service/SettingService.php
Normal file
340
lib/Service/SettingService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
328
lib/Service/TenantAvatarService.php
Normal file
328
lib/Service/TenantAvatarService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
214
lib/Service/TenantFaviconService.php
Normal file
214
lib/Service/TenantFaviconService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
118
lib/Service/TenantScopeService.php
Normal file
118
lib/Service/TenantScopeService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
207
lib/Service/TenantService.php
Normal file
207
lib/Service/TenantService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
307
lib/Service/UserAvatarService.php
Normal file
307
lib/Service/UserAvatarService.php
Normal 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
804
lib/Service/UserService.php
Normal 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];
|
||||
}
|
||||
}
|
||||
103
lib/Support/Flash.php
Normal file
103
lib/Support/Flash.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
use MintyPHP\Session;
|
||||
|
||||
class Flash
|
||||
{
|
||||
private const SESSION_KEY = 'flash_messages';
|
||||
private const KEEP_KEY = 'flash_keep';
|
||||
|
||||
private static function ensureSession()
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
Session::start();
|
||||
}
|
||||
if (!isset($_SESSION[self::SESSION_KEY])) {
|
||||
$_SESSION[self::SESSION_KEY] = [];
|
||||
}
|
||||
}
|
||||
|
||||
public static function add(string $type, string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
self::ensureSession();
|
||||
if ($key !== null) {
|
||||
$_SESSION[self::SESSION_KEY] = array_values(array_filter(
|
||||
$_SESSION[self::SESSION_KEY],
|
||||
function ($existing) use ($key, $scope) {
|
||||
$sameKey = ($existing['key'] ?? null) === $key;
|
||||
$sameScope = ($existing['scope'] ?? null) === $scope;
|
||||
return !($sameKey && $sameScope);
|
||||
}
|
||||
));
|
||||
}
|
||||
$id = bin2hex(random_bytes(8));
|
||||
$_SESSION[self::SESSION_KEY][] = [
|
||||
'id' => $id,
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
'scope' => $scope,
|
||||
'key' => $key,
|
||||
];
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function success(string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
return self::add('success', $message, $scope, $key);
|
||||
}
|
||||
|
||||
public static function error(string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
return self::add('error', $message, $scope, $key);
|
||||
}
|
||||
|
||||
public static function info(string $message, ?string $scope = null, ?string $key = null): string
|
||||
{
|
||||
return self::add('info', $message, $scope, $key);
|
||||
}
|
||||
|
||||
public static function peek(?string $scope = null): array
|
||||
{
|
||||
self::ensureSession();
|
||||
$messages = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
if ($scope === null) {
|
||||
return $messages;
|
||||
}
|
||||
|
||||
return array_values(array_filter($messages, function ($message) use ($scope) {
|
||||
$messageScope = $message['scope'] ?? null;
|
||||
return $messageScope === null || $messageScope === $scope;
|
||||
}));
|
||||
}
|
||||
|
||||
public static function has(): bool
|
||||
{
|
||||
self::ensureSession();
|
||||
return !empty($_SESSION[self::SESSION_KEY]);
|
||||
}
|
||||
|
||||
public static function dismiss(string $id): void
|
||||
{
|
||||
self::ensureSession();
|
||||
$messages = $_SESSION[self::SESSION_KEY] ?? [];
|
||||
$messages = array_values(array_filter($messages, function ($message) use ($id) {
|
||||
return ($message['id'] ?? '') !== $id;
|
||||
}));
|
||||
|
||||
if ($messages) {
|
||||
$_SESSION[self::SESSION_KEY] = $messages;
|
||||
} else {
|
||||
unset($_SESSION[self::SESSION_KEY]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function keep(int $times = 1)
|
||||
{
|
||||
self::ensureSession();
|
||||
$times = max(1, $times);
|
||||
$current = (int) ($_SESSION[self::KEEP_KEY] ?? 0);
|
||||
$_SESSION[self::KEEP_KEY] = max($current, $times);
|
||||
}
|
||||
}
|
||||
92
lib/Support/Guard.php
Normal file
92
lib/Support/Guard.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\TenantService;
|
||||
use MintyPHP\Service\AuthService;
|
||||
use MintyPHP\Http\Request;
|
||||
|
||||
class Guard
|
||||
{
|
||||
private const TENANT_REFRESH_INTERVAL = 60;
|
||||
|
||||
public static function requireLogin(string $redirect = 'login'): void
|
||||
{
|
||||
if (!isset($_SESSION['user'])) {
|
||||
Flash::error('Login required', 'login', 'login_required');
|
||||
Router::redirect($redirect);
|
||||
}
|
||||
self::refreshTenantContext();
|
||||
if (!empty($_SESSION['no_active_tenant'])) {
|
||||
AuthService::logout();
|
||||
Flash::error('No active tenant assigned', 'login', 'no_active_tenant');
|
||||
Router::redirect($redirect);
|
||||
}
|
||||
}
|
||||
|
||||
private static function validateCurrentTenant(): void
|
||||
{
|
||||
$currentTenant = $_SESSION['current_tenant'] ?? null;
|
||||
if (!$currentTenant || empty($currentTenant['id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenantId = (int) $currentTenant['id'];
|
||||
$tenant = TenantService::findById($tenantId);
|
||||
if ($tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Current tenant was deleted, refresh session data
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
AuthService::loadTenantDataIntoSession($userId);
|
||||
} else {
|
||||
unset($_SESSION['current_tenant']);
|
||||
}
|
||||
}
|
||||
|
||||
private static function refreshTenantContext(): void
|
||||
{
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$last = (int) ($_SESSION['tenant_context_refreshed_at'] ?? 0);
|
||||
$now = time();
|
||||
if ($last > 0 && ($now - $last) < self::TENANT_REFRESH_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
AuthService::loadTenantDataIntoSession($userId);
|
||||
$_SESSION['tenant_context_refreshed_at'] = $now;
|
||||
}
|
||||
|
||||
public static function requirePermission(string $permissionKey, string $redirect = 'admin'): void
|
||||
{
|
||||
self::requireLogin();
|
||||
self::validateCurrentTenant();
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if (!$userId || !PermissionService::userHas($userId, $permissionKey)) {
|
||||
Flash::error('Permission denied', $redirect, 'permission_denied');
|
||||
Router::redirect($redirect);
|
||||
}
|
||||
}
|
||||
|
||||
public static function requirePermissionOrForbidden(string $permissionKey): void
|
||||
{
|
||||
self::requireLogin();
|
||||
self::validateCurrentTenant();
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if (!$userId || !PermissionService::userHas($userId, $permissionKey)) {
|
||||
if (Request::wantsJson()) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'forbidden']);
|
||||
exit;
|
||||
}
|
||||
Router::redirect('error/forbidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
13
lib/Support/Hello.php
Normal file
13
lib/Support/Hello.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
class Hello
|
||||
{
|
||||
public static $name = 'MintyPHP';
|
||||
|
||||
public static function getGreeting(): string
|
||||
{
|
||||
return 'Hello ' . static::$name . '!';
|
||||
}
|
||||
}
|
||||
230
lib/Support/SearchConfig.php
Normal file
230
lib/Support/SearchConfig.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
use MintyPHP\Service\PermissionService;
|
||||
use MintyPHP\Service\UserAvatarService;
|
||||
|
||||
class SearchConfig
|
||||
{
|
||||
public static function tenantScopeFilters(): array
|
||||
{
|
||||
return [
|
||||
'users' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
|
||||
'address-book' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
|
||||
'tenants' => 'and id in (???)',
|
||||
'departments' => 'and exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))',
|
||||
];
|
||||
}
|
||||
|
||||
public static function resources(string $query, string $locale): array
|
||||
{
|
||||
$like = '%' . $query . '%';
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'address-book',
|
||||
'label' => t('Address book'),
|
||||
'permission' => PermissionService::ADDRESS_BOOK_VIEW,
|
||||
'countSql' => 'select count(*) from users where active = 1 and (first_name like ? or last_name like ? or email like ?) {{tenantFilter}}',
|
||||
'countParams' => [$like, $like, $like],
|
||||
'previewSql' => 'select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name limit ?',
|
||||
'previewParams' => [$like, $like, $like],
|
||||
'resultSql' => 'select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name',
|
||||
'resultParams' => [$like, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'users',
|
||||
'label' => t('Users'),
|
||||
'permission' => PermissionService::USERS_VIEW,
|
||||
'countSql' => 'select count(*) from users where (first_name like ? or last_name like ? or email like ?) {{tenantFilter}}',
|
||||
'countParams' => [$like, $like, $like],
|
||||
'previewSql' => 'select uuid, first_name, last_name, email from users where (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name limit ?',
|
||||
'previewParams' => [$like, $like, $like],
|
||||
'resultSql' => 'select uuid, first_name, last_name, email from users where (first_name like ? or last_name like ? or email like ?) {{tenantFilter}} order by last_name, first_name',
|
||||
'resultParams' => [$like, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'tenants',
|
||||
'label' => t('Tenants'),
|
||||
'permission' => PermissionService::TENANTS_VIEW,
|
||||
'countSql' => 'select count(*) from tenants where description like ? {{tenantFilter}}',
|
||||
'countParams' => [$like],
|
||||
'previewSql' => 'select uuid, description from tenants where description like ? {{tenantFilter}} order by description limit ?',
|
||||
'previewParams' => [$like],
|
||||
'resultSql' => 'select uuid, description from tenants where description like ? {{tenantFilter}} order by description',
|
||||
'resultParams' => [$like],
|
||||
],
|
||||
[
|
||||
'key' => 'departments',
|
||||
'label' => t('Departments'),
|
||||
'permission' => PermissionService::DEPARTMENTS_VIEW,
|
||||
'countSql' => 'select count(*) from departments where description like ? {{tenantFilter}}',
|
||||
'countParams' => [$like],
|
||||
'previewSql' => 'select uuid, description from departments where description like ? {{tenantFilter}} order by description limit ?',
|
||||
'previewParams' => [$like],
|
||||
'resultSql' => 'select uuid, description from departments where description like ? {{tenantFilter}} order by description',
|
||||
'resultParams' => [$like],
|
||||
],
|
||||
[
|
||||
'key' => 'roles',
|
||||
'label' => t('Roles'),
|
||||
'permission' => PermissionService::ROLES_VIEW,
|
||||
'countSql' => 'select count(*) from roles where description like ?',
|
||||
'countParams' => [$like],
|
||||
'previewSql' => 'select uuid, description from roles where description like ? order by description limit ?',
|
||||
'previewParams' => [$like],
|
||||
'resultSql' => 'select uuid, description from roles where description like ? order by description',
|
||||
'resultParams' => [$like],
|
||||
],
|
||||
[
|
||||
'key' => 'permissions',
|
||||
'label' => t('Permissions'),
|
||||
'permission' => PermissionService::PERMISSIONS_VIEW,
|
||||
'countSql' => 'select count(*) from permissions where `key` like ? or description like ?',
|
||||
'countParams' => [$like, $like],
|
||||
'previewSql' => 'select id, description, `key` from permissions where `key` like ? or description like ? order by `key` limit ?',
|
||||
'previewParams' => [$like, $like],
|
||||
'resultSql' => 'select id, description, `key` from permissions where `key` like ? or description like ? order by `key`',
|
||||
'resultParams' => [$like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'pages',
|
||||
'label' => t('Pages'),
|
||||
'permission' => '',
|
||||
'countSql' => 'select count(*) from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? or pc.content like ?',
|
||||
'countParams' => [$locale, $like, $like],
|
||||
'previewSql' => 'select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? or pc.content like ? order by p.slug limit ?',
|
||||
'previewParams' => [$locale, $like, $like],
|
||||
'resultSql' => 'select p.slug from pages p join page_contents pc on pc.page_id = p.id and pc.locale = ? where p.slug like ? or pc.content like ? order by p.slug',
|
||||
'resultParams' => [$locale, $like, $like],
|
||||
],
|
||||
[
|
||||
'key' => 'settings',
|
||||
'label' => t('Settings'),
|
||||
'permission' => PermissionService::SETTINGS_VIEW,
|
||||
'countSql' => 'select count(*) from settings where `key` like ? or value like ?',
|
||||
'countParams' => [$like, $like],
|
||||
'previewSql' => null,
|
||||
'previewParams' => [],
|
||||
'resultSql' => 'select `key`, value from settings where `key` like ? or value like ? order by `key`',
|
||||
'resultParams' => [$like, $like],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function iconForKey(string $key): string
|
||||
{
|
||||
return match ($key) {
|
||||
'address-book' => 'bi-people',
|
||||
'users' => 'bi-person-badge',
|
||||
'tenants' => 'bi-buildings',
|
||||
'departments' => 'bi-diagram-3',
|
||||
'roles' => 'bi-people',
|
||||
'permissions' => 'bi-shield-lock',
|
||||
'pages' => 'bi-file-text',
|
||||
'settings' => 'bi-gear',
|
||||
default => 'bi-search',
|
||||
};
|
||||
}
|
||||
|
||||
public static function listUrl(string $key, string $query): string
|
||||
{
|
||||
$encoded = urlencode($query);
|
||||
|
||||
return match ($key) {
|
||||
'address-book' => lurl('address-book') . '?search=' . $encoded,
|
||||
'users' => lurl('admin/users') . '?search=' . $encoded,
|
||||
'tenants' => lurl('admin/tenants') . '?search=' . $encoded,
|
||||
'departments' => lurl('admin/departments') . '?search=' . $encoded,
|
||||
'roles' => lurl('admin/roles') . '?search=' . $encoded,
|
||||
'permissions' => lurl('admin/permissions') . '?search=' . $encoded,
|
||||
'pages' => lurl(''),
|
||||
'settings' => lurl('admin/settings'),
|
||||
default => lurl(''),
|
||||
};
|
||||
}
|
||||
|
||||
public static function mapPreviewItem(string $key, array $data, string $query): ?array
|
||||
{
|
||||
if ($key === 'users') {
|
||||
$label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
if ($email !== '') {
|
||||
$label = $label === '' ? $email : ($label . ' — ' . $email);
|
||||
}
|
||||
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'address-book') {
|
||||
$label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$email = trim((string) ($data['email'] ?? ''));
|
||||
if ($email !== '') {
|
||||
$label = $label === '' ? $email : ($label . ' — ' . $email);
|
||||
}
|
||||
$url = lurl('address-book/view/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'permissions') {
|
||||
$label = (string) ($data['description'] ?? '');
|
||||
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
|
||||
} elseif ($key === 'pages') {
|
||||
$label = (string) ($data['slug'] ?? '');
|
||||
$url = lurl('page/' . $label) . '?search=' . urlencode($query);
|
||||
} else {
|
||||
$label = (string) ($data['description'] ?? '');
|
||||
$url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
|
||||
}
|
||||
|
||||
if ($label === '' || $url === '') {
|
||||
return null;
|
||||
}
|
||||
return ['label' => $label, 'url' => $url];
|
||||
}
|
||||
|
||||
public static function mapResultItem(string $key, string $label, array $data): ?array
|
||||
{
|
||||
$title = '';
|
||||
$description = '';
|
||||
$url = '';
|
||||
$image = '';
|
||||
if ($key === 'users') {
|
||||
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$description = (string) ($data['email'] ?? '');
|
||||
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? ''));
|
||||
} elseif ($key === 'address-book') {
|
||||
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
|
||||
$description = (string) ($data['email'] ?? '');
|
||||
$uuid = (string) ($data['uuid'] ?? '');
|
||||
$url = lurl('address-book/view/' . $uuid);
|
||||
if ($uuid !== '' && UserAvatarService::hasAvatar($uuid)) {
|
||||
$image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64';
|
||||
}
|
||||
} elseif ($key === 'permissions') {
|
||||
$title = (string) ($data['key'] ?? '');
|
||||
$description = (string) ($data['description'] ?? '');
|
||||
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? ''));
|
||||
} elseif ($key === 'pages') {
|
||||
$title = (string) ($data['slug'] ?? '');
|
||||
$description = t('Page');
|
||||
$url = lurl('page/' . $title);
|
||||
} elseif ($key === 'settings') {
|
||||
$title = (string) ($data['key'] ?? '');
|
||||
$description = (string) ($data['value'] ?? '');
|
||||
$url = lurl('admin/settings');
|
||||
} else {
|
||||
$title = (string) ($data['description'] ?? '');
|
||||
$description = $label;
|
||||
$url = lurl('admin/' . $key . '/edit/' . ($data['uuid'] ?? ''));
|
||||
}
|
||||
|
||||
if ($title === '' || $url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $label,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'url' => $url,
|
||||
'image' => $image,
|
||||
'icon' => self::iconForKey($key),
|
||||
];
|
||||
}
|
||||
}
|
||||
28
lib/Support/Tile.php
Normal file
28
lib/Support/Tile.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Support;
|
||||
|
||||
class Tile
|
||||
{
|
||||
public static function render(array $options = []): void
|
||||
{
|
||||
$defaults = [
|
||||
'href' => '#',
|
||||
'label' => '',
|
||||
'count' => null,
|
||||
'icon' => 'bi bi-grid-1x2',
|
||||
'iconBg' => null,
|
||||
'iconColor' => null,
|
||||
'class' => '',
|
||||
];
|
||||
|
||||
$data = array_merge($defaults, $options);
|
||||
$template = dirname(__DIR__, 2) . '/templates/partials/app-tile.phtml';
|
||||
if (!is_file($template)) {
|
||||
return;
|
||||
}
|
||||
|
||||
extract($data, EXTR_SKIP);
|
||||
require $template;
|
||||
}
|
||||
}
|
||||
6
lib/Support/helpers.php
Normal file
6
lib/Support/helpers.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
require __DIR__ . '/helpers/app.php';
|
||||
require __DIR__ . '/helpers/auth.php';
|
||||
require __DIR__ . '/helpers/i18n.php';
|
||||
require __DIR__ . '/helpers/ui.php';
|
||||
208
lib/Support/helpers/app.php
Normal file
208
lib/Support/helpers/app.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
function e($string): void
|
||||
{
|
||||
echo htmlspecialchars((string) $string, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function asset(string $path): string
|
||||
{
|
||||
return \MintyPHP\Router::getBaseUrl() . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function localeBase(): string
|
||||
{
|
||||
$locale = \MintyPHP\I18n::$locale ?? \MintyPHP\I18n::$defaultLocale;
|
||||
$prefix = $locale !== '' ? $locale . '/' : '';
|
||||
return \MintyPHP\Router::getBaseUrl() . $prefix;
|
||||
}
|
||||
|
||||
function lurl(string $path = ''): string
|
||||
{
|
||||
return localeBase() . ltrim($path, '/');
|
||||
}
|
||||
|
||||
function appTitle(): string
|
||||
{
|
||||
$default = defined('APP_NAME') ? APP_NAME : 'IMVS';
|
||||
$title = appSetting('app_title');
|
||||
if ($title !== null) {
|
||||
return $title;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
function appUrl(string $path = ''): string
|
||||
{
|
||||
if ($path !== '' && preg_match('#^https?://#i', $path)) {
|
||||
return $path;
|
||||
}
|
||||
$base = getenv('APP_URL') ?: '';
|
||||
$base = rtrim((string) $base, '/');
|
||||
if ($base === '') {
|
||||
$scheme = 'http';
|
||||
$https = $_SERVER['HTTPS'] ?? '';
|
||||
$forwarded = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '';
|
||||
if ($https === 'on' || $https === '1' || $forwarded === 'https') {
|
||||
$scheme = 'https';
|
||||
}
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($host !== '') {
|
||||
$base = $scheme . '://' . $host;
|
||||
}
|
||||
}
|
||||
if ($base === '') {
|
||||
return \MintyPHP\Router::getBaseUrl() . ltrim((string) $path, '/');
|
||||
}
|
||||
$path = ltrim((string) $path, '/');
|
||||
return $base . '/' . $path;
|
||||
}
|
||||
|
||||
function appLogoUrlAbsolute(int $size = 128): string
|
||||
{
|
||||
$url = appLogoUrl($size);
|
||||
return appUrl($url);
|
||||
}
|
||||
|
||||
function appSetting(string $key): ?string
|
||||
{
|
||||
$cacheFile = dirname(__DIR__, 3) . '/config/settings.php';
|
||||
if (!is_file($cacheFile)) {
|
||||
return null;
|
||||
}
|
||||
$settings = include $cacheFile;
|
||||
if (!is_array($settings)) {
|
||||
return null;
|
||||
}
|
||||
$value = $settings[$key] ?? null;
|
||||
$value = $value !== null ? trim((string) $value) : '';
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
function appDefaultLocale(): ?string
|
||||
{
|
||||
$locale = appSetting('app_locale');
|
||||
if ($locale === null) {
|
||||
return null;
|
||||
}
|
||||
$available = defined('APP_LOCALES') ? APP_LOCALES : [];
|
||||
if ($available && !in_array($locale, $available, true)) {
|
||||
return null;
|
||||
}
|
||||
return $locale;
|
||||
}
|
||||
|
||||
function appDefaultTheme(): string
|
||||
{
|
||||
$setting = appSetting('app_theme');
|
||||
if (in_array($setting, ['light', 'dark'], true)) {
|
||||
return $setting;
|
||||
}
|
||||
$envTheme = getenv('APP_THEME') ?: 'light';
|
||||
$envTheme = strtolower(trim((string) $envTheme));
|
||||
return in_array($envTheme, ['light', 'dark'], true) ? $envTheme : 'light';
|
||||
}
|
||||
|
||||
function allowUserTheme(): bool
|
||||
{
|
||||
$value = appSetting('app_theme_user');
|
||||
if ($value === null || $value === '') {
|
||||
return true;
|
||||
}
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
function appPrimaryColor(): ?string
|
||||
{
|
||||
$tenantColor = $_SESSION['current_tenant']['primary_color'] ?? null;
|
||||
if ($tenantColor !== null) {
|
||||
$tenantColor = strtolower(trim((string) $tenantColor));
|
||||
if (preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $tenantColor)) {
|
||||
return $tenantColor;
|
||||
}
|
||||
}
|
||||
|
||||
$value = appSetting('app_primary_color');
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$value = strtolower(trim($value));
|
||||
if (!preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) {
|
||||
return null;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
function appPrimaryCssVars(): string
|
||||
{
|
||||
$hex = appPrimaryColor();
|
||||
if ($hex === null) {
|
||||
return '';
|
||||
}
|
||||
$hex = ltrim($hex, '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
$r = hexdec(substr($hex, 0, 2)) / 255;
|
||||
$g = hexdec(substr($hex, 2, 2)) / 255;
|
||||
$b = hexdec(substr($hex, 4, 2)) / 255;
|
||||
|
||||
$max = max($r, $g, $b);
|
||||
$min = min($r, $g, $b);
|
||||
$delta = $max - $min;
|
||||
|
||||
$h = 0.0;
|
||||
if ($delta !== 0.0) {
|
||||
if ($max === $r) {
|
||||
$h = 60 * fmod((($g - $b) / $delta), 6);
|
||||
} elseif ($max === $g) {
|
||||
$h = 60 * ((($b - $r) / $delta) + 2);
|
||||
} else {
|
||||
$h = 60 * ((($r - $g) / $delta) + 4);
|
||||
}
|
||||
}
|
||||
if ($h < 0) {
|
||||
$h += 360;
|
||||
}
|
||||
|
||||
$l = ($max + $min) / 2;
|
||||
$s = $delta === 0.0 ? 0.0 : $delta / (1 - abs(2 * $l - 1));
|
||||
|
||||
$h = round($h, 2);
|
||||
$s = round($s * 100, 2) . '%';
|
||||
$l = round($l * 100, 2) . '%';
|
||||
|
||||
return "--app-primary-h-base: {$h}; --app-primary-s-base: {$s}; --app-primary-l-base: {$l}; --app-primary-h-light: {$h}; --app-primary-s-light: {$s}; --app-primary-l-light: {$l};";
|
||||
}
|
||||
|
||||
function appLogoUrl(?int $size = null): string
|
||||
{
|
||||
if (class_exists('MintyPHP\\Service\\BrandingLogoService') && \MintyPHP\Service\BrandingLogoService::hasLogo()) {
|
||||
$query = $size ? '?size=' . (int) $size : '';
|
||||
return lurl('branding/logo' . $query);
|
||||
}
|
||||
return asset('brand/logo.svg');
|
||||
}
|
||||
|
||||
function appFaviconUrl(string $file): string
|
||||
{
|
||||
$tenantUuid = $_SESSION['current_tenant']['uuid'] ?? '';
|
||||
if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\TenantFaviconService')) {
|
||||
if (\MintyPHP\Service\TenantFaviconService::hasFavicon($tenantUuid)) {
|
||||
return asset('favicon/tenants/' . $tenantUuid . '/' . ltrim($file, '/'));
|
||||
}
|
||||
}
|
||||
return asset('favicon/' . ltrim($file, '/'));
|
||||
}
|
||||
|
||||
function d()
|
||||
{
|
||||
return call_user_func_array('MintyPHP\\Debugger::debug', func_get_args());
|
||||
}
|
||||
|
||||
function sortByDescription(array &$items): void
|
||||
{
|
||||
usort($items, static fn($a, $b) =>
|
||||
strcasecmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? ''))
|
||||
);
|
||||
}
|
||||
27
lib/Support/helpers/auth.php
Normal file
27
lib/Support/helpers/auth.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
function accountUrl(): string
|
||||
{
|
||||
$user = $_SESSION['user'] ?? [];
|
||||
$uuid = (string) ($user['uuid'] ?? '');
|
||||
return $uuid !== '' ? lurl('profile') : lurl('login');
|
||||
}
|
||||
|
||||
function can(string $permissionKey): bool
|
||||
{
|
||||
$userId = (int) ($_SESSION['user']['id'] ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (!class_exists('MintyPHP\\Service\\PermissionService')) {
|
||||
return false;
|
||||
}
|
||||
$keys = \MintyPHP\Service\PermissionService::getCachedPermissions($userId);
|
||||
if (!$keys) {
|
||||
$keys = \MintyPHP\Service\PermissionService::getUserPermissions($userId);
|
||||
if (!$keys) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return in_array($permissionKey, $keys, true);
|
||||
}
|
||||
42
lib/Support/helpers/i18n.php
Normal file
42
lib/Support/helpers/i18n.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
function t()
|
||||
{
|
||||
$arguments = func_get_args();
|
||||
$arguments[0] = \MintyPHP\I18n::translate($arguments[0]);
|
||||
if (count($arguments) === 1) {
|
||||
return $arguments[0];
|
||||
}
|
||||
return call_user_func_array('sprintf', $arguments);
|
||||
}
|
||||
|
||||
function dt($value, ?string $format = null)
|
||||
{
|
||||
$displayTz = null;
|
||||
try {
|
||||
$displayTz = new \DateTimeZone(defined('APP_TIMEZONE') ? APP_TIMEZONE : date_default_timezone_get());
|
||||
} catch (\Exception $e) {
|
||||
$displayTz = new \DateTimeZone(date_default_timezone_get());
|
||||
}
|
||||
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
$date = (new \DateTimeImmutable('@' . $value->getTimestamp()))->setTimezone($displayTz);
|
||||
} elseif (is_string($value) && $value !== '') {
|
||||
try {
|
||||
// Treat DB timestamps as UTC and convert for display
|
||||
$date = new \DateTimeImmutable($value, new \DateTimeZone('UTC'));
|
||||
$date = $date->setTimezone($displayTz);
|
||||
} catch (\Exception $e) {
|
||||
return $value;
|
||||
}
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($format === null) {
|
||||
$locale = \MintyPHP\I18n::$locale ?? '';
|
||||
$format = (strpos((string) $locale, 'de') === 0) ? 'd.m.Y H:i' : 'Y-m-d H:i';
|
||||
}
|
||||
|
||||
return $date->format($format);
|
||||
}
|
||||
178
lib/Support/helpers/ui.php
Normal file
178
lib/Support/helpers/ui.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
function gridLang(): array
|
||||
{
|
||||
return [
|
||||
'search' => [
|
||||
'placeholder' => t('Search...'),
|
||||
],
|
||||
'sort' => [
|
||||
'sortAsc' => t('Sort column ascending'),
|
||||
'sortDesc' => t('Sort column descending'),
|
||||
],
|
||||
'pagination' => [
|
||||
'previous' => t('Previous'),
|
||||
'next' => t('Next'),
|
||||
'navigate' => t('Page %d of %d'),
|
||||
'page' => t('Page %d'),
|
||||
'showing' => t('Showing'),
|
||||
'of' => t('of'),
|
||||
'to' => t('to'),
|
||||
'results' => t('results'),
|
||||
],
|
||||
'loading' => t('Loading...'),
|
||||
'noRecordsFound' => t('No records found'),
|
||||
'error' => t('An error happened while fetching the data'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a multi-select filter field with hidden input sync.
|
||||
*
|
||||
* @param string $id Base ID for the hidden input (select gets "-ui" suffix)
|
||||
* @param string $label Field label (will be translated)
|
||||
* @param string $placeholder Placeholder text (will be translated)
|
||||
* @param array $items Array of items with 'id' and 'description' keys
|
||||
* @param array $selected Array of selected item IDs
|
||||
*/
|
||||
function multiSelectFilter(
|
||||
string $id,
|
||||
string $label,
|
||||
string $placeholder,
|
||||
array $items,
|
||||
array $selected
|
||||
): void {
|
||||
?>
|
||||
<label class="app-field">
|
||||
<span><?php e(t($label)); ?></span>
|
||||
<input type="hidden" id="<?php e($id); ?>" value="<?php e(implode(',', $selected)); ?>">
|
||||
<select
|
||||
id="<?php e($id); ?>-ui"
|
||||
multiple
|
||||
data-multi-select
|
||||
data-sync-target="#<?php e($id); ?>"
|
||||
data-select-all="true"
|
||||
data-list-all="false"
|
||||
data-search="true"
|
||||
data-placeholder="<?php e(t($placeholder)); ?>"
|
||||
data-search-placeholder="<?php e(t('Search...')); ?>"
|
||||
data-select-all-label="<?php e(t('Select all')); ?>"
|
||||
data-selected-label="<?php e(t('%d selected')); ?>"
|
||||
>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php $itemId = (string) ($item['id'] ?? ''); ?>
|
||||
<?php if ($itemId !== ''): ?>
|
||||
<option value="<?php e($itemId); ?>"<?php if (in_array($itemId, $selected, true)) { ?> selected<?php } ?>>
|
||||
<?php e($item['description'] ?? ''); ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a multi-select form field for data entry.
|
||||
*
|
||||
* @param string $id Element ID
|
||||
* @param string $name Form field name
|
||||
* @param string $label Field label (will be translated)
|
||||
* @param string $placeholder Placeholder text (will be translated)
|
||||
* @param array $items Array of items with 'id' and 'description' keys
|
||||
* @param array $selected Array of selected item IDs
|
||||
* @param bool $disabled Whether the field is disabled/readonly
|
||||
* @param string|array $labelKeys Key(s) to use for option label (tries in order)
|
||||
* @param string|null $formId Form ID for selects outside form element
|
||||
*/
|
||||
function multiSelectForm(
|
||||
string $id,
|
||||
string $name,
|
||||
string $label,
|
||||
string $placeholder,
|
||||
array $items,
|
||||
array $selected,
|
||||
bool $disabled = false,
|
||||
string|array $labelKeys = 'description',
|
||||
?string $formId = null
|
||||
): void {
|
||||
$labelKeyList = is_array($labelKeys) ? $labelKeys : [$labelKeys];
|
||||
?>
|
||||
<label>
|
||||
<span><?php e(t($label)); ?></span>
|
||||
</label>
|
||||
<select
|
||||
id="<?php e($id); ?>"
|
||||
name="<?php e($name); ?>"
|
||||
<?php if ($formId !== null): ?>form="<?php e($formId); ?>"<?php endif; ?>
|
||||
multiple
|
||||
data-multi-select="true"
|
||||
data-select-all="true"
|
||||
data-search="true"
|
||||
data-placeholder="<?php e(t($placeholder)); ?>"
|
||||
data-search-placeholder="<?php e(t('Search...')); ?>"
|
||||
data-select-all-label="<?php e(t('Select all')); ?>"
|
||||
<?php if ($disabled): ?>data-disabled="true" disabled<?php endif; ?>
|
||||
>
|
||||
<?php foreach ($items as $item): ?>
|
||||
<?php $itemId = (int) ($item['id'] ?? 0); ?>
|
||||
<?php if ($itemId > 0): ?>
|
||||
<?php
|
||||
$isSelected = in_array($itemId, $selected, true);
|
||||
$itemLabel = '';
|
||||
foreach ($labelKeyList as $key) {
|
||||
if (isset($item[$key]) && (string) $item[$key] !== '') {
|
||||
$itemLabel = (string) $item[$key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
?>
|
||||
<option value="<?php e((string) $itemId); ?>"<?php if ($isSelected) { ?> selected<?php } ?>>
|
||||
<?php e($itemLabel); ?>
|
||||
</option>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php
|
||||
}
|
||||
|
||||
function navActive($path, bool $prefix = false): array
|
||||
{
|
||||
$current = \MintyPHP\Http\Request::path();
|
||||
$paths = is_array($path) ? $path : [$path];
|
||||
$isActive = false;
|
||||
foreach ($paths as $p) {
|
||||
$p = (string) $p;
|
||||
$match = $prefix ? strpos($current, $p) === 0 : $current === $p;
|
||||
if (!$match && ($current === '' || $current === '/')) {
|
||||
$match = $p === '' || $p === '/';
|
||||
}
|
||||
if ($match) {
|
||||
$isActive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'class' => $isActive ? 'active' : '',
|
||||
'aria' => $isActive ? 'aria-current="page"' : '',
|
||||
'isActive' => $isActive,
|
||||
];
|
||||
}
|
||||
|
||||
function navActivePublicPages(array $extraSlugs = []): array
|
||||
{
|
||||
$current = \MintyPHP\Http\Request::path();
|
||||
if ($current === '') {
|
||||
return ['class' => '', 'aria' => '', 'isActive' => false];
|
||||
}
|
||||
|
||||
$publicSlugs = array_merge(['imprint', 'privacy'], $extraSlugs);
|
||||
$isActive = strpos($current, 'page/') === 0 || in_array($current, $publicSlugs, true);
|
||||
|
||||
return [
|
||||
'class' => $isActive ? 'active' : '',
|
||||
'aria' => $isActive ? 'aria-current="page"' : '',
|
||||
'isActive' => $isActive,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user