df6d89cb59
Operator-flagged 2026-05-27: walk the whole project for the same shape
as the min-dim Delete-button silent failure (crypto.subtle TypeError
on plain HTTP). FC runs over plain HTTP per the homelab posture;
Secure-Context-gated browser APIs are undefined on the production
origin.
**Audit results across `frontend/src/`:**
crypto.subtle.digest — 2 sites:
- MinDimensionCard (fixed 2026-05-27)
- BulkEditorPanel (THIS FIX)
navigator.clipboard — 1 site, already guarded:
- utils/clipboard.js writeText with execCommand fallback
serviceWorker / mediaDevices / Push / Web USB|HID|Bluetooth|Serial /
cookieStore / queryLocalFonts / WebAuthn / geolocation
— NOT USED, nothing to fix
Extension scripts (background.js) use crypto.subtle but run from
moz-extension:// which IS a Secure Context — left as-is.
**BulkEditorPanel double bug**
The bulk-delete UI on the gallery selection had been broken since
FC-3k shipped, in two ways:
1. `crypto.subtle.digest` swallowed TypeError on plain HTTP — modal
never opened. Same symptom as min-dim.
2. Even on HTTPS, the modal's `kind="images-selection"` produced
`delete-images-selection-<sha8>` while the backend expected
`delete-images-<sha8>`. The two would never match.
Fix:
- Backend `/api/admin/images/bulk-delete` dry-run response now returns
`confirm_token` (the canonical `delete-images-<sha8>` string).
Integration test `test_bulk_delete_dry_run_returns_counts` pinned to
assert the new field.
- DestructiveConfirmModal gains an `expectedTokenOverride` prop. When
set, it bypasses the `${action}-${kind}-${runId}` formula and uses
the explicit string. This decouples the UI label (`kind`) from the
wire-format token (server-provided), so future endpoints can use a
kind-specific label without their kind name leaking into the token.
- BulkEditorPanel passes `:expected-token-override="bulkProjected?.confirm_token"`
— no client-side crypto, no kind-prefix mismatch.
- MinDimensionCard refactored to the same explicit pattern (was
slicing the 8-char suffix off the backend's token and passing it
through `runId`; now passes the full backend token via
`expected-token-override` directly). Cleaner; one source of truth.
**Banked memory**
`feedback_no_secure_context_apis.md` documents the full table of
Secure-Context-gated APIs, which ones FC currently uses, and how each
is handled. Indexed in MEMORY.md. Sites for the audit also listed in
the memory for future drift-checking.
No other Secure-Context-gated APIs found in `frontend/src/`. The same
shape won't recur unless someone adds a new dependency on one — at
which point the banked memory should fire.
233 lines
7.3 KiB
Vue
233 lines
7.3 KiB
Vue
<template>
|
|
<aside class="fc-bulk-panel" :class="{ active: sel.isSelectMode }">
|
|
<div class="fc-bulk-panel__head">
|
|
<h3>Bulk Edit</h3>
|
|
<v-btn icon="mdi-close" variant="text" size="small" @click="sel.exitSelectMode()" />
|
|
</div>
|
|
<div class="fc-bulk-panel__stat">{{ sel.count }} image(s) selected</div>
|
|
|
|
<div class="fc-bulk-panel__section">
|
|
<h4>Add tag</h4>
|
|
<v-autocomplete
|
|
v-model="addModel" :items="addHits" item-title="name" item-value="id"
|
|
:loading="addLoading" density="compact" variant="outlined" hide-details
|
|
placeholder="Search tags…" no-filter
|
|
@update:search="onAddSearch" @update:model-value="onAddPick"
|
|
/>
|
|
</div>
|
|
|
|
<div class="fc-bulk-panel__section">
|
|
<h4>Remove common tag</h4>
|
|
<div v-if="sel.commonTags.length === 0" class="fc-bulk-panel__hint">
|
|
No tags common to all selected images.
|
|
</div>
|
|
<v-chip
|
|
v-for="t in sel.commonTags" :key="t.id" size="small" label
|
|
class="ma-1" closable @click:close="sel.bulkRemove(t.id)"
|
|
>{{ t.name }}</v-chip>
|
|
</div>
|
|
|
|
<div class="fc-bulk-panel__section">
|
|
<h4>Consensus suggestions</h4>
|
|
<div v-if="sel.count < 2" class="fc-bulk-panel__hint">
|
|
Select 2+ images for consensus suggestions.
|
|
</div>
|
|
<template v-else>
|
|
<div
|
|
v-for="(items, cat) in sel.consensus" :key="cat"
|
|
class="fc-bulk-panel__cat"
|
|
>
|
|
<div class="fc-bulk-panel__cat-name">{{ cat }}</div>
|
|
<div
|
|
v-for="s in items" :key="s.canonical_tag_id"
|
|
class="fc-bulk-panel__sug"
|
|
>
|
|
<span>{{ s.name }}</span>
|
|
<span class="fc-bulk-panel__cov">
|
|
{{ Math.round(s.coverage * 100) }}% · {{ s.confidence.toFixed(2) }}
|
|
</span>
|
|
<v-btn
|
|
size="x-small" variant="tonal" color="accent"
|
|
@click="sel.bulkAdd(s.canonical_tag_id, 'ml_accepted')"
|
|
>Accept</v-btn>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="fc-bulk-panel__section">
|
|
<h4>Destructive</h4>
|
|
<v-btn
|
|
color="error" variant="flat" rounded="pill" block
|
|
prepend-icon="mdi-delete-forever"
|
|
:disabled="!sel.count"
|
|
:loading="deleting"
|
|
@click="onDeleteClick"
|
|
>Delete {{ sel.count }} selected</v-btn>
|
|
</div>
|
|
|
|
<div class="fc-bulk-panel__foot">
|
|
<v-btn variant="text" block @click="sel.clear()">Clear selection</v-btn>
|
|
</div>
|
|
|
|
<DestructiveConfirmModal
|
|
v-model="deleteModalOpen"
|
|
action="delete"
|
|
kind="images-selection"
|
|
:expected-token-override="bulkProjected?.confirm_token || ''"
|
|
tier="C"
|
|
:projected-counts="bulkProjectedCounts"
|
|
:description="bulkDescription"
|
|
@confirm="onDeleteConfirm"
|
|
/>
|
|
</aside>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
|
import { useAdminStore } from '../../stores/admin.js'
|
|
import { useApi } from '../../composables/useApi.js'
|
|
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
|
|
|
const sel = useGallerySelectionStore()
|
|
const api = useApi()
|
|
const adminStore = useAdminStore()
|
|
|
|
const addModel = ref(null)
|
|
const addHits = ref([])
|
|
const addLoading = ref(false)
|
|
let addDebounce = null
|
|
|
|
function onAddSearch(q) {
|
|
if (addDebounce) clearTimeout(addDebounce)
|
|
if (!q) { addHits.value = []; return }
|
|
addDebounce = setTimeout(async () => {
|
|
addLoading.value = true
|
|
try {
|
|
addHits.value = await api.get('/api/tags/autocomplete', {
|
|
params: { q, limit: 20 }
|
|
})
|
|
} finally {
|
|
addLoading.value = false
|
|
}
|
|
}, 250)
|
|
}
|
|
async function onAddPick(id) {
|
|
if (id == null) return
|
|
await sel.bulkAdd(id)
|
|
addModel.value = null
|
|
addHits.value = []
|
|
}
|
|
|
|
// Refresh common tags + consensus whenever the selection changes.
|
|
watch(() => sel.order.length, () => {
|
|
if (sel.isSelectMode) sel.refresh()
|
|
})
|
|
|
|
// --- FC-3k bulk delete -----------------------------------------------
|
|
|
|
const deleting = ref(false)
|
|
const deleteModalOpen = ref(false)
|
|
const bulkProjected = ref(null)
|
|
|
|
const bulkProjectedCounts = computed(() => bulkProjected.value
|
|
? {
|
|
images: bulkProjected.value.images_found,
|
|
thumbnails: bulkProjected.value.thumbs_to_unlink,
|
|
bytes: bulkProjected.value.bytes_on_disk,
|
|
}
|
|
: null,
|
|
)
|
|
const bulkDescription = computed(
|
|
() => bulkProjected.value
|
|
? `${bulkProjected.value.images_found} images, `
|
|
+ `${Math.round(bulkProjected.value.bytes_on_disk / 1_048_576)} MiB on disk`
|
|
: '',
|
|
)
|
|
|
|
// The dry-run response hands the canonical Tier-C confirm token back
|
|
// as `confirm_token` (e.g. `delete-images-1a2b3c4d`), passed straight
|
|
// to the modal via `expected-token-override`. We used to compute the
|
|
// hash client-side via `crypto.subtle.digest`, but (1) that's
|
|
// Secure-Context-gated and undefined on plain-HTTP origins
|
|
// (homelab posture), so the click silently threw TypeError and the
|
|
// modal never opened, and (2) the modal's `kind="images-selection"`
|
|
// produced `delete-images-selection-<sha8>` while the backend
|
|
// expected `delete-images-<sha8>` — so it never would have worked
|
|
// even on HTTPS. Operator-flagged 2026-05-27.
|
|
|
|
async function onDeleteClick() {
|
|
if (!sel.order.length) return
|
|
deleting.value = true
|
|
try {
|
|
bulkProjected.value = await adminStore.projectBulkImageDelete(sel.order)
|
|
deleteModalOpen.value = true
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
|
|
async function onDeleteConfirm(token) {
|
|
deleting.value = true
|
|
try {
|
|
const result = await adminStore.dispatchBulkImageDelete(sel.order, token)
|
|
const taskId = result.task_id
|
|
if (taskId) {
|
|
adminStore.pollTaskUntilDone(taskId).catch(() => {})
|
|
}
|
|
sel.clear()
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-bulk-panel {
|
|
position: fixed; top: 0; right: 0;
|
|
width: 320px; height: 100vh; z-index: 1100;
|
|
background: rgb(var(--v-theme-surface));
|
|
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
|
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
|
|
transform: translateX(100%);
|
|
transition: transform 0.3s ease;
|
|
display: flex; flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
.fc-bulk-panel.active { transform: translateX(0); }
|
|
.fc-bulk-panel__head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-bulk-panel__head h3 {
|
|
font-family: 'Fraunces', Georgia, serif; font-size: 18px; margin: 0;
|
|
}
|
|
.fc-bulk-panel__stat {
|
|
padding: 10px 16px; font-size: 13px; opacity: 0.8;
|
|
background: rgba(232, 228, 216, 0.05);
|
|
}
|
|
.fc-bulk-panel__section {
|
|
padding: 16px;
|
|
border-bottom: 1px solid rgba(232, 228, 216, 0.06);
|
|
}
|
|
.fc-bulk-panel__section h4 {
|
|
margin: 0 0 10px; font-size: 12px; text-transform: uppercase;
|
|
letter-spacing: 0.04em; opacity: 0.7;
|
|
}
|
|
.fc-bulk-panel__hint { font-size: 12px; opacity: 0.6; }
|
|
.fc-bulk-panel__cat-name {
|
|
font-size: 11px; text-transform: uppercase; opacity: 0.5; margin: 8px 0 4px;
|
|
}
|
|
.fc-bulk-panel__sug {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 4px 0; font-size: 13px;
|
|
}
|
|
.fc-bulk-panel__sug span:first-child { flex: 1; min-width: 0; }
|
|
.fc-bulk-panel__cov {
|
|
font-variant-numeric: tabular-nums; opacity: 0.6; font-size: 11px;
|
|
}
|
|
.fc-bulk-panel__foot { margin-top: auto; padding: 12px 16px; }
|
|
</style>
|