Guides: make images render, and stop drafts leaking to readers

Images were a broken icon for every user. The stored URL was bound straight
to <img src>, and a plain image request carries no Authorization header while
this app keeps its JWT in one, so every fetch was a 401. They are now fetched
through the API client, which also fixes the stored '/api/...' path under a
subpath mount where the API is not at /api at all. A failed image says so in
words rather than rendering as a broken icon, and the editor's own thumbnails
go through the same path - an author needs to see the screenshot they pasted.

Revision and image routes gated on their parent guide. Both were gated on
guides.view alone, and revision ids are sequential, so being able to read one
guide was enough to walk the ids and read every unpublished draft on the site -
the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide
still serves to any reader; one that only appears in a draft does not.

The guide list, the tag chips and search now match the PUBLISHED text rather
than the working copy. Matching the working copy handed readers a hit on
wording that is not in the page when they open it.

The slug no longer follows the title. It was regenerated on every save that
carried a title and the editor autosaves every three seconds, so fixing a typo
in a heading silently moved the URL and broke exactly the pasted links the
stored slug exists to protect.

publish() now aborts when the pre-publish save failed. It ran regardless, so
the author saw "Published version N" while readers got the previous revision.

Link rendering: text is escaped before formatting, so a captured URL arrives
carrying entities and was escaped a second time - every multi-parameter
ServiceNow and SharePoint link opened with &amp;amp; in it. Emphasis could also
reach inside an href, and '//host' passed as an in-app link when the browser
reads it as off-site.

Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes
that no test executed, which is how three of them shipped serving drafts.
This commit is contained in:
cproudlock
2026-08-26 15:04:09 -04:00
parent 00773a77af
commit cebb3e9e44
7 changed files with 590 additions and 26 deletions

View File

@@ -72,8 +72,27 @@
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
</div>
<!--
The image is fetched by script, not by the browser.
An <img src> is a plain browser request: it carries no Authorization
header, and this app keeps its JWT in one. So a direct src against the
authenticated image route is a 401 and a broken-image icon for every
reader - which is exactly how this shipped. Fetching through the api
client sends the token, and it resolves against the client's baseURL, so
it also survives a subpath mount that a stored '/api/...' string does
not.
A failure says so in words. A guide whose screenshot is missing must not
look like a guide that never had one.
-->
<figure v-else-if="block.type === 'image'" class="gb-figure">
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
<img v-if="imageState(block).src" :src="imageState(block).src"
:alt="block.alt || ''">
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
</p>
<p v-else class="gb-imageloading">Loading image...</p>
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
</figure>
@@ -97,8 +116,9 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, toRef } from 'vue'
import { renderInline } from '../utils/inline.js'
import { useGuideImages } from '../utils/images.js'
const props = defineProps({
blocks: { type: Array, default: () => [] },
@@ -112,6 +132,9 @@ const selected = ref('')
const codeNodes = {}
function registerCode (id, el) { if (el) codeNodes[id] = el }
// Images are fetched by script, not by <img src>; see utils/images.js.
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
function inline (text) {
return renderInline(text)
}
@@ -226,6 +249,12 @@ function select (block) {
.gb-figure { margin: 0 0 1.2rem; }
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
.gb-imageloading, .gb-imagefailed {
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
border-radius: 4px; font-size: 0.9rem;
}
.gb-imageloading { color: var(--textmuted); }
.gb-imagefailed { color: var(--danger, #b3261e); }
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }