Skip to content
Open
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Temporary Items

# Files from my local computer
php_errors.log
local-dev

# Backup Files
.pydio_id
Expand Down Expand Up @@ -178,6 +179,7 @@ api/plugins/*
!api/plugins/speedTest/
!api/plugins/shuck-stop/
api/plugins/shuck-stop/drives.json
!api/plugins/komga/

# =========================
# Custom files
Expand Down
5 changes: 5 additions & 0 deletions .htaccess
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
RewriteEngine On
RewriteBase /api/v2
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /api/v2/index.php [QSA,L]
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rewrite rule will route all requests (including the UI, static assets, etc.) to /api/v2/index.php because it matches ^ at the repo root. That will likely break normal page loads and asset serving. Restrict this rewrite to only /api/v2/* paths, or move this file into api/v2/.htaccess and rewrite to that directory’s index.php with an appropriate RewriteBase.

Suggested change
RewriteRule ^ /api/v2/index.php [QSA,L]
RewriteRule ^api/v2/ /api/v2/index.php [QSA,L]

Copilot uses AI. Check for mistakes.
1 change: 1 addition & 0 deletions api/classes/organizr.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Organizr
use ProwlarrHomepageItem;
use JDownloaderHomepageItem;
use JellyfinHomepageItem;
use KomgaHomepageItem;
use LidarrHomepageItem;
use MiscHomepageItem;
use MonitorrHomepageItem;
Expand Down
4 changes: 2 additions & 2 deletions api/functions/sso-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ public function getKomgaToken($email, $password, $fallback = false)
$credentials = array('auth' => new Requests_Auth_Digest(array($email, $password)));
$url = $this->qualifyURL($this->config['komgaURL']);
$options = $this->requestOptions($url, $this->getSSOTimeout(), true, false, $credentials);
$response = Requests::get($url . '/api/v1/users/me', ['X-Auth-Token' => 'organizrSSO'], $options);
$response = Requests::get($url . '/api/v2/users/me', ['X-Auth-Token' => 'organizrSSO'], $options);
if ($response->success) {
if ($response->headers['x-auth-token']) {
$this->setLoggerChannel('Komga')->info('Grabbed token');
Expand Down Expand Up @@ -395,4 +395,4 @@ public function getPetioToken($username, $password, $oAuthToken = null, $fallbac
return false;
}
}
}
}
58 changes: 58 additions & 0 deletions api/homepage/komga.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

trait KomgaHomepageItem
{
public function komgaSettingsArray($infoOnly = false)
{
$homepageInformation = [
'name' => 'Komga',
'enabled' => true,
'image' => 'plugins/images/komga.svg',
'category' => 'Entertainment',
'settingsArray' => __FUNCTION__
];
if ($infoOnly) {
return $homepageInformation;
}
$homepageSettings = [
'debug' => true,
'settings' => [
'Enable' => [
$this->settingsOption('enable', 'homepageKomgaEnabled', ['label' => 'Activate Komga', 'help' => 'Display the Komga module on the home page']),
$this->settingsOption('auth', 'homepageKomgaAuth', ['label' => 'Authentification']),
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI label uses "Authentification"; in English this should be "Authentication" (consistent with other homepage items).

Suggested change
$this->settingsOption('auth', 'homepageKomgaAuth', ['label' => 'Authentification']),
$this->settingsOption('auth', 'homepageKomgaAuth', ['label' => 'Authentication']),

Copilot uses AI. Check for mistakes.
],
]
];
return array_merge($homepageInformation, $homepageSettings);
}

public function komgaHomepagePermissions($key = null)
{
$permissions = [
'main' => [
'enabled' => [
'homepageKomgaEnabled'
],
'auth' => [
'homepageKomgaAuth'
]
]
];
return $this->homepageCheckKeyPermissions($key, $permissions);
}

public function homepageOrderKomga()
{
if ($this->homepageItemPermissions($this->komgaHomepagePermissions('main'))) {

return '
<div id="' . __FUNCTION__ . '">
<div id="komgaLatestBookContainer" class="homepage-item" data-id="KOMGA">
<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Komga Books...</h2></div>
</div>
</div>
';
}
}

}
109 changes: 109 additions & 0 deletions api/plugins/komga/api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php
$app->get('/plugins/komga/books/latest', function ($request, $response, $args) {
$komgaPlugin = new KomgaPlugin();
$GLOBALS['api']['response']['data'] = [];

if ($komgaPlugin->checkRoute($request)) {
if ($komgaPlugin->qualifyRequest($komgaPlugin->config['KOMGA-minAuth'], true)) {
$url = $komgaPlugin->config['KOMGA-url'] ?? '';
$apiKey = $komgaPlugin->config['KOMGA-apikey'] ?? '';

// Check for group override
$groupId = $komgaPlugin->user['groupID'] ?? null;
$libraries = $komgaPlugin->config['KOMGA-libraries'] ?? 'all';

if ($groupId !== null) {
$groupOverride = $komgaPlugin->config['KOMGA-library-group-' . $groupId] ?? 'default';
if ($groupOverride !== 'default') {
$libraries = $groupOverride;
}
}

if ($url && $apiKey) {
$endpoint = '/api/v1/books?sort=createdDate,desc&size=20';
if ($libraries && $libraries !== 'all') {
$endpoint .= '&library_id=' . $libraries;
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$libraries is concatenated into the query string without URL encoding. If library IDs ever contain characters like &/? this will break the request and could allow parameter injection. Use rawurlencode($libraries) (or build the URL with a query array) when appending library_id.

Suggested change
$endpoint .= '&library_id=' . $libraries;
$endpoint .= '&library_id=' . rawurlencode($libraries);

Copilot uses AI. Check for mistakes.
}

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, rtrim($url, '/') . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"X-API-Key: $apiKey",
"Accept: application/json"
]);
$res = curl_exec($ch);
curl_close($ch);

if ($res) {
$data = json_decode($res, true);
// Provide settings to JS via our API response
$GLOBALS['api']['response']['data'] = [
'title' => $komgaPlugin->config['KOMGA-title'] ?? 'Recently added books',
'baseUrl' => rtrim($url, '/') . '/book/',
'books' => (isset($data['content']) ? $data['content'] : $data),
'tabName' => $komgaPlugin->config['KOMGA-tab-name'] ?? 'Komga'
];
Comment on lines +35 to +46
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cURL request for latest books has no connect/overall timeout, so a slow/unreachable Komga server can hang the API request and degrade homepage load. Set reasonable CURLOPT_CONNECTTIMEOUT/CURLOPT_TIMEOUT (similar to the 2s timeout used in settings) and handle curl_error/HTTP status codes to return a clean error response.

Suggested change
$res = curl_exec($ch);
curl_close($ch);
if ($res) {
$data = json_decode($res, true);
// Provide settings to JS via our API response
$GLOBALS['api']['response']['data'] = [
'title' => $komgaPlugin->config['KOMGA-title'] ?? 'Recently added books',
'baseUrl' => rtrim($url, '/') . '/book/',
'books' => (isset($data['content']) ? $data['content'] : $data),
'tabName' => $komgaPlugin->config['KOMGA-tab-name'] ?? 'Komga'
];
// Prevent hanging requests to Komga
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
curl_setopt($ch, CURLOPT_TIMEOUT, 2);
$res = curl_exec($ch);
if ($res === false) {
$error = curl_error($ch);
curl_close($ch);
$GLOBALS['responseCode'] = 502;
$GLOBALS['api']['response']['error'] = 'Unable to fetch latest Komga books.';
$GLOBALS['api']['response']['errorDetails'] = $error;
} else {
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
$data = json_decode($res, true);
// Provide settings to JS via our API response
$GLOBALS['api']['response']['data'] = [
'title' => $komgaPlugin->config['KOMGA-title'] ?? 'Recently added books',
'baseUrl' => rtrim($url, '/') . '/book/',
'books' => (isset($data['content']) ? $data['content'] : $data),
'tabName' => $komgaPlugin->config['KOMGA-tab-name'] ?? 'Komga'
];
} else {
$GLOBALS['responseCode'] = 502;
$GLOBALS['api']['response']['error'] = 'Komga responded with an unexpected status code.';
$GLOBALS['api']['response']['statusCode'] = $httpCode;
}

Copilot uses AI. Check for mistakes.
}
}
}
}

$response->getBody()->write(jsonE($GLOBALS['api']));
return $response
->withHeader('Content-Type', 'application/json;charset=UTF-8')
->withStatus($GLOBALS['responseCode']);
});

$app->get('/plugins/komga/image', function ($request, $response, $args) {
$komgaPlugin = new KomgaPlugin();

if ($komgaPlugin->checkRoute($request)) {
if ($komgaPlugin->qualifyRequest(999, true)) {
$apiUrl = $komgaPlugin->config['KOMGA-url'] ?? '';
$apiKey = $komgaPlugin->config['KOMGA-apikey'] ?? '';

// Allow full URL (if passed via query string) or just append to Komga API URL
$urlParams = $request->getQueryParams();
$thumbnailUrl = $urlParams['url'] ?? '';

if ($apiUrl && $apiKey && $thumbnailUrl) {
// Determine if thumbnailUrl is absolute or relative
if (!preg_match('~^(?:f|ht)tps?://~i', $thumbnailUrl)) {
$thumbnailUrl = rtrim($apiUrl, '/') . '/' . ltrim($thumbnailUrl, '/');
}
Comment on lines +62 to +74
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/plugins/komga/image accepts an arbitrary url query param and, when absolute URLs are allowed, will forward the Komga X-API-Key header to that host. This is an SSRF vector and can leak the Komga API key to attacker-controlled domains; additionally, the route is currently accessible to guests (qualifyRequest(999)). Restrict access to at least the plugin’s configured auth level, and validate that the requested URL is only within the configured Komga base URL (e.g., same scheme/host/port and expected path prefix) before proxying.

Copilot uses AI. Check for mistakes.

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $thumbnailUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"X-API-Key: $apiKey"
]);
$res = curl_exec($ch);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);

if ($res) {
Comment on lines +82 to +86
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The image proxy cURL request also lacks connect/overall timeouts and doesn’t check for cURL errors or non-2xx HTTP status codes. Add timeouts and validate the upstream response code to avoid tying up PHP workers and to prevent caching/serving error pages as images.

Suggested change
$res = curl_exec($ch);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($res) {
// Set reasonable timeouts to avoid tying up PHP workers
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$res = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$curlError = curl_errno($ch);
curl_close($ch);
if ($res !== false && $curlError === 0 && $httpCode >= 200 && $httpCode < 300) {

Copilot uses AI. Check for mistakes.
$response->getBody()->write($res);
return $response
->withHeader('Content-Type', $contentType ?: 'image/jpeg');
}
}
}
}

return $response->withStatus(404);
});

$app->get('/plugins/komga/settings', function ($request, $response, $args) {
$komgaPlugin = new KomgaPlugin();
if ($komgaPlugin->checkRoute($request)) {
if ($komgaPlugin->qualifyRequest(1, true)) {
$GLOBALS['api']['response']['data'] = $komgaPlugin->_pluginGetSettings();
}
}
$response->getBody()->write(jsonE($GLOBALS['api']));
return $response
->withHeader('Content-Type', 'application/json;charset=UTF-8')
->withStatus($GLOBALS['responseCode']);
});
12 changes: 12 additions & 0 deletions api/plugins/komga/config.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
/*
* Default configuration for Komga Plugin
*/
return array(
'KOMGA-enabled' => false,
'KOMGA-minAuth' => 1,
'KOMGA-url' => '',
'KOMGA-apikey' => '',
'KOMGA-libraries' => 'all',
'KOMGA-title' => 'Recently added books'
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KOMGA-tab-name is used in the plugin settings/API response but isn’t present in the default config array. Adding it here (with the same default as the settings UI) avoids undefined config keys and makes defaults consistent across fresh installs.

Suggested change
'KOMGA-title' => 'Recently added books'
'KOMGA-title' => 'Recently added books',
'KOMGA-tab-name' => ''

Copilot uses AI. Check for mistakes.
);
125 changes: 125 additions & 0 deletions api/plugins/komga/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
let KOMGA_TAB_NAME = 'Komga-(Livres)';

