feat: an image library — every picture this account has made
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram is still good. Until now a generated image could only be seen in the resource it was made for, and there was no way to find one again or to delete it. Library → Images is a grid of every finished image the account owns, across all three workflows, newest first. GET /api/generated-images is scoped by owner_id in the statement rather than filtered after, returns only finished jobs — an unfinished one is a broken frame in a gallery — and pages by keyset, because a gallery that grows while you scroll repeats or skips rows under OFFSET. Most of this already existed. Thumbnails were already rendered at 256 and 640 by sharp and already served by ?w=, with their own checksum so the client's tamper check passes on a derived copy; hydrateImage already handles auth, the account boundary and caching. The tiles ask for the 256px preview, so thirty of them cost a few kB each rather than thirty full-size downloads, and the prompt is decrypted for the caption because it is the only human-readable label an image has. Deleting needed new work. The storage interface had no remove at all, so a delete that dropped the row would have left the object and both previews in the bucket — paid for, and still readable by anything with credentials. Storage now removes all three keys, and the bytes go before the row: a row pointing at a missing object is a broken image in a gallery, while an object without its row is only wasted space, and unreachable storage refuses the delete outright rather than reporting a success that left the picture behind. THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift, and the drift that matters is a width that gets written and never deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
83206e907c
commit
259b4858be
8 changed files with 461 additions and 9 deletions
|
|
@ -253,6 +253,42 @@ download that works without it.
|
|||
of Python. Both pip packages are pinned: unpinned, a rebuild from the same commit
|
||||
could produce different documents.
|
||||
|
||||
## The image library
|
||||
|
||||
Library → **Images** is every picture the account has generated, across all
|
||||
three features — My Resources, the Clinical Assistant and the Learning Hub —
|
||||
newest first. A figure outlives the deck it was drawn for: the deck gets
|
||||
replaced, the diagram is still good.
|
||||
|
||||
`GET /api/generated-images` returns only finished jobs, scoped by `owner_id` in
|
||||
the statement rather than filtered afterwards. Paging is keyset (`created_at <
|
||||
cursor`), not `OFFSET`, because a gallery that grows while you scroll repeats or
|
||||
skips a row under `OFFSET`. The prompt is the only human-readable label an image
|
||||
has — there is no filename and no title — so it is decrypted for the caption; a
|
||||
prompt that cannot be decrypted costs the caption, never the picture.
|
||||
|
||||
Tiles request the stored 256px preview through `data-image-thumb`, so thirty
|
||||
tiles cost a few kB each rather than thirty full-size downloads. Every fetch
|
||||
goes through `hydrateImage`, never a bare `src`: assets are served `no-store`
|
||||
and a bare `src` would not carry the session on a mobile client.
|
||||
|
||||
### Deleting
|
||||
|
||||
`DELETE /api/generated-images/:id` removes the bytes before the row, and refuses
|
||||
the whole operation if storage is unreachable. The other order would leave a row
|
||||
pointing at a key that is gone — an image listed in the gallery that renders
|
||||
broken — whereas failing between the two leaves a complete, working image and an
|
||||
error worth retrying.
|
||||
|
||||
Both derived previews go with the original; they live under their own prefix in
|
||||
the same bucket, and missing them would leave paid-for bytes behind that are
|
||||
still readable. `THUMB_WIDTHS` is defined once, in `generatedImageStorage.js`,
|
||||
because a width that is written but never deleted is exactly what two copies of
|
||||
that list produces.
|
||||
|
||||
A resource that embedded the figure keeps working: a deck stores the job id and
|
||||
renders without the figure when it has gone.
|
||||
|
||||
## Slide review
|
||||
|
||||
Off unless an administrator names a model, in **Admin → Slide review**.
|
||||
|
|
|
|||
|
|
@ -149,13 +149,37 @@
|
|||
<div class="card" style="margin-top:14px;">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-book"></i> Library</h3>
|
||||
<button id="btn-mr-refresh" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
<!-- Two views of the same library. Documents is what this card has always
|
||||
shown; Images is every picture this account has generated, wherever it
|
||||
was made, because a figure is worth keeping after the deck it was
|
||||
drawn for has been replaced. -->
|
||||
<div role="tablist" aria-label="Library view" style="display:flex;gap:2px;background:var(--g100);border-radius:6px;padding:2px;">
|
||||
<button id="tab-mr-docs" role="tab" aria-selected="true" aria-controls="mr-list" class="btn-sm" type="button"
|
||||
style="border:0;border-radius:4px;background:var(--white);font-size:12px;padding:4px 10px;">Documents</button>
|
||||
<button id="tab-mr-images" role="tab" aria-selected="false" aria-controls="mr-images-panel" class="btn-sm" type="button"
|
||||
style="border:0;border-radius:4px;background:transparent;font-size:12px;padding:4px 10px;">Images</button>
|
||||
</div>
|
||||
<button id="btn-mr-refresh" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px 0;">
|
||||
<div id="mr-docs-search" style="padding:12px 16px 0;">
|
||||
<input id="mr-search" type="search" class="admin-control" placeholder="Search your resources by title or topic" autocomplete="off">
|
||||
</div>
|
||||
<!-- Bounded rather than unlimited: a long library otherwise pushes everything
|
||||
else off the page. Tall enough to show several at a glance, and it
|
||||
collapses to the content when there are only a few. -->
|
||||
<div id="mr-list" style="padding:12px 16px;display:flex;flex-direction:column;gap:6px;max-height:360px;overflow-y:auto;"></div>
|
||||
<div id="mr-list" role="tabpanel" aria-labelledby="tab-mr-docs" style="padding:12px 16px;display:flex;flex-direction:column;gap:6px;max-height:360px;overflow-y:auto;"></div>
|
||||
|
||||
<!-- A grid rather than a list: a picture is recognised by looking at it, and a
|
||||
row of filenames would defeat the point of keeping them. Auto-fill so it
|
||||
reflows to one or two columns on a phone without a breakpoint. -->
|
||||
<div id="mr-images-panel" role="tabpanel" aria-labelledby="tab-mr-images" hidden
|
||||
style="padding:12px 16px;max-height:420px;overflow-y:auto;">
|
||||
<p id="mr-images-empty" style="margin:0;font-size:12px;color:var(--g500);">Loading…</p>
|
||||
<div id="mr-images-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;"></div>
|
||||
<div style="display:flex;justify-content:center;padding-top:10px;">
|
||||
<button id="btn-mr-images-more" class="btn-sm btn-ghost" type="button" hidden>Load more</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,18 @@
|
|||
if (generate) generate.addEventListener('click', runGenerate);
|
||||
|
||||
var refresh = document.getElementById('btn-mr-refresh');
|
||||
if (refresh) refresh.addEventListener('click', loadLibrary);
|
||||
// Refreshes whichever view is showing, rather than always the documents.
|
||||
if (refresh) refresh.addEventListener('click', function () {
|
||||
var panel = document.getElementById('mr-images-panel');
|
||||
if (panel && !panel.hidden) loadImages(true); else loadLibrary();
|
||||
});
|
||||
|
||||
var docsTab = document.getElementById('tab-mr-docs');
|
||||
var imagesTab = document.getElementById('tab-mr-images');
|
||||
if (docsTab) docsTab.addEventListener('click', function () { showLibraryTab('docs'); });
|
||||
if (imagesTab) imagesTab.addEventListener('click', function () { showLibraryTab('images'); });
|
||||
var moreImages = document.getElementById('btn-mr-images-more');
|
||||
if (moreImages) moreImages.addEventListener('click', function () { loadImages(false); });
|
||||
|
||||
// One delegated handler: rows are rebuilt on every refresh, so binding per
|
||||
// row would leak listeners and miss anything added later.
|
||||
|
|
@ -254,6 +265,172 @@
|
|||
});
|
||||
}
|
||||
|
||||
// ── The image library ──────────────────────────────────────────────────────
|
||||
// Every picture this account has generated, across all three features. Kept
|
||||
// because a figure outlives the deck it was drawn for: the deck gets replaced,
|
||||
// the diagram is still good.
|
||||
//
|
||||
// Thumbnails, not originals. The server stores a 256px preview beside each
|
||||
// asset, so a grid of thirty costs a few kB each instead of thirty full-size
|
||||
// renders — data-image-thumb is what asks hydrateImage for the small copy.
|
||||
var imagesLoaded = false;
|
||||
var nextImagesBefore = null;
|
||||
|
||||
function showLibraryTab(which) {
|
||||
var docs = which !== 'images';
|
||||
[['tab-mr-docs', docs], ['tab-mr-images', !docs]].forEach(function (pair) {
|
||||
var tab = document.getElementById(pair[0]);
|
||||
if (!tab) return;
|
||||
tab.setAttribute('aria-selected', pair[1] ? 'true' : 'false');
|
||||
tab.style.background = pair[1] ? 'var(--white)' : 'transparent';
|
||||
});
|
||||
var list = document.getElementById('mr-list');
|
||||
var panel = document.getElementById('mr-images-panel');
|
||||
var search = document.getElementById('mr-docs-search');
|
||||
if (list) list.hidden = !docs;
|
||||
if (search) search.hidden = !docs;
|
||||
if (panel) panel.hidden = docs;
|
||||
// Fetched the first time the tab is opened, not on page load: most visits
|
||||
// never open it, and it is a database read plus a thumbnail per tile.
|
||||
if (!docs && !imagesLoaded) loadImages(true);
|
||||
}
|
||||
|
||||
function loadImages(reset) {
|
||||
var grid = document.getElementById('mr-images-grid');
|
||||
var empty = document.getElementById('mr-images-empty');
|
||||
var more = document.getElementById('btn-mr-images-more');
|
||||
if (!grid) return;
|
||||
if (reset) { grid.textContent = ''; nextImagesBefore = null; }
|
||||
if (empty) { empty.hidden = false; empty.textContent = 'Loading…'; }
|
||||
|
||||
var url = '/api/generated-images?limit=60' +
|
||||
(nextImagesBefore && !reset ? '&before=' + encodeURIComponent(nextImagesBefore) : '');
|
||||
fetch(url, { headers: getAuthHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not load your images');
|
||||
imagesLoaded = true;
|
||||
nextImagesBefore = data.nextBefore || null;
|
||||
if (more) more.hidden = !nextImagesBefore;
|
||||
if (empty) {
|
||||
empty.hidden = Boolean(data.images.length) || Boolean(grid.children.length);
|
||||
empty.textContent = 'No images yet. Tick "Add illustrations" when you generate, ' +
|
||||
'or ask the Clinical Assistant for a diagram.';
|
||||
}
|
||||
import('/js/generatedImages.js').then(function (m) {
|
||||
data.images.forEach(function (image) { grid.appendChild(imageTile(image, m)); });
|
||||
}).catch(function () {
|
||||
if (empty) { empty.hidden = false; empty.textContent = 'Images could not be displayed.'; }
|
||||
});
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (empty) { empty.hidden = false; empty.textContent = err.message; }
|
||||
});
|
||||
}
|
||||
|
||||
// Built as elements, never innerHTML: the caption is a model-written prompt.
|
||||
function imageTile(image, m) {
|
||||
var tile = document.createElement('figure');
|
||||
tile.style.cssText = 'margin:0;border:1px solid var(--g200);border-radius:8px;overflow:hidden;' +
|
||||
'display:flex;flex-direction:column;background:var(--white);';
|
||||
|
||||
var frame = document.createElement('div');
|
||||
frame.style.cssText = 'aspect-ratio:4/3;background:var(--g100);display:flex;align-items:center;justify-content:center;overflow:hidden;';
|
||||
var img = document.createElement('img');
|
||||
img.alt = image.prompt ? 'Generated illustration: ' + image.prompt.slice(0, 80) : 'Generated illustration';
|
||||
img.setAttribute('data-image-thumb', '256');
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
|
||||
frame.appendChild(img);
|
||||
m.hydrateImage(img, image.imageUrl).catch(function () {
|
||||
img.remove();
|
||||
frame.textContent = 'Unavailable';
|
||||
frame.style.fontSize = '11px';
|
||||
frame.style.color = 'var(--g500)';
|
||||
});
|
||||
|
||||
var caption = document.createElement('figcaption');
|
||||
caption.style.cssText = 'padding:8px;display:flex;flex-direction:column;gap:6px;min-width:0;';
|
||||
|
||||
var text = document.createElement('div');
|
||||
text.style.cssText = 'font-size:11px;color:var(--g600);line-height:1.35;max-height:3.4em;overflow:hidden;';
|
||||
text.textContent = image.prompt || 'No description recorded';
|
||||
|
||||
var meta = document.createElement('div');
|
||||
meta.style.cssText = 'font-size:10px;color:var(--g500);display:flex;gap:6px;flex-wrap:wrap;';
|
||||
meta.textContent = image.source + ' · ' + new Date(image.createdAt).toLocaleDateString();
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.style.cssText = 'display:flex;gap:6px;';
|
||||
var open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'btn-sm btn-ghost';
|
||||
open.style.cssText = 'font-size:11px;padding:3px 8px;';
|
||||
open.textContent = 'Open';
|
||||
open.addEventListener('click', function () { openImage(image, m); });
|
||||
|
||||
var remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'btn-sm btn-ghost';
|
||||
remove.style.cssText = 'font-size:11px;padding:3px 8px;color:var(--red);';
|
||||
remove.textContent = 'Delete';
|
||||
remove.addEventListener('click', function () { deleteImage(image, tile, remove); });
|
||||
|
||||
actions.append(open, remove);
|
||||
caption.append(text, meta, actions);
|
||||
tile.append(frame, caption);
|
||||
return tile;
|
||||
}
|
||||
|
||||
// The full-size copy, fetched the same authenticated way as the tile. A plain
|
||||
// link would not carry the session on a mobile client, and the asset is served
|
||||
// no-store on purpose.
|
||||
function openImage(image, m) {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;' +
|
||||
'display:flex;align-items:center;justify-content:center;padding:24px;';
|
||||
var full = document.createElement('img');
|
||||
full.alt = image.prompt || 'Generated illustration';
|
||||
full.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;border-radius:6px;';
|
||||
overlay.appendChild(full);
|
||||
overlay.addEventListener('click', function () { overlay.remove(); });
|
||||
document.addEventListener('keydown', function escape(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', escape);
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
m.hydrateImage(full, image.imageUrl).catch(function () {
|
||||
full.remove();
|
||||
overlay.textContent = 'That image could not be loaded.';
|
||||
overlay.style.color = 'var(--white)';
|
||||
});
|
||||
}
|
||||
|
||||
function deleteImage(image, tile, button) {
|
||||
// Asked, because it removes the picture from every deck that used it. The
|
||||
// deck keeps working; the figure is simply no longer there.
|
||||
if (!window.confirm('Delete this image? Any resource that used it will render without it.')) return;
|
||||
button.disabled = true;
|
||||
fetch('/api/generated-images/' + encodeURIComponent(image.id), {
|
||||
method: 'DELETE', headers: getAuthHeaders()
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Could not delete that image');
|
||||
tile.remove();
|
||||
var grid = document.getElementById('mr-images-grid');
|
||||
var empty = document.getElementById('mr-images-empty');
|
||||
if (grid && !grid.children.length && empty) {
|
||||
empty.hidden = false;
|
||||
empty.textContent = 'No images yet.';
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
button.disabled = false;
|
||||
if (typeof showToast === 'function') showToast(err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// The library as last fetched. Held so searching and the Modify picker both
|
||||
// work from one copy rather than each asking the server again.
|
||||
var library = [];
|
||||
|
|
|
|||
|
|
@ -40,6 +40,93 @@ async function sendAsset(req, res, download) {
|
|||
res.setHeader('Content-Disposition', (download ? 'attachment' : 'inline') + '; filename="generated-image.' + ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] + '"');
|
||||
res.send(image.bytes);
|
||||
}
|
||||
// ── The owner's own pictures, wherever they were made ───────────────────────
|
||||
// Every image this account generated, newest first, across all three workflows.
|
||||
// The per-workflow endpoint below answers "how is this job doing"; this answers
|
||||
// "what have I made", which is the question a library is for.
|
||||
//
|
||||
// Only finished jobs: a queued or failed one has nothing to show, and listing it
|
||||
// would put a broken frame in a gallery. The prompt is stored encrypted and is
|
||||
// decrypted here because it is the only human-readable label an image has —
|
||||
// there is no filename and no title.
|
||||
const WORKFLOW_LABELS = {
|
||||
clinical_assistant: 'Clinical Assistant',
|
||||
learning_hub: 'Learning Hub',
|
||||
my_resources: 'My Resources'
|
||||
};
|
||||
|
||||
router.get('/generated-images', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 60, 1), 120);
|
||||
// Keyset, not OFFSET: a gallery that grows while you scroll would otherwise
|
||||
// repeat or skip a row on every page.
|
||||
const before = req.query.before ? new Date(String(req.query.before)) : null;
|
||||
if (before && isNaN(before.getTime())) throw images.failure(400, 'Invalid cursor');
|
||||
|
||||
const params = [req.user.id];
|
||||
let where = "owner_id=$1 AND stage='done'";
|
||||
if (before) { params.push(before.toISOString()); where += ' AND created_at < $' + params.length; }
|
||||
params.push(limit + 1);
|
||||
|
||||
const result = await db.query(
|
||||
'SELECT id, workflow, model, byte_length, mime, prompt_cipher, created_at ' +
|
||||
'FROM generated_image_jobs WHERE ' + where +
|
||||
' ORDER BY created_at DESC LIMIT $' + params.length, params);
|
||||
|
||||
const rows = result.rows.slice(0, limit);
|
||||
const encryption = require('../utils/crypto');
|
||||
res.json({
|
||||
success: true,
|
||||
images: rows.map(function (row) {
|
||||
let prompt = '';
|
||||
// A prompt that cannot be decrypted is not worth failing a gallery for;
|
||||
// the picture is still there and still downloadable.
|
||||
try { prompt = String(encryption.decryptString(row.prompt_cipher) || ''); } catch (e) { prompt = ''; }
|
||||
return {
|
||||
id: row.id,
|
||||
workflow: row.workflow,
|
||||
source: WORKFLOW_LABELS[row.workflow] || row.workflow,
|
||||
model: row.model,
|
||||
bytes: row.byte_length,
|
||||
mime: row.mime,
|
||||
createdAt: row.created_at,
|
||||
prompt: prompt.slice(0, 300),
|
||||
thumbUrl: '/api/generated-images/' + row.id + '?w=256',
|
||||
imageUrl: '/api/generated-images/' + row.id,
|
||||
downloadUrl: '/api/generated-images/' + row.id + '?download=1'
|
||||
};
|
||||
}),
|
||||
nextBefore: result.rows.length > limit ? rows[rows.length - 1].created_at : null
|
||||
});
|
||||
} catch (e) { fail(res, e); }
|
||||
});
|
||||
|
||||
// Deleting is the owner's. Scoped by owner_id in the statement rather than
|
||||
// checked first, so a borrowed id deletes nothing rather than someone else's
|
||||
// picture, and an id that is not theirs is indistinguishable from one that does
|
||||
// not exist.
|
||||
//
|
||||
// The bytes go before the row. The other order would leave a row pointing at a
|
||||
// key that is gone — a listed image that renders broken — whereas failing
|
||||
// between the two leaves a complete, working image and an error the caller can
|
||||
// retry. Storage that is unreachable refuses the delete rather than removing
|
||||
// the row and stranding the object in the bucket.
|
||||
//
|
||||
// A resource that embedded the figure keeps working: a deck stores the job id
|
||||
// and renders without the figure when it has gone.
|
||||
router.delete('/generated-images/:id', async (req, res) => {
|
||||
try {
|
||||
if (!images.UUID.test(req.params.id)) throw images.failure(404, 'Image not found');
|
||||
const owned = await db.query(
|
||||
'SELECT id FROM generated_image_jobs WHERE id=$1 AND owner_id=$2',
|
||||
[req.params.id, req.user.id]);
|
||||
if (!owned.rows.length) throw images.failure(404, 'Image not found');
|
||||
|
||||
await images.service().discard(req.params.id, req.user.id);
|
||||
res.json({ success: true });
|
||||
} catch (e) { fail(res, e); }
|
||||
});
|
||||
|
||||
router.get('/generated-images/:id', async (req, res) => {
|
||||
try { await sendAsset(req, res, req.query.download === '1'); } catch (e) { fail(res, e); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ async function download(url, { lookup = dns.lookup, request = https.get, signal
|
|||
}
|
||||
// Derived previews are addressed by asset id and width, so a request can only
|
||||
// ever reach a preview of the asset it already has permission to read.
|
||||
// Defined here, the lower-level module, and re-exported by generatedImages.
|
||||
// Two copies would drift: a width added for writing and not for deleting would
|
||||
// be paid for once and then left in the bucket forever.
|
||||
const THUMB_WIDTHS = Object.freeze([256, 640]);
|
||||
function thumbKey(id, width) { return 'thumbs/' + id + '/' + width; }
|
||||
|
||||
function createStorage(env = process.env) {
|
||||
|
|
@ -92,7 +96,18 @@ function createStorage(env = process.env) {
|
|||
for await (const chunk of response.Body) { size += chunk.length; if (size > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } chunks.push(chunk); }
|
||||
return inspect(Buffer.concat(chunks), response.ContentType);
|
||||
},
|
||||
// Deleting an image means deleting the picture, not just the row that points
|
||||
// at it. The original and both derived previews live under two different
|
||||
// prefixes, so all three keys go — otherwise "delete" leaves the bytes in
|
||||
// the bucket, paid for and still readable by anything holding credentials.
|
||||
//
|
||||
// A key that is already gone is success: S3 DeleteObject is idempotent, and
|
||||
// a job whose thumbnails were never rendered has none to remove.
|
||||
async remove(id) {
|
||||
const keys = ['assets/' + id].concat(THUMB_WIDTHS.map(function (w) { return thumbKey(id, w); }));
|
||||
for (const Key of keys) await client.send(new DeleteObjectCommand({ Bucket, Key }));
|
||||
},
|
||||
close() { client.destroy(); }
|
||||
};
|
||||
}
|
||||
module.exports = { MAX_BYTES, inspect, decodeBase64, download, createStorage };
|
||||
module.exports = { MAX_BYTES, THUMB_WIDTHS, inspect, decodeBase64, download, createStorage };
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ function shouldRetryImageFallback(state) {
|
|||
// Previews are generated once and stored beside the original, so a 56px tile
|
||||
// costs a few kB instead of ~280kB. Only these widths are allowed: a caller
|
||||
// cannot ask for arbitrary sizes and turn this into a CPU amplifier.
|
||||
const THUMB_WIDTHS = Object.freeze([256, 640]);
|
||||
// One definition, in generatedImageStorage, which is also what deletes them.
|
||||
const { THUMB_WIDTHS } = storageUtil;
|
||||
|
||||
function thumbWidth(requested) {
|
||||
const width = Number(requested);
|
||||
|
|
@ -239,7 +240,26 @@ async function warmThumbs(id) {
|
|||
}
|
||||
}
|
||||
|
||||
async function get(id, owner, workflow) {
|
||||
// Remove a picture and everything derived from it. Storage first, then the row:
|
||||
// a row without its object is a broken image in a gallery, while an object
|
||||
// without its row is only wasted space, and this cannot produce the former.
|
||||
async function discard(id, owner) {
|
||||
if (!UUID.test(id)) throw failure(404, 'Image job not found');
|
||||
try {
|
||||
await getStorage().remove(id);
|
||||
} catch (e) {
|
||||
// Bytes that could not be removed must not become a row that says they
|
||||
// were: the caller is told to try again rather than shown a success that
|
||||
// left the image in the bucket.
|
||||
throw failure(503, 'Image storage is unavailable; nothing was deleted');
|
||||
}
|
||||
const result = await db.query(
|
||||
'DELETE FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 RETURNING id', [id, owner]);
|
||||
if (!result.rows.length) throw failure(404, 'Image job not found');
|
||||
return { deleted: id };
|
||||
}
|
||||
|
||||
async function get(id, owner, workflow) {
|
||||
if (!UUID.test(id)) throw failure(404, 'Image job not found');
|
||||
const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 AND workflow=$3', [id, owner, workflow]);
|
||||
if (!result.rows[0]) throw failure(404, 'Image job not found');
|
||||
|
|
@ -272,7 +292,7 @@ async function get(id, owner, workflow) {
|
|||
if (active) await active;
|
||||
// The same storage client serves draining HTTP reads; process shutdown owns its lifetime.
|
||||
}
|
||||
return { enqueue, get, asset, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs };
|
||||
return { enqueue, get, asset, discard, ready, snapshot, claim, tick, start, stop, thumbnail, warmThumbs };
|
||||
}
|
||||
let singleton;
|
||||
function service() { return singleton || (singleton = createImageService({ db: require('../db/database') })); }
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ test('a preview cannot widen access or be asked for arbitrary sizes', () => {
|
|||
const utils = read('src/utils/generatedImages.js');
|
||||
const route = read('src/routes/generatedImages.js');
|
||||
// An open width parameter would let a caller drive arbitrary resizes.
|
||||
assert.match(utils, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\);/);
|
||||
assert.match(utils, /const \{ THUMB_WIDTHS \} = storageUtil;/);
|
||||
assert.match(utils, /return THUMB_WIDTHS\.includes\(width\) \? width : null;/);
|
||||
// Permission is checked against the ORIGINAL before any preview is served.
|
||||
assert.match(route, /await images\.service\(\)\.asset\(req\.params\.id, req\.user\); \/\/ authorise/);
|
||||
|
|
|
|||
93
test/image-library.test.js
Normal file
93
test/image-library.test.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// The image library: every picture this account generated, across all three
|
||||
// features, with thumbnails rather than originals and a delete that removes the
|
||||
// bytes and not only the row.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
|
||||
|
||||
test('the listing is the owner’s own finished images, newest first', () => {
|
||||
const route = read('src/routes/generatedImages.js');
|
||||
assert.match(route, /router\.get\('\/generated-images'/);
|
||||
// Scoped by owner in the statement, not filtered afterwards.
|
||||
assert.match(route, /owner_id=\$1 AND stage='done'/);
|
||||
assert.match(route, /ORDER BY created_at DESC/);
|
||||
// Unfinished jobs have nothing to show; listing one puts a broken frame in a
|
||||
// gallery.
|
||||
assert.match(route, /Only finished jobs/);
|
||||
});
|
||||
|
||||
test('paging is keyset, not OFFSET', () => {
|
||||
// A gallery that grows while you scroll repeats or skips a row under OFFSET.
|
||||
const route = read('src/routes/generatedImages.js');
|
||||
assert.match(route, /created_at < \$/);
|
||||
assert.doesNotMatch(route, /\bOFFSET\s+\$?\d/i, 'no SQL OFFSET; the word in a comment is fine');
|
||||
assert.match(route, /nextBefore/);
|
||||
});
|
||||
|
||||
test('a prompt that cannot be decrypted does not cost the gallery its picture', () => {
|
||||
const route = read('src/routes/generatedImages.js');
|
||||
assert.match(route, /try \{ prompt = String\(encryption\.decryptString/);
|
||||
assert.match(route, /catch \(e\) \{ prompt = ''; \}/);
|
||||
});
|
||||
|
||||
test('deleting removes the bytes before the row, and refuses if it cannot', () => {
|
||||
// A row without its object is a broken image in a gallery; an object without
|
||||
// its row is only wasted space. Only one of those is allowed to happen.
|
||||
const lib = read('src/utils/generatedImages.js');
|
||||
assert.match(lib, /async function discard\(id, owner\)/);
|
||||
const discard = lib.slice(lib.indexOf('async function discard'));
|
||||
const storageFirst = discard.indexOf('getStorage().remove(id)');
|
||||
const rowSecond = discard.indexOf('DELETE FROM generated_image_jobs');
|
||||
assert.ok(storageFirst > -1 && rowSecond > storageFirst, 'storage must be removed first');
|
||||
assert.match(discard.slice(0, 800), /throw failure\(503, 'Image storage is unavailable; nothing was deleted'\)/);
|
||||
});
|
||||
|
||||
test('deleting removes the previews too, not just the original', () => {
|
||||
// Both derived widths live under their own prefix. Missing them leaves paid-for
|
||||
// bytes in the bucket that are still readable.
|
||||
const storage = read('src/utils/generatedImageStorage.js');
|
||||
assert.match(storage, /async remove\(id\)/);
|
||||
assert.match(storage, /\['assets\/' \+ id\]\.concat\(THUMB_WIDTHS\.map/);
|
||||
});
|
||||
|
||||
test('the thumbnail widths have one definition', () => {
|
||||
// Two copies drift: a width written but never deleted is paid for once and
|
||||
// then left in the bucket forever.
|
||||
const storage = read('src/utils/generatedImageStorage.js');
|
||||
const lib = read('src/utils/generatedImages.js');
|
||||
assert.match(storage, /const THUMB_WIDTHS = Object\.freeze\(\[256, 640\]\)/);
|
||||
assert.match(lib, /const \{ THUMB_WIDTHS \} = storageUtil/);
|
||||
assert.doesNotMatch(lib, /THUMB_WIDTHS = Object\.freeze/, 'not defined twice');
|
||||
assert.deepEqual(require('../src/utils/generatedImages').THUMB_WIDTHS, [256, 640]);
|
||||
});
|
||||
|
||||
test('a borrowed id deletes nothing rather than someone else’s picture', () => {
|
||||
const route = read('src/routes/generatedImages.js');
|
||||
assert.match(route, /SELECT id FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/);
|
||||
const lib = read('src/utils/generatedImages.js');
|
||||
assert.match(lib, /DELETE FROM generated_image_jobs WHERE id=\$1 AND owner_id=\$2/);
|
||||
});
|
||||
|
||||
test('the grid asks for the stored preview, not the original', () => {
|
||||
// Thirty tiles at full size is thirty full-size downloads.
|
||||
const ui = read('public/js/myResources.js');
|
||||
assert.match(ui, /setAttribute\('data-image-thumb', '256'\)/);
|
||||
assert.match(ui, /hydrateImage\(img, image\.imageUrl\)/,
|
||||
'fetched through the authenticated loader, never a bare src');
|
||||
});
|
||||
|
||||
test('the caption is a model-written prompt and never reaches the page as HTML', () => {
|
||||
const ui = read('public/js/myResources.js');
|
||||
const tile = ui.slice(ui.indexOf('function imageTile'), ui.indexOf('function openImage'));
|
||||
assert.match(tile, /text\.textContent = image\.prompt/);
|
||||
assert.doesNotMatch(tile, /innerHTML/);
|
||||
});
|
||||
|
||||
test('the images tab loads on first open, not on page load', () => {
|
||||
// Most visits never open it, and it costs a query plus a thumbnail per tile.
|
||||
const ui = read('public/js/myResources.js');
|
||||
assert.match(ui, /if \(!docs && !imagesLoaded\) loadImages\(true\)/);
|
||||
});
|
||||
Loading…
Reference in a new issue