Files
Heimdall/app/Http/Controllers/ItemController.php
T
KodeStar ad9baffa62 Only offer pinned tags as the default tag
The default_tag dropdown was populated from every tag (type=1), but the
dashboard taglist only renders pinned tags. Selecting an unpinned tag as the
default therefore triggered a click on a taglist entry that does not exist,
silently doing nothing. Filter the option queries in both Setting accessors to
pinned tags so only selectable tags are offered, and assert an unpinned tag is
excluded.

Also drop the unused $data['default_tag'] assignment in ItemController: the
taglist partial reads the setting directly via Setting::fetch(), so the view
variable was never consumed.
2026-07-08 18:26:19 +01:00

617 lines
20 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Application;
use App\Item;
use App\Jobs\ProcessApps;
use App\User;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\ServerException;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\ValidationException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Illuminate\Http\Response;
use enshrined\svgSanitize\Sanitizer;
class ItemController extends Controller
{
public function __construct()
{
parent::__construct();
$this->middleware('allowed');
}
/**
* Display a listing of the resource on the dashboard.
*/
public function dash(Request $request): View
{
$treat_tags_as = \App\Setting::fetch('treat_tags_as');
$data["treat_tags_as"] = $treat_tags_as;
if (config('app.auth_roles_enable')) {
$roles = explode(config('app.auth_roles_delimiter'), $request->header(config('app.auth_roles_header')));
if ($treat_tags_as == 'categories') {
$data['categories'] = Item::whereHas('children')->with('children', function ($query) {
$query->pinned()->orderBy('order', 'asc');
})->pinned()->orderBy('order', 'asc')->get();
} elseif ($treat_tags_as == 'tags') {
$data['apps'] = Item::with('parents')->where('type', 0)->pinned()->orderBy('order', 'asc')->get();
$data['all_apps'] = Item::where('type', 0)->orderBy('order', 'asc')->get();
$data['taglist'] = Item::where('id', 0)->orWhere(function ($query) {
$query->where('type', 1)->pinned();
})->orderBy('order', 'asc')->get();
} else {
$data['apps'] = Item::whereHas('parents', function ($query) {
$query->where('id', 0);
})->whereIn('role', $roles)->orWhere('type', 1)->pinned()->orderBy('order', 'asc')->get();
$data['all_apps'] = Item::whereHas('parents', function ($query) {
$query->where('id', 0);
})->orWhere('type', 1)->orderBy('order', 'asc')->get();
}
} else {
if ($treat_tags_as == 'categories') {
$data['categories'] = Item::whereHas('children')->with('children', function ($query) {
$query->pinned()->orderBy('order', 'asc');
})->pinned()->orderBy('order', 'asc')->get();
} elseif ($treat_tags_as == 'tags') {
$data['apps'] = Item::with('parents')->where('type', 0)->pinned()->orderBy('order', 'asc')->get();
$data['all_apps'] = Item::where('type', 0)->orderBy('order', 'asc')->get();
$data['taglist'] = Item::where('id', 0)->orWhere(function ($query) {
$query->where('type', 1)->pinned();
})->orderBy('order', 'asc')->get();
} else {
$data['apps'] = Item::whereHas('parents', function ($query) {
$query->where('id', 0);
})->orWhere('type', 1)->pinned()->orderBy('order', 'asc')->get();
$data['all_apps'] = Item::whereHas('parents', function ($query) {
$query->where('id', 0);
})->orWhere(function ($query) {
$query->where('type', 1)->whereNot('id', 0);
})->orderBy('order', 'asc')->get();
}
}
//$data['all_apps'] = Item::doesntHave('parents')->get();
// die(print_r($data));
return view('welcome', $data);
}
/**
* Set order on the dashboard.
*
* @return void
*/
public function setOrder(Request $request)
{
$order = array_filter($request->input('order'));
foreach ($order as $o => $id) {
$item = Item::find($id);
$item->order = $o;
$item->save();
}
}
/**
* Pin item on the dashboard.
*
* @param $id
*/
public function pin($id): RedirectResponse
{
$item = Item::findOrFail($id);
$item->pinned = true;
$item->save();
$route = route('dash', []);
return redirect($route);
}
/**
* Unpin item on the dashboard.
*
* @param $id
*/
public function unpin($id): RedirectResponse
{
$item = Item::findOrFail($id);
$item->pinned = false;
$item->save();
$route = route('dash', []);
return redirect($route);
}
/**
* Unpin item on the dashboard.
*
* @return RedirectResponse|View
*/
public function pinToggle($id, $ajax = false, $tag = false)
{
$item = Item::findOrFail($id);
$new = !(((bool)$item->pinned === true));
$item->pinned = $new;
$item->save();
if ($ajax) {
$item = Item::whereId($tag)->first();
$data['apps'] = new Collection;
if ((int)$tag === 0) {
$tags = Item::where('type', 1)->pinned()->orderBy('order', 'asc')->get();
$data['apps'] = $data['apps']->merge($tags);
}
$apps = $item->children()->pinned()->orderBy('order', 'asc')->get();
$data['apps'] = $data['apps']->merge($apps);
$data['ajax'] = true;
return view('sortable', $data);
} else {
$route = route('dash', []);
return redirect($route);
}
}
/**
* Display a listing of the resource.
*/
public function index(Request $request): View
{
$trash = (bool)$request->input('trash');
$data['apps'] = Item::ofType('item')->orderBy('title', 'asc')->get();
$data['trash'] = Item::ofType('item')->onlyTrashed()->get();
if ($trash) {
return view('items.trash', $data);
} else {
return view('items.list', $data);
}
}
/**
* Show the form for creating a new resource.
*/
public function create(): View
{
//
$data['item'] = new \App\Item();
$data['tags'] = Item::ofType('tag')->orderBy('title', 'asc')->pluck('title', 'id');
$data['tags']->prepend(__('app.dashboard'), 0);
$data['current_tags'] = '0';
return view('items.create', $data);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(int $id): View
{
// Get the item
$item = Item::find($id);
if ($item->appid === null && $item->class !== null) { // old apps won't have an app id so set it
$app = Application::where('class', $item->class)->first();
if ($app) {
$item->appid = $app->appid;
}
}
$data['item'] = $item;
$data['tags'] = Item::ofType('tag')->orderBy('title', 'asc')->pluck('title', 'id');
$data['tags']->prepend(__('app.dashboard'), 0);
$data['current_tags'] = $data['item']->tags();
//$data['current_tags'] = $data['item']->parent;
//die(print_r($data['current_tags']));
// show the edit form and pass the nerd
return view('items.edit', $data);
}
/**
* @param null $id
* @throws ValidationException
*/
public static function storelogic(Request $request, $id = null): Item
{
$application = Application::single($request->input('appid'));
$validatedData = $request->validate([
'title' => 'required|max:255',
'url' => 'required',
'file' => 'image'
]);
if ($request->hasFile('file')) {
$image = $request->file('file');
$extension = $image->getClientOriginalExtension();
if ($extension === 'svg') {
$sanitizer = new Sanitizer();
$sanitizedSvg = $sanitizer->sanitize(file_get_contents($image->getRealPath()));
// Verify that the sanitization removed malicious content
if (strpos($sanitizedSvg, '<script>') !== false) {
throw ValidationException::withMessages(['file' => 'SVG contains malicious content and cannot be uploaded.']);
}
// Save the sanitized SVG back to the file
file_put_contents($image->getRealPath(), $sanitizedSvg);
}
$path = $image->store('icons', 'public');
$request->merge([
'icon' => $path,
]);
} elseif (strpos($request->input('icon'), 'http') === 0) {
$options = [
"ssl" => [
"verify_peer" => false,
"verify_peer_name" => false,
],
];
// Proxy management
$httpsProxy = getenv('HTTPS_PROXY');
$httpsProxyLower = getenv('https_proxy');
if ($httpsProxy !== false || $httpsProxyLower !== false) {
$options['http']['proxy'] = $httpsProxy ?: $httpsProxyLower;
}
$file = $request->input('icon');
$path_parts = pathinfo($file);
if (!array_key_exists('extension', $path_parts)) {
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
}
$extension = $path_parts['extension'];
$contents = file_get_contents($request->input('icon'), false, stream_context_create($options));
if ($extension === 'svg') {
$sanitizer = new Sanitizer();
$contents = $sanitizer->sanitize($contents);
// Verify that the sanitization removed malicious content
if (strpos($contents, '<script>') !== false) {
throw ValidationException::withMessages(['file' => 'SVG contains malicious content and cannot be uploaded.']);
}
}
if (!isImage($contents, $extension)) {
throw ValidationException::withMessages(['file' => 'Icon must be an image.']);
}
$path = 'icons/' . ($application ? $application->icon : md5($contents) . '.' . $extension);
// Private apps could have here duplicated icons folder
if (strpos($path, 'icons/icons/') !== false) {
$path = str_replace('icons/icons/', 'icons/', $path);
}
if (!Storage::disk('public')->exists($path)) {
Storage::disk('public')->put($path, $contents);
}
$request->merge([
'icon' => $path,
]);
}
$config = Item::checkConfig($request->input('config'));
// Don't overwrite the stored password if it wasn't submitted when updating the item
if ($id !== null && strpos($config, '"password":null') !== false) {
$storedItem = Item::find($id);
$storedConfigObject = json_decode($storedItem->getAttribute('description'));
$configObject = json_decode($config);
if ($storedConfigObject && property_exists($storedConfigObject, 'password')) {
$configObject->password = $storedConfigObject->password;
} else {
$configObject->password = null;
}
$config = json_encode($configObject);
}
$current_user = User::currentUser();
$request->merge([
'description' => $config,
'user_id' => $current_user->getId(),
]);
if ($request->input('appid') === 'null' || $request->input('appid') === null) {
$request->merge([
'class' => null,
]);
} else {
$request->merge([
'class' => Application::classFromName($application->name),
]);
}
if ($id === null) {
$item = Item::create($request->all());
} else {
$item = Item::find($id);
$item->update($request->all());
}
$item->parents()->sync($request->tags);
return $item;
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
{
self::storelogic($request);
$route = route('dash', []);
return redirect($route)
->with('success', __('app.alert.success.item_created'));
}
/**
* Display the specified resource.
*/
public function show(int $id): void
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, int $id): RedirectResponse
{
self::storelogic($request, $id);
$route = route('dash', []);
return redirect($route)
->with('success', __('app.alert.success.item_updated'));
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Request $request, int $id): RedirectResponse
{
//
$force = (bool)$request->input('force');
if ($force) {
Item::withTrashed()
->where('id', $id)
->forceDelete();
} else {
Item::find($id)->delete();
}
$route = route('items.index', []);
return redirect($route)
->with('success', __('app.alert.success.item_deleted'));
}
/**
* Restore the specified resource from soft deletion.
*/
public function restore(int $id): RedirectResponse
{
//
Item::withTrashed()
->where('id', $id)
->restore();
$route = route('items.index', []);
return redirect($route)
->with('success', __('app.alert.success.item_restored'));
}
/**
* Return details for supported apps
*
* @throws GuzzleException
*/
public function appload(Request $request): ?string
{
$output = [];
$appid = $request->input('app');
$itemId = $request->input('item_id');
if ($appid === 'null') {
return null;
}
$app = Application::single($appid);
if (!$app) {
return response()->json(['error' => 'Application not found.'], 404);
}
$output = (array)$app;
$appdetails = Application::getApp($appid);
if (!$appdetails) {
return response()->json(['error' => 'Application details not found.'], 404);
}
if ((bool)$app->enhanced === true) {
$item = $itemId ? Item::find($itemId) : Item::where('appid', $appid)->first();
if ($item) {
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = $item->description;
} else {
// Ensure the app is installed if not found
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = null;
}
}
$output['colour'] = ($app->tile_background == 'light') ? '#fafbfc' : '#161b1f';
if (strpos($app->icon, '://') !== false) {
$output['iconview'] = $app->icon;
} elseif (strpos($app->icon, 'icons/') !== false) {
$output['iconview'] = URL::to('/') . '/storage/' . $app->icon;
$output['icon'] = str_replace('icons/', '', $output['icon']);
} else {
$output['iconview'] = config('app.appsource') . 'icons/' . $app->icon;
}
return json_encode($output);
}
/**
* @return void
*/
public function testConfig(Request $request)
{
$data = $request->input('data');
//$url = $data[array_search('url', array_column($data, 'name'))]['value'];
$single = Application::single($data['type']);
$app = $single->class;
// If password is not resubmitted fill it from the database when in edit mode
if (array_key_exists('password', $data) &&
$data['password'] === null &&
array_key_exists('id', $data)
) {
$item = Item::find($data['id']);
if ($item) {
$itemConfig = $item->getConfig();
$data['password'] = $itemConfig->password;
}
}
$app_details = new $app();
$app_details->config = (object)$data;
$app_details->test();
}
/**
* @param $url
* @param array|bool $overridevars
* @throws GuzzleException
*/
public function execute($url, array $attrs = [], $overridevars = false): ?ResponseInterface
{
// Default Guzzle client configuration
$clientOptions = [
'http_errors' => false,
'timeout' => 15,
'connect_timeout' => 15,
'verify' => false, // In production, set this to `true` and manage certs.
];
// If the user provided overrides, use them.
if ($overridevars !== false) {
$clientOptions = $overridevars;
}
// Resolve the hostname to an IP address
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
// Check if the IP is private or reserved
$allowInternalIps = env('ALLOW_INTERNAL_REQUESTS', false);
if (!$allowInternalIps && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
Log::warning('Blocked access to private or reserved IPs.', ['ip' => $ip, 'host' => $host]);
abort(Response::HTTP_FORBIDDEN, 'Access to private or reserved IPs is not allowed.');
}
// Force Guzzle to use the resolved IP address
$clientOptions['curl'][CURLOPT_RESOLVE] = ["{$host}:80:{$ip}", "{$host}:443:{$ip}"];
$client = new Client($clientOptions);
$method = 'GET';
try {
return $client->request($method, $url, $attrs);
} catch (ConnectException $e) {
Log::warning('SSRF Attempt Blocked: Connection to a private IP was prevented.', [
'url' => $url,
'error' => $e->getMessage()
]);
return null;
} catch (ServerException $e) {
Log::debug($e->getMessage());
} catch (\Exception $e) {
Log::error('General error: ' . $e->getMessage());
}
return null;
}
/**
* @param $url
* @throws GuzzleException
*/
public function websitelookup($url): StreamInterface
{
$decodedUrl = base64_decode($url);
// Validate the URL format.
if (filter_var($decodedUrl, FILTER_VALIDATE_URL) === false) {
abort(Response::HTTP_BAD_REQUEST, 'Invalid URL format provided.');
}
$response = $this->execute($decodedUrl);
// If execute() returns null, it means the connection failed.
// This can happen for many reasons, including our SSRF protection kicking in.
if ($response === null) {
abort(Response::HTTP_FORBIDDEN, 'Access to the requested resource is not allowed or the resource is unavailable.');
}
return $response->getBody();
}
/**
* @param $id
* @return void
*/
public function getStats($id)
{
$item = Item::find($id);
$config = $item->getconfig();
if (isset($item->class)) {
$application = new $item->class;
$application->config = $config;
echo $application->livestats();
}
}
/**
* @return \Illuminate\Contracts\Foundation\Application|RedirectResponse|Redirector
*/
public function checkAppList(): RedirectResponse
{
ProcessApps::dispatch();
$route = route('items.index');
return redirect($route)
->with('success', __('app.alert.success.updating'));
}
}