mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 09:12:24 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f95032177 |
@@ -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, a')) return; // links open without toggling selection
|
||||
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(card);
|
||||
});
|
||||
|
||||
@@ -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)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></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">×</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" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</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.
|
||||
|
||||
@@ -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">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+45
-91
@@ -25,12 +25,11 @@ 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 PathEscape, canonical_root, confine, is_inside
|
||||
from src.path_confinement import is_inside
|
||||
from src.upload_handler import (
|
||||
UploadCleanupSafetyError,
|
||||
count_recent_uploads,
|
||||
extract_upload_ids,
|
||||
secure_filename,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -154,47 +153,30 @@ def setup_upload_routes(upload_handler):
|
||||
|
||||
def _upload_root() -> str:
|
||||
from src.constants import UPLOAD_DIR
|
||||
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
|
||||
return os.path.realpath(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:
|
||||
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))
|
||||
from src.constants import UPLOAD_DIR
|
||||
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
|
||||
direct = os.path.join(upload_root, file_id)
|
||||
if os.path.lexists(direct):
|
||||
try:
|
||||
canonical_direct = confine(root, direct, allow_root=False)
|
||||
except (PathEscape, ValueError):
|
||||
if not _path_inside_upload_dir(direct):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if os.path.isfile(canonical_direct):
|
||||
return canonical_direct
|
||||
if os.path.isfile(direct):
|
||||
return direct
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
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")
|
||||
|
||||
for walk_root, _dirs, files in os.walk(root, followlinks=False):
|
||||
if safe_id not in files:
|
||||
for root, _dirs, files in os.walk(upload_root, followlinks=False):
|
||||
if file_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):
|
||||
path = os.path.join(root, file_id)
|
||||
if not _path_inside_upload_dir(path):
|
||||
raise HTTPException(403, "Access denied")
|
||||
if os.path.isfile(canonical_cand):
|
||||
return canonical_cand
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
raise HTTPException(404, "File not found")
|
||||
@@ -453,79 +435,52 @@ def setup_upload_routes(upload_handler):
|
||||
logger.error(f"Failed to get upload stats: {e}")
|
||||
raise HTTPException(500, "Failed to get upload statistics")
|
||||
|
||||
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
|
||||
@router.get("/{file_id}")
|
||||
async def download_file(request: Request, file_id: str, thumb: int = 0):
|
||||
"""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."""
|
||||
"""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."""
|
||||
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 = safe_file_id
|
||||
original_name = 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") == safe_file_id), None)
|
||||
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
|
||||
if info:
|
||||
original_name = info.get("name", safe_file_id)
|
||||
original_name = info.get("name", file_id)
|
||||
file_owner = info.get("owner") if info else None
|
||||
_verify_upload_access(request, file_owner)
|
||||
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")
|
||||
path = _resolve_upload_path(file_id)
|
||||
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:
|
||||
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.
|
||||
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.
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=mime,
|
||||
@@ -541,10 +496,9 @@ 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:
|
||||
safe_id = secure_filename(os.path.basename(file_id))
|
||||
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
|
||||
cache_dir = os.path.join(_upload_root(), ".vision")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
|
||||
return os.path.join(cache_dir, file_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."""
|
||||
|
||||
@@ -7256,39 +7256,6 @@ body.bg-pattern-ascii-fireflies {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Inline assistant markdown images (``) — 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); }
|
||||
|
||||
@@ -22,20 +22,6 @@
|
||||
|
||||
/* 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; }
|
||||
|
||||
@@ -704,7 +704,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -664,7 +664,6 @@
|
||||
<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>
|
||||
|
||||
+1
-61
@@ -18,13 +18,7 @@
|
||||
'use strict';
|
||||
|
||||
// Click-as-button rows we want reachable by keyboard.
|
||||
var ROW_SELECTOR = [
|
||||
'#sidebar .list-item',
|
||||
'#user-bar-profile',
|
||||
'.sidebar-brand',
|
||||
'.export-dropdown-item',
|
||||
'.dropdown-item'
|
||||
].join(',');
|
||||
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].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
|
||||
@@ -61,31 +55,6 @@
|
||||
(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
|
||||
@@ -149,38 +118,9 @@
|
||||
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.
|
||||
|
||||
+5
-7
@@ -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', 'spec'].includes(String(toolModes[id] || '').toLowerCase())
|
||||
const mode = ['none', 'compact', 'full'].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,8 +777,7 @@ 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' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1357,7 +1356,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', 'spec'].includes(String(m.tool_mode || '').toLowerCase())
|
||||
const mode = ['none', 'compact', 'full'].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">
|
||||
@@ -1371,8 +1370,7 @@ 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' : ''}>Ajax Compact</option>
|
||||
<option value="spec" ${mode === 'spec' ? 'selected' : ''}>Ajax Spec (router)</option>
|
||||
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
|
||||
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1442,7 +1440,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', 'spec'].includes(value) ? value : '';
|
||||
modelToolModes[modelId] = ['none', 'compact', 'full'].includes(value) ? value : '';
|
||||
}
|
||||
});
|
||||
const total = panel.querySelectorAll('input.adm-cb-hidden[type=checkbox]').length;
|
||||
|
||||
@@ -44,283 +44,6 @@ 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;
|
||||
@@ -4372,7 +4095,6 @@ 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;
|
||||
@@ -6797,7 +6519,6 @@ 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;
|
||||
|
||||
@@ -457,7 +457,6 @@ 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;
|
||||
@@ -2376,7 +2375,6 @@ 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();
|
||||
@@ -2573,7 +2571,6 @@ 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();
|
||||
@@ -3446,8 +3443,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
for (const ev of roundTools) {
|
||||
const image = generatedImageResult(ev);
|
||||
if (image) {
|
||||
const imgBubble = buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id);
|
||||
box.appendChild(imgBubble);
|
||||
box.appendChild(buildImageBubble(image.image_url, image.image_prompt, image.image_model, image.image_size, image.image_quality, image.image_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,8 +212,6 @@ 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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -101,14 +101,12 @@ 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 || '#000000');
|
||||
const { h, s, v } = hexToHsv(hex);
|
||||
return suggestionsForHsv(h, s, v);
|
||||
}
|
||||
|
||||
|
||||
@@ -1498,8 +1498,7 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
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 _defaultServeModel = (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 : '';
|
||||
|
||||
+5
-42
@@ -1996,17 +1996,6 @@ 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
|
||||
@@ -2301,16 +2290,8 @@ 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) => {
|
||||
const canPlace = placementStartedOnPage;
|
||||
placementStartedOnPage = false;
|
||||
if (!canPlace || !isBlankPageTarget(ev.target) || _pdfAnnotationDragging) return;
|
||||
if (ev.target !== pageWrap && ev.target.tagName !== 'IMG') return;
|
||||
if (!_pdfDropMode) return;
|
||||
const rect = pageWrap.getBoundingClientRect();
|
||||
const xPct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
@@ -2442,11 +2423,9 @@ 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 && !_pdfAnnotationDragging) ? '' : 'none';
|
||||
const v = (show && !dismissed) ? '' : 'none';
|
||||
del.style.display = v;
|
||||
grip.style.display = v;
|
||||
resize.style.display = v;
|
||||
@@ -2467,8 +2446,7 @@ 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,
|
||||
_refreshHandles: () => _setHandlesVisible(handlesRequested) };
|
||||
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 };
|
||||
|
||||
if (kind === 'check') {
|
||||
// Stamp checkmark — value is fixed, nothing to listen for.
|
||||
@@ -2590,12 +2568,7 @@ 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);
|
||||
@@ -2607,7 +2580,6 @@ 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';
|
||||
@@ -2626,14 +2598,11 @@ 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.
|
||||
@@ -2642,7 +2611,6 @@ 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';
|
||||
@@ -2660,14 +2628,11 @@ 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
|
||||
@@ -15274,8 +15239,7 @@ 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';
|
||||
const editorPane = document.querySelector('.doc-editor-pane');
|
||||
menu.style.zIndex = String(Math.max(topPortalZ(), (parseInt(getComputedStyle(editorPane).zIndex, 10) || 0) + 1));
|
||||
menu.style.zIndex = '9999';
|
||||
|
||||
const langLabel = lang ? lang.toUpperCase() : 'TXT';
|
||||
// Form-backed markdown doc → primary export is the filled PDF, not the
|
||||
@@ -15350,8 +15314,7 @@ 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 exportBtn = document.getElementById('doc-footer-export-btn');
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target) && !exportBtn?.contains(ev.target));
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
}
|
||||
|
||||
function _richTextExportCss() {
|
||||
|
||||
@@ -140,41 +140,12 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
||||
return { gen: false, inpaint: false };
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2img }) {
|
||||
// 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.
|
||||
listen(root, 'change', (e) => {
|
||||
container.addEventListener('change', (e) => {
|
||||
const sel = e.target.closest('select');
|
||||
if (!sel) return;
|
||||
if (sel.value !== '__serve_cookbook__') return;
|
||||
@@ -184,7 +155,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
openCookbookForImg2img();
|
||||
});
|
||||
// Track prior value so we can restore it after the sentinel fires.
|
||||
listen(root, 'focus', (e) => {
|
||||
container.addEventListener('focus', (e) => {
|
||||
const sel = e.target.closest('select');
|
||||
if (sel && sel.value !== '__serve_cookbook__') sel._prevServeValue = sel.value;
|
||||
}, true);
|
||||
@@ -193,7 +164,8 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
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) listen(aiInpaintSelect, 'change', () => updateFooterEditModel());
|
||||
if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());
|
||||
if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel());
|
||||
// The Quick Edit agent (planner) picker: dynamically populates from
|
||||
// /api/editor/agent-models; restore/save choice from localStorage.
|
||||
const populateAgentSelect = async () => {
|
||||
@@ -204,10 +176,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
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.id}`;
|
||||
const optVal = `${m.source === 'local' ? 'local' : (m.endpoint_id ? String(m.endpoint_id) : (m.base_url || ''))}::${m.id}`;
|
||||
const label = m.label || m.id;
|
||||
if (!agentOptions.some(o => o.value === optVal)) {
|
||||
agentOptions.push({ value: optVal, label });
|
||||
@@ -231,9 +202,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
aiCommandAgentSelect.value = prevVal;
|
||||
}
|
||||
} catch {}
|
||||
if (aiCommandAgentSelect._agentWired !== listeners) {
|
||||
aiCommandAgentSelect._agentWired = listeners;
|
||||
listen(aiCommandAgentSelect, 'change', () => {
|
||||
if (!aiCommandAgentSelect._agentWired) {
|
||||
aiCommandAgentSelect._agentWired = true;
|
||||
aiCommandAgentSelect.addEventListener('change', () => {
|
||||
try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {}
|
||||
});
|
||||
}
|
||||
@@ -251,10 +222,8 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
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>';
|
||||
@@ -272,8 +241,8 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
const canInpaint = Boolean(caps.inpaint || m.kind === 'edit');
|
||||
if (!canGen && !canInpaint) continue;
|
||||
|
||||
// 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' : '');
|
||||
// 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 || '');
|
||||
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}` : '';
|
||||
@@ -343,50 +312,32 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
if (aiGenSelect) appendServeSentinel(aiGenSelect);
|
||||
if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect);
|
||||
if (aiCommandSelect) appendServeSentinel(aiCommandSelect);
|
||||
// Sentinel changes are handled by the component's delegated listener.
|
||||
// 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);
|
||||
if (aiCommandSelect) {
|
||||
aiCommandSelect._modelsReady = true;
|
||||
try {
|
||||
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 } }));
|
||||
}
|
||||
const saved = localStorage.getItem('ge-ai-command-model');
|
||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved;
|
||||
} catch {}
|
||||
|
||||
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 } }));
|
||||
});
|
||||
}
|
||||
aiCommandSelect.addEventListener('change', () => {
|
||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||
try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {}
|
||||
updateFooterEditModel();
|
||||
});
|
||||
}
|
||||
// Restore each per-tool selection from localStorage.
|
||||
for (const ts of perToolSelects) {
|
||||
@@ -398,7 +349,7 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
}
|
||||
} catch {}
|
||||
let prevValue = ts.value;
|
||||
listen(ts, 'change', () => {
|
||||
ts.addEventListener('change', () => {
|
||||
if (ts.value === '__serve_cookbook__') {
|
||||
ts.value = prevValue;
|
||||
openCookbookForImg2img();
|
||||
@@ -411,7 +362,6 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
_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.
|
||||
@@ -420,6 +370,22 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
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() };
|
||||
@@ -427,11 +393,15 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
}
|
||||
}
|
||||
loadAIModels();
|
||||
if (aiCommandSelect) aiCommandSelect._refreshAIModels = baseUrl => {
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
void loadAIModels({ selectBaseUrl: baseUrl });
|
||||
const onModelEndpointsUpdated = (e) => {
|
||||
if (!container.isConnected) {
|
||||
window.removeEventListener('ge:model-endpoints-updated', onModelEndpointsUpdated);
|
||||
return;
|
||||
}
|
||||
loadAIModels({ selectBaseUrl: e.detail?.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
|
||||
@@ -450,5 +420,5 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
if ([...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||
});
|
||||
};
|
||||
listen(root, 'mousedown', refreshOnOpen, true);
|
||||
container.addEventListener('mousedown', refreshOnOpen, true);
|
||||
}
|
||||
|
||||
@@ -1498,11 +1498,8 @@ 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;
|
||||
@@ -1557,37 +1554,9 @@ 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 || '',
|
||||
@@ -5021,7 +4990,7 @@ export async function _refreshUnreadBadge({ unreadCountOverride = null, preserve
|
||||
} catch (_) { _syncUnreadTabBadge(0); }
|
||||
}
|
||||
|
||||
export async function _loadEmails({ force = false, useCache = true, skipFastPath = false } = {}) {
|
||||
export async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
const seq = ++_libLoadSeq;
|
||||
state._libLoading = true;
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
@@ -5075,15 +5044,11 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
|
||||
}
|
||||
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 });
|
||||
}
|
||||
@@ -5119,7 +5084,7 @@ export async function _loadEmails({ force = false, useCache = true, skipFastPath
|
||||
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 && !skipFastPath) {
|
||||
if (!cached && cacheable && !force) {
|
||||
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, a')) return; // links open without toggling selection
|
||||
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(card);
|
||||
});
|
||||
|
||||
@@ -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)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button></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">×</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" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">×</button>`;
|
||||
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">×</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.
|
||||
|
||||
@@ -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">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+8
-57
@@ -2229,36 +2229,6 @@ 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')) {
|
||||
@@ -2637,7 +2607,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 `<endpoint_id>::<id>`)
|
||||
// and the SAME localStorage key `ge-ai-command-model` (value `<base_url>::<id>`)
|
||||
// so the two stay in sync.
|
||||
const _GE_MODEL_KEY = 'ge-ai-command-model';
|
||||
const imageModelSelect = document.getElementById('gallery-image-model');
|
||||
@@ -2647,8 +2617,7 @@ 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 rawSaved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
||||
let saved = rawSaved;
|
||||
const saved = localStorage.getItem(_GE_MODEL_KEY) || '';
|
||||
imageModelSelect.innerHTML = '';
|
||||
const def = document.createElement('option');
|
||||
def.value = '';
|
||||
@@ -2656,34 +2625,12 @@ export function openGallery() {
|
||||
imageModelSelect.appendChild(def);
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
const endpointKey = m.endpoint_id ? String(m.endpoint_id) : (m.source === 'local' ? 'local' : '');
|
||||
opt.value = endpointKey ? `${endpointKey}::${m.id}` : m.id;
|
||||
opt.value = `${m.base_url || ''}::${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>';
|
||||
}
|
||||
@@ -2698,7 +2645,11 @@ 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;
|
||||
|
||||
+3
-50
@@ -294,61 +294,14 @@ function moreListPayloadFromHref(href) {
|
||||
return _moreListPayloads.get(`${match[1]}:${match[2]}`) || '';
|
||||
}
|
||||
|
||||
// Assistant markdown images (``) 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 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 safeUrl = safeLinkUrl(url);
|
||||
if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || '');
|
||||
const safeAlt = escapeHtml(alt || '');
|
||||
const safeTitle = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
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('://', '://');
|
||||
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>`
|
||||
);
|
||||
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async">`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -810,10 +810,7 @@ async function _pick(m) {
|
||||
_renderLoading('Loading models…');
|
||||
}
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
// 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 => {
|
||||
_refreshPickerModels({ force: true, showLoading: !hasCache }).then(isLatest => {
|
||||
if (isLatest && !menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -589,12 +589,6 @@ 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);
|
||||
|
||||
@@ -80,10 +80,7 @@ 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(() => {
|
||||
if (document.hidden) return;
|
||||
_reconnectActive();
|
||||
}, 60000);
|
||||
_activePollInterval = setInterval(() => { _reconnectActive(); }, 20000);
|
||||
}
|
||||
|
||||
// Allow an immediate adopt when the chat stream signals a new research
|
||||
|
||||
+1
-93
@@ -38,89 +38,6 @@ 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;
|
||||
@@ -767,14 +684,6 @@ 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]';
|
||||
@@ -1021,8 +930,7 @@ function createSessionItem(s) {
|
||||
const text = typeof m.content === 'string' ? m.content.trim() : JSON.stringify(m.content);
|
||||
return `${label}: ${text}`;
|
||||
});
|
||||
const header = await _copyChatHeaderFor(s);
|
||||
const text = header + '\n\n' + lines.join('\n\n');
|
||||
const text = lines.join('\n\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (_clipErr) {
|
||||
|
||||
@@ -106,9 +106,6 @@ 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');
|
||||
|
||||
@@ -5336,29 +5336,6 @@ 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;
|
||||
@@ -5396,7 +5373,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" class="egg-dice" data-dice-sides="${sides}" title="d${sides}">${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" 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>`;
|
||||
}).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>`);
|
||||
@@ -5713,7 +5690,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" class="egg-color-copy" data-color="${ctx.esc(c)}" title="Click to copy"></div>
|
||||
<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="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>
|
||||
|
||||
@@ -34,9 +34,6 @@ 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 = '';
|
||||
@@ -82,32 +79,6 @@ 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) {
|
||||
@@ -166,41 +137,6 @@ 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
|
||||
@@ -212,8 +148,7 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
function update(fullText) {
|
||||
lastText = fullText;
|
||||
if (degraded) {
|
||||
cancelPendingTail();
|
||||
safeFullRender(fullText);
|
||||
fullRender(fullText);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -223,34 +158,24 @@ 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)) {
|
||||
resetDom();
|
||||
started = false;
|
||||
committedLen = 0;
|
||||
tailShownLen = 0;
|
||||
appendMode = null;
|
||||
}
|
||||
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;
|
||||
}
|
||||
const tail = fullText.slice(committedLen);
|
||||
if (!tail) {
|
||||
cancelPendingTail();
|
||||
clearTail();
|
||||
tailShownLen = 0;
|
||||
} else if (tailShownLen === 0) {
|
||||
cancelPendingTail();
|
||||
renderTail(tail);
|
||||
} else {
|
||||
scheduleTailRender(tail);
|
||||
}
|
||||
renderTail(fullText.slice(committedLen));
|
||||
} catch (err) {
|
||||
cancelPendingTail();
|
||||
degraded = true;
|
||||
console.error('streamingRenderer: falling back to full render', err);
|
||||
safeFullRender(fullText);
|
||||
fullRender(fullText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,11 +185,7 @@ 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() {
|
||||
cancelPendingTail();
|
||||
if (degraded) {
|
||||
safeFullRender(lastText);
|
||||
return;
|
||||
}
|
||||
if (degraded) return;
|
||||
try {
|
||||
if (!started) start();
|
||||
clearTail();
|
||||
@@ -277,9 +198,9 @@ export function createStreamRenderer(contentEl, { render, hljs } = {}) {
|
||||
} catch (err) {
|
||||
degraded = true;
|
||||
console.error('streamingRenderer: falling back to full render', err);
|
||||
safeFullRender(lastText);
|
||||
fullRender(lastText);
|
||||
}
|
||||
}
|
||||
|
||||
return { update, finalize, reset };
|
||||
return { update, finalize };
|
||||
}
|
||||
|
||||
+1
-1
@@ -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" aria-label="Close">✖</button>
|
||||
<button class="close-btn" id="tasks-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">
|
||||
|
||||
@@ -40,7 +40,6 @@ 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",
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -65,6 +65,12 @@ def test_what_im_writing_targets_the_visible_document():
|
||||
language="richtext",
|
||||
)
|
||||
|
||||
assert _turn_targets_active_document(
|
||||
{"domains": set()},
|
||||
"can you fact check what Im writing",
|
||||
active_document,
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_fact_check_does_not_target_a_stale_visible_document():
|
||||
active_document = MagicMock(
|
||||
|
||||
@@ -352,8 +352,7 @@ 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 "listen(aiInpaintSelect, 'change', () => updateFooterEditModel());" in models
|
||||
assert "signal: listeners.signal" in models
|
||||
assert "aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());" in models
|
||||
|
||||
|
||||
def test_quick_edit_has_a_model_picker():
|
||||
|
||||
@@ -79,14 +79,6 @@ 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):
|
||||
|
||||
@@ -8,7 +8,6 @@ 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():
|
||||
@@ -38,28 +37,3 @@ 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
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
@@ -401,51 +401,6 @@ 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 `` 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"
|
||||
""
|
||||
)
|
||||
|
||||
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://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:  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(")")
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
"""Independent validation test for the claim that
|
||||
`SkillsManager.update_skill` mutates the first skill on disk matching
|
||||
`name` regardless of the caller's owner, and that `owner` is in its
|
||||
`scalar_keys` whitelist allowing cross-user ownership reassignment.
|
||||
"""Skills operations must preserve ownership across same-slug skills.
|
||||
|
||||
This test sets up two user-owned skills on disk with the SAME slug
|
||||
(`login-flow`) — Alice's and Bob's — and then calls `update_skill` with
|
||||
NO `owner` argument. If the bug is real, exactly one of the two files
|
||||
will be mutated (whichever `_iter_skill_files` yields first) and the
|
||||
caller will have effectively re-stamped the file as owned by the value
|
||||
in `updates["owner"]` ("attacker"). If the manager method is safe (or
|
||||
the slug uniqueness invariant makes the bug moot), the call should
|
||||
either:
|
||||
* raise (it requires an `owner` argument), OR
|
||||
* be a no-op (no other side effect on Bob's file), OR
|
||||
* the file that gets modified should still belong to its original
|
||||
owner (no ownership reassignment).
|
||||
|
||||
We assert the safer behaviors; the test FAILS only when update_skill
|
||||
silently mutates a file owned by a different user AND overwrites the
|
||||
`owner` field with an attacker's value.
|
||||
An unscoped update may match only ownerless skills. With Alice's and Bob's
|
||||
skills sharing a slug, it must return False and leave both files unchanged.
|
||||
Unexpected implementation errors must fail the regression test.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -27,9 +11,6 @@ import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── module-load stubbing (matches other tests in this repo) ──────────
|
||||
# Stub heavy deps so importing the skills manager doesn't pull DB / FastAPI.
|
||||
for _mod in ("sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative"):
|
||||
@@ -100,37 +81,34 @@ def test_update_skill_does_not_mutate_foreign_owned_skill(tmp_path):
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
|
||||
# Snapshot before.
|
||||
before_alice = alice_path.read_text(encoding="utf-8")
|
||||
before_bob = bob_path.read_text(encoding="utf-8")
|
||||
before_alice = alice_path.read_bytes()
|
||||
before_bob = bob_path.read_bytes()
|
||||
|
||||
# Try to reassign + mutate. The caller does NOT supply an owner
|
||||
# arg, mirroring the in-process callers in tool_implementations.py
|
||||
# (lines 716, 740, 753) which call sm.update_skill(name, updates).
|
||||
try:
|
||||
result = sm.update_skill(
|
||||
"login-flow",
|
||||
{"owner": "attacker", "description": "pwned"},
|
||||
)
|
||||
except TypeError as e:
|
||||
# If the method were fixed to require an owner arg, this is
|
||||
# the desired (safe) behavior — the call refused.
|
||||
pytest.skip(
|
||||
f"update_skill raised TypeError (refused unsafe call): {e}"
|
||||
)
|
||||
return
|
||||
result = sm.update_skill(
|
||||
"login-flow",
|
||||
{"owner": "attacker", "description": "pwned"},
|
||||
)
|
||||
|
||||
# After: read what each file now contains.
|
||||
after_alice = alice_path.read_text(encoding="utf-8")
|
||||
after_bob = bob_path.read_text(encoding="utf-8")
|
||||
after_alice = alice_path.read_bytes()
|
||||
after_bob = bob_path.read_bytes()
|
||||
|
||||
assert after_alice == before_alice
|
||||
assert after_bob == before_bob
|
||||
assert b"owner: alice" in after_alice
|
||||
assert b"owner: bob" in after_bob
|
||||
|
||||
# Invariant 1: a file that was owned by `alice` (resp. `bob`) MUST
|
||||
# NOT end up owned by `attacker` after the call. If it does, that's
|
||||
# the cross-user ownership reassignment bug.
|
||||
assert "owner: attacker" not in after_alice, (
|
||||
assert b"owner: attacker" not in after_alice, (
|
||||
"BUG: Alice's file was silently re-owned as 'attacker' by "
|
||||
"update_skill (cross-user ownership reassignment)."
|
||||
)
|
||||
assert "owner: attacker" not in after_bob, (
|
||||
assert b"owner: attacker" not in after_bob, (
|
||||
"BUG: Bob's file was silently re-owned as 'attacker' by "
|
||||
"update_skill (cross-user ownership reassignment)."
|
||||
)
|
||||
@@ -138,23 +116,19 @@ def test_update_skill_does_not_mutate_foreign_owned_skill(tmp_path):
|
||||
# Invariant 2: a file that was owned by `alice` and contained
|
||||
# description "alice original" must not be silently mutated into
|
||||
# "pwned" by a caller that did not supply an owner.
|
||||
if "alice original" in before_alice:
|
||||
assert "alice original" in after_alice, (
|
||||
if b"alice original" in before_alice:
|
||||
assert b"alice original" in after_alice, (
|
||||
"BUG: Alice's skill description was overwritten by a call "
|
||||
"to update_skill that did not scope to her owner."
|
||||
)
|
||||
|
||||
if "bob original" in before_bob:
|
||||
assert "bob original" in after_bob, (
|
||||
if b"bob original" in before_bob:
|
||||
assert b"bob original" in after_bob, (
|
||||
"BUG: Bob's skill description was overwritten by a call "
|
||||
"to update_skill that did not scope to his owner."
|
||||
)
|
||||
|
||||
# The return value should not lie about success — if the manager
|
||||
# touched nothing because both files were foreign-owned, the safer
|
||||
# behavior is to return False, not True. (A return of True is the
|
||||
# buggy path; we don't assert False, we just don't assert True.)
|
||||
_ = result # not asserted; documented behavior is not the point.
|
||||
assert result is False, "an unscoped update must not report a foreign-owner mutation"
|
||||
|
||||
|
||||
def test_update_skill_scalar_keys_exclude_owner():
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
"""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
|
||||
@@ -1,251 +0,0 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user