Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 59cca6b915 fix(upload): canonicalize confinement on symlinked upload roots 2026-10-29 02:02:47 +00:00
Alexandre Teixeira 571ac09cea fix(security): sanitize file_id and enforce strict prefix confinement on upload paths 2026-10-29 01:39:41 +00:00
Alexandre Teixeira f979e5602f fix(upload,sw): sanitize and confine upload paths and align service worker precache token 2026-10-29 01:35:07 +00:00
Alexandre Teixeira b3e2e628ab fix(upload,ui): confine thumbnail paths and align stylesheet cache-bust token 2026-10-29 01:25:07 +00:00
Alexandre Teixeira f843a3dc01 merge(dev): integrate latest upstream dev (PR #6498 omit temperature from subscription requests) 2026-10-29 01:12:31 +00:00
Alexandre Teixeira 72ba37af6b merge(dev): integrate reviewed preview UI changes 2026-10-29 01:12:08 +00:00
pewdiepie-archdaemon da8ba69c74 Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 2c48e7fdab Verify the Gallery model picker in the UI review that ships it 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 76ab72412d Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 04ca6c26c3 Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon fdb2a330d9 Remove duplicate session copy-header helpers after integration 2026-10-26 16:30:56 +00:00
pewdiepie-archdaemon e461a6b5c5 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon 22144050fe Update canonical Notes modules alongside their deployment files 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon ace6be44f3 Merge current dev and refresh PR #6740 from the 7011 integration 2026-10-26 16:21:34 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
47 changed files with 2893 additions and 295 deletions
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation();
tapToEditOrSelect(card);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
+92 -46
View File
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
from src.owner_identity import auth_disabled, is_request_sentinel_owner
from src.attachment_refs import attachment_refs_from_metadata
from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import is_inside
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
from src.upload_handler import (
UploadCleanupSafetyError,
count_recent_uploads,
extract_upload_ids,
secure_filename,
)
logger = logging.getLogger(__name__)
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
def _upload_root() -> str:
from src.constants import UPLOAD_DIR
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool:
return is_inside(_upload_root(), path)
def _resolve_upload_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
direct = os.path.join(upload_root, file_id)
safe_id = secure_filename(os.path.basename(file_id))
if not safe_id:
raise HTTPException(400, "Invalid file ID")
root = _upload_root()
direct = os.path.normpath(os.path.join(root, safe_id))
if os.path.lexists(direct):
if not _path_inside_upload_dir(direct):
try:
canonical_direct = confine(root, direct, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(direct):
return direct
if os.path.isfile(canonical_direct):
return canonical_direct
raise HTTPException(404, "File not found")
for root, _dirs, files in os.walk(upload_root, followlinks=False):
if file_id not in files:
continue
path = os.path.join(root, file_id)
if not _path_inside_upload_dir(path):
info = _load_upload_info(safe_id)
stored_path = (info or {}).get("path")
if stored_path:
try:
canonical_stored = confine(root, stored_path, allow_root=False)
if os.path.isfile(canonical_stored):
return canonical_stored
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(path):
return path
for walk_root, _dirs, files in os.walk(root, followlinks=False):
if safe_id not in files:
continue
cand = os.path.normpath(os.path.join(walk_root, safe_id))
try:
canonical_cand = confine(root, cand, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(canonical_cand):
return canonical_cand
raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found")
@@ -435,52 +453,79 @@ def setup_upload_routes(upload_handler):
logger.error(f"Failed to get upload stats: {e}")
raise HTTPException(500, "Failed to get upload statistics")
@router.get("/{file_id}")
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
async def download_file(request: Request, file_id: str, thumb: int = 0):
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
JPEG thumbnail for images (used by chat attachment previews) so the
client isn't downloading the full-resolution photo just to show it tiny."""
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
a small cached JPEG thumbnail for images (used by chat attachment previews)
so the client isn't downloading the full-resolution photo just to show it tiny."""
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
safe_file_id = secure_filename(os.path.basename(file_id))
import mimetypes as _mt
# Look up original filename and owner from uploads.json
original_name = file_id
original_name = safe_file_id
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
# back to the .bak sibling, then to {}), so a truncated DB degrades to
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
db = upload_handler._load_upload_index()
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
if info:
original_name = info.get("name", file_id)
original_name = info.get("name", safe_file_id)
file_owner = info.get("owner") if info else None
_verify_upload_access(request, file_owner)
path = _resolve_upload_path(file_id)
upload_root = _upload_root()
path = _resolve_upload_path(safe_file_id)
try:
path = confine(upload_root, path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
from fastapi.responses import FileResponse
# Downscaled thumbnail for image previews — generated once and cached.
# Skip generation during HEAD probe to avoid loading/processing whole image.
if thumb and mime.startswith("image/"):
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
try:
from PIL import Image, ImageOps
thumb_dir = os.path.join(_upload_root(), ".thumbs")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
# iPhone / camera JPEGs encode rotation in EXIF rather than
# the pixel data. Browsers honour that on the original via
# image-orientation:from-image, but PIL strips EXIF when it
# saves the JPEG thumb, leaving the pixels sideways. Bake
# the rotation into the pixels before thumbnailing.
im = ImageOps.exif_transpose(im)
im.thumbnail((320, 320))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
thumb_path = confine(upload_root, thumb_path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
raise HTTPException(403, "Access denied")
if request.method == "HEAD":
if (os.path.isfile(thumb_path)
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
# GET may generate a JPEG or fall back to the original on failure.
# Neither representation is known without decoding the image. HEAD
# must not advertise the original/stale file's headers as a thumb.
from fastapi.responses import Response
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
del response.headers["content-length"]
return response
else:
try:
from PIL import Image, ImageOps
os.makedirs(thumb_dir, exist_ok=True)
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
# iPhone / camera JPEGs encode rotation in EXIF rather than
# the pixel data. Browsers honour that on the original via
# image-orientation:from-image, but PIL strips EXIF when it
# saves the JPEG thumb, leaving the pixels sideways. Bake
# the rotation into the pixels before thumbnailing.
im = ImageOps.exif_transpose(im)
im.thumbnail((320, 320))
if im.mode not in ("RGB", "L"):
im = im.convert("RGB")
im.save(thumb_path, "JPEG", quality=80)
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
except Exception as e:
logger.warning(f"Thumbnail generation failed for {file_id}: {e}")
# Fall through to the full image.
return FileResponse(
path,
media_type=mime,
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
def _vision_cache_path(file_id: str) -> str:
cache_dir = os.path.join(_upload_root(), ".vision")
safe_id = secure_filename(os.path.basename(file_id))
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, file_id + ".txt")
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
"""Copy upload OCR/vision text onto the promoted gallery image row."""
+33
View File
@@ -7256,6 +7256,39 @@ body.bg-pattern-ascii-fireflies {
text-align: center;
}
/* Inline assistant markdown images (`![alt](url)`) — rendered by
static/js/markdown.js as a.clickable <a class="chat-image"> wrapper. */
a.chat-image {
display: inline-block;
max-width: 100%;
margin: 0.35rem 0;
}
a.chat-image img.chat-inline-image {
display: block;
max-width: 100%;
height: auto;
border-radius: 8px;
cursor: zoom-in;
}
a.chat-image .chat-image-fallback {
display: none;
font-size: 0.8rem;
color: var(--color-muted, #94a3b8);
word-break: break-all;
padding: 0.5rem 0.75rem;
border: 1px dashed var(--border, #475569);
border-radius: 8px;
background: var(--panel, #202428);
}
a.chat-image.image-broken img.chat-inline-image {
display: none;
}
a.chat-image.image-broken .chat-image-fallback {
display: block;
text-decoration: underline;
cursor: pointer;
}
/* Setup wizard */
.setup-wizard { padding: 16px; max-width: 500px; }
.setup-wizard .setup-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 12px; color: var(--fg); }
+14
View File
@@ -22,6 +22,20 @@
/* Session items */
.session-item { position: relative; }
/* Title fade: the server auto-renames a chat shortly after its first
prompt, which re-renders the sidebar. Fade the new title in so the
change reads as smooth rather than a snap. */
.session-item .grow.session-title-updated {
animation: sessionTitleFade 0.5s ease;
}
@keyframes sessionTitleFade {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.session-item .grow.session-title-updated { animation: none; }
}
.text-ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-menu-btn { padding: 0 2px !important; min-width: 20px; height: 20px; display: inline-flex !important; align-items: center; justify-content: center; background: none !important; border-color: transparent !important; }
.session-menu-btn:hover { background: none !important; border-color: transparent !important; }
+1
View File
@@ -704,6 +704,7 @@ textarea.memory-add-input {
#serve-bulk-bar,
#gallery-bulk-bar,
#gallery-editor-drafts-bulk,
#notes-bulk-bar,
#memory-modal .memory-bulk-bar {
padding-right: 18px;
}
+1
View File
@@ -664,6 +664,7 @@
<div class="theme-fd-group">
<label class="theme-fd-label">Font</label>
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
<option value="gohu">GohuFont</option>
<option value="mono">Monospace</option>
<option value="sans">Sans-serif</option>
<option value="serif">Serif</option>
+61 -1
View File
@@ -18,7 +18,13 @@
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
var ROW_SELECTOR = [
'#sidebar .list-item',
'#user-bar-profile',
'.sidebar-brand',
'.export-dropdown-item',
'.dropdown-item'
].join(',');
// Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid
@@ -55,6 +61,31 @@
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
}
// Settings form labels are written as <label class="settings-label">Text</label>
// with the control as a sibling/descendant, but are not associated via for/id,
// so screen readers cannot name the inputs. Associate each label with its
// control when the mapping is unambiguous (a single form control per row).
var settingsLabelSeq = 0;
function associateSettingsLabels(root) {
(root || document).querySelectorAll('.settings-label').forEach(function (label) {
if (label.hasAttribute('for')) return;
var control = null;
var sib = label.nextElementSibling;
if (sib && /^(INPUT|SELECT|TEXTAREA)$/.test(sib.tagName)) {
control = sib;
} else {
var row = label.closest('.settings-row') || label.parentElement;
if (row) {
var controls = row.querySelectorAll('input,select,textarea');
if (controls.length === 1) control = controls[0];
}
}
if (!control || control.closest('label')) return;
if (!control.id) control.id = 'a11y-settings-' + (++settingsLabelSeq);
if (!control.getAttribute('aria-label')) label.setAttribute('for', control.id);
});
}
// ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
@@ -118,9 +149,38 @@
el.click();
});
// Disclosure buttons: keep aria-expanded in sync with their controlled
// menu/dropdown, regardless of which module toggles its visibility.
var DISCLOSURE_MAP = [
{ button: 'session-sort-btn', menu: 'session-sort-dropdown' },
{ button: 'export-dl-btn', menu: 'export-dropdown-menu' },
{ button: 'overflow-plus-btn', menu: 'overflow-menu' }
];
function syncDisclosures() {
DISCLOSURE_MAP.forEach(function (d) {
var btn = document.getElementById(d.button);
if (!btn) return;
var menu = document.getElementById(d.menu);
var open = false;
if (menu) {
var cs = window.getComputedStyle(menu);
open = cs.display !== 'none' && menu.offsetWidth > 0;
}
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
});
}
function init() {
enhanceAll(document);
enhanceModals(document);
associateSettingsLabels(document);
// Any click (the dropdown toggles, outside-click closes, etc.) may change
// a disclosure's open state; re-sync on the next tick.
document.addEventListener('click', function () {
if (window.setTimeout) setTimeout(syncDisclosures, 0);
});
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
+7 -5
View File
@@ -763,7 +763,7 @@ function renderFeatherlessPanel(panel, ep, row) {
const sortedIds = Array.from(enabledSet).sort((a, b) => a.localeCompare(b));
enabledListEl.innerHTML = sortedIds.map(id => {
const displayName = id.split('/').pop() || id;
const mode = ['none', 'compact', 'full'].includes(String(toolModes[id] || '').toLowerCase())
const mode = ['none', 'compact', 'full', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
? String(toolModes[id]).toLowerCase()
: '';
return `<div title="${esc(id)}" data-ep-model-row data-model-id="${esc(id)}" class="adm-model-row">
@@ -777,7 +777,8 @@ function renderFeatherlessPanel(panel, ep, row) {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(id)}" data-original-tool-mode="${esc(toolModes[id] || '')}">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
@@ -1356,7 +1357,7 @@ async function loadEndpoints() {
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m => {
const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase())
const mode = ['none', 'compact', 'full', 'spec'].includes(String(m.tool_mode || '').toLowerCase())
? String(m.tool_mode).toLowerCase()
: '';
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
@@ -1370,7 +1371,8 @@ async function loadEndpoints() {
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Ajax Compact</option>
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
@@ -1440,7 +1442,7 @@ async function _saveEpModelState(epId, panel) {
const value = String(sel.value || '').toLowerCase();
const original = String(sel.dataset.originalToolMode || '').toLowerCase();
if (modelId && (sel.dataset.toolModeTouched === '1' || original)) {
modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : '';
modelToolModes[modelId] = ['none', 'compact', 'full', 'spec'].includes(value) ? value : '';
}
});
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
+279
View File
@@ -44,6 +44,283 @@ import { invalidateSettings } from './appConfig.js';
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
// Inline chat images share the editor's 8192px dimension ceiling, with a
// conservative 16MP budget for its multiple canvas surfaces. No blob URLs are
// needed here: chat previews use server-authorized upload references.
const _MAX_DATA_URL_LENGTH = 10 * 1024 * 1024;
const _MAX_INLINE_BYTES = 7 * 1024 * 1024;
const _MAX_IMAGE_DIMENSION = 8192;
const _MAX_IMAGE_PIXELS = 16 * 1024 * 1024;
let _inlineRasterCache = null; // One bounded source; repeated actions reuse its decode.
function _imageDimensionsAllowed(width, height) {
return width > 0 && height > 0 && width <= _MAX_IMAGE_DIMENSION &&
height <= _MAX_IMAGE_DIMENSION && width * height <= _MAX_IMAGE_PIXELS;
}
function _rasterDimensions(bytes, mime) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (offset, text) => [...text].every((char, i) => bytes[offset + i] === char.charCodeAt(0));
if (mime === 'image/png' && bytes.length >= 33 &&
bytes[0] === 137 && ascii(1, 'PNG\r\n\x1a\n') && view.getUint32(8) === 13 && ascii(12, 'IHDR')) {
return [view.getUint32(16), view.getUint32(20)];
}
if (mime === 'image/gif' && bytes.length >= 13 && (ascii(0, 'GIF87a') || ascii(0, 'GIF89a'))) {
return [view.getUint16(6, true), view.getUint16(8, true)];
}
if (mime === 'image/jpeg' && bytes[0] === 255 && bytes[1] === 216) {
let pos = 2;
while (pos + 4 <= bytes.length) {
if (bytes[pos++] !== 255) return null;
while (bytes[pos] === 255) pos++;
const marker = bytes[pos++];
if (marker === 218 || marker === 217) break;
if (marker === 1 || (marker >= 208 && marker <= 215)) continue;
if (pos + 2 > bytes.length) return null;
const size = view.getUint16(pos);
if (size < 2 || pos + size > bytes.length) return null;
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && size >= 8) {
return [view.getUint16(pos + 5), view.getUint16(pos + 3)];
}
pos += size;
}
}
if (mime === 'image/webp' && bytes.length >= 30 && ascii(0, 'RIFF') &&
view.getUint32(4, true) + 8 === bytes.length && ascii(8, 'WEBP')) {
if (ascii(12, 'VP8X')) return [1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16), 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16)];
if (ascii(12, 'VP8L') && bytes[20] === 47) return [1 + ((bytes[21] | (bytes[22] << 8)) & 16383), 1 + (((bytes[22] >> 6) | (bytes[23] << 2) | (bytes[24] << 10)) & 16383)];
if (ascii(12, 'VP8 ') && bytes[23] === 157 && bytes[24] === 1 && bytes[25] === 42) return [view.getUint16(26, true) & 16383, view.getUint16(28, true) & 16383];
}
return null;
}
function _inlineRaster(url) {
if (_inlineRasterCache?.url === url) return _inlineRasterCache;
const result = { url, valid: false };
_inlineRasterCache = result;
if (url.length > _MAX_DATA_URL_LENGTH) return result;
const match = /^data:(image\/(?:png|jpeg|jpg|webp|gif));base64,/i.exec(url);
if (!match) return result;
const payload = url.slice(match[0].length);
if (!payload || payload.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return result;
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
const size = payload.length / 4 * 3 - padding;
if (size > _MAX_INLINE_BYTES) return result;
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
if (padding && (alphabet.indexOf(payload[payload.length - padding - 1]) & (padding === 2 ? 15 : 3))) return result;
try {
const bytes = typeof Uint8Array.fromBase64 === 'function' ? Uint8Array.fromBase64(payload) : (() => {
const binary = atob(payload); const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return array;
})();
const mime = match[1].toLowerCase().replace('image/jpg', 'image/jpeg');
const dimensions = _rasterDimensions(bytes, mime);
if (!dimensions || !_imageDimensionsAllowed(...dimensions)) return result;
Object.assign(result, { valid: true, bytes, mime, dimensions });
} catch (_) {}
return result;
}
// This synchronous guard rejects untrusted references before any fetch/decode.
// Inline images additionally require a successful browser decode before launch.
function _isAuthorizedImageUrl(url) {
if (!url || typeof url !== 'string') return false;
const trimmed = url.trim();
if (trimmed.startsWith('data:')) return _inlineRaster(trimmed).valid;
if (!trimmed || trimmed.startsWith('blob:') || trimmed.startsWith('//')) return false;
try {
const base = window.location.origin;
const parsed = new URL(trimmed, base);
return parsed.origin === base && !parsed.search && !parsed.hash &&
/^\/api\/(?:upload|gallery)\/[A-Za-z0-9_\-]+(?:\.[a-zA-Z0-9]+)?$/.test(parsed.pathname);
} catch (_) { return false; }
}
async function _validateInlineImage(url) {
const raster = _inlineRaster(url.trim());
if (!raster.valid) return false;
if (!raster.decoded) raster.decoded = (async () => {
try {
const image = await createImageBitmap(new Blob([raster.bytes], { type: raster.mime }));
try {
return _imageDimensionsAllowed(image.width, image.height) &&
image.width * image.height === raster.dimensions[0] * raster.dimensions[1];
} finally { image.close(); }
} catch (_) { return false; }
finally { raster.bytes = null; }
})();
return raster.decoded;
}
// Find the specific user message that originated the turn containing container.
function _findOriginatingUserMessage(container) {
if (!container) return null;
let curr = container.previousElementSibling;
while (curr) {
if (curr.classList && curr.classList.contains('msg-user')) {
return curr;
}
curr = curr.previousElementSibling;
}
const box = document.getElementById('chat-history');
if (box && box.contains(container)) {
const all = Array.from(box.querySelectorAll('.msg, .agent-thread, .ai-msg-wrap, .round-holder'));
const idx = all.findIndex((el) => el === container || el.contains(container));
if (idx > 0) {
for (let i = idx - 1; i >= 0; i--) {
if (all[i].classList && all[i].classList.contains('msg-user')) {
return all[i];
}
}
}
}
return null;
}
// Resolve the image attached to the exact turn associated with container / action.
// Never falls back to unrelated images from earlier conversation turns.
function _attachedImageForTurn(container, action) {
const explicitId = action && (action.imageId || action.image_id || action.fileId || action.file_id);
if (explicitId && typeof explicitId === 'string' && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(explicitId)) {
return (API_BASE || '') + '/api/upload/' + explicitId;
}
const explicitUrl = action && (action.imageUrl || action.image_url);
if (explicitUrl) return _isAuthorizedImageUrl(explicitUrl) ? explicitUrl : null;
const userMsg = _findOriginatingUserMessage(container);
if (!userMsg) return null;
const previews = userMsg.querySelectorAll(
'.attach-image-preview[data-file-id], .attach-card[data-file-id]'
);
for (let i = previews.length - 1; i >= 0; i--) {
const fid = previews[i].dataset ? previews[i].dataset.fileId : null;
if (fid && /^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9]+)?$/.test(fid)) {
return (API_BASE || '') + '/api/upload/' + fid;
}
const img = previews[i].querySelector('img');
const src = img ? (img.getAttribute('src') || '') : '';
if (_isAuthorizedImageUrl(src)) return src;
}
const imgs = userMsg.querySelectorAll('.attach-image-preview img, .attach-card img');
for (let i = imgs.length - 1; i >= 0; i--) {
const src = imgs[i].getAttribute('src') || '';
if (_isAuthorizedImageUrl(src)) return src;
}
return null;
}
function _currentAttachedImageUrl(container, action) {
return _attachedImageForTurn(container, action);
}
function _notifyError(msg) {
try {
if (uiModule && typeof uiModule.showError === 'function') {
uiModule.showError(msg);
return;
}
} catch (_) {}
console.warn('[chat]', msg);
}
// Render the one-click "Open in editor & apply" button on a photo-edit reply
// that had no open editor (photo_editor_action kind 'open_and_apply').
function renderPhotoEditorApplyButton(container, action) {
if (!container || !action || action.kind !== 'open_and_apply') return;
if (container.querySelector('.chat-open-in-editor-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'chat-open-in-editor-btn';
btn.textContent = 'Open in editor & apply';
btn.style.cssText = 'margin-top:8px;padding:6px 12px;border-radius:6px;border:1px solid rgba(120,120,120,0.4);background:transparent;color:inherit;cursor:pointer;font:inherit;';
btn.addEventListener('click', async () => {
if (btn.disabled) return;
btn.disabled = true;
try {
let imageUrl = _attachedImageForTurn(container, action);
if (!imageUrl) {
_notifyError('No attached photo found for this conversation turn.');
btn.disabled = false;
return;
}
if (!_isAuthorizedImageUrl(imageUrl)) {
_notifyError('Unauthorized or invalid image reference.');
btn.disabled = false;
return;
}
if (imageUrl.trim().startsWith('data:') && !await _validateInlineImage(imageUrl)) {
_notifyError('Invalid or oversized image attachment.');
return;
}
// Gallery references name an owner-scoped metadata resource, not image bytes.
if (!imageUrl.trim().startsWith('data:') && new URL(imageUrl, window.location.origin).pathname.startsWith('/api/gallery/')) {
const response = await fetch(imageUrl, { credentials: 'same-origin' });
if (!response.ok) { _notifyError('The gallery image is unavailable.'); return; }
const image = await response.json();
const source = new URL(image.url || '', window.location.origin);
if (source.origin !== window.location.origin || source.search || source.hash ||
!/^\/api\/generated-image\/[A-Za-z0-9_.-]+$/.test(source.pathname)) {
_notifyError('Invalid gallery image reference.'); return;
}
imageUrl = source.href;
}
// Verify attachment is accessible and authorized on the server before launching editor
if (imageUrl.includes('/api/upload/')) {
try {
const probe = await fetch(imageUrl, { method: 'HEAD' });
if (!probe.ok) {
if (probe.status === 404 || probe.status === 410) {
_notifyError('The attached image is no longer available or upload has expired.');
} else if (probe.status === 401 || probe.status === 403) {
_notifyError('You are not authorized to edit this attached image.');
} else {
_notifyError('Could not verify image attachment authorization.');
}
btn.disabled = false;
return;
}
} catch (_) {
_notifyError('Could not verify image attachment authorization.');
btn.disabled = false;
return;
}
}
if (typeof window.gePhotoEditorApply !== 'function') {
await import('/static/js/galleryEditor.js?v=20260909movepicklayer1');
}
await window.gePhotoEditorApply({
imageId: (action && (action.imageId || action.image_id)) || null,
imageUrl,
prompt: action.prompt || '',
displayName: 'Chat edit',
});
} catch (err) {
console.error('[chat] open_and_apply failed', err);
_notifyError('Failed to apply edit: ' + (err.message || err));
} finally {
btn.disabled = false;
}
});
container.appendChild(btn);
}
if (typeof window !== 'undefined') {
window.__chatPhotoEditorTestHooks = {
_isAuthorizedImageUrl,
_findOriginatingUserMessage,
_attachedImageForTurn,
renderPhotoEditorApplyButton,
};
}
let API_BASE = '';
let currentAbort = null;
let isStreaming = false;
@@ -4095,6 +4372,7 @@ import { invalidateSettings } from './appConfig.js';
threadAbove.classList.add('has-bottom');
}
terminalFinalResponseRendered = _renderTerminalAnswer(finalText, '', json.render_owner, json.replacement_scope);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
lastContentRoundHolder = roundHolder || lastContentRoundHolder;
uiModule.scrollHistory();
continue;
@@ -6519,6 +6797,7 @@ import { invalidateSettings } from './appConfig.js';
gotDelta = true;
try { spinner.destroy(); } catch (_) {}
renderDelta(json);
if (json.photo_editor_action) renderPhotoEditorApplyButton(roundHolder, json.photo_editor_action);
replayRendering.settle();
} else if (json.delta) {
if (!replayRendering.accepts(json)) continue;
+5 -1
View File
@@ -457,6 +457,7 @@ function _makeActionBtn(className, title, text, handler) {
btn.className = className;
btn.type = 'button';
btn.title = title;
btn.setAttribute('aria-label', title);
btn.textContent = text;
btn.addEventListener('click', handler);
return btn;
@@ -2375,6 +2376,7 @@ export function createMsgFooter(msgElement, { animate = false } = {}) {
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -2571,6 +2573,7 @@ export function createUserMsgFooter(msgElement) {
moreBtn.className = 'msg-action-btn msg-more-btn';
moreBtn.type = 'button';
moreBtn.title = 'More actions';
moreBtn.setAttribute('aria-label', 'More actions');
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -3443,7 +3446,8 @@ export function addMessage(role, content, modelName, metadata) {
for (const ev of roundTools) {
const image = generatedImageResult(ev);
if (image) {
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id));
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
box.appendChild(imgBubble);
}
}
}
+2
View File
@@ -212,6 +212,8 @@ export function handleUIControl(uiData) {
var fn = mod.openPanel || mod.openNotes || (mod.default && (mod.default.openPanel || mod.default.openNotes));
if (fn) fn();
}).catch(function(){});
} else if (panel === 'tasks') {
document.getElementById('tool-tasks-btn')?.click();
} else if (panel === 'theme' || panel === 'themes') {
import('./theme.js?v=20260911organsrain1').then(function(mod) {
var fn = mod.togglePopup || (mod.default && mod.default.togglePopup);
+3 -3
View File
@@ -390,11 +390,11 @@ export function runHTML(code, panel) {
* Main entry point — called when a Run button is clicked
*/
export function run(btn) {
const code = btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const pre = btn.closest('pre');
if (!pre) return;
const codeEl = pre.querySelector('code');
const code = codeEl ? codeEl.textContent : btn.getAttribute('data-code');
const lang = (btn.getAttribute('data-lang') || '').toLowerCase();
const panel = getOrCreatePanel(pre);
if (!code || !code.trim()) {
+3 -1
View File
@@ -101,12 +101,14 @@ function suggestionsForHsv(h, s, v) {
];
}
// ── Reusable helpers for hosts that surface swatches inline ──────────
// (e.g. the editor's SWATCHES panel showing suggestions + recents).
function computeSuggestions() { return suggestionsForHsv(_h, _s, _v); }
export function getRecentColors() { return getRecents(); }
export function recordRecentColor(hex) { addRecent(hex); }
export function suggestColorsFor(hex) {
const { h, s, v } = hexToHsv(hex);
const { h, s, v } = hexToHsv(hex || '#000000');
return suggestionsForHsv(h, s, v);
}
+2 -1
View File
@@ -1498,7 +1498,8 @@ function _rerenderCachedModels() {
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _minimaxM3Snapshot = '~/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
const _modelPathValue = _savedModelPath || _defaultServeModel;
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
+42 -5
View File
@@ -1996,6 +1996,17 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Active drop mode for the PDF toolbar — toolbar buttons set this; the
// next click on a page consumes it. null means clicks do nothing.
let _pdfDropMode = null;
let _pdfAnnotationDragging = false;
function _setPdfAnnotationDragging(dragging) {
_pdfAnnotationDragging = dragging;
for (const ref of _pdfPaneAnnotationsByDoc.get(activeDocId) || []) {
ref._refreshHandles?.();
if (dragging) {
const menu = ref.wrap?.querySelector('.pdf-annotation-text-menu');
if (menu) menu.style.display = 'none';
}
}
}
// Per-doc last-used line spacing for text annotations. Once the user picks
// 1.6 for one box, every text box dropped after that defaults to 1.6.
const _pdfLastLineHeight = new Map(); // docId -> number
@@ -2290,8 +2301,16 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// active (toolbar buttons set the mode). Without a mode, clicking does
// nothing — keeps page interactions predictable so users don't get
// surprise boxes from stray clicks.
const isBlankPageTarget = target => target === pageWrap
|| (target?.tagName === 'IMG' && target.parentElement === pageWrap);
let placementStartedOnPage = false;
pageWrap.addEventListener('pointerdown', ev => {
placementStartedOnPage = isBlankPageTarget(ev.target);
}, true);
pageWrap.addEventListener('click', (ev) => {
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
const canPlace = placementStartedOnPage;
placementStartedOnPage = false;
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
if (!_pdfDropMode) return;
const rect = pageWrap.getBoundingClientRect();
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
@@ -2423,9 +2442,11 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
// Set handle visibility together; clicking/tapping the annotation itself
// brings hidden controls back.
let handlesRequested = _isTouch;
const _setHandlesVisible = (show) => {
if (!_pdfAnnotationDragging) handlesRequested = show;
const dismissed = wrap.dataset.controlsDismissed === '1';
const v = (show && !dismissed) ? '' : 'none';
const v = (show && !dismissed && !_pdfAnnotationDragging) ? '' : 'none';
del.style.display = v;
grip.style.display = v;
resize.style.display = v;
@@ -2446,7 +2467,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
_setHandlesVisible(true);
});
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible };
const ref = { id: ann.id, page: ann.page, x: ann.x, y: ann.y, w: ann.w, h: ann.h, el: input, wrap, kind, _setHandlesVisible,
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
if (kind === 'check') {
// Stamp checkmark — value is fixed, nothing to listen for.
@@ -2568,7 +2590,12 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ref._autoGrow = _autoGrow;
}
del.addEventListener('pointerdown', ev => {
ev.preventDefault();
ev.stopPropagation();
});
del.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
_pushPdfUndoSnapshot();
_removeAnnotation(ref);
@@ -2580,6 +2607,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { grip.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and resize handles while moving so they don't obscure the
// box — easier to see exactly where it lands. Restored on release.
del.style.display = 'none';
@@ -2598,11 +2626,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Drag bottom-right corner to resize. Width/height stored as percentages.
@@ -2611,6 +2642,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
ev.stopPropagation();
_pushPdfUndoSnapshot();
try { resize.setPointerCapture(ev.pointerId); } catch (_) {}
_setPdfAnnotationDragging(true);
// Hide the × and move handles while resizing — clean view of the box edge.
del.style.display = 'none';
grip.style.display = 'none';
@@ -2628,11 +2660,14 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
_setPdfAnnotationDragging(false);
_setHandlesVisible(true);
_schedulePdfPaneSave();
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
});
// Text options menu — opened from the floating … button so the spacing
@@ -15239,7 +15274,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
menu.style.top = (rect.bottom + 2) + 'px';
menu.style.right = (window.innerWidth - rect.right) + 'px';
menu.style.left = 'auto';
menu.style.zIndex = '9999';
const editorPane = document.querySelector('.doc-editor-pane');
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
const langLabel = lang ? lang.toUpperCase() : 'TXT';
// Form-backed markdown doc → primary export is the filled PDF, not the
@@ -15314,7 +15350,8 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
// Outside-click AND Escape both route through the central esc-stack via
// bindMenuDismiss; onClose owns the actual node removal.
const close = bindMenuDismiss(menu, () => { menu.remove(); });
const exportBtn = document.getElementById('doc-footer-export-btn');
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
}
function _richTextExportCss() {
+89 -59
View File
@@ -140,12 +140,41 @@ export function modelCaps(modelId, endpointName, endpointType) {
return { gen: false, inpaint: false };
}
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
// Global listeners resolve live controls instead of retaining editor instances.
function syncCommandModel(value) {
const select = document.getElementById('ge-ai-command-model');
if (!select || !select._modelsReady) return;
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
value = '';
}
if (select.value !== value) {
select.value = value;
if (select.value !== value) select.value = '';
updateFooterEditModel();
}
}
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
window.addEventListener('storage', e => {
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
});
window.addEventListener('ge:model-endpoints-updated', e => {
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
});
export function wireAIModelSelectors({ container, apiBase = '', openCookbookForImg2img = () => {} } = {}) {
const root = container || document;
root._aiModelListeners?.abort();
const listeners = new AbortController();
root._aiModelListeners = listeners;
const listen = (target, type, handler, options = {}) => target?.addEventListener(type, handler, {
...(typeof options === 'boolean' ? { capture: options } : options), signal: listeners.signal,
});
// Delegated handler for the "+ Serve a model in Cookbook…" sentinel
// option — catches clicks regardless of whether loadAIModels has
// rewired the individual select yet and survives any innerHTML
// reset later.
container.addEventListener('change', (e) => {
listen(root, 'change', (e) => {
const sel = e.target.closest('select');
if (!sel) return;
if (sel.value !== '__serve_cookbook__') return;
@@ -155,7 +184,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
openCookbookForImg2img();
});
// Track prior value so we can restore it after the sentinel fires.
container.addEventListener('focus', (e) => {
listen(root, 'focus', (e) => {
const sel = e.target.closest('select');
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
}, true);
@@ -164,8 +193,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const aiInpaintSelect = document.getElementById('ge-ai-inpaint');
const aiCommandSelect = document.getElementById('ge-ai-command-model');
const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model');
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
if (aiInpaintSelect) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
// The Quick Edit agent (planner) picker: dynamically populates from
// /api/editor/agent-models; restore/save choice from localStorage.
const populateAgentSelect = async () => {
@@ -176,9 +204,10 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const res = await fetch(`${apiBase}/api/editor/agent-models`);
if (res.ok) {
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const serverModels = data.models || [];
for (const m of serverModels) {
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`;
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : '')}::${m.id}`;
const label = m.label || m.id;
if (!agentOptions.some(o => o.value === optVal)) {
agentOptions.push({ value: optVal, label });
@@ -202,9 +231,9 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
aiCommandAgentSelect.value = prevVal;
}
} catch {}
if (!aiCommandAgentSelect._agentWired) {
aiCommandAgentSelect._agentWired = true;
aiCommandAgentSelect.addEventListener('change', () => {
if (aiCommandAgentSelect._agentWired !== listeners) {
aiCommandAgentSelect._agentWired = listeners;
listen(aiCommandAgentSelect, 'change', () => {
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
});
}
@@ -222,8 +251,10 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const prevGenValue = aiGenSelect?.value || '';
const prevInpaintValue = aiInpaintSelect?.value || '';
const res = await fetch(`${apiBase}/api/editor/image-models`);
if (listeners.signal.aborted || !root.isConnected) return;
if (!res.ok) throw new Error(`Could not load image models (${res.status})`);
const data = await res.json();
if (listeners.signal.aborted || !root.isConnected) return;
const modelsList = data.models || [];
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
@@ -241,8 +272,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
if (!canGen && !canInpaint) continue;
// Use opaque endpoint_id if available; fallback to provider name or base_url for admin
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.base_url || '');
// Use opaque endpoint_id if available; never fall back to raw base_url
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
const value = endpointKey ? `${endpointKey}::${modelId}` : modelId;
const shortModel = modelId ? String(modelId).split('/').pop() : (m.label || m.provider);
const epHint = m.provider && m.provider !== modelId && m.provider !== 'image' ? ` · ${m.provider}` : '';
@@ -312,32 +343,50 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
if (aiGenSelect) appendServeSentinel(aiGenSelect);
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
// Wire the sentinel on the Gen + Inpaint selects too.
const wireServeSentinel = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServeSentinel(aiGenSelect);
wireServeSentinel(aiInpaintSelect);
wireServeSentinel(aiCommandSelect);
// Sentinel changes are handled by the component's delegated listener.
if (aiCommandSelect) {
aiCommandSelect._modelsReady = true;
try {
const saved = localStorage.getItem('ge-ai-command-model');
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
let saved = rawSaved;
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = loaded.filter(l => l.model === modelPart);
if (matching.length === 1) {
saved = matching[0].value;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem('ge-ai-command-model', saved);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
}
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
aiCommandSelect.value = saved;
} else {
aiCommandSelect.value = '';
}
if (aiCommandSelect.value !== rawSaved) {
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
}
} catch {}
aiCommandSelect.addEventListener('change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
updateFooterEditModel();
});
if (aiCommandSelect._commandWired !== listeners) {
aiCommandSelect._commandWired = listeners;
listen(aiCommandSelect, 'change', () => {
if (aiCommandSelect.value === '__serve_cookbook__') return;
const val = aiCommandSelect.value || '';
try {
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
else localStorage.removeItem('ge-ai-command-model');
} catch {}
updateFooterEditModel();
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
});
}
}
// Restore each per-tool selection from localStorage.
for (const ts of perToolSelects) {
@@ -349,7 +398,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
}
} catch {}
let prevValue = ts.value;
ts.addEventListener('change', () => {
listen(ts, 'change', () => {
if (ts.value === '__serve_cookbook__') {
ts.value = prevValue;
openCookbookForImg2img();
@@ -362,6 +411,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
} catch (e) {
if (listeners.signal.aborted || !root.isConnected) return;
// Fetch failed — still give the user the affordance to set up
// a model. Otherwise the dropdown shows only "Auto" with no
// hint about what to do next.
@@ -370,22 +420,6 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback;
if (aiCommandSelect) aiCommandSelect.innerHTML = fallback;
document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; });
const wireServe = (sel) => {
if (!sel) return;
let prev = sel.value;
sel.addEventListener('change', () => {
if (sel.value === '__serve_cookbook__') {
sel.value = prev;
openCookbookForImg2img();
return;
}
prev = sel.value;
});
};
wireServe(aiGenSelect);
wireServe(aiInpaintSelect);
wireServe(aiCommandSelect);
document.querySelectorAll('select.ge-tool-model').forEach(wireServe);
void populateAgentSelect();
updateFooterEditModel();
_aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() };
@@ -393,15 +427,11 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
}
}
loadAIModels();
const onModelEndpointsUpdated = (e) => {
if (!container.isConnected) {
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
return;
}
loadAIModels({ selectBaseUrl: e.detail?.baseUrl || '' });
if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
if (listeners.signal.aborted || !root.isConnected) return;
void loadAIModels({ selectBaseUrl: baseUrl });
void populateAgentSelect();
};
window.addEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
// Re-fetch the model list when the user opens the inpaint dropdown,
// so a model served via Cookbook mid-edit shows up without having to
// close and reopen the editor. Debounced to one refresh per 3s so
@@ -420,5 +450,5 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
});
};
container.addEventListener('mousedown', refreshOnOpen, true);
listen(root, 'mousedown', refreshOnOpen, true);
}
+37 -2
View File
@@ -1498,8 +1498,11 @@ let _libSyncStatus = {
source: '',
warming: false,
loading: false,
stale: false,
};
let _libSyncTicker = null;
let _libStaleRefetchTimer = null;
let _libStaleRefetchCount = 0;
function _libSyncDateFrom(value) {
if (!value) return null;
@@ -1554,9 +1557,37 @@ function _setEmailSyncStatus(next = {}) {
if (Object.prototype.hasOwnProperty.call(next, 'loading')) {
_libSyncStatus.loading = Boolean(next.loading);
}
if (Object.prototype.hasOwnProperty.call(next, 'stale')) {
_libSyncStatus.stale = Boolean(next.stale);
}
_renderEmailSyncStatus();
}
// A server-side stale (sync.stale) response means the SWR snapshot was served
// while a background IMAP refresh is still running. Re-fetch in place after a
// short delay (bounded) so the fresh rows land with no skeleton and no scroll
// jump. We only keep retrying while the response is still marked stale.
function _clearStaleEmailRefetch() {
if (_libStaleRefetchTimer) {
clearTimeout(_libStaleRefetchTimer);
_libStaleRefetchTimer = null;
}
_libStaleRefetchCount = 0;
}
function _scheduleStaleEmailRefetch() {
if (_libStaleRefetchTimer || _libStaleRefetchCount >= 3) return;
const folderAtSchedule = state._libFolder;
const accountAtSchedule = state._libAccountId || '';
_libStaleRefetchCount++;
_libStaleRefetchTimer = setTimeout(() => {
_libStaleRefetchTimer = null;
if (document.hidden) return;
if (folderAtSchedule !== state._libFolder || accountAtSchedule !== (state._libAccountId || '')) return;
_loadEmails({ force: false, useCache: false, skipFastPath: true });
}, 2500);
}
function _libCacheKeyFor(accountId, folder, filter, hasAttachments) {
return [
accountId || '',
@@ -4990,7 +5021,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
} catch (_) { _syncUnreadTabBadge(0); }
}
export async function _loadEmails({ force = false, useCache = true } = {}) {
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
const seq = ++_libLoadSeq;
state._libLoading = true;
const accountAtStart = state._libAccountId || '';
@@ -5044,11 +5075,15 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
}
const stats = document.getElementById('email-lib-stats');
if (stats) stats.textContent = `${state._libTotal} emails`;
const _isStale = Boolean(sync.stale);
_setEmailSyncStatus({
updatedAt: sync.updated_at || '',
source: sync.source || (cacheSource ? 'client_cache' : ''),
loading: false,
stale: _isStale,
});
if (_isStale) _scheduleStaleEmailRefetch();
else _clearStaleEmailRefetch();
if (cacheable && !cacheSource) {
_libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal, sync });
}
@@ -5084,7 +5119,7 @@ export async function _loadEmails({ force = false, useCache = true } = {}) {
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
const dateQS = `${dateFromAtStart ? `&date_from=${encodeURIComponent(dateFromAtStart)}` : ''}${dateToAtStart ? `&date_to=${encodeURIComponent(dateToAtStart)}` : ''}`;
if (!cached && cacheable && !force) {
if (!cached && cacheable && !force && !skipFastPath) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 450);
try {
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation();
tapToEditOrSelect(card);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
+57 -8
View File
@@ -2229,6 +2229,36 @@ function _makeGalleryDraggable(content) {
// Re-export the manager for the rail click handler
import * as Modals from './modalManager.js';
// Module-owned listeners resolve the current picker; closed modals are never retained.
window.addEventListener('ge-ai-command-model-change', (e) => {
const imageModelSelect = document.getElementById('gallery-image-model');
if (!imageModelSelect || !imageModelSelect._modelsReady) return;
let val = (e && e.detail && typeof e.detail.value === 'string') ? e.detail.value : '';
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
val = '';
}
if (imageModelSelect.value !== val) {
imageModelSelect.value = val;
if (imageModelSelect.value !== val) imageModelSelect.value = '';
}
});
window.addEventListener('storage', (e) => {
const imageModelSelect = document.getElementById('gallery-image-model');
if (e && e.key === 'ge-ai-command-model' && imageModelSelect?._modelsReady) {
let val = e.newValue || '';
if (val && ![...imageModelSelect.options].some(o => o.value === val)) {
if (localStorage.getItem('ge-ai-command-model') === val) localStorage.removeItem('ge-ai-command-model');
val = '';
}
if (imageModelSelect.value !== val) {
imageModelSelect.value = val;
if (imageModelSelect.value !== val) imageModelSelect.value = '';
}
}
});
export function openGallery() {
// If already minimized — restore in place, preserve all state
if (Modals.isRegistered('gallery-modal') && Modals.isMinimized('gallery-modal')) {
@@ -2607,7 +2637,7 @@ export function openGallery() {
// ── Image model (Settings tab) ──
// Mirror of the Quick Edit picker: same source (GET /api/editor/image-models)
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
// and the SAME localStorage key `ge-ai-command-model` (value `<endpoint_id>::<id>`)
// so the two stay in sync.
const _GE_MODEL_KEY = 'ge-ai-command-model';
const imageModelSelect = document.getElementById('gallery-image-model');
@@ -2617,7 +2647,8 @@ export function openGallery() {
const r = await fetch(`${API_BASE}/api/editor/image-models`, { credentials: 'same-origin' });
const data = r.ok ? await r.json() : null;
const models = (data && Array.isArray(data.models)) ? data.models : [];
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
const rawSaved = localStorage.getItem(_GE_MODEL_KEY) || '';
let saved = rawSaved;
imageModelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
@@ -2625,12 +2656,34 @@ export function openGallery() {
imageModelSelect.appendChild(def);
for (const m of models) {
const opt = document.createElement('option');
opt.value = `${m.base_url || ''}::${m.id}`;
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
opt.value = endpointKey ? `${endpointKey}::${m.id}` : m.id;
opt.textContent = `${m.id} · ${m.provider || ''} (${m.kind || 'edit'})`.trim();
imageModelSelect.appendChild(opt);
}
imageModelSelect._modelsReady = true;
// Invalidate or migrate legacy localStorage values containing URLs or leading ::
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
const matching = models.filter(m => m.id === modelPart);
if (matching.length === 1) {
const epKey = matching[0].endpoint_id ? String(matching[0].endpoint_id) : (matching[0].source === 'local' ? 'local' : '');
saved = epKey ? `${epKey}::${matching[0].id}` : matching[0].id;
} else {
saved = '';
}
try {
if (saved) localStorage.setItem(_GE_MODEL_KEY, saved);
else localStorage.removeItem(_GE_MODEL_KEY);
} catch (_) {}
}
imageModelSelect.value = saved;
if (imageModelSelect.value !== saved) imageModelSelect.value = '';
if (imageModelSelect.value !== rawSaved) {
if (imageModelSelect.value) localStorage.setItem(_GE_MODEL_KEY, imageModelSelect.value);
else localStorage.removeItem(_GE_MODEL_KEY);
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: imageModelSelect.value } }));
}
} catch (e) {
imageModelSelect.innerHTML = '<option value="">Default (configured provider)</option>';
}
@@ -2645,11 +2698,7 @@ export function openGallery() {
});
_fillImageModels();
}
window.addEventListener('storage', (e) => {
if (e && e.key === _GE_MODEL_KEY && imageModelSelect) {
imageModelSelect.value = e.newValue || '';
}
});
// ── Tag All Untagged ──
let _tagCancelRequested = false;
+50 -3
View File
@@ -294,14 +294,61 @@ function moreListPayloadFromHref(href) {
return _moreListPayloads.get(`${match[1]}:${match[2]}`) || '';
}
// Assistant markdown images (`![alt](url)`) render inline as a clickable
// thumbnail that opens the full-size image in a new tab. Only https URLs are
// allowed — an http URL (or anything unparseable) is downgraded to its alt
// text rather than rendered as an image. A broken image (the model can
// fabricate a URL) must never leave an empty gap, so each image is paired with
// a hidden fallback caption (alt text + URL) that _bindChatImageFallback()
// reveals when the load fails.
function imageHtml(alt, url, title) {
const safeUrl = safeLinkUrl(url);
if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || '');
const rawUrl = String(url || '').trim();
let safeUrl = '';
try {
const parsed = new URL(rawUrl, window.location.origin);
if (parsed.protocol === 'https:') safeUrl = parsed.href;
} catch (_) {
safeUrl = '';
}
if (!safeUrl) return escapeHtml(alt || rawUrl || '');
const safeAlt = escapeHtml(alt || '');
const safeTitle = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async">`;
const displayUrl = escapeHtml(safeUrl);
const fallbackLabel = escapeHtml(alt || 'Image');
// The fallback caption shows the URL as readable text, but a bare
// `https://` in that text would be caught by the later bare-URL autolink
// pass and wrapped in a nested <a> — which then truncates this outer <a>
// when it is extracted as an allowed-HTML block. Encode the `//` so it
// displays as `https://` yet is invisible to the autolink regex.
const fallbackUrl = displayUrl.replace('://', ':&#47;&#47;');
return (
`<a class="chat-image" href="${displayUrl}" target="_blank" rel="noopener noreferrer" title="Open image in a new tab">` +
`<img class="chat-inline-image" src="${displayUrl}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async" referrerpolicy="no-referrer">` +
`<span class="chat-image-fallback">${fallbackLabel} — ${fallbackUrl}</span>` +
`</a>`
);
}
// Reveal the alt-text + link fallback whenever an inline chat image fails to
// load, and hide it again if a late load succeeds. `error`/`load` on <img> do
// not bubble, so this listens in the capture phase (document → target).
function _bindChatImageFallback() {
if (typeof document === 'undefined' || !document.addEventListener) return;
const wrapOf = (target) => {
if (!target || target.tagName !== 'IMG' || !target.classList || !target.classList.contains('chat-inline-image')) return null;
return target.closest ? target.closest('a.chat-image') : null;
};
document.addEventListener('error', (event) => {
const wrap = wrapOf(event && event.target);
if (wrap) wrap.classList.add('image-broken');
}, true);
document.addEventListener('load', (event) => {
const wrap = wrapOf(event && event.target);
if (wrap) wrap.classList.remove('image-broken');
}, true);
}
_bindChatImageFallback();
function _isModelEndpointUrl(rawUrl) {
try {
const parsed = new URL(String(rawUrl || ''), window.location.origin);
+4 -1
View File
@@ -810,7 +810,10 @@ async function _pick(m) {
_renderLoading('Loading models…');
}
if (window.modelsModule && window.modelsModule.refreshModels) {
_refreshPickerModels({ force: true, showLoading: !hasCache }).then(isLatest => {
// Open instantly from cache; never force a re-probe on open (a dead
// endpoint would otherwise stall every open). The explicit refresh
// button still forces a full re-probe.
_refreshPickerModels({ force: false, showLoading: !hasCache }).then(isLatest => {
if (isLatest && !menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
}).catch(() => {});
+6
View File
@@ -589,6 +589,12 @@ export async function refreshModels(force = false, opts = {}) {
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
}
box.appendChild(noModels);
const openAdmin = noModels.querySelector('#models-open-admin');
if (openAdmin) openAdmin.addEventListener('click', (e) => {
e.preventDefault();
const btn = document.getElementById('user-bar-admin');
if (btn) btn.click();
});
}
} catch (e) {
console.error(e);
+4 -1
View File
@@ -80,7 +80,10 @@ export function init(apiBase) {
// sidebar — _reconnectActive only ran once at load before, so
// agent-started jobs never appeared until a page reload.
if (_activePollInterval) clearInterval(_activePollInterval);
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
_activePollInterval = setInterval(() => {
if (document.hidden) return;
_reconnectActive();
}, 60000);
}
// Allow an immediate adopt when the chat stream signals a new research
+93 -1
View File
@@ -38,6 +38,89 @@ let _historyPager = null;
const _serverStreamChecksInFlight = new Set();
const _serverStreamAbsentUntil = new Map();
const SERVER_STREAM_ABSENT_TTL_MS = 30000;
// Last title we rendered for each session, so the sidebar can fade a title
// in when the server auto-renames a chat (e.g. the spec model titles a new
// chat from its first prompt) instead of snapping to it.
const _prevSessionTitles = new Map();
// === copy-chat header (debug trace block) ===
function _toolSurfaceLabel(mode) {
const m = String(mode == null ? '' : mode).toLowerCase();
if (m === 'full') return 'Regular tools';
if (m === 'compact') return 'Ajax Compact';
if (m === 'spec') return 'Ajax Spec (router)';
if (m === 'none') return 'Tools off';
if (!m) return 'Auto';
return 'unknown';
}
function _formatJst(now) {
const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
const p = (n) => String(n).padStart(2, '0');
return `${jst.getUTCFullYear()}-${p(jst.getUTCMonth() + 1)}-${p(jst.getUTCDate())} ${p(jst.getUTCHours())}:${p(jst.getUTCMinutes())}`;
}
function _buildCopyChatHeader(info) {
const version = info.version || 'unknown';
const build = info.build || 'unknown';
const commit = (info.sourceCommit || 'unknown').slice(0, 8);
const model = info.model || 'unknown';
const endpointUrl = info.endpointUrl || 'unknown';
const toolSurface = info.toolSurface || 'unknown';
const copiedAt = info.copiedAt ? _formatJst(info.copiedAt) : 'unknown';
const sessionId = info.sessionId || 'unknown';
return [
'--- Odysseus info ---',
`Odysseus: ${version} | Harness build: ${build} | Commit: ${commit}`,
`Model: ${model} @ ${endpointUrl} | Tool surface: ${toolSurface}`,
`Copied: ${copiedAt} JST | Session: ${sessionId}`,
'----------------------',
].join('\n');
}
// === end copy-chat header ===
async function _copyChatHeaderFor(s) {
let version = 'unknown';
let build = 'unknown';
let sourceCommit = 'unknown';
try {
const res = await fetch(`${API_BASE}/api/version`);
const v = await res.json();
if (v && typeof v === 'object') {
version = v.version || 'unknown';
build = v.build || 'unknown';
sourceCommit = v.source_commit || 'unknown';
}
} catch (_) {}
let toolSurface = 'unknown';
try {
const res = await fetch(`${API_BASE}/api/model-endpoints`);
if (res.ok) {
const eps = await res.json();
const list = Array.isArray(eps) ? eps : [];
const model = (s && s.model) || '';
const ep = list.find((x) => x && (
(s && s.endpoint_id != null && x.id === s.endpoint_id) ||
(s && s.endpoint_url && x.base_url === s.endpoint_url)
));
if (ep && ep.model_tool_modes && model) {
toolSurface = _toolSurfaceLabel(ep.model_tool_modes[model]);
}
}
} catch (_) {}
return _buildCopyChatHeader({
version,
build,
sourceCommit,
model: (s && s.model) || undefined,
endpointUrl: (s && s.endpoint_url) || undefined,
toolSurface,
sessionId: (s && s.id) || undefined,
copiedAt: new Date(),
});
}
function _shouldPreserveStartupComposer(msgInput) {
if (!msgInput || !msgInput.value) return false;
@@ -684,6 +767,14 @@ function createSessionItem(s) {
let chatTitle = s.name || '';
if (_isFork) chatTitle = chatTitle.replace(/^Fork:\s*/, '').replace(/^\u2ADD\s*/, '');
if (_isGroup) chatTitle = chatTitle.replace(/^\[GRP\]\s*/, '');
// Fade the row's title in only when it actually changed since the last
// render; everything else about the re-render stays silent.
const _prevTitle = _prevSessionTitles.get(s.id);
if (chatTitle && _prevTitle !== undefined && _prevTitle !== chatTitle) {
span.classList.add('session-title-updated');
}
if (chatTitle) _prevSessionTitles.set(s.id, chatTitle);
let label = chatTitle;
if (s.model) label += ' · ' + s.model.split('/').pop();
if (s.archived) label += ' [archived]';
@@ -930,7 +1021,8 @@ function createSessionItem(s) {
const text = typeof m.content === 'string' ? m.content.trim() : JSON.stringify(m.content);
return `${label}: ${text}`;
});
const text = lines.join('\n\n');
const header = await _copyChatHeaderFor(s);
const text = header + '\n\n' + lines.join('\n\n');
try {
await navigator.clipboard.writeText(text);
} catch (_clipErr) {
+3
View File
@@ -106,6 +106,9 @@ export function initSidebarLayout(Storage, opts) {
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
hamburgerBtn.setAttribute('aria-expanded', String(!sidebarHidden));
const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn');
if (sidebarToggleBtn) sidebarToggleBtn.setAttribute('aria-expanded', String(!sidebarHidden));
}
// Keep incognito button clear of hamburger
const incogBtn = document.getElementById('incognito-btn');
+25 -2
View File
@@ -5336,6 +5336,29 @@ function _eggRender(html) {
uiModule.scrollHistory();
}
// Delegated handlers for egg commands that used inline onclick (blocked by
// Firefox CSP script-src-attr). Dice re-roll + color copy.
if (!window._eggDelegatedBound) {
window._eggDelegatedBound = true;
document.addEventListener('click', (e) => {
const dice = e.target.closest && e.target.closest('.egg-dice');
if (dice) {
const sides = Math.min(parseInt(dice.dataset.diceSides) || 6, 1000);
dice.style.animation = 'none';
void dice.offsetHeight;
dice.textContent = Math.floor(Math.random() * sides) + 1;
dice.style.animation = 'egg-shake 0.3s ease';
return;
}
const swatch = e.target.closest && e.target.closest('.egg-color-copy');
if (swatch) {
navigator.clipboard.writeText(swatch.dataset.color || '');
swatch.style.transform = 'scale(0.9)';
setTimeout(() => { swatch.style.transform = ''; }, 150);
}
});
}
async function _cmdFlip(args, ctx) {
const isHeads = Math.random() < 0.5;
const edge = Math.random() < 0.001;
@@ -5373,7 +5396,7 @@ async function _cmdRoll(args, ctx) {
const results = Array.from({ length: count }, () => Math.floor(Math.random() * sides) + 1);
const total = results.reduce((a, b) => a + b, 0);
const dice = results.map((v, i) => {
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" title="d${sides}" onclick="this.style.animation='none';this.offsetHeight;var r=Math.floor(Math.random()*${sides})+1;this.textContent=r;this.style.animation='egg-shake 0.3s ease'">${v}</div>`;
return `<div style="min-width:42px;height:42px;border-radius:6px;border:2px solid var(--border);background:var(--panel);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:var(--red);animation:egg-spin 0.5s ease-out ${i*0.08}s both;cursor:pointer" class="egg-dice" data-dice-sides="${sides}" title="d${sides}">${v}</div>`;
}).join('');
const totalHtml = count > 1 ? `<div style="font-size:0.8em;opacity:0.5;margin-top:4px">${count}d${sides} = ${total}</div>` : '';
_eggRender(`<div style="display:flex;flex-direction:column;align-items:center;gap:4px"><div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:center">${dice}</div>${totalHtml}</div>`);
@@ -5690,7 +5713,7 @@ async function _cmdColor(args, ctx) {
const hex = args[0] || '#' + Math.floor(Math.random()*16777215).toString(16).padStart(6,'0');
const c = hex.startsWith('#') ? hex : '#' + hex;
_eggRender(`<div style="display:flex;align-items:center;gap:12px;animation:egg-fade 0.3s ease-out">
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText('${ctx.esc(c)}');this.style.transform='scale(0.9)';setTimeout(()=>this.style.transform='',150)"></div>
<div style="width:48px;height:48px;border-radius:4px;border:1px solid var(--border);background:${ctx.esc(c)};cursor:pointer" class="egg-color-copy" data-color="${ctx.esc(c)}" title="Click to copy"></div>
<div style="display:flex;flex-direction:column;gap:2px"><code style="font-size:1.1em">${ctx.esc(c)}</code>
<span style="font-size:0.75em;opacity:0.4">click swatch to copy</span>
</div>
+89 -10
View File
@@ -34,6 +34,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
let degraded = !ENABLED; // true once we fall back to full re-render
let tailRaf = null; // pending requestAnimationFrame id for the live-tail re-render
let pendingTail = ''; // most recent tail text awaiting a rAF re-render
let tailGeneration = 0;
function start() {
contentEl.textContent = '';
@@ -79,6 +82,32 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
while (holder.firstChild) contentEl.appendChild(holder.firstChild);
}
// Coalesce the per-token tail re-render into at most one render per animation
// frame. Fast providers emit many deltas per frame; the frozen-block pass is
// cheap and stays synchronous, but the markdown re-parse + innerHTML write of
// the still-growing tail only needs to happen once per frame for smooth output.
function scheduleTailRender(tailText) {
pendingTail = tailText;
if (tailRaf !== null) return;
const generation = tailGeneration;
tailRaf = requestAnimationFrame(() => {
if (generation !== tailGeneration) return;
tailRaf = null;
// The container may have been rewritten out from under us (chat.js writes
// contentEl.innerHTML for thinking indicators and tool blocks). Skip and
// let the next update() self-heal rather than appending stale tail nodes.
if (tailMarker && tailMarker.parentNode === contentEl) {
try {
renderTail(pendingTail);
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render in rAF', err);
safeFullRender(lastText);
}
}
});
}
// Stream the body of an unterminated code fence by appending only the new
// characters to a stable <pre><code> text node — no re-parse, no re-highlight.
function appendOpenFence(tailText, fence) {
@@ -137,6 +166,41 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
highlight(contentEl);
}
function safeFullRender(fullText) {
try {
fullRender(fullText);
} catch (err) {
// The source survives even when the parser/highlighter is unavailable.
// textContent also ensures that untrusted message text stays inert.
console.error('streamingRenderer: preserving source as plain text', err);
contentEl.textContent = fullText;
}
}
function cancelPendingTail() {
tailGeneration++;
if (tailRaf !== null) {
cancelAnimationFrame(tailRaf);
tailRaf = null;
}
pendingTail = '';
}
function resetDom() {
cancelPendingTail();
started = false;
tailMarker = null;
committedLen = 0;
tailShownLen = 0;
appendMode = null;
}
function reset() {
resetDom();
lastText = '';
degraded = !ENABLED;
}
// Render the latest full source text.
//
// PRECONDITION: callers must pass append-only text — each call's `fullText` must
@@ -148,7 +212,8 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
function update(fullText) {
lastText = fullText;
if (degraded) {
fullRender(fullText);
cancelPendingTail();
safeFullRender(fullText);
return;
}
try {
@@ -158,24 +223,34 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
// container. Rebuild from scratch so we never append onto foreign content or
// touch a detached marker.
if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
started = false;
committedLen = 0;
tailShownLen = 0;
appendMode = null;
resetDom();
}
if (!started) start();
const next = splitFinalized(fullText, render, committedLen);
if (next > committedLen) {
cancelPendingTail();
clearTail();
freeze(fullText.slice(committedLen, next));
committedLen = next;
appendMode = null; // whatever was streaming is now frozen
tailShownLen = 0;
}
renderTail(fullText.slice(committedLen));
const tail = fullText.slice(committedLen);
if (!tail) {
cancelPendingTail();
clearTail();
tailShownLen = 0;
} else if (tailShownLen === 0) {
cancelPendingTail();
renderTail(tail);
} else {
scheduleTailRender(tail);
}
} catch (err) {
cancelPendingTail();
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
fullRender(fullText);
safeFullRender(fullText);
}
}
@@ -185,7 +260,11 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
// reasons and so doesn't call this, but it completes the renderer's lifecycle and
// is exercised by the tests.
function finalize() {
if (degraded) return;
cancelPendingTail();
if (degraded) {
safeFullRender(lastText);
return;
}
try {
if (!started) start();
clearTail();
@@ -198,9 +277,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
fullRender(lastText);
safeFullRender(lastText);
}
}
return { update, finalize };
return { update, finalize, reset };
}
+1 -1
View File
@@ -3185,7 +3185,7 @@ export function openTasks(focusId, opts) {
<div class="modal-header">
<h4 style="position:relative;top:-2px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3L2 6"/><path d="M22 6l-3-3"/></svg>Tasks</h4>
<span style="flex:1"></span>
<button class="close-btn" id="tasks-close">✖</button>
<button class="close-btn" id="tasks-close" aria-label="Close">✖</button>
</div>
<div class="memory-tabs tasks-tabs" role="tablist">
<button class="memory-tab tasks-tab active" data-tab="tasks" role="tab" aria-selected="true">
+1
View File
@@ -40,6 +40,7 @@ const LS_KEY = 'odysseus-theme';
const CUSTOM_THEMES_KEY = 'odysseus-custom-themes';
const FONT_MAP = {
gohu: "'GohuFont', monospace",
mono: "'Fira Code', monospace",
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
serif: "Georgia, 'Times New Roman', serif",
+402
View File
@@ -0,0 +1,402 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('Chat Photo Editor Action Integrity and Turn Scoping', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const serverResponses = new Map();
const recordedUploadRequests = [];
await page.route('http://photo-test.local/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/') {
return route.fulfill({
contentType: 'text/html',
body: `<!DOCTYPE html>
<html>
<body>
<div id="toast"></div>
<div id="chat-history"></div>
</body>
</html>`,
});
}
if (path.startsWith('/api/upload/')) {
const id = path.replace('/api/upload/', '');
const method = route.request().method();
recordedUploadRequests.push({ id, method });
const status = serverResponses.get(id) || 200;
if (status === 200) {
return route.fulfill({
status: 200,
headers: {
'Content-Type': 'image/png',
'Content-Length': '16',
'X-Content-Type-Options': 'nosniff',
},
body: method === 'HEAD' ? Buffer.alloc(0) : Buffer.from('fake-image-bytes'),
});
}
return route.fulfill({
status,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ detail: 'Upload error or forbidden' }),
});
}
try {
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
} catch (err) {
return route.abort();
}
});
await page.goto('http://photo-test.local/');
const results = await page.evaluate(async () => {
const box = document.getElementById('chat-history');
// Import chat.js to test hooks
await import('/static/js/chat.js');
const hooks = window.__chatPhotoEditorTestHooks;
if (!hooks) throw new Error('Test hooks not exposed on window.__chatPhotoEditorTestHooks');
const { _isAuthorizedImageUrl, _findOriginatingUserMessage, _attachedImageForTurn, renderPhotoEditorApplyButton } = hooks;
// --- Scenario 1: Authorization checks ---
const authValidUpload = _isAuthorizedImageUrl('/api/upload/img_123');
const authValidGallery = _isAuthorizedImageUrl('/api/gallery/img_123');
const authBlob = _isAuthorizedImageUrl('blob:http://photo-test.local/550e8400-e29b-41d4-a716-446655440000');
const authCrossOriginBlob = _isAuthorizedImageUrl('blob:https://evil.com/550e8400-e29b-41d4-a716-446655440000');
const authMalformedBlob = _isAuthorizedImageUrl('blob:javascript:alert(1)');
const pixel = document.createElement('canvas'); pixel.width = pixel.height = 1;
const authDataPng = _isAuthorizedImageUrl(pixel.toDataURL('image/png'));
const authDataJpeg = _isAuthorizedImageUrl(pixel.toDataURL('image/jpeg'));
const authDataWebp = _isAuthorizedImageUrl(pixel.toDataURL('image/webp'));
const authDataGif = _isAuthorizedImageUrl('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
const authDataSvg = _isAuthorizedImageUrl('data:image/svg+xml;base64,PHN2Zz4=');
const authDataHtml = _isAuthorizedImageUrl('data:text/html;base64,PGgxPlRlc3Q=');
const authDataMalformed = _isAuthorizedImageUrl('data:image/png;base64,invalid!@#$');
const authDataOversized = _isAuthorizedImageUrl('data:image/png;base64,' + 'A'.repeat(11 * 1024 * 1024));
const authMaliciousDomain = _isAuthorizedImageUrl('https://evil.com/api/upload/img_123');
const authSchemeRelative = _isAuthorizedImageUrl('//evil.com/api/upload/img_123');
const authJavascript = _isAuthorizedImageUrl('javascript:alert(1)');
const authDirectoryTraversal = _isAuthorizedImageUrl('/api/upload/../../../etc/passwd');
const authEncodedTraversal = _isAuthorizedImageUrl('/api/upload/%2e%2e%2f');
// --- Scenario 2: Multi-turn isolation ---
// Build Turn 1: User attaches img_turn1
const user1 = document.createElement('div');
user1.className = 'msg msg-user';
user1.innerHTML = '<div class="body">Turn 1 text</div><div class="attach-image-preview" data-file-id="img_turn1"><img src="/api/upload/img_turn1" /></div>';
box.appendChild(user1);
const ai1 = document.createElement('div');
ai1.className = 'msg msg-ai round-holder';
ai1.innerHTML = '<div class="body">Turn 1 AI reply</div>';
box.appendChild(ai1);
// Build Turn 2: User attaches NO photo (just text)
const user2 = document.createElement('div');
user2.className = 'msg msg-user';
user2.innerHTML = '<div class="body">Turn 2 text without photo</div>';
box.appendChild(user2);
const ai2 = document.createElement('div');
ai2.className = 'msg msg-ai round-holder';
ai2.innerHTML = '<div class="body">Turn 2 AI reply</div>';
box.appendChild(ai2);
// Build Turn 3: User attaches img_turn3
const user3 = document.createElement('div');
user3.className = 'msg msg-user';
user3.innerHTML = '<div class="body">Turn 3 text</div><div class="attach-card" data-file-id="img_turn3"><img src="/api/upload/img_turn3" /></div>';
box.appendChild(user3);
const ai3 = document.createElement('div');
ai3.className = 'msg msg-ai round-holder';
ai3.innerHTML = '<div class="body">Turn 3 AI reply</div>';
box.appendChild(ai3);
// Resolve images for each turn
const turn1Resolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', prompt: 'edit turn 1' });
const turn2Resolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', prompt: 'edit turn 2' });
const turn3Resolved = _attachedImageForTurn(ai3, { kind: 'open_and_apply', prompt: 'edit turn 3' });
// Action with explicit imageId should honor explicit id
const explicitActionResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999' });
const filenameResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999.png' });
const invalidExplicitResolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', imageUrl: 'data:image/png;base64,=' });
// Action on container with no user message above
const detachedAi = document.createElement('div');
detachedAi.className = 'msg msg-ai';
const detachedResolved = _attachedImageForTurn(detachedAi, { kind: 'open_and_apply' });
return {
authValidUpload,
authValidGallery,
authBlob,
authCrossOriginBlob,
authMalformedBlob,
authDataPng,
authDataJpeg,
authDataWebp,
authDataGif,
authDataSvg,
authDataHtml,
authDataMalformed,
authDataOversized,
authMaliciousDomain,
authSchemeRelative,
authJavascript,
authDirectoryTraversal,
authEncodedTraversal,
turn1Resolved,
turn2Resolved,
turn3Resolved,
explicitActionResolved,
filenameResolved,
invalidExplicitResolved,
detachedResolved,
};
});
// Verify Scenario 1: Authorization
assert.equal(results.authValidUpload, true, 'Valid same-origin upload is authorized');
assert.equal(results.authValidGallery, true, 'Valid same-origin gallery is authorized');
assert.equal(results.authBlob, false, 'Same-origin alone does not authorize a blob URL');
assert.equal(results.authCrossOriginBlob, false, 'Cross-origin blob URL is rejected');
assert.equal(results.authMalformedBlob, false, 'Malformed blob URL is rejected');
assert.equal(results.authDataPng, true, 'PNG data URL is authorized');
assert.equal(results.authDataJpeg, true, 'JPEG data URL is authorized');
assert.equal(results.authDataWebp, true, 'WebP data URL is authorized');
assert.equal(results.authDataGif, true, 'GIF data URL is authorized');
assert.equal(results.authDataSvg, false, 'SVG data URL is rejected');
assert.equal(results.authDataHtml, false, 'HTML data URL is rejected');
assert.equal(results.authDataMalformed, false, 'Malformed data URL is rejected');
assert.equal(results.authDataOversized, false, 'Oversized data URL > 10MB is rejected');
assert.equal(results.authMaliciousDomain, false, 'External malicious domain rejected');
assert.equal(results.authSchemeRelative, false, 'Scheme-relative URL rejected');
assert.equal(results.authJavascript, false, 'Javascript scheme rejected');
assert.equal(results.authDirectoryTraversal, false, 'Directory traversal in path rejected');
assert.equal(results.authEncodedTraversal, false, 'Encoded directory traversal in path rejected');
// Verify Scenario 2: Multi-turn isolation
assert.equal(results.turn1Resolved, '/api/upload/img_turn1', 'Turn 1 AI correctly resolves Turn 1 user image');
assert.equal(results.turn2Resolved, null, 'Turn 2 AI resolves NULL because Turn 2 had no attached image');
assert.equal(results.turn3Resolved, '/api/upload/img_turn3', 'Turn 3 AI correctly resolves Turn 3 user image');
assert.equal(results.explicitActionResolved, '/api/upload/explicit_999', 'Explicit action imageId honored');
assert.equal(results.filenameResolved, '/api/upload/explicit_999.png', 'Canonical filenames keep their extensions');
assert.equal(results.invalidExplicitResolved, null, 'Invalid explicit data never falls back to a different image');
assert.equal(results.detachedResolved, null, 'Detached AI container without user message resolves to null');
// --- Scenario 3: Button click lifecycle (deleted upload, rapid click, open apply) ---
const clickResults = await page.evaluate(async () => {
const hooks = window.__chatPhotoEditorTestHooks;
const { renderPhotoEditorApplyButton } = hooks;
let editorCalls = [];
window.gePhotoEditorApply = async (opts) => {
editorCalls.push(opts);
await new Promise((r) => setTimeout(r, 50));
};
let errorsShown = [];
const fakeUi = {
showError(msg) { errorsShown.push(msg); },
showToast(msg) {},
};
// Case A: Missing attachment on Turn 2
const ai2 = document.querySelectorAll('.msg-ai')[1];
renderPhotoEditorApplyButton(ai2, { kind: 'open_and_apply', prompt: 'test turn 2' });
const btn2 = ai2.querySelector('.chat-open-in-editor-btn');
btn2.click();
await new Promise((r) => setTimeout(r, 20));
const btn2DisabledAfterMissing = btn2.disabled;
// Case B: Deleted upload (server returns 404)
const userDel = document.createElement('div');
userDel.className = 'msg msg-user';
userDel.innerHTML = '<div class="attach-image-preview" data-file-id="img_deleted"><img src="/api/upload/img_deleted" /></div>';
document.getElementById('chat-history').appendChild(userDel);
const aiDel = document.createElement('div');
aiDel.className = 'msg msg-ai round-holder';
document.getElementById('chat-history').appendChild(aiDel);
renderPhotoEditorApplyButton(aiDel, { kind: 'open_and_apply', prompt: 'test deleted' });
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
return {
hasBtn2: !!btn2,
btn2DisabledAfterMissing,
};
});
assert.equal(clickResults.hasBtn2, true);
assert.equal(clickResults.btn2DisabledAfterMissing, false, 'Button re-enabled after missing attachment');
// Set server status 404 for img_deleted
serverResponses.set('img_deleted', 404);
const deletedResults = await page.evaluate(async () => {
const aiDel = document.querySelectorAll('.msg-ai')[3];
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
btnDel.click();
await new Promise((r) => setTimeout(r, 50));
return {
disabled: btnDel.disabled,
};
});
assert.equal(deletedResults.disabled, false, 'Button re-enabled when server returns 404');
// Case C: Valid attachment on Turn 1 click launches editor
const validResults = await page.evaluate(async () => {
let editorCalls = [];
window.gePhotoEditorApply = async (opts) => {
editorCalls.push(opts);
};
const ai1 = document.querySelectorAll('.msg-ai')[0];
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
renderPhotoEditorApplyButton(ai1, { kind: 'open_and_apply', prompt: 'make b&w' });
const btn1 = ai1.querySelector('.chat-open-in-editor-btn');
// Rapid clicks: trigger multiple synchronous clicks
btn1.click();
btn1.click();
btn1.click();
await new Promise((r) => setTimeout(r, 50));
return {
editorCallsCount: editorCalls.length,
editorCall: editorCalls[0],
};
});
assert.equal(validResults.editorCallsCount, 1, 'Rapid clicks deduplicated, editor called exactly once');
assert.equal(validResults.editorCall.imageUrl, '/api/upload/img_turn1', 'Editor opened with Turn 1 image');
assert.equal(validResults.editorCall.prompt, 'make b&w');
// Case D: Verify HEAD probe was used for verification
const headProbeObserved = recordedUploadRequests.some((r) => r.id === 'img_turn1' && r.method === 'HEAD');
assert.equal(headProbeObserved, true, 'Chat-to-editor preflight executed an authorized HEAD probe');
// Case E: Unauthorized upload returns 403, surfaces error and re-enables button
serverResponses.set('img_forbidden', 403);
const forbiddenResults = await page.evaluate(async () => {
const userForbidden = document.createElement('div');
userForbidden.className = 'msg msg-user';
userForbidden.innerHTML = '<div class="attach-image-preview" data-file-id="img_forbidden"><img src="/api/upload/img_forbidden" /></div>';
document.getElementById('chat-history').appendChild(userForbidden);
const aiForbidden = document.createElement('div');
aiForbidden.className = 'msg msg-ai round-holder';
document.getElementById('chat-history').appendChild(aiForbidden);
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
renderPhotoEditorApplyButton(aiForbidden, { kind: 'open_and_apply', prompt: 'forbidden test' });
const btnForb = aiForbidden.querySelector('.chat-open-in-editor-btn');
btnForb.click();
await new Promise((r) => setTimeout(r, 50));
return {
disabled: btnForb.disabled,
};
});
assert.equal(forbiddenResults.disabled, false, 'Button re-enabled when server returns 403');
} finally {
await browser.close();
}
});
test('editor actions validate raster bytes, dimensions, decode and reject all unverified blobs before launch', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const apiRequests = [];
await page.route('http://raster.local/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="chat-history"></div>' });
if (path === '/api/gallery/owned') { apiRequests.push(path); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'owned', url: '/api/generated-image/owned.png' }) }); }
if (path === '/api/gallery/foreign') { apiRequests.push(path); return route.fulfill({ status: 404, body: '' }); }
if (path.startsWith('/api/upload/')) { apiRequests.push(path); return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png' }, body: '' }); }
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
catch { return route.fulfill({ status: 404, body: '' }); }
});
await page.goto('http://raster.local/');
const results = await page.evaluate(async () => {
await import('/static/js/chat.js');
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const png = canvas.toDataURL('image/png');
const bytes = Uint8Array.from(atob(png.split(',')[1]), ch => ch.charCodeAt(0));
const encode = bytes => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return 'data:image/png;base64,' + btoa(text); };
const headerDimensions = (w, h) => { const b = bytes.slice(); const v = new DataView(b.buffer); v.setUint32(16, w); v.setUint32(20, h); return encode(b); };
const invalidDecode = bytes.slice(0, 33); // valid signature/dimensions but no pixel data
const ownBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
const revoked = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); URL.revokeObjectURL(revoked);
// This action intentionally has no blob registration API. Even application-created
// object URLs must use their authorized server attachment or raster data source.
const cases = [
['upload', '/api/upload/owned', true], ['gallery', '/api/gallery/owned', true],
['png', png, true], ['jpeg', canvas.toDataURL('image/jpeg'), true], ['webp', canvas.toDataURL('image/webp'), true],
['gif', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', true],
['padding-one', 'data:image/png;base64,=', false], ['padding-length', 'data:image/png;base64,A', false],
['padding-middle', 'data:image/png;base64,AA=AA', false], ['padding-excess', 'data:image/png;base64,AAAA====', false], ['foreign-gallery', '/api/gallery/foreign', false],
['pad-bits', 'data:image/png;base64,AB==', false], ['bytes', 'data:image/png;base64,AAAA', false],
['mismatch', png.replace('image/png', 'image/jpeg'), false], ['truncated-image', encode(invalidDecode), false],
['svg', 'data:image/svg+xml;base64,PHN2Zy8+', false], ['html', 'data:text/html;base64,PGgxLz4=', false],
['svg-disguised', 'data:image/png;base64,PHN2Zy8+', false],
['foreign-blob', 'blob:https://foreign.invalid/deadbeef', false], ['arbitrary-blob', 'blob:http://raster.local/deadbeef', false],
['application-created-blob', ownBlob, false], ['revoked-blob', revoked, false],
['oversized-url', 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024), false],
['oversized-decoded-bytes', 'data:image/png;base64,' + 'A'.repeat(4 * Math.ceil((7 * 1024 * 1024 + 3) / 3)), false],
['dimension', headerDimensions(8193, 1), false], ['pixel-budget', headerDimensions(8192, 8192), false],
['scheme-relative', '//raster.local/api/upload/owned', false], ['encoded-traversal', '/api/upload/%2e%2e/owned', false],
];
const oldDecode = window.createImageBitmap; let decodes = 0;
window.createImageBitmap = (...args) => { decodes++; return oldDecode(...args); };
let launches = []; window.gePhotoEditorApply = async opts => launches.push(opts.imageUrl);
const output = [];
for (const [name, url, accepted] of cases) {
const host = document.createElement('div'); document.body.append(host);
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: url, prompt: 'synthetic edit' });
const btn = host.querySelector('button'); const before = launches.length; const decodeBefore = decodes;
btn.click(); btn.click(); btn.click();
while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5));
output.push({ name, accepted, launches: launches.length - before, decodes: decodes - decodeBefore }); host.remove();
}
// Cache reuse across repeated activation of the same complete inline image.
const host = document.createElement('div'); document.body.append(host);
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: png, prompt: 'repeat' });
const btn = host.querySelector('button'); const before = decodes;
for (let i = 0; i < 3; i++) { btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); }
const cachedDecodes = decodes - before;
URL.revokeObjectURL(ownBlob);
return { cases: output, cachedDecodes, launches };
});
assert.deepEqual(results.cases.filter(item => item.launches !== (item.accepted ? 1 : 0)).map(item => item.name), [], 'every raster authorization case must match');
for (const name of ['dimension', 'pixel-budget', 'oversized-url', 'oversized-decoded-bytes', 'bytes', 'mismatch']) {
assert.equal(results.cases.find(item => item.name === name).decodes, 0, name + ' rejected before browser allocation');
}
assert.equal(results.cases.find(item => item.name === 'truncated-image').decodes, 1, 'actual decoder rejects incomplete image');
assert.equal(results.cachedDecodes, 1, 'same image only decoded once for repeated activation');
assert.deepEqual(apiRequests, ['/api/upload/owned', '/api/gallery/owned', '/api/gallery/foreign']);
assert.ok(results.launches.includes('http://raster.local/api/generated-image/owned.png'));
} finally { await browser.close(); }
});
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const START = '// === copy-chat header (debug trace block) ===';
const END = '// === end copy-chat header ===';
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
test('copy-chat header has exact shape and values', () => {
const header = _buildCopyChatHeader({
version: '1.0.3',
build: '20261006.1',
sourceCommit: 'a1b2c3d4e5f6',
model: 'gpt-4o',
endpointUrl: 'https://api.example.com/v1',
toolSurface: 'Ajax Spec (router)',
sessionId: 'sess-123',
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
});
const expected = [
'--- Odysseus info ---',
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('unknown fields are never omitted', () => {
const header = _buildCopyChatHeader({});
const expected = [
'--- Odysseus info ---',
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
'Model: unknown @ unknown | Tool surface: unknown',
'Copied: unknown JST | Session: unknown',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('closing separator is exactly 22 dashes', () => {
const header = _buildCopyChatHeader({});
const lines = header.split('\n');
assert.equal(lines.length, 5);
assert.equal(lines[lines.length - 1], '----------------------');
assert.equal(lines[lines.length - 1].length, 22);
});
test('tool surface labels map modes to human labels', () => {
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
assert.equal(_toolSurfaceLabel(''), 'Auto');
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
assert.equal(_toolSurfaceLabel(null), 'Auto');
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
});
test('JST timestamp is UTC+9 with HH:MM padding', () => {
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN || 'http://127.0.0.1:7011';
const browser = await chromium.launch({ headless: true });
try {
for (const width of [390, 900]) {
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
if (!process.env.ODYSSEUS_TEST_STATIC_ORIGIN) {
await page.route(`${origin}/**`, async route => {
const url = new URL(route.request().url());
const rel = url.pathname.replace(/^\//, '');
try {
const file = new URL('../' + rel, import.meta.url);
const body = await readFile(file);
const ext = rel.split('.').pop();
const type = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'application/octet-stream';
return route.fulfill({ contentType: type, body });
} catch {
return route.fulfill({ status: 404, body: 'Not found' });
}
});
}
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?mobile-export-regression=1');
mod.init('/api');
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
});
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
});
await page.locator('#doc-footer-export-btn').click();
const menu = page.locator('#doc-export-menu');
await menu.waitFor({ state: 'visible' });
const bounds = await menu.boundingBox();
assert(bounds.x >= 0 && bounds.y >= 0);
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
// A second click closes the menu, then reopening must still allow export.
await page.locator('#doc-footer-export-btn').click();
assert.equal(await menu.count(), 0);
await page.locator('#doc-footer-export-btn').click();
const pending = page.waitForEvent('download');
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
const download = await pending;
assert(download.suggestedFilename().endsWith('.md'));
assert.equal(await download.failure(), null);
const stream = await download.createReadStream();
let content = '';
for await (const chunk of stream) content += chunk;
assert.equal(content, '# Mobile export\n\nActual document content.');
await page.close();
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
}
} finally {
await browser.close();
}
+268
View File
@@ -0,0 +1,268 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('Gallery and Quick Edit Model Picker: identity, privacy, and synchronization', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const mockModels = [
{
id: 'sdxl',
label: 'Stable Diffusion XL (Primary)',
kind: 'edit',
provider: 'Cluster A',
endpoint_id: 101,
// Non-admin view: base_url omitted
},
{
id: 'sdxl',
label: 'Stable Diffusion XL (Secondary)',
kind: 'edit',
provider: 'Cluster B',
endpoint_id: 202,
// Non-admin view: base_url omitted
},
{
id: 'qwen-image',
label: 'Qwen Image Edit',
kind: 'edit',
provider: 'Local Seneca',
endpoint_id: 303,
},
];
await page.route('http://gallery-sync.local/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/') {
return route.fulfill({
contentType: 'text/html',
body: `<!DOCTYPE html>
<html>
<body>
<div id="gallery-settings-container">
<select id="gallery-image-model"></select>
</div>
<div id="editor-container">
<select class="ge-ai-command-model" id="ge-ai-command-model"></select>
<div id="ge-status-model"></div>
</div>
</body>
</html>`,
});
}
if (path === '/api/editor/image-models') {
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: mockModels }),
});
}
if (path === '/api/editor/agent-models') {
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: [] }),
});
}
try {
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
} catch (err) {
return route.abort();
}
});
await page.goto('http://gallery-sync.local/');
// --- Test 1: Option value encoding and distinct endpoint identities ---
const evalResult1 = await page.evaluate(async () => {
// Set legacy localStorage value that contained a private base URL
localStorage.setItem('ge-ai-command-model', 'http://private-cluster.internal/v1::qwen-image');
const { wireAIModelSelectors } = await import('/static/js/editor/ai-models.js');
wireAIModelSelectors({ container: document.body, apiBase: '' });
// Run gallery initialization
const { openGallery } = await import('/static/js/gallery.js');
try { openGallery(); } catch (_) {}
// Wait a moment for network fetch
await new Promise((r) => setTimeout(r, 80));
const gallerySel = document.getElementById('gallery-image-model');
const qeSel = document.getElementById('ge-ai-command-model');
const galleryOpts = Array.from(gallerySel.options).map((o) => ({ value: o.value, text: o.textContent }));
const qeOpts = Array.from(qeSel.options).map((o) => ({ value: o.value, text: o.textContent }));
const migratedKey = localStorage.getItem('ge-ai-command-model');
return {
galleryOpts,
qeOpts,
galleryValue: gallerySel.value,
qeValue: qeSel.value,
migratedKey,
};
});
// Check duplicate model id 'sdxl' produces distinguishable values with endpoint_id
const sdxlGalleryOpts = evalResult1.galleryOpts.filter((o) => o.value.endsWith('::sdxl'));
assert.equal(sdxlGalleryOpts.length, 2, 'Two endpoints offering sdxl produce two distinct options');
assert.equal(sdxlGalleryOpts[0].value, '101::sdxl');
assert.equal(sdxlGalleryOpts[1].value, '202::sdxl');
// Check no options contain undefined or empty prefix
for (const opt of evalResult1.galleryOpts) {
assert.equal(opt.value.startsWith('::'), false, 'Option value must not start with empty ::');
assert.equal(opt.value.includes('undefined'), false, 'Option value must not include undefined');
assert.equal(opt.value.includes('http://'), false, 'Option value must not expose raw http URL');
}
// Check legacy migration: 'http://private-cluster.internal/v1::qwen-image' was migrated to '303::qwen-image'
assert.equal(evalResult1.migratedKey, '303::qwen-image', 'Legacy key with URL migrated cleanly');
assert.equal(evalResult1.galleryValue, '303::qwen-image', 'Gallery picker selected migrated model');
assert.equal(evalResult1.qeValue, '303::qwen-image', 'Quick Edit picker selected migrated model');
// --- Test 2: Same-tab synchronization (Gallery -> Quick Edit) ---
const evalResult2 = await page.evaluate(async () => {
const gallerySel = document.getElementById('gallery-image-model');
gallerySel.value = '101::sdxl';
gallerySel.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 40));
const qeSel = document.getElementById('ge-ai-command-model');
const statusEl = document.getElementById('ge-status-model');
const savedKey = localStorage.getItem('ge-ai-command-model');
return {
qeValue: qeSel.value,
statusText: statusEl ? statusEl.textContent : '',
savedKey,
};
});
assert.equal(evalResult2.qeValue, '101::sdxl', 'Changing gallery picker updates quick-edit picker in same tab');
assert.equal(evalResult2.savedKey, '101::sdxl', 'Changing gallery picker updates localStorage');
// --- Test 3: Same-tab synchronization (Quick Edit -> Gallery) ---
const evalResult3 = await page.evaluate(async () => {
const qeSel = document.getElementById('ge-ai-command-model');
qeSel.value = '202::sdxl';
qeSel.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 40));
const gallerySel = document.getElementById('gallery-image-model');
const savedKey = localStorage.getItem('ge-ai-command-model');
return {
galleryValue: gallerySel.value,
savedKey,
};
});
assert.equal(evalResult3.galleryValue, '202::sdxl', 'Changing quick-edit picker updates gallery picker in same tab');
assert.equal(evalResult3.savedKey, '202::sdxl', 'Changing quick-edit picker updates localStorage');
// --- Test 4: Cross-tab synchronization via storage event ---
const evalResult4 = await page.evaluate(async () => {
// Simulate storage event from another tab
window.dispatchEvent(new StorageEvent('storage', {
key: 'ge-ai-command-model',
newValue: '101::sdxl',
}));
await new Promise((r) => setTimeout(r, 40));
const gallerySel = document.getElementById('gallery-image-model');
const qeSel = document.getElementById('ge-ai-command-model');
return {
galleryValue: gallerySel.value,
qeValue: qeSel.value,
};
});
assert.equal(evalResult4.galleryValue, '101::sdxl', 'Storage event updates gallery picker');
assert.equal(evalResult4.qeValue, '101::sdxl', 'Storage event updates quick-edit picker');
} finally {
await browser.close();
}
});
test('picker reconstruction owns listeners and clears invalid persisted identities', async () => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
let imageRequests = 0;
await context.route('http://picker-lifecycle.local/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="ge-status-model"></div><div id="editor-container"><select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select></div>' });
if (path.startsWith('/api/')) {
if (path.endsWith('/image-models')) imageRequests++;
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: path.endsWith('/image-models') ? [{ id: 'qwen-image', endpoint_id: 'alice', kind: 'edit', provider: 'A' }] : [], items: [], albums: [] }) });
}
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
catch { return route.fulfill({ status: 404, body: '' }); }
});
const page = await context.newPage();
await page.addInitScript(() => {
window.modelListeners = {};
const add = window.addEventListener.bind(window);
window.addEventListener = (type, ...args) => { if (['storage', 'ge-ai-command-model-change', 'ge:model-endpoints-updated'].includes(type)) window.modelListeners[type] = (window.modelListeners[type] || 0) + 1; return add(type, ...args); };
window.modelEvents = 0;
add('ge-ai-command-model-change', () => window.modelEvents++);
});
const init = async p => {
await p.goto('http://picker-lifecycle.local/');
await p.evaluate(async () => {
window.ai = await import('/static/js/editor/ai-models.js');
window.gallery = await import('/static/js/gallery.js');
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
window.gallery.openGallery();
});
await p.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
};
await init(page);
const initial = await page.evaluate(() => ({ ...window.modelListeners }));
for (let i = 0; i < 3; i++) {
await page.evaluate(() => { window.gallery.closeGallery(); window.oldSelect = document.getElementById('ge-ai-command-model'); window.oldValue = window.oldSelect.value; document.getElementById('editor-container').replaceChildren(); });
await page.waitForFunction(() => !document.getElementById('gallery-modal'));
await page.evaluate(() => {
document.getElementById('editor-container').innerHTML = '<select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select>';
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
window.gallery.openGallery();
});
await page.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
}
assert.deepEqual(await page.evaluate(() => window.modelListeners), initial, 'no new global listeners after reopen/reconstruction');
const before = imageRequests;
await page.evaluate(() => window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated')));
await page.waitForTimeout(100);
assert.equal(imageRequests - before, 1, 'only live editor refreshes; no detached control handlers');
const p2 = await context.newPage(); await init(p2);
await page.evaluate(() => { window.modelEvents = 0; window.footerUpdates = 0; new MutationObserver(records => window.footerUpdates += records.length).observe(document.getElementById('ge-status-model'), { childList: true }); const s = document.getElementById('ge-ai-command-model'); s.value = 'alice::qwen-image'; s.dispatchEvent(new Event('change')); });
await p2.waitForFunction(() => document.getElementById('gallery-image-model').value === 'alice::qwen-image');
assert.equal(await page.evaluate(() => window.modelEvents), 1);
assert.equal(await page.evaluate(() => window.footerUpdates), 1, 'exactly one footer update');
assert.match(await page.locator('#ge-status-model').textContent(), /Edit:.*qwen-image/);
assert.equal(await page.evaluate(() => window.oldSelect.value), await page.evaluate(() => window.oldValue), 'global events never update detached controls');
await init(p2);
assert.equal(await p2.locator('#gallery-image-model').inputValue(), 'alice::qwen-image');
for (const invalid of ['deleted::qwen-image', 'disabled::qwen-image', 'https://private.invalid/v1::missing']) {
await page.evaluate(value => localStorage.setItem('ge-ai-command-model', value), invalid);
await init(page);
assert.equal(await page.evaluate(() => localStorage.getItem('ge-ai-command-model')), null, invalid);
assert.equal(await page.locator('#gallery-image-model').inputValue(), '');
assert.equal(await page.locator('#ge-ai-command-model').inputValue(), '');
}
} finally { await browser.close(); }
});
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
const browser = await chromium.launch({ headless: true });
try {
for (const touch of [true, false]) {
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
await page.evaluate(({ build, state, placement }) => {
new Function(`
const activeDocId = 'test', docId = activeDocId;
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
let _pdfDropMode = 'check', serial = 0;
const _newAnnotationId = () => 'new-' + (++serial);
const _pushPdfUndoSnapshot = () => {};
const _schedulePdfPaneSave = () => {};
const pageWrap = document.getElementById('page');
const page = {page: 1};
${state}
${build}
${placement}
for (let i = 0; i < 2; i++) {
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
annotationRefs.push(ref);
ref._setHandlesVisible(true);
}
window.refs = annotationRefs;
`)();
}, { build, state, placement });
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
const box = await grip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(300, 220);
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
// Proximity updates must not bring controls back while dragging.
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
await page.mouse.up();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
await page.locator('#page').click({ position: { x: 100, y: 450 } });
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
await page.close();
}
} finally {
await browser.close();
}
+252
View File
@@ -0,0 +1,252 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('StreamingRenderer: rAF coalescing and lifecycle cancellation', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://streaming.test/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/') {
return route.fulfill({ contentType: 'text/html', body: '<!DOCTYPE html><html><body><div id="root"></div></body></html>' });
}
try {
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
} catch (err) {
return route.abort();
}
});
await page.goto('http://streaming.test/');
const result = await page.evaluate(async () => {
const container = document.getElementById('root');
// Custom mock for rAF to control frame timing
const originalRaf = window.requestAnimationFrame;
const originalCancelRaf = window.cancelAnimationFrame;
let rafId = 0;
const pendingRfs = new Map();
window.requestAnimationFrame = (cb) => {
const id = ++rafId;
pendingRfs.set(id, cb);
return id;
};
window.cancelAnimationFrame = (id) => {
pendingRfs.delete(id);
};
const stepFrames = () => {
const callbacks = Array.from(pendingRfs.values());
pendingRfs.clear();
for (const cb of callbacks) cb(performance.now());
return callbacks.length;
};
const { createStreamRenderer } = await import('/static/js/streamingRenderer.js');
const options = { render: (text) => `<p>${text}</p>` };
// Test 1: Fast bursts within a frame coalesce into a single rAF render
const host1 = document.createElement('div');
container.appendChild(host1);
const renderer1 = createStreamRenderer(host1, options);
// Initial token renders synchronously to remove spinner immediately
renderer1.update('A');
const textAfterFirst = host1.textContent;
// Now stream 5 rapid tokens in the same frame
renderer1.update('AB');
renderer1.update('ABC');
renderer1.update('ABCD');
renderer1.update('ABCDE');
// Before frame fires, only the scheduled pendingTail exists
const textBeforeFrame = host1.textContent;
const frameExecuted = stepFrames();
const textAfterFrame = host1.textContent;
// Test 2: Finalize cancels pending rAF callback and freezes remaining text
const host2 = document.createElement('div');
container.appendChild(host2);
const renderer2 = createStreamRenderer(host2, options);
renderer2.update('First');
renderer2.update('First, more rapid streaming');
const pendingBeforeFinalize = pendingRfs.size;
renderer2.finalize();
const pendingAfterFinalize = pendingRfs.size;
const textAfterFinalize = host2.textContent;
// Executing any residual frames must do nothing because cancelAnimationFrame was called
const residualSteps = stepFrames();
const textAfterResidual = host2.textContent;
// Test 3: Reset clears state and cancels pending frames
const host3 = document.createElement('div');
container.appendChild(host3);
const renderer3 = createStreamRenderer(host3, options);
renderer3.update('Start');
renderer3.update('Start and streaming tokens...');
renderer3.reset();
const pendingAfterReset = pendingRfs.size;
// Test 4: Finalized blocks (\n\n) freeze synchronously
const host4 = document.createElement('div');
container.appendChild(host4);
const renderer4 = createStreamRenderer(host4, options);
renderer4.update('Paragraph 1\n\n');
renderer4.update('Paragraph 1\n\nParagraph 2');
stepFrames();
renderer4.finalize();
// Test 5: DOM marker replacement during streaming preserves text across immediate finalize
const host5 = document.createElement('div');
container.appendChild(host5);
const renderer5 = createStreamRenderer(host5, options);
renderer5.update('First chunk\n\n');
stepFrames();
// Simulate container rewrite by thinking indicator or tool card
host5.innerHTML = '<div class="thinking">Thinking...</div>';
// Stream update on replaced DOM
renderer5.update('First chunk\n\nSecond chunk following replace');
stepFrames();
// Finalize immediately
renderer5.finalize();
const host5FinalText = host5.textContent.trim();
// Test 6: Asynchronous exception inside rAF callback falls back to degraded full render safely
const host6 = document.createElement('div');
container.appendChild(host6);
let crashTailRender = false;
let tailCrashes = 0;
const unstableOptions = {
render: (text) => {
if (crashTailRender && text === 'pending burst') {
tailCrashes++;
throw new Error('Simulated partial tail rAF render crash');
}
return `<p>${text}</p>`;
}
};
const renderer6 = createStreamRenderer(host6, unstableOptions);
renderer6.update('Stable start\n\n');
stepFrames();
crashTailRender = true;
renderer6.update('Stable start\n\npending');
renderer6.update('Stable start\n\npending burst');
// Step frame: triggers renderTail inside rAF, which catches and falls back to full render
stepFrames();
crashTailRender = false;
renderer6.finalize();
const host6FinalText = host6.textContent.trim();
// Restore
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancelRaf;
return {
textAfterFirst,
textBeforeFrame,
frameExecuted,
textAfterFrame,
pendingBeforeFinalize,
pendingAfterFinalize,
textAfterFinalize,
residualSteps,
textAfterResidual,
pendingAfterReset,
host4Text: host4.textContent.trim(),
host5FinalText,
host6FinalText,
tailCrashes,
};
});
assert.equal(result.tailCrashes, 1, 'partial tail error path genuinely entered');
assert.equal(result.textAfterFirst, 'A', 'First token renders synchronously for immediate responsiveness');
assert.equal(result.frameExecuted, 1, 'Multiple rapid updates coalesce into a single animation frame');
assert.equal(result.textAfterFrame, 'ABCDE', 'Coalesced tail renders full updated content');
assert.equal(result.pendingBeforeFinalize > 0, true, 'Pending rAF was queued during rapid streaming');
assert.equal(result.pendingAfterFinalize, 0, 'finalize() successfully cancels pending animation frames');
assert.equal(result.textAfterFinalize, 'First, more rapid streaming', 'finalize() rendered complete final text');
assert.equal(result.residualSteps, 0, 'No orphaned rAF callbacks executed after finalize');
assert.equal(result.pendingAfterReset, 0, 'reset() cancels pending frames');
assert.equal(result.host4Text.includes('Paragraph 1') && result.host4Text.includes('Paragraph 2'), true);
assert.equal(
result.host5FinalText.includes('First chunk') && result.host5FinalText.includes('Second chunk following replace'),
true,
'finalize() after self-healing preserves full text without erasing content'
);
assert.equal(
result.host6FinalText,
'Stable start\n\npending burst',
'rAF callback error triggers degraded full render without destroying content'
);
} finally {
await browser.close();
}
});
test('combined rAF/full-render failures preserve authoritative text and recover canonically', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://recovery.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="root"></div>' });
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') });
});
await page.goto('http://recovery.test/');
const cases = await page.evaluate(async () => {
const { createStreamRenderer } = await import('/static/js/streamingRenderer.js');
const { mdToHtml } = await import('/static/js/markdown.js');
const canonical = text => { const d = document.createElement('div'); d.innerHTML = mdToHtml(text); return d.innerHTML.replace(/>\s*\n\s*</g, '><').trim(); };
let next = 0; const pending = new Map(); const cancelled = [];
window.requestAnimationFrame = cb => { pending.set(++next, cb); return next; };
window.cancelAnimationFrame = id => { if (pending.has(id)) cancelled.push(pending.get(id)); pending.delete(id); };
const frame = () => { const callbacks = [...pending.values()]; pending.clear(); callbacks.forEach(cb => cb()); };
const results = [];
for (const recover of [false, true]) {
const el = document.createElement('div'); let broken = false; let throws = 0;
const renderer = createStreamRenderer(el, { render: text => { if (broken) { throws++; throw Error('both render paths fail'); } return mdToHtml(text); } });
renderer.update('prefix\n\ntail'); renderer.update('prefix\n\ntail plus'); broken = true;
frame(); // auditor reproduction: both tail and full fallback must actually throw
const afterFailure = el.textContent;
if (recover) broken = false;
renderer.finalize();
const expected = 'prefix\n\ntail plus';
results.push({ recover, throws, afterFailure, text: el.textContent, html: el.innerHTML, canonical: canonical(expected) });
broken = false;
renderer.update(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd'); renderer.finalize();
results.at(-1).subsequentCanonical = el.innerHTML.replace(/>\s*\n\s*</g, '><').trim() === canonical(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd');
}
// Broken first-token/synchronous path must also preserve text safely.
const el = document.createElement('div'); const bad = createStreamRenderer(el, { render: () => { throw Error('always broken'); } });
bad.update('<img src=x onerror=alert(1)>\n```js\nx\n'); bad.finalize();
results.push({ safeText: el.textContent, children: el.childElementCount });
// Pending callback, DOM rewrite, self-healing, then immediate finalize.
const host = document.createElement('div'); const healed = createStreamRenderer(host, { render: mdToHtml });
healed.update('A'); healed.update('AB'); host.innerHTML = 'thinking'; healed.update('ABC'); healed.update('ABCD'); healed.finalize();
const before = host.innerHTML; frame(); cancelled.forEach(cb => cb());
results.push({ healed: host.textContent, stable: before === host.innerHTML, pending: pending.size });
return results;
});
for (const result of cases.slice(0, 2)) {
assert.ok(result.throws >= 2, 'both incremental and full render actually threw');
assert.equal(result.afterFailure, 'prefix\n\ntail plus', 'complete safe text after failure');
if (result.recover) assert.equal(result.html.replace(/>\s*\n\s*</g, '><').trim(), result.canonical);
else assert.equal(result.text, 'prefix\n\ntail plus');
assert.equal(result.subsequentCanonical, true);
}
assert.equal(cases[2].safeText, '<img src=x onerror=alert(1)>\n```js\nx\n');
assert.equal(cases[2].children, 0, 'fallback never interprets HTML');
assert.deepEqual(cases[3], { healed: 'ABCD', stable: true, pending: 0 });
} finally { await browser.close(); }
});
-135
View File
@@ -1,135 +0,0 @@
"""Real admin gates must run before MCP and diagnostic data access."""
import builtins
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from core.auth import AuthManager
from routes.auth_routes import SESSION_COOKIE
from tests.helpers.database import disposable_database
@pytest.fixture
def authenticated_app(tmp_path, monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "true")
auth = AuthManager(str(tmp_path / "auth.json"))
for user, admin in (("member", False), ("admin", True)):
assert auth.create_user(user, "disposable-test-password", is_admin=admin)
app = FastAPI()
app.state.auth_manager = auth
@app.middleware("http")
async def fixture_session(request, call_next):
# Supply the authenticated principal, without replacing require_admin.
request.state.current_user = auth.get_username_for_token(request.cookies.get(SESSION_COOKIE))
return await call_next(request)
def client_for(user):
client = TestClient(app)
if user != "anonymous":
token = auth.create_session(user, "disposable-test-password")
assert token
client.cookies.set(SESSION_COOKIE, token)
return client
return app, client_for
@pytest.mark.parametrize("user", ["anonymous", "member", "admin"])
@pytest.mark.parametrize("path", ["/servers", "/tools", "/servers/private-server/tools"])
def test_mcp_listing_requires_real_admin_before_data_access(
tmp_path, monkeypatch, authenticated_app, user, path,
):
from core.database import McpServer
from routes.mcp import mcp_routes
app, client_for = authenticated_app
with disposable_database(tmp_path) as factory:
with factory() as db:
db.add(McpServer(
id="private-server", name="private-config-marker", transport="stdio",
command="fixture-only", args="[]", env=json.dumps({"FIXTURE": "private-config-marker"}),
))
db.commit()
session = Mock(wraps=factory)
manager = SimpleNamespace(
get_server_status=Mock(return_value={"status": "connected", "tool_count": 1}),
get_all_tools=Mock(return_value=[{
"server_id": "private-server", "name": "private-tool", "description": "private-config-marker",
}]),
)
# setup_mcp_routes otherwise appends to a shared process-global router.
monkeypatch.setattr(mcp_routes, "router", APIRouter(prefix="/api/mcp"))
monkeypatch.setattr(mcp_routes, "SessionLocal", session)
app.include_router(mcp_routes.setup_mcp_routes(manager))
with client_for(user) as client:
response = client.get("/api/mcp" + path)
if user == "admin":
assert response.status_code == 200
assert "private-config-marker" in response.text
payload = response.json()
if path == "/servers":
assert payload[0]["env"] == {"FIXTURE": "private-config-marker"}
else:
assert payload[0]["name"] == "private-tool"
else:
assert response.status_code == 403
assert response.json() == {"detail": "Admin only"}
assert "private-config-marker" not in response.text
session.assert_not_called()
manager.get_server_status.assert_not_called()
manager.get_all_tools.assert_not_called()
@pytest.mark.parametrize("user", ["anonymous", "member", "admin"])
@pytest.mark.parametrize("method,path", [
("GET", "/api/db/stats"),
("GET", "/api/rag/stats"),
("GET", "/api/test/youtube?url=https://youtu.be/fixture"),
("POST", "/api/test-research"),
("GET", "/api/diagnostics/services"),
("GET", "/api/diagnostics/logs"),
])
def test_diagnostics_require_real_admin_before_sensitive_work(
tmp_path, monkeypatch, authenticated_app, user, method, path,
):
import core.database as database
import src.service_health as health
from routes import diagnostics_routes as routes
app, client_for = authenticated_app
marker = "private-diagnostics-marker"
stats = Mock(return_value={"marker": marker})
rag = SimpleNamespace(get_stats=Mock(return_value={"marker": marker}))
research = SimpleNamespace(call_research_service=AsyncMock(return_value=marker))
extract_id = Mock(return_value="fixture")
transcript = AsyncMock(return_value={"success": True, "transcript": marker})
collect = AsyncMock(return_value={"marker": marker})
log_dir = tmp_path / "logs"
log_dir.mkdir()
(log_dir / "app.log").write_text(marker + "\n", encoding="utf-8")
read_log = Mock(wraps=builtins.open)
monkeypatch.setattr(database, "get_detailed_stats", stats)
monkeypatch.setattr(routes, "extract_youtube_id", extract_id)
monkeypatch.setattr(routes, "extract_transcript_async", transcript)
monkeypatch.setattr(health, "collect_service_health", collect)
monkeypatch.setattr(routes, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(routes, "open", read_log, raising=False)
app.include_router(routes.setup_diagnostics_routes(rag, True, research))
with client_for(user) as client:
response = client.request(method, path)
if user == "admin":
assert response.status_code == 200
assert marker in response.text
else:
assert response.status_code == 403
assert response.json() == {"detail": "Admin only"}
assert marker not in response.text
for operation in (stats, rag.get_stats, research.call_research_service,
extract_id, transcript, collect, read_log):
operation.assert_not_called()
+2 -1
View File
@@ -352,7 +352,8 @@ def test_footer_shows_active_edit_model():
assert "el.textContent = `Edit: ${label}`;" in models
# Wired both after load and on inpaint-select change.
assert "updateFooterEditModel();" in models
assert "aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());" in models
assert "listen(aiInpaintSelect, 'change', () => updateFooterEditModel());" in models
assert "signal: listeners.signal" in models
def test_quick_edit_has_a_model_picker():
@@ -79,6 +79,14 @@ def test_swr_contract_is_wired_into_the_list_path():
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
def test_frontend_email_library_recognizes_skip_fast_path():
lib_path = pathlib.Path(__file__).resolve().parent.parent / "static" / "js" / "emailLibrary" / "index.js"
email_lib_src = lib_path.read_text(encoding="utf-8")
assert "skipFastPath: true" in email_lib_src
assert "skipFastPath = false" in email_lib_src
assert "!skipFastPath" in email_lib_src
def test_manual_refresh_fetches_mailbox_instead_of_returning_stale_index(monkeypatch):
class EmptyMailbox:
def select(self, folder, readonly=False):
+26
View File
@@ -8,6 +8,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
AI_MODELS_JS = (ROOT / "static/js/editor/ai-models.js").read_text(encoding="utf-8")
def test_settings_tab_has_image_model_picker():
@@ -37,3 +38,28 @@ def test_cloud_image_models_are_listed_alongside_the_local_ones():
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
def test_option_values_use_endpoint_id_not_base_url():
"""Neither gallery.js nor ai-models.js may expose private base URLs in option values."""
assert "${m.base_url" not in GALLERY_JS
assert "m.endpoint_id ? String(m.endpoint_id)" in GALLERY_JS
assert "${m.base_url" not in AI_MODELS_JS
assert "m.endpoint_id ? String(m.endpoint_id)" in AI_MODELS_JS
def test_two_way_event_and_storage_synchronization():
"""Gallery and Quick Edit must dispatch and listen to ge-ai-command-model-change and storage."""
assert "new CustomEvent('ge-ai-command-model-change'" in GALLERY_JS
assert "addEventListener('ge-ai-command-model-change'" in GALLERY_JS
assert "addEventListener('storage'" in GALLERY_JS
assert "new CustomEvent('ge-ai-command-model-change'" in AI_MODELS_JS
assert "addEventListener('ge-ai-command-model-change'" in AI_MODELS_JS
assert "addEventListener('storage'" in AI_MODELS_JS
def test_legacy_localstorage_migration_scrubs_urls():
"""Both pickers must detect and migrate legacy URLs without leaking them."""
assert "saved.startsWith('http://')" in GALLERY_JS
assert "saved.startsWith('http://')" in AI_MODELS_JS
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY = ROOT / "static/js/gallery.js"
def test_editor_landing_has_an_import_button_next_to_new_canvas():
"""The editor landing must offer a direct "Import" action.
It sits in the same action row as "New canvas..." / "Browse gallery" and,
on file pick, opens the editor on the chosen raster image (``openEditor``
takes an image data URL as its first argument).
"""
src = GALLERY.read_text(encoding="utf-8")
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
# Button order: New canvas → Import → Browse gallery.
new_at = src.index('id="gallery-editor-new"')
import_at = src.index('id="gallery-editor-import"')
pick_at = src.index('id="gallery-editor-pick"')
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
# Wiring opens the editor on the selected file's data URL.
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
assert "readAsDataURL(file)" in src
assert "await openEditor(reader.result, null, null, name)" in src
+45
View File
@@ -401,6 +401,51 @@ def test_url_outside_inline_code_is_still_autolinked(node_available):
assert 'href="https://example.com/page"' in html
def test_https_markdown_image_renders_inline_as_clickable_thumbnail(node_available):
# Assistant `![alt](url)` images must render inline: https-only, lazy,
# max-width-100% (via CSS), and click-to-open the full size in a new tab.
html = _run_markdown_case(
"Here is a map:\n\n"
"![Map of Sweden](https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/Sweden_location_map.svg/600px-Sweden_location_map.svg.png)"
)
assert html.count('class="chat-image"') == 1
assert html.count('class="chat-inline-image"') == 1
assert 'alt="Map of Sweden"' in html
assert 'loading="lazy"' in html
assert 'decoding="async"' in html
assert 'referrerpolicy="no-referrer"' in html
assert 'target="_blank"' in html
assert 'rel="noopener noreferrer"' in html
# The fallback caption (alt + URL) is emitted so a broken load never leaves
# an empty gap; it stays hidden until the capture-phase error listener
# toggles .image-broken.
assert 'class="chat-image-fallback"' in html
# The fallback caption carries the alt text plus the URL (the `//` is
# entity-encoded so the bare-URL autolink pass can't nest another <a>).
assert "Map of Sweden — https:&#47;&#47;upload.wikimedia.org" in html
def test_http_markdown_image_is_downgraded_to_alt_text(node_available):
# Non-https image URLs must not render as an <img> (https-only policy); the
# alt text is kept as plain text so nothing is silently dropped.
html = _run_markdown_case("Look: ![a http pic](http://example.com/pic.png) done.")
assert "<img" not in html
assert 'class="chat-image"' not in html
assert "a http pic" in html
def test_javascript_scheme_image_url_is_never_rendered(node_available):
# A javascript: URL inside image syntax must be rejected outright, never
# emitted as a src/href.
html = _run_markdown_case("![x](javascript:alert(1))")
assert "<img" not in html
assert "javascript:" not in html
assert 'class="chat-image"' not in html
def test_inline_code_content_is_html_escaped(node_available):
# Inline code is now extracted before the global escape pass, so it must be
# escaped at extraction time (matching the fenced-code-block handling).
@@ -0,0 +1,415 @@
"""Regression suite for upload confinement compatibility on symlinked and ordinary roots.
Verifies:
1. Ordinary upload root.
2. Upload root that is a symlink to an authorized directory.
3. Valid file in nested upload directory.
4. Symlinked in-root directory resolving safely inside the root.
5. Symlink escaping outside the authorized root — rejected.
6. Malformed upload ID — rejected.
7. Missing upload — rejected.
8. Foreign-user upload — rejected.
9. Valid GET/HEAD parity on both ordinary and symlinked roots.
10. Cached, missing and stale thumbnail HEAD behavior (zero body, no generation).
11. No directory traversal or unauthorized file disclosure.
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import APIRouter, FastAPI
from starlette.testclient import TestClient
from src.path_confinement import canonical_root, confine, PathEscape
from src.upload_handler import UploadHandler, secure_filename
import routes.upload_routes as upload_routes_mod
import src.constants as constants
class _AuthManager:
is_configured = True
def __init__(self, admins=("admin",)):
self._admins = set(admins)
def is_admin(self, user: str | None) -> bool:
return user in self._admins
def _build_test_app(handler: UploadHandler, default_user: str | None = "alice") -> FastAPI:
test_router = APIRouter(prefix="/api/upload", tags=["uploads"])
orig_router = upload_routes_mod.router
upload_routes_mod.router = test_router
try:
upload_routes_mod.setup_upload_routes(handler)
finally:
upload_routes_mod.router = orig_router
app = FastAPI()
app.state.auth_manager = _AuthManager()
@app.middleware("http")
async def inject_user(request, call_next):
header_user = request.headers.get("x-test-user")
if header_user is not None:
request.state.current_user = header_user if header_user != "none" else None
else:
request.state.current_user = default_user
return await call_next(request)
app.include_router(test_router)
return app
def _create_fixture_env(tmp_path: Path, monkeypatch, *, symlinked_root: bool = False):
if symlinked_root:
real_uploads = tmp_path / "real_uploads"
real_uploads.mkdir(parents=True, exist_ok=True)
upload_dir = tmp_path / "symlink_uploads"
os.symlink(real_uploads, upload_dir)
storage_base = real_uploads
else:
upload_dir = tmp_path / "ordinary_uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
storage_base = upload_dir
dated_dir = storage_base / "2026" / "10" / "29"
dated_dir.mkdir(parents=True, exist_ok=True)
alice_id = "a" * 32 + ".png"
bob_id = "b" * 32 + ".png"
nested_id = "c" * 32 + ".png"
# Valid PNG image bytes using PIL
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (10, 10), "red").save(buf, format="PNG")
png_bytes = buf.getvalue()
alice_path = storage_base / alice_id
bob_path = storage_base / bob_id
nested_path = dated_dir / nested_id
alice_path.write_bytes(png_bytes)
bob_path.write_bytes(png_bytes)
nested_path.write_bytes(png_bytes)
index = {
f"alice:{alice_id}": {
"id": alice_id,
"path": str(alice_path),
"mime": "image/png",
"size": len(png_bytes),
"name": "alice.png",
"original_name": "alice.png",
"owner": "alice",
},
f"bob:{bob_id}": {
"id": bob_id,
"path": str(bob_path),
"mime": "image/png",
"size": len(png_bytes),
"name": "bob.png",
"original_name": "bob.png",
"owner": "bob",
},
f"alice:{nested_id}": {
"id": nested_id,
"path": str(nested_path),
"mime": "image/png",
"size": len(png_bytes),
"name": "nested.png",
"original_name": "nested.png",
"owner": "alice",
},
}
(storage_base / "uploads.json").write_text(json.dumps(index), encoding="utf-8")
monkeypatch.setattr(constants, "UPLOAD_DIR", str(upload_dir))
handler = UploadHandler(str(tmp_path), str(upload_dir))
app = _build_test_app(handler)
client = TestClient(app)
return SimpleNamespace(
upload_dir=upload_dir,
storage_base=storage_base,
alice_id=alice_id,
bob_id=bob_id,
nested_id=nested_id,
png_bytes=png_bytes,
handler=handler,
app=app,
client=client,
)
# 1. Ordinary upload root
def test_matrix_01_ordinary_upload_root(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=False)
get_res = env.client.get(f"/api/upload/{env.alice_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{env.alice_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 2. Upload root that is a symlink to an authorized directory
def test_matrix_02_symlinked_upload_root(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
get_res = env.client.get(f"/api/upload/{env.alice_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{env.alice_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 3. Valid file in nested upload directory
def test_matrix_03_nested_upload_directory(tmp_path: Path, monkeypatch):
for symlinked in (False, True):
env = _create_fixture_env(tmp_path / f"nested_{symlinked}", monkeypatch, symlinked_root=symlinked)
get_res = env.client.get(f"/api/upload/{env.nested_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{env.nested_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 4. Symlinked in-root directory resolving safely inside the root
def test_matrix_04_symlinked_in_root_directory_safe(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=False)
real_sub = env.storage_base / "real_sub"
real_sub.mkdir(parents=True, exist_ok=True)
sub_file_id = "d" * 32 + ".png"
(real_sub / sub_file_id).write_bytes(env.png_bytes)
link_sub = env.storage_base / "link_sub"
os.symlink(real_sub, link_sub)
# Register in uploads.json pointing via symlinked in-root directory
uploads_json = env.storage_base / "uploads.json"
index = json.loads(uploads_json.read_text(encoding="utf-8"))
index[f"alice:{sub_file_id}"] = {
"id": sub_file_id,
"path": str(link_sub / sub_file_id),
"mime": "image/png",
"size": len(env.png_bytes),
"name": "sub.png",
"owner": "alice",
}
uploads_json.write_text(json.dumps(index), encoding="utf-8")
get_res = env.client.get(f"/api/upload/{sub_file_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{sub_file_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 5. Symlink escaping outside the authorized root — rejected
def test_matrix_05_symlink_escaping_outside_root_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=False)
outside_dir = tmp_path / "outside_store"
outside_dir.mkdir(parents=True, exist_ok=True)
secret_file = outside_dir / "secret.txt"
secret_file.write_text("CONFIDENTIAL_SYSTEM_DATA", encoding="utf-8")
# A: Direct file symlink escaping root
escape_file_id = "e" * 32 + ".txt"
os.symlink(secret_file, env.storage_base / escape_file_id)
uploads_json = env.storage_base / "uploads.json"
index = json.loads(uploads_json.read_text(encoding="utf-8"))
index[f"alice:{escape_file_id}"] = {
"id": escape_file_id,
"path": str(env.storage_base / escape_file_id),
"mime": "text/plain",
"size": len("CONFIDENTIAL_SYSTEM_DATA"),
"name": "escape.txt",
"owner": "alice",
}
uploads_json.write_text(json.dumps(index), encoding="utf-8")
get_res = env.client.get(f"/api/upload/{escape_file_id}")
assert get_res.status_code == 403
head_res = env.client.head(f"/api/upload/{escape_file_id}")
assert head_res.status_code == 403
assert len(head_res.content) == 0
# B: Directory symlink escaping root
escape_dir_link = env.storage_base / "evil_sub"
os.symlink(outside_dir, escape_dir_link)
escape_dir_id = "f" * 32 + ".txt"
index[f"alice:{escape_dir_id}"] = {
"id": escape_dir_id,
"path": str(escape_dir_link / "secret.txt"),
"mime": "text/plain",
"size": len("CONFIDENTIAL_SYSTEM_DATA"),
"name": "escape_sub.txt",
"owner": "alice",
}
uploads_json.write_text(json.dumps(index), encoding="utf-8")
get_dir = env.client.get(f"/api/upload/{escape_dir_id}")
assert get_dir.status_code == 403
head_dir = env.client.head(f"/api/upload/{escape_dir_id}")
assert head_dir.status_code == 403
assert len(head_dir.content) == 0
# 6. Malformed upload ID — rejected
def test_matrix_06_malformed_upload_id_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
malformed_ids = [
"../../etc/passwd",
"..%2f..%2fetc%2fpasswd",
"invalid!id@specials",
"short_id.png",
"g" * 32 + ".png", # non-hex character 'g'
" " + "a" * 32 + ".png",
"a" * 31,
]
for bad_id in malformed_ids:
get_res = env.client.get(f"/api/upload/{bad_id}")
assert get_res.status_code in (400, 403, 404), f"GET failed for {bad_id}: {get_res.status_code}"
head_res = env.client.head(f"/api/upload/{bad_id}")
assert head_res.status_code == get_res.status_code, f"HEAD failed for {bad_id}: {head_res.status_code}"
assert len(head_res.content) == 0
# 7. Missing upload — rejected
def test_matrix_07_missing_upload_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
missing_id = "1" * 32 + ".png"
get_res = env.client.get(f"/api/upload/{missing_id}")
assert get_res.status_code == 404
head_res = env.client.head(f"/api/upload/{missing_id}")
assert head_res.status_code == 404
assert len(head_res.content) == 0
# 8. Foreign-user upload — rejected
def test_matrix_08_foreign_user_upload_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
# Bob attempts to access Alice's upload
bob_get = env.client.get(f"/api/upload/{env.alice_id}", headers={"x-test-user": "bob"})
assert bob_get.status_code == 404
bob_head = env.client.head(f"/api/upload/{env.alice_id}", headers={"x-test-user": "bob"})
assert bob_head.status_code == 404
assert len(bob_head.content) == 0
# Unauthenticated attempts
unauth_get = env.client.get(f"/api/upload/{env.alice_id}", headers={"x-test-user": "none"})
assert unauth_get.status_code in (401, 403)
unauth_head = env.client.head(f"/api/upload/{env.alice_id}", headers={"x-test-user": "none"})
assert unauth_head.status_code == unauth_get.status_code
assert len(unauth_head.content) == 0
# Admin access succeeds
admin_get = env.client.get(f"/api/upload/{env.alice_id}", headers={"x-test-user": "admin"})
assert admin_get.status_code == 200
admin_head = env.client.head(f"/api/upload/{env.alice_id}", headers={"x-test-user": "admin"})
assert admin_head.status_code == 200
assert len(admin_head.content) == 0
# 9. Valid GET/HEAD parity on both ordinary and symlinked roots
def test_matrix_09_get_head_parity(tmp_path: Path, monkeypatch):
for symlinked in (False, True):
env = _create_fixture_env(tmp_path / f"parity_{symlinked}", monkeypatch, symlinked_root=symlinked)
get_res = env.client.get(f"/api/upload/{env.alice_id}")
head_res = env.client.head(f"/api/upload/{env.alice_id}")
assert get_res.status_code == 200
assert head_res.status_code == 200
assert len(head_res.content) == 0
assert get_res.headers.get("x-content-type-options") == "nosniff"
assert head_res.headers.get("x-content-type-options") == "nosniff"
assert get_res.headers.get("content-type") == head_res.headers.get("content-type")
assert get_res.headers.get("content-length") == head_res.headers.get("content-length")
# 10. Cached, missing and stale thumbnail HEAD behavior
def test_matrix_10_thumbnail_head_behavior(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
thumb_path = env.storage_base / ".thumbs" / f"{env.alice_id}.jpg"
from unittest.mock import patch
# Case A: Missing thumbnail
assert not thumb_path.exists()
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head_missing = env.client.head(f"/api/upload/{env.alice_id}?thumb=1")
assert head_missing.status_code == 200
assert len(head_missing.content) == 0
# Must NOT generate thumbnail on disk during HEAD
assert not thumb_path.exists()
# Must NOT advertise Content-Length when thumbnail is ungenerated
assert "content-length" not in head_missing.headers
# Generate thumbnail via GET
get_gen = env.client.get(f"/api/upload/{env.alice_id}?thumb=1")
assert get_gen.status_code == 200
assert thumb_path.exists()
thumb_mtime = os.path.getmtime(thumb_path)
# Case B: Cached (fresh) thumbnail
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head_cached = env.client.head(f"/api/upload/{env.alice_id}?thumb=1")
assert head_cached.status_code == 200
assert len(head_cached.content) == 0
assert head_cached.headers.get("content-type") == "image/jpeg"
assert "content-length" in head_cached.headers
assert head_cached.headers["content-length"] == str(len(thumb_path.read_bytes()))
# Mtime did not change
assert os.path.getmtime(thumb_path) == thumb_mtime
# Case C: Stale thumbnail (original modified after thumb)
alice_file = env.storage_base / env.alice_id
future_time = time.time() + 100
os.utime(alice_file, (future_time, future_time))
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head_stale = env.client.head(f"/api/upload/{env.alice_id}?thumb=1")
assert head_stale.status_code == 200
assert len(head_stale.content) == 0
# Must NOT advertise old stale Content-Length
assert "content-length" not in head_stale.headers
# Must NOT regenerate thumb during HEAD
assert os.path.getmtime(thumb_path) == thumb_mtime
# 11. No directory traversal or unauthorized file disclosure
def test_matrix_11_directory_traversal_prevention(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
secret = tmp_path / "system_secret.txt"
secret.write_text("SENSITIVE_KEY_VALUE", encoding="utf-8")
traversal_paths = [
f"/api/upload/..%2f..%2f{secret.name}",
f"/api/upload/%2e%2e%2f%2e%2e%2f{secret.name}",
f"/api/upload/../../{secret.name}",
f"/api/upload/{env.alice_id}/../../{secret.name}",
]
for target in traversal_paths:
res = env.client.get(target)
assert res.status_code in (400, 403, 404)
assert "SENSITIVE_KEY_VALUE" not in res.text
+251
View File
@@ -0,0 +1,251 @@
"""Tests for the HTTP GET and HEAD contract on /api/upload/{file_id}.
Verifies that HEAD /api/upload/{file_id} returns identical status and response
headers to GET while transferring zero body bytes, enforcing the same
authorization and ownership checks.
"""
import json
import os
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
class _AuthManager:
is_configured = True
def __init__(self, admins=()):
self._admins = set(admins)
def is_admin(self, user):
return user in self._admins
def _make_upload_env(tmp_path, monkeypatch):
from src.upload_handler import UploadHandler
from src import constants
import routes.upload_routes as upload_routes_mod
upload_dir = tmp_path / "uploads"
dated = upload_dir / "2026" / "06" / "02"
dated.mkdir(parents=True)
alice_id = "a" * 32 + ".png"
bob_id = "b" * 32 + ".png"
alice_path = dated / alice_id
bob_path = dated / bob_id
alice_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"
bob_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x06\x00\x00\x00\x1f\x15c4"
alice_path.write_bytes(alice_bytes)
bob_path.write_bytes(bob_bytes)
index = {
f"alice:{alice_id}": {
"id": alice_id,
"path": str(alice_path),
"mime": "image/png",
"size": len(alice_bytes),
"name": "alice.png",
"original_name": "alice.png",
"owner": "alice",
},
f"bob:{bob_id}": {
"id": bob_id,
"path": str(bob_path),
"mime": "image/png",
"size": len(bob_bytes),
"name": "bob.png",
"original_name": "bob.png",
"owner": "bob",
},
}
(upload_dir / "uploads.json").write_text(json.dumps(index), encoding="utf-8")
monkeypatch.setattr(constants, "UPLOAD_DIR", str(upload_dir))
handler = UploadHandler(str(tmp_path), str(upload_dir))
return handler, alice_id, bob_id, upload_dir, alice_bytes
def _create_test_app(handler, current_user="alice", auth_manager=None):
from routes.upload_routes import router, setup_upload_routes
# Fresh router instance to avoid route collisions across test calls
from fastapi import APIRouter
test_router = APIRouter(prefix="/api/upload", tags=["uploads"])
import routes.upload_routes as ur
orig_router = ur.router
ur.router = test_router
try:
setup_upload_routes(handler)
finally:
ur.router = orig_router
app = FastAPI()
app.state.auth_manager = auth_manager or _AuthManager()
@app.middleware("http")
async def inject_user(request, call_next):
request.state.current_user = current_user
return await call_next(request)
app.include_router(test_router)
return app
def test_head_and_get_parity_on_valid_upload(tmp_path, monkeypatch):
handler, alice_id, _bob_id, _upload_dir, alice_bytes = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
# 1. GET response
get_res = client.get(f"/api/upload/{alice_id}")
assert get_res.status_code == 200
assert get_res.content == alice_bytes
assert int(get_res.headers["content-length"]) == len(alice_bytes)
assert get_res.headers["x-content-type-options"] == "nosniff"
# 2. HEAD response
head_res = client.head(f"/api/upload/{alice_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0, "HEAD response must transfer 0 body bytes"
assert head_res.headers["content-length"] == get_res.headers["content-length"]
assert head_res.headers["content-type"] == get_res.headers["content-type"]
assert head_res.headers["x-content-type-options"] == "nosniff"
def test_head_rejects_invalid_file_id(tmp_path, monkeypatch):
handler, _alice_id, _bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
get_res = client.get("/api/upload/invalid!id@bad")
assert get_res.status_code == 400
head_res = client.head("/api/upload/invalid!id@bad")
assert head_res.status_code == 400
assert len(head_res.content) == 0
def test_head_returns_404_on_missing_file(tmp_path, monkeypatch):
handler, _alice_id, _bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
missing_id = "c" * 32 + ".png"
get_res = client.get(f"/api/upload/{missing_id}")
assert get_res.status_code == 404
head_res = client.head(f"/api/upload/{missing_id}")
assert head_res.status_code == 404
assert len(head_res.content) == 0
def test_head_denies_cross_owner_without_leaking_existence(tmp_path, monkeypatch):
handler, _alice_id, bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
# Alice requests Bob's file
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
get_res = client.get(f"/api/upload/{bob_id}")
assert get_res.status_code == 404
head_res = client.head(f"/api/upload/{bob_id}")
assert head_res.status_code == 404
assert len(head_res.content) == 0
def test_head_denies_unauthenticated_access(tmp_path, monkeypatch):
handler, alice_id, _bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
# Anonymous user (None) with auth configured
app = _create_test_app(handler, current_user=None, auth_manager=_AuthManager())
client = TestClient(app)
get_res = client.get(f"/api/upload/{alice_id}")
assert get_res.status_code == 403
head_res = client.head(f"/api/upload/{alice_id}")
assert head_res.status_code == 403
assert len(head_res.content) == 0
def test_head_allows_admin_access(tmp_path, monkeypatch):
handler, _alice_id, bob_id, _upload_dir, bob_bytes = _make_upload_env(tmp_path, monkeypatch)
# Admin requests Bob's file
auth_mgr = _AuthManager(admins=["admin_user"])
app = _create_test_app(handler, current_user="admin_user", auth_manager=auth_mgr)
client = TestClient(app)
get_res = client.get(f"/api/upload/{bob_id}")
assert get_res.status_code == 200
head_res = client.head(f"/api/upload/{bob_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
assert int(head_res.headers["content-length"]) == len(bob_bytes)
def test_head_probe_with_thumb_param(tmp_path, monkeypatch):
handler, alice_id, _bob_id, _upload_dir, _alice_bytes = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
# HEAD with thumb=1 on image
head_res = client.head(f"/api/upload/{alice_id}?thumb=1")
assert head_res.status_code == 200
assert len(head_res.content) == 0
@pytest.mark.parametrize("cache", ["missing", "stale", "changed", "corrupt"])
def test_thumbnail_head_omits_unknown_representation_headers(tmp_path, monkeypatch, cache):
"""Unknown JPEG generation/fallback must not advertise original/stale metadata."""
from PIL import Image
from unittest.mock import patch
handler, file_id, _, upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
source = next(upload_dir.rglob(file_id))
if cache != "corrupt":
Image.new("RGB", (12, 15), "red").save(source)
if cache in {"stale", "changed"}:
thumb = upload_dir / ".thumbs" / (file_id + ".jpg")
thumb.parent.mkdir()
Image.new("RGB", (2, 2), "blue").save(thumb)
os.utime(thumb, (1, 1))
if cache == "changed":
Image.new("RGB", (25, 30), "green").save(source)
client = TestClient(_create_test_app(handler))
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
response = client.head(f"/api/upload/{file_id}?thumb=1")
assert response.status_code == 200
assert response.content == b""
assert response.headers["x-content-type-options"] == "nosniff"
for header in ("content-type", "content-length", "etag", "last-modified", "content-disposition"):
assert header not in response.headers, (cache, header, response.headers)
get = client.get(f"/api/upload/{file_id}?thumb=1")
assert get.status_code == 200
assert get.headers["content-type"] == ("image/png" if cache == "corrupt" else "image/jpeg")
# Successful GET creates a current cache, and HEAD can now describe it exactly.
head = client.head(f"/api/upload/{file_id}?thumb=1")
if cache != "corrupt":
for header in ("content-type", "content-length", "etag", "last-modified"):
assert head.headers[header] == get.headers[header]
else:
assert "content-type" not in head.headers
assert "content-length" not in head.headers
def test_thumbnail_head_current_cache_and_authorization(tmp_path, monkeypatch):
from PIL import Image
from unittest.mock import patch
handler, file_id, _, upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
source = next(upload_dir.rglob(file_id))
Image.new("RGB", (12, 15), "red").save(source)
client = TestClient(_create_test_app(handler))
get = client.get(f"/api/upload/{file_id}?thumb=1")
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head = client.head(f"/api/upload/{file_id}?thumb=1")
assert head.status_code == 200 and not head.content
assert head.headers["content-type"] == "image/jpeg"
assert head.headers["content-length"] == get.headers["content-length"]
for user, status in (("bob", 404), (None, 403)):
denied = TestClient(_create_test_app(handler, current_user=user)).head(f"/api/upload/{file_id}?thumb=1")
assert denied.status_code == status
assert "etag" not in denied.headers and "last-modified" not in denied.headers