API: Refactor data URL encoding and decoding in pkg/media #127 #1090

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-04-09 10:58:48 +02:00
parent 7f1041c434
commit cf3b933e59
7 changed files with 128 additions and 85 deletions

View File

@@ -56,7 +56,7 @@ func (m *Model) File(imageUri string, confidenceThreshold int) (result Labels, e
var data []byte
if data, err = media.ReadUri(imageUri); err != nil {
if data, err = media.ReadUrl(imageUri); err != nil {
return nil, err
}

View File

@@ -23,7 +23,7 @@ func (m *Passcode) MarshalJSON() ([]byte, error) {
UID: m.UID,
Type: m.KeyType,
Secret: m.Secret(),
QRCode: media.Base64(m.Png(350)),
QRCode: media.DataUrl(m.Png(350)),
RecoveryCode: m.RecoveryCode,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,

View File

@@ -1,30 +1,23 @@
package media
import (
"bytes"
"encoding/base64"
"fmt"
"github.com/gabriel-vasile/mimetype"
"io"
)
// Base64 returns a data URL representing the binary buffer data.
func Base64(buf *bytes.Buffer) string {
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
if encoded == "" {
return ""
}
var mimeType string
mime, err := mimetype.DetectReader(buf)
if err != nil {
mimeType = "application/octet-stream"
} else {
mimeType = mime.String()
}
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
// EncodeBase64 returns the base64 encoding of bin.
func EncodeBase64(bin []byte) string {
return base64.StdEncoding.EncodeToString(bin)
}
// ReadBase64 returns a new reader that decodes base64 and returns binary data.
func ReadBase64(stream io.Reader) io.Reader {
return base64.NewDecoder(base64.StdEncoding, stream)
}
// DecodeBase64 returns the bytes represented by the base64 string s.
// If the input is malformed, it returns the partially decoded data and
// [CorruptInputError]. Newline characters (\r and \n) are ignored.
func DecodeBase64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}

View File

@@ -1,25 +1,15 @@
package media
import (
"bytes"
"encoding/base64"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
const gopher = `iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDsJkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAAILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36IMRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZmrSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhktrRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEjyy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5feA+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fUyLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2SnssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg==`
// gopherPng creates an io.Reader by decoding the base64 encoded image data string in the gopher constant.
func gopherPng() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
func TestBase64(t *testing.T) {
t.Run("PNG", func(t *testing.T) {
buf := new(bytes.Buffer)
_, bufErr := buf.ReadFrom(gopherPng())
assert.NoError(t, bufErr)
assert.Equal(t, "data:image/png;base64,"+gopher, Base64(buf))
t.Run("Gopher", func(t *testing.T) {
data, err := DecodeBase64(gopher)
assert.NoError(t, err)
assert.Equal(t, gopher, EncodeBase64(data))
})
}

70
pkg/media/data_url.go Normal file
View File

@@ -0,0 +1,70 @@
package media
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"github.com/gabriel-vasile/mimetype"
)
// DataUrl returns a data URL representing the binary buffer data.
func DataUrl(buf *bytes.Buffer) string {
encoded := EncodeBase64(buf.Bytes())
if encoded == "" {
return ""
}
var mimeType string
mime, err := mimetype.DetectReader(buf)
if err != nil {
mimeType = "application/octet-stream"
} else {
mimeType = mime.String()
}
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded)
}
// ReadUrl reads binary data from a regular file path,
// fetches its data from a remote http or https URL,
// or decodes a base64 data URL as created by DataUrl.
func ReadUrl(file string) (data []byte, err error) {
u, err := url.Parse(file)
if err != nil {
log.Fatal(err)
}
// Also supports http, https, and data URLs instead of a file name for remote processing.
if u.Scheme == "http" || u.Scheme == "https" {
resp, httpErr := http.Get(file)
if httpErr != nil {
return nil, httpErr
}
defer resp.Body.Close()
if data, err = io.ReadAll(resp.Body); err != nil {
return nil, err
}
} else if u.Scheme == "data" {
if _, binaryData, found := strings.Cut(u.Opaque, ";base64,"); !found || len(binaryData) == 0 {
return nil, fmt.Errorf("invalid data URL")
} else {
return DecodeBase64(binaryData)
}
} else if data, err = os.ReadFile(file); err != nil {
return nil, err
}
return data, err
}

View File

@@ -0,0 +1,36 @@
package media
import (
"bytes"
"io"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
const gopher = `iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDsJkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAAILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36IMRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZmrSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhktrRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEjyy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5feA+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fUyLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2SnssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg==`
// gopherPng creates an io.Reader by decoding the base64 encoded image data string in the gopher constant.
func gopherPng() io.Reader { return ReadBase64(strings.NewReader(gopher)) }
func TestDataUrl(t *testing.T) {
t.Run("Gopher", func(t *testing.T) {
buf := new(bytes.Buffer)
_, bufErr := buf.ReadFrom(gopherPng())
assert.NoError(t, bufErr)
assert.Equal(t, "data:image/png;base64,"+gopher, DataUrl(buf))
})
}
func TestReadUrl(t *testing.T) {
t.Run("Gopher", func(t *testing.T) {
dataUrl := "data:image/png;base64," + gopher
if data, err := ReadUrl(dataUrl); err != nil {
t.Fatal(err)
} else {
expected, _ := DecodeBase64(gopher)
assert.Equal(t, expected, data)
}
})
}

View File

@@ -1,46 +0,0 @@
package media
import (
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
)
// ReadUri reads binary data from regular files, fetches data from remote http and
// https URLs, or decodes base64 data URLs - depending on the type of URI you pass.
func ReadUri(uri string) (data []byte, err error) {
u, err := url.Parse(uri)
if err != nil {
log.Fatal(err)
}
// Also supports http, https, and data URLs instead of a file name for remote processing.
if u.Scheme == "http" || u.Scheme == "https" {
resp, httpErr := http.Get(uri)
if httpErr != nil {
return nil, httpErr
}
defer resp.Body.Close()
if data, err = io.ReadAll(resp.Body); err != nil {
return nil, err
}
} else if u.Scheme == "data" {
if _, binaryData, found := strings.Cut(u.Opaque, ";base64,"); !found || len(binaryData) == 0 {
return nil, fmt.Errorf("invalid data URL")
} else {
return base64.StdEncoding.DecodeString(binaryData)
}
} else if data, err = os.ReadFile(uri); err != nil {
return nil, err
}
return data, err
}