Skip to content

Commit

Permalink
Download file revisions (cs3org#3766)
Browse files Browse the repository at this point in the history
  • Loading branch information
gmgigi96 committed Jun 28, 2023
1 parent 9b3c70a commit 94bbf3f
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 31 deletions.
8 changes: 8 additions & 0 deletions changelog/unreleased/download-file-revisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Enhancement: Download file revisions

Currently it is only possible to restore a file version,
replacing the actual file with the selected version.
This allows an user to download a version file,
without touching/replacing the last version of the file

https://github.com/cs3org/reva/pull/3766
23 changes: 18 additions & 5 deletions internal/grpc/services/gateway/storageprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ import (
// transferClaims are custom claims for a JWT token to be used between the metadata and data gateways.
type transferClaims struct {
jwt.StandardClaims
Target string `json:"target"`
Target string `json:"target"`
VersionKey string `json:"version_key,omitempty"`
}

func (s *svc) sign(_ context.Context, target string) (string, error) {
func (s *svc) sign(_ context.Context, target, versionKey string) (string, error) {
// Tus sends a separate request to the datagateway service for every chunk.
// For large files, this can take a long time, so we extend the expiration
ttl := time.Duration(s.c.TransferExpires) * time.Second
Expand All @@ -65,7 +66,8 @@ func (s *svc) sign(_ context.Context, target string) (string, error) {
Audience: "reva",
IssuedAt: time.Now().Unix(),
},
Target: target,
Target: target,
VersionKey: versionKey,
}

t := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims)
Expand Down Expand Up @@ -470,6 +472,17 @@ func (s *svc) InitiateFileDownload(ctx context.Context, req *provider.InitiateFi
panic("gateway: download: unknown path:" + p)
}

func versionKey(req *provider.InitiateFileDownloadRequest) string {
if req.Opaque == nil || req.Opaque.Map == nil {
return ""
}
val := req.Opaque.Map["version_key"]
if val == nil {
return ""
}
return string(val.Value)
}

func (s *svc) initiateFileDownload(ctx context.Context, req *provider.InitiateFileDownloadRequest) (*gateway.InitiateFileDownloadResponse, error) {
// TODO(ishank011): enable downloading references spread across storage providers, eg. /eos
c, err := s.find(ctx, req.Ref)
Expand Down Expand Up @@ -506,7 +519,7 @@ func (s *svc) initiateFileDownload(ctx context.Context, req *provider.InitiateFi

// TODO(labkode): calculate signature of the whole request? we only sign the URI now. Maybe worth https://tools.ietf.org/html/draft-cavage-http-signatures-11
target := u.String()
token, err := s.sign(ctx, target)
token, err := s.sign(ctx, target, versionKey(req))
if err != nil {
return &gateway.InitiateFileDownloadResponse{
Status: status.NewInternal(ctx, err, "error creating signature for download"),
Expand Down Expand Up @@ -712,7 +725,7 @@ func (s *svc) initiateFileUpload(ctx context.Context, req *provider.InitiateFile

// TODO(labkode): calculate signature of the whole request? we only sign the URI now. Maybe worth https://tools.ietf.org/html/draft-cavage-http-signatures-11
target := u.String()
token, err := s.sign(ctx, target)
token, err := s.sign(ctx, target, "")
if err != nil {
return &gateway.InitiateFileUploadResponse{
Status: status.NewInternal(ctx, err, "error creating signature for upload"),
Expand Down
4 changes: 2 additions & 2 deletions internal/http/services/archiver/manager/archiver.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ func (a *Archiver) CreateTar(ctx context.Context, dst io.Writer) error {
}

if !isDir {
err = a.downloader.Download(ctx, path, w)
err = a.downloader.Download(ctx, path, "", w)
if err != nil {
return err
}
Expand Down Expand Up @@ -239,7 +239,7 @@ func (a *Archiver) CreateZip(ctx context.Context, dst io.Writer) error {
}

if !isDir {
err = a.downloader.Download(ctx, path, dst)
err = a.downloader.Download(ctx, path, "", dst)
if err != nil {
return err
}
Expand Down
15 changes: 14 additions & 1 deletion internal/http/services/datagateway/datagateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ func init() {
// transferClaims are custom claims for a JWT token to be used between the metadata and data gateways.
type transferClaims struct {
jwt.StandardClaims
Target string `json:"target"`
Target string `json:"target"`
VersionKey string `json:"version_key,omitempty"`
}
type config struct {
Prefix string `mapstructure:"prefix"`
Expand Down Expand Up @@ -191,6 +192,12 @@ func (s *svc) doHead(w http.ResponseWriter, r *http.Request) {
}
httpReq.Header = r.Header

if claims.VersionKey != "" {
q := httpReq.URL.Query()
q.Add("version_key", claims.VersionKey)
httpReq.URL.RawQuery = q.Encode()
}

httpRes, err := httpClient.Do(httpReq)
if err != nil {
log.Error().Err(err).Msg("error doing HEAD request to data service")
Expand Down Expand Up @@ -237,6 +244,12 @@ func (s *svc) doGet(w http.ResponseWriter, r *http.Request) {
}
httpReq.Header = r.Header

if claims.VersionKey != "" {
q := httpReq.URL.Query()
q.Add("version_key", claims.VersionKey)
httpReq.URL.RawQuery = q.Encode()
}

httpRes, err := httpClient.Do(httpReq)
if err != nil {
log.Error().Err(err).Msg("error doing GET request to data service")
Expand Down
48 changes: 48 additions & 0 deletions internal/http/services/owncloud/ocdav/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,17 @@ package ocdav

import (
"context"
"fmt"
"net/http"
"path"
"path/filepath"

rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/cs3org/reva/pkg/appctx"
"github.com/cs3org/reva/pkg/rhttp/router"
"github.com/cs3org/reva/pkg/storage/utils/downloader"
rtrace "github.com/cs3org/reva/pkg/trace"
"github.com/cs3org/reva/pkg/utils/resourceid"
)
Expand Down Expand Up @@ -74,6 +77,10 @@ func (h *VersionsHandler) Handler(s *svc, rid *provider.ResourceId) http.Handler
h.doRestore(w, r, s, rid, key)
return
}
if key != "" && r.Method == http.MethodGet {
h.doDownload(w, r, s, rid, key)
return
}

http.Error(w, "501 Forbidden", http.StatusNotImplemented)
})
Expand Down Expand Up @@ -210,3 +217,44 @@ func (h *VersionsHandler) doRestore(w http.ResponseWriter, r *http.Request, s *s
}
w.WriteHeader(http.StatusNoContent)
}

func (h *VersionsHandler) doDownload(w http.ResponseWriter, r *http.Request, s *svc, rid *provider.ResourceId, key string) {
ctx, span := rtrace.Provider.Tracer("ocdav").Start(r.Context(), "restore")
defer span.End()

sublog := appctx.GetLogger(ctx).With().Interface("resourceid", rid).Str("key", key).Logger()

client, err := s.getClient()
if err != nil {
sublog.Error().Err(err).Msg("error getting grpc client")
w.WriteHeader(http.StatusInternalServerError)
return
}

resStat, err := client.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{
ResourceId: rid,
},
})

if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}

if resStat.Status.Code != rpc.Code_CODE_OK {
HandleErrorStatus(&sublog, w, resStat.Status)
return
}

fname := filepath.Base(resStat.Info.Path)

w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fname))
w.Header().Set("Content-Transfer-Encoding", "binary")

down := downloader.NewDownloader(client)
if err := down.Download(ctx, resStat.Info.Path, key, w); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
70 changes: 52 additions & 18 deletions pkg/rhttp/datatx/utils/download/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
package download

import (
"context"
"fmt"
"io"
"mime/multipart"
Expand Down Expand Up @@ -68,30 +69,57 @@ func GetOrHeadFile(w http.ResponseWriter, r *http.Request, fs storage.FS, spaceI
}
// TODO check preconditions like If-Range, If-Match ...

var md *provider.ResourceInfo
var err error

// do a stat to set a Content-Length header
var (
md *provider.ResourceInfo
content io.ReadCloser
size int64
err error
)

// do a stat to get the mime type
if md, err = fs.GetMD(ctx, ref, nil); err != nil {
handleError(w, &sublog, err, "stat")
return
}
mimeType := md.MimeType

if versionKey := r.URL.Query().Get("version_key"); versionKey != "" {
// the request is for a version file
stat, err := statRevision(ctx, fs, ref, versionKey)
if err != nil {
handleError(w, &sublog, err, "stat revision")
return
}
size = int64(stat.Size)
content, err = fs.DownloadRevision(ctx, ref, versionKey)
if err != nil {
handleError(w, &sublog, err, "download revision")
return
}
} else {
size = int64(md.Size)
content, err = fs.Download(ctx, ref)
if err != nil {
handleError(w, &sublog, err, "download")
return
}
}
defer content.Close()

var ranges []HTTPRange

if r.Header.Get("Range") != "" {
ranges, err = ParseRange(r.Header.Get("Range"), int64(md.Size))
ranges, err = ParseRange(r.Header.Get("Range"), size)
if err != nil {
if err == ErrNoOverlap {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", md.Size))
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
}
sublog.Error().Err(err).Interface("md", md).Interface("ranges", ranges).Msg("range request not satisfiable")
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)

return
}
if SumRangesSize(ranges) > int64(md.Size) {
if SumRangesSize(ranges) > size {
// The total number of bytes in all the ranges
// is larger than the size of the file by
// itself, so this is probably an attack, or a
Expand All @@ -100,15 +128,8 @@ func GetOrHeadFile(w http.ResponseWriter, r *http.Request, fs storage.FS, spaceI
}
}

content, err := fs.Download(ctx, ref)
if err != nil {
handleError(w, &sublog, err, "download")
return
}
defer content.Close()

code := http.StatusOK
sendSize := int64(md.Size)
sendSize := size
var sendContent io.Reader = content

var s io.Seeker
Expand Down Expand Up @@ -146,9 +167,9 @@ func GetOrHeadFile(w http.ResponseWriter, r *http.Request, fs storage.FS, spaceI
}
sendSize = ra.Length
code = http.StatusPartialContent
w.Header().Set("Content-Range", ra.ContentRange(int64(md.Size)))
w.Header().Set("Content-Range", ra.ContentRange(size))
case len(ranges) > 1:
sendSize = RangesMIMESize(ranges, md.MimeType, int64(md.Size))
sendSize = RangesMIMESize(ranges, mimeType, size)
code = http.StatusPartialContent

pr, pw := io.Pipe()
Expand All @@ -158,7 +179,7 @@ func GetOrHeadFile(w http.ResponseWriter, r *http.Request, fs storage.FS, spaceI
defer pr.Close() // cause writing goroutine to fail and exit if CopyN doesn't finish.
go func() {
for _, ra := range ranges {
part, err := mw.CreatePart(ra.MimeHeader(md.MimeType, int64(md.Size)))
part, err := mw.CreatePart(ra.MimeHeader(mimeType, size))
if err != nil {
_ = pw.CloseWithError(err) // CloseWithError always returns nil
return
Expand Down Expand Up @@ -197,6 +218,19 @@ func GetOrHeadFile(w http.ResponseWriter, r *http.Request, fs storage.FS, spaceI
}
}

func statRevision(ctx context.Context, fs storage.FS, ref *provider.Reference, revisionKey string) (*provider.FileVersion, error) {
versions, err := fs.ListRevisions(ctx, ref)
if err != nil {
return nil, err
}
for _, v := range versions {
if v.Key == revisionKey {
return v, nil
}
}
return nil, errtypes.NotFound("version not found")
}

func handleError(w http.ResponseWriter, log *zerolog.Logger, err error, action string) {
switch err.(type) {
case errtypes.IsNotFound:
Expand Down
20 changes: 16 additions & 4 deletions pkg/storage/utils/downloader/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/cs3org/reva/internal/http/services/datagateway"
"github.com/cs3org/reva/pkg/errtypes"
"github.com/cs3org/reva/pkg/rhttp"
Expand All @@ -35,7 +36,7 @@ import (
// Downloader is the interface implemented by the objects that are able to
// download a path into a destination Writer.
type Downloader interface {
Download(context.Context, string, io.Writer) error
Download(ctx context.Context, path string, versionKey string, w io.Writer) error
}

type revaDownloader struct {
Expand All @@ -61,12 +62,23 @@ func getDownloadProtocol(protocols []*gateway.FileDownloadProtocol, prot string)
}

// Download downloads a resource given the path to the dst Writer.
func (r *revaDownloader) Download(ctx context.Context, path string, dst io.Writer) error {
downResp, err := r.gtw.InitiateFileDownload(ctx, &provider.InitiateFileDownloadRequest{
func (r *revaDownloader) Download(ctx context.Context, path, versionKey string, dst io.Writer) error {
req := &provider.InitiateFileDownloadRequest{
Ref: &provider.Reference{
Path: path,
},
})
}
if versionKey != "" {
req.Opaque = &types.Opaque{
Map: map[string]*types.OpaqueEntry{
"version_key": {
Decoder: "plain",
Value: []byte(versionKey),
},
},
}
}
downResp, err := r.gtw.InitiateFileDownload(ctx, req)

switch {
case err != nil:
Expand Down
2 changes: 1 addition & 1 deletion pkg/storage/utils/downloader/mock/downloader_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func NewDownloader() downloader.Downloader {
}

// Download copies the content of a local file into the dst Writer.
func (m *mockDownloader) Download(ctx context.Context, path string, dst io.Writer) error {
func (m *mockDownloader) Download(ctx context.Context, path, _ string, dst io.Writer) error {
f, err := os.Open(path)
if err != nil {
return err
Expand Down

0 comments on commit 94bbf3f

Please sign in to comment.