fix(images): 私有图片支持 Bearer 鉴权,画布节点改为带鉴权 blob 加载

This commit is contained in:
2026-10-12 13:54:34 +08:00
parent c559e712b6
commit 256eeb3234
5 changed files with 84 additions and 45 deletions
+2
View File
@@ -7,6 +7,7 @@ import (
"backend/internal/config" "backend/internal/config"
"backend/internal/service" "backend/internal/service"
"backend/internal/storage" "backend/internal/storage"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -55,6 +56,7 @@ func (h *ImageHandler) Serve(c *gin.Context) {
if !public { if !public {
authorized, err := h.imageAccess.IsAuthorized( authorized, err := h.imageAccess.IsAuthorized(
c.Request.Context(), c.Request.Context(),
c.GetHeader("Authorization"),
readCookie(c, h.cfg.SessionCookieName), readCookie(c, h.cfg.SessionCookieName),
user, user,
) )
+18 -13
View File
@@ -100,6 +100,11 @@ func ownsImageDir(user *model.User, owner string) bool {
return false 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 // checkOwnerDirCollision rejects a signup whose sanitized username or email
// local-part collides with any existing user's directory candidates. Media // local-part collides with any existing user's directory candidates. Media
// authorization (ownsImageDir) matches on those candidates, so without this // authorization (ownsImageDir) matches on those candidates, so without this
@@ -481,21 +486,21 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str
} }
} }
return map[string]any{ return map[string]any{
"id": user.ID, "id": user.ID,
"email": user.Email, "email": user.Email,
"name": user.Name, "name": user.Name,
"role": user.Role, "role": user.Role,
"status": user.Status, "status": user.Status,
"credits": user.Credits, "credits": user.Credits,
"recharge_total": user.RechargeTotal, "recharge_total": user.RechargeTotal,
"concurrency_group": concName, "concurrency_group": concName,
"concurrency_limit": concMax, "concurrency_limit": concMax,
"checkin_last": user.CheckinLast, "checkin_last": user.CheckinLast,
"checkin_streak": user.CheckinStreak, "checkin_streak": user.CheckinStreak,
"checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"), "checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"),
"invite_code": user.InviteCode, "invite_code": user.InviteCode,
"invite_count": stats.InviteCount, "invite_count": stats.InviteCount,
"invite_earned": stats.InviteEarned, "invite_earned": stats.InviteEarned,
}, nil }, nil
} }
+16 -1
View File
@@ -48,6 +48,21 @@ func (s *ImageAccessService) IsPublic(ctx context.Context, rel string) (bool, er
return s.showcase.IsPublicFile(ctx, rel) 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) return s.auth.IsAuthorizedForPrivateImage(ctx, sessionCookie, owner)
} }
+16
View File
@@ -60,6 +60,22 @@ export function generatedUrl(name) {
return `${BASE}/images/${s.replace(/^\/?images\//, '')}` 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 /** 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. */ * when no thumbnail object exists (old images), so this is always safe. */
export function thumbUrl(name) { export function thumbUrl(name) {
@@ -2,8 +2,8 @@
// 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放, // 单个画布节点。位置/尺寸由 stage 的世界层 transform 统一缩放,
// 这里只负责节点自身内容:image / note / gen(生成中占位)。 // 这里只负责节点自身内容:image / note / gen(生成中占位)。
// 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。 // 性能:contain 隔离布局;图片 lazy + async 解码 + 缩略图。
import { computed, ref, onMounted, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { generatedUrl, thumbUrl } from '../../api' import { generatedUrl, loadImageBlob, thumbUrl } from '../../api'
const props = defineProps({ const props = defineProps({
node: { type: Object, required: true }, node: { type: Object, required: true },
@@ -17,14 +17,38 @@ const portLabels = { top: '上', right: '右', bottom: '下', left: '左' }
const imgLoaded = ref(false) const imgLoaded = ref(false)
const imgBroken = ref(false) const imgBroken = ref(false)
const imgStatus = ref('') const imgStatus = ref('')
const imgThumb = computed(() => thumbUrl(props.node.src)) const imgBlob = ref('')
const imgOriginal = computed(() => generatedUrl(props.node.src)) 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 noteEl = ref(null)
const editing = ref(false) 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(() => { onMounted(() => {
// 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。 // 非受控 contenteditable:只在挂载时写初值,避免输入时光标被 v-model 回写打跳。
if (props.node.type === 'note' && noteEl.value) noteEl.value.innerText = props.node.text || '' 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) { function onImgLoad(e) {
imgLoaded.value = true imgLoaded.value = true
imgBroken.value = false 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() { function startEdit() {
if (props.node.type !== 'note') return if (props.node.type !== 'note') return
editing.value = true editing.value = true
@@ -149,15 +152,13 @@ defineExpose({ startEdit, commitNote })
<!-- 图片节点 --> <!-- 图片节点 -->
<template v-if="node.type === 'image'"> <template v-if="node.type === 'image'">
<img <img
v-if="!imgBroken" v-if="!imgBroken && imgBlob"
class="cv-node__img" class="cv-node__img"
:src="imgThumb" :src="imgBlob"
loading="lazy"
decoding="async" decoding="async"
draggable="false" draggable="false"
alt="" alt=""
@load="onImgLoad" @load="onImgLoad"
@error="onImgError"
> >
<div v-else class="cv-node__missing">图片不可用<small v-if="imgStatus">HTTP {{ imgStatus }}</small></div> <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> <div v-if="node.prompt" class="cv-node__cap" :title="node.prompt">{{ node.prompt }}</div>