mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Merge audit16-core/security into the September 16 core audit fixes
This commit is contained in:
@@ -112,6 +112,26 @@ Web interface:
|
|||||||
re-sent with backoff instead of being counted as failed and skipped — that is
|
re-sent with backoff instead of being counted as failed and skipped — that is
|
||||||
how a disabled plugin with an update waiting was silently left out.
|
how a disabled plugin with an update waiting was silently left out.
|
||||||
|
|
||||||
|
Security (request paths and inline handlers, siblings of #561):
|
||||||
|
|
||||||
|
- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
|
||||||
|
`POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
|
||||||
|
and answer 400 otherwise. A `plugin_id` of `../../config` used to create an
|
||||||
|
`uploads/` directory outside `assets/plugins`, write images and
|
||||||
|
`.metadata.json` there, list it, and delete whatever file a metadata entry
|
||||||
|
named. Delete now unlinks only a path that resolves inside that plugin's
|
||||||
|
uploads directory (any other entry is dropped without touching a file).
|
||||||
|
- `PluginManager.get_plugin_directory()` returns `None` for anything but a
|
||||||
|
plain name, so `POST /api/v3/plugins/action` can no longer run a manifest
|
||||||
|
script from a directory outside the plugins directory (`../elsewhere`); the
|
||||||
|
route also rejects such ids with 400.
|
||||||
|
- Plugin Store, saved-repository and custom-registry buttons escape registry
|
||||||
|
values for their inline `onclick` handlers (`jsStringAttr` in
|
||||||
|
`plugins_manager.js`). An entry id containing `'` used to close the attribute
|
||||||
|
and add its own script. The store's View button opens only `http(s)` links.
|
||||||
|
- The uploaded-images list escapes each file's original name, path and ids; a
|
||||||
|
name like `<img src=x onerror=...>.png` was inserted as markup.
|
||||||
|
|
||||||
Plugin system:
|
Plugin system:
|
||||||
|
|
||||||
- A plugin no longer starts with a schema warning and a degraded flag because
|
- A plugin no longer starts with a schema warning and a degraded flag because
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from src.plugin_system.plugin_loader import PluginLoader
|
|||||||
from src.plugin_system.plugin_executor import PluginExecutor
|
from src.plugin_system.plugin_executor import PluginExecutor
|
||||||
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
from src.plugin_system.plugin_state import PluginStateManager, PluginState
|
||||||
from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
|
from src.plugin_system.schema_manager import SchemaManager, normalize_legacy_booleans
|
||||||
|
from src.common.path_safety import safe_path_component
|
||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
get_plugin_dir_mode
|
get_plugin_dir_mode
|
||||||
@@ -743,11 +744,20 @@ class PluginManager:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Directory path as string or None if not found
|
Directory path as string or None if not found
|
||||||
|
|
||||||
|
``plugin_id`` often comes straight from a request, so anything that is
|
||||||
|
not one plain path segment (``..``, ``a/b``, an absolute path) is
|
||||||
|
refused instead of being joined onto ``plugins_dir``. The join is not
|
||||||
|
resolved further: dev plugins are symlinks into ``plugins_dir``.
|
||||||
"""
|
"""
|
||||||
with self._discovery_lock:
|
with self._discovery_lock:
|
||||||
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
||||||
return str(self.plugin_directories[plugin_id])
|
return str(self.plugin_directories[plugin_id])
|
||||||
|
|
||||||
|
plugin_id = safe_path_component(plugin_id)
|
||||||
|
if plugin_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
plugin_dir = self.plugins_dir / plugin_id
|
plugin_dir = self.plugins_dir / plugin_id
|
||||||
if plugin_dir.exists():
|
if plugin_dir.exists():
|
||||||
return str(plugin_dir)
|
return str(plugin_dir)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
|||||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||||
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
|
||||||
|
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
|
||||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
|
|||||||
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
|
||||||
'unit/test_style_editor_layout_leaf_columns.js',
|
'unit/test_style_editor_layout_leaf_columns.js',
|
||||||
'unit/test_style_editor_layout_leaf_collision.js',
|
'unit/test_style_editor_layout_leaf_collision.js',
|
||||||
'unit/test_update_all.js'];
|
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
|
||||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||||
'dom/test_tools_sections.js'];
|
'dom/test_tools_sections.js'];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// Registry- and upload-supplied strings must not escape the inline handlers
|
||||||
|
// plugins_manager.js builds for them.
|
||||||
|
//
|
||||||
|
// The store, saved-repository and custom-registry renderers wrote
|
||||||
|
//
|
||||||
|
// <button onclick='... installFromCustomRegistry(${JSON.stringify(id)}) ...'>
|
||||||
|
//
|
||||||
|
// JSON.stringify makes a valid JS string but leaves `'` alone, so an entry id
|
||||||
|
// of x' onmouseover='alert(1) closed the single-quoted attribute and added a
|
||||||
|
// handler of its own. The live window.updateImageList (plugins_manager.js loads
|
||||||
|
// last, so its copy beats the file-upload widget's) put the uploaded file's
|
||||||
|
// original name into the markup unescaped.
|
||||||
|
//
|
||||||
|
// Each case renders with the shipped function, parses the tag the way a browser
|
||||||
|
// does (quoted attribute values, entities decoded), and checks two things: no
|
||||||
|
// attribute appeared that the template did not write, and the decoded handler
|
||||||
|
// runs and passes the hostile value through intact as data.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(
|
||||||
|
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (label, cond, extra) => cond
|
||||||
|
? (pass++, console.log(' ok ' + label))
|
||||||
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' -> ' + JSON.stringify(extra).slice(0, 400) : '')));
|
||||||
|
|
||||||
|
function extract(opener) {
|
||||||
|
const start = SRC.indexOf(opener);
|
||||||
|
if (start < 0) { console.error('FAIL: cannot find ' + JSON.stringify(opener)); process.exit(1); }
|
||||||
|
let depth = 0;
|
||||||
|
for (let j = SRC.indexOf('{', start); j < SRC.length; j++) {
|
||||||
|
if (SRC[j] === '{') depth++;
|
||||||
|
else if (SRC[j] === '}' && --depth === 0) return SRC.slice(start, j + 1);
|
||||||
|
}
|
||||||
|
console.error('FAIL: unbalanced braces after ' + opener); process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOM shim ───────────────────────────────────────────────────────────────
|
||||||
|
class FakeEl {
|
||||||
|
constructor() { this.innerHTML = ''; this.value = ''; this.textContent = ''; }
|
||||||
|
}
|
||||||
|
class TextEl {
|
||||||
|
set textContent(v) { this._t = String(v == null ? '' : v); }
|
||||||
|
get innerHTML() {
|
||||||
|
return (this._t || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const els = {};
|
||||||
|
global.document = {
|
||||||
|
getElementById: id => (els[id] ||= new FakeEl()),
|
||||||
|
createElement: () => new TextEl(),
|
||||||
|
};
|
||||||
|
global.window = global;
|
||||||
|
global.pluginLog = () => {};
|
||||||
|
global.isStorePluginInstalled = () => false;
|
||||||
|
global.isNewPlugin = () => false;
|
||||||
|
global.formatDate = () => '';
|
||||||
|
global.setGridHtmlIfChanged = (container, html) => { container.innerHTML = html; };
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
eval([
|
||||||
|
'function escapeHtml(text) {', 'function escapeAttribute(text) {', 'function jsStringAttr(value) {',
|
||||||
|
'function renderPluginStore(plugins) {', 'function renderSavedRepositories(repositories) {',
|
||||||
|
'function renderCustomRegistryPlugins(plugins, registryUrl) {',
|
||||||
|
].map(extract).join('\n') + '\nglobal.jsStringAttr = jsStringAttr; global.escapeHtml = escapeHtml;'
|
||||||
|
+ '\nglobal.renderPluginStore = renderPluginStore; global.renderSavedRepositories = renderSavedRepositories;'
|
||||||
|
+ '\nglobal.renderCustomRegistryPlugins = renderCustomRegistryPlugins; global.escapeAttribute = escapeAttribute;');
|
||||||
|
// eslint-disable-next-line no-eval
|
||||||
|
eval(extract('window.updateImageList = function(fieldId, images) {'));
|
||||||
|
|
||||||
|
// ── minimal HTML start-tag tokenizer ───────────────────────────────────────
|
||||||
|
function decodeEntities(s) {
|
||||||
|
return s.replace(/&(#x[0-9a-f]+|#\d+|quot|amp|lt|gt|apos);/gi, (m, e) => {
|
||||||
|
const k = e.toLowerCase();
|
||||||
|
if (k === 'quot') return '"';
|
||||||
|
if (k === 'amp') return '&';
|
||||||
|
if (k === 'lt') return '<';
|
||||||
|
if (k === 'gt') return '>';
|
||||||
|
if (k === 'apos') return "'";
|
||||||
|
return String.fromCodePoint(k[1] === 'x' ? parseInt(k.slice(2), 16) : parseInt(k.slice(1), 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function tags(html, name) {
|
||||||
|
const out = [];
|
||||||
|
let i = 0;
|
||||||
|
while ((i = html.indexOf('<' + name, i)) >= 0) {
|
||||||
|
let j = i + name.length + 1;
|
||||||
|
const attrs = [];
|
||||||
|
for (;;) {
|
||||||
|
while (/\s/.test(html[j])) j++;
|
||||||
|
if (html[j] === '>' || j >= html.length) break;
|
||||||
|
if (html[j] === '/') { j++; continue; }
|
||||||
|
let n = '';
|
||||||
|
while (j < html.length && !/[\s=>]/.test(html[j])) n += html[j++];
|
||||||
|
let v = '';
|
||||||
|
while (/\s/.test(html[j])) j++;
|
||||||
|
if (html[j] === '=') {
|
||||||
|
j++;
|
||||||
|
while (/\s/.test(html[j])) j++;
|
||||||
|
const q = html[j];
|
||||||
|
if (q === '"' || q === "'") {
|
||||||
|
const end = html.indexOf(q, j + 1);
|
||||||
|
v = html.slice(j + 1, end); j = end + 1;
|
||||||
|
} else {
|
||||||
|
while (j < html.length && !/[\s>]/.test(html[j])) v += html[j++];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attrs.push([n.toLowerCase(), decodeEntities(v)]);
|
||||||
|
}
|
||||||
|
out.push(attrs);
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const names = attrs => attrs.map(a => a[0]);
|
||||||
|
const attr = (attrs, n) => (attrs.find(a => a[0] === n) || [])[1];
|
||||||
|
|
||||||
|
// Run a decoded inline handler with window/document stubs; return the calls.
|
||||||
|
function runHandler(code) {
|
||||||
|
const calls = [];
|
||||||
|
const win = new Proxy({}, {
|
||||||
|
get: (_, prop) => (...args) => { calls.push([prop, ...args]); },
|
||||||
|
has: () => true,
|
||||||
|
});
|
||||||
|
const doc = { getElementById: () => ({ value: '' }) };
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
new Function('window', 'document', 'console', code)(win, doc, { error() {} });
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQ = "x' onmouseover='alert(1)";
|
||||||
|
const DQ = 'x" onmouseover="alert(1)';
|
||||||
|
const AMP = 'x' onmouseover='alert(1)';
|
||||||
|
|
||||||
|
console.log('\n1. custom registry install button');
|
||||||
|
for (const hostile of [SQ, DQ, AMP]) {
|
||||||
|
renderCustomRegistryPlugins([{ id: hostile, name: 'n', plugin_path: hostile }], hostile);
|
||||||
|
const buttons = tags(els['custom-registry-grid'].innerHTML, 'button');
|
||||||
|
const b = buttons.find(a => attr(a, 'onclick'));
|
||||||
|
ok(`${JSON.stringify(hostile)}: only onclick and class attributes`,
|
||||||
|
b && names(b).join(',') === 'onclick,class', b && names(b));
|
||||||
|
let calls = [];
|
||||||
|
try { calls = runHandler(attr(b, 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
|
||||||
|
ok(`${JSON.stringify(hostile)}: handler passes id, url and path intact`,
|
||||||
|
calls.length === 1 && calls[0][0] === 'installFromCustomRegistry'
|
||||||
|
&& calls[0][1] === hostile && calls[0][2] === hostile && calls[0][3] === hostile, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n2. store install and view buttons');
|
||||||
|
for (const hostile of [SQ, DQ, AMP]) {
|
||||||
|
renderPluginStore([{ id: hostile, name: 'n', repo: 'https://github.com/o/r', plugin_path: 'p' }]);
|
||||||
|
const buttons = tags(els['plugin-store-grid'].innerHTML, 'button');
|
||||||
|
ok(`${JSON.stringify(hostile)}: two buttons, no extra attributes`,
|
||||||
|
buttons.length === 2 && buttons.every(b => names(b).every(n => ['onclick', 'class', 'disabled'].includes(n))),
|
||||||
|
buttons.map(names));
|
||||||
|
let calls = [];
|
||||||
|
try { calls = runHandler(attr(buttons[0], 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
|
||||||
|
ok(`${JSON.stringify(hostile)}: install handler gets the id intact`,
|
||||||
|
calls.length === 1 && calls[0][0] === 'installPlugin' && calls[0][1] === hostile, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n3. store view button only opens http(s) links');
|
||||||
|
renderPluginStore([{ id: 'a', name: 'n', repo: 'https://github.com/o/r', plugin_path: 'plugins/a' }]);
|
||||||
|
{
|
||||||
|
const view = tags(els['plugin-store-grid'].innerHTML, 'button')[1];
|
||||||
|
const calls = runHandler(attr(view, 'onclick'));
|
||||||
|
ok('https repo opens the plugin tree',
|
||||||
|
calls.length === 1 && calls[0][0] === 'open' && calls[0][1] === 'https://github.com/o/r/tree/main/plugins/a', calls);
|
||||||
|
}
|
||||||
|
renderPluginStore([{ id: 'a', name: 'n', repo: 'javascript:alert(document.domain)//' }]);
|
||||||
|
{
|
||||||
|
const view = tags(els['plugin-store-grid'].innerHTML, 'button')[1];
|
||||||
|
const calls = runHandler(attr(view, 'onclick'));
|
||||||
|
ok('javascript: repo opens nothing', calls.length === 0, calls);
|
||||||
|
ok('javascript: repo button is disabled', names(view).includes('disabled'), names(view));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n4. saved repository remove button');
|
||||||
|
for (const hostile of [SQ, DQ, AMP]) {
|
||||||
|
renderSavedRepositories([{ url: hostile, name: hostile }]);
|
||||||
|
const b = tags(els['saved-repositories-list'].innerHTML, 'button')[0];
|
||||||
|
ok(`${JSON.stringify(hostile)}: no extra attributes`,
|
||||||
|
b && names(b).join(',') === 'onclick,class,title,aria-label', b && names(b));
|
||||||
|
let calls = [];
|
||||||
|
try { calls = runHandler(attr(b, 'onclick')); } catch (e) { calls = [['threw', String(e)]]; }
|
||||||
|
ok(`${JSON.stringify(hostile)}: handler gets the url intact`,
|
||||||
|
calls.length === 1 && calls[0][0] === 'removeSavedRepository' && calls[0][1] === hostile, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n5. live window.updateImageList escapes the uploaded file name');
|
||||||
|
window.getUploadConfig = () => ({ plugin_id: SQ });
|
||||||
|
window.currentPluginConfig = null;
|
||||||
|
{
|
||||||
|
const name = '<img src=x onerror=alert(1)>' + DQ + '.png';
|
||||||
|
window.updateImageList('f', [{ id: SQ, path: 'assets/x".png', filename: DQ, original_filename: name, size: 1 }]);
|
||||||
|
const html = els['f_image_list'].innerHTML;
|
||||||
|
ok('no raw markup from original_filename', !html.includes('<img src=x onerror'), html);
|
||||||
|
const imgs = tags(html, 'img');
|
||||||
|
ok('one <img>, only the template attributes',
|
||||||
|
imgs.length === 1 && names(imgs[0]).join(',') === 'src,alt,loading,decoding,class,onerror', imgs.map(names));
|
||||||
|
ok('alt carries the stored filename as text', attr(imgs[0], 'alt') === DQ, imgs[0]);
|
||||||
|
const buttons = tags(html, 'button');
|
||||||
|
ok('two buttons, no extra attributes',
|
||||||
|
buttons.length === 2 && buttons.every(b => names(b).join(',') === 'type,onclick,class,title,aria-label'),
|
||||||
|
buttons.map(names));
|
||||||
|
const del = runHandler(attr(buttons[1], 'onclick'));
|
||||||
|
ok('delete handler gets field, image and plugin ids intact',
|
||||||
|
del.length === 1 && del[0][0] === 'deleteUploadedImage' && del[0][1] === 'f' && del[0][2] === SQ && del[0][3] === SQ, del);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
@@ -315,3 +315,202 @@ class TestPluginVersionLookup:
|
|||||||
assert module._get_plugin_version("..") == ""
|
assert module._get_plugin_version("..") == ""
|
||||||
finally:
|
finally:
|
||||||
module.api_v3.plugin_store_manager = original
|
module.api_v3.plugin_store_manager = original
|
||||||
|
|
||||||
|
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 20
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginAssetRoutes:
|
||||||
|
"""/api/v3/plugins/assets/upload, /list and /delete
|
||||||
|
|
||||||
|
All three joined a request-supplied plugin_id straight onto
|
||||||
|
assets/plugins, so '../../config' created an uploads directory under
|
||||||
|
config/, wrote images and .metadata.json there, listed it, and deleted
|
||||||
|
whatever file a metadata entry's path named. #561 fixed only the route
|
||||||
|
that serves the uploaded files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def project(self, tmp_path, api_v3_module, monkeypatch):
|
||||||
|
import web_interface.blueprints.api_v3.plugins as plugins_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(plugins_module, "PROJECT_ROOT", tmp_path)
|
||||||
|
(tmp_path / "config").mkdir()
|
||||||
|
secrets = tmp_path / "config" / "config_secrets.json"
|
||||||
|
secrets.write_text('{"api_key":"hunter2"}', encoding="utf-8")
|
||||||
|
return tmp_path, secrets
|
||||||
|
|
||||||
|
def _upload(self, client, plugin_id):
|
||||||
|
import io
|
||||||
|
|
||||||
|
return client.post(
|
||||||
|
"/api/v3/plugins/assets/upload",
|
||||||
|
data={"plugin_id": plugin_id, "files": (io.BytesIO(PNG), "x.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _tree(self, root):
|
||||||
|
return sorted(str(p.relative_to(root)) for p in root.rglob("*"))
|
||||||
|
|
||||||
|
def test_an_ordinary_upload_list_and_delete_still_work(self, api_v3_client, project):
|
||||||
|
root, _ = project
|
||||||
|
response = self._upload(api_v3_client, "static-image")
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
uploaded = response.get_json()["uploaded_files"][0]
|
||||||
|
assert uploaded["path"].startswith("assets/plugins/static-image/uploads/")
|
||||||
|
stored = root / uploaded["path"]
|
||||||
|
assert stored.exists()
|
||||||
|
|
||||||
|
listed = api_v3_client.get(
|
||||||
|
"/api/v3/plugins/assets/list", query_string={"plugin_id": "static-image"}
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert [a["id"] for a in listed.get_json()["data"]["assets"]] == [uploaded["id"]]
|
||||||
|
|
||||||
|
deleted = api_v3_client.post(
|
||||||
|
"/api/v3/plugins/assets/delete",
|
||||||
|
json={"plugin_id": "static-image", "image_id": uploaded["id"]},
|
||||||
|
)
|
||||||
|
assert deleted.status_code == 200
|
||||||
|
assert not stored.exists()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("plugin_id", [
|
||||||
|
"../../config", "..", "a/b", "/abs", "x" + BACKSLASH + "y", "C:",
|
||||||
|
])
|
||||||
|
def test_a_traversing_upload_writes_nothing(self, api_v3_client, project, plugin_id):
|
||||||
|
root, _ = project
|
||||||
|
before = self._tree(root)
|
||||||
|
response = self._upload(api_v3_client, plugin_id)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert self._tree(root) == before
|
||||||
|
|
||||||
|
def test_a_traversing_list_reads_nothing(self, api_v3_client, project):
|
||||||
|
root, _ = project
|
||||||
|
outside = root / "config" / "uploads"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / ".metadata.json").write_text(
|
||||||
|
json.dumps({"i": {"id": "i", "path": "leaked"}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
response = api_v3_client.get(
|
||||||
|
"/api/v3/plugins/assets/list", query_string={"plugin_id": "../../config"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert b"leaked" not in response.data
|
||||||
|
|
||||||
|
def test_a_traversing_delete_deletes_nothing(self, api_v3_client, project):
|
||||||
|
root, secrets = project
|
||||||
|
outside = root / "config" / "uploads"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / ".metadata.json").write_text(
|
||||||
|
json.dumps({"i": {"id": "i", "path": "config/config_secrets.json"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
response = api_v3_client.post(
|
||||||
|
"/api/v3/plugins/assets/delete",
|
||||||
|
json={"plugin_id": "../../config", "image_id": "i"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert secrets.exists(), "file outside assets/plugins was deleted"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stored_path", [
|
||||||
|
"config/config_secrets.json",
|
||||||
|
"assets/plugins/static-image/uploads/../../../../config/config_secrets.json",
|
||||||
|
"assets/plugins/other/uploads/x.png",
|
||||||
|
None,
|
||||||
|
])
|
||||||
|
def test_a_metadata_path_outside_the_uploads_is_never_unlinked(
|
||||||
|
self, api_v3_client, project, stored_path
|
||||||
|
):
|
||||||
|
root, secrets = project
|
||||||
|
uploads = root / "assets" / "plugins" / "static-image" / "uploads"
|
||||||
|
uploads.mkdir(parents=True)
|
||||||
|
other = root / "assets" / "plugins" / "other" / "uploads"
|
||||||
|
other.mkdir(parents=True)
|
||||||
|
(other / "x.png").write_bytes(PNG)
|
||||||
|
(uploads / ".metadata.json").write_text(
|
||||||
|
json.dumps({"i": {"id": "i", "path": stored_path}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
response = api_v3_client.post(
|
||||||
|
"/api/v3/plugins/assets/delete",
|
||||||
|
json={"plugin_id": "static-image", "image_id": "i"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert secrets.exists(), "file named by tampered metadata was deleted"
|
||||||
|
assert (other / "x.png").exists(), "another plugin's upload was deleted"
|
||||||
|
# The bad entry is still dropped, so the UI can get rid of it.
|
||||||
|
assert json.loads((uploads / ".metadata.json").read_text(encoding="utf-8")) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginActionDirectory:
|
||||||
|
"""POST /api/v3/plugins/action runs a script named by the manifest in
|
||||||
|
get_plugin_directory(plugin_id), and plugin_id is the request body's.
|
||||||
|
get_plugin_directory joined it onto plugins_dir unchecked, so
|
||||||
|
'../elsewhere' ran a script from any directory holding a manifest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tree(self, tmp_path):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from src.plugin_system.plugin_manager import PluginManager
|
||||||
|
|
||||||
|
plugins = tmp_path / "plugin-repos"
|
||||||
|
(plugins / "demo").mkdir(parents=True)
|
||||||
|
(plugins / "ledmatrix-legacy").mkdir()
|
||||||
|
outside = tmp_path / "elsewhere"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "manifest.json").write_text(json.dumps({
|
||||||
|
"web_ui_actions": [{"id": "go", "type": "script", "script": "s.py"}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
marker = tmp_path / "PWNED"
|
||||||
|
(outside / "s.py").write_text(
|
||||||
|
"open(%r, 'w').write('ran')\n" % str(marker), encoding="utf-8"
|
||||||
|
)
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.plugins_dir = plugins
|
||||||
|
manager._discovery_lock = threading.Lock()
|
||||||
|
manager.plugin_directories = {}
|
||||||
|
manager.get_plugin_directory = (
|
||||||
|
lambda pid: PluginManager.get_plugin_directory(manager, pid)
|
||||||
|
)
|
||||||
|
return manager, plugins, marker
|
||||||
|
|
||||||
|
def test_real_plugin_directories_are_still_found(self, tree):
|
||||||
|
manager, plugins, _ = tree
|
||||||
|
assert manager.get_plugin_directory("demo") == str(plugins / "demo")
|
||||||
|
assert manager.get_plugin_directory("legacy") == str(plugins / "ledmatrix-legacy")
|
||||||
|
assert manager.get_plugin_directory("nope") is None
|
||||||
|
|
||||||
|
def test_a_discovered_plugin_is_returned_from_the_registry(self, tree, tmp_path):
|
||||||
|
manager, _, _ = tree
|
||||||
|
manager.plugin_directories = {"linked": tmp_path / "somewhere"}
|
||||||
|
assert manager.get_plugin_directory("linked") == str(tmp_path / "somewhere")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("plugin_id", [
|
||||||
|
"..", "../elsewhere", "a/b", "/abs", "x" + BACKSLASH + "..", "",
|
||||||
|
])
|
||||||
|
def test_get_plugin_directory_refuses_anything_but_a_plain_name(self, tree, plugin_id):
|
||||||
|
manager, _, _ = tree
|
||||||
|
assert manager.get_plugin_directory(plugin_id) is None
|
||||||
|
|
||||||
|
def test_the_action_endpoint_runs_nothing_outside_the_plugins_dir(
|
||||||
|
self, api_v3_client, api_v3_module, tree
|
||||||
|
):
|
||||||
|
manager, _, marker = tree
|
||||||
|
api_v3_module.api_v3.plugin_manager = manager
|
||||||
|
response = api_v3_client.post(
|
||||||
|
"/api/v3/plugins/action",
|
||||||
|
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert not marker.exists(), "script outside the plugins directory ran"
|
||||||
|
|
||||||
|
def test_the_fallback_without_a_plugin_manager_is_guarded_too(
|
||||||
|
self, api_v3_client, api_v3_module
|
||||||
|
):
|
||||||
|
api_v3_module.api_v3.plugin_manager = None
|
||||||
|
response = api_v3_client.post(
|
||||||
|
"/api/v3/plugins/action",
|
||||||
|
json={"plugin_id": "../elsewhere", "action_id": "go", "params": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|||||||
@@ -2832,6 +2832,12 @@ def execute_plugin_action():
|
|||||||
'received': {'plugin_id': plugin_id, 'action_id': action_id, 'has_params': bool(action_params)}
|
'received': {'plugin_id': plugin_id, 'action_id': action_id, 'has_params': bool(action_params)}
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# plugin_id comes from the request body and picks the directory whose
|
||||||
|
# manifest names the script to run, so it must be a plain name.
|
||||||
|
plugin_id = safe_path_component(plugin_id)
|
||||||
|
if plugin_id is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
||||||
|
|
||||||
# Get plugin directory
|
# Get plugin directory
|
||||||
if api_v3.plugin_manager:
|
if api_v3.plugin_manager:
|
||||||
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
plugin_dir = api_v3.plugin_manager.get_plugin_directory(plugin_id)
|
||||||
@@ -3303,6 +3309,15 @@ def authenticate_ytm():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error('Error in authenticate_ytm', exc_info=True)
|
logger.error('Error in authenticate_ytm', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||||
|
def _plugin_uploads_dir(plugin_id):
|
||||||
|
"""assets/plugins/<plugin_id>/uploads for a request-supplied id, or None.
|
||||||
|
|
||||||
|
Same guard as the serving route in app.py: one plain path segment,
|
||||||
|
resolved and contained under assets/plugins.
|
||||||
|
"""
|
||||||
|
return resolve_under(PROJECT_ROOT / 'assets' / 'plugins', plugin_id, 'uploads')
|
||||||
|
|
||||||
|
|
||||||
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
@api_v3.route('/plugins/assets/upload', methods=['POST'])
|
||||||
def upload_plugin_asset():
|
def upload_plugin_asset():
|
||||||
"""Upload asset files for a plugin"""
|
"""Upload asset files for a plugin"""
|
||||||
@@ -3322,8 +3337,13 @@ def upload_plugin_asset():
|
|||||||
if len(files) > 10:
|
if len(files) > 10:
|
||||||
return jsonify({'status': 'error', 'message': 'Maximum 10 files per upload'}), 400
|
return jsonify({'status': 'error', 'message': 'Maximum 10 files per upload'}), 400
|
||||||
|
|
||||||
# Setup plugin assets directory
|
# Setup plugin assets directory. plugin_id is a form field: without
|
||||||
assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads'
|
# the guard '../../config' created, listed and wrote into directories
|
||||||
|
# outside assets/plugins (the serving route was fixed in #561).
|
||||||
|
assets_dir = _plugin_uploads_dir(plugin_id)
|
||||||
|
if assets_dir is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
||||||
|
plugin_id = safe_path_component(plugin_id)
|
||||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Load metadata file
|
# Load metadata file
|
||||||
@@ -3958,7 +3978,9 @@ def delete_plugin_asset():
|
|||||||
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
|
return jsonify({'status': 'error', 'message': 'plugin_id and image_id are required'}), 400
|
||||||
|
|
||||||
# Get asset directory
|
# Get asset directory
|
||||||
assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads'
|
assets_dir = _plugin_uploads_dir(plugin_id)
|
||||||
|
if assets_dir is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
||||||
metadata_file = assets_dir / '.metadata.json'
|
metadata_file = assets_dir / '.metadata.json'
|
||||||
|
|
||||||
if not metadata_file.exists():
|
if not metadata_file.exists():
|
||||||
@@ -3971,9 +3993,17 @@ def delete_plugin_asset():
|
|||||||
if image_id not in metadata:
|
if image_id not in metadata:
|
||||||
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
# Delete file
|
# Delete file. The stored path is data, not a trusted location: only
|
||||||
file_path = PROJECT_ROOT / metadata[image_id]['path']
|
# unlink it when it resolves to a file directly inside this plugin's
|
||||||
if file_path.exists():
|
# uploads. An entry pointing anywhere else is dropped from the
|
||||||
|
# metadata without touching the file it names.
|
||||||
|
entry = metadata[image_id] if isinstance(metadata[image_id], dict) else {}
|
||||||
|
parts = safe_relative_parts(entry.get('path'))
|
||||||
|
file_path = resolve_under(PROJECT_ROOT, *parts) if parts else None
|
||||||
|
if file_path is None or file_path.parent != assets_dir:
|
||||||
|
logger.warning('Asset %s has a path outside its uploads directory; '
|
||||||
|
'removing the entry without deleting a file', image_id)
|
||||||
|
elif file_path.exists():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
|
|
||||||
# Remove from metadata
|
# Remove from metadata
|
||||||
@@ -3997,7 +4027,9 @@ def list_plugin_assets():
|
|||||||
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
|
return jsonify({'status': 'error', 'message': 'plugin_id is required'}), 400
|
||||||
|
|
||||||
# Get asset directory
|
# Get asset directory
|
||||||
assets_dir = PROJECT_ROOT / 'assets' / 'plugins' / plugin_id / 'uploads'
|
assets_dir = _plugin_uploads_dir(plugin_id)
|
||||||
|
if assets_dir is None:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid plugin_id'}), 400
|
||||||
metadata_file = assets_dir / '.metadata.json'
|
metadata_file = assets_dir / '.metadata.json'
|
||||||
|
|
||||||
if not metadata_file.exists():
|
if not metadata_file.exists():
|
||||||
|
|||||||
@@ -3893,13 +3893,15 @@ function renderPluginStore(plugins) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to escape for JavaScript strings
|
// JS string literal for an inline handler; see jsStringAttr
|
||||||
const escapeJs = (text) => {
|
const escapeJs = (text) => {
|
||||||
return JSON.stringify(text || '');
|
return jsStringAttr(text || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
setGridHtmlIfChanged(container, plugins.map(plugin => {
|
setGridHtmlIfChanged(container, plugins.map(plugin => {
|
||||||
const installed = isStorePluginInstalled(plugin);
|
const installed = isStorePluginInstalled(plugin);
|
||||||
|
// Registry data: only open real web links, never javascript: URLs.
|
||||||
|
const repoLink = plugin.repo && /^https?:\/\//i.test(plugin.repo) ? plugin.repo : '';
|
||||||
return `
|
return `
|
||||||
<div class="plugin-card">
|
<div class="plugin-card">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
@@ -3941,7 +3943,7 @@ function renderPluginStore(plugins) {
|
|||||||
<button onclick='if(window.installPlugin){const branchInput = document.getElementById("branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"); window.installPlugin(${escapeJs(plugin.id)}, branchInput?.value?.trim() || null)}else{console.error("installPlugin not available")}' class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
<button onclick='if(window.installPlugin){const branchInput = document.getElementById("branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"); window.installPlugin(${escapeJs(plugin.id)}, branchInput?.value?.trim() || null)}else{console.error("installPlugin not available")}' class="btn ${installed ? 'bg-gray-500 hover:bg-gray-600' : 'bg-green-600 hover:bg-green-700'} text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
||||||
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
|
<i class="fas ${installed ? 'fa-redo' : 'fa-download'} mr-2"></i>${installed ? 'Reinstall' : 'Install'}
|
||||||
</button>
|
</button>
|
||||||
<button onclick='${plugin.repo ? `window.open(${escapeJs(plugin.plugin_path ? plugin.repo + "/tree/" + encodeURIComponent(plugin.default_branch || plugin.branch || "main") + "/" + plugin.plugin_path.split("/").map(encodeURIComponent).join("/") : plugin.repo)}, "_blank")` : `void(0)`}' ${plugin.repo ? '' : 'disabled'} class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold${plugin.repo ? '' : ' opacity-50 cursor-not-allowed'}">
|
<button onclick='${repoLink ? `window.open(${escapeJs(plugin.plugin_path ? repoLink + "/tree/" + encodeURIComponent(plugin.default_branch || plugin.branch || "main") + "/" + plugin.plugin_path.split("/").map(encodeURIComponent).join("/") : repoLink)}, "_blank")` : `void(0)`}' ${repoLink ? '' : 'disabled'} class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold${repoLink ? '' : ' opacity-50 cursor-not-allowed'}">
|
||||||
<i class="fas fa-external-link-alt mr-2"></i>View
|
<i class="fas fa-external-link-alt mr-2"></i>View
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -4102,9 +4104,9 @@ function renderSavedRepositories(repositories) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to escape for JavaScript strings
|
// JS string literal for an inline handler; see jsStringAttr
|
||||||
const escapeJs = (text) => {
|
const escapeJs = (text) => {
|
||||||
return JSON.stringify(text || '');
|
return jsStringAttr(text || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
container.innerHTML = repositories.map(repo => {
|
container.innerHTML = repositories.map(repo => {
|
||||||
@@ -4498,9 +4500,9 @@ function renderCustomRegistryPlugins(plugins, registryUrl) {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to escape for JavaScript strings
|
// JS string literal for an inline handler; see jsStringAttr
|
||||||
const escapeJs = (text) => {
|
const escapeJs = (text) => {
|
||||||
return JSON.stringify(text || '');
|
return jsStringAttr(text || '');
|
||||||
};
|
};
|
||||||
|
|
||||||
container.innerHTML = plugins.map(plugin => {
|
container.innerHTML = plugins.map(plugin => {
|
||||||
@@ -4617,6 +4619,15 @@ function escapeAttribute(text) {
|
|||||||
.replace(/>/g, '>');
|
.replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A quoted JS string literal that is safe inside an inline handler attribute
|
||||||
|
// (onclick='f(${jsStringAttr(id)})' or onclick="..."). JSON.stringify alone
|
||||||
|
// makes a valid JS string but leaves ' and & untouched, so a registry entry
|
||||||
|
// id containing ' closed a single-quoted attribute and added its own
|
||||||
|
// handlers. The browser decodes the entities before the JS is parsed.
|
||||||
|
function jsStringAttr(value) {
|
||||||
|
return escapeAttribute(JSON.stringify(value == null ? '' : String(value)));
|
||||||
|
}
|
||||||
|
|
||||||
// Format date for display
|
// Format date for display
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
if (!dateString) return 'Unknown';
|
if (!dateString) return 'Unknown';
|
||||||
@@ -5104,11 +5115,11 @@ window.updateImageList = function(fieldId, images) {
|
|||||||
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
|
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div id="img_${img.id || idx}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
<div id="img_${escapeAttribute(img.id || idx)}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center space-x-3 flex-1">
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
<img src="/${img.path || ''}"
|
<img src="/${escapeAttribute(String(img.path || '').replace(/^\/+/, ''))}"
|
||||||
alt="${img.filename || ''}"
|
alt="${escapeAttribute(img.filename || '')}"
|
||||||
loading="lazy" decoding="async"
|
loading="lazy" decoding="async"
|
||||||
class="w-16 h-16 object-cover rounded"
|
class="w-16 h-16 object-cover rounded"
|
||||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||||
@@ -5116,7 +5127,7 @@ window.updateImageList = function(fieldId, images) {
|
|||||||
<i class="fas fa-image text-gray-400"></i>
|
<i class="fas fa-image text-gray-400"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-sm font-medium text-gray-900 truncate">${img.original_filename || img.filename || 'Image'}</p>
|
<p class="text-sm font-medium text-gray-900 truncate">${escapeHtml(img.original_filename || img.filename || 'Image')}</p>
|
||||||
<p class="text-xs text-gray-500">${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}</p>
|
<p class="text-xs text-gray-500">${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}</p>
|
||||||
<p class="text-xs text-blue-600 mt-1">
|
<p class="text-xs text-blue-600 mt-1">
|
||||||
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
||||||
@@ -5125,14 +5136,14 @@ window.updateImageList = function(fieldId, images) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
<div class="flex items-center space-x-2 ml-4">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
|
onclick="window.openImageSchedule(${jsStringAttr(fieldId)}, ${jsStringAttr(img.id)}, ${idx})"
|
||||||
class="text-blue-600 hover:text-blue-800 p-2"
|
class="text-blue-600 hover:text-blue-800 p-2"
|
||||||
title="Schedule this image"
|
title="Schedule this image"
|
||||||
aria-label="Schedule image ${escapeAttribute(img.original_filename || img.filename || '')}">
|
aria-label="Schedule image ${escapeAttribute(img.original_filename || img.filename || '')}">
|
||||||
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
|
<i class="fas fa-calendar-alt" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${pluginId}')"
|
onclick="window.deleteUploadedImage(${jsStringAttr(fieldId)}, ${jsStringAttr(img.id)}, ${jsStringAttr(pluginId)})"
|
||||||
class="text-red-600 hover:text-red-800 p-2"
|
class="text-red-600 hover:text-red-800 p-2"
|
||||||
title="Delete image"
|
title="Delete image"
|
||||||
aria-label="Delete image ${escapeAttribute(img.original_filename || img.filename || '')}">
|
aria-label="Delete image ${escapeAttribute(img.original_filename || img.filename || '')}">
|
||||||
@@ -5141,7 +5152,7 @@ window.updateImageList = function(fieldId, images) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Schedule widget will be inserted here when opened -->
|
<!-- Schedule widget will be inserted here when opened -->
|
||||||
<div id="schedule_${img.id || idx}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
<div id="schedule_${escapeAttribute(img.id || idx)}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
Reference in New Issue
Block a user