Merge remote-tracking branch 'origin/master' into develop
This commit is contained in:
@@ -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
@@ -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()) {
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 === '..';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
+7
@@ -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');
|
||||
|
||||
@@ -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') }}"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user