Compare commits

...

27 Commits
v2.7.3 ... 2.x

Author SHA1 Message Date
KodeStar
7861ae1512 Merge pull request #1524 from KodeStar/remove_dropdown_on_single_provider
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Remove search provider dropdown when there's only a single provider
2025-11-11 12:05:43 +00:00
Chris Hunt
66dfe95c9f Fix lint issue 2025-11-11 12:02:53 +00:00
Chris Hunt
130661bd34 Remove search provider dropdown when there's only a single provider
Resolves #1509
2025-11-11 12:00:41 +00:00
KodeStar
900fc83e79 Merge pull request #1523 from KodeStar/add_autocomplete_suggestions
Add autocomplete suggestions support
2025-11-11 11:43:55 +00:00
Chris Hunt
4f30332854 Fix lint issues 2025-11-11 11:42:38 +00:00
Chris Hunt
852c231724 Add autocomplete suggestions support and added to bing, duckduckgo, and google 2025-11-11 11:39:06 +00:00
KodeStar
045bdf0deb Merge pull request #1507 from KodeStar/taglist
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix tag list url when tags are treated as tags
2025-09-16 09:53:06 +01:00
Chris Hunt
755c3e59e1 Fix tag list url when tags are treated as tags 2025-09-16 09:50:03 +01:00
KodeStar
32bf1d034f Add password field to fix #1498
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
2025-09-15 16:49:42 +01:00
Chris Hunt
6d12c547e7 Add password field 2025-09-15 16:42:53 +01:00
KodeStar
ae4ce92dab Merge pull request #1503 from KodeStar/background_max_file_size
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Add current background maxsize #1501
2025-09-15 16:28:52 +01:00
KodeStar
966279b252 Merge pull request #1499 from webmogul1/patch-1
Update app.php
2025-09-15 16:28:28 +01:00
KodeStar
54cf2b88ca Update application version to 2.7.6 2025-09-15 16:28:00 +01:00
Chris Hunt
31f1ba8192 Add current background maxsize #1501 2025-09-15 16:26:10 +01:00
webmogul1
eadd9d1dd8 Update app.php
Change version to 2.7.5
2025-09-11 14:46:50 -04:00
KodeStar
c9ea2cdeb3 Merge pull request #1496 from KodeStar/bugfix/update_proxy_and_items_with_no_password
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Update items with no password
2025-09-10 16:15:23 +01:00
Chris Hunt
517f51ba90 Update items with no password 2025-09-10 16:14:14 +01:00
KodeStar
825f67a4a4 Merge pull request #1480 from Nyuwb/patch-1
feat(icon-upload): proxy management
2025-09-10 15:15:48 +01:00
KodeStar
05a552ffcf Merge pull request #1475 from micvog/fix-german-translation
Fixed multiple typos (German translation)
2025-09-10 15:14:55 +01:00
Adam
ad4584e548 Merge pull request #1488 from linuxserver/inherit-security-md
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
2025-08-23 15:22:58 +01:00
Adam
7d93099f2c Delete SECURITY.md
Inherit LSIO standard security.md from https://github.com/linuxserver/.github/blob/main/SECURITY.md
2025-08-23 15:20:18 +01:00
KodeStar
31ca05f74f Merge pull request #1483 from KodeStar/2.x
Some checks failed
Mark stale issues and pull requests / stale (push) Has been cancelled
Fix for some enhanced apps not working
2025-08-02 17:50:17 +01:00
Chris Hunt
cd95fc3b92 Update search test 2025-08-02 17:43:49 +01:00
Chris Hunt
63e777b338 Redirect to search provider without error fixes #1482 2025-08-02 17:40:37 +01:00
Chris Hunt
fd926e983d Fix for some enhanced apps not working 2025-08-02 17:17:40 +01:00
Fabien Ehrlich
dce37c1412 feat(icon-upload): proxy management 2025-07-31 16:54:44 +02:00
micvog
31db31d0f7 Fixed multiple typos (German translation) 2025-07-21 17:37:39 +02:00
20 changed files with 7177 additions and 71 deletions

View File

@@ -1,14 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.3.x | :white_check_mark: |
| < 2.3 | :x: |
## Reporting a Vulnerability
You can report any vulnerabilities on our discord server by DM-ing a team member, or asking a team member to DM you.
https://discord.com/invite/YWrKVTn

View File

