diff --git a/backend/internal/http/handler/images.go b/backend/internal/http/handler/images.go index d901fc9..21a2582 100644 --- a/backend/internal/http/handler/images.go +++ b/backend/internal/http/handler/images.go @@ -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, ) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 9fd4595..72937db 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -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 @@ -481,21 +486,21 @@ func (s *AuthService) PublicUser(ctx context.Context, user *model.User) (map[str } } return map[string]any{ - "id": user.ID, - "email": user.Email, - "name": user.Name, - "role": user.Role, - "status": user.Status, - "credits": user.Credits, - "recharge_total": user.RechargeTotal, + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + "status": user.Status, + "credits": user.Credits, + "recharge_total": user.RechargeTotal, "concurrency_group": concName, "concurrency_limit": concMax, - "checkin_last": user.CheckinLast, - "checkin_streak": user.CheckinStreak, - "checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"), - "invite_code": user.InviteCode, - "invite_count": stats.InviteCount, - "invite_earned": stats.InviteEarned, + "checkin_last": user.CheckinLast, + "checkin_streak": user.CheckinStreak, + "checkin_today": user.CheckinLast == time.Now().Format("2006-01-02"), + "invite_code": user.InviteCode, + "invite_count": stats.InviteCount, + "invite_earned": stats.InviteEarned, }, nil } diff --git a/backend/internal/service/image_access.go b/backend/internal/service/image_access.go index 99a9745..68bbac5 100644 --- a/backend/internal/service/image_access.go +++ b/backend/internal/service/image_access.go @@ -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 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) } diff --git a/frontend/src/api.js b/frontend/src/api.js index 8e8628f..b7d43ea 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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) { diff --git a/frontend/src/components/canvas/CanvasNodeView.vue b/frontend/src/components/canvas/CanvasNodeView.vue index e16fdcf..e151fe1 100644 --- a/frontend/src/components/canvas/CanvasNodeView.vue +++ b/frontend/src/components/canvas/CanvasNodeView.vue @@ -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 }) 图片不可用HTTP {{ imgStatus }} {{ node.prompt }}