Merge remote-tracking branch 'origin/master' into develop

This commit is contained in:
snipe
2026-04-22 14:38:39 +01:00
21 changed files with 367 additions and 78 deletions
+9
View File
@@ -4262,6 +4262,15 @@
"contributions": [
"code"
]
},
{
"login": "Husky-Devel",
"name": "Peter Gallwas",
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
"profile": "https://www.husky.nz",
"contributions": [
"code"
]
}
]
}
+1 -1
View File
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
@@ -182,7 +182,7 @@ class AcceptanceController extends Controller
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
$encoded_logo = null;
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.$settings->acceptance_pdf_logo));
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
}
// Get the data array ready for the notifications and PDF generation
@@ -15,6 +15,8 @@ class ActionlogController extends Controller
{
public function displaySig($filename): RedirectResponse|Response|bool
{
$filename = basename((string) $filename);
// PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class
error_reporting(0);
@@ -44,6 +46,7 @@ class ActionlogController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
if ($actionlog = Actionlog::where('filename', $filename)->with('user')->with('target')->firstOrFail()) {
+24 -4
View File
@@ -39,6 +39,7 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class controls all actions related to assets for
@@ -1122,11 +1123,23 @@ class AssetsController extends Controller
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
}
// Allow the asset tag to be passed in the payload (legacy method)
if ($request->filled('asset_tag')) {
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
$audit_key = $request->input('audit_key', null);
// If they have selected to scan by serial, use that
if (($settings->unique_serial == '1') && ($audit_by_field == 'serial') && ($audit_key)) {
$asset = Asset::where('serial', '=', trim($audit_key))->first();
// If they have selected by asset tag, use that
} elseif (($audit_by_field == 'asset_tag') && ($audit_key)) {
$asset = Asset::where('asset_tag', '=', trim($audit_key))->first();
// Allow the asset tag to be passed in the payload (legacy method)
} elseif ($request->filled('asset_tag')) {
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
}
// If none of the above were selected, fall back to the route-model-binding
if ($asset) {
$originalValues = $asset->getRawOriginal();
@@ -1148,7 +1161,9 @@ class AssetsController extends Controller
// Set up the payload for re-display in the API response
$payload = [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
'asset_tag' => e($asset->asset_tag),
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
'note' => e($request->input('note')),
'status_label' => e($asset->status?->display_name),
'status_type' => $asset->status?->getStatuslabelType(),
@@ -1223,8 +1238,13 @@ class AssetsController extends Controller
}
$fail_payload = [
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
];
// No matching asset for the asset tag that was passed.
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
return response()->json(Helper::formatStandardApiResponse('error', $fail_payload, trans('admin/hardware/message.does_not_exist')), 200);
}
@@ -165,6 +165,7 @@ class SettingsController extends Controller
if (config('mail.reply_to.address') == '') {
Log::debug('MAIL_REPLYTO_ADDR not set in env. Skipping mail test.');
return response()->json(['message' => trans('admin/settings/general.mail_test_no_email')], 403);
}
@@ -292,6 +293,11 @@ class SettingsController extends Controller
*/
public function downloadBackup($file): JsonResponse|BinaryFileResponse
{
$file = $this->sanitizeBackupFilename($file);
if ($file === null) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 404);
}
$path = storage_path('app/backups');
@@ -335,4 +341,21 @@ class SettingsController extends Controller
}
}
private function sanitizeBackupFilename(mixed $filename): ?string
{
$filename = trim((string) $filename);
if ($filename === '' || str_contains($filename, "\0")) {
return null;
}
$sanitized = basename($filename);
if (($sanitized === '') || ($sanitized === '.') || ($sanitized === '..')) {
return null;
}
return ($sanitized === $filename) ? $sanitized : null;
}
}
@@ -251,6 +251,7 @@ class ProfileController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
$logentry = Actionlog::where('filename', $filename)->first();
+8 -1
View File
@@ -32,6 +32,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use League\Csv\EscapeFormula;
@@ -1070,7 +1071,13 @@ class ReportsController extends Controller
foreach ($customfields as $customfield) {
$column_name = $customfield->db_column_name();
if ($request->filled($customfield->db_column_name())) {
$row[] = $asset->$column_name;
$value = $asset->$column_name;
if (($customfield->field_encrypted == '1') && Gate::allows('assets.view.encrypted_custom_fields')) {
$value = Helper::gracefulDecrypt($customfield, $value);
}
$row[] = $value;
}
}
@@ -872,6 +872,11 @@ class SettingsController extends Controller
public function downloadFile($filename = null): RedirectResponse|BinaryFileResponse
{
$path = 'app/backups';
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
if (Storage::exists($path.'/'.$filename)) {
@@ -897,6 +902,12 @@ class SettingsController extends Controller
*/
public function deleteFile($filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (config('app.allow_backup_delete') == 'true') {
if (! config('app.lock_passwords')) {
@@ -971,6 +982,11 @@ class SettingsController extends Controller
*/
public function postRestore(Request $request, $filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
$path = 'app/backups';
@@ -1284,4 +1300,14 @@ class SettingsController extends Controller
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_unrevoked'));
}
private function hasInvalidBackupFilename(string $filename): bool
{
if ($filename === '' || $filename === '.' || $filename === '..') {
return true;
}
// Reject path separators in case a crafted value survives route decoding.
return str_contains($filename, '/') || str_contains($filename, '\\');
}
}
@@ -17,13 +17,17 @@ class StorageProxyController extends Controller
*/
public function show(string $path): Response|StreamedResponse
{
if ($this->hasPathTraversalSegments($path)) {
abort(404);
}
$disk = Storage::disk('public');
// The S3 adapter includes the disk's root prefix in generated URLs,
// but Flysystem also prepends it internally on every operation.
// Strip it here to avoid double-prefixing.
$root = trim(config('filesystems.disks.public.root', ''), '/');
if ($root !== '' && str_starts_with($path, $root . '/')) {
if ($root !== '' && str_starts_with($path, $root.'/')) {
$path = substr($path, strlen($root) + 1);
}
@@ -33,12 +37,12 @@ class StorageProxyController extends Controller
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
$lastModified = $disk->lastModified($path);
$etag = md5($path . $lastModified);
$etag = md5($path.$lastModified);
$size = $disk->size($path);
if ($this->isNotModified($etag, $lastModified)) {
return response('', 304)
->header('ETag', '"' . $etag . '"')
->header('ETag', '"'.$etag.'"')
->header('Cache-Control', 'public, max-age=86400');
}
@@ -51,8 +55,8 @@ class StorageProxyController extends Controller
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $size,
'ETag' => '"' . $etag . '"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
'ETag' => '"'.$etag.'"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
'Cache-Control' => 'public, max-age=86400',
]);
}
@@ -60,7 +64,7 @@ class StorageProxyController extends Controller
private function isNotModified(string $etag, int $lastModified): bool
{
$requestEtag = request()->header('If-None-Match');
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
return true;
}
@@ -71,4 +75,16 @@ class StorageProxyController extends Controller
return false;
}
private function hasPathTraversalSegments(string $path): bool
{
$normalizedPath = str_replace('\\', '/', $path);
return str_contains($normalizedPath, "\0")
|| str_starts_with($normalizedPath, '/')
|| str_contains($normalizedPath, '../')
|| str_contains($normalizedPath, '/..')
|| str_ends_with($normalizedPath, '/..')
|| $normalizedPath === '..';
}
}
+15 -11
View File
@@ -68,17 +68,8 @@ class DefaultLabel extends RectangleSheet
$usableWidth = $this->pageWidth - $this->pageMarginLeft - $this->pageMarginRight;
$usableHeight = $this->pageHeight - $this->pageMarginTop - $this->pageMarginBottom;
$this->columns = ($usableWidth + $this->labelSpacingH) / ($this->labelWidth + $this->labelSpacingH);
$this->rows = ($usableHeight + $this->labelSpacingV) / ($this->labelHeight + $this->labelSpacingV);
// Make sure the columns and rows are never zero, since that scenario should never happen
if ($this->columns == 0) {
$this->columns = 1;
}
if ($this->rows == 0) {
$this->rows = 1;
}
$this->columns = $this->calculateGridCount($usableWidth, $this->labelWidth, $this->labelSpacingH);
$this->rows = $this->calculateGridCount($usableHeight, $this->labelHeight, $this->labelSpacingV);
}
@@ -299,4 +290,17 @@ class DefaultLabel extends RectangleSheet
return $labelHeight;
}
private function calculateGridCount(float $usableSize, float $labelSize, float $spacing): int
{
$denominator = $labelSize + $spacing;
if ($denominator <= 0.0) {
return 1;
}
$count = (int) floor(($usableSize + $spacing) / $denominator);
return max(1, $count);
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ class Label implements View
$logo = Storage::disk('public')->path('companies/'.e($asset->company->image));
} elseif (! empty($settings->label_logo)) {
// Use the general site label logo, if available
$logo = Storage::disk('public')->path('/'.e($settings->label_logo));
$logo = Storage::disk('public')->path('/'.e(basename($settings->label_logo)));
} elseif (! empty($asset->is_label_preview)) {
$logo = public_path('img/label-preview-logo.png');
}
@@ -18,6 +18,13 @@ class DenormalizedEolAndAddColumnForExplicitDateToAssets extends Migration
*/
public function up()
{
Schema::table('companies', function (Blueprint $table) {
if (! Schema::hasColumn('companies', 'deleted_at')) {
$table->softDeletes();
}
});
Schema::table('assets', function (Blueprint $table) {
if (! Schema::hasColumn('assets', 'eol_explicit')) {
$table->boolean('eol_explicit')->default(false)->after('asset_eol_date');
+4 -21
View File
@@ -54,7 +54,7 @@ return [
'avatar_upload' => 'Upload Avatar',
'back' => 'Back',
'bad_data' => 'Nothing found. Maybe bad data?',
'bulkaudit' => 'Bulk Audit',
'bulkaudit' => 'Scanner Bulk Audit',
'bulkaudit_status' => 'Audit Status',
'bulk_checkout' => 'Bulk Checkout',
'bulk_edit' => 'Bulk Edit',
@@ -670,6 +670,9 @@ return [
'child_locations' => 'Child Locations',
'append' => 'Append',
'optional' => 'OPTIONAL',
'audit_by_field' => 'Audit by Field',
'audit_by_field_help' => 'Auditing by scanning serial numbers is only an available option if serial numbers are required to be unique in the Admin Settings.',
'audit_key' => 'Asset',
// Add form placeholders here
'placeholders' => [
@@ -700,26 +703,6 @@ return [
'checkin_item' => 'Checkin :name',
],
'skins' => [
'site_default' => 'Site Default',
'default_blue' => 'Default Blue',
'blue_dark' => 'Blue (Dark Mode)',
'green' => 'Green',
'green_dark' => 'Green (Dark Mode)',
'red' => 'Red',
'red_dark' => 'Red (Dark Mode)',
'orange' => 'Orange',
'orange_dark' => 'Orange (Dark Mode)',
'black' => 'Black',
'black_dark' => 'Black (Dark Mode)',
'purple' => 'Purple',
'purple_dark' => 'Purple (Dark Mode)',
'yellow' => 'Yellow',
'yellow_dark' => 'Yellow (Dark Mode)',
'high_contrast' => 'High Contrast',
],
'select_all_none' => 'Select/Unselect All',
'generic_model_not_found' => 'That :model was not found or you do not have permission to access it',
'report_not_editable' => 'You do not have permission to edit this report template',
@@ -504,7 +504,7 @@
@if (isset($infoPanelObj->alert_on_response))
<x-info-element>
@if ($infoPanelObj->require_acceptance == 1)
@if ($infoPanelObj->alert_on_response)
<x-icon type="checkmark" class="fa-fw text-success" title="{{ trans('general.yes') }}"/>
@else
<x-icon type="x" class="fa-fw text-danger" title="{{ trans('general.no') }}"/>
+40 -23
View File
@@ -27,15 +27,29 @@
<div class="box-body">
{{csrf_field()}}
<!-- Next Audit -->
<div class="form-group {{ $errors->has('asset_tag') ? 'error' : '' }}">
<label for="asset_tag" class="col-md-3 control-label" id="audit_tag">{{ trans('general.asset_tag') }}</label>
<div class="col-md-9">
<div class="input-group date col-md-11 required" data-date-format="yyyy-mm-dd">
<input type="text" class="form-control" name="asset_tag" id="asset_tag" required value="{{ old('asset_tag') }}">
<div class="form-group {{ $errors->has('audit_by_field') ? 'error' : '' }}">
<label for="audit_by_field" class="col-md-3 control-label" id="audit_by_field">{{ trans('general.audit_by_field') }}</label>
<div class="col-md-8">
<select name="audit_by_field" data-minimum-results-for-search="Infinity" id="audit_by_field" class="form-control select2" aria-label="audit_by_field" required>
<option value="asset_tag">{{ trans('general.asset_tag') }}</option>
<option value="serial" {{ (($settings->unique_serial != '1') ? 'disabled' : '') }}>{{ trans('general.serial_number') }}</option>
</select>
{!! $errors->first('audit_by_field', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
{!! $errors->first('asset_tag', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
<p class="help-block">
<x-icon type="tip"/>
{{ trans('general.audit_by_field_help') }}
</p>
</div>
</div>
<!-- Tag/Serial -->
<div class="form-group {{ $errors->has('asset_tag') ? 'error' : '' }}">
<label for="audit_key" class="col-md-3 control-label" id="audit_key_label">{{ trans('general.asset_tag') }}</label>
<div class="col-md-8">
<input type="text" class="form-control" name="audit_key" required value="{{ old('audit_key') }}">
{!! $errors->first('audit_key', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>
@@ -105,7 +119,7 @@
<table id="audited" class="table table-striped snipe-table">
<thead>
<tr>
<th>{{ trans('general.asset_tag') }}</th>
<th>{{ trans('general.audit') }}</th>
<th>{{ trans('general.bulkaudit_status') }}</th>
<th>{{ trans('general.status') }}</th>
<th>{{ trans('general.notes') }}</th>
@@ -134,6 +148,15 @@
@section('moar_scripts')
<script nonce="{{ csrf_token() }}">
$(document.body).on("change", "#audit_by_field", function () {
$('label#audit_key_label').text('{{ trans('general.asset_tag') }}');
if (this.value === 'serial') {
$('label#audit_key_label').text('{{ trans('general.serial_number') }}');
}
});
$("#audit-form").submit(function (event) {
$('#audited-div').show();
$('#audit-loader').show();
@@ -142,7 +165,7 @@
var form = $("#audit-form").get(0);
var formData = $('#audit-form').serializeArray();
var asset_tag = $('#asset_tag').val();
var audit_key = $('#audit_key').val();
$.ajax({
url: "{{ route('api.asset.audit.legacy') }}",
@@ -156,7 +179,7 @@
success : function (data) {
if (data.status == 'success') {
$('#audited tbody').prepend("<tr class='success'><td>" + data.payload.asset_tag + "</td><td>" + data.messages + "</td><td>" + data.payload.status_label + " (" + data.payload.status_type + ")</td><td>" + data.payload.note + "</td><td><i class='fas fa-check text-success' style='font-size:18px;'></i></td></tr>");
$('#audited tbody').prepend("<tr class='success'><td>" + data.payload.audit_by_field + ': ' + data.payload.audit_key + "</td><td>" + data.messages + "</td><td>" + data.payload.status_label + " (" + data.payload.status_type + ")</td><td>" + data.payload.note + "</td><td><i class='fas fa-check' style='font-size:18px;'></i></td></tr>");
@if ($user?->enable_sounds)
var audio = new Audio('{{ config('app.url') }}/sounds/success.mp3');
@@ -165,12 +188,12 @@
incrementOnSuccess();
} else {
handleAuditFail(data, asset_tag);
handleAuditFail(data);
}
$('input#asset_tag').val('');
$('input#audit_key').val('');
},
error: function (data) {
handleAuditFail(data, asset_tag);
handleAuditFail(data, audit_key);
},
complete: function() {
$('#audit-loader').hide();
@@ -181,19 +204,13 @@
return false;
});
function handleAuditFail (data, asset_tag) {
function handleAuditFail(data) {
@if ($user?->enable_sounds)
var audio = new Audio('{{ config('app.url') }}/sounds/error.mp3');
audio.play()
@endif
if ((!asset_tag) && (data.payload) && (data.payload.asset_tag)) {
asset_tag = data.payload.asset_tag;
}
asset_tag = jQuery('<span>' + asset_tag + '</span>').text();
let messages = "";
// Loop through the error messages
@@ -203,7 +220,7 @@
}
}
$('#audited tbody').prepend("<tr class='danger'><td>" + asset_tag + "</td><td>" + messages + "</td><td></td><td></td><td><i class='fas fa-times text-danger' style='font-size:18px;'></i></td></tr>");
$('#audited tbody').prepend("<tr class='danger'><td>" + data.payload.audit_by_field + ': ' + data.payload.audit_key + "</td><td>" + messages + "</td><td></td><td></td><td><i class='fas fa-times' style='font-size:18px;'></i></td></tr>");
}
function incrementOnSuccess() {
@@ -212,7 +229,7 @@
$('#audit-counter').html(y);
}
$("#audit_tag").focus();
$("#audit_key").focus();
</script>
@stop
+9 -7
View File
@@ -1610,13 +1610,6 @@
</a>
</li>
@endcan
@can('admin')
<li id="import-history-sidenav-option" {!! (request()->is('hardware/history') ? ' class="active"' : '') !!}>
<a href="{{ url('hardware/history') }}">
{{ trans('general.import-history') }}
</a>
</li>
@endcan
@can('audit', \App\Models\Asset::class)
<li id="bulk-audit-sidenav-option" {!! (request()->is('hardware/bulkaudit') ? ' class="active"' : '') !!}>
<a href="{{ route('assets.bulkaudit') }}">
@@ -1624,6 +1617,15 @@
</a>
</li>
@endcan
@can('admin')
<li id="import-history-sidenav-option" {!! (request()->is('hardware/history') ? ' class="active"' : '') !!}>
<a href="{{ url('hardware/history') }}">
{{ trans('general.import-history') }}
</a>
</li>
@endcan
</ul>
</li>
@endcan
+52 -1
View File
@@ -4,6 +4,7 @@ namespace Tests\Feature\Assets\Api;
use App\Models\Asset;
use App\Models\User;
use Carbon\Carbon;
use PHPUnit\Framework\Attributes\Group;
use Tests\TestCase;
@@ -59,6 +60,53 @@ class AuditAssetTest extends TestCase
$this->assertEquals($future, $asset->next_audit_date);
}
public function test_legacy_asset_audit_defaults_to_asset_tag_when_audit_by_field_is_not_sent()
{
$asset = Asset::factory()->create();
$future = now()->addMonths(2)->toDateString();
$this->actingAsForApi(User::factory()->auditAssets()->create())
->postJson(route('api.asset.audit.legacy'), [
// Simulates the new quickscan form where audit_key is posted
// and the dropdown may remain on default.
'audit_key' => $asset->asset_tag,
'next_audit_date' => $future,
'note' => 'default asset tag',
])
->assertStatusMessageIs('success')
->assertJsonPath('payload.id', $asset->id)
->assertJsonPath('payload.audit_by_field', 'Asset Tag')
->assertJsonPath('payload.audit_key', (string) $asset->asset_tag)
->assertStatus(200);
$asset->refresh();
$this->assertEquals($future, $asset->next_audit_date);
}
public function test_legacy_asset_audit_can_find_asset_by_serial_when_selected()
{
$this->settings->enableUniqueSerialNumbers();
$asset = Asset::factory()->create(['serial' => 'SERIAL-ABC-123']);
$future = now()->addMonths(2)->toDateString();
$this->actingAsForApi(User::factory()->auditAssets()->create())
->postJson(route('api.asset.audit.legacy'), [
'audit_by_field' => 'serial',
'audit_key' => $asset->serial,
'next_audit_date' => $future,
'note' => 'serial lookup',
])
->assertStatusMessageIs('success')
->assertJsonPath('payload.id', $asset->id)
->assertJsonPath('payload.audit_by_field', 'Serial')
->assertJsonPath('payload.audit_key', $asset->serial)
->assertStatus(200);
$asset->refresh();
$this->assertEquals($future, $asset->next_audit_date);
}
public function test_asset_audit_is_saved()
{
$asset = Asset::factory()->create(['next_audit_date' => now()->subMonth()->toDateString()]);
@@ -85,7 +133,10 @@ class AuditAssetTest extends TestCase
$this->assertHasTheseActionLogs($asset, ['create', 'audit']);
$asset->refresh();
$this->assertEquals($now, $asset->last_audit_date);
$this->assertTrue(
Carbon::parse($asset->last_audit_date)->betweenIncluded($now->copy()->subSecond(), now()->addSecond()),
'Expected last_audit_date to be close to the audit request time.'
);
$this->assertEquals($future, $asset->next_audit_date);
}
@@ -4,9 +4,11 @@ namespace Tests\Feature\Reporting;
use App\Models\Asset;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\ReportTemplate;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Testing\TestResponse;
use League\Csv\Reader;
use PHPUnit\Framework\Assert;
@@ -174,4 +176,35 @@ class CustomReportTest extends TestCase implements TestsPermissionsRequirement
->assertSeeTextInStreamedResponse('Asset D')
->assertDontSeeTextInStreamedResponse('Asset E');
}
public function test_custom_report_decrypts_encrypted_custom_fields_when_user_has_permission(): void
{
$customField = CustomField::factory()->encrypt()->create();
$columnName = $customField->db_column_name();
$asset = Asset::factory()->create(['name' => 'Encrypted Asset']);
$asset->{$columnName} = Crypt::encrypt('super-secret-value');
$asset->save();
$user = User::factory()->create([
'permissions' => json_encode([
'reports.view' => '1',
'assets.view.encrypted_custom_fields' => '1',
]),
]);
$response = $this->actingAs($user)
->post('reports/custom', [
'asset_name' => '1',
$columnName => '1',
])
->assertOk()
->assertHeader('content-type', 'text/csv; charset=utf-8');
$records = collect(Reader::createFromString($response->streamedContent())->getRecords())
->flatten()
->filter();
$this->assertTrue($records->contains('super-secret-value'));
}
}
@@ -0,0 +1,63 @@
<?php
namespace Tests\Feature\Security;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class FilenameTraversalMitigationTest extends TestCase
{
public function test_settings_backup_download_rejects_nested_filename_input(): void
{
config(['app.lock_passwords' => false]);
$this->actingAs(User::factory()->superuser()->create())
->get('/admin/backups/download/..')
->assertRedirect(route('settings.backups.index'))
->assertSessionHas('error', trans('admin/settings/message.backup.file_not_found'));
}
public function test_settings_backup_delete_rejects_nested_filename_input(): void
{
config(['app.lock_passwords' => false]);
config(['app.allow_backup_delete' => 'true']);
$this->actingAs(User::factory()->superuser()->create())
->delete('/admin/backups/delete/..')
->assertRedirect(route('settings.backups.index'))
->assertSessionHas('error', trans('admin/settings/message.backup.file_not_found'));
}
public function test_settings_backup_restore_rejects_nested_filename_input(): void
{
config(['app.lock_passwords' => false]);
$this->actingAs(User::factory()->superuser()->create())
->post('/admin/backups/restore/..')
->assertRedirect(route('settings.backups.index'))
->assertSessionHas('error', trans('admin/settings/message.backup.file_not_found'));
}
public function test_storage_proxy_blocks_path_traversal_segments(): void
{
$this->withoutMiddleware();
Storage::disk('public')->put('proxy-safe/example.txt', 'ok');
$this->get('/storage-proxy/..%2Fproxy-safe%2Fexample.txt')
->assertNotFound();
}
public function test_storage_proxy_serves_valid_public_path(): void
{
$this->withoutMiddleware();
Storage::disk('public')->put('proxy-safe/example-valid.txt', 'ok');
$response = $this->get('/storage-proxy/proxy-safe/example-valid.txt')
->assertOk();
$this->assertStringContainsString('ok', $response->streamedContent());
}
}
@@ -35,4 +35,28 @@ class DefaultLabelTest extends TestCase
// simply ensuring constructor didn't throw exception...
$this->assertInstanceOf(DefaultLabel::class, new DefaultLabel);
}
public function test_handles_column_denominator_that_resolves_to_zero_gracefully()
{
$this->settings->set([
'labels_width' => 0.1,
'labels_display_sgutter' => -0.1,
]);
$label = new DefaultLabel;
$this->assertSame(1, $label->getColumns());
}
public function test_handles_row_denominator_that_resolves_to_zero_gracefully()
{
$this->settings->set([
'labels_height' => 0.1,
'labels_display_bgutter' => -0.1,
]);
$label = new DefaultLabel;
$this->assertSame(1, $label->getRows());
}
}