Public Access
directory card photo: fetch the JSON envelope, decode the data URI, and bound every request
The endpoint answers 200 application/json with {"Image":"data:image/jpeg;base64,…"} —
a JSON envelope, NOT image bytes. An <img src="…?id=N"> pointed at it never settles:
measured on the live page, complete=false · naturalWidth=0 for 20 s, so the fallback
never fired and the card showed an empty slot. The card now FETCHES the route, reads
`Image` and puts the data URI on an <img> only when it passes the isImageSrc allowlist.
- static/expert-image.js: `load(id)` replaces `url(id, rec)`. It fetches the route,
reads `Image`, accepts the array shape n8n sometimes returns, and resolves to a usable
image source or `null` on ANY failure. EVERY request is bounded by `timeoutMs` (8000):
an AbortController is aborted by a setTimeout, the abort rejects the fetch and lands in
the same catch that returns null — so a hang becomes the monogram, not an empty box.
The timer is cleared on every completion. The decoded URI is cached by id (failures
too), so a re-render or a shared id never re-fetches. Every non-success path — timeout,
abort, non-2xx, {}, a blank/missing Image, a value that fails isImageSrc — ends in the
monogram: never an empty box, never a broken-image icon, never alt text in the layout.
- Directory card (odoo-community-developers/index.html): the monogram reserves the 72x72
box and stands until a real photo has DECODED; the photo is requested lazily, only when
a card nears the viewport (IntersectionObserver, rootMargin 200px), so a 14-card page
does not fire 1.4 MB on load. The monogram and the photo are the same 72x72 box, so the
swap moves nothing (measured: head box 274.81x72 and ask-row tops byte-identical across
a loaded photo and a monogram).
- /freelancer-edit/: the photo preview makes the same fetch, so the form no longer points
an <img> at the JSON route either.
Proved in a real browser against the repo served with the two webhook URLs stubbed:
jpeg data URI -> photo (src prefix data:image/jpeg;base64,/, naturalWidth 800); {} / 404 /
500 / a bad value -> monogram, 0 broken-image icons, 0 cards with neither; a stub that
never answers -> monogram after 8000 ms with the request ABORTED (client abort event at
8000.x ms, all 6 sockets disconnected server-side). No record created, no probe write.
This commit is contained in:
1 parent
2b0e89191a
commit
29ff175e89
3 files changed
+191
-86
No files matched your search
+92
-30
@@ -1,4 +1,4 @@
|
||||
/* The freelancer PHOTO — one endpoint, one constant.
|
||||
/* The freelancer PHOTO — one endpoint, one constant, one fetch.
|
||||
|
||||
The photo does NOT live in the profile payload any more. It used to ride there as
|
||||
inline base64 under the key `Image`; the webhook no longer sends that key, so the
|
||||
@@ -7,42 +7,104 @@
|
||||
ONE constant holds BOTH halves of the address: the route and the query-parameter
|
||||
name. Changing either is a one-line edit here, and both pages pick it up.
|
||||
|
||||
The parameter name is `id`. It was recorded as UNVERIFIED in the brief (every probe
|
||||
answered "workflow is not registered"), and it is now CONFIRMED by measurement
|
||||
against the live route: `?id=<n>` answers 200 with the photo for that record, while
|
||||
`/4`, `?profile_id=`, `?image_id=` and a missing id all fail. See
|
||||
webmaster/out/2026-11-03-photo-endpoint/REPORT.md.
|
||||
The parameter name is `id`, CONFIRMED by measurement against the live route:
|
||||
`?id=<n>` answers 200 with the photo for that record, while `/4`, `?profile_id=`,
|
||||
`?image_id=` and a missing id all fail.
|
||||
|
||||
The endpoint currently answers `application/json` with `{"Image":"data:image/jpeg;
|
||||
base64,…"}` — a JSON envelope, NOT image bytes — so an <img> cannot decode it and
|
||||
every card falls back to its monogram. That is the measured state, not a guess; the
|
||||
page is built for the shape the brief specifies (an <img> pointed at this route) and
|
||||
starts showing photos the moment the route returns image bytes.
|
||||
THE ENDPOINT RETURNS JSON, NOT IMAGE BYTES. Measured on the live route:
|
||||
|
||||
`versionParam`: appended ONLY when the payload carries a version token, so a
|
||||
year-long immutable cache on the endpoint stays correct — the URL changes when the
|
||||
photo does. Without a token the URL is stable and the endpoint's own headers decide
|
||||
caching (see the report: it sends no Cache-Control and a weak ETag today). */
|
||||
GET …/odoo_experts_image?id=10 → 200 application/json
|
||||
body: {"Image":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"}
|
||||
|
||||
So a plain `<img src="…?id=N">` receives JSON, cannot decode it and fires `onerror`
|
||||
— the monogram. That is why the card FETCHES the route, reads `Image` and puts the
|
||||
DATA URI on an `<img>` only when it passes the `isImageSrc` allowlist. n8n sometimes
|
||||
answers an array instead of an object, so both shapes are accepted.
|
||||
|
||||
`load(id)` resolves to a usable image source, or `null` on ANY failure — a non-2xx, an
|
||||
empty `{}`, a missing/blank `Image`, a value that fails `isImageSrc`, or a request that
|
||||
does not answer in `timeoutMs`. A `null` answer means "show the monogram": the caller
|
||||
never sees a broken image. The decoded URI is cached by id (and a failure is remembered
|
||||
too), so a re-render can never re-fetch a photo already in memory.
|
||||
|
||||
THE TIMEOUT. A request that neither succeeds nor errors leaves the fallback unable to
|
||||
fire (measured live: an <img> pointed at this route sat `complete=false · naturalWidth=0`
|
||||
for 20 s). Each fetch is therefore bounded by `timeoutMs` (default 8000): an
|
||||
`AbortController` is aborted by a `setTimeout`, the abort rejects the fetch, and the
|
||||
rejection lands in the same catch that returns `null` — the monogram. The timer is
|
||||
cleared on every completion, so a fast answer never pays for the guard. `AbortController`
|
||||
is universal in the browsers this site targets (it already needs `fetch`, `Promise` and
|
||||
`IntersectionObserver`); if it were missing there is nothing to abort with, but the
|
||||
abort is what turns a hang into the fallback, so it is the one capability worth naming.
|
||||
|
||||
The route answers with `Access-Control-Allow-Origin` echoing the caller and
|
||||
`Cache-Control: public, max-age=3600`, so the browser may also serve a repeat from its
|
||||
own HTTP cache. */
|
||||
window.O4PExpertImage = {
|
||||
endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image",
|
||||
idParam: "id",
|
||||
versionParam: "v",
|
||||
// Anything that changes when the photo changes. `updatedAt` used to be in the payload.
|
||||
versionFields: ["updatedAt", "updated_at", "ImageVersion", "image_version"],
|
||||
// A photo request that hangs is a card with no photo AND no fallback: bound it.
|
||||
timeoutMs: 8000,
|
||||
|
||||
version: function (rec) {
|
||||
if (!rec) return "";
|
||||
for (var i = 0; i < this.versionFields.length; i++) {
|
||||
var v = rec[this.versionFields[i]];
|
||||
if (v !== undefined && v !== null && v !== "") return String(v);
|
||||
}
|
||||
return "";
|
||||
/* The ONLY allowlist for an image source in this codebase — `https://` or a
|
||||
`data:image/` URI. Both forms are what this endpoint has served across its life. */
|
||||
isImageSrc: function (v) {
|
||||
return typeof v === "string" &&
|
||||
(v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0);
|
||||
},
|
||||
|
||||
url: function (id, rec) {
|
||||
var u = this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
|
||||
var v = this.version(rec);
|
||||
if (v) u += "&" + this.versionParam + "=" + encodeURIComponent(v);
|
||||
return u;
|
||||
url: function (id) {
|
||||
return this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
|
||||
},
|
||||
|
||||
// id -> decoded image source (or "" when the record has no usable photo)
|
||||
cache: Object.create(null),
|
||||
// id -> the in-flight Promise, so two observers of one id share ONE request
|
||||
inflight: Object.create(null),
|
||||
|
||||
/* Fetch the photo for `id`. Resolves to an image source, or null when there is none.
|
||||
Never rejects — a caller only has to check for a value. Every request is bounded by
|
||||
`timeoutMs`: on expiry the fetch is ABORTED (not merely abandoned) and the rejection
|
||||
resolves null, so a hung endpoint becomes the monogram instead of an empty box. */
|
||||
load: function (id) {
|
||||
var self = this;
|
||||
var key = String(id);
|
||||
if (key in self.cache) return Promise.resolve(self.cache[key] || null);
|
||||
if (self.inflight[key]) return self.inflight[key];
|
||||
|
||||
var ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null;
|
||||
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, self.timeoutMs) : 0;
|
||||
|
||||
var req = fetch(self.url(key), {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: ctrl ? ctrl.signal : undefined
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (body) {
|
||||
// n8n sometimes wraps the record in an array — accept both shapes.
|
||||
var d = Array.isArray(body) ? body[0] : body;
|
||||
var img = d && d.Image;
|
||||
if (!self.isImageSrc(img)) throw new Error("no image");
|
||||
self.cache[key] = img;
|
||||
return img;
|
||||
})
|
||||
.catch(function () {
|
||||
// Every failure — non-2xx, no image, a decode-hostile value, or the timeout's
|
||||
// abort — is the monogram. Remember it, so a shared id under a re-render does not
|
||||
// ask the network twice.
|
||||
self.cache[key] = "";
|
||||
return null;
|
||||
})
|
||||
.then(function (out) {
|
||||
if (timer) clearTimeout(timer);
|
||||
delete self.inflight[key];
|
||||
return out;
|
||||
});
|
||||
|
||||
self.inflight[key] = req;
|
||||
return req;
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user