@@ -27,6 +27,17 @@ function format_bytes($bytes, bool $is_drive_size = true, string $beforeunit = '
}
}
function parse_size($size) {
$unit = strtolower(substr($size, -1));
$bytes = (int)$size;
switch($unit) {
case 'g': $bytes *= 1024 * 1024 * 1024; break;
case 'm': $bytes *= 1024 * 1024; break;
case 'k': $bytes *= 1024; break;
}
return $bytes;
}
/**
* @param $title
* @param string $separator

View File

@@ -267,9 +267,16 @@ class ItemController extends Controller
],
];
// Proxy management
$httpsProxy = getenv('HTTPS_PROXY');
$httpsProxyLower = getenv('https_proxy');
if ($httpsProxy !== false || $httpsProxyLower !== false) {
$options['proxy']['http'] = $httpsProxy ?: $httpsProxyLower;
}
$file = $request->input('icon');
$path_parts = pathinfo($file);
if (!isset($path_parts['extension'])) {
if (!array_key_exists('extension', $path_parts)) {
throw ValidationException::withMessages(['file' => 'Icon URL must have a valid file extension.']);
}
$extension = $path_parts['extension'];
@@ -312,7 +319,11 @@ class ItemController extends Controller
$storedConfigObject = json_decode($storedItem->getAttribute('description'));
$configObject = json_decode($config);
$configObject->password = $storedConfigObject->password;
if ($storedConfigObject && property_exists($storedConfigObject, 'password')) {
$configObject->password = $storedConfigObject->password;
} else {
$configObject->password = null;
}
$config = json_encode($configObject);
}
@@ -429,20 +440,31 @@ class ItemController extends Controller
return null;
}
$output['config'] = null;
$output['custom'] = 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(!isset($app->config)) { // class based config
$output['custom'] = className($appdetails->name) . '.config';
$output['appvalue'] = $item->description;
// }
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';
@@ -450,14 +472,12 @@ class ItemController extends Controller
if (strpos($app->icon, '://') !== false) {
$output['iconview'] = $app->icon;
} elseif (strpos($app->icon, 'icons/') !== false) {
// Private apps have the icon locally
$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);
}

View File

@@ -4,9 +4,11 @@ namespace App\Http\Controllers;
use App\Search;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Http;
class SearchController extends Controller
{
@@ -21,17 +23,17 @@ class SearchController extends Controller
// Sanitize the query to prevent XSS
$query = htmlspecialchars($query, ENT_QUOTES, 'UTF-8');
// Validate the presence and non-emptiness of the query parameter
if (!$query || trim($query) === '') {
abort(400, 'Missing or empty query parameter');
}
$provider = Search::providerDetails($requestprovider);
if (!$provider || !isset($provider->type)) {
abort(404, 'Invalid provider');
}
// If the query is empty, redirect to the provider's base URL
if (!$query || trim($query) === '') {
return redirect($provider->url);
}
if ($provider->type == 'standard') {
return redirect($provider->url.'?'.$provider->query.'='.urlencode($query));
} elseif ($provider->type == 'external') {
@@ -39,5 +41,99 @@ class SearchController extends Controller
return $class->getResults($query, $provider);
}
abort(404, 'Provider type not supported');}
abort(404, 'Provider type not supported');
}
/**
* Get autocomplete suggestions for a search query
*
* @return JsonResponse
*/
public function autocomplete(Request $request)
{
$requestprovider = $request->input('provider');
$query = $request->input('q');
if (!$query || trim($query) === '') {
return response()->json([]);
}
$provider = Search::providerDetails($requestprovider);
if (!$provider || !isset($provider->autocomplete)) {
return response()->json([]);
}
// Replace {query} placeholder with actual query
$autocompleteUrl = str_replace('{query}', urlencode($query), $provider->autocomplete);
try {
$response = Http::timeout(5)->get($autocompleteUrl);
if ($response->successful()) {
$data = $response->body();
// Parse the response based on provider
$suggestions = $this->parseAutocompleteResponse($data, $provider->id);
return response()->json($suggestions);
}
} catch (\Exception $e) {
// Return empty array on error
return response()->json([]);
}
return response()->json([]);
}
/**
* Parse autocomplete response based on provider format
*
* @param string $data
* @param string $providerId
* @return array
*/
private function parseAutocompleteResponse($data, $providerId)
{
$suggestions = [];
switch ($providerId) {
case 'google':
// Google returns XML format
if (strpos($data, '<?xml') === 0) {
$xml = simplexml_load_string($data);
if ($xml && isset($xml->CompleteSuggestion)) {
foreach ($xml->CompleteSuggestion as $suggestion) {
if (isset($suggestion->suggestion['data'])) {
$suggestions[] = (string) $suggestion->suggestion['data'];
}
}
}
}
break;
case 'bing':
case 'ddg':
// Bing and DuckDuckGo return JSON array format
$json = json_decode($data, true);
if (is_array($json) && isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
}
break;
default:
// Try to parse as JSON array
$json = json_decode($data, true);
if (is_array($json)) {
if (isset($json[1]) && is_array($json[1])) {
$suggestions = $json[1];
} else {
$suggestions = $json;
}
}
break;
}
return $suggestions;
}
}

