'', 'CUSTOM REGEX' => '', 'ALPHA' => 'alpha', 'ALPHA-DASH' => 'alpha_dash', 'NUMERIC' => 'numeric', 'ALPHA-NUMERIC' => 'alpha_num', 'EMAIL' => 'email', 'DATE' => 'date', // Distinct pattern so getFormatAttribute() can reverse-map back to // 'DATETIME'. If both DATE and DATETIME shared 'date' the first-match // loop would always return 'DATE' and the DATETIME picker never fires. // The datetimepicker widget always outputs Y-m-d H:i:s, so this // date_format is the exact shape we expect. 'DATETIME' => 'date_format:Y-m-d H:i:s', 'URL' => 'url', 'IP' => 'ip', 'IPV4' => 'ipv4', 'IPV6' => 'ipv6', 'MAC' => 'regex:/^[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}:[a-fA-F0-9]{2}$/', 'BOOLEAN' => 'boolean', // Phone / fax validation is famously unreliable (international // formats, extensions, punctuation variance), so PHONE / FAX // deliberately use no-op patterns. 'string' / 'present' are chosen // over '' because an empty pattern would collide with 'ANY' in the // getFormatAttribute reverse-map. Both pass for any form input. 'PHONE' => 'string', 'FAX' => 'present', ]; public const ELEMENT_KEYS = [ 'text', 'listbox', 'textarea', 'markdown-textarea', 'checkbox', 'radio', 'date_picker', 'datetime_picker', ]; public const TEXT_ONLY_FORMATS = [ 'EMAIL', 'URL', 'PHONE', 'FAX', 'MAC', 'IP', 'IPV4', 'IPV6', 'ALPHA', 'ALPHA-DASH', 'ALPHA-NUMERIC', 'NUMERIC', ]; public $guarded = [ 'id', ]; /** * Validation rules. * At least empty array must be provided if using ValidatingTrait. * * @var array */ protected $rules = [ 'name' => 'required|unique:custom_fields', 'element' => 'required|in:text,listbox,textarea,markdown-textarea,checkbox,radio,date_picker,datetime_picker', 'field_encrypted' => 'nullable|boolean', 'auto_add_to_fieldsets' => 'boolean', 'show_in_listview' => 'boolean', 'show_in_requestable_list' => 'boolean', 'show_in_email' => 'boolean', 'format' => 'nullable|string', ]; protected $casts = [ 'show_in_requestable_list' => 'boolean', ]; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'name', 'element', 'format', 'field_values', 'field_encrypted', 'help_text', 'show_in_email', 'is_unique', 'display_in_user_view', 'auto_add_to_fieldsets', 'show_in_listview', 'show_in_email', 'display_checkout', 'display_checkin', 'display_audit', 'show_in_requestable_list', ]; /** * The attributes that should be included when searching the model. * * @var array */ protected $searchableAttributes = [ 'name', 'format', 'element', 'db_column', 'help_text', ]; /** * The relations and their attributes that should be included when searching the model. * * @var array */ protected $searchableRelations = [ 'fieldset' => ['name'], 'assetModels' => ['name'], 'adminuser' => ['first_name', 'last_name', 'display_name'], ]; /** * This is confusing, since it's actually the custom fields table that * we're usually modifying, but since we alter the assets table, we have to * say that here, otherwise the new fields get added onto the custom fields * table instead of the assets table. * * @author [Brady Wetherington] [] * * @since [v3.0] */ public static $table_name = 'assets'; /** * Convert the custom field's name property to a db-safe string. * * We could probably have used str_slug() here but not sure what it would * do with previously existing values. - @snipe * * @author [A. Gianotto] [] * * @since [v3.4] * * @return string */ public static function name_to_db_name($name) { return '_snipeit_'.preg_replace('/[^a-zA-Z0-9]/', '_', strtolower($name)); } /** * Returns the element keys valid for a given format label. * * Single source of truth for the format/element compatibility matrix. * Consumed both by getRules() (server-side validation for UI and API) * and by the Livewire editor for live dropdown option disabling. */ public static function allowedElementKeysForFormat(?string $formatLabel): array { if ($formatLabel === 'DATE') { return ['date_picker']; } if ($formatLabel === 'DATETIME') { return ['datetime_picker']; } // 'CUSTOM REGEX' is the friendly label; on saved records the // stored value is the actual regex pattern (e.g. 'regex:/^\d+$/'). if ($formatLabel === 'CUSTOM REGEX' || str_starts_with((string) $formatLabel, 'regex:')) { return ['text']; } if (in_array($formatLabel, self::TEXT_ONLY_FORMATS, true)) { return ['text']; } if ($formatLabel === 'BOOLEAN') { return ['text', 'checkbox', 'radio']; } // ANY / null / empty — all elements allowed. return self::ELEMENT_KEYS; } /** * Returns whether encryption is allowed for a given element/format combo. * * DATE / DATETIME formats are backed by native date columns that can't * hold ciphertext. checkbox / radio elements store comma-separated * values that don't round-trip through encrypt/decrypt cleanly. */ public static function canEncryptFor(?string $element, ?string $formatLabel): bool { if (in_array($formatLabel, ['DATE', 'DATETIME'], true)) { return false; } if (in_array($element, ['checkbox', 'radio'], true)) { return false; } return true; } /** * Whether an element needs a field_values list (options to pick from). */ public static function elementRequiresFieldValues(?string $element): bool { return in_array($element, ['listbox', 'checkbox', 'radio'], true); } /** * Returns the icon type (matching IconHelper keys) that should decorate * a text input for the given format, or null when no icon applies. * Kept in one place so the preview and the real asset-form render agree. */ public static function iconForFormat(?string $formatLabel): ?string { return match ($formatLabel) { 'EMAIL' => 'email', 'URL' => 'link', 'PHONE' => 'phone', 'FAX' => 'fax', default => null, }; } /** * Override Watson's getRules() to layer per-instance dynamic rules on * top of the static $rules array. Ensures the format/element matrix, * encryption compatibility, and field_values requirement are enforced * on ALL entry points (UI, API, factories, seeders) — not just the UI. */ public function getRules() { $rules = $this->rules; // Uses the accessor, which reverse-maps stored patterns like // 'date' back to the label 'DATE' so the matrix keys line up. $format = $this->format; $element = $this->element; $allowedElements = self::allowedElementKeysForFormat($format); $rules['element'] = [ 'required', Rule::in(self::ELEMENT_KEYS), function ($attribute, $value, $fail) use ($allowedElements, $format) { if (! in_array($value, $allowedElements, true)) { $fail(trans('admin/custom_fields/general.validation.element_not_valid_for_format', [ 'element' => $value, 'format' => $format ?: 'ANY', 'allowed' => implode(', ', $allowedElements), ])); } }, ]; if (self::elementRequiresFieldValues($element)) { $rules['field_values'] = ['required', 'string', function ($attribute, $value, $fail) use ($element) { if (trim((string) $value) === '') { $fail(trans('admin/custom_fields/general.validation.field_values_required', [ 'element' => $element, ])); } }]; } if ($this->field_encrypted && ! self::canEncryptFor($element, $format)) { $rules['field_encrypted'] = [function ($attribute, $value, $fail) use ($element, $format) { if (in_array($format, ['DATE', 'DATETIME'], true)) { $fail(trans('admin/custom_fields/general.validation.cannot_encrypt_format', [ 'format' => $format, ])); return; } $fail(trans('admin/custom_fields/general.validation.cannot_encrypt_element', [ 'element' => $element, ])); }]; } return $rules; } /** * Set some boot methods for creating and updating. * * There is never ever a time when we wouldn't want to be updating those asset * column names and the values of the db column name in the custom fields table * if they have changed, so we handle that here so that we don't have to remember * to do it in the controllers. * * @author [A. Gianotto] [] * * @since [v3.4] * * @return bool */ public static function boot() { parent::boot(); self::created( function ($custom_field) { // Column already exists on the assets table - nothing to do here. // This *shouldn't* happen in the wild. if (Schema::hasColumn(self::$table_name, $custom_field->db_column)) { return false; } // Add the column to the assets table. Most custom-field types // land in a TEXT column so we don't have to worry about width // or type coercion for freeform values. DATE and DATETIME // are the exceptions: creating them as native DATE / DATETIME // columns lets MySQL/MariaDB sort and range-filter // chronologically on that column instead of lexicographically // on a text string. Schema::table( self::$table_name, function ($table) use ($custom_field) { $column = $custom_field->convertUnicodeDbSlug(); if ($custom_field->format === 'DATETIME') { $table->dateTime($column)->nullable(); } elseif ($custom_field->format === 'DATE') { $table->date($column)->nullable(); } else { $table->text($column)->nullable(); } } ); // Update the db_column property in the custom fields table. // syncOriginal() first because Laravel doesn't call it until // finishSave() completes, and finishSave() hasn't run yet at // this point in the `created` event. Without the explicit // sync, the internal save below arrives at the `updating` // hook with $original == [] — making every attribute look // dirty and tripping the format-lock check. $custom_field->syncOriginal(); $custom_field->db_column = $custom_field->convertUnicodeDbSlug(); $custom_field->save(); } ); // Format/element/encryption compatibility is enforced by getRules() // so both UI (Livewire) and API callers hit the same validation. // See allowedElementKeysForFormat() / canEncryptFor() above. self::updating( function ($custom_field) { // DATE / DATETIME custom fields are backed by native // DATE / DATETIME columns on the assets table. Changing // format to or from these types is a HARD block because // it requires an ALTER TABLE column-type change: existing // text values would fail conversion to a date column, and // existing date values can't round-trip back to text // without an explicit stringify step. Other format changes // (e.g. PHONE -> EMAIL) affect only validation rules and // are allowed — the Livewire UI warns the user before they // save that existing assets may fail future validation. if ($custom_field->isDirty('format')) { $original = $custom_field->getOriginal('format'); $originalFriendly = collect(self::PREDEFINED_FORMATS) ->search(fn ($pattern) => $pattern === $original) ?: $original; $lockedFormats = ['DATE', 'DATETIME']; $isOrWasLocked = in_array($originalFriendly, $lockedFormats, true) || in_array($custom_field->format, $lockedFormats, true); if ($isOrWasLocked) { $custom_field->getErrors()->add( 'format', 'The format cannot be changed to or from '.($originalFriendly ?: 'the current type').' on an existing field. Create a new field with the desired format instead.' ); return false; } } // Column already exists on the assets table - nothing to do here. if ($custom_field->isDirty('name')) { if (Schema::hasColumn(self::$table_name, $custom_field->convertUnicodeDbSlug())) { return true; } // Rename the field if the name has changed Schema::table( self::$table_name, function ($table) use ($custom_field) { $table->renameColumn($custom_field->convertUnicodeDbSlug($custom_field->getOriginal('name')), $custom_field->convertUnicodeDbSlug()); } ); // Save the updated column name to the custom fields table $custom_field->db_column = $custom_field->convertUnicodeDbSlug(); $custom_field->save(); return true; } return true; } ); // Drop the assets column if we've deleted it from custom fields self::deleting( function ($custom_field) { // Guard against orphans from an earlier bug that could leave // db_column empty. There's no column to drop in that case; // still allow the custom_fields row itself to be deleted so // the record can be cleaned up. if (empty($custom_field->db_column)) { return true; } if (! Schema::hasColumn(self::$table_name, $custom_field->db_column)) { return true; } return Schema::table( self::$table_name, function ($table) use ($custom_field) { $table->dropColumn($custom_field->db_column); } ); } ); } /** * Establishes the customfield -> fieldset relationship * * @author [A. Gianotto] [] * * @since [v3.0] * * @return Relation */ public function fieldset() { return $this->belongsToMany(CustomFieldset::class); } public function displayFieldInCheckinForm() { if ($this->display_checkin == '1') { return true; } return false; } public function displayFieldInCheckoutForm() { if ($this->display_checkout == '1') { return true; } return false; } public function displayFieldInAuditForm() { if ($this->display_audit == '1') { return true; } return false; } public function displayFieldInCurrentForm($form_type = null) { switch ($form_type) { case 'audit': return $this->displayFieldInAuditForm(); case 'checkin': return $this->displayFieldInCheckinForm(); case 'checkout': return $this->displayFieldInCheckoutForm(); } } public function assetModels() { return $this->fieldset()->with('models')->get()->pluck('models')->flatten()->unique('id'); } /** * Establishes the customfield -> admin user relationship * * @author [A. Gianotto] [] * * @since [v3.0] * * @return Relation */ public function user() { return $this->belongsTo(User::class); } /** * Establishes the customfield -> default values relationship * * @author Hannah Tinkler * * @since [v3.0] * * @return Relation */ public function defaultValues() { return $this->belongsToMany(AssetModel::class, 'models_custom_fields')->withPivot('default_value'); } /** * Returns the default value for a given model using the defaultValues * relationship * * @param int $modelId * @return string */ public function defaultValue($modelId) { return $this->defaultValues->filter( function ($item) use ($modelId) { return $item->pivot->asset_model_id == $modelId; } )->map( function ($item) { return $item->pivot->default_value; } )->first(); } /** * Checks the format of the attribute * * @author [A. Gianotto] [] * * @param $value string * * @since [v3.0] * * @return bool */ public function check_format($value) { return preg_match('/^'.$this->attributes['format'].'$/', $value) === 1; } /** * Gets the DB column name. * * @author [A. Gianotto] [] * * @since [v3.0] * * @return string */ public function db_column_name() { return $this->db_column; } /** * Mutator for the 'format' attribute. * * This is used by the dropdown to store the laravel-specific * validator strings in the database but still return the * user-friendly text in the dropdowns, and in the custom fields display. * * @author [A. Gianotto] [] * * @since [v3.4] * * @return string */ public function getFormatAttribute($value) { foreach (self::PREDEFINED_FORMATS as $name => $pattern) { if ($pattern === $value || $name === $value) { return $name; } } return $value; } /** * Returns the display label of the ORIGINAL format (before any pending * in-memory changes). getFormatAttribute reads the current value; this * reads $original — needed to answer "is this field ALREADY locked into * DATE/DATETIME on disk" so the UI can render a readonly control. */ public function getOriginalFormat(): string { $raw = $this->getOriginal('format') ?? ''; foreach (self::PREDEFINED_FORMATS as $name => $pattern) { if ($pattern === $raw || $name === $raw) { return $name; } } return $raw; } /** * Format a value string as an array for select boxes and checkboxes. * * @author [A. Gianotto] [] * * @since [v3.4] * * @return array */ public function setFormatAttribute($value) { if (isset(self::PREDEFINED_FORMATS[$value])) { $this->attributes['format'] = self::PREDEFINED_FORMATS[$value]; } else { $this->attributes['format'] = $value; } } /** * Format a value string as an array for select boxes and checkboxes. * * @author [A. Gianotto] [] * * @since [v3.4] * * @return array */ public function formatFieldValuesAsArray() { $result = []; $arr = preg_split('/\\r\\n|\\r|\\n/', $this->field_values ?? ''); if (($this->element != 'checkbox') && ($this->element != 'radio')) { $result[''] = 'Select '.strtolower($this->format); } for ($x = 0; $x < count($arr); $x++) { $arr_parts = explode('|', $arr[$x]); if ($arr_parts[0] != '') { if (array_key_exists('1', $arr_parts)) { $result[$arr_parts[0]] = trim($arr_parts[1]); } else { $result[$arr_parts[0]] = trim($arr_parts[0]); } } } return $result; } /** * Check whether the field is encrypted * * @author [A. Gianotto] [] * * @since [v3.4] * * @return bool */ public function isFieldDecryptable($string) { if (($this->field_encrypted == '1') && ($string != '')) { return true; } return false; } /** * Convert non-UTF-8 or weirdly encoded text into something that * won't break the database. * * @author [A. Gianotto] [] * * @since [v3.4] * * @return string */ public function convertUnicodeDbSlug($original = null) { $name = $original ? $original : $this->name; $id = $this->id ? $this->id : 'xx'; if (! function_exists('transliterator_transliterate')) { $long_slug = '_snipeit_'.str_slug(mb_convert_encoding(trim($name), 'UTF-8'), '_'); } else { $long_slug = '_snipeit_'.Utf8Slugger::slugify($name, '_'); } return substr($long_slug, 0, 50).'_'.$id; } /** * Get validation rules for custom fields to use with Validator * * @author [V. Cordes] [] * * @param int $id * * @since [v4.1.10] * * @return array */ public function validationRules($regex_format = null) { return [ 'format' => [ Rule::in(array_merge(array_keys(self::PREDEFINED_FORMATS), self::PREDEFINED_FORMATS, [$regex_format])), ], ]; } /** * Check to see if there is a custom regex format type * * @see https://github.com/grokability/snipe-it/issues/5896 * * @author Wes Hulette * * @since 5.0.0 * * @return string */ public function getFormatType() { if (stripos($this->format, 'regex') === 0 && ($this->format !== self::PREDEFINED_FORMATS['MAC'])) { return 'CUSTOM REGEX'; } return $this->format; } }