window.komgaFetchAuthImage = async function (url, elementId) {
try {
const proxyUrl = `api/v2/plugins/komga/image?url=${encodeURIComponent(url)}`;
const res = await fetch(proxyUrl);
if (!res.ok) throw new Error();
const blob = await res.blob();
const objectURL = URL.createObjectURL(blob);
const el = document.getElementById(elementId);
if (el) el.style.backgroundImage = `url('${objectURL}')`;
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL.createObjectURL(blob) is never revoked, so navigating around or reloading the component can leak memory over time. Consider revoking the object URL after the image is applied (e.g., after a short delay or once the image has been used) to avoid accumulation.

Suggested change
if (el) el.style.backgroundImage = `url('${objectURL}')`;
if (el) {
el.style.backgroundImage = `url('${objectURL}')`;
// Revoke the object URL after a short delay to avoid memory leaks.
setTimeout(() => {
URL.revokeObjectURL(objectURL);
}, 1000);
}

Copilot uses AI. Check for mistakes.
} catch (err) {
console.error("Error loading secure image", err);
}
}

window.komgaLoadLatestBooks = async function () {
try {
const response = await fetch('api/v2/plugins/komga/books/latest', {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const res = await response.json();

if (response.ok && res.response?.data) {
const data = res.response.data;
if (data.tabName) KOMGA_TAB_NAME = data.tabName;
const books = Array.isArray(data.books) ? data.books : [];
if (books.length > 0) {
renderKomgaBooks(books, data.title, data.baseUrl);
}
}
} catch (e) {
console.error('Error Komga API:', e);
}
}

window.komgaOpenBook = function (komgaBaseUrl, bookId) {
const targetUrl = `${komgaBaseUrl}${bookId}`;
const sideBarLinks = document.querySelectorAll('.sidebar-nav a, .tab-menu-item, .nav-link');
let found = false;
sideBarLinks.forEach(link => {
if (link.textContent.trim() === KOMGA_TAB_NAME || link.textContent.includes('Komga')) {
link.click();
found = true;
}
});
Comment on lines +42 to +47
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sideBarLinks.forEach(...) can click multiple matching links because there’s no way to break out early once found is true. This can cause unexpected navigation if multiple items contain “Komga”. Use a for...of loop (or Array.prototype.find) so you can stop after the first match, and match the tab more precisely (e.g., exact match on the configured tab name).

Suggested change
sideBarLinks.forEach(link => {
if (link.textContent.trim() === KOMGA_TAB_NAME || link.textContent.includes('Komga')) {
link.click();
found = true;
}
});
for (const link of sideBarLinks) {
const text = (link.textContent || '').trim();
if (text && text.toLowerCase() === KOMGA_TAB_NAME.toLowerCase()) {
link.click();
found = true;
break;
}
}

Copilot uses AI. Check for mistakes.
if (!found) window.location.hash = `#${KOMGA_TAB_NAME}`;

setTimeout(() => {
const iframe = document.querySelector('iframe[src*="komga"]');
if (iframe) iframe.src = targetUrl;
}, 700);
};

function renderKomgaBooks(books, titleDisplay, komgaBaseUrl) {
const container = document.getElementById('homepage-items');
if (!container) return;

let html = `
<div class="row" style="margin-bottom: 30px; margin-top: 20px;">
<div class="col-lg-12">
<div style="display: flex; align-items: center; margin-bottom: 15px; padding-left: 5px;">
<img src="plugins/images/komga.svg" style="width: 22px; height: 22px; margin-right: 10px;" onerror="this.style.display='none'">
<span style="text-transform: uppercase; font-weight: bold; font-size: 13px; letter-spacing: 1px; color: #eee;" lang="en">${titleDisplay}</span>
</div>
Comment on lines +60 to +66
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code builds HTML with template literals containing titleDisplay, title, series, etc. from Komga and then assigns it via innerHTML. If any of those fields contain HTML (or quotes), this becomes an XSS vector in Organizr. Prefer constructing DOM nodes and assigning user-controlled text via textContent (or sanitize/escape all interpolated values) before inserting into the document.

Copilot uses AI. Check for mistakes.
<div class="komga-container" style="display: flex; flex-wrap: nowrap; overflow-x: auto; gap: 12px; padding: 5px;">`;

books.forEach((book, index) => {
const imgId = `komga-img-${index}`;
const metadata = book.metadata || {};
const title = metadata.title || book.name || window.lang.translate('Untitled');
const series = book.seriesTitle || '';
const number = metadata.number ? `n°${metadata.number}` : '';

html += `
<div style="flex: 0 0 150px; max-width: 150px; cursor: pointer;" onclick="komgaOpenBook('${komgaBaseUrl}', '${book.id}')">
<div id="${imgId}" class="komga-card-hover"
style="background-color: #333; background-size: cover; background-position: center; width: 100%; aspect-ratio: 2/3; border-radius: 4px; box-shadow: 0 4px 10px rgba(0,0,0,0.5); margin-bottom: 8px; transition: transform 0.2s ease;">
Comment on lines +76 to +79
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onclick="komgaOpenBook('${komgaBaseUrl}', '${book.id}')" injects dynamic values into an inline handler without escaping. If either value contains quotes it can break the attribute and enable script injection. Attach click listeners with addEventListener (and keep data in closures / data-* attributes) rather than building inline JS in HTML strings.

Copilot uses AI. Check for mistakes.
</div>
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 4px;">
<span style="font-size: 12px; font-weight: bold; color: #fff; display: block; overflow: hidden; text-overflow: ellipsis;">${title}</span>
<span style="font-size: 11px; color: #aaa; display: block; overflow: hidden; text-overflow: ellipsis;">${series} ${number}</span>
</div>
</div>`;

const thumbUrl = book.thumbnailUrl || `/api/v1/books/${book.id}/thumbnail`;
window.komgaFetchAuthImage(thumbUrl, imgId);
});

html += `</div></div></div>`;

let hookContainer = document.getElementById('komgaLatestBookContainer');
if (!hookContainer) {
hookContainer = document.createElement('div');
hookContainer.id = 'komgaLatestBookContainer';
hookContainer.className = 'homepage-item';

const activeSessions = document.querySelector('.active-sessions-item');
if (activeSessions) {
activeSessions.after(hookContainer);
} else {
container.appendChild(hookContainer);
}
}

hookContainer.innerHTML = html;
hookContainer.classList.remove('hidden');
}

// Inject custom CSS
const style = document.createElement('style');
style.innerHTML = `
.komga-card-hover:hover { transform: scale(1.05) !important; }
.komga-container::-webkit-scrollbar { display: none; }
.komga-container { scrollbar-width: none; }
`;
document.head.appendChild(style);

// Initial load handler
setTimeout(() => {
if (document.getElementById('homepage-items')) {
window.komgaLoadLatestBooks();
}
}, 800);
14 changes: 14 additions & 0 deletions api/plugins/komga/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"Komga": {
"repo": "https://github.com/JamesDAdams/organizrv2-plugin-komga",
"author": "JamesAdams",
"category": "Entertainment",
"description": "Add the latest Komga books to the homepage",
"icon": "plugins/images/komga.svg",
"version": "1.0.9",
"minimum_organizr_version": "2.1.0",
"github_folder": "komga",
"project_folder": "komga",
"license": "personal"
}
}
Loading
Loading