fix(images): 私有图片支持 Bearer 鉴权,画布节点改为带鉴权 blob 加载
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"backend/internal/config"
|
||||
"backend/internal/service"
|
||||
"backend/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,7 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
||||
if !public {
|
||||
authorized, err := h.imageAccess.IsAuthorized(
|
||||
c.Request.Context(),
|
||||
c.GetHeader("Authorization"),
|
||||
readCookie(c, h.cfg.SessionCookieName),
|
||||
user,
|
||||
)
|
||||
|
||||
@@ -100,6 +100,11 @@ func ownsImageDir(user *model.User, owner string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// OwnsImageDir is the exported alias used by other services (image access, etc.)
|
||||
func (s *AuthService) OwnsImageDir(user *model.User, owner string) bool {
|
||||
return ownsImageDir(user, owner)
|
||||
}
|
||||
|
||||
// checkOwnerDirCollision rejects a signup whose sanitized username or email
|
||||
// local-part collides with any existing user's directory candidates. Media
|
||||
// authorization (ownsImageDir) matches on those candidates, so without this
|
||||
|
||||
@@ -48,6 +48,21 @@ func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, er
|
||||
return s.showcase.IsPublicFile(ctx, rel)
|
||||
}
|
||||
|
||||
func (s *ImageAccessService) IsAuthorized(ctx context.Context, sessionCookie, owner string) (bool, error) {
|
||||
func (s *ImageAccessService) IsAuthorized(ctx context.Context, authHeader, sessionCookie, owner string) (bool, error) {
|
||||
// Private images may be requested by an <img> tag with the session cookie
|
||||
// OR by the SPA via a Bearer token. Admins may view anyone's images.
|
||||
if authHeader != "" {
|
||||
user, _, err := s.auth.CurrentUserFromBearer(ctx, authHeader)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if user == nil {
|
||||
return false, nil
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
return s.auth.OwnsImageDir(user, owner), nil
|
||||
}
|
||||
return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,22 @@ export function generatedUrl(name) {
|
||||
return `${BASE}/images/${s.replace(/^\/?images\//, '')}`
|
||||
}
|
||||
|
||||
/** Load a private image (thumbnail, falling back to the original) using the
|
||||
* same Bearer token the SPA already has. Returns a browser blob: URL so the
|
||||
* component can revoke it later. Throws on real failure. */
|
||||
export async function loadImageBlob(name, signal) {
|
||||
const headers = auth.token ? { Authorization: `Bearer ${auth.token}` } : {}
|
||||
let u = thumbUrl(name)
|
||||
let r = await fetch(u, { headers, credentials: 'include', signal })
|
||||
if (!r.ok || r.status === 404) {
|
||||
u = generatedUrl(name)
|
||||
r = await fetch(u, { headers, credentials: 'include', signal })
|
||||
}
|
||||
if (!r.ok) throw new Error(`图片加载失败 (${r.status})`)
|
||||
const blob = await r.blob()
|
||||
return { url: URL.createObjectURL(blob), status: r.status }
|
||||
}
|
||||
|
||||
/** Small thumbnail URL for list views. The server falls back to the original
|
||||
* when no thumbnail object exists (old images), so this is always safe. */
|
||||
export function thumbUrl(name) {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
|
||||
// 这里只负责节点自身内容:image / note / gen(生成中占位)。
|
||||
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { generatedUrl, thumbUrl } from '../../api'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { generatedUrl, loadImageBlob, thumbUrl } from '../../api'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
@@ -17,14 +17,38 @@ const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
|
||||
const imgLoaded = ref(false)
|
||||
const imgBroken = ref(false)
|
||||
const imgStatus = ref('')
|
||||
const imgThumb = computed(() => thumbUrl(props.node.src))
|
||||
const imgBlob = ref('')
|
||||
const imgOriginal = computed(() => generatedUrl(props.node.src))
|
||||
const imgOriginalAbs = computed(() => {
|
||||
try { return new URL(imgOriginal.value, window.location.href).href } catch { return imgOriginal.value }
|
||||
})
|
||||
const noteEl = ref(null)
|
||||
const editing = ref(false)
|
||||
|
||||
let imgController = null
|
||||
async function loadSrc() {
|
||||
if (props.node.type !== 'image' || !props.node.src) return
|
||||
imgController?.abort()
|
||||
imgController = new AbortController()
|
||||
imgLoaded.value = false
|
||||
imgBroken.value = false
|
||||
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
|
||||
try {
|
||||
const { url } = await loadImageBlob(props.node.src, imgController.signal)
|
||||
imgBlob.value = url
|
||||
} catch (e) {
|
||||
imgBroken.value = true
|
||||
const msg = e?.message || ''
|
||||
const m = msg.match(/\((\d+)\)/)
|
||||
imgStatus.value = m ? m[1] : '失败'
|
||||
imgLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadSrc())
|
||||
onUnmounted(() => {
|
||||
imgController?.abort()
|
||||
if (imgBlob.value) { URL.revokeObjectURL(imgBlob.value); imgBlob.value = '' }
|
||||
})
|
||||
watch(() => props.node.src, loadSrc)
|
||||
|
||||
onMounted(() => {
|
||||
// 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。
|
||||
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || ''
|
||||
@@ -41,14 +65,6 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.node.src,
|
||||
() => {
|
||||
imgLoaded.value = false
|
||||
imgBroken.value = false
|
||||
},
|
||||
)
|
||||
|
||||
function onImgLoad(e) {
|
||||
imgLoaded.value = true
|
||||
imgBroken.value = false
|
||||
@@ -64,19 +80,6 @@ function onImgLoad(e) {
|
||||
}
|
||||
}
|
||||
|
||||
function onImgError(e) {
|
||||
if (e.target.src !== imgOriginalAbs.value) {
|
||||
e.target.src = imgOriginal.value
|
||||
return
|
||||
}
|
||||
imgLoaded.value = true
|
||||
imgBroken.value = true
|
||||
imgStatus.value = ''
|
||||
fetch(imgOriginal.value, { credentials: 'include' })
|
||||
.then((r) => { imgStatus.value = String(r.status) })
|
||||
.catch(() => { imgStatus.value = '网络错误' })
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (props.node.type !== 'note') return
|
||||
editing.value = true
|
||||
@@ -149,15 +152,13 @@ defineExpose({ startEdit, commitNote })
|
||||
<!-- 图片节点 -->
|
||||
<template v-if="node.type === 'image'">
|
||||
<img
|
||||
v-if="!imgBroken"
|
||||
v-if="!imgBroken && imgBlob"
|
||||
class="cv-node__img"
|
||||
:src="imgThumb"
|
||||
loading="lazy"
|
||||
:src="imgBlob"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
alt=""
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
>
|
||||
<div v-else class="cv-node__missing">图片不可用<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div>
|
||||
<div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>
|
||||
|
||||
Reference in New Issue
Block a user