mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix(attachments): base-prefix interactive pane attachment requests
A console interactive pane is node-proxied — every request rides the pane's
transport base ("/node/{id}"). The attachment controller hardcoded bare
/v1/api/workstreams/... paths, so upload / list / delete / preview landed on
the console's OWN coord route, which resolves ws_id via coord_mgr.get() and
404s as "coordinator not found". The standalone server (base="") was
unaffected, which masked the bug.
Thread the pane base through: createAttachmentController and
buildAttachmentPreview take an optional getBase / base, and the interactive
pane wires this._base into both. Coordinator panes and the standalone server
pass "" and stay origin-mounted as before.
This commit is contained in:
@@ -642,6 +642,35 @@ def test_tab_menu_dead_controller_prefers_live_node() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_lane_is_base_aware() -> None:
|
||||
"""Console regression: an interactive pane is node-proxied, so its attachment
|
||||
upload / list / delete / preview requests must ride the pane's transport base
|
||||
("/node/{id}"). Without it they hit the console's OWN coord route and 404 as
|
||||
"coordinator not found" (the standalone server, base="", was unaffected —
|
||||
which masked the bug). Mirrors the base-aware verb lane: the controller
|
||||
resolves a base from ``opts.getBase`` and prefixes every attachment URL;
|
||||
``buildAttachmentPreview`` takes the base for its thumbnail / content src; the
|
||||
interactive pane wires both."""
|
||||
attach = (_SHARED / "composer_attachments.js").read_text(encoding="utf-8")
|
||||
assert "function _attachUrl(base, wsId, id, suffix)" in attach, (
|
||||
"the per-attachment URL builder must be base-first"
|
||||
)
|
||||
assert "function _base()" in attach and "opts.getBase" in attach, (
|
||||
"the controller must resolve a node base from opts.getBase"
|
||||
)
|
||||
assert "base: _base()" in attach, "committed-chip previews must carry the base"
|
||||
assert "base = opts.base" in attach, "buildAttachmentPreview must consume opts.base"
|
||||
# upload + remove + rehydrate must each base-prefix their collection/row URL.
|
||||
assert attach.count("_base() +") >= 3, (
|
||||
"upload, remove, and rehydrate must each base-prefix their URL"
|
||||
)
|
||||
pane = (_SHARED / "interactive.js").read_text(encoding="utf-8")
|
||||
assert "getBase: () =>" in pane, (
|
||||
"the interactive pane must pass its node base into the attachment controller"
|
||||
)
|
||||
assert "base: attachBase" in pane, "history-pill previews must ride the pane's base too"
|
||||
|
||||
|
||||
def test_step7_tab_menu_css_promoted_shared() -> None:
|
||||
"""Step 7: the dropdown chrome is promoted to the SHARED shell sheet (so both
|
||||
deployments render it), recovered from the retired .ws-tab-dropdown design but
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* composer_attachments.js — shared paperclip / chip / upload pipeline.
|
||||
*
|
||||
* Used by both:
|
||||
* - turnstone/ui/static/app.js (interactive Pane)
|
||||
* - turnstone/shared_static/interactive.js (interactive Pane — standalone
|
||||
* server at the origin, console node-proxied via opts.getBase)
|
||||
* - turnstone/console/static/coordinator/coordinator.js (coord IIFE)
|
||||
*
|
||||
* Owns: the in-flight `pendingAttachments` Map (insertion-ordered, so
|
||||
@@ -47,8 +48,14 @@ function _inferKind(file) {
|
||||
return "text";
|
||||
}
|
||||
|
||||
function _attachUrl(wsId, id, suffix) {
|
||||
// ``base`` is the node-proxy prefix ("/node/{id}") for a console-hosted
|
||||
// interactive pane, or "" for the standalone server / console-local coordinator
|
||||
// panes where the attachment routes are mounted at the origin. Without it a
|
||||
// console interactive pane's request lands on the console's own coord route and
|
||||
// 404s as "coordinator not found".
|
||||
function _attachUrl(base, wsId, id, suffix) {
|
||||
return (
|
||||
base +
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments/" +
|
||||
@@ -70,10 +77,12 @@ export function kindIcon(kind) {
|
||||
// Build an inline preview node for a committed attachment (real id), or null.
|
||||
// image/pdf → server-rendered thumbnail; audio → <audio> player; text → a lazy
|
||||
// snippet. Auth is cookie-based, so a plain media `src` works same-origin.
|
||||
// ``opts.base`` is the node-proxy prefix (see _attachUrl) — "" by default.
|
||||
export function buildAttachmentPreview(opts) {
|
||||
var kind = opts.kind,
|
||||
wsId = opts.wsId,
|
||||
id = opts.attachmentId;
|
||||
id = opts.attachmentId,
|
||||
base = opts.base || "";
|
||||
if (!wsId || !id) return null;
|
||||
if (kind === "image" || kind === "pdf") {
|
||||
var img = document.createElement("img");
|
||||
@@ -81,7 +90,7 @@ export function buildAttachmentPreview(opts) {
|
||||
img.loading = "lazy";
|
||||
img.decoding = "async";
|
||||
img.alt = "";
|
||||
img.src = _attachUrl(wsId, id, "/thumbnail");
|
||||
img.src = _attachUrl(base, wsId, id, "/thumbnail");
|
||||
// If the thumbnail can't render, swap in the kind glyph rather than removing
|
||||
// the node: the caller has already replaced the original icon span with this
|
||||
// img, so a bare remove() would leave a blank gap (no icon at all).
|
||||
@@ -99,7 +108,7 @@ export function buildAttachmentPreview(opts) {
|
||||
audio.className = "attach-preview attach-preview-audio";
|
||||
audio.controls = true;
|
||||
audio.preload = "none";
|
||||
audio.src = _attachUrl(wsId, id, "/content");
|
||||
audio.src = _attachUrl(base, wsId, id, "/content");
|
||||
// Native control is keyboard-focusable; name it so it isn't announced as a
|
||||
// bare "audio" with no indication of which attachment it plays.
|
||||
audio.setAttribute(
|
||||
@@ -119,7 +128,7 @@ export function buildAttachmentPreview(opts) {
|
||||
(opts && opts.authFetch) ||
|
||||
(typeof window !== "undefined" ? window.authFetch : null);
|
||||
if (typeof fetchFn !== "function") return null;
|
||||
fetchFn(_attachUrl(wsId, id, "/content"), {
|
||||
fetchFn(_attachUrl(base, wsId, id, "/content"), {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
})
|
||||
@@ -180,6 +189,11 @@ function _toastError(msg) {
|
||||
* chipsEl: HTMLElement — chips render target (composer.chipsEl).
|
||||
* getWsId: () => string — current workstream id (function so the
|
||||
* interactive pane can swap tabs without re-instantiating).
|
||||
* getBase: optional () => string — node-proxy prefix ("/node/{id}")
|
||||
* for a console-hosted interactive pane; "" (default) for the
|
||||
* standalone server and console-local coordinator panes. A
|
||||
* function, like getWsId, so a tab swap to a session on another
|
||||
* node retargets without re-instantiating the controller.
|
||||
* authFetch: optional override (default window.authFetch).
|
||||
* onError: optional (msg, err) => void — replaces the default toast
|
||||
* for upload failures.
|
||||
@@ -200,6 +214,11 @@ export function createAttachmentController(opts) {
|
||||
var fn = opts.authFetch || window.authFetch;
|
||||
return fn(url, init);
|
||||
}
|
||||
// Node-proxy prefix for the active tab (see opts.getBase). Resolved per call
|
||||
// so a tab swap onto a different node retargets every subsequent request.
|
||||
function _base() {
|
||||
return (typeof opts.getBase === "function" && opts.getBase()) || "";
|
||||
}
|
||||
var pending = new Map();
|
||||
|
||||
function renderChip(info) {
|
||||
@@ -256,6 +275,7 @@ export function createAttachmentController(opts) {
|
||||
var prev = buildAttachmentPreview({
|
||||
kind: info.kind,
|
||||
wsId: getWsId(),
|
||||
base: _base(),
|
||||
attachmentId: info.attachment_id,
|
||||
filename: info.filename,
|
||||
authFetch: _authFetch,
|
||||
@@ -348,7 +368,10 @@ export function createAttachmentController(opts) {
|
||||
renderChip(placeholder);
|
||||
|
||||
_authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/attachments",
|
||||
_base() +
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments",
|
||||
{ method: "POST", credentials: "include", body: fd },
|
||||
)
|
||||
.then(function (r) {
|
||||
@@ -381,7 +404,8 @@ export function createAttachmentController(opts) {
|
||||
var wsId = getWsId();
|
||||
if (!wsId) return;
|
||||
_authFetch(
|
||||
"/v1/api/workstreams/" +
|
||||
_base() +
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments/" +
|
||||
encodeURIComponent(attachmentId),
|
||||
@@ -401,7 +425,10 @@ export function createAttachmentController(opts) {
|
||||
var wsId = getWsId();
|
||||
if (!wsId) return Promise.resolve();
|
||||
return _authFetch(
|
||||
"/v1/api/workstreams/" + encodeURIComponent(wsId) + "/attachments",
|
||||
_base() +
|
||||
"/v1/api/workstreams/" +
|
||||
encodeURIComponent(wsId) +
|
||||
"/attachments",
|
||||
{ method: "GET", credentials: "include" },
|
||||
)
|
||||
.then(function (r) {
|
||||
|
||||
@@ -370,6 +370,7 @@ class Pane {
|
||||
const pills = document.createElement("div");
|
||||
pills.className = "msg-user-attach";
|
||||
const attachWsId = this.wsId;
|
||||
const attachBase = this._base;
|
||||
attachments.forEach(function (a) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "msg-user-attach-pill";
|
||||
@@ -393,6 +394,7 @@ class Pane {
|
||||
? window.buildAttachmentPreview({
|
||||
kind: a.kind,
|
||||
wsId: attachWsId,
|
||||
base: attachBase,
|
||||
attachmentId: a.attachment_id,
|
||||
filename: a.filename,
|
||||
})
|
||||
@@ -751,6 +753,9 @@ class Pane {
|
||||
getWsId: () => {
|
||||
return this.wsId;
|
||||
},
|
||||
getBase: () => {
|
||||
return this._base;
|
||||
},
|
||||
onError: (msg) => {
|
||||
showToast(msg);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user