fix(images): 私有图片支持 Bearer 鉴权,画布节点改为带鉴权 blob 加载
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user