diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index dd3019c..50d62b2 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -484,6 +484,22 @@ class AutotagRoutesMixin: if not raw_path or not isinstance(raw_path, str): return None, None, self._bad_request("image_id or path is required") + if "root_index" in data: + root_index = data["root_index"] + if isinstance(root_index, bool) or not isinstance(root_index, int): + return None, None, self._bad_request("root_index must be an integer") + roots = self._get_all_output_dirs() + if not 0 <= root_index < len(roots): + return None, None, self._forbidden(raw_path) + root = roots[root_index] + if Path(raw_path).is_absolute() or ".." in raw_path.replace( + "\\", "/" + ).split("/"): + return None, None, self._forbidden(raw_path) + raw_path = str(Path(root) / raw_path) + if not path_is_within(raw_path, root): + return None, None, self._forbidden(raw_path) + resolved = self._resolve_allowed_image_path(raw_path) if resolved is None: return None, None, self._forbidden(raw_path) diff --git a/tests/js/gallery-review.test.js b/tests/js/gallery-review.test.js new file mode 100644 index 0000000..96a2600 --- /dev/null +++ b/tests/js/gallery-review.test.js @@ -0,0 +1,88 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ImageHelpers = require('../../web/js/image-helpers.js'); +function deferred() { + let resolve; + const promise = new Promise(yes => { resolve = yes; }); + return { promise, resolve }; +} +function fixture() { + const elements = new Map(); + const requests = []; + const document = { addEventListener() {}, getElementById(id) { + if (!elements.has(id)) elements.set(id, {}); + return elements.get(id); + } }; + const context = vm.createContext({ document, window: {}, ImageHelpers, + escapeHtml: value => value, console: { error() {} }, + ApiClient: { resolveUrl: value => value, fetchUrl: (url, options) => { + const request = deferred(); + requests.push({ url, body: JSON.parse(options.body), ...request }); + return request.promise; + } }, + }); + const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context); + const manager = Object.create(Manager.prototype); + const notifications = []; + Object.assign(manager, { autoTagState: { reviewImages: [ + { id: 'digest', path: 'same.png', root_index: 1, url: '/same.png' }, + { id: 'other', path: 'next.png', root_index: 0, url: '/next.png' }, + ], reviewIndex: 0, currentTags: [], modelType: 'gguf' }, + renderReviewTags() {}, showNotification: (...args) => notifications.push(args), hideModal() {}, loadImages() {}, + }); + const reply = (index, data) => requests[index].resolve({ json: async () => data }); + return { manager, requests, reply, notifications }; +} +test('gallery review retains root identity and applies tags with the returned prompt ID', async () => { + const { manager, requests, reply } = fixture(); + const load = manager.loadNextReviewImage(); + assert.equal(requests[0].body.root_index, 1); + reply(0, { success: true, prompt_id: 42, tags: ['smile'] }); + await load; + manager.loadNextReviewImage = () => {}; + const apply = manager.applyReviewTags(); + assert.deepEqual(requests[1].body, { prompt_id: 42, tags: ['smile'] }); + reply(1, { success: true }); + await apply; +}); +test('unlinked images do not submit an invalid apply request or advance', async () => { + const { manager, requests, reply } = fixture(); + const load = manager.loadNextReviewImage(); + reply(0, { success: true, prompt_id: null, tags: ['smile'] }); + await load; + const apply = manager.applyReviewTags(); + if (requests[1]) reply(1, { success: false, error: "Unexpected apply" }); + await apply; + assert.equal(requests.length, 1); + assert.equal(manager.autoTagState.reviewIndex, 0); +}); +test('a failed apply keeps the current image available for retry', async () => { + const { manager, requests, reply } = fixture(); + const load = manager.loadNextReviewImage(); + reply(0, { success: true, prompt_id: 42, tags: ['smile'] }); + await load; + const apply = manager.applyReviewTags(); + reply(1, { success: false, error: 'Try again' }); + await apply; + assert.equal(manager.autoTagState.reviewIndex, 0); + assert.equal(requests.length, 2); +}); +test('late generation responses cannot restore tags from a skipped or closed image', async () => { + const { manager, reply } = fixture(); + const first = manager.loadNextReviewImage(); + manager.autoTagState.reviewIndex = 1; + const second = manager.loadNextReviewImage(); + reply(1, { success: true, prompt_id: 2, tags: ['current'] }); + await second; + reply(0, { success: true, prompt_id: 1, tags: ['stale'] }); + await first; + assert.deepEqual(Array.from(manager.autoTagState.currentTags), ['current']); + const third = manager.loadNextReviewImage(); + manager.cancelReview(); + reply(2, { success: true, prompt_id: 2, tags: ['closed'] }); + await third; + assert.equal(manager.autoTagState.currentTags.length, 0); +}); diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index 1259e65..e0558e8 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -1049,3 +1049,70 @@ class TestScanOutputDir(AutotagAPITestCase): if __name__ == "__main__": unittest.main() + + +class TestGalleryReviewContract(AutotagAPITestCase): + async def test_root_relative_gallery_targets_generate_and_apply_to_their_prompt( + self, + ): + second = self.tmp_root / "second-output" + second.mkdir() + roots = [self.output_dir, second] + self.api._get_all_output_dirs = lambda: roots + for index, root in enumerate(roots): + pid = self._save_prompt(f"root {index}") + self._link_image(pid, self._make_image(root, "same.png")) + response = await self._post_json( + "/prompt_manager/autotag/single", + {"path": "same.png", "root_index": index, "model_type": "gguf"}, + ) + self.assertEqual(response.status, 200) + data = await response.json() + self.assertEqual(data["prompt_id"], pid) + self.assertEqual(self.service.generate_calls[-1][0], str(root / "same.png")) + response = await self._post_json( + "/prompt_manager/autotag/apply", + {"prompt_id": data["prompt_id"], "tags": data["tags"]}, + ) + self.assertEqual(response.status, 200) + self.assertEqual( + set(self.api.db.get_prompt_by_id(pid)["tags"]), set(data["tags"]) + ) + + async def test_gallery_unlinked_image_returns_no_prompt_id(self): + self._make_image(self.output_dir) + response = await self._post_json( + "/prompt_manager/autotag/single", {"path": "img.png", "root_index": 0} + ) + self.assertEqual(response.status, 200) + self.assertIsNone((await response.json())["prompt_id"]) + + async def test_gallery_root_selection_rejects_invalid_roots_and_escapes(self): + self._make_image(self.output_dir) + for root, path, expected in [ + (9, "img.png", 403), + (-1, "img.png", 403), + (True, "img.png", 400), + ([], "img.png", 400), + (0, "../secret/img.png", 403), + (0, str(self.secret_dir / "img.png"), 403), + ]: + with self.subTest(root=root, path=path): + response = await self._post_json( + "/prompt_manager/autotag/single", {"path": path, "root_index": root} + ) + self.assertEqual(response.status, expected) + + async def test_gallery_root_selection_rejects_symlink_into_another_root(self): + second = self.tmp_root / "second-output" + second.mkdir() + target = self._make_image(second) + try: + (self.output_dir / "link.png").symlink_to(target) + except (OSError, NotImplementedError): + self.skipTest("symlinks unavailable") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + response = await self._post_json( + "/prompt_manager/autotag/single", {"path": "link.png", "root_index": 0} + ) + self.assertEqual(response.status, 403) diff --git a/web/js/gallery.js b/web/js/gallery.js index 78deff6..c94e736 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -3542,6 +3542,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } async loadNextReviewImage() { + const request = {}; + this.reviewRequest = request; + this.autoTagState.currentTags = []; + this.autoTagState.reviewPromptId = null; if (this.autoTagState.reviewIndex >= this.autoTagState.reviewImages.length) { this.hideModal("autoTagReviewModal"); this.showNotification('Review complete!', 'success'); @@ -3567,7 +3571,9 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; const data = await response.json(); + if (this.reviewRequest !== request) return; if (data.success) { + this.autoTagState.reviewPromptId = data.prompt_id ?? null; this.autoTagState.currentTags = data.tags; this.renderReviewTags(); } else { @@ -3575,6 +3581,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; `
Error: ${escapeHtml(data.error)}
`; } } catch (error) { + if (this.reviewRequest !== request) return; console.error('Error generating tags:', error); document.getElementById('reviewTagsContainer').innerHTML = '
Failed to generate tags
'; @@ -3612,28 +3619,41 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; return; } - const image = this.autoTagState.reviewImages[this.autoTagState.reviewIndex]; + const promptId = this.autoTagState.reviewPromptId; + if (!promptId) { + this.showNotification('This image has no linked prompt. Scan the output folder first.', 'warning'); + return; + } + const request = this.reviewRequest; + if (this.reviewApplying === request) return; + this.reviewApplying = request; try { const response = await ApiClient.fetchUrl('/prompt_manager/autotag/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - image_path: image.path, + prompt_id: promptId, tags: this.autoTagState.currentTags }) }); const data = await response.json(); + if (this.reviewRequest !== request) return; if (data.success) { this.showNotification(`Applied ${this.autoTagState.currentTags.length} tags`, 'success'); } else { this.showNotification(data.error || 'Failed to apply tags', 'warning'); + return; } } catch (error) { + if (this.reviewRequest !== request) return; console.error('Error applying tags:', error); this.showNotification('Failed to apply tags', 'error'); + return; + } finally { + if (this.reviewApplying === request) this.reviewApplying = null; } this.autoTagState.reviewIndex++; @@ -3641,6 +3661,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } cancelReview() { + this.reviewRequest = null; + this.autoTagState.reviewPromptId = null; this.autoTagState.reviewImages = []; this.autoTagState.reviewIndex = 0; this.autoTagState.currentTags = []; diff --git a/web/js/image-helpers.js b/web/js/image-helpers.js index 70f31ee..fc019d7 100644 --- a/web/js/image-helpers.js +++ b/web/js/image-helpers.js @@ -79,6 +79,9 @@ typeof entry.path === "string" && entry.path && !Number.isInteger(entry.id) ? { path: entry.path } : { image_id: entry.id }; + if ("path" in target && Number.isInteger(entry.root_index)) { + target.root_index = entry.root_index; + } const modelType = String(settings.modelType || ""); const params = modelType.startsWith("wd14") ? {