feat: 支持管理端查看生成媒体 (#72)
This commit is contained in:
@@ -25,6 +25,8 @@ export const updateRoutePool = (id, data) => request({ url: `${base}/route-pools
|
||||
export const listPortalUsers = () => request({ url: `${base}/users`, method: 'get' })
|
||||
export const updatePortalUserStatus = (id, status) => request({ url: `${base}/users/${id}`, method: 'put', data: { status }})
|
||||
export const listGenerations = () => request({ url: `${base}/generations`, method: 'get' })
|
||||
export const getGeneration = id => request({ url: `${base}/generations/${id}`, method: 'get' })
|
||||
export const getGenerationMedia = url => request({ url, method: 'get', responseType: 'blob' })
|
||||
|
||||
export const listAPIKeys = params => request({ url: `${base}/api-keys`, method: 'get', params })
|
||||
export const getAPIKey = id => request({ url: `${base}/api-keys/${id}`, method: 'get' })
|
||||
|
||||
@@ -42,8 +42,9 @@ service.interceptors.response.use(
|
||||
* Determine the request status by custom code
|
||||
* Here is just an example
|
||||
* You can also judge the status by HTTP Status Code
|
||||
*/
|
||||
*/
|
||||
response => {
|
||||
if (response.config.responseType === 'blob') return response.data
|
||||
const code = response.data.code
|
||||
if (code === 401) {
|
||||
store.dispatch('user/resetToken')
|
||||
|
||||
@@ -12,27 +12,108 @@
|
||||
<div class="detail-head"><h2>任务 #{{ selected.id }}</h2><el-tag :type="statusType(selected.status)">{{ statusText(selected.status) }}</el-tag></div>
|
||||
<dl class="detail-list"><dt>用户账号</dt><dd>{{ selected.username }}<span v-if="selected.display_name">({{ selected.display_name }})</span></dd><dt>生成类型</dt><dd>{{ kindText(selected.kind) }}</dd><dt>Provider 尝试</dt><dd>{{ selected.provider_attempt_count }}</dd><dt>总耗时</dt><dd class="numeric">{{ selectedTiming.total }}</dd><dt>排队耗时</dt><dd class="numeric">{{ selectedTiming.queue }}</dd><dt>处理耗时</dt><dd class="numeric">{{ selectedTiming.processing }}</dd><dt>上游耗时</dt><dd class="numeric">{{ selectedTiming.upstream }}</dd><dt>提交时间</dt><dd>{{ formatTime(selected.created_at) }}</dd><dt>开始时间</dt><dd>{{ formatTime(selected.started_at) }}</dd><dt>完成时间</dt><dd>{{ formatTime(selected.completed_at) }}</dd><template v-if="selected.error_code"><dt>错误码</dt><dd class="danger-text mono">{{ selected.error_code }}</dd></template><template v-if="selected.error_message"><dt>错误摘要</dt><dd>{{ selected.error_message }}</dd></template></dl>
|
||||
<el-collapse class="prompt-collapse"><el-collapse-item title="渲染 Prompt"><pre class="prompt">{{ selected.rendered_prompt || '-' }}</pre></el-collapse-item></el-collapse>
|
||||
<el-skeleton v-if="detailLoading" :rows="6" animated />
|
||||
<el-alert v-else-if="detailError" title="详情加载失败" :description="detailError" type="error" :closable="false" show-icon />
|
||||
<template v-else>
|
||||
<h3>提示词原图</h3>
|
||||
<div v-if="inputMedia.length" class="media-grid">
|
||||
<article v-for="item in inputMedia" :key="`input-${item.id}`" class="media-item">
|
||||
<button v-if="item.preview_url" class="image-button" type="button" :aria-label="`查看原图 ${item.name}`" @click="openPreview(item, 'input')"><img :src="item.preview_url" :alt="item.name || '提示词原图'"></button>
|
||||
<div v-else class="media-placeholder"><span v-if="item.loading">加载中...</span><span v-else>{{ item.error || '无法预览' }}</span></div>
|
||||
<div class="media-meta"><strong>{{ item.name || `输入 #${item.id}` }}</strong><span>{{ mediaMeta(item) }}</span></div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="muted">本任务没有提示词原图。</p>
|
||||
<h3>生成结果</h3>
|
||||
<div v-if="imageOutputs.length" class="media-grid">
|
||||
<article v-for="item in imageOutputs" :key="`output-${item.id}`" class="media-item">
|
||||
<button v-if="item.preview_url" class="image-button" type="button" :aria-label="`查看生成图片 #${item.id}`" @click="openPreview(item, 'output')"><img :src="item.preview_url" :alt="`生成图片 #${item.id}`"></button>
|
||||
<div v-else class="media-placeholder"><span v-if="item.loading">加载中...</span><span v-else>{{ item.error || '无法预览' }}</span></div>
|
||||
<div class="media-meta"><strong>图片 #{{ item.id }}</strong><span>{{ mediaMeta(item) }}</span></div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="textOutputs.length" class="text-results"><article v-for="item in textOutputs" :key="`text-${item.id}`"><strong>文本 #{{ item.id }}</strong><pre>{{ item.text || '-' }}</pre></article></div>
|
||||
<p v-if="!imageOutputs.length && !textOutputs.length" class="muted">本任务尚无生成结果。</p>
|
||||
</template>
|
||||
<h3>尝试记录</h3><el-timeline v-if="attempts.length"><el-timeline-item v-for="(attempt, index) in attempts" :key="index" :type="attempt.presentation.type" :timestamp="formatTime(attempt.presentation.timestamp)"><strong>{{ attempt.presentation.label }} · {{ attempt.presentation.title }}</strong><p>{{ attempt.presentation.status }}<span v-if="attempt.presentation.duration !== '-'"> · {{ attempt.presentation.duration }}</span><span v-if="attempt.error_message"> · {{ attempt.error_message }}</span></p></el-timeline-item></el-timeline><p v-else class="muted">尚无尝试记录。</p>
|
||||
</section>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="previewOpen" :title="previewTitle" width="min(92vw, 1100px)" append-to-body @closed="clearPreview"><div v-loading="previewLoading" class="preview-stage"><el-alert v-if="previewError" title="原图加载失败" :description="previewError" type="error" :closable="false" show-icon /><img v-else-if="previewURL" :src="previewURL" :alt="previewTitle"></div></el-dialog>
|
||||
</main></template></BasicLayout>
|
||||
</template>
|
||||
<script>
|
||||
import { listGenerations } from '@/api/chorus'
|
||||
import { getGeneration, getGenerationMedia, listGenerations } from '@/api/chorus'
|
||||
import { contains, dataOf, formatTime } from '../shared'
|
||||
import { attemptPresentation, generationDetailSize, generationTiming, kindText, reconcileGenerationDetail } from './helpers'
|
||||
export default {
|
||||
name: 'ChorusGenerations',
|
||||
data() { return { loading: false, rows: [], selected: null, detailOpen: false, viewportWidth: window.innerWidth, keyword: '', status: '', statuses: ['pending', 'running', 'succeeded', 'failed'], nowMs: Date.now(), clock: null } },
|
||||
data() { return { loading: false, rows: [], selected: null, detailOpen: false, detailLoading: false, detailError: '', detailVersion: 0, inputMedia: [], imageOutputs: [], textOutputs: [], objectURLs: [], previewOpen: false, previewLoading: false, previewURL: '', previewTitle: '', previewError: '', previewVersion: 0, viewportWidth: window.innerWidth, keyword: '', status: '', statuses: ['pending', 'running', 'succeeded', 'failed'], nowMs: Date.now(), clock: null } },
|
||||
computed: { filtered() { return this.rows.filter(row => (!this.keyword || [row.id, row.user_id, row.username, row.display_name, row.error_code].some(value => contains(value, this.keyword))) && (!this.status || row.status === this.status)) }, attempts() { return Array.isArray(this.selected && this.selected.attempts) ? this.selected.attempts.map(attempt => ({ ...attempt, presentation: attemptPresentation(attempt) })) : [] }, selectedTiming() { return generationTiming(this.selected, this.nowMs) }, detailSize() { return generationDetailSize(this.viewportWidth) } },
|
||||
created() { if (this.$route.query.user) this.keyword = String(this.$route.query.user); this.load(); this.clock = window.setInterval(() => { this.nowMs = Date.now() }, 1000) },
|
||||
mounted() { window.addEventListener('resize', this.handleResize) },
|
||||
beforeUnmount() { window.clearInterval(this.clock); window.removeEventListener('resize', this.handleResize) },
|
||||
methods: { formatTime, kindText, timing(row) { return generationTiming(row, this.nowMs) }, openDetail(row) { this.selected = row; this.detailOpen = true }, clearDetail() { this.selected = null }, handleResize() { this.viewportWidth = window.innerWidth }, syncSelected() { const state = reconcileGenerationDetail(this.rows, this.selected, this.detailOpen); this.selected = state.selected; this.detailOpen = state.detailOpen }, async load() { this.loading = true; try { this.rows = dataOf(await listGenerations()); this.syncSelected() } finally { this.loading = false } }, reset() { this.keyword = ''; this.status = '' }, statusText(value) { return ({ pending: '等待中', running: '处理中', succeeded: '成功', failed: '失败' })[value] || value }, statusType(value) { return ({ pending: 'info', running: 'warning', succeeded: 'success', failed: 'danger' })[value] || 'info' } }
|
||||
beforeUnmount() { window.clearInterval(this.clock); window.removeEventListener('resize', this.handleResize); this.revokeObjectURLs() },
|
||||
methods: {
|
||||
formatTime, kindText,
|
||||
timing(row) { return generationTiming(row, this.nowMs) },
|
||||
async openDetail(row) {
|
||||
const version = ++this.detailVersion
|
||||
this.revokeObjectURLs(); this.inputMedia = []; this.imageOutputs = []; this.textOutputs = []; this.detailError = ''
|
||||
this.selected = row; this.detailOpen = true; this.detailLoading = true
|
||||
try {
|
||||
const detail = dataOf(await getGeneration(row.id))
|
||||
if (version !== this.detailVersion) return
|
||||
this.selected = detail
|
||||
this.inputMedia = (detail.inputs || []).map(item => ({ ...item, loading: true, error: '', preview_url: '' }))
|
||||
this.imageOutputs = (detail.outputs || []).filter(item => item.url).map(item => ({ ...item, loading: true, error: '', preview_url: '', original_url: '' }))
|
||||
this.textOutputs = (detail.outputs || []).filter(item => item.text !== undefined && item.text !== null)
|
||||
await Promise.all([
|
||||
...this.inputMedia.map(item => this.loadMedia(item, item.url, version, 'preview_url')),
|
||||
...this.imageOutputs.map(item => this.loadMedia(item, item.thumbnail_url || item.url, version, 'preview_url'))
|
||||
])
|
||||
} catch (error) {
|
||||
if (version === this.detailVersion) this.detailError = error && error.message ? error.message : '请求失败'
|
||||
} finally {
|
||||
if (version === this.detailVersion) this.detailLoading = false
|
||||
}
|
||||
},
|
||||
async loadMedia(item, url, version, field) {
|
||||
if (!url) { item.loading = false; item.error = '没有可用文件'; return }
|
||||
try {
|
||||
const blob = await getGenerationMedia(url)
|
||||
const objectURL = URL.createObjectURL(blob)
|
||||
if (version !== this.detailVersion) { URL.revokeObjectURL(objectURL); return }
|
||||
this.objectURLs.push(objectURL); item[field] = objectURL
|
||||
} catch (error) { if (version === this.detailVersion) item.error = error && error.message ? error.message : '加载失败' } finally { if (version === this.detailVersion) item.loading = false }
|
||||
},
|
||||
async openPreview(item, type) {
|
||||
const version = ++this.previewVersion
|
||||
this.previewOpen = true; this.previewLoading = true; this.previewError = ''; this.previewURL = ''; this.previewTitle = type === 'input' ? (item.name || `输入 #${item.id}`) : `生成图片 #${item.id}`
|
||||
if (type === 'input') { this.previewURL = item.preview_url; this.previewLoading = false; return }
|
||||
if (item.original_url) { this.previewURL = item.original_url; this.previewLoading = false; return }
|
||||
try {
|
||||
const blob = await getGenerationMedia(item.url)
|
||||
const objectURL = URL.createObjectURL(blob)
|
||||
if (!this.previewOpen || version !== this.previewVersion) { URL.revokeObjectURL(objectURL); return }
|
||||
this.objectURLs.push(objectURL); item.original_url = objectURL; this.previewURL = objectURL
|
||||
} catch (error) { if (version === this.previewVersion) this.previewError = error && error.message ? error.message : '加载失败' } finally { if (version === this.previewVersion) this.previewLoading = false }
|
||||
},
|
||||
mediaMeta(item) { const dimensions = item.width && item.height ? `${item.width} × ${item.height}` : ''; const size = Number(item.size_bytes) > 0 ? `${(Number(item.size_bytes) / 1024).toFixed(1)} KB` : ''; return [dimensions, size, item.mime_type].filter(Boolean).join(' · ') || '媒体文件' },
|
||||
clearPreview() { this.previewVersion++; this.previewOpen = false; this.previewLoading = false; this.previewURL = ''; this.previewTitle = ''; this.previewError = '' },
|
||||
revokeObjectURLs() { this.objectURLs.forEach(url => URL.revokeObjectURL(url)); this.objectURLs = [] },
|
||||
clearDetail() { this.detailVersion++; this.revokeObjectURLs(); this.clearPreview(); this.selected = null; this.inputMedia = []; this.imageOutputs = []; this.textOutputs = []; this.detailError = ''; this.detailLoading = false },
|
||||
handleResize() { this.viewportWidth = window.innerWidth },
|
||||
syncSelected() { const previous = this.selected; const state = reconcileGenerationDetail(this.rows, previous, this.detailOpen); this.selected = state.selected && previous ? { ...previous, ...state.selected } : state.selected; this.detailOpen = state.detailOpen },
|
||||
async load() { this.loading = true; try { this.rows = dataOf(await listGenerations()); this.syncSelected() } finally { this.loading = false } },
|
||||
reset() { this.keyword = ''; this.status = '' },
|
||||
statusText(value) { return ({ pending: '等待中', running: '处理中', succeeded: '成功', failed: '失败' })[value] || value },
|
||||
statusType(value) { return ({ pending: 'info', running: 'warning', succeeded: 'success', failed: 'danger' })[value] || 'info' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use '../shared.scss';
|
||||
.detail-head { display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--el-border-color-light); padding-bottom: 10px; }
|
||||
.detail-head h2 { margin: 0; font-size: 16px; }.generation-detail h3 { font-size: 15px; margin: 18px 0 12px; }.prompt-collapse { margin-top: 14px; }.prompt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; margin: 0; font-family: inherit; }.numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }.user-name { display: block; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }.media-item { min-width: 0; border: 1px solid var(--el-border-color-light); border-radius: 6px; overflow: hidden; background: var(--el-bg-color); }.image-button { display: block; width: 100%; aspect-ratio: 1 / 1; padding: 0; border: 0; background: var(--el-fill-color-light); cursor: zoom-in; }.image-button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -2px; }.image-button img { display: block; width: 100%; height: 100%; object-fit: contain; }.media-placeholder { display: grid; place-items: center; aspect-ratio: 1 / 1; padding: 12px; color: var(--el-text-color-secondary); text-align: center; background: var(--el-fill-color-light); }.media-meta { display: grid; gap: 4px; padding: 9px 10px; }.media-meta strong { overflow-wrap: anywhere; }.media-meta span { color: var(--el-text-color-secondary); font-size: 12px; overflow-wrap: anywhere; }.text-results { display: grid; gap: 10px; margin-top: 12px; }.text-results article { border-left: 3px solid var(--el-border-color); padding: 8px 10px; background: var(--el-fill-color-lighter); }.text-results pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: inherit; }.preview-stage { min-height: 180px; display: grid; place-items: center; }.preview-stage img { display: block; max-width: 100%; max-height: 75vh; object-fit: contain; }
|
||||
@media (max-width: 520px) { .media-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import request from '@/utils/request'
|
||||
import { getGeneration, getGenerationMedia } from '@/api/chorus'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Chorus Admin generation media', () => {
|
||||
beforeEach(() => request.mockClear())
|
||||
|
||||
test('loads detail and media through authenticated API requests', () => {
|
||||
getGeneration(9)
|
||||
expect(request).toHaveBeenCalledWith({ url: '/api/v1/chorus/generations/9', method: 'get' })
|
||||
getGenerationMedia('/api/v1/chorus/generations/9/outputs/12/thumbnail')
|
||||
expect(request).toHaveBeenCalledWith({ url: '/api/v1/chorus/generations/9/outputs/12/thumbnail', method: 'get', responseType: 'blob' })
|
||||
})
|
||||
|
||||
test('uses blob URLs and explicit loading, empty, and error states', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '../../../src/views/chorus/generations/index.vue'), 'utf8')
|
||||
expect(source).toContain('URL.createObjectURL(blob)')
|
||||
expect(source).toContain('URL.revokeObjectURL')
|
||||
expect(source).toContain('提示词原图')
|
||||
expect(source).toContain('本任务没有提示词原图')
|
||||
expect(source).toContain('本任务尚无生成结果')
|
||||
expect(source).toContain('详情加载失败')
|
||||
expect(source).not.toContain('Admin-Token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
package chorus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type testMediaObject struct {
|
||||
data []byte
|
||||
object corestorage.Object
|
||||
}
|
||||
|
||||
type testMediaReader map[string]testMediaObject
|
||||
|
||||
func (r testMediaReader) Open(_ context.Context, key string) (io.ReadCloser, corestorage.Object, error) {
|
||||
item, ok := r[key]
|
||||
if !ok {
|
||||
return nil, corestorage.Object{}, errors.New("missing synthetic object")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(item.data)), item.object, nil
|
||||
}
|
||||
|
||||
func newGenerationMediaService(t *testing.T) (*Service, testMediaReader) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, status TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`,
|
||||
`CREATE TABLE generations (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, provider_model_id INTEGER, route_pool_id INTEGER, route_pool_version INTEGER, prompt_template_id INTEGER, route_snapshot JSON, role_rule TEXT, provider_attempt_count INTEGER NOT NULL, kind TEXT NOT NULL, status TEXT NOT NULL, idempotency_key TEXT NOT NULL, user_prompt TEXT NOT NULL, rendered_prompt TEXT NOT NULL, attempts JSON NOT NULL, attempt_count INTEGER NOT NULL, error_code TEXT, error_message TEXT, lease_owner TEXT, lease_token TEXT, lease_until DATETIME, available_at DATETIME NOT NULL, started_at DATETIME, completed_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`,
|
||||
`CREATE TABLE generation_inputs (id INTEGER PRIMARY KEY, generation_id INTEGER NOT NULL, position INTEGER NOT NULL, role TEXT NOT NULL, note TEXT, original_name TEXT NOT NULL, mime_type TEXT NOT NULL, storage_key TEXT NOT NULL, size_bytes INTEGER NOT NULL, width INTEGER, height INTEGER, created_at DATETIME NOT NULL)`,
|
||||
`CREATE TABLE generation_outputs (id INTEGER PRIMARY KEY, generation_id INTEGER NOT NULL, kind TEXT NOT NULL, text_content TEXT, storage_key TEXT, thumbnail_storage_key TEXT, mime_type TEXT, size_bytes INTEGER, width INTEGER, height INTEGER, created_at DATETIME NOT NULL)`,
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
user := model.User{ID: 7, Username: "synthetic-user", Email: "synthetic@example.invalid", PasswordHash: "unused", DisplayName: "Synthetic", Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||
generation := model.Generation{ID: 9, UserID: user.ID, Kind: model.GenerationKind("image"), Status: model.GenerationStatus("succeeded"), IdempotencyKey: "synthetic-generation", UserPrompt: "synthetic prompt", RenderedPrompt: "rendered synthetic prompt", Attempts: json.RawMessage(`[]`), AvailableAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
note := "reference"
|
||||
input := model.GenerationInput{ID: 11, GenerationID: generation.ID, Role: model.InputRole("reference"), Note: ¬e, OriginalName: "reference.png", MIMEType: "image/png", StorageKey: "private/input-key", SizeBytes: 5, CreatedAt: now}
|
||||
outputKey, thumbnailKey, mimeType, textOutput := "private/output-key", "private/thumbnail-key", "image/png", "synthetic text result"
|
||||
size := uint64(6)
|
||||
outputs := []model.GenerationOutput{
|
||||
{ID: 12, GenerationID: generation.ID, Kind: model.GenerationKind("image"), StorageKey: &outputKey, ThumbnailStorageKey: &thumbnailKey, MIMEType: &mimeType, SizeBytes: &size, CreatedAt: now},
|
||||
{ID: 13, GenerationID: generation.ID, Kind: model.GenerationKind("text"), TextContent: &textOutput, CreatedAt: now},
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&generation).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&input).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&outputs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := testMediaReader{
|
||||
input.StorageKey: {data: []byte("input"), object: corestorage.Object{Key: input.StorageKey, OwnerID: user.ID, GenerationID: generation.ID, ContentType: input.MIMEType, Size: 5}},
|
||||
outputKey: {data: []byte("output"), object: corestorage.Object{Key: outputKey, OwnerID: user.ID, GenerationID: generation.ID, ContentType: mimeType, Size: 6}},
|
||||
thumbnailKey: {data: []byte("thumb"), object: corestorage.Object{Key: thumbnailKey, OwnerID: user.ID, GenerationID: generation.ID, ContentType: mimeType, Size: 5}},
|
||||
}
|
||||
service, err := NewService(db, Config{Storage: reader})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, reader
|
||||
}
|
||||
|
||||
func TestGenerationDetailExposesMediaURLsWithoutStorageKeys(t *testing.T) {
|
||||
service, _ := newGenerationMediaService(t)
|
||||
detail, err := service.Generation(context.Background(), 9)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(detail.Inputs) != 1 || detail.Inputs[0].URL != "/api/v1/chorus/generations/9/inputs/11" {
|
||||
t.Fatalf("inputs=%#v", detail.Inputs)
|
||||
}
|
||||
if len(detail.Outputs) != 2 || detail.Outputs[0].ThumbnailURL == "" || detail.Outputs[1].Text == nil {
|
||||
t.Fatalf("outputs=%#v", detail.Outputs)
|
||||
}
|
||||
payload, err := json.Marshal(detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{"private/input-key", "private/output-key", "private/thumbnail-key"} {
|
||||
if bytes.Contains(payload, []byte(secret)) {
|
||||
t.Fatalf("detail leaked storage key %q: %s", secret, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationMediaRequiresMatchingDatabaseAndObjectOwnership(t *testing.T) {
|
||||
service, reader := newGenerationMediaService(t)
|
||||
input, object, name, err := service.OpenGenerationInput(context.Background(), 9, 11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := io.ReadAll(input)
|
||||
input.Close()
|
||||
if string(data) != "input" || object.OwnerID != 7 || name != "reference.png" {
|
||||
t.Fatalf("input=%q object=%#v name=%q", data, object, name)
|
||||
}
|
||||
output, _, err := service.OpenGenerationOutput(context.Background(), 9, 12, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ = io.ReadAll(output)
|
||||
output.Close()
|
||||
if string(data) != "thumb" {
|
||||
t.Fatalf("thumbnail=%q", data)
|
||||
}
|
||||
if _, _, _, err := service.OpenGenerationInput(context.Background(), 10, 11); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("cross-generation input error=%v", err)
|
||||
}
|
||||
item := reader["private/output-key"]
|
||||
item.object.OwnerID = 99
|
||||
reader["private/output-key"] = item
|
||||
if _, _, err := service.OpenGenerationOutput(context.Background(), 9, 12, false); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("mismatched owner error=%v", err)
|
||||
}
|
||||
item = reader["private/thumbnail-key"]
|
||||
item.object.ContentType = "text/html"
|
||||
reader["private/thumbnail-key"] = item
|
||||
if _, _, err := service.OpenGenerationOutput(context.Background(), 9, 12, true); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("unsafe thumbnail MIME error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationMediaRouteStreamsAuthorizedObjectWithSafeHeaders(t *testing.T) {
|
||||
service, _ := newGenerationMediaService(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
authentication := func(c *gin.Context) {
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.IdentityKey: float64(7)})
|
||||
c.Next()
|
||||
}
|
||||
RegisterWithService(engine.Group("/api/v1"), authentication, func(c *gin.Context) { c.Next() }, service)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/chorus/generations/9/inputs/11", nil)
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || response.Body.String() != "input" {
|
||||
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"Cache-Control": "no-store", "Pragma": "no-cache", "X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'", "Content-Type": "image/png",
|
||||
} {
|
||||
if got := response.Header().Get(key); got != want {
|
||||
t.Errorf("%s=%q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(response.Header().Get("Content-Disposition"), "reference.png") {
|
||||
t.Fatalf("Content-Disposition=%q", response.Header().Get("Content-Disposition"))
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package chorus
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/admin/common/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -128,6 +132,13 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc
|
||||
r.GET("/generations", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) {
|
||||
return service.Generations(c.Request.Context())
|
||||
}))
|
||||
r.GET("/generations/:id", withID(factory, func(c *gin.Context, service *Service, actor uint64, requestID string, id uint64) (any, error) {
|
||||
adminNoStore(c)
|
||||
return service.Generation(c.Request.Context(), id)
|
||||
}))
|
||||
r.GET("/generations/:id/inputs/:inputID", generationMedia(factory, "input"))
|
||||
r.GET("/generations/:id/outputs/:outputID", generationMedia(factory, "output"))
|
||||
r.GET("/generations/:id/outputs/:outputID/thumbnail", generationMedia(factory, "thumbnail"))
|
||||
r.GET("/api-keys", withService(factory, func(c *gin.Context, service *Service, actor uint64, requestID string) (any, error) {
|
||||
adminNoStore(c)
|
||||
filter, err := apiKeyFilter(c)
|
||||
@@ -146,6 +157,63 @@ func register(v1 *gin.RouterGroup, authentication, authorization gin.HandlerFunc
|
||||
}))
|
||||
}
|
||||
|
||||
func generationMedia(factory serviceFactory, resource string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
service, _, _, ok := requestScope(c, factory)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
generationID, err := pathID(c, "id")
|
||||
if err != nil {
|
||||
respond(c, nil, err)
|
||||
return
|
||||
}
|
||||
resourceName := "outputID"
|
||||
if resource == "input" {
|
||||
resourceName = "inputID"
|
||||
}
|
||||
resourceID, err := pathID(c, resourceName)
|
||||
if err != nil {
|
||||
respond(c, nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
adminNoStore(c)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'")
|
||||
if resource == "input" {
|
||||
reader, object, name, openErr := service.OpenGenerationInput(c.Request.Context(), generationID, resourceID)
|
||||
if openErr != nil {
|
||||
respond(c, nil, openErr)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
filename := path.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
if filename == "." || filename == "/" || filename == "" {
|
||||
filename = "input"
|
||||
}
|
||||
c.Header("Content-Type", object.ContentType)
|
||||
c.Header("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filename}))
|
||||
c.Header("Content-Length", strconv.FormatInt(object.Size, 10))
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, reader)
|
||||
return
|
||||
}
|
||||
|
||||
reader, object, openErr := service.OpenGenerationOutput(c.Request.Context(), generationID, resourceID, resource == "thumbnail")
|
||||
if openErr != nil {
|
||||
respond(c, nil, openErr)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
c.Header("Content-Type", object.ContentType)
|
||||
c.Header("Content-Disposition", "inline")
|
||||
c.Header("Content-Length", strconv.FormatInt(object.Size, 10))
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = io.Copy(c.Writer, reader)
|
||||
}
|
||||
}
|
||||
|
||||
func apiKeyFilter(c *gin.Context) (APIKeyFilter, error) {
|
||||
filter := APIKeyFilter{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: 1, PageSize: 20}
|
||||
var err error
|
||||
|
||||
@@ -39,6 +39,10 @@ func TestChorusRoutesRequireAuthenticationAuthorizationAndHaveNoDeleteEndpoint(t
|
||||
{name: "API keys unauthenticated", method: http.MethodGet, path: "/api/v1/chorus/api-keys", want: http.StatusUnauthorized},
|
||||
{name: "API keys not authorized", method: http.MethodGet, path: "/api/v1/chorus/api-keys", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "API key revoke not authorized", method: http.MethodPost, path: "/api/v1/chorus/api-keys/1/revoke", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "generation detail unauthenticated", method: http.MethodGet, path: "/api/v1/chorus/generations/1", want: http.StatusUnauthorized},
|
||||
{name: "generation input not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/inputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "generation output not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "generation thumbnail not authorized", method: http.MethodGet, path: "/api/v1/chorus/generations/1/outputs/1/thumbnail", headers: map[string]string{"Authorization": "Bearer test"}, want: http.StatusForbidden},
|
||||
{name: "delete is not exposed", method: http.MethodDelete, path: "/api/v1/chorus/providers/1", headers: map[string]string{"Authorization": "Bearer test", "X-Chorus-Role": "operator"}, want: http.StatusNotFound},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
+171
-1
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/provider"
|
||||
corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -46,6 +48,7 @@ type Config struct {
|
||||
MaxResponseBytes int64
|
||||
Now func() time.Time
|
||||
Probe ProbeRunner
|
||||
Storage corestorage.Reader
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -55,6 +58,7 @@ type Service struct {
|
||||
maxResponseBytes int64
|
||||
now func() time.Time
|
||||
probe ProbeRunner
|
||||
storage corestorage.Reader
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, cfg Config) (*Service, error) {
|
||||
@@ -70,7 +74,7 @@ func NewService(db *gorm.DB, cfg Config) (*Service, error) {
|
||||
return &Service{
|
||||
db: db, allowConnectivityChecks: cfg.AllowConnectivityChecks,
|
||||
connectivityCooldown: cfg.ConnectivityCooldown, maxResponseBytes: cfg.MaxResponseBytes,
|
||||
now: cfg.Now, probe: cfg.Probe,
|
||||
now: cfg.Now, probe: cfg.Probe, storage: cfg.Storage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -816,6 +820,172 @@ func (s *Service) Generations(ctx context.Context) ([]GenerationView, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Generation(ctx context.Context, id uint64) (GenerationDetailView, error) {
|
||||
var row generationRow
|
||||
if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil {
|
||||
return GenerationDetailView{}, translateNotFound(err)
|
||||
}
|
||||
item, err := s.generationView(ctx, row)
|
||||
if err != nil {
|
||||
return GenerationDetailView{}, err
|
||||
}
|
||||
|
||||
var inputRows []model.GenerationInput
|
||||
if err := s.db.WithContext(ctx).Where("generation_id = ?", id).Order("position, id").Find(&inputRows).Error; err != nil {
|
||||
return GenerationDetailView{}, fmt.Errorf("list generation inputs: %w", err)
|
||||
}
|
||||
inputs := make([]GenerationInputView, 0, len(inputRows))
|
||||
for _, input := range inputRows {
|
||||
inputs = append(inputs, GenerationInputView{
|
||||
ID: input.ID, Position: input.Position, Role: input.Role, Note: input.Note, Name: input.OriginalName,
|
||||
MIMEType: input.MIMEType, SizeBytes: input.SizeBytes, Width: input.Width, Height: input.Height,
|
||||
URL: fmt.Sprintf("/api/v1/chorus/generations/%d/inputs/%d", id, input.ID),
|
||||
})
|
||||
}
|
||||
|
||||
var outputRows []model.GenerationOutput
|
||||
if err := s.db.WithContext(ctx).Where("generation_id = ?", id).Order("id").Find(&outputRows).Error; err != nil {
|
||||
return GenerationDetailView{}, fmt.Errorf("list generation outputs: %w", err)
|
||||
}
|
||||
outputs := make([]GenerationOutputView, 0, len(outputRows))
|
||||
for _, output := range outputRows {
|
||||
view := GenerationOutputView{
|
||||
ID: output.ID, Kind: output.Kind, Text: output.TextContent, MIMEType: output.MIMEType,
|
||||
SizeBytes: output.SizeBytes, Width: output.Width, Height: output.Height, CreatedAt: output.CreatedAt,
|
||||
}
|
||||
if output.StorageKey != nil {
|
||||
view.URL = fmt.Sprintf("/api/v1/chorus/generations/%d/outputs/%d", id, output.ID)
|
||||
}
|
||||
if output.ThumbnailStorageKey != nil {
|
||||
view.ThumbnailURL = fmt.Sprintf("/api/v1/chorus/generations/%d/outputs/%d/thumbnail", id, output.ID)
|
||||
}
|
||||
outputs = append(outputs, view)
|
||||
}
|
||||
return GenerationDetailView{GenerationView: item, Inputs: inputs, Outputs: outputs}, nil
|
||||
}
|
||||
|
||||
func (s *Service) generationView(ctx context.Context, row generationRow) (GenerationView, error) {
|
||||
var user portalUserRow
|
||||
if err := s.db.WithContext(ctx).First(&user, row.UserID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return GenerationView{}, fmt.Errorf("get generation user: %w", err)
|
||||
}
|
||||
attempts := parseGenerationAttempts(row.Attempts)
|
||||
models := make(map[uint64]providerModelRow)
|
||||
providers := make(map[uint64]providerRow)
|
||||
modelIDs := make([]uint64, 0, len(attempts))
|
||||
seen := make(map[uint64]struct{}, len(attempts))
|
||||
for _, attempt := range attempts {
|
||||
if attempt.ProviderModelID != 0 {
|
||||
if _, ok := seen[attempt.ProviderModelID]; !ok {
|
||||
seen[attempt.ProviderModelID] = struct{}{}
|
||||
modelIDs = append(modelIDs, attempt.ProviderModelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(modelIDs) > 0 {
|
||||
var modelRows []providerModelRow
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", modelIDs).Find(&modelRows).Error; err != nil {
|
||||
return GenerationView{}, fmt.Errorf("get generation provider models: %w", err)
|
||||
}
|
||||
providerIDs := make([]uint64, 0, len(modelRows))
|
||||
for _, modelRow := range modelRows {
|
||||
models[modelRow.ID] = modelRow
|
||||
providerIDs = append(providerIDs, modelRow.ProviderID)
|
||||
}
|
||||
var providerRows []providerRow
|
||||
if len(providerIDs) > 0 {
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", providerIDs).Find(&providerRows).Error; err != nil {
|
||||
return GenerationView{}, fmt.Errorf("get generation providers: %w", err)
|
||||
}
|
||||
for _, providerRow := range providerRows {
|
||||
providers[providerRow.ID] = providerRow
|
||||
}
|
||||
}
|
||||
}
|
||||
username := user.Username
|
||||
if username == "" {
|
||||
username = fmt.Sprintf("user_%d", row.UserID)
|
||||
}
|
||||
item := GenerationView{
|
||||
ID: row.ID, UserID: row.UserID, Username: username, DisplayName: user.DisplayName, Status: row.Status,
|
||||
Kind: row.Kind, RenderedPrompt: truncate(row.RenderedPrompt, 1024), Attempts: enrichGenerationAttempts(attempts, models, providers),
|
||||
ProviderAttemptCnt: row.ProviderAttemptCount, CreatedAt: row.CreatedAt, StartedAt: row.StartedAt, CompletedAt: row.CompletedAt,
|
||||
}
|
||||
if row.ErrorCode != nil {
|
||||
item.ErrorCode = *row.ErrorCode
|
||||
}
|
||||
if row.ErrorMessage != nil {
|
||||
item.ErrorMessage = truncate(*row.ErrorMessage, 256)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) OpenGenerationInput(ctx context.Context, generationID, inputID uint64) (io.ReadCloser, corestorage.Object, string, error) {
|
||||
if s.storage == nil {
|
||||
return nil, corestorage.Object{}, "", ErrNotFound
|
||||
}
|
||||
var generation generationRow
|
||||
if err := s.db.WithContext(ctx).Select("id", "user_id").First(&generation, generationID).Error; err != nil {
|
||||
return nil, corestorage.Object{}, "", translateNotFound(err)
|
||||
}
|
||||
var input model.GenerationInput
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND generation_id = ?", inputID, generationID).First(&input).Error; err != nil {
|
||||
return nil, corestorage.Object{}, "", translateNotFound(err)
|
||||
}
|
||||
reader, object, err := s.storage.Open(ctx, input.StorageKey)
|
||||
if err != nil {
|
||||
return nil, corestorage.Object{}, "", ErrNotFound
|
||||
}
|
||||
if object.OwnerID != generation.UserID || object.GenerationID != generationID || !strings.EqualFold(object.ContentType, input.MIMEType) || !isSafeAdminImageMIME(object.ContentType) {
|
||||
reader.Close()
|
||||
return nil, corestorage.Object{}, "", ErrNotFound
|
||||
}
|
||||
return reader, object, input.OriginalName, nil
|
||||
}
|
||||
|
||||
func (s *Service) OpenGenerationOutput(ctx context.Context, generationID, outputID uint64, thumbnail bool) (io.ReadCloser, corestorage.Object, error) {
|
||||
if s.storage == nil {
|
||||
return nil, corestorage.Object{}, ErrNotFound
|
||||
}
|
||||
var generation generationRow
|
||||
if err := s.db.WithContext(ctx).Select("id", "user_id").First(&generation, generationID).Error; err != nil {
|
||||
return nil, corestorage.Object{}, translateNotFound(err)
|
||||
}
|
||||
var output model.GenerationOutput
|
||||
if err := s.db.WithContext(ctx).Where("id = ? AND generation_id = ?", outputID, generationID).First(&output).Error; err != nil {
|
||||
return nil, corestorage.Object{}, translateNotFound(err)
|
||||
}
|
||||
key := output.StorageKey
|
||||
if thumbnail {
|
||||
key = output.ThumbnailStorageKey
|
||||
}
|
||||
if key == nil {
|
||||
return nil, corestorage.Object{}, ErrNotFound
|
||||
}
|
||||
reader, object, err := s.storage.Open(ctx, *key)
|
||||
if err != nil {
|
||||
return nil, corestorage.Object{}, ErrNotFound
|
||||
}
|
||||
if object.OwnerID != generation.UserID || object.GenerationID != generationID || !isSafeAdminImageMIME(object.ContentType) {
|
||||
reader.Close()
|
||||
return nil, corestorage.Object{}, ErrNotFound
|
||||
}
|
||||
if !thumbnail && (output.MIMEType == nil || !strings.EqualFold(object.ContentType, *output.MIMEType)) {
|
||||
reader.Close()
|
||||
return nil, corestorage.Object{}, ErrNotFound
|
||||
}
|
||||
return reader, object, nil
|
||||
}
|
||||
|
||||
func isSafeAdminImageMIME(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "image/png", "image/jpeg", "image/webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseGenerationAttempts(raw json.RawMessage) []model.Attempt {
|
||||
var attempts []model.Attempt
|
||||
if len(raw) == 0 || json.Unmarshal(raw, &attempts) != nil {
|
||||
|
||||
@@ -190,6 +190,38 @@ type GenerationView struct {
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
type GenerationDetailView struct {
|
||||
GenerationView
|
||||
Inputs []GenerationInputView `json:"inputs"`
|
||||
Outputs []GenerationOutputView `json:"outputs"`
|
||||
}
|
||||
|
||||
type GenerationInputView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Position uint32 `json:"position"`
|
||||
Role model.InputRole `json:"role"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
Name string `json:"name"`
|
||||
MIMEType string `json:"mime_type"`
|
||||
SizeBytes uint64 `json:"size_bytes"`
|
||||
Width *uint32 `json:"width,omitempty"`
|
||||
Height *uint32 `json:"height,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type GenerationOutputView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Kind model.GenerationKind `json:"kind"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
MIMEType *string `json:"mime_type,omitempty"`
|
||||
SizeBytes *uint64 `json:"size_bytes,omitempty"`
|
||||
Width *uint32 `json:"width,omitempty"`
|
||||
Height *uint32 `json:"height,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
}
|
||||
|
||||
type GenerationAttemptView struct {
|
||||
model.Attempt
|
||||
ProviderName string `json:"provider_name,omitempty"`
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
ext "git.ilapage.cn/OPC/chorus/admin/config"
|
||||
sharedconfig "git.ilapage.cn/OPC/chorus/internal/config"
|
||||
safehttp "git.ilapage.cn/OPC/chorus/internal/platform/http"
|
||||
platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
@@ -66,6 +67,11 @@ func setup(path string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mediaStorage, err := platformstorage.NewLocalReader(ext.ExtConfig.Chorus.StorageRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure read-only Chorus storage: %w", err)
|
||||
}
|
||||
serviceConfig.config.Storage = mediaStorage
|
||||
chorus.Configure(func(db *gorm.DB) (*chorus.Service, error) {
|
||||
return chorus.NewService(db, serviceConfig.config)
|
||||
})
|
||||
|
||||
+12
-5
@@ -3,14 +3,21 @@ package config
|
||||
var ExtConfig Extend
|
||||
|
||||
// Extend 扩展配置
|
||||
// extend:
|
||||
// demo:
|
||||
// name: demo-name
|
||||
//
|
||||
// extend:
|
||||
// demo:
|
||||
// name: demo-name
|
||||
//
|
||||
// 使用方法: config.ExtConfig......即可!!
|
||||
type Extend struct {
|
||||
AMap AMap // 这里配置对应配置文件的结构即可
|
||||
AMap AMap `yaml:"amap"`
|
||||
Chorus Chorus `yaml:"chorus"`
|
||||
}
|
||||
|
||||
type AMap struct {
|
||||
Key string
|
||||
Key string `yaml:"key"`
|
||||
}
|
||||
|
||||
type Chorus struct {
|
||||
StorageRoot string `yaml:"storage_root"`
|
||||
}
|
||||
|
||||
@@ -21,3 +21,7 @@ settings:
|
||||
source: user:password@tcp(127.0.0.1:3308)/chorus?charset=utf8mb4&parseTime=True&loc=Local
|
||||
cache:
|
||||
memory: ''
|
||||
extend:
|
||||
chorus:
|
||||
# Read-only access to the same protected storage root used by Portal.
|
||||
storage_root: ../var/storage
|
||||
|
||||
@@ -50,6 +50,7 @@ require (
|
||||
github.com/chanxuehong/rand v0.0.0-20211009035549-2f07823e8e99 // indirect
|
||||
github.com/chanxuehong/wechat v0.0.0-20230222024006-36f0325263cd // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
|
||||
@@ -105,6 +105,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
@@ -577,6 +579,7 @@ golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xi
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
|
||||
@@ -21,8 +21,12 @@ type PutRequest struct {
|
||||
Source io.Reader
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Put(ctx context.Context, request PutRequest) (Object, error)
|
||||
type Reader interface {
|
||||
Open(ctx context.Context, key string) (io.ReadCloser, Object, error)
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Reader
|
||||
Put(ctx context.Context, request PutRequest) (Object, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
@@ -99,6 +99,26 @@ func NewLocal(config Config) (*Local, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewLocalReader opens an existing local store without enabling its write path.
|
||||
func NewLocalReader(root string) (*Local, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, fmt.Errorf("local storage root is required")
|
||||
}
|
||||
resolved, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve storage root: %w", err)
|
||||
}
|
||||
resolved, err = filepath.EvalSymlinks(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve storage root links: %w", err)
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("local storage root is unavailable")
|
||||
}
|
||||
return &Local{root: resolved}, nil
|
||||
}
|
||||
|
||||
func (s *Local) Put(ctx context.Context, request corestorage.PutRequest) (object corestorage.Object, err error) {
|
||||
if request.OwnerID == 0 || request.GenerationID == 0 || strings.TrimSpace(request.ContentType) == "" || request.Source == nil {
|
||||
return corestorage.Object{}, ErrInvalidMetadata
|
||||
|
||||
@@ -75,6 +75,45 @@ func TestLocalPutOpenAtomicMetadataAndTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLocalReaderOpensExistingObjectsWithoutWriteSetup(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store, err := NewLocal(Config{
|
||||
Root: root, MaxObjectBytes: 1024, MaxImagePixels: 1_000_000, ThumbnailMaxSide: 256,
|
||||
AllowedImageMIME: map[string]bool{"image/png": true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
object, err := store.Put(context.Background(), corestorage.PutRequest{
|
||||
Key: "users/7/generations/9/input", OwnerID: 7, GenerationID: 9,
|
||||
ContentType: "text/plain", Source: bytes.NewBufferString("read-only object"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
readerStore, err := NewLocalReader(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, metadata, err := readerStore.Open(context.Background(), object.Key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "read-only object" || metadata.OwnerID != 7 || metadata.GenerationID != 9 {
|
||||
t.Fatalf("data=%q metadata=%#v", data, metadata)
|
||||
}
|
||||
|
||||
if _, err := NewLocalReader(filepath.Join(root, "missing")); err == nil {
|
||||
t.Fatal("NewLocalReader accepted a missing root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFailureCleansTemporaryFiles(t *testing.T) {
|
||||
store := newTestStore(t, 4, 1_000_000)
|
||||
_, err := store.Put(context.Background(), corestorage.PutRequest{
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
DELETE casbin
|
||||
FROM sys_casbin_rule casbin
|
||||
JOIN sys_api api
|
||||
ON casbin.ptype = 'p'
|
||||
AND casbin.v0 = 'chorus_operator'
|
||||
AND casbin.v1 = api.path
|
||||
AND casbin.v2 = api.action
|
||||
WHERE api.handle IN (
|
||||
'chorus.generations.get',
|
||||
'chorus.generations.input.read',
|
||||
'chorus.generations.output.read',
|
||||
'chorus.generations.output.thumbnail'
|
||||
);
|
||||
|
||||
DELETE menu_api
|
||||
FROM sys_menu_api_rule menu_api
|
||||
JOIN sys_api api ON api.id = menu_api.sys_api_id
|
||||
JOIN sys_menu menu ON menu.menu_id = menu_api.menu_id
|
||||
WHERE menu.path = '/chorus/generations'
|
||||
AND api.handle IN (
|
||||
'chorus.generations.get',
|
||||
'chorus.generations.input.read',
|
||||
'chorus.generations.output.read',
|
||||
'chorus.generations.output.thumbnail'
|
||||
);
|
||||
|
||||
DELETE FROM sys_api
|
||||
WHERE handle IN (
|
||||
'chorus.generations.get',
|
||||
'chorus.generations.input.read',
|
||||
'chorus.generations.output.read',
|
||||
'chorus.generations.output.thumbnail'
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
INSERT INTO sys_api (handle, title, path, type, action)
|
||||
VALUES
|
||||
('chorus.generations.get', 'Get generation detail', '/api/v1/chorus/generations/:id', 'BUS', 'GET'),
|
||||
('chorus.generations.input.read', 'Read generation input', '/api/v1/chorus/generations/:id/inputs/:inputID', 'BUS', 'GET'),
|
||||
('chorus.generations.output.read', 'Read generation output', '/api/v1/chorus/generations/:id/outputs/:outputID', 'BUS', 'GET'),
|
||||
('chorus.generations.output.thumbnail', 'Read generation output thumbnail', '/api/v1/chorus/generations/:id/outputs/:outputID/thumbnail', 'BUS', 'GET')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title), type = VALUES(type), deleted_at = NULL;
|
||||
|
||||
INSERT IGNORE INTO sys_menu_api_rule (menu_id, sys_api_id)
|
||||
SELECT menu.menu_id, api.id
|
||||
FROM sys_menu menu
|
||||
JOIN sys_api api ON api.handle IN (
|
||||
'chorus.generations.get',
|
||||
'chorus.generations.input.read',
|
||||
'chorus.generations.output.read',
|
||||
'chorus.generations.output.thumbnail'
|
||||
)
|
||||
WHERE menu.path = '/chorus/generations';
|
||||
|
||||
INSERT IGNORE INTO sys_casbin_rule (ptype, v0, v1, v2, v3, v4, v5)
|
||||
SELECT 'p', 'chorus_operator', path, action, '', '', ''
|
||||
FROM sys_api
|
||||
WHERE handle IN (
|
||||
'chorus.generations.get',
|
||||
'chorus.generations.input.read',
|
||||
'chorus.generations.output.read',
|
||||
'chorus.generations.output.thumbnail'
|
||||
);
|
||||
@@ -31,6 +31,7 @@ func TestMigrationPairsAndProductionTables(t *testing.T) {
|
||||
"000007_admin_navigation_localization.up.sql",
|
||||
"000008_portal_username_login.up.sql",
|
||||
"000009_admin_grouped_navigation.up.sql",
|
||||
"000010_admin_generation_media.up.sql",
|
||||
}
|
||||
slices.Sort(upFiles)
|
||||
if !slices.Equal(upFiles, wantFiles) {
|
||||
@@ -62,6 +63,30 @@ func TestMigrationPairsAndProductionTables(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGenerationMediaMigrationContracts(t *testing.T) {
|
||||
up, err := os.ReadFile("000010_admin_generation_media.up.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down, err := os.ReadFile("000010_admin_generation_media.down.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, required := range []string{
|
||||
"chorus.generations.get", "chorus.generations.input.read", "chorus.generations.output.read",
|
||||
"chorus.generations.output.thumbnail", "sys_menu_api_rule", "sys_casbin_rule", "/chorus/generations",
|
||||
} {
|
||||
if !strings.Contains(string(up), required) || !strings.Contains(string(down), required) {
|
||||
t.Errorf("generation media migration is missing reversible contract %s", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"generation_inputs", "generation_outputs", "storage_key", "AutoMigrate"} {
|
||||
if strings.Contains(string(up), forbidden) || strings.Contains(string(down), forbidden) {
|
||||
t.Errorf("generation media permission migration must not touch %s", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminGroupedNavigationMigrationContracts(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -155,7 +155,12 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.menus.%' AND action <> 'GET'`, 0)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.apis.%' AND action <> 'GET'`, 0)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle LIKE 'chorus.system.login-logs.%' AND action <> 'GET'`, 0)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_casbin_rule WHERE ptype = 'p' AND v0 = 'chorus_operator' AND v1 LIKE '/api/v1/chorus/generations/%' AND v2 = 'GET'`, 4)
|
||||
|
||||
runMigrate("down", "1")
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 0)
|
||||
assertGroupedAdminNavigation(t, ctx, db)
|
||||
runMigrate("down", "1")
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path = '/chorus'`, 1)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_menu WHERE path IN ('/chorus/configuration', '/chorus/monitoring', '/chorus/access', '/chorus/system')`, 0)
|
||||
@@ -199,6 +204,7 @@ func TestMigrationsUpDownUpMySQL(t *testing.T) {
|
||||
assertColumnExists(t, ctx, db, "users", "username", true)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM users WHERE id = 1 AND username = 'user_1'`, 1)
|
||||
assertGroupedAdminNavigation(t, ctx, db)
|
||||
assertCount(t, ctx, db, `SELECT COUNT(*) FROM sys_api WHERE handle IN ('chorus.generations.get', 'chorus.generations.input.read', 'chorus.generations.output.read', 'chorus.generations.output.thumbnail')`, 4)
|
||||
}
|
||||
|
||||
func assertGroupedAdminNavigation(t *testing.T, ctx context.Context, db *sql.DB) {
|
||||
|
||||
Reference in New Issue
Block a user