Feat/add komga latest books homepage#2056
Conversation
Update API endpoint from v1 to v2 for user retrieval
…t-plugin-invite Add option create komga account plugin invite
Feat/add dockerfile
There was a problem hiding this comment.
Pull request overview
Adds a Komga integration intended to show recently added Komga books on the Organizr homepage by introducing a new plugin (API + settings + frontend JS) and registering a new homepage item/trait.
Changes:
- Added a new
komgaplugin (settings, API endpoints, frontend rendering JS) and icon asset. - Added a Komga homepage item and registered it in
Organizrvia a new trait. - Updated Komga SSO “users/me” endpoint version and adjusted ignore rules / added an Apache rewrite file.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/images/komga.svg | Adds Komga icon used by the plugin/homepage item. |
| api/plugins/komga/plugin.php | Defines plugin metadata and settings UI (including library/group override selection). |
| api/plugins/komga/plugin.json | Adds plugin catalog metadata (repo, version, icon). |
| api/plugins/komga/main.js | Fetches latest books and renders the homepage component in the browser. |
| api/plugins/komga/config.php | Adds default config values for the Komga plugin. |
| api/plugins/komga/api.php | Adds API endpoints for latest books, settings, and an image proxy. |
| api/homepage/komga.php | Adds homepage item trait and container markup. |
| api/functions/sso-functions.php | Updates Komga SSO endpoint path version. |
| api/classes/organizr.class.php | Registers KomgaHomepageItem trait. |
| .htaccess | Adds rewrite rules targeting /api/v2/index.php. |
| .gitignore | Ensures komga plugin folder is committed; ignores local-dev. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $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' | ||
| ]; |
There was a problem hiding this comment.
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.
| $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; | |
| } |
| if ($url && $apiKey) { | ||
| $endpoint = '/api/v1/books?sort=createdDate,desc&size=20'; | ||
| if ($libraries && $libraries !== 'all') { | ||
| $endpoint .= '&library_id=' . $libraries; |
There was a problem hiding this comment.
$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.
| $endpoint .= '&library_id=' . $libraries; | |
| $endpoint .= '&library_id=' . rawurlencode($libraries); |
| 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> |
There was a problem hiding this comment.
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.
| sideBarLinks.forEach(link => { | ||
| if (link.textContent.trim() === KOMGA_TAB_NAME || link.textContent.includes('Komga')) { | ||
| link.click(); | ||
| found = true; | ||
| } | ||
| }); |
There was a problem hiding this comment.
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).
| 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; | |
| } | |
| } |
| const blob = await res.blob(); | ||
| const objectURL = URL.createObjectURL(blob); | ||
| const el = document.getElementById(elementId); | ||
| if (el) el.style.backgroundImage = `url('${objectURL}')`; |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| RewriteBase /api/v2 | ||
| RewriteCond %{REQUEST_FILENAME} !-f | ||
| RewriteCond %{REQUEST_FILENAME} !-d | ||
| RewriteRule ^ /api/v2/index.php [QSA,L] |
There was a problem hiding this comment.
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.
| RewriteRule ^ /api/v2/index.php [QSA,L] | |
| RewriteRule ^api/v2/ /api/v2/index.php [QSA,L] |
| 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, '/'); | ||
| } |
There was a problem hiding this comment.
/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.
| $res = curl_exec($ch); | ||
| $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); | ||
| curl_close($ch); | ||
|
|
||
| if ($res) { |
There was a problem hiding this comment.
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.
| $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) { |
| 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;"> |
There was a problem hiding this comment.
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.
| 'KOMGA-url' => '', | ||
| 'KOMGA-apikey' => '', | ||
| 'KOMGA-libraries' => 'all', | ||
| 'KOMGA-title' => 'Recently added books' |
There was a problem hiding this comment.
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.
| 'KOMGA-title' => 'Recently added books' | |
| 'KOMGA-title' => 'Recently added books', | |
| 'KOMGA-tab-name' => '' |
|
is this ready to merge? |
A new plugin and homepage item for have komga latest books.