View File

@@ -101,6 +101,8 @@ class TagController extends Controller
$data['tag'] = $item->id;
$data['all_apps'] = $item->children;
$data['taglist'] = Item::ofType('tag')->where('id', '>', 0)->orderBy('title', 'asc')->get();
return view('welcome', $data);
}

View File

@@ -111,16 +111,31 @@ abstract class Search
if ((bool) $user_search_provider) {
$name = 'app.options.'.$user_search_provider;
$provider = self::providerDetails($user_search_provider);
$providers = self::providers();
$providerCount = count($providers);
// If there's only one provider, use its key instead of the user's setting
if ($providerCount === 1) {
$user_search_provider = $providers->keys()->first();
}
$output .= '<div class="searchform">';
$output .= '<form action="'.url('search').'"'.getLinkTargetAttribute().' method="get">';
$output .= '<div id="search-container" class="input-container">';
$output .= '<select name="provider">';
foreach (self::providers() as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
// Only show dropdown if there's more than one provider
if ($providerCount > 1) {
$output .= '<select name="provider">';
foreach ($providers as $key => $searchprovider) {
$selected = ((string) $key === (string) $user_search_provider) ? ' selected="selected"' : '';
$output .= '<option value="'.$key.'"'.$selected.'>'.$searchprovider['name'].'</option>';
}
$output .= '</select>';
} else {
// Hidden input for single provider
$output .= '<input type="hidden" name="provider" value="'.$user_search_provider.'" />';
}
$output .= '</select>';
$output .= '<input type="text" name="q" value="'.e(Input::get('q') ?? '').'" class="homesearch" autofocus placeholder="'.__('app.settings.search').'..." />';
$output .= '<button type="submit">'.ucwords(__('app.settings.search')).'</button>';
$output .= '</div>';

View File

@@ -21,6 +21,27 @@ class CustomFormBuilder
);
}
public function password($name, $options = [])
{
return new HtmlString(
$this->html->input('password', $name)->attributes($options)
);
}
public function hidden($name, $value = null, $options = [])
{
return new HtmlString(
$this->html->input('hidden', $name, $value)->attributes($options)
);
}
public function checkbox($name, $value = null, $checked = false, $options = [])
{
return new HtmlString(
$this->html->checkbox($name, $value, $checked)->attributes($options)
);
}
public function select($name, $list = [], $selected = null, $options = [])
{
return new HtmlString(

View File

@@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Facade;
return [
'version' => '2.7.3',
'version' => '2.7.7',
'appsource' => env('APP_SOURCE', 'https://appslist.heimdall.site/'),

View File

@@ -20,7 +20,7 @@ return array (
'settings.language' => 'Sprache',
'settings.reset' => 'Zurücksetzen auf Standard',
'settings.remove' => 'Entfernen',
'settings.search' => 'suche',
'settings.search' => 'Suche',
'settings.no_items' => 'Keine Elemente gefunden',
'settings.label' => 'Bezeichnung',
'settings.value' => 'Wert',
@@ -33,7 +33,7 @@ return array (
'options.ddg' => 'DuckDuckGo',
'options.bing' => 'Bing',
'options.qwant' => 'Qwant',
'options.startpage' => 'StartSeite',
'options.startpage' => 'Startseite',
'options.yes' => 'Ja',
'options.no' => 'Nein',
'options.nzbhydra' => 'NZBHydra',
@@ -46,7 +46,7 @@ return array (
'dash.pin_item' => 'Element auf dem Dashboard anheften',
'dash.no_apps' => 'Derzeit gibt es keine angeheftete Anwendungen. :link1 oder :link2',
'dash.link1' => 'Anwendung neu hinzufügen',
'dash.link2' => 'anheften',
'dash.link2' => 'Anheften',
'dash.pinned_items' => 'Angeheftete Elemente',
'apps.app_list' => 'Anwendungsliste',
'apps.view_trash' => 'Ansicht Papierkorb',
@@ -66,7 +66,7 @@ return array (
'apps.add_tag' => 'Tag hinzufügen',
'apps.tag_name' => 'Tag Name',
'apps.tags' => 'Tags',
'apps.override' => 'Fals anders zur Haupt-URL',
'apps.override' => 'Falls anders zur Haupt-URL',
'apps.preview' => 'Vorschau',
'apps.apptype' => 'Anwendungstyp',
'apps.website' => 'Webseite',
@@ -81,7 +81,7 @@ return array (
'user.avatar' => 'Avatar',
'user.email' => 'Email',
'user.password_confirm' => 'Passwort bestätigen',
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in kraft.',
'user.secure_front' => 'Öffentlichen Zugang erlauben - Tritt nur bei gesetztem Passwort in Kraft.',
'user.autologin' => 'Anmelden von spezieller URL erlauben. Jeder mit diesem Link kann sich anmelden.',
'url' => 'URL',
'title' => 'Titel',

2185
public/css/app.css vendored

File diff suppressed because one or more lines are too long

4637
public/js/app.js vendored

File diff suppressed because one or more lines are too long

1
public/js/dummy.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,4 @@
{
"/js/dummy.js": "/js/dummy.js?id=daec5f3b283a510837bec36ca3868a54",
"/css/app.css": "/css/app.css?id=8e5c9ae35dd160a37c9d33d663f996b9",
"/js/app.js": "/js/app.js?id=19052619246fec368cad13937c62d850"
"/css/app.css": "/css/app.css?id=271cb5f5a1f91d0a6dfbc65e374ffc14",
"/js/app.js": "/js/app.js?id=2ebeb753597d1cbbf88d8bc652e4af5b"
}

View File

@@ -108,11 +108,90 @@ $.when($.ready).then(() => {
}
});
// Autocomplete functionality
let autocompleteTimeout = null;
let currentAutocompleteRequest = null;
function hideAutocomplete() {
$("#search-autocomplete").remove();
}
function showAutocomplete(suggestions, inputElement) {
hideAutocomplete();
if (!suggestions || suggestions.length === 0) {
return;
}
const $input = $(inputElement);
const position = $input.position();
const width = $input.outerWidth();
const $autocomplete = $('<div id="search-autocomplete"></div>');
suggestions.forEach((suggestion) => {
const $item = $('<div class="autocomplete-item"></div>')
.text(suggestion)
.on("click", () => {
$input.val(suggestion);
hideAutocomplete();
$input.closest("form").submit();
});
$autocomplete.append($item);
});
$autocomplete.css({
position: "absolute",
top: `${position.top + $input.outerHeight()}px`,
left: `${position.left}px`,
width: `${width}px`,
});
$input.closest("#search-container").append($autocomplete);
}
function fetchAutocomplete(query, provider) {
// Cancel previous request if any
if (currentAutocompleteRequest) {
currentAutocompleteRequest.abort();
}
if (!query || query.trim().length < 2) {
hideAutocomplete();
return;
}
currentAutocompleteRequest = $.ajax({
url: `${base}search/autocomplete`,
method: "GET",
data: {
q: query,
provider,
},
success(data) {
const inputElement = $("#search-container input[name=q]")[0];
showAutocomplete(data, inputElement);
},
error() {
hideAutocomplete();
},
complete() {
currentAutocompleteRequest = null;
},
});
}
$("#search-container")
.on("input", "input[name=q]", function () {
const search = this.value;
const items = $("#sortable").find(".item-container");
if ($("#search-container select[name=provider]").val() === "tiles") {
// Get provider from either select or hidden input
const provider =
$("#search-container select[name=provider]").val() ||
$("#search-container input[name=provider]").val();
if (provider === "tiles") {
hideAutocomplete();
if (search.length > 0) {
items.hide();
items
@@ -126,6 +205,12 @@ $.when($.ready).then(() => {
}
} else {
items.show();
// Debounce autocomplete requests
clearTimeout(autocompleteTimeout);
autocompleteTimeout = setTimeout(() => {
fetchAutocomplete(search, provider);
}, 300);
}
})
.on("change", "select[name=provider]", function () {
@@ -147,9 +232,24 @@ $.when($.ready).then(() => {
} else {
$("#search-container button").show();
items.show();
hideAutocomplete();
}
});
// Hide autocomplete when clicking outside
$(document).on("click", (e) => {
if (!$(e.target).closest("#search-container").length) {
hideAutocomplete();
}
});
// Hide autocomplete on Escape key
$(document).on("keydown", (e) => {
if (e.key === "Escape") {
hideAutocomplete();
}
});
$("#search-container select[name=provider]").trigger("change");
$("#app")

View File

@@ -926,6 +926,12 @@ div.create {
max-width: 620px;
position: relative;
z-index: 4;
// Reduce width when there's no select dropdown (only has hidden input)
&:has(input[name="provider"][type="hidden"]) {
max-width: 520px;
}
form {
width: 100%;
}
@@ -933,7 +939,6 @@ div.create {
background: white;
border-radius: 5px;
box-shadow: 0px 0px 5px 0 rgba(0,0,0,0.4);
overflow: hidden;
position: relative;
display: flex;
@@ -945,6 +950,11 @@ div.create {
width: 100%;
background: transparent;
}
// When there's no select dropdown, round the input's left corners
input[name="q"]:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
button {
position: absolute;
right: 0px;
@@ -965,7 +975,42 @@ div.create {
background: #f5f5f5;
border: none;
border-right: 1px solid #ddd;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
// When select exists, remove input's left border radius
select ~ input[name="q"] {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
#search-autocomplete {
position: absolute;
z-index: 1000;
background: white;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
box-shadow: 0px 4px 8px 0 rgba(0,0,0,0.2);
max-height: 300px;
overflow-y: auto;
.autocomplete-item {
padding: 12px 15px;
cursor: pointer;
font-size: 15px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #f5f5f5;
}
}
}
.ui-autocomplete {

View File

@@ -1,7 +1,18 @@
<section class="module-container">
@if($enable_auth_admin_controls)
<header>
<div class="section-title">{{ __($setting->label) }}</div>
<div class="section-title">
{{ __($setting->label) }}
@if($setting->type === 'image')
@php
$max_upload = ini_get('upload_max_filesize');
$max_upload_bytes = parse_size($max_upload);
@endphp
<a class="settinglink" target="_blank" rel="nofollow noreferer" href="https://github.com/linuxserver/Heimdall?tab=readme-ov-file#new-background-image-not-being-set">({{ format_bytes($max_upload_bytes, false) }})</a>
@endif
</div>
<div class="module-actions">
<button type="submit"class="button"><i class="fa fa-save"></i><span>{{ __('app.buttons.save') }}</span></button>
<a href="{{ route('settings.index', []) }}" class="button"><i class="fa fa-ban"></i><span>{{ __('app.buttons.cancel') }}</span></a>

View File

@@ -75,6 +75,7 @@ Route::post('test_config', [ItemController::class,'testConfig'])->name('test_con
Route::get('get_stats/{id}', [ItemController::class,'getStats'])->name('get_stats');
Route::get('/search', [SearchController::class,'index'])->name('search');
Route::get('/search/autocomplete', [SearchController::class,'autocomplete'])->name('search.autocomplete');
Route::get('view/{name_view}', function ($name_view) {
return view('SupportedApps::'.$name_view)->render();

View File

@@ -18,6 +18,7 @@ bing:
method: get
target: _blank
query: q
autocomplete: https://api.bing.com/osjson.aspx?query={query}
ddg:
id: ddg
@@ -26,6 +27,7 @@ ddg:
method: get
target: _blank
query: q
autocomplete: https://duckduckgo.com/ac/?q={query}&type=list
google:
id: google
@@ -34,6 +36,7 @@ google:
method: get
target: _blank
query: q
autocomplete: https://suggestqueries.google.com/complete/search?output=toolbar&hl=en&q={query}
startpage:
id: startpage

View File

@@ -32,22 +32,4 @@ class SearchTest extends TestCase
$response->assertStatus(404); // Assert that the response status is 404
}
public function test_search_page_without_query_parameter(): void
{
$provider = 'google'; // Example provider
$response = $this->get(route('search', ['provider' => $provider]));
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
}
public function test_search_page_with_empty_query(): void
{
$provider = 'google'; // Example provider
$query = ''; // Empty search term
$response = $this->get(route('search', ['provider' => $provider, 'q' => $query]));
$response->assertStatus(400); // Assert that the response status is 400 (Bad Request)
}
}

1
webpack.mix.js vendored
View File

@@ -12,7 +12,6 @@ const mix = require("laravel-mix");
*/
mix
.js("resources/assets/js/app.js", "public/js/dummy.js")
.babel(
[
"node_modules/sortablejs/Sortable.min.js",