fix(audit-g2): async race / state-leak across eight stores
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 34s
CI / intimp (push) Successful in 3m31s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m2s

Extracts gallery.js's hand-rolled inflightId pattern into a new
useInflightToken composable; adopts in every store that previously
had no guard against late-response overwrites or wrong-image URL
interpolation.

Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62)
flagged:

- modal.removeTag rolled back the chip rail unconditionally even
  when only the secondary dismiss POST had failed — UI lied until
  refresh. And all tag-mutation URLs interpolated currentImageId
  AFTER an await, so a fast prev/next could route DELETE/POST to
  the wrong image. Both fixed: split try/catch (dismiss failure
  surfaces a warning, doesn't roll back the delete); imageId
  captured at call-time and used in URLs throughout.

- suggestions.accept dereferenced currentImageId after the awaited
  POST /api/tags, so the subsequent /suggestions/accept could
  apply A's chosen tag to image B AND push it to B's allowlist.
  Fixed by capturing imageId at click-time + inflight guard on
  load().

Same shape across artist / downloads / artistDirectory /
tagDirectory / posts stores: rapid filter/nav changes used to
interleave responses (last-writer-wins). Now the late response is
discarded and the most-recent request wins. Filter-change-during-
search no longer drops the second fetch because the loading flag
was still true from the first.

gallery.js's inflightId removed in favor of the shared composable
so the pattern stays consistent.
This commit is contained in:
2026-06-02 14:07:58 -04:00
parent 80ef9bce48
commit e66987f092
9 changed files with 234 additions and 30 deletions
@@ -0,0 +1,52 @@
// Inflight-token guard for stores whose async loads can be re-triggered
// by rapid filter/navigation changes. Without this, late responses
// from a prior load overwrite the store with stale data
// (last-writer-wins, not request-order-wins). gallery.js had a
// hand-rolled `inflightId` of the same shape; this composable
// extracts it so every store can use the same pattern.
//
// Audit 2026-06-02 (workflow wf_bbe3fdb1-e62) found this missing in
// modal/suggestions/artist/downloads/directory/posts. The two most
// operator-impacting consequences: (1) modal tag mutations could
// land DELETE/POST on the wrong image when the user navigated mid-
// flight; (2) suggestions accept could push a tag to the wrong
// image AND add it to the allowlist.
//
// Usage:
// const inflight = useInflightToken()
//
// async function load() {
// const t = inflight.claim()
// const body = await api.get(...)
// if (!t.isCurrent()) return // stale — abort write
// items.value = body.items // safe to commit
// }
//
// function setFilter(f) {
// inflight.cancel() // any in-flight token is now stale
// filter.value = f
// load() // claims a fresh token
// }
//
// For multi-await flows (POST then GET, optimistic mutation then
// reconcile), check isCurrent() after EACH await — any intervening
// claim() or cancel() invalidates the prior token.
export function useInflightToken() {
let _seq = 0
let _current = 0
function claim() {
_current = ++_seq
const id = _current
return {
id,
isCurrent: () => _current === id,
}
}
function cancel() {
_current = ++_seq
}
return { claim, cancel }
}
+16 -3
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js'
import { usePostsStore } from './posts.js' import { usePostsStore } from './posts.js'
const PAGE = 60 const PAGE = 60
@@ -15,12 +16,17 @@ export const useArtistStore = defineStore('artist', () => {
const error = ref(null) const error = ref(null)
const notFound = ref(false) const notFound = ref(false)
let started = false let started = false
// Rapid artist-to-artist navigation used to render the previous
// artist's overview/images briefly when the second load resolved
// after the third. Audit 2026-06-02.
const inflight = useInflightToken()
async function load (slug) { async function load (slug) {
// Cross-artist reset: clear this store AND the posts store so the new // Cross-artist reset: clear this store AND the posts store so the new
// artist doesn't briefly render with the previous artist's content // artist doesn't briefly render with the previous artist's content
// when the user is on the Posts tab. (Gallery tab uses this artist // when the user is on the Posts tab. (Gallery tab uses this artist
// store's own images list — cleared above.) // store's own images list — cleared above.)
inflight.cancel()
overview.value = null overview.value = null
images.value = [] images.value = []
nextCursor.value = null nextCursor.value = null
@@ -29,14 +35,18 @@ export const useArtistStore = defineStore('artist', () => {
error.value = null error.value = null
loading.value = true loading.value = true
usePostsStore().$reset?.() usePostsStore().$reset?.()
const t = inflight.claim()
try { try {
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`) const body = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
if (!t.isCurrent()) return
overview.value = body
await loadMoreImages(slug) await loadMoreImages(slug)
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
if (e.status === 404) notFound.value = true if (e.status === 404) notFound.value = true
else error.value = e.message else error.value = e.message
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
@@ -44,19 +54,22 @@ export const useArtistStore = defineStore('artist', () => {
if (imagesLoading.value) return if (imagesLoading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
imagesLoading.value = true imagesLoading.value = true
const t = inflight.claim()
try { try {
const params = { limit: PAGE } const params = { limit: PAGE }
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get( const body = await api.get(
`/api/artist/${encodeURIComponent(slug)}/images`, { params } `/api/artist/${encodeURIComponent(slug)}/images`, { params }
) )
if (!t.isCurrent()) return
images.value.push(...body.images) images.value.push(...body.images)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e.message error.value = e.message
} finally { } finally {
imagesLoading.value = false if (t.isCurrent()) imagesLoading.value = false
} }
} }
+10 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
const PAGE = 60 const PAGE = 60
@@ -13,16 +14,23 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
const q = ref('') const q = ref('')
const platform = ref(null) const platform = ref(null)
let started = false let started = false
// Typed "alice" then "alice bob" used to drop the second fetch
// entirely (loading flag still true from the first), so the UI
// showed alice results while the input said "alice bob". Inflight
// token + reset() cancelling in-flight requests fixes both: the
// first response is discarded, the second is fetched. Audit 2026-06-02.
const inflight = useInflightToken()
async function loadMore() { async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const params = { limit: PAGE } const params = { limit: PAGE }
if (q.value) params.q = q.value if (q.value) params.q = q.value
if (platform.value) params.platform = platform.value if (platform.value) params.platform = platform.value
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/artists/directory', { params }) const body = await api.get('/api/artists/directory', { params })
if (!t.isCurrent()) return
cards.value.push(...body.cards) cards.value.push(...body.cards)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
@@ -30,6 +38,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => {
} }
async function reset() { async function reset() {
inflight.cancel()
cards.value = [] cards.value = []
nextCursor.value = null nextCursor.value = null
started = false started = false
+12
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useDownloadsStore = defineStore('downloads', () => { export const useDownloadsStore = defineStore('downloads', () => {
const api = useApi() const api = useApi()
@@ -22,6 +23,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
// the "active now" panel always reflects what's happening regardless of // the "active now" panel always reflects what's happening regardless of
// how the operator has filtered the historical list below. // how the operator has filtered the historical list below.
const activeEvents = ref([]) const activeEvents = ref([])
// Filter changes (applyFilter) and rapid pagination can interleave
// responses; without an inflight guard the late response from a
// prior filter overwrites the current view. Audit 2026-06-02.
const inflight = useInflightToken()
function _params(extra = {}) { function _params(extra = {}) {
const out = { limit: 50, ...extra } const out = { limit: 50, ...extra }
@@ -32,8 +37,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
} }
async function loadFirst() { async function loadFirst() {
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get('/api/downloads', { params: _params() }) const body = await api.get('/api/downloads', { params: _params() })
if (!t.isCurrent()) return
events.value = body events.value = body
cursor.value = body.length ? body[body.length - 1].id : null cursor.value = body.length ? body[body.length - 1].id : null
hasMore.value = body.length === 50 hasMore.value = body.length === 50
@@ -42,8 +49,10 @@ export const useDownloadsStore = defineStore('downloads', () => {
async function loadMore() { async function loadMore() {
if (!hasMore.value || cursor.value == null) return if (!hasMore.value || cursor.value == null) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) }) const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) })
if (!t.isCurrent()) return
events.value.push(...body) events.value.push(...body)
cursor.value = body.length ? body[body.length - 1].id : cursor.value cursor.value = body.length ? body[body.length - 1].id : cursor.value
hasMore.value = body.length === 50 hasMore.value = body.length === 50
@@ -71,6 +80,9 @@ export const useDownloadsStore = defineStore('downloads', () => {
} }
async function applyFilter(patch) { async function applyFilter(patch) {
// Drop any in-flight loadFirst/loadMore from the previous filter
// so its late response doesn't overwrite this filter's results.
inflight.cancel()
filter.value = { ...filter.value, ...patch } filter.value = { ...filter.value, ...patch }
await loadFirst() await loadFirst()
} }
+14 -4
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useInflightToken } from '../composables/useInflightToken.js'
// Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one // Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one
// 50-item request so items render as each batch lands. Total initial // 50-item request so items render as each batch lands. Total initial
@@ -22,9 +23,12 @@ export const useGalleryStore = defineStore('gallery', () => {
const timelineBuckets = ref([]) const timelineBuckets = ref([])
const timelineLoading = ref(false) const timelineLoading = ref(false)
let inflightId = 0 // Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out
// moved this pattern into useInflightToken so every store can share it.
const inflight = useInflightToken()
async function loadInitial() { async function loadInitial() {
inflight.cancel()
images.value = [] images.value = []
dateGroups.value = [] dateGroups.value = []
nextCursor.value = null nextCursor.value = null
@@ -41,19 +45,19 @@ export const useGalleryStore = defineStore('gallery', () => {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
error.value = null error.value = null
const myId = ++inflightId const t = inflight.claim()
try { try {
const params = { limit: PAGE, ...activeFilterParam() } const params = { limit: PAGE, ...activeFilterParam() }
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/gallery/scroll', { params }) const body = await api.get('/api/gallery/scroll', { params })
if (myId !== inflightId) return // stale response if (!t.isCurrent()) return
images.value.push(...body.images) images.value.push(...body.images)
dateGroups.value = mergeGroups(dateGroups.value, body.date_groups) dateGroups.value = mergeGroups(dateGroups.value, body.date_groups)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
} finally { } finally {
if (myId === inflightId) loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
@@ -68,8 +72,14 @@ export const useGalleryStore = defineStore('gallery', () => {
} }
async function jumpTo(year, month) { async function jumpTo(year, month) {
// Rapid timeline-jump clicks need the same race guard as
// loadMore — first jump's late body could clobber the second
// jump's already-applied state.
inflight.cancel()
const t = inflight.claim()
const params = { year, month, ...activeFilterParam() } const params = { year, month, ...activeFilterParam() }
const body = await api.get('/api/gallery/jump', { params }) const body = await api.get('/api/gallery/jump', { params })
if (!t.isCurrent()) return
if (body.cursor) { if (body.cursor) {
images.value = [] images.value = []
dateGroups.value = [] dateGroups.value = []
+62 -12
View File
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const useModalStore = defineStore('modal', () => { export const useModalStore = defineStore('modal', () => {
const api = useApi() const api = useApi()
@@ -10,6 +11,11 @@ export const useModalStore = defineStore('modal', () => {
const currentImageId = ref(null) const currentImageId = ref(null)
const current = ref(null) const current = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
// Tag mutations interpolate the image id into the URL after an
// await; without an inflight token, a fast prev/next can route the
// DELETE/POST to the wrong image AND the response to the wrong
// chip rail. Audit 2026-06-02.
const inflight = useInflightToken()
// Post-scoped cycle. When set, prev/next cycles within this array // Post-scoped cycle. When set, prev/next cycles within this array
// (used by PostCard's expanded-mosaic PostImageGrid clicks). When // (used by PostCard's expanded-mosaic PostImageGrid clicks). When
@@ -19,6 +25,9 @@ export const useModalStore = defineStore('modal', () => {
const postImageIndex = ref(0) const postImageIndex = ref(0)
async function open (id, opts = {}) { async function open (id, opts = {}) {
// Cancel any in-flight tag mutation or reloadTags from the
// previous image so its late response can't apply to this one.
inflight.cancel()
currentImageId.value = id currentImageId.value = id
current.value = null // cleared upfront so it stays null on error current.value = null // cleared upfront so it stays null on error
// Update post-scoped state if caller passed it; otherwise clear so // Update post-scoped state if caller passed it; otherwise clear so
@@ -31,12 +40,16 @@ export const useModalStore = defineStore('modal', () => {
postImageIds.value = null postImageIds.value = null
postImageIndex.value = 0 postImageIndex.value = 0
} }
const t = inflight.claim()
await run(async () => { await run(async () => {
current.value = await api.get(`/api/gallery/image/${id}`) const body = await api.get(`/api/gallery/image/${id}`)
if (!t.isCurrent()) return
current.value = body
}) })
} }
async function close () { async function close () {
inflight.cancel()
currentImageId.value = null currentImageId.value = null
current.value = null current.value = null
error.value = null error.value = null
@@ -75,37 +88,74 @@ export const useModalStore = defineStore('modal', () => {
} }
async function reloadTags () { async function reloadTags () {
if (!currentImageId.value) return // Capture image id at call-time. After an await, currentImageId
const tags = await api.get(`/api/images/${currentImageId.value}/tags`) // may have advanced via prev/next navigation; without capture, the
if (current.value) current.value.tags = tags // GET would target the new image and write its tags onto a chip
// rail the user didn't open. Audit 2026-06-02.
const imageId = currentImageId.value
if (!imageId) return
const t = inflight.claim()
const tags = await api.get(`/api/images/${imageId}/tags`)
if (!t.isCurrent()) return
// Only commit if the modal is still showing this image — guards
// against close() / nav clearing current between the await and now.
if (current.value && currentImageId.value === imageId) {
current.value.tags = tags
}
} }
async function removeTag (tagId) { async function removeTag (tagId) {
if (!currentImageId.value) return const imageId = currentImageId.value
if (!imageId) return
const prev = current.value.tags const prev = current.value.tags
// Optimistic UI: drop the chip immediately.
current.value.tags = current.value.tags.filter(t => t.id !== tagId) current.value.tags = current.value.tags.filter(t => t.id !== tagId)
// Split the two POSTs so a dismiss failure (secondary side-effect)
// doesn't roll back the successful DELETE — previously the catch
// unconditionally restored the chip rail even when only the
// dismiss had failed, so the UI lied until refresh. Audit 2026-06-02.
try { try {
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`) await api.delete(`/api/images/${imageId}/tags/${tagId}`)
await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, { } catch (e) {
// Real failure: roll back, surface, rethrow.
if (current.value && currentImageId.value === imageId) {
current.value.tags = prev
}
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
throw e
}
// DELETE landed. The dismiss is fire-and-best-effort — log on
// failure but DON'T roll back the chip rail; the tag is gone
// server-side regardless.
try {
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: tagId }, body: { tag_id: tagId },
}) })
} catch (e) { } catch (e) {
current.value.tags = prev toast({
toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' }) text: `Tag removed, but failed to dismiss suggestion: ${e.message}`,
throw e type: 'warning',
})
} }
} }
async function addExistingTag (tagId) { async function addExistingTag (tagId) {
if (!currentImageId.value) return const imageId = currentImageId.value
await api.post(`/api/images/${currentImageId.value}/tags`, { if (!imageId) return
await api.post(`/api/images/${imageId}/tags`, {
body: { tag_id: tagId, source: 'manual' }, body: { tag_id: tagId, source: 'manual' },
}) })
await reloadTags() await reloadTags()
} }
async function createAndAdd ({ name, kind, fandom_id = null }) { async function createAndAdd ({ name, kind, fandom_id = null }) {
// Capture imageId so the post-create reloadTags / addExistingTag
// flow stays bound to the image the user clicked on, even if
// they navigated during the /api/tags POST.
const imageId = currentImageId.value
if (!imageId) return
const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } }) const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } })
if (currentImageId.value !== imageId) return // navigated away
await addExistingTag(tag.id) await addExistingTag(tag.id)
} }
+23 -3
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
export const usePostsStore = defineStore('posts', () => { export const usePostsStore = defineStore('posts', () => {
const api = useApi() const api = useApi()
@@ -19,6 +20,12 @@ export const usePostsStore = defineStore('posts', () => {
const doneOlder = ref(false) const doneOlder = ref(false)
const doneNewer = ref(false) const doneNewer = ref(false)
const anchorId = ref(null) const anchorId = ref(null)
// loadInitial, loadMore, loadAround, loadOlder, loadNewer all share
// one `loading` flag and previously had no inflight guard. A filter
// change (loadInitial) racing a still-in-flight loadMore would
// append the prior filter's items into the new filter's feed.
// Audit 2026-06-02.
const inflight = useInflightToken()
function _qs() { function _qs() {
const q = {} const q = {}
@@ -36,6 +43,7 @@ export const usePostsStore = defineStore('posts', () => {
} }
async function loadInitial(newFilters) { async function loadInitial(newFilters) {
inflight.cancel()
filters.value = { filters.value = {
artist_id: newFilters?.artist_id ?? null, artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null, platform: newFilters?.platform ?? null,
@@ -46,8 +54,10 @@ export const usePostsStore = defineStore('posts', () => {
async function loadMore() { async function loadMore() {
if (loading.value || done.value) return if (loading.value || done.value) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get('/api/posts', { params: _qs() }) const body = await api.get('/api/posts', { params: _qs() })
if (!t.isCurrent()) return
items.value.push(...body.items) items.value.push(...body.items)
cursor.value = body.next_cursor cursor.value = body.next_cursor
if (body.next_cursor == null) done.value = true if (body.next_cursor == null) done.value = true
@@ -79,6 +89,7 @@ export const usePostsStore = defineStore('posts', () => {
// posts feed; without this the older/newer scroll loaded unfiltered // posts feed; without this the older/newer scroll loaded unfiltered
// global posts instead of staying in the artist's stream). // global posts instead of staying in the artist's stream).
async function loadAround(postId, newFilters) { async function loadAround(postId, newFilters) {
inflight.cancel()
filters.value = { filters.value = {
artist_id: newFilters?.artist_id ?? null, artist_id: newFilters?.artist_id ?? null,
platform: newFilters?.platform ?? null, platform: newFilters?.platform ?? null,
@@ -86,10 +97,12 @@ export const usePostsStore = defineStore('posts', () => {
loading.value = true loading.value = true
error.value = null error.value = null
anchorId.value = null anchorId.value = null
const t = inflight.claim()
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: _aroundParams({ around: postId }), params: _aroundParams({ around: postId }),
}) })
if (!t.isCurrent()) return
items.value = body.items items.value = body.items
cursorOlder.value = body.cursor_older cursorOlder.value = body.cursor_older
cursorNewer.value = body.cursor_newer cursorNewer.value = body.cursor_newer
@@ -97,47 +110,54 @@ export const usePostsStore = defineStore('posts', () => {
doneNewer.value = body.cursor_newer == null doneNewer.value = body.cursor_newer == null
anchorId.value = body.anchor_id anchorId.value = body.anchor_id
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e error.value = e
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
async function loadOlder() { async function loadOlder() {
if (loading.value || doneOlder.value || cursorOlder.value == null) return if (loading.value || doneOlder.value || cursorOlder.value == null) return
loading.value = true loading.value = true
const t = inflight.claim()
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: _aroundParams({ params: _aroundParams({
cursor: cursorOlder.value, direction: 'older', cursor: cursorOlder.value, direction: 'older',
}), }),
}) })
if (!t.isCurrent()) return
items.value.push(...body.items) items.value.push(...body.items)
cursorOlder.value = body.next_cursor cursorOlder.value = body.next_cursor
if (body.next_cursor == null) doneOlder.value = true if (body.next_cursor == null) doneOlder.value = true
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e error.value = e
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
async function loadNewer() { async function loadNewer() {
if (loading.value || doneNewer.value || cursorNewer.value == null) return if (loading.value || doneNewer.value || cursorNewer.value == null) return
loading.value = true loading.value = true
const t = inflight.claim()
try { try {
const body = await api.get('/api/posts', { const body = await api.get('/api/posts', {
params: _aroundParams({ params: _aroundParams({
cursor: cursorNewer.value, direction: 'newer', cursor: cursorNewer.value, direction: 'newer',
}), }),
}) })
if (!t.isCurrent()) return
items.value.unshift(...body.items) items.value.unshift(...body.items)
cursorNewer.value = body.next_cursor cursorNewer.value = body.next_cursor
if (body.next_cursor == null) doneNewer.value = true if (body.next_cursor == null) doneNewer.value = true
} catch (e) { } catch (e) {
if (!t.isCurrent()) return
error.value = e error.value = e
} finally { } finally {
loading.value = false if (t.isCurrent()) loading.value = false
} }
} }
+34 -3
View File
@@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js'
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
// Category display order: people first, general last. // Category display order: people first, general last.
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
@@ -18,12 +19,25 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
const byCategory = ref({}) // { category: [suggestion, ...] } const byCategory = ref({}) // { category: [suggestion, ...] }
const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
let currentImageId = null let currentImageId = null
// Audit 2026-06-02: this store had no inflight guard — a late
// /suggestions response from a prior image could overwrite
// byCategory while currentImageId pointed at a new one, and
// accept() dereferenced currentImageId AFTER an awaited POST so
// the subsequent /suggestions/accept could apply A's chosen tag
// to image B (and push it to the allowlist). Both fixed below
// by capturing imageId at call-time and gating writes on the token.
const inflight = useInflightToken()
async function load(imageId) { async function load(imageId) {
// Cancel any in-flight load from the previous image so its late
// response can't overwrite this image's byCategory.
inflight.cancel()
currentImageId = imageId currentImageId = imageId
byCategory.value = {} // cleared upfront so it stays empty on error byCategory.value = {} // cleared upfront so it stays empty on error
const t = inflight.claim()
await run(async () => { await run(async () => {
const body = await api.get(`/api/images/${imageId}/suggestions`) const body = await api.get(`/api/images/${imageId}/suggestions`)
if (!t.isCurrent()) return
byCategory.value = body.by_category || {} byCategory.value = body.by_category || {}
}) })
} }
@@ -35,6 +49,11 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
async function accept(suggestion) { async function accept(suggestion) {
// Capture imageId so a mid-flight prev/next can't reroute the
// accept POST to a different image AND push the tag to that
// image's allowlist.
const imageId = currentImageId
if (imageId == null) return
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
// accept endpoint needs a tag_id, so for raw tags we create the tag // accept endpoint needs a tag_id, so for raw tags we create the tag
// first via the existing /api/tags endpoint, then accept by id. // first via the existing /api/tags endpoint, then accept by id.
@@ -45,10 +64,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
}) })
tagId = created.id tagId = created.id
} }
await api.post(`/api/images/${currentImageId}/suggestions/accept`, { await api.post(`/api/images/${imageId}/suggestions/accept`, {
body: { tag_id: tagId } body: { tag_id: tagId }
}) })
// Only drop from THIS image's category list — if the user navigated,
// the new image has its own suggestions and this drop would corrupt them.
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion) _drop(suggestion.category, s => s === suggestion)
}
toast({ toast({
text: `Tagged: ${suggestion.display_name}`, text: `Tagged: ${suggestion.display_name}`,
type: 'success' type: 'success'
@@ -56,14 +79,18 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
async function aliasAccept(suggestion, canonicalTagId) { async function aliasAccept(suggestion, canonicalTagId) {
await api.post(`/api/images/${currentImageId}/suggestions/alias`, { const imageId = currentImageId
if (imageId == null) return
await api.post(`/api/images/${imageId}/suggestions/alias`, {
body: { body: {
alias_string: suggestion.display_name, alias_string: suggestion.display_name,
alias_category: suggestion.category, alias_category: suggestion.category,
canonical_tag_id: canonicalTagId canonical_tag_id: canonicalTagId
} }
}) })
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion) _drop(suggestion.category, s => s === suggestion)
}
toast({ toast({
text: `Aliased & tagged: ${suggestion.display_name}`, text: `Aliased & tagged: ${suggestion.display_name}`,
type: 'success' type: 'success'
@@ -71,16 +98,20 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
} }
async function dismiss(suggestion) { async function dismiss(suggestion) {
const imageId = currentImageId
if (imageId == null) return
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw // Dismiss needs a tag_id; raw tags have none, so dismissing a raw
// suggestion just hides it client-side (nothing to persist a rejection // suggestion just hides it client-side (nothing to persist a rejection
// against until the tag exists). // against until the tag exists).
if (suggestion.canonical_tag_id != null) { if (suggestion.canonical_tag_id != null) {
await api.post(`/api/images/${currentImageId}/suggestions/dismiss`, { await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
body: { tag_id: suggestion.canonical_tag_id } body: { tag_id: suggestion.canonical_tag_id }
}) })
} }
if (currentImageId === imageId) {
_drop(suggestion.category, s => s === suggestion) _drop(suggestion.category, s => s === suggestion)
} }
}
return { return {
byCategory, loading, error, byCategory, loading, error,
+8 -1
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js' import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js' import { useAsyncAction } from '../composables/useAsyncAction.js'
import { useInflightToken } from '../composables/useInflightToken.js'
const PAGE = 60 const PAGE = 60
@@ -13,16 +14,21 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
const kind = ref(null) const kind = ref(null)
const q = ref('') const q = ref('')
let started = false let started = false
// Same shape as artistDirectory — rapid setQuery/setKind dropped
// the second fetch because loading was still true from the first.
// Audit 2026-06-02.
const inflight = useInflightToken()
async function loadMore() { async function loadMore() {
if (loading.value) return
if (started && nextCursor.value === null) return if (started && nextCursor.value === null) return
const t = inflight.claim()
await run(async () => { await run(async () => {
const params = { limit: PAGE } const params = { limit: PAGE }
if (kind.value) params.kind = kind.value if (kind.value) params.kind = kind.value
if (q.value) params.q = q.value if (q.value) params.q = q.value
if (nextCursor.value) params.cursor = nextCursor.value if (nextCursor.value) params.cursor = nextCursor.value
const body = await api.get('/api/tags/directory', { params }) const body = await api.get('/api/tags/directory', { params })
if (!t.isCurrent()) return
cards.value.push(...body.cards) cards.value.push(...body.cards)
nextCursor.value = body.next_cursor nextCursor.value = body.next_cursor
started = true started = true
@@ -30,6 +36,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => {
} }
async function reset() { async function reset() {
inflight.cancel()
cards.value = [] cards.value = []
nextCursor.value = null nextCursor.value = null
started = false started = false