3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Helpers/Helper.php
snipe 5478c671ab Fixes #10387, #12460, and #19389 - better validation for text files
Files whose sniff yields nothing usable (empty, octet-stream, INI-shaped) now pass.
2026-07-28 21:16:39 +01:00

2123 lines
68 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Helpers;
use App\Models\Accessory;
use App\Models\AssetModel;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\CustomField;
use App\Models\CustomFieldset;
use App\Models\Depreciation;
use App\Models\License;
use App\Models\Location;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Intervention\Image\ImageManagerStatic as Image;
class Helper
{
/**
* This is only used for reversing the migration that updates the locale to the 5-6 letter codes from two
* letter codes. The normal dropdowns use the autoglossonyms in the language files located
* in resources/en-US/localizations.php.
*/
public static $language_map = [
'af' => 'af-ZA', // Afrikaans
'am' => 'am-ET', // Amharic
'ar' => 'ar-SA', // Arabic
'bg' => 'bg-BG', // Bulgarian
'ca' => 'ca-ES', // Catalan
'cs' => 'cs-CZ', // Czech
'cy' => 'cy-GB', // Welsh
'da' => 'da-DK', // Danish
'de-i' => 'de-if', // German informal
'de' => 'de-DE', // German
'el' => 'el-GR', // Greek
'en' => 'en-US', // English
'et' => 'et-EE', // Estonian
'fa' => 'fa-IR', // Persian
'fi' => 'fi-FI', // Finnish
'fil' => 'fil-PH', // Filipino
'fr' => 'fr-FR', // French
'he' => 'he-IL', // Hebrew
'hr' => 'hr-HR', // Croatian
'hu' => 'hu-HU', // Hungarian
'id' => 'id-ID', // Indonesian
'is' => 'is-IS', // Icelandic
'it' => 'it-IT', // Italian
'iu' => 'iu-NU', // Inuktitut
'ja' => 'ja-JP', // Japanese
'ko' => 'ko-KR', // Korean
'lt' => 'lt-LT', // Lithuanian
'lv' => 'lv-LV', // Latvian
'mi' => 'mi-NZ', // Maori
'mk' => 'mk-MK', // Macedonian
'mn' => 'mn-MN', // Mongolian
'ms' => 'ms-MY', // Malay
'nl' => 'nl-NL', // Dutch
'no' => 'nb-NO', // Norwegian Bokmål
'pl' => 'pl-PL', // Polish
'pt' => 'pt-PT', // Portuguese
'ro' => 'ro-RO', // Romanian
'ru' => 'ru-RU', // Russian
'sk' => 'sk-SK', // Slovak
'sl' => 'sl-SI', // Slovenian
'so' => 'so-SO', // Somali
'ta' => 'ta-IN', // Tamil
'th' => 'th-TH', // Thai
'tl' => 'tl-PH', // Tagalog
'tr' => 'tr-TR', // Turkish
'uk' => 'uk-UA', // Ukrainian
'vi' => 'vi-VN', // Vietnamese
'zu' => 'zu-ZA', // Zulu
];
/**
* Simple helper to invoke the markdown parser
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.0]
*
* @return string
*/
public static function parseEscapedMarkedown($str = null)
{
$Parsedown = new \Parsedown;
$Parsedown->setSafeMode(true);
if ($str) {
return $Parsedown->text(strip_tags($str));
}
}
/**
* Slice an in-memory Collection into a LengthAwarePaginator using the
* page + perPage values normalized by SetPaginationDefaults middleware.
*
* Selectlist endpoints that fetch the full set up-front (so they can
* post-process — e.g. hierarchy indenting in Companies / Locations) need
* to wrap the result in a paginator before handing it to SelectlistTransformer.
* This helper avoids re-deriving the page math at every call site, and
* inherits the middleware's bounds check against config('app.max_results').
*/
public static function paginateCollection(Collection $items): LengthAwarePaginator
{
$page = app('api_current_page');
$perPage = app('api_limit_value');
return new LengthAwarePaginator(
$items->forPage($page, $perPage)->values(),
$items->count(),
$perPage,
$page,
);
}
public static function parseEscapedMarkedownInline($str = null)
{
$Parsedown = new \Parsedown;
$Parsedown->setSafeMode(true);
if ($str) {
return $Parsedown->line(strip_tags($str));
}
}
/**
* The importer has formatted number strings since v3,
* so the value might be a string, or an integer.
* If it's a number, format it as a string.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.0]
*
* @return string
*/
public static function formatCurrencyOutput($cost)
{
if (is_numeric($cost)) {
if (Setting::getSettings()->digit_separator == '1.234,56') {
return number_format($cost, 2, ',', '.');
}
return number_format($cost, 2, '.', ',');
}
// It's already been parsed.
return $cost;
}
/**
* Static colors for pie charts.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.3]
*
* @return string
*/
public static function defaultChartColors(int $index = 0)
{
if ($index < 0) {
$index = 0;
}
$colors = [
'#008941',
'#FF4A46',
'#006FA6',
'#A30059',
'#1CE6FF',
'#FFDBE5',
'#7A4900',
'#0000A6',
'#63FFAC',
'#B79762',
'#004D43',
'#8FB0FF',
'#997D87',
'#5A0007',
'#809693',
'#FEFFE6',
'#1B4400',
'#4FC601',
'#3B5DFF',
'#4A3B53',
'#FF2F80',
'#61615A',
'#BA0900',
'#6B7900',
'#00C2A0',
'#FFAA92',
'#FF90C9',
'#B903AA',
'#D16100',
'#DDEFFF',
'#000035',
'#7B4F4B',
'#A1C299',
'#300018',
'#0AA6D8',
'#013349',
'#00846F',
'#372101',
'#FFB500',
'#C2FFED',
'#A079BF',
'#CC0744',
'#C0B9B2',
'#C2FF99',
'#001E09',
'#00489C',
'#6F0062',
'#0CBD66',
'#EEC3FF',
'#456D75',
'#B77B68',
'#7A87A1',
'#788D66',
'#885578',
'#FAD09F',
'#FF8A9A',
'#D157A0',
'#BEC459',
'#456648',
'#0086ED',
'#886F4C',
'#34362D',
'#B4A8BD',
'#00A6AA',
'#452C2C',
'#636375',
'#A3C8C9',
'#FF913F',
'#938A81',
'#575329',
'#00FECF',
'#B05B6F',
'#8CD0FF',
'#3B9700',
'#04F757',
'#C8A1A1',
'#1E6E00',
'#7900D7',
'#A77500',
'#6367A9',
'#A05837',
'#6B002C',
'#772600',
'#D790FF',
'#9B9700',
'#549E79',
'#FFF69F',
'#201625',
'#72418F',
'#BC23FF',
'#99ADC0',
'#3A2465',
'#922329',
'#5B4534',
'#FDE8DC',
'#404E55',
'#0089A3',
'#CB7E98',
'#A4E804',
'#324E72',
'#6A3A4C',
'#83AB58',
'#001C1E',
'#D1F7CE',
'#004B28',
'#C8D0F6',
'#A3A489',
'#806C66',
'#222800',
'#BF5650',
'#E83000',
'#66796D',
'#DA007C',
'#FF1A59',
'#8ADBB4',
'#1E0200',
'#5B4E51',
'#C895C5',
'#320033',
'#FF6832',
'#66E1D3',
'#CFCDAC',
'#D0AC94',
'#7ED379',
'#012C58',
'#7A7BFF',
'#D68E01',
'#353339',
'#78AFA1',
'#FEB2C6',
'#75797C',
'#837393',
'#943A4D',
'#B5F4FF',
'#D2DCD5',
'#9556BD',
'#6A714A',
'#001325',
'#02525F',
'#0AA3F7',
'#E98176',
'#DBD5DD',
'#5EBCD1',
'#3D4F44',
'#7E6405',
'#02684E',
'#962B75',
'#8D8546',
'#9695C5',
'#E773CE',
'#D86A78',
'#3E89BE',
'#CA834E',
'#518A87',
'#5B113C',
'#55813B',
'#E704C4',
'#00005F',
'#A97399',
'#4B8160',
'#59738A',
'#FF5DA7',
'#F7C9BF',
'#643127',
'#513A01',
'#6B94AA',
'#51A058',
'#A45B02',
'#1D1702',
'#E20027',
'#E7AB63',
'#4C6001',
'#9C6966',
'#64547B',
'#97979E',
'#006A66',
'#391406',
'#F4D749',
'#0045D2',
'#006C31',
'#DDB6D0',
'#7C6571',
'#9FB2A4',
'#00D891',
'#15A08A',
'#BC65E9',
'#FFFFFE',
'#C6DC99',
'#203B3C',
'#671190',
'#6B3A64',
'#F5E1FF',
'#FFA0F2',
'#CCAA35',
'#374527',
'#8BB400',
'#797868',
'#C6005A',
'#3B000A',
'#C86240',
'#29607C',
'#402334',
'#7D5A44',
'#CCB87C',
'#B88183',
'#AA5199',
'#B5D6C3',
'#A38469',
'#9F94F0',
'#A74571',
'#B894A6',
'#71BB8C',
'#00B433',
'#789EC9',
'#6D80BA',
'#953F00',
'#5EFF03',
'#E4FFFC',
'#1BE177',
'#BCB1E5',
'#76912F',
'#003109',
'#0060CD',
'#D20096',
'#895563',
'#29201D',
'#5B3213',
'#A76F42',
'#89412E',
'#1A3A2A',
'#494B5A',
'#A88C85',
'#F4ABAA',
'#A3F3AB',
'#00C6C8',
'#EA8B66',
'#958A9F',
'#BDC9D2',
'#9FA064',
'#BE4700',
'#658188',
'#83A485',
'#453C23',
'#47675D',
'#3A3F00',
'#061203',
'#DFFB71',
'#868E7E',
'#98D058',
'#6C8F7D',
'#D7BFC2',
'#3C3E6E',
'#D83D66',
'#2F5D9B',
'#6C5E46',
'#D25B88',
'#5B656C',
'#00B57F',
'#545C46',
'#866097',
'#365D25',
'#252F99',
'#00CCFF',
'#674E60',
'#FC009C',
'#92896B',
];
$total_colors = count($colors);
if ($index >= $total_colors) {
Log::info('Status label count is '.$index.' and exceeds the allowed count of 266.');
// patch fix for array key overflow (color count starts at 1, array starts at 0)
$index = $index - $total_colors - 1;
// constraints to keep result in 0-265 range. This should never be needed, but if something happens
// to create this many status labels and it DOES happen, this will keep it from failing at least.
if ($index < 0) {
$index = 0;
} elseif ($index > ($total_colors - 1)) {
$index = $total_colors - 1;
}
}
return $colors[$index];
}
/**
* Check if a string has any RTL characters
*
* @param $value
* @return bool
*/
public static function hasRtl($string)
{
$rtlChar = '/[\x{0590}-\x{083F}]|[\x{08A0}-\x{08FF}]|[\x{FB1D}-\x{FDFF}]|[\x{FE70}-\x{FEFF}]/u';
return preg_match($rtlChar, $string) != 0;
}
// is chinese, japanese or korean language
public static function isCjk($string)
{
return Helper::isChinese($string) || Helper::isJapanese($string) || Helper::isKorean($string);
}
public static function isChinese($string)
{
return preg_match("/\p{Han}+/u", $string);
}
public static function isJapanese($string)
{
return preg_match('/[\x{4E00}-\x{9FBF}\x{3040}-\x{309F}\x{30A0}-\x{30FF}]/u', $string);
}
public static function isKorean($string)
{
return preg_match('/[\x{3130}-\x{318F}\x{AC00}-\x{D7AF}]/u', $string);
}
/**
* Increases or decreases the brightness of a color by a percentage of the current brightness.
*
* @param string $hexCode Supported formats: `#FFF`, `#FFFFFF`, `FFF`, `FFFFFF`
* @param float $adjustPercent A number between -1 and 1. E.g. 0.3 = 30% lighter; -0.4 = 40% darker.
* @return string
*/
public static function adjustBrightness($hexCode, $adjustPercent)
{
$hexCode = ltrim($hexCode, '#');
if (strlen($hexCode) == 3) {
$hexCode = $hexCode[0].$hexCode[0].$hexCode[1].$hexCode[1].$hexCode[2].$hexCode[2];
}
$hexCode = array_map('hexdec', str_split($hexCode, 2));
foreach ($hexCode as &$color) {
$adjustableLimit = $adjustPercent < 0 ? $color : 255 - $color;
$adjustAmount = ceil($adjustableLimit * $adjustPercent);
$color = str_pad(dechex($color + $adjustAmount), 2, '0', STR_PAD_LEFT);
}
return '#'.implode($hexCode);
}
/**
* Static background (highlight) colors for pie charts
* This is inelegant, and could be refactored later.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.2]
*
* @return array
*/
public static function chartBackgroundColors()
{
$colors = [
'#f56954',
'#00a65a',
'#f39c12',
'#00c0ef',
'#3c8dbc',
'#d2d6de',
'#3c8dbc',
'#3c8dbc',
'#3c8dbc',
];
return $colors;
}
/**
* Format currency using comma for thousands until local info is property used.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.7]
*
* @return string
*/
public static function ParseFloat($floatString)
{
/*******
*
* WARNING: This does conversions based on *locale* - a Unix-ey-like thing.
*
* Everything else in the system tends to convert based on the Snipe-IT settings
*
* So it's very likely this is *not* what you want - instead look for the new
*
* ParseCurrency($currencyString)
*
* Which should be directly below here
*
*/
$LocaleInfo = localeconv();
$floatString = str_replace(',', '', $floatString);
$floatString = str_replace($LocaleInfo['decimal_point'], '.', $floatString);
// Strip Currency symbol
// If no currency symbol is set, default to $ because Murica
$currencySymbol = $LocaleInfo['currency_symbol'];
if (empty($currencySymbol)) {
$currencySymbol = '$';
}
$floatString = str_replace($currencySymbol, '', $floatString);
return floatval($floatString);
}
/**
* Format currency using comma or period for thousands, and period or comma for decimal, based on settings.
*
* @author [B. Wetherington] [<bwetherington@grokability.com>]
*
* @since [v5.2]
*
* @return float
*/
public static function ParseCurrency($currencyString)
{
$without_currency = str_replace(Setting::getSettings()->default_currency, '', $currencyString); // generally shouldn't come up, since we don't do this in fields, but just in case it does...
if (Setting::getSettings()->digit_separator == '1.234,56') {
// EU format
$without_thousands = str_replace('.', '', $without_currency);
$corrected_decimal = str_replace(',', '.', $without_thousands);
} else {
$without_thousands = str_replace(',', '', $without_currency);
$corrected_decimal = $without_thousands; // decimal is already OK
}
return floatval($corrected_decimal);
}
/**
* Get the list of status labels in an array to make a dropdown menu
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.5]
*
* @return array
*/
public static function statusLabelList()
{
$statuslabel_list = ['' => trans('general.select_statuslabel')] + Statuslabel::orderBy('default_label', 'desc')->orderBy('name', 'asc')->orderBy('deployable', 'desc')
->pluck('name', 'id')->toArray();
return $statuslabel_list;
}
/**
* Get the list of deployable status labels in an array to make a dropdown menu
*
* @todo This should probably be a selectlist, same as the other endpoints
* and we should probably add to the API controllers to make sure that
* the status_id submitted is actually really deployable.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v5.1.0]
*
* @return array
*/
public static function deployableStatusLabelList()
{
$statuslabel_list = Statuslabel::where('deployable', '=', '1')->orderBy('default_label', 'desc')
->orderBy('name', 'asc')
->orderBy('deployable', 'desc')
->pluck('name', 'id')->toArray();
return $statuslabel_list;
}
/**
* Get the list of status label types in an array to make a dropdown menu
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.5]
*
* @return array
*/
public static function statusTypeList()
{
$statuslabel_types =
['' => trans('admin/hardware/form.select_statustype')]
+ ['deployable' => trans('admin/hardware/general.deployable')]
+ ['pending' => trans('admin/hardware/general.pending')]
+ ['undeployable' => trans('admin/hardware/general.undeployable')]
+ ['archived' => trans('admin/hardware/general.archived')];
return $statuslabel_types;
}
/**
* Get the list of depreciations in an array to make a dropdown menu
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.5]
*
* @return array
*/
public static function depreciationList()
{
$depreciation_list = ['' => trans('admin/licenses/form.no_depreciation')] + Depreciation::orderBy('name', 'asc')
->pluck('name', 'id')->toArray();
return $depreciation_list;
}
/**
* Get the list of category types in an array to make a dropdown menu
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.5]
*
* @return array
*/
public static function categoryTypeList($selection = null)
{
$category_types = [
'' => '',
'accessory' => trans('general.accessory'),
'asset' => trans('general.asset'),
'consumable' => trans('general.consumable'),
'component' => trans('general.component'),
'license' => trans('general.license'),
];
if ($selection != null) {
return $category_types[strtolower($selection)];
} else {
return $category_types;
}
}
/**
* Get the list of custom fields in an array to make a dropdown menu
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v2.5]
*
* @return array
*/
public static function customFieldsetList()
{
$customfields = ['' => trans('admin/models/general.no_custom_field')] + CustomFieldset::pluck('name', 'id')->toArray();
return $customfields;
}
/**
* Get the list of custom field formats in an array to make a dropdown menu
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.4]
*
* @return array
*/
public static function predefined_formats()
{
$keys = array_keys(CustomField::PREDEFINED_FORMATS);
$stuff = array_combine($keys, $keys);
// Display-only label swap. 'ANY' as a label reads like the format
// requires something; 'ANY/NONE' makes it clear that no validation
// is applied. The KEY stays 'ANY' so submitted form values,
// JS lookups, and stored/compared values all keep working.
if (isset($stuff['ANY'])) {
$stuff['ANY'] = 'ANY/NONE';
}
return $stuff;
}
/**
* Get the list of barcode dimensions
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.3]
*
* @return array
*/
public static function barcodeDimensions($barcode_type = 'QRCODE')
{
if ($barcode_type == 'C128') {
$size['height'] = '-1';
$size['width'] = '-10';
} elseif ($barcode_type == 'PDF417') {
$size['height'] = '-3';
$size['width'] = '-10';
} else {
$size['height'] = '-3';
$size['width'] = '-3';
}
return $size;
}
/**
* Generates a random string
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return array
*/
public static function generateRandomString($length = 10)
{
$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$charactersLength = strlen($characters);
$randomString = '';
for ($i = 0; $i < $length; $i++) {
$randomString .= $characters[rand(0, $charactersLength - 1)];
}
return $randomString;
}
/**
* A method to be used to handle deprecations notifications, currently handling MS Teams. more can be added when needed.
*
*
* @author [Godfrey Martinez]
*
* @since [v7.0.14]
*/
public static function deprecationCheck(): array
{
// The check and message that the user is still using the deprecated version
$deprecations = [
'ms_teams_deprecated' => [
'check' => ! Str::contains(Setting::getSettings()->webhook_endpoint, 'workflows') && (Setting::getSettings()->webhook_selected === 'microsoft'),
'message' => 'The Microsoft Teams webhook URL being used will be deprecated Dec 31st, 2025. <a class="btn btn-primary" href="'.route('settings.slack.index').'">Change webhook endpoint</a>'],
];
// if item of concern is being used and its being used with the deprecated values return the notification array.
if (Setting::getSettings()->webhook_selected === 'microsoft' && $deprecations['ms_teams_deprecated']['check']) {
return $deprecations;
}
return [];
}
/**
* This nasty little method gets the low inventory info for the
* alert dropdown
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return array
*/
public static function checkLowInventory()
{
$alert_threshold = (int) Setting::getSettings()->alert_threshold;
// Push the "below threshold" filter into SQL via havingRaw on the
// withCount alias, so only rows that will actually alert get
// hydrated. Previous shape loaded every row with min_amt set and
// filtered in PHP — on a 1000-item deployment with 5 low-inventory
// items that meant 200× more rows than needed. Also select only
// the columns the foreach uses (id / name / qty / min_amt),
// avoiding hydration of long text columns like License::serial.
// select() must come BEFORE withCount(): withCount uses addSelect
// under the hood, so a select() after would wipe the count alias.
// GROUP BY primary key satisfies SQLite's strict "HAVING requires
// an aggregated query" check — MariaDB allows bare HAVING but the
// test suite runs SQLite. Grouping by a unique key is a no-op for
// row cardinality (functional dependency), so nothing else shifts.
$consumables = Consumable::select('id', 'name', 'qty', 'min_amt')
->withCount('consumableAssignments as consumables_users_count')
->whereNotNull('min_amt')
->groupBy('consumables.id')
->havingRaw('(qty - consumables_users_count) < (min_amt + ?)', [$alert_threshold])
->get();
$accessories = Accessory::select('id', 'name', 'qty', 'min_amt')
->withCount('checkouts as checkouts_count')
->whereNotNull('min_amt')
->groupBy('accessories.id')
->havingRaw('(qty - checkouts_count) < (min_amt + ?)', [$alert_threshold])
->get();
$components = Component::select('id', 'name', 'qty', 'min_amt')
->withCount('assets as sum_unconstrained_assets')
->whereNotNull('min_amt')
->groupBy('components.id')
->havingRaw('(qty - sum_unconstrained_assets) < (min_amt + ?)', [$alert_threshold])
->get();
$asset_models = AssetModel::select('id', 'name', 'min_amt')
->where('min_amt', '>', 0)
->withCount(['availableAssets', 'assets'])
->groupBy('models.id')
->havingRaw('available_assets_count < (min_amt + ?)', [$alert_threshold])
->get();
// Use the licenses_available withCount alias directly in the
// foreach below rather than $license->remaincount(). remaincount()
// fires two extra queries per row (licenseSeatsCount via
// getLicenseSeatsCountAttribute() and assigned_seats_count via
// getAssignedSeatsCountAttribute()), plus a third
// unReassignableCount() query for non-reassignable licenses —
// classic N+1 on a licenses-with-min_amt list.
$licenses = License::select('id', 'name', 'min_amt')
->withCount('availCount as licenses_available')
->where('min_amt', '>', 0)
->groupBy('licenses.id')
->havingRaw('licenses_available < (min_amt + ?)', [$alert_threshold])
->get();
$items_array = [];
$all_count = 0;
foreach ($consumables as $consumable) {
$avail = $consumable->qty - $consumable->consumables_users_count;
$percent = $consumable->qty > 0
? number_format((($avail / $consumable->qty) * 100), 0)
: 100;
$items_array[$all_count]['id'] = $consumable->id;
$items_array[$all_count]['name'] = $consumable->name;
$items_array[$all_count]['type'] = 'consumables';
$items_array[$all_count]['percent'] = $percent;
$items_array[$all_count]['remaining'] = $avail;
$items_array[$all_count]['min_amt'] = $consumable->min_amt;
$all_count++;
}
foreach ($accessories as $accessory) {
$avail = $accessory->qty - $accessory->checkouts_count;
$percent = $accessory->qty > 0
? number_format((($avail / $accessory->qty) * 100), 0)
: 100;
$items_array[$all_count]['id'] = $accessory->id;
$items_array[$all_count]['name'] = $accessory->name;
$items_array[$all_count]['type'] = 'accessories';
$items_array[$all_count]['percent'] = $percent;
$items_array[$all_count]['remaining'] = $avail;
$items_array[$all_count]['min_amt'] = $accessory->min_amt;
$all_count++;
}
foreach ($components as $component) {
$avail = $component->qty - $component->sum_unconstrained_assets;
$percent = $component->qty > 0
? number_format((($avail / $component->qty) * 100), 0)
: 100;
$items_array[$all_count]['id'] = $component->id;
$items_array[$all_count]['name'] = $component->name;
$items_array[$all_count]['type'] = 'components';
$items_array[$all_count]['percent'] = $percent;
$items_array[$all_count]['remaining'] = $avail;
$items_array[$all_count]['min_amt'] = $component->min_amt;
$all_count++;
}
foreach ($asset_models as $asset_model) {
$total_owned = $asset_model->assets_count;
$avail = $asset_model->available_assets_count;
$percent = $avail > 0
? number_format((($avail / $total_owned) * 100), 0)
: 100;
$items_array[$all_count]['id'] = $asset_model->id;
$items_array[$all_count]['name'] = $asset_model->name;
$items_array[$all_count]['type'] = 'models';
$items_array[$all_count]['percent'] = $percent;
$items_array[$all_count]['remaining'] = $avail;
$items_array[$all_count]['min_amt'] = $asset_model->min_amt;
$all_count++;
}
foreach ($licenses as $license) {
$avail = $license->licenses_available;
$percent = $avail > 0
? number_format((($avail / $license->min_amt) * 100), 0)
: 100;
$items_array[$all_count]['id'] = $license->id;
$items_array[$all_count]['name'] = $license->name;
$items_array[$all_count]['type'] = 'licenses';
$items_array[$all_count]['percent'] = $percent;
$items_array[$all_count]['remaining'] = $avail;
$items_array[$all_count]['min_amt'] = $license->min_amt;
$all_count++;
}
return $items_array;
}
/**
* Check if the file is an image, so we can show a preview
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @param File $file
* @return string | bool
*/
public static function checkUploadIsImage($file)
{
$finfo = @finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension
$filetype = @finfo_file($finfo, $file);
finfo_close($finfo);
if (($filetype == 'image/jpeg') || ($filetype == 'image/jpg') || ($filetype == 'image/png') || ($filetype == 'image/bmp') || ($filetype == 'image/gif') || ($filetype == 'image/avif') || ($filetype == 'image/webp')) {
return $filetype;
}
return false;
}
/**
* Check if the file is a video, so we can show a preview
*
* @param File $file
* @return string | bool
*
* @author [B. Wetherington] [<bwetherington@grokability.com>]
*
* @since [v8.1.18]
*/
public static function checkUploadIsVideo($file)
{
$finfo = @finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension
$filetype = @finfo_file($finfo, $file);
finfo_close($finfo);
if (($filetype == 'video/mp4') || ($filetype == 'video/quicktime') || ($filetype == 'video/mpeg') || ($filetype == 'video/ogg') || ($filetype == 'video/webm') || ($filetype == 'video/x-msvide')) {
return $filetype;
}
return false;
}
/**
* Check if the file is audio, so we can show a preview
*
* @param File $file
* @return string | bool
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*/
public static function checkUploadIsAudio($file)
{
$finfo = @finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension
$filetype = @finfo_file($finfo, $file);
finfo_close($finfo);
if (($filetype == 'audio/mpeg') || ($filetype == 'audio/ogg')) {
return $filetype;
}
return false;
}
/**
* Walks through the permissions in the permissions config file and determines if
* permissions are granted based on a $selected_arr array.
*
* The $permissions array is a multidimensional array broke down by section.
* (Licenses, Assets, etc)
*
* The $selected_arr should be a flattened array that contains just the
* corresponding permission name and a true or false boolean to determine
* if that group/user has been granted that permission.
*
* @author [A. Gianotto] [<snipe@snipe.net]
*
* @param array $permissions
* @param array $selected_arr
*
* @since [v1.0]
*
* @return array
*/
public static function selectedPermissionsArray($permissions, $selected_arr = [])
{
$permissions_arr = [];
if (is_array($permissions)) {
$permissions = json_encode($permissions);
}
// Set default to empty JSON if the value is null
$permissions = json_decode($permissions ?? '{}', JSON_OBJECT_AS_ARRAY);
foreach ($permissions as $permission) {
for ($x = 0; $x < count($permission); $x++) {
$permission_name = $permission[$x]['permission'];
if ($permission[$x]['display'] === true) {
if (is_array($selected_arr)) {
if (array_key_exists($permission_name, $selected_arr)) {
$permissions_arr[$permission_name] = (int) $selected_arr[$permission_name];
} else {
$permissions_arr[$permission_name] = 0;
}
} else {
$permissions_arr[$permission_name] = 0;
}
}
}
}
return $permissions_arr;
}
/**
* Introspects into the model validation to see if the field passed is required.
* This is used by the blades to add a required class onto the HTML element.
* This isn't critical, but is helpful to keep form fields in sync with the actual
* model level validation.
*
* This does not currently handle form request validation requiredness :(
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return bool
*/
public static function checkIfRequired($class, $field)
{
// Transient forms with no bound model (e.g. bulk-checkout) can't be
// introspected for required-ness; treat the field as not required
// rather than crashing on `null::rules()`. Callers that need the
// real required flag will pass an $item.
if (! $class) {
return false;
}
$rules = $class::rules();
foreach ($rules as $rule_name => $rule) {
if ($rule_name == $field) {
if (is_array($rule)) {
if (in_array('required', $rule)) {
return true;
}
if (in_array('fmcs_company', $rule) && self::fmcsCompanyIsCurrentlyRequired()) {
return true;
}
return false;
} else {
if (strpos($rule, 'required') !== false) {
return true;
}
if (strpos($rule, 'fmcs_company') !== false && self::fmcsCompanyIsCurrentlyRequired()) {
return true;
}
return false;
}
}
}
return false;
}
/**
* Mirror of the fmcs_company validator's runtime condition, used by
* checkIfRequired() so Blade fields render the "required" indicator
* (asterisk / aria-required) in the same conditions the backend will
* reject a blank submission. See ValidationServiceProvider.
*/
protected static function fmcsCompanyIsCurrentlyRequired(): bool
{
$settings = \App\Models\Setting::getSettings();
if (! $settings->full_multiple_companies_support) {
return false;
}
if ((bool) $settings->null_company_is_floater) {
return false;
}
if (! auth()->check()) {
return false;
}
$actor = auth()->user();
if ($actor->isSuperUser()) {
return false;
}
// Uncompanied users work in the null pseudo-company namespace
// under strict mode; null IS a valid company id for them, so
// don't render the field as required.
return $actor->companies()->exists();
}
/**
* Return the numeric max length declared for a field in the model's
* validation rules (looks for `max:N`). Returns null when the model, field,
* or `max:` rule can't be found — callers should fall back to whatever
* default they want. Used by `<x-form.row>` to auto-cap text inputs to
* the DB column width without every callsite having to pass maxlength.
*/
public static function fieldMaxLength($class, string $field): ?int
{
if (! $class) {
return null;
}
$rules = $class::rules();
$rule = $rules[$field] ?? null;
if ($rule === null) {
return null;
}
$rule_string = is_array($rule) ? implode('|', $rule) : $rule;
if (preg_match('/(?:^|\|)max:(\d+)/', $rule_string, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Check to see if the given key exists in the array, and trim excess white space before returning it
*
* @author Daniel Melzter
*
* @since 3.0
*
* @param $array array
* @param $key string
* @param $default string
* @return string
*/
public static function array_smart_fetch(array $array, $key, $default = '')
{
array_change_key_case($array, CASE_LOWER);
return array_key_exists(strtolower($key), array_change_key_case($array)) ? e(trim($array[$key])) : $default;
}
/**
* Gracefully handle decrypting encrypted fields (custom fields, etc).
*
* @todo allow this to handle more than just strings (arrays, etc)
*
* @author A. Gianotto
*
* @since 3.6
*
* @param string $string
* @return string
*/
public static function gracefulDecrypt(CustomField $field, $string)
{
if ($field->isFieldDecryptable($string)) {
try {
Crypt::decrypt($string);
return Crypt::decrypt($string);
} catch (DecryptException $e) {
return 'Error Decrypting: '.$e->getMessage();
}
}
return $string;
}
public static function formatStandardApiResponse($status, $payload = null, $messages = null)
{
$array['status'] = $status;
$array['messages'] = $messages;
if (($messages) && (is_array($messages)) && (count($messages) > 0)) {
$array['messages'] = $messages;
}
($payload) ? $array['payload'] = $payload : $array['payload'] = null;
return $array;
}
/*
Possible solution for unicode fieldnames
*/
public static function make_slug($string)
{
return preg_replace('/\s+/u', '_', trim($string));
}
/**
* Return an array (or null) of the the raw and formatted date object for easy use in
* the API and the bootstrap table listings.
*
* @return array|string|null
*/
public static function getFormattedDateObject($date, $type = 'datetime', $array = true)
{
if ($date == '') {
return null;
}
$settings = Setting::getSettings();
/**
* Wrap this in a try/catch so that if Carbon crashes, for example if the $date value
* isn't actually valid, we don't crash out completely.
*
* While this *shouldn't* typically happen since we validate dates before entering them
* into the database (and we use date/datetime fields for native fields in the system),
* it is a possible scenario that a custom field could be created as an "ANY" field, data gets
* added, and then the custom field format gets edited later. If someone put bad data in the
* database before then - or if they manually edited the field's value - it will crash.
*/
try {
$tmp_date = new Carbon($date);
if ($type == 'datetime') {
$dt['datetime'] = $tmp_date->format('Y-m-d H:i:s');
$dt['formatted'] = $tmp_date->format($settings->date_display_format.' '.$settings->time_display_format);
} else {
$dt['date'] = $tmp_date->format('Y-m-d');
$dt['formatted'] = $tmp_date->format($settings->date_display_format);
}
if ($array == 'true') {
return $dt;
}
return $dt['formatted'];
} catch (\Exception $e) {
Log::warning($e);
return $date.' (Invalid '.$type.' value.)';
}
}
// Nicked from Drupal :)
// Returns a file size limit in bytes based on the PHP upload_max_filesize
// and post_max_size
public static function file_upload_max_size()
{
static $max_size = -1;
if ($max_size < 0) {
// Start with post_max_size.
$post_max_size = self::parse_size(ini_get('post_max_size'));
if ($post_max_size > 0) {
$max_size = $post_max_size;
}
// If upload_max_size is less, then reduce. Except if upload_max_size is
// zero, which indicates no limit.
$upload_max = self::parse_size(ini_get('upload_max_filesize'));
if ($upload_max > 0 && $upload_max < $max_size) {
$max_size = $upload_max;
}
}
return $max_size;
}
public static function file_upload_max_size_readable()
{
static $max_size = -1;
if ($max_size < 0) {
// Start with post_max_size.
$post_max_size = self::parse_size(ini_get('post_max_size'));
if ($post_max_size > 0) {
$max_size = ini_get('post_max_size');
}
// If upload_max_size is less, then reduce. Except if upload_max_size is
// zero, which indicates no limit.
$upload_max = self::parse_size(ini_get('upload_max_filesize'));
if ($upload_max > 0 && $upload_max < $post_max_size) {
$max_size = ini_get('upload_max_filesize');
}
}
return $max_size;
}
public static function parse_size($size)
{
$unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size.
$size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size.
if ($unit) {
// Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
return round($size * pow(1024, stripos('bkmgtpezy', $unit[0])));
} else {
return round($size);
}
}
public static function filetype_icon($filename)
{
$extension = substr(strrchr($filename, '.'), 1);
$allowedExtensionMap = [
// Images
'jpg' => 'far fa-image',
'jfif' => 'far fa-image',
'jpeg' => 'far fa-image',
'gif' => 'far fa-image',
'png' => 'far fa-image',
'webp' => 'far fa-image',
'avif' => 'far fa-image',
'ico' => 'far fa-image',
'svg' => 'fas fa-vector-square',
// word
'doc' => 'far fa-file-word',
'docx' => 'far fa-file-word',
// Excel
'xls' => 'far fa-file-excel',
'xlsx' => 'far fa-file-excel',
'ods' => 'far fa-file-excel',
// Presentation
'ppt' => 'far fa-file-powerpoint',
'odp' => 'far fa-file-powerpoint',
// archive
'zip' => 'fas fa-file-archive',
'rar' => 'fas fa-file-archive',
// Text
'odt' => 'far fa-file-alt',
'txt' => 'far fa-file-alt',
'rtf' => 'far fa-file-alt',
'xml' => 'fas fa-code',
'json' => 'fas fa-code',
// Misc
'pdf' => 'far fa-file-pdf',
'lic' => 'far fa-save',
'key' => 'fas fa-key',
// video
'mov' => 'fa-solid fa-video',
'mp4' => 'fa-solid fa-video',
'webm' => 'fa-solid fa-video',
// audio
'ogg' => 'fa-solid fa-file-audio',
'mp3' => 'fa-solid fa-file-audio',
'wav' => 'fa-solid fa-file-audio',
];
if ($extension && array_key_exists($extension, $allowedExtensionMap)) {
return $allowedExtensionMap[$extension];
}
return 'far fa-file';
}
/**
* Get a random unencrypted password.
*
* @author Steffen Buehl <sb@sbuehl.com>
*
* @since 5.0.0
*/
public static function generateUnencryptedPassword(): string
{
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$password = '';
for ($i = 0; $i < 20; $i++) {
$password .= substr($chars, random_int(0, strlen($chars) - 1), 1);
}
return $password;
}
/**
* Process base64 encoded image data and save it on supplied path
*
* @param string $image_data base64 encoded image data with mime type
* @param string $save_path path to a folder where the image should be saved
* @return string path to uploaded image or false if something went wrong
*/
public static function processUploadedImage(string $image_data, string $save_path)
{
if ($image_data == null || $save_path == null) {
return false;
}
// After modification, the image is prefixed by mime info like the following:
// data:image/jpeg;base64,; This causes the image library to be unhappy, so we need to remove it.
$header = explode(';', $image_data, 2)[0];
// Grab the image type from the header while we're at it.
$extension = substr($header, strpos($header, '/') + 1);
// Start reading the image after the first comma, postceding the base64.
$image = substr($image_data, strpos($image_data, ',') + 1);
$file_name = str_random(25).'.'.$extension;
$directory = public_path($save_path);
// Check if the uploads directory exists. If not, try to create it.
if (! file_exists($directory)) {
mkdir($directory, 0755, true);
}
$path = public_path($save_path.$file_name);
try {
Image::make($image)->resize(500, 500, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
})->save($path);
} catch (\Exception $e) {
return false;
}
return $file_name;
}
/**
* Universal helper to show file size in human-readable formats
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since 5.0
*
* @return string[]
*/
public static function formatFilesizeUnits($bytes)
{
if ($bytes >= 1073741824) {
$bytes = number_format($bytes / 1073741824, 2).' GB';
} elseif ($bytes >= 1048576) {
$bytes = number_format($bytes / 1048576, 2).' MB';
} elseif ($bytes >= 1024) {
$bytes = number_format($bytes / 1024, 2).' KB';
} elseif ($bytes > 1) {
$bytes = $bytes.' bytes';
} elseif ($bytes == 1) {
$bytes = $bytes.' byte';
} else {
$bytes = '0 bytes';
}
return $bytes;
}
/**
* This is weird but used by the side nav to determine which URL to point the user to
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since 5.0
*
* @return string[]
*/
public static function SettingUrls()
{
$settings = [
'#',
'fields*',
'statuslabels*',
'models*',
'categories*',
'manufacturers*',
'suppliers*',
'departments*',
'locations*',
'companies*',
'depreciations*',
];
return $settings;
}
/*
* This is a shorter way to see if the app is in demo mode.
*
* This makes it cleanly available in blades and in controllers, e.g.
*
* Blade:
* {{ Helper::isDemoMode() ? ' disabled' : ''}} for form blades where we need to disable a form
*
* Controller:
* if (Helper::isDemoMode()) {
* // don't allow the thing
* }
* @todo - use this everywhere else in the app where we have very long if/else config('app.lock_passwords') stuff
*/
public static function isDemoMode()
{
if (config('app.lock_passwords') === true) {
return true;
Log::debug('app locked!');
}
return false;
}
/**
* Conversion between units of measurement
*
* @author Grant Le Roux <grant.leroux+snipe-it@gmail.com>
*
* @since 5.0
*
* @param float $value Measurement value to convert
* @param string $srcUnit Source unit of measurement
* @param string $dstUnit Destination unit of measurement
* @param int $round Round the result to decimals (Default false - No rounding)
* @return float
*/
public static function convertUnit($value, $srcUnit, $dstUnit, $round = false)
{
$srcFactor = static::getUnitConversionFactor($srcUnit);
$dstFactor = static::getUnitConversionFactor($dstUnit);
$output = $value * $srcFactor / $dstFactor;
return ($round !== false) ? round($output, $round) : $output;
}
/**
* Get conversion factor from unit of measurement to mm
*
* @author Grant Le Roux <grant.leroux+snipe-it@gmail.com>
*
* @since 5.0
*
* @param string $unit Unit of measurement
* @return float
*/
public static function getUnitConversionFactor($unit)
{
switch (strtolower($unit)) {
case 'mm':
return 1.0;
case 'cm':
return 10.0;
case 'm':
return 1000.0;
case 'in':
return 25.4;
case 'ft':
return 12 * static::getUnitConversionFactor('in');
case 'yd':
return 3 * static::getUnitConversionFactor('ft');
case 'pt':
return (1 / 72) * static::getUnitConversionFactor('in');
default:
throw new \InvalidArgumentException('Unit: '.e($unit).' is not supported');
return false;
}
}
/*
* I know it's gauche to return a shitty HTML string, but this is just a helper and since it will be the same every single time,
* it seemed pretty safe to do here. Don't you judge me.
*/
public static function showDemoModeFieldWarning()
{
if (Helper::isDemoMode()) {
return '<p class="text-warning"><i class="fas fa-lock"></i>'.trans('general.feature_disabled').'</p>';
}
}
/**
* Ah, legacy code.
*
* This corrects the original mistakes from 2013 where we used the wrong locale codes. Hopefully we
* can get rid of this in a future version, but this should at least give us the belt and suspenders we need
* to be sure this change is not too disruptive.
*
* In this array, we ONLY include the older languages where we weren't using the correct locale codes.
*
* @see public static $language_map in this file
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since 6.3.0
*
* @return string []
*/
public static function mapLegacyLocale($language_code = null)
{
if (strlen($language_code) > 4) {
return $language_code;
}
foreach (self::$language_map as $legacy => $new) {
if ($language_code == $legacy) {
return $new;
}
}
// Return US english if we don't have a match
return 'en-US';
}
public static function mapBackToLegacyLocale($new_locale = null)
{
if (strlen($new_locale) <= 4) {
return $new_locale; // "new locale" apparently wasn't quite so new
}
// This does a *reverse* search against our new language map array - given the value, find the *key* for it
$legacy_locale = array_search($new_locale, self::$language_map);
if ($legacy_locale !== false) {
return $legacy_locale;
}
return $new_locale; // better that you have some weird locale that doesn't fit into our mappings anywhere than 'void'
}
public static function determineLanguageDirection()
{
return in_array(app()->getLocale(),
[
'ar-SA',
'fa-IR',
'he-IL',
]) ? 'rtl' : 'ltr';
}
/**
* Return $url if it points at the same origin as config('app.url'),
* otherwise null. Callers should coalesce with a safe default:
*
* $target = Helper::sameOriginUrl($input) ?? route('home');
*
* Any user-controllable redirect target (hidden form field, Referer
* header, SAML RelayState, url.intended session value, etc.) MUST
* pass through this before reaching redirect(...) or we hand attackers
* an open-redirect / phishing hand-off primitive. Relative URLs are
* treated as same-origin; javascript:/data: and other non-http(s)
* schemes are rejected. CR/LF are stripped to prevent response-splitting
* via a crafted Location header.
*/
public static function sameOriginUrl(?string $url): ?string
{
if (! $url) {
return null;
}
$url = str_replace(["\r", "\n"], '', $url);
$parts = parse_url($url);
if ($parts === false) {
return null;
}
if (isset($parts['scheme']) && ! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return null;
}
$host = $parts['host'] ?? null;
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
if ($host !== null && strcasecmp($host, (string) $appHost) !== 0) {
return null;
}
return $url;
}
public static function getRedirectOption($request, $id, $table, $item_id = null): RedirectResponse
{
$redirect_option = session('redirect_option') ?? $request->redirect_option;
$checkout_to_type = session('checkout_to_type') ?? null;
$checkedInFrom = session('checkedInFrom');
$other_redirect = session('other_redirect');
// Defense-in-depth: url.intended can be written by any writer
// in the app (SamlController::acs sanitizes its RelayState
// input up-front, but future writers may not), and Laravel's
// redirect()->intended() performs no host validation on read.
$backUrl = self::sameOriginUrl(session()->pull('url.intended', 'home')) ?? route('home');
// return to previous page
if ($redirect_option == 'back') {
return redirect()->intended($backUrl);
}
// return to index
if ($redirect_option == 'index') {
$indexUrl = match ($table) {
'Assets' => route('hardware.index'),
'Users' => route('users.index'),
'Licenses' => route('licenses.index'),
'Accessories' => route('accessories.index'),
'Components' => route('components.index'),
'Consumables' => route('consumables.index'),
'Maintenances' => route('maintenances.index'),
};
// #15214: preserve query-string filters when the user came
// from a filtered view of the same index they're being sent
// back to (side-nav status filter, category filter, etc.).
// Without this the plain index route drops the caller's
// context and the user has to re-select their side-nav
// filter after every edit. $backUrl was already vetted for
// off-site hosts above.
if ($backUrl && parse_url($backUrl, PHP_URL_PATH) === parse_url($indexUrl, PHP_URL_PATH)) {
return redirect($backUrl);
}
return redirect($indexUrl);
}
// return to thing being assigned
if ($redirect_option == 'item') {
return match ($table) {
'Assets' => redirect()->route('hardware.show', $id ?? $item_id),
'Users' => redirect()->route('users.show', $id ?? $item_id),
'Licenses' => redirect()->route('licenses.show', $id ?? $item_id),
'Accessories' => redirect()->route('accessories.show', $id ?? $item_id),
'Components' => redirect()->route('components.show', $id ?? $item_id),
'Consumables' => redirect()->route('consumables.show', $id ?? $item_id),
};
}
// return to assignment target
if ($redirect_option == 'target') {
$userId = $request->assigned_user ?? $checkedInFrom;
$locationId = $request->assigned_location ?? $checkedInFrom;
$assetId = $request->assigned_asset ?? $checkedInFrom;
return match ($checkout_to_type) {
'user' => $userId
? redirect()->route('users.show', $userId)
: redirect()->route('users.index'),
'location' => $locationId
? redirect()->route('locations.show', $locationId)
: redirect()->route('locations.index'),
'asset' => $assetId
? redirect()->route('hardware.show', $assetId)
: redirect()->route('hardware.index'),
};
}
// return to somewhere else
if ($redirect_option == 'other_redirect') {
return match ($other_redirect) {
'audit' => redirect()->route('assets.audit.due'),
'model' => redirect()->route('models.show', $request->model_id),
};
}
return redirect()->back()->with('error', trans('admin/hardware/message.checkout.error'));
}
/**
* Check for inconsistencies before activating scoped locations with FullMultipleCompanySupport
* If there are locations with different companies than related objects unforseen problems could arise
*
* @author T. Regnery <tobias.regnery@gmail.com>
*
* @since 7.0
*
* @param $artisan when false, bail out on first inconsistent entry
* @param $location_id when set, only test this specific location
* @param $new_company_id in case of updating a location, this is the newly requested company_id
* @return string []
*/
public static function test_locations_fmcs($artisan, $location_id = null, $new_company_id = null)
{
$mismatched = [];
if ($location_id) {
$location = Location::find($location_id);
if ($location) {
$locations = collect([])->push(Location::find($location_id));
}
} else {
$locations = Location::all();
}
// Bail out early if there are no locations
if ($locations->count() == 0) {
return [];
}
$floater = (bool) Setting::getSettings()->null_company_is_floater;
foreach ($locations as $location) {
// in case of an update of a single location, use the newly requested company_id
if ($new_company_id) {
$location_company = $new_company_id;
} else {
$location_company = $location->company_id;
}
// Depending on the relationship, we must use different operations to retrieve the objects
$keywords_relation = [
'many' => [
'accessories',
'assets',
'assignedAccessories',
'assignedAssets',
'components',
'consumables',
'rtd_assets',
'users',
],
'one' => [
'manager',
'parent',
]];
// In case of a single location, the children must be checked as well, because we don't walk every location
if ($location_id) {
$keywords_relation['many'][] = 'children';
}
foreach ($keywords_relation as $relation => $keywords) {
foreach ($keywords as $keyword) {
if ($relation == 'many') {
$items = $location->{$keyword}->all();
// assignedAccessories returns AccessoryCheckout records (no company_id);
// resolve each to its parent Accessory so the comparison is valid.
if ($keyword === 'assignedAccessories') {
$items = collect($items)->map(fn ($checkout) => $checkout->accessory)->filter()->values()->all();
}
} else {
$items = collect([])->push($location->$keyword);
}
$count = 0;
foreach ($items as $item) {
if (! $item) {
continue;
}
// Users belong to companies via the many-to-many pivot (company_user).
// canReceiveFromCompany() returns true only when the user's pivot
// contains the location's company, so !canReceiveFromCompany() is
// the correct mismatch signal. Pass $location_company through as
// ?int — casting null to (int) would coerce it to 0 and miss the
// null-company branch inside the method.
if ($item instanceof User) {
$isMismatch = ! $item->canReceiveFromCompany($location_company === null ? null : (int) $location_company);
} elseif ($item->company_id == $location_company) {
$isMismatch = false;
} elseif (is_null($item->company_id) || is_null($location_company)) {
$isMismatch = ! $floater;
} else {
$isMismatch = true;
}
if ($isMismatch) {
if ($item instanceof User) {
$itemCompanyIds = $item->companies->pluck('id')->implode(', ');
$itemCompanyNames = $item->companies->pluck('name')->implode(', ');
} else {
$itemCompanyIds = $item->company_id ?? null;
$itemCompanyNames = $item->company->name ?? null;
}
$mismatched[] = [
class_basename(get_class($item)),
$item->id,
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
$itemCompanyIds,
$itemCompanyNames,
$item->location->name ?? null,
$item->location->company->name ?? null,
$location_company ?? null,
];
$count++;
// Bail early if this is not being run via artisan
if ((! $artisan) && ($count > 0)) {
return $mismatched;
}
}
}
}
}
}
return $mismatched;
}
public static function labelFieldLayoutScaling(
$pdf,
iterable|\Closure $fields,
float $currentX,
float $usableWidth,
float $usableHeight,
float $baseLabelSize,
float $baseFieldSize,
float $baseFieldMargin,
?string $title = null,
float $baseTitleSize = 0.0,
float $baseTitleMargin = 0.0,
float $baseLabelPadding = 1.5,
float $baseGap = 1.5,
float $maxScale = 1.8,
string $labelFont = 'freesans',
): array {
$fieldCount = count($fields);
$perFieldHeight = max($baseLabelSize, $baseFieldSize) + $baseFieldMargin;
$baseFieldsHeight = $fieldCount * $perFieldHeight;
$hasTitle = is_string($title) && trim($title) !== '';
$baseTitleHeight = $hasTitle ? ($baseTitleSize + $baseTitleMargin) : 0.0;
$baseTotalHeight = $baseTitleHeight + $baseFieldsHeight;
$scale = 1.0;
if ($baseTotalHeight > 0 && $usableHeight > 0) {
$scale = $usableHeight / $baseTotalHeight;
}
$scale = min($scale, $maxScale);
$labelSize = $baseLabelSize;
$fieldSize = $baseFieldSize * $scale;
$fieldMargin = $baseFieldMargin * $scale;
$rowAdvance = max($labelSize, $fieldSize) + $fieldMargin;
$titleSize = $hasTitle ? ($baseTitleSize * $scale) : 0.0;
$titleMargin = $hasTitle ? ($baseTitleMargin * $scale) : 0.0;
$titleAdvance = $hasTitle ? ($titleSize + $titleMargin) : 0.0;
$pdf->SetFont($labelFont, '', $baseLabelSize);
$maxLabelWidthPerUnit = 0;
foreach ($fields as $field) {
$rawLabel = $field['label'] ?? null;
// If no label, do not include it in label-column sizing
if (! is_string($rawLabel) || trim($rawLabel) === '') {
continue;
}
$label = rtrim($field['label'], ':').':';
$width = $pdf->GetStringWidth($label);
$maxLabelWidthPerUnit = max($maxLabelWidthPerUnit, $width / $baseLabelSize);
}
$labelPadding = $baseLabelPadding * $scale;
$gap = $baseGap * $scale;
$labelWidth = ($maxLabelWidthPerUnit * $labelSize) + $labelPadding;
$valueX = $currentX + $labelWidth + $gap;
$valueWidth = $usableWidth - $labelWidth - $gap;
$fullValueX = $currentX;
$fullValueWidth = $usableWidth;
return compact(
'scale',
'hasTitle',
'titleSize',
'titleMargin',
'titleAdvance',
'labelSize',
'fieldSize',
'fieldMargin',
'rowAdvance',
'labelWidth',
'valueX',
'valueWidth',
'fullValueX',
'fullValueWidth',
);
}
public static function normalizeFullModelName($model): string
{
if (str_contains($model, 'App\\Models\\')) {
return $model;
}
return 'App\\Models\\'.ucwords($model);
}
/**
* Render a markdown-textarea value as HTML.
*
* Soft line breaks (single newlines) are rendered as <br> so that line
* breaks typed in the textarea are preserved in the output.
*
* When $inline is true, block-level elements are suppressed and hard
* breaks are pre-processed manually — used for the encrypted reveal span
* where block HTML cannot be placed inside a font-size-toggled <span>.
*/
public static function renderMarkdown(?string $text, bool $inline = false): string
{
if (empty($text)) {
return '';
}
if ($inline) {
// Convert newlines to CommonMark hard breaks for inline rendering
$text = preg_replace('/(?<! {2})\n/', " \n", $text);
return Str::inlineMarkdown($text, ['html_input' => 'escape', 'allow_unsafe_links' => false]);
}
$html = trim(Str::markdown($text, [
'html_input' => 'escape',
'allow_unsafe_links' => false,
'renderer' => ['soft_break' => "<br>\n"],
]));
// If the entire output is a single <p> block, unwrap it so the content
// renders inline-ish without the <p> adding unwanted top spacing in the
// compact detail-view layout.
if (str_starts_with($html, '<p>') && str_ends_with($html, '</p>') && substr_count($html, '<p>') === 1) {
return substr($html, 3, -4);
}
return $html;
}
}