mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-02-10 14:09:05 -06:00
groupware: blob streaming (upload and download)
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -18,6 +19,7 @@ type SessionEventListener interface {
|
||||
type Client struct {
|
||||
wellKnown SessionClient
|
||||
api ApiClient
|
||||
blob BlobClient
|
||||
sessionEventListeners *eventListeners[SessionEventListener]
|
||||
io.Closer
|
||||
}
|
||||
@@ -26,10 +28,11 @@ func (j *Client) Close() error {
|
||||
return j.api.Close()
|
||||
}
|
||||
|
||||
func NewClient(wellKnown SessionClient, api ApiClient) Client {
|
||||
func NewClient(wellKnown SessionClient, api ApiClient, blob BlobClient) Client {
|
||||
return Client{
|
||||
wellKnown: wellKnown,
|
||||
api: api,
|
||||
blob: blob,
|
||||
sessionEventListeners: newEventListeners[SessionEventListener](),
|
||||
}
|
||||
}
|
||||
@@ -63,6 +66,9 @@ type Session struct {
|
||||
// The upload URL template
|
||||
UploadUrlTemplate string
|
||||
|
||||
// The upload URL template
|
||||
DownloadUrlTemplate string
|
||||
|
||||
// TODO
|
||||
DefaultMailAccountId string
|
||||
|
||||
@@ -91,12 +97,17 @@ func newSession(sessionResponse SessionResponse) (Session, Error) {
|
||||
if uploadUrl == "" {
|
||||
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an upload URL")}
|
||||
}
|
||||
downloadUrl := sessionResponse.DownloadUrl
|
||||
if downloadUrl == "" {
|
||||
return Session{}, SimpleError{code: JmapErrorInvalidSessionResponse, err: fmt.Errorf("JMAP session response does not provide an download URL")}
|
||||
}
|
||||
|
||||
return Session{
|
||||
Username: username,
|
||||
DefaultMailAccountId: mailAccountId,
|
||||
JmapUrl: *apiUrl,
|
||||
UploadUrlTemplate: uploadUrl,
|
||||
DownloadUrlTemplate: downloadUrl,
|
||||
SessionResponse: sessionResponse,
|
||||
}, nil
|
||||
}
|
||||
@@ -126,6 +137,9 @@ const (
|
||||
logOffset = "offset"
|
||||
logLimit = "limit"
|
||||
logApiUrl = "apiurl"
|
||||
logDownloadUrl = "downloadurl"
|
||||
logBlobId = "blobId"
|
||||
logUploadUrl = "downloadurl"
|
||||
logSessionState = "session-state"
|
||||
logSince = "since"
|
||||
|
||||
@@ -540,6 +554,25 @@ type UploadedBlob struct {
|
||||
Sha512 string `json:"sha:512"`
|
||||
}
|
||||
|
||||
func (j *Client) UploadBlobStream(accountId string, session *Session, ctx context.Context, logger *log.Logger, contentType string, body io.Reader) (UploadedBlob, Error) {
|
||||
aid := session.BlobAccountId(accountId)
|
||||
// TODO(pbleser-oc) use a library for proper URL template parsing
|
||||
uploadUrl := strings.ReplaceAll(session.UploadUrlTemplate, "{accountId}", aid)
|
||||
return j.blob.UploadBinary(ctx, logger, session, uploadUrl, contentType, body)
|
||||
}
|
||||
|
||||
func (j *Client) DownloadBlobStream(accountId string, blobId string, name string, typ string, session *Session, ctx context.Context, logger *log.Logger) (*BlobDownload, Error) {
|
||||
aid := session.BlobAccountId(accountId)
|
||||
// TODO(pbleser-oc) use a library for proper URL template parsing
|
||||
downloadUrl := session.DownloadUrlTemplate
|
||||
downloadUrl = strings.ReplaceAll(downloadUrl, "{accountId}", aid)
|
||||
downloadUrl = strings.ReplaceAll(downloadUrl, "{blobId}", blobId)
|
||||
downloadUrl = strings.ReplaceAll(downloadUrl, "{name}", name)
|
||||
downloadUrl = strings.ReplaceAll(downloadUrl, "{type}", typ)
|
||||
logger = &log.Logger{Logger: logger.With().Str(logDownloadUrl, downloadUrl).Str(logBlobId, blobId).Str(logAccountId, aid).Logger()}
|
||||
return j.blob.DownloadBinary(ctx, logger, session, downloadUrl)
|
||||
}
|
||||
|
||||
func (j *Client) UploadBlob(accountId string, session *Session, ctx context.Context, logger *log.Logger, data []byte, contentType string) (UploadedBlob, Error) {
|
||||
aid := session.MailAccountId(accountId)
|
||||
|
||||
|
||||
@@ -15,3 +15,8 @@ type ApiClient interface {
|
||||
type SessionClient interface {
|
||||
GetSession(username string, logger *log.Logger) (SessionResponse, Error)
|
||||
}
|
||||
|
||||
type BlobClient interface {
|
||||
UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, content io.Reader) (UploadedBlob, Error)
|
||||
DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
@@ -24,6 +25,7 @@ type HttpJmapApiClient struct {
|
||||
var (
|
||||
_ ApiClient = &HttpJmapApiClient{}
|
||||
_ SessionClient = &HttpJmapApiClient{}
|
||||
_ BlobClient = &HttpJmapApiClient{}
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -153,3 +155,88 @@ func (h *HttpJmapApiClient) Command(ctx context.Context, logger *log.Logger, ses
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, body io.Reader) (UploadedBlob, Error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadUrl, body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to create POST request for %v", uploadUrl)
|
||||
return UploadedBlob{}, SimpleError{code: JmapErrorCreatingRequest, err: err}
|
||||
}
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("User-Agent", h.userAgent)
|
||||
h.auth(session.Username, logger, req)
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to perform POST %v", uploadUrl)
|
||||
return UploadedBlob{}, SimpleError{code: JmapErrorSendingRequest, err: err}
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
|
||||
return UploadedBlob{}, SimpleError{code: JmapErrorServerResponse, err: err}
|
||||
}
|
||||
if res.Body != nil {
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
}(res.Body)
|
||||
}
|
||||
|
||||
responseBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("failed to read response body")
|
||||
return UploadedBlob{}, SimpleError{code: JmapErrorServerResponse, err: err}
|
||||
}
|
||||
|
||||
var result UploadedBlob
|
||||
err = json.Unmarshal(responseBody, &result)
|
||||
if err != nil {
|
||||
logger.Error().Str("url", uploadUrl).Err(err).Msg("failed to decode JSON payload from the upload response")
|
||||
return UploadedBlob{}, SimpleError{code: JmapErrorDecodingResponseBody, err: err}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *HttpJmapApiClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to create GET request for %v", downloadUrl)
|
||||
return nil, SimpleError{code: JmapErrorCreatingRequest, err: err}
|
||||
}
|
||||
req.Header.Add("User-Agent", h.userAgent)
|
||||
h.auth(session.Username, logger, req)
|
||||
|
||||
res, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msgf("failed to perform GET %v", downloadUrl)
|
||||
return nil, SimpleError{code: JmapErrorSendingRequest, err: err}
|
||||
}
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode > 299 {
|
||||
logger.Error().Str("status", res.Status).Msg("HTTP response status code is not 2xx")
|
||||
return nil, SimpleError{code: JmapErrorServerResponse, err: err}
|
||||
}
|
||||
|
||||
sizeStr := res.Header.Get("Content-Length")
|
||||
size := -1
|
||||
if sizeStr != "" {
|
||||
size, err = strconv.Atoi(sizeStr)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msgf("failed to parse Content-Length blob download response header value '%v'", sizeStr)
|
||||
size = -1
|
||||
}
|
||||
}
|
||||
|
||||
return &BlobDownload{
|
||||
Body: res.Body,
|
||||
Size: size,
|
||||
Type: res.Header.Get("Content-Type"),
|
||||
ContentDisposition: res.Header.Get("Content-Disposition"),
|
||||
CacheControl: res.Header.Get("Cache-Control"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package jmap
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -1297,6 +1298,14 @@ type BlobGetResponse struct {
|
||||
NotFound []any `json:"notFound,omitempty"`
|
||||
}
|
||||
|
||||
type BlobDownload struct {
|
||||
Body io.ReadCloser
|
||||
Size int
|
||||
Type string
|
||||
ContentDisposition string
|
||||
CacheControl string
|
||||
}
|
||||
|
||||
const (
|
||||
BlobGet Command = "Blob/get"
|
||||
BlobUpload Command = "Blob/upload"
|
||||
|
||||
@@ -2,15 +2,20 @@ package jmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -57,6 +62,39 @@ func (t TestJmapApiClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type TestJmapBlobClient struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func NewTestJmapBlobClient(t *testing.T) BlobClient {
|
||||
return &TestJmapBlobClient{t: t}
|
||||
}
|
||||
|
||||
func (t TestJmapBlobClient) UploadBinary(ctx context.Context, logger *log.Logger, session *Session, uploadUrl string, contentType string, body io.Reader) (UploadedBlob, Error) {
|
||||
bytes, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
return UploadedBlob{}, SimpleError{code: 0, err: err}
|
||||
}
|
||||
hasher := sha512.New()
|
||||
hasher.Write(bytes)
|
||||
return UploadedBlob{
|
||||
Id: uuid.NewString(),
|
||||
Size: len(bytes),
|
||||
Type: contentType,
|
||||
Sha512: base64.StdEncoding.EncodeToString(hasher.Sum(nil)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *TestJmapBlobClient) DownloadBinary(ctx context.Context, logger *log.Logger, session *Session, downloadUrl string) (*BlobDownload, Error) {
|
||||
return &BlobDownload{
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Size: -1,
|
||||
Type: "text/plain",
|
||||
ContentDisposition: "attachment; filename=\"file.txt\"",
|
||||
CacheControl: "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serveTestFile(t *testing.T, name string) ([]byte, Error) {
|
||||
cwd, _ := os.Getwd()
|
||||
p := filepath.Join(cwd, "testdata", name)
|
||||
@@ -102,9 +140,10 @@ func TestRequests(t *testing.T) {
|
||||
require := require.New(t)
|
||||
apiClient := NewTestJmapApiClient(t)
|
||||
wkClient := NewTestJmapWellKnownClient(t)
|
||||
blobClient := NewTestJmapBlobClient(t)
|
||||
logger := log.NopLogger()
|
||||
ctx := context.Background()
|
||||
client := NewClient(wkClient, apiClient)
|
||||
client := NewClient(wkClient, apiClient, blobClient)
|
||||
|
||||
jmapUrl, err := url.Parse("http://localhost/jmap")
|
||||
require.NoError(err)
|
||||
|
||||
Reference in New Issue
Block a user