feat(gateway): 图片上传端点(B1) — POST /v1/upload + GET /v1/files/{name}
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- upload.go: multipart上传(owner/admin+审计)→ {url,size,mime,name}; MaxBytesReader 5MB + DetectContentType嗅探 + 白名单raster(拒SVG/HTML) + crypto/rand 128-bit随机名 + 0700目录
- 静态服务: 同端口 GET /v1/files/{name}, 能力URL, 路径穿越全形态拒绝(.. / %2f / %2e%2e), nosniff, Range
- errors: ErrPayloadTooLarge(413)/ErrUnsupportedMediaType(415)
- server.go 路由+Deps.DataDir; wire.go resolveDataDir 注入
- 8测试+真实二进制E2E, build/vet/test绿+交叉编译
- Docs/api-client v0.24 + api-reference §0.3 同步
This commit is contained in:
AskaEth committed 2026-09-08 21:36:03 +08:00
1 parent 3fc10fb86f
commit 7ecb43bd5d
7 files changed
+723 -3

No files matched your search

+19
View File
@@ -276,6 +276,11 @@ type Deps struct {
// It backs the admin backup/restore endpoints. Optional; when empty those
// endpoints answer honestly with a clear not-configured error.
DatabasePath string
// DataDir is the resolved data directory (the value resolveDataDir returns,
// which may differ from cfg.DataDir when the OS default applies). It backs
// the image-upload store (<DataDir>/uploads) and its static route. Optional;
// when empty the upload handlers fall back to cfg.DataDir.
DataDir string
// Streaming marks the injected Loop as streaming-capable (its OnStream is
// wired to publish ws.stream_chunk). When set the hub skips its own single
// final stream_chunk emission, since deltas already stream through OnStream.
@@ -316,6 +321,10 @@ type Server struct {
streaming bool
frontend fs.FS
dbPath string
// dataDir is the resolved data directory backing the upload store
// (<dataDir>/uploads). Injected by the composition root; cfg.DataDir is the
// fallback. See Deps.DataDir.
dataDir string
// llm is the hot-swappable provider stack (models.json). When nil the
// admin /llm endpoints fall back to the env config. It is optional so tests
// that do not wire it keep working.
@@ -364,6 +373,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
ownerRegistrar: deps.OwnerRegistrar,
webhooks: deps.Webhooks,
dbPath: deps.DatabasePath,
dataDir: deps.DataDir,
log: sourcedLog(deps.Log),
}
s.hub = newHub(s)
@@ -395,6 +405,14 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/audio/speech", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleSpeech)))
mux.Handle("POST /api/v1/audio/voice-clone", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.handleVoiceClone)))
// Image upload (B1): a multipart POST that stores a raster image under the
// resolved data dir and returns a same-origin URL usable directly as
// chat.image. Upload is owner/admin + audited; the static route below is a
// capability URL (unguessable name) because chat.image / <img> cannot carry a
// bearer token. Both ride the one gateway port (single-binary red line).
mux.Handle("POST /api/v1/upload", s.auth.Authorize(auth.RoleOwner, auth.RoleAdmin)(http.HandlerFunc(s.handleUpload)))
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
// Session/model reads and writes are the owner's private history; owner/admin only
// (chat uses the same roles). Session metadata in GET /sessions stays readAuth for
// any authenticated role, but the message content (and thinking) is owner/admin-only
@@ -480,6 +498,7 @@ func (s *Server) Handler() http.Handler {
}
var h http.Handler = mux
h = guardUploadPaths(h)
if s.frontend != nil {
h = s.spa(h)
}