mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 17:44:30 -04:00
fix(localai-proxy): wrap bare base64 image inputs, block unreachable Depth exports, and validate download URLs
Detect/Depth/FaceVerify/FaceAnalyze forwarded the bare base64 core hands the backend, but the upstream's own REST handlers only accept a URL or a data:...;base64, string, so every real call 400'd. Wrap the payload as a data URI (sniffing its MIME type) before sending it. Depth requests for exports/dst now return Unimplemented: those files are written to the upstream's own local disk and are unreachable from here, so failover should move to a local target instead. Generation replies that hand back a URL are now re-fetched by path only, checked against the upstream's known generated-content prefixes, instead of stripping the configured base as a literal string prefix — the old approach broke (or silently trusted an arbitrary host) the moment the upstream advertised a different base via LOCALAI_BASE_URL, a reverse proxy, or X-Forwarded-Host. Assisted-by: Claude:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
be9c039e65
commit
37023939ad
2 files changed
+265
-95
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -32,6 +33,30 @@ func fileToBase64(path string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
// toDataURI wraps a base64 payload as a data URI. Detect/Depth/FaceVerify/
|
||||
// FaceAnalyze's REST endpoints decode their image field with
|
||||
// utils.GetContentURIAsBase64, which only accepts an http(s) URL or a
|
||||
// `data:<mime>;base64,<payload>` string — never bare base64. That is exactly
|
||||
// what core hands the backend for these methods (see
|
||||
// core/http/endpoints/localai/images.go's decodeImageInput, which already
|
||||
// stripped any data: prefix off before we ever see it), so forwarding it
|
||||
// unwrapped 400s on every real call. The MIME type isn't carried alongside
|
||||
// the payload, so it's sniffed from the decoded bytes.
|
||||
func toDataURI(b64 string) (string, error) {
|
||||
if b64 == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return "", status.Errorf(codes.InvalidArgument, "localai-proxy: decode base64 image: %v", err)
|
||||
}
|
||||
mime := http.DetectContentType(data)
|
||||
if mime == "application/octet-stream" {
|
||||
mime = "image/png"
|
||||
}
|
||||
return "data:" + mime + ";base64," + b64, nil
|
||||
}
|
||||
|
||||
// genItem is one schema.Item as the image/video/3D generation endpoints
|
||||
// return it: either inline base64 data, or a URL to download the asset from.
|
||||
type genItem struct {
|
||||
@@ -66,20 +91,42 @@ func (p *LocalAIProxy) writeGenItem(ctx context.Context, path string, items []ge
|
||||
if item.URL == "" {
|
||||
return status.Errorf(codes.Internal, "localai-proxy: upstream %s returned neither b64_json nor url", path)
|
||||
}
|
||||
return p.getToFile(ctx, p.relativePath(item.URL), dst)
|
||||
rel, err := generatedContentPath(path, item.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.getToFile(ctx, rel, dst)
|
||||
}
|
||||
|
||||
// relativePath strips the configured upstream base from a URL the upstream
|
||||
// handed back (e.g. "<upstream>/generated-images/x.png"), so the follow-up
|
||||
// download goes through the normal request path instead of concatenating two
|
||||
// absolute URLs.
|
||||
func (p *LocalAIProxy) relativePath(raw string) string {
|
||||
if cfg := p.cfg.Load(); cfg != nil {
|
||||
if rel, ok := strings.CutPrefix(raw, cfg.base); ok {
|
||||
return rel
|
||||
// generatedContentPrefixes are the static paths a LocalAI instance serves
|
||||
// generated media under (core/http/app.go's e.Static calls). A generation
|
||||
// reply's URL is only ever safe to re-fetch through this proxy's own
|
||||
// authenticated client when it resolves to one of these.
|
||||
var generatedContentPrefixes = []string{"/generated-images/", "/generated-videos/", "/generated-audio/", "/generated-3d/"}
|
||||
|
||||
// generatedContentPath extracts the path to re-download raw from, ignoring
|
||||
// whatever host is in it. A literal-prefix strip of the configured upstream
|
||||
// base (the previous approach) breaks the moment the upstream advertises a
|
||||
// different host than the one this proxy is configured with — LOCALAI_BASE_URL,
|
||||
// a reverse proxy, or X-Forwarded-Host can all change it — so only the path is
|
||||
// trusted, and only when it is one this proxy's upstream is actually known to
|
||||
// serve generated media under; anything else could point anywhere, and taking
|
||||
// it on faith would let a compromised or misconfigured upstream make this
|
||||
// proxy fetch (with its bearer key) whatever URL it likes.
|
||||
func generatedContentPath(callPath, raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", status.Errorf(codes.Internal, "localai-proxy: upstream %s returned an invalid url %q: %v", callPath, raw, err)
|
||||
}
|
||||
for _, prefix := range generatedContentPrefixes {
|
||||
if strings.HasPrefix(u.Path, prefix) {
|
||||
if u.RawQuery != "" {
|
||||
return u.Path + "?" + u.RawQuery, nil
|
||||
}
|
||||
return u.Path, nil
|
||||
}
|
||||
}
|
||||
return raw
|
||||
return "", status.Errorf(codes.InvalidArgument, "localai-proxy: upstream %s returned an unexpected url %q", callPath, raw)
|
||||
}
|
||||
|
||||
// --- Images ---------------------------------------------------------
|
||||
@@ -335,13 +382,19 @@ type detectResponseBody struct {
|
||||
Detections []detectionBody `json:"detections"`
|
||||
}
|
||||
|
||||
// Detect posts Src, which core already carries as a base64 payload (the same
|
||||
// convention DetectionEndpoint uses to call this method locally), and maps
|
||||
// each detection, decoding its PNG mask.
|
||||
// Detect posts Src, which core already carries as a bare base64 payload (the
|
||||
// same convention DetectionEndpoint uses to call this method locally) — but
|
||||
// the upstream's own endpoint only accepts a URL or a data URI, so it is
|
||||
// re-wrapped as one (see toDataURI) — and maps each detection, decoding its
|
||||
// PNG mask.
|
||||
func (p *LocalAIProxy) Detect(req *pb.DetectOptions) (pb.DetectResponse, error) {
|
||||
image, err := toDataURI(req.GetSrc())
|
||||
if err != nil {
|
||||
return pb.DetectResponse{}, err
|
||||
}
|
||||
body := detectRequestBody{
|
||||
Model: p.model(""),
|
||||
Image: req.GetSrc(),
|
||||
Image: image,
|
||||
Prompt: req.GetPrompt(),
|
||||
Points: req.GetPoints(),
|
||||
Boxes: req.GetBoxes(),
|
||||
@@ -371,17 +424,18 @@ func (p *LocalAIProxy) Detect(req *pb.DetectOptions) (pb.DetectResponse, error)
|
||||
return pb.DetectResponse{Detections: detections}, nil
|
||||
}
|
||||
|
||||
// depthRequestBody has no Dst/Exports fields: Depth refuses those requests
|
||||
// before building the body (see the Depth doc comment), so they never reach
|
||||
// the upstream.
|
||||
type depthRequestBody struct {
|
||||
Model string `json:"model"`
|
||||
Image string `json:"image"`
|
||||
Dst string `json:"dst,omitempty"`
|
||||
IncludeDepth bool `json:"include_depth,omitempty"`
|
||||
IncludeConfidence bool `json:"include_confidence,omitempty"`
|
||||
IncludePose bool `json:"include_pose,omitempty"`
|
||||
IncludeSky bool `json:"include_sky,omitempty"`
|
||||
IncludePoints bool `json:"include_points,omitempty"`
|
||||
PointsConfThresh float32 `json:"points_conf_thresh,omitempty"`
|
||||
Exports []string `json:"exports,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Image string `json:"image"`
|
||||
IncludeDepth bool `json:"include_depth,omitempty"`
|
||||
IncludeConfidence bool `json:"include_confidence,omitempty"`
|
||||
IncludePose bool `json:"include_pose,omitempty"`
|
||||
IncludeSky bool `json:"include_sky,omitempty"`
|
||||
IncludePoints bool `json:"include_points,omitempty"`
|
||||
PointsConfThresh float32 `json:"points_conf_thresh,omitempty"`
|
||||
}
|
||||
|
||||
type depthResponseBody struct {
|
||||
@@ -399,20 +453,29 @@ type depthResponseBody struct {
|
||||
IsMetric bool `json:"is_metric"`
|
||||
}
|
||||
|
||||
// Depth posts Src (a base64 payload, per the same convention as Detect) and
|
||||
// maps the full response, decoding the point-cloud color bytes.
|
||||
// Depth posts Src (a bare base64 payload, per the same convention as Detect,
|
||||
// re-wrapped as a data URI via toDataURI) and maps the full response,
|
||||
// decoding the point-cloud color bytes. A request for exports or a dst
|
||||
// directory is refused: those would be written to the upstream's own local
|
||||
// disk, and ExportPaths would name files this proxy (and whatever asked it
|
||||
// for them) can never reach.
|
||||
func (p *LocalAIProxy) Depth(req *pb.DepthRequest) (pb.DepthResponse, error) {
|
||||
if req.GetDst() != "" || len(req.GetExports()) > 0 {
|
||||
return pb.DepthResponse{}, unimplemented("Depth exports (written to the upstream's own local disk, unreachable from here)")
|
||||
}
|
||||
image, err := toDataURI(req.GetSrc())
|
||||
if err != nil {
|
||||
return pb.DepthResponse{}, err
|
||||
}
|
||||
body := depthRequestBody{
|
||||
Model: p.model(""),
|
||||
Image: req.GetSrc(),
|
||||
Dst: req.GetDst(),
|
||||
Image: image,
|
||||
IncludeDepth: req.GetIncludeDepth(),
|
||||
IncludeConfidence: req.GetIncludeConfidence(),
|
||||
IncludePose: req.GetIncludePose(),
|
||||
IncludeSky: req.GetIncludeSky(),
|
||||
IncludePoints: req.GetIncludePoints(),
|
||||
PointsConfThresh: req.GetPointsConfThresh(),
|
||||
Exports: req.GetExports(),
|
||||
}
|
||||
var resp depthResponseBody
|
||||
if err := p.postJSON(context.Background(), "/v1/depth", body, &resp); err != nil {
|
||||
@@ -472,11 +535,21 @@ type faceVerifyResponseBody struct {
|
||||
Img2AntispoofScore *float32 `json:"img2_antispoof_score,omitempty"`
|
||||
}
|
||||
|
||||
// FaceVerify posts Img1/Img2, which core already carries as base64 (the same
|
||||
// convention FaceVerifyEndpoint uses to call this method locally).
|
||||
// FaceVerify posts Img1/Img2, which core already carries as bare base64 (the
|
||||
// same convention FaceVerifyEndpoint uses to call this method locally) —
|
||||
// re-wrapped as data URIs via toDataURI, since the upstream's own endpoint
|
||||
// only accepts a URL or a data URI.
|
||||
func (p *LocalAIProxy) FaceVerify(req *pb.FaceVerifyRequest) (pb.FaceVerifyResponse, error) {
|
||||
img1, err := toDataURI(req.GetImg1())
|
||||
if err != nil {
|
||||
return pb.FaceVerifyResponse{}, err
|
||||
}
|
||||
img2, err := toDataURI(req.GetImg2())
|
||||
if err != nil {
|
||||
return pb.FaceVerifyResponse{}, err
|
||||
}
|
||||
body := faceVerifyRequestBody{
|
||||
Model: p.model(""), Img1: req.GetImg1(), Img2: req.GetImg2(),
|
||||
Model: p.model(""), Img1: img1, Img2: img2,
|
||||
Threshold: req.GetThreshold(), AntiSpoofing: req.GetAntiSpoofing(),
|
||||
}
|
||||
var resp faceVerifyResponseBody
|
||||
@@ -536,10 +609,16 @@ type faceAnalyzeResponseBody struct {
|
||||
Faces []faceAnalysisBody `json:"faces"`
|
||||
}
|
||||
|
||||
// FaceAnalyze posts Img, which core already carries as base64.
|
||||
// FaceAnalyze posts Img, which core already carries as bare base64 —
|
||||
// re-wrapped as a data URI via toDataURI, since the upstream's own endpoint
|
||||
// only accepts a URL or a data URI.
|
||||
func (p *LocalAIProxy) FaceAnalyze(req *pb.FaceAnalyzeRequest) (pb.FaceAnalyzeResponse, error) {
|
||||
img, err := toDataURI(req.GetImg())
|
||||
if err != nil {
|
||||
return pb.FaceAnalyzeResponse{}, err
|
||||
}
|
||||
body := faceAnalyzeRequestBody{
|
||||
Model: p.model(""), Img: req.GetImg(), Actions: req.GetActions(), AntiSpoofing: req.GetAntiSpoofing(),
|
||||
Model: p.model(""), Img: img, Actions: req.GetActions(), AntiSpoofing: req.GetAntiSpoofing(),
|
||||
}
|
||||
var resp faceAnalyzeResponseBody
|
||||
if err := p.postJSON(context.Background(), "/v1/face/analyze", body, &resp); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"google.golang.org/grpc/codes"
|
||||
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
"github.com/mudler/LocalAI/pkg/utils"
|
||||
)
|
||||
|
||||
// b64 is the base64 encoding a spec expects the proxy to have produced from
|
||||
@@ -84,6 +86,38 @@ var _ = Describe("media methods", func() {
|
||||
Expect(codeOf(err)).To(Equal(codes.Unavailable))
|
||||
Expect(dst).NotTo(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("downloads a reply URL from the configured upstream even when its host does not match", func() {
|
||||
// A reverse proxy, LOCALAI_BASE_URL, or X-Forwarded-Host can all make
|
||||
// the upstream hand back a URL on a different host than the one this
|
||||
// proxy is configured with. Only the path should matter: the proxy
|
||||
// must still fetch it from cfg.base (this fake upstream), with its
|
||||
// own bearer key, never from the host named in the URL.
|
||||
p := loadProxy(up, nil)
|
||||
up.replyJSON("/v1/images/generations", map[string]any{
|
||||
"data": []map[string]any{{"url": "http://mismatched-host.invalid:1/generated-images/out.png"}},
|
||||
})
|
||||
up.script("/generated-images/out.png", scriptedResponse{Status: http.StatusOK, ContentType: "image/png", Body: "downloaded-bytes"})
|
||||
dst := filepath.Join(GinkgoT().TempDir(), "out.png")
|
||||
|
||||
Expect(p.GenerateImage(&pb.GenerateImageRequest{PositivePrompt: "a cat", Dst: dst})).To(Succeed())
|
||||
|
||||
got, err := os.ReadFile(dst)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(got)).To(Equal("downloaded-bytes"))
|
||||
})
|
||||
|
||||
It("refuses a reply URL whose path is not a known generated-content path", func() {
|
||||
p := loadProxy(up, nil)
|
||||
up.replyJSON("/v1/images/generations", map[string]any{
|
||||
"data": []map[string]any{{"url": up.URL + "/etc/passwd"}},
|
||||
})
|
||||
dst := filepath.Join(GinkgoT().TempDir(), "out.png")
|
||||
|
||||
err := p.GenerateImage(&pb.GenerateImageRequest{PositivePrompt: "a cat", Dst: dst})
|
||||
Expect(codeOf(err)).To(Equal(codes.InvalidArgument))
|
||||
Expect(dst).NotTo(BeAnExistingFile())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UpscaleImage", func() {
|
||||
@@ -211,105 +245,162 @@ var _ = Describe("media methods", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Detect", func() {
|
||||
It("posts the image and maps detections including the mask", func() {
|
||||
p := loadProxy(up, nil)
|
||||
mask := base64.StdEncoding.EncodeToString([]byte("png-mask"))
|
||||
up.replyJSON("/v1/detection", map[string]any{
|
||||
"detections": []map[string]any{
|
||||
{"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, "confidence": 0.9, "class_name": "cat", "mask": mask},
|
||||
},
|
||||
})
|
||||
// pngBytes is a minimal payload with a real PNG magic number, so
|
||||
// http.DetectContentType (which toDataURI uses) sniffs "image/png" the
|
||||
// way it would for a real image, and decodeAndCheckImage below can tell
|
||||
// this test wrote a valid data URI apart from one that merely echoes bare
|
||||
// base64.
|
||||
pngBytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("fake-png-body")...)
|
||||
|
||||
res, err := p.Detect(&pb.DetectOptions{Src: "base64-image-data", Prompt: "cat", Threshold: 0.5})
|
||||
// decodeAndCheckImage extracts field from an upstream request body and
|
||||
// decodes it exactly the way the real REST handlers do — with
|
||||
// utils.GetContentURIAsBase64 (core/http/endpoints/localai/images.go's
|
||||
// decodeImageInput calls the same function) — so a spec here fails the
|
||||
// same way a live upstream would if the proxy ever regressed to sending
|
||||
// bare base64, which that function rejects outright.
|
||||
decodeAndCheckImage := func(body map[string]any, field string, want []byte) {
|
||||
raw, _ := body[field].(string)
|
||||
decoded, err := utils.GetContentURIAsBase64(raw)
|
||||
ExpectWithOffset(1, err).NotTo(HaveOccurred(), "the real upstream would reject %s the same way", field)
|
||||
got, err := base64.StdEncoding.DecodeString(decoded)
|
||||
ExpectWithOffset(1, err).NotTo(HaveOccurred())
|
||||
ExpectWithOffset(1, got).To(Equal(want))
|
||||
}
|
||||
|
||||
Describe("Detect", func() {
|
||||
It("wraps the bare base64 image as a data URI the real upstream decoder accepts, and maps detections", func() {
|
||||
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
decodeAndCheckImage(body, "image", pngBytes)
|
||||
Expect(body["prompt"]).To(Equal("cat"))
|
||||
|
||||
mask := base64.StdEncoding.EncodeToString([]byte("png-mask"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"detections": []map[string]any{
|
||||
{"x": 1.0, "y": 2.0, "width": 3.0, "height": 4.0, "confidence": 0.9, "class_name": "cat", "mask": mask},
|
||||
},
|
||||
})
|
||||
})
|
||||
DeferCleanup(up.Close)
|
||||
p := loadProxy(up, nil)
|
||||
|
||||
res, err := p.Detect(&pb.DetectOptions{
|
||||
Src: base64.StdEncoding.EncodeToString(pngBytes), Prompt: "cat", Threshold: 0.5,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Detections).To(HaveLen(1))
|
||||
Expect(res.Detections[0].ClassName).To(Equal("cat"))
|
||||
Expect(res.Detections[0].Confidence).To(BeNumerically("~", 0.9, 1e-6))
|
||||
Expect(res.Detections[0].Mask).To(Equal([]byte("png-mask")))
|
||||
|
||||
req := up.last()
|
||||
Expect(req.Path).To(Equal("/v1/detection"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("image", "base64-image-data"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("prompt", "cat"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("threshold", BeNumerically("~", 0.5, 1e-6)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Depth", func() {
|
||||
It("posts the image and maps the full depth response", func() {
|
||||
p := loadProxy(up, nil)
|
||||
colors := base64.StdEncoding.EncodeToString([]byte("rgb"))
|
||||
up.replyJSON("/v1/depth", map[string]any{
|
||||
"width": 2, "height": 1, "depth": []float64{0.1, 0.2},
|
||||
"point_colors": colors, "is_metric": true,
|
||||
})
|
||||
It("wraps the bare base64 image as a data URI and maps the full depth response", func() {
|
||||
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
decodeAndCheckImage(body, "image", pngBytes)
|
||||
Expect(body["include_depth"]).To(Equal(true))
|
||||
|
||||
res, err := p.Depth(&pb.DepthRequest{Src: "base64-image-data", IncludeDepth: true})
|
||||
colors := base64.StdEncoding.EncodeToString([]byte("rgb"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"width": 2, "height": 1, "depth": []float64{0.1, 0.2},
|
||||
"point_colors": colors, "is_metric": true,
|
||||
})
|
||||
})
|
||||
DeferCleanup(up.Close)
|
||||
p := loadProxy(up, nil)
|
||||
|
||||
res, err := p.Depth(&pb.DepthRequest{Src: base64.StdEncoding.EncodeToString(pngBytes), IncludeDepth: true})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Width).To(Equal(int32(2)))
|
||||
Expect(res.Height).To(Equal(int32(1)))
|
||||
Expect(res.Depth).To(Equal([]float32{0.1, 0.2}))
|
||||
Expect(res.PointColors).To(Equal([]byte("rgb")))
|
||||
Expect(res.IsMetric).To(BeTrue())
|
||||
})
|
||||
|
||||
req := up.last()
|
||||
Expect(req.Path).To(Equal("/v1/depth"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("image", "base64-image-data"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("include_depth", true))
|
||||
It("refuses a request for exports or a dst directory without calling the upstream, so failover moves on", func() {
|
||||
p := loadProxy(up, nil)
|
||||
|
||||
_, exportsErr := p.Depth(&pb.DepthRequest{Src: "irrelevant", Exports: []string{"glb"}})
|
||||
Expect(codeOf(exportsErr)).To(Equal(codes.Unimplemented))
|
||||
|
||||
_, dstErr := p.Depth(&pb.DepthRequest{Src: "irrelevant", Dst: "/some/output/dir"})
|
||||
Expect(codeOf(dstErr)).To(Equal(codes.Unimplemented))
|
||||
|
||||
Expect(up.recorded()).To(BeEmpty(), "an exports/dst request must never reach the upstream")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FaceVerify", func() {
|
||||
It("posts both images and maps the response, including liveness fields", func() {
|
||||
p := loadProxy(up, nil)
|
||||
up.replyJSON("/v1/face/verify", map[string]any{
|
||||
"verified": true, "distance": 0.1, "threshold": 0.4, "confidence": 92.0, "model": "buffalo_l",
|
||||
"img1_area": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
"img2_area": map[string]any{"x": 5.0, "y": 6.0, "w": 7.0, "h": 8.0},
|
||||
"img1_is_real": true, "img1_antispoof_score": 0.99,
|
||||
"img2_is_real": false, "img2_antispoof_score": 0.1,
|
||||
})
|
||||
It("wraps both bare base64 images as data URIs and maps the response, including liveness fields", func() {
|
||||
img2Bytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("other-face")...)
|
||||
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
decodeAndCheckImage(body, "img1", pngBytes)
|
||||
decodeAndCheckImage(body, "img2", img2Bytes)
|
||||
Expect(body["anti_spoofing"]).To(Equal(true))
|
||||
|
||||
res, err := p.FaceVerify(&pb.FaceVerifyRequest{Img1: "img1-b64", Img2: "img2-b64", AntiSpoofing: true})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"verified": true, "distance": 0.1, "threshold": 0.4, "confidence": 92.0, "model": "buffalo_l",
|
||||
"img1_area": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
"img2_area": map[string]any{"x": 5.0, "y": 6.0, "w": 7.0, "h": 8.0},
|
||||
"img1_is_real": true, "img1_antispoof_score": 0.99,
|
||||
"img2_is_real": false, "img2_antispoof_score": 0.1,
|
||||
})
|
||||
})
|
||||
DeferCleanup(up.Close)
|
||||
p := loadProxy(up, nil)
|
||||
|
||||
res, err := p.FaceVerify(&pb.FaceVerifyRequest{
|
||||
Img1: base64.StdEncoding.EncodeToString(pngBytes), Img2: base64.StdEncoding.EncodeToString(img2Bytes),
|
||||
AntiSpoofing: true,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Verified).To(BeTrue())
|
||||
Expect(res.Model).To(Equal("buffalo_l"))
|
||||
Expect(res.Img1Area.W).To(BeNumerically("==", 3))
|
||||
Expect(res.Img1IsReal).To(BeTrue())
|
||||
Expect(res.Img2IsReal).To(BeFalse())
|
||||
|
||||
req := up.last()
|
||||
Expect(req.Path).To(Equal("/v1/face/verify"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("img1", "img1-b64"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("img2", "img2-b64"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("anti_spoofing", true))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FaceAnalyze", func() {
|
||||
It("posts the image and maps per-face demographic attributes", func() {
|
||||
p := loadProxy(up, nil)
|
||||
up.replyJSON("/v1/face/analyze", map[string]any{
|
||||
"faces": []map[string]any{
|
||||
{
|
||||
"region": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
"face_confidence": 0.95, "age": 30.0, "dominant_gender": "Man",
|
||||
"gender": map[string]any{"Man": 0.9, "Woman": 0.1},
|
||||
},
|
||||
},
|
||||
})
|
||||
It("wraps the bare base64 image as a data URI and maps per-face demographic attributes", func() {
|
||||
up = newFakeUpstreamWithHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
Expect(json.NewDecoder(r.Body).Decode(&body)).To(Succeed())
|
||||
decodeAndCheckImage(body, "img", pngBytes)
|
||||
Expect(body["actions"]).To(ConsistOf("age", "gender"))
|
||||
|
||||
res, err := p.FaceAnalyze(&pb.FaceAnalyzeRequest{Img: "img-b64", Actions: []string{"age", "gender"}})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"faces": []map[string]any{
|
||||
{
|
||||
"region": map[string]any{"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
|
||||
"face_confidence": 0.95, "age": 30.0, "dominant_gender": "Man",
|
||||
"gender": map[string]any{"Man": 0.9, "Woman": 0.1},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
DeferCleanup(up.Close)
|
||||
p := loadProxy(up, nil)
|
||||
|
||||
res, err := p.FaceAnalyze(&pb.FaceAnalyzeRequest{
|
||||
Img: base64.StdEncoding.EncodeToString(pngBytes), Actions: []string{"age", "gender"},
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Faces).To(HaveLen(1))
|
||||
Expect(res.Faces[0].DominantGender).To(Equal("Man"))
|
||||
Expect(res.Faces[0].Gender).To(HaveKeyWithValue("Man", Equal(float32(0.9))))
|
||||
|
||||
req := up.last()
|
||||
Expect(req.Path).To(Equal("/v1/face/analyze"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("img", "img-b64"))
|
||||
Expect(req.JSON).To(HaveKeyWithValue("actions", ConsistOf("age", "gender")))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user