Commit 14673366 authored by Nemanja Zbiljić's avatar Nemanja Zbiljić Committed by GitHub

Support manifest traversal (#452)

Add functionality to serve directory content

- add 'pkg/manifest'
- update 'pkg/api'
parent 9c613c0d
......@@ -8,6 +8,7 @@ import (
"net/http"
"github.com/ethersphere/bee/pkg/logging"
"github.com/ethersphere/bee/pkg/manifest"
m "github.com/ethersphere/bee/pkg/metrics"
"github.com/ethersphere/bee/pkg/storage"
"github.com/ethersphere/bee/pkg/tags"
......@@ -28,6 +29,7 @@ type server struct {
type Options struct {
Tags *tags.Tags
Storer storage.Storer
ManifestParser manifest.Parser
CORSAllowedOrigins []string
Logger logging.Logger
Tracer *tracing.Tracer
......
......@@ -13,6 +13,7 @@ import (
"github.com/ethersphere/bee/pkg/api"
"github.com/ethersphere/bee/pkg/logging"
"github.com/ethersphere/bee/pkg/manifest"
"github.com/ethersphere/bee/pkg/pingpong"
"github.com/ethersphere/bee/pkg/storage"
"github.com/ethersphere/bee/pkg/tags"
......@@ -20,10 +21,11 @@ import (
)
type testServerOptions struct {
Pingpong pingpong.Interface
Storer storage.Storer
Tags *tags.Tags
Logger logging.Logger
Pingpong pingpong.Interface
Storer storage.Storer
ManifestParser manifest.Parser
Tags *tags.Tags
Logger logging.Logger
}
func newTestServer(t *testing.T, o testServerOptions) *http.Client {
......@@ -31,9 +33,10 @@ func newTestServer(t *testing.T, o testServerOptions) *http.Client {
o.Logger = logging.New(ioutil.Discard, 0)
}
s := api.New(api.Options{
Tags: o.Tags,
Storer: o.Storer,
Logger: o.Logger,
Tags: o.Tags,
Storer: o.Storer,
ManifestParser: o.ManifestParser,
Logger: o.Logger,
})
ts := httptest.NewServer(s)
t.Cleanup(ts.Close)
......
......@@ -5,19 +5,12 @@
package api
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/ethersphere/bee/pkg/encryption"
"github.com/ethersphere/bee/pkg/file"
"github.com/ethersphere/bee/pkg/file/joiner"
"github.com/ethersphere/bee/pkg/file/splitter"
"github.com/ethersphere/bee/pkg/jsonhttp"
"github.com/ethersphere/bee/pkg/storage"
"github.com/ethersphere/bee/pkg/swarm"
"github.com/gorilla/mux"
)
......@@ -46,7 +39,6 @@ func (s *server) bytesUploadHandler(w http.ResponseWriter, r *http.Request) {
// bytesGetHandler handles retrieval of raw binary data of arbitrary length.
func (s *server) bytesGetHandler(w http.ResponseWriter, r *http.Request) {
addressHex := mux.Vars(r)["address"]
ctx := r.Context()
address, err := swarm.ParseHexAddress(addressHex)
if err != nil {
......@@ -56,35 +48,9 @@ func (s *server) bytesGetHandler(w http.ResponseWriter, r *http.Request) {
return
}
toDecrypt := len(address.Bytes()) == (swarm.HashSize + encryption.KeyLength)
j := joiner.NewSimpleJoiner(s.Storer)
dataSize, err := j.Size(ctx, address)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
s.Logger.Debugf("bytes: not found %s: %v", address, err)
s.Logger.Error("bytes: not found")
jsonhttp.NotFound(w, "not found")
return
}
s.Logger.Debugf("bytes: invalid root chunk %s: %v", address, err)
s.Logger.Error("bytes: invalid root chunk")
jsonhttp.BadRequest(w, "invalid root chunk")
return
additionalHeaders := http.Header{
"Content-Type": {"application/octet-stream"},
}
outBuffer := bytes.NewBuffer(nil)
c, err := file.JoinReadAll(j, address, outBuffer, toDecrypt)
if err != nil && c == 0 {
s.Logger.Debugf("bytes download: data join %s: %v", address, err)
s.Logger.Errorf("bytes download: data join %s", address)
jsonhttp.NotFound(w, nil)
return
}
w.Header().Set("ETag", fmt.Sprintf("%q", address))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", dataSize))
if _, err = io.Copy(w, outBuffer); err != nil {
s.Logger.Debugf("bytes download: data read %s: %v", address, err)
s.Logger.Errorf("bytes download: data read %s", address)
}
s.downloadHandler(w, r, address, additionalHeaders)
}
// Copyright 2020 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/ethersphere/bee/pkg/collection/entry"
"github.com/ethersphere/bee/pkg/encryption"
"github.com/ethersphere/bee/pkg/file"
"github.com/ethersphere/bee/pkg/file/joiner"
"github.com/ethersphere/bee/pkg/jsonhttp"
"github.com/ethersphere/bee/pkg/swarm"
"github.com/gorilla/mux"
)
const (
// ManifestContentType represents content type used for noting that specific
// file should be processed as manifest
ManifestContentType = "application/bzz-manifest+json"
)
func (s *server) bzzDownloadHandler(w http.ResponseWriter, r *http.Request) {
addressHex := mux.Vars(r)["address"]
path := mux.Vars(r)["path"]
address, err := swarm.ParseHexAddress(addressHex)
if err != nil {
s.Logger.Debugf("bzz download: parse address %s: %v", addressHex, err)
s.Logger.Error("bzz download: parse address")
jsonhttp.BadRequest(w, "invalid address")
return
}
toDecrypt := len(address.Bytes()) == (swarm.HashSize + encryption.KeyLength)
// read manifest entry
j := joiner.NewSimpleJoiner(s.Storer)
buf := bytes.NewBuffer(nil)
_, err = file.JoinReadAll(j, address, buf, toDecrypt)
if err != nil {
s.Logger.Debugf("bzz download: read entry %s: %v", address, err)
s.Logger.Errorf("bzz download: read entry %s", address)
jsonhttp.NotFound(w, nil)
return
}
e := &entry.Entry{}
err = e.UnmarshalBinary(buf.Bytes())
if err != nil {
s.Logger.Debugf("bzz download: unmarshal entry %s: %v", address, err)
s.Logger.Errorf("bzz download: unmarshal entry %s", address)
jsonhttp.InternalServerError(w, "error unmarshaling entry")
return
}
// read metadata
buf = bytes.NewBuffer(nil)
_, err = file.JoinReadAll(j, e.Metadata(), buf, toDecrypt)
if err != nil {
s.Logger.Debugf("bzz download: read metadata %s: %v", address, err)
s.Logger.Errorf("bzz download: read metadata %s", address)
jsonhttp.NotFound(w, nil)
return
}
metadata := &entry.Metadata{}
err = json.Unmarshal(buf.Bytes(), metadata)
if err != nil {
s.Logger.Debugf("bzz download: unmarshal metadata %s: %v", address, err)
s.Logger.Errorf("bzz download: unmarshal metadata %s", address)
jsonhttp.InternalServerError(w, "error unmarshaling metadata")
return
}
// we are expecting manifest Mime type here
if ManifestContentType != metadata.MimeType {
s.Logger.Debugf("bzz download: not manifest %s: %v", address, err)
s.Logger.Error("bzz download: not manifest")
jsonhttp.BadRequest(w, "not manifest")
return
}
// read manifest content
buf = bytes.NewBuffer(nil)
_, err = file.JoinReadAll(j, e.Reference(), buf, toDecrypt)
if err != nil {
s.Logger.Debugf("bzz download: data join %s: %v", address, err)
s.Logger.Errorf("bzz download: data join %s", address)
jsonhttp.NotFound(w, nil)
return
}
manifest, err := s.ManifestParser.Parse(buf.Bytes())
if err != nil {
s.Logger.Debugf("bzz download: unmarshal manifest %s: %v", address, err)
s.Logger.Errorf("bzz download: unmarshal manifest %s", address)
jsonhttp.InternalServerError(w, "error unmarshaling manifest")
return
}
me, err := manifest.FindEntry(path)
if err != nil {
s.Logger.Debugf("bzz download: invalid path %s/%s: %v", address, path, err)
s.Logger.Error("bzz download: invalid path")
jsonhttp.BadRequest(w, "invalid path address")
return
}
manifestEntryAddress := me.GetReference()
var additionalHeaders http.Header
// copy headers from manifest
if me.GetHeaders() != nil {
additionalHeaders = me.GetHeaders().Clone()
} else {
additionalHeaders = http.Header{}
}
// include filename
if me.GetName() != "" {
additionalHeaders.Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", me.GetName()))
}
// read file entry
buf = bytes.NewBuffer(nil)
_, err = file.JoinReadAll(j, manifestEntryAddress, buf, toDecrypt)
if err != nil {
s.Logger.Debugf("bzz download: read file entry %s: %v", address, err)
s.Logger.Errorf("bzz download: read file entry %s", address)
jsonhttp.NotFound(w, nil)
return
}
fe := &entry.Entry{}
err = fe.UnmarshalBinary(buf.Bytes())
if err != nil {
s.Logger.Debugf("bzz download: unmarshal file entry %s: %v", address, err)
s.Logger.Errorf("bzz download: unmarshal file entry %s", address)
jsonhttp.InternalServerError(w, "error unmarshaling file entry")
return
}
fileEntryAddress := fe.Reference()
s.downloadHandler(w, r, fileEntryAddress, additionalHeaders)
}
// Copyright 2020 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package api_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"mime"
"net/http"
"strings"
"testing"
"github.com/ethersphere/bee/pkg/api"
"github.com/ethersphere/bee/pkg/collection/entry"
"github.com/ethersphere/bee/pkg/file"
"github.com/ethersphere/bee/pkg/file/splitter"
"github.com/ethersphere/bee/pkg/jsonhttp"
"github.com/ethersphere/bee/pkg/jsonhttp/jsonhttptest"
"github.com/ethersphere/bee/pkg/logging"
"github.com/ethersphere/bee/pkg/manifest/jsonmanifest"
smock "github.com/ethersphere/bee/pkg/storage/mock"
"github.com/ethersphere/bee/pkg/swarm"
"github.com/ethersphere/bee/pkg/tags"
)
func TestBzz(t *testing.T) {
var (
bzzDownloadResource = func(addr, path string) string { return "/bzz:/" + addr + "/" + path }
storer = smock.NewStorer()
sp = splitter.NewSimpleSplitter(storer)
client = newTestServer(t, testServerOptions{
Storer: storer,
ManifestParser: jsonmanifest.NewParser(),
Tags: tags.NewTags(),
Logger: logging.New(ioutil.Discard, 5),
})
)
t.Run("download-file-by-path", func(t *testing.T) {
fileName := "sample.html"
filePath := "test/" + fileName
missingFilePath := "test/missing"
sampleHtml := `<!DOCTYPE html>
<html>
<body>
<h1>My First Heading</h1>
<p>My first paragraph.</p>
</body>
</html>`
var err error
var fileContentReference swarm.Address
var fileReference swarm.Address
var manifestFileReference swarm.Address
// save file
fileContentReference, err = file.SplitWriteAll(context.Background(), sp, strings.NewReader(sampleHtml), int64(len(sampleHtml)), false)
if err != nil {
t.Fatal(err)
}
fileMetadata := entry.NewMetadata(fileName)
fileMetadataBytes, err := json.Marshal(fileMetadata)
if err != nil {
t.Fatal(err)
}
fileMetadataReference, err := file.SplitWriteAll(context.Background(), sp, bytes.NewReader(fileMetadataBytes), int64(len(fileMetadataBytes)), false)
if err != nil {
t.Fatal(err)
}
fe := entry.New(fileContentReference, fileMetadataReference)
fileEntryBytes, err := fe.MarshalBinary()
if err != nil {
t.Fatal(err)
}
fileReference, err = file.SplitWriteAll(context.Background(), sp, bytes.NewReader(fileEntryBytes), int64(len(fileEntryBytes)), false)
if err != nil {
t.Fatal(err)
}
// save manifest
jsonManifest := jsonmanifest.NewManifest()
jsonManifest.Add(filePath, jsonmanifest.JSONEntry{
Reference: fileReference,
Name: fileName,
Headers: http.Header{
"Content-Type": {"text/html", "charset=utf-8"},
},
})
manifestFileBytes, err := jsonManifest.Serialize()
if err != nil {
t.Fatal(err)
}
fr, err := file.SplitWriteAll(context.Background(), sp, bytes.NewReader(manifestFileBytes), int64(len(manifestFileBytes)), false)
if err != nil {
t.Fatal(err)
}
m := entry.NewMetadata(fileName)
m.MimeType = api.ManifestContentType
metadataBytes, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
mr, err := file.SplitWriteAll(context.Background(), sp, bytes.NewReader(metadataBytes), int64(len(metadataBytes)), false)
if err != nil {
t.Fatal(err)
}
// now join both references (mr,fr) to create an entry and store it.
newEntry := entry.New(fr, mr)
manifestFileEntryBytes, err := newEntry.MarshalBinary()
if err != nil {
t.Fatal(err)
}
manifestFileReference, err = file.SplitWriteAll(context.Background(), sp, bytes.NewReader(manifestFileEntryBytes), int64(len(manifestFileEntryBytes)), false)
if err != nil {
t.Fatal(err)
}
// read file from manifest path
rcvdHeader := jsonhttptest.ResponseDirectCheckBinaryResponse(t, client, http.MethodGet, bzzDownloadResource(manifestFileReference.String(), filePath), nil, http.StatusOK, []byte(sampleHtml), nil)
cd := rcvdHeader.Get("Content-Disposition")
_, params, err := mime.ParseMediaType(cd)
if err != nil {
t.Fatal(err)
}
if params["filename"] != fileName {
t.Fatal("Invalid file name detected")
}
if rcvdHeader.Get("ETag") != fmt.Sprintf("%q", fileContentReference) {
t.Fatal("Invalid ETags header received")
}
if rcvdHeader.Get("Content-Type") != "text/html; charset=utf-8" {
t.Fatal("Invalid content type detected")
}
// check on invalid path
jsonhttptest.ResponseDirectSendHeadersAndReceiveHeaders(t, client, http.MethodGet, bzzDownloadResource(manifestFileReference.String(), missingFilePath), nil, http.StatusBadRequest, jsonhttp.StatusResponse{
Message: "invalid path address",
Code: http.StatusBadRequest,
}, nil)
})
}
......@@ -30,6 +30,10 @@ import (
"github.com/gorilla/mux"
)
const (
defaultBufSize = 4096
)
const (
multiPartFormData = "multipart/form-data"
EncryptHeader = "swarm-encrypt"
......@@ -254,17 +258,38 @@ func (s *server) fileDownloadHandler(w http.ResponseWriter, r *http.Request) {
return
}
additionalHeaders := http.Header{
"Content-Disposition": {fmt.Sprintf("inline; filename=\"%s\"", metaData.Filename)},
"Content-Type": {metaData.MimeType},
}
s.downloadHandler(w, r, e.Reference(), additionalHeaders)
}
// downloadHandler contains common logic for dowloading Swarm file from API
func (s *server) downloadHandler(
w http.ResponseWriter,
r *http.Request,
reference swarm.Address,
additionalHeaders http.Header,
) {
ctx := r.Context()
toDecrypt := len(reference.Bytes()) == (swarm.HashSize + encryption.KeyLength)
j := joiner.NewSimpleJoiner(s.Storer)
// send the file data back in the response
dataSize, err := j.Size(r.Context(), e.Reference())
dataSize, err := j.Size(ctx, reference)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
s.Logger.Debugf("file download: not found %s: %v", e.Reference(), err)
s.Logger.Errorf("file download: not found %s", addr)
jsonhttp.NotFound(w, nil)
s.Logger.Debugf("api download: not found %s: %v", reference, err)
s.Logger.Error("api download: not found")
jsonhttp.NotFound(w, "not found")
return
}
s.Logger.Debugf("file download: invalid root chunk %s: %v", e.Reference(), err)
s.Logger.Errorf("file download: invalid root chunk %s", addr)
s.Logger.Debugf("api download: invalid root chunk %s: %v", reference, err)
s.Logger.Error("api download: invalid root chunk")
jsonhttp.BadRequest(w, "invalid root chunk")
return
}
......@@ -276,36 +301,46 @@ func (s *server) fileDownloadHandler(w http.ResponseWriter, r *http.Request) {
<-ctx.Done()
if err := ctx.Err(); err != nil {
if err := pr.CloseWithError(err); err != nil {
s.Logger.Debugf("file download: data join close %s: %v", addr, err)
s.Logger.Errorf("file download: data join close %s", addr)
s.Logger.Debugf("api download: data join close %s: %v", reference, err)
s.Logger.Errorf("api download: data join close %s", reference)
}
}
}()
go func() {
_, err := file.JoinReadAll(j, e.Reference(), pw, toDecrypt)
_, err := file.JoinReadAll(j, reference, pw, toDecrypt)
if err := pw.CloseWithError(err); err != nil {
s.Logger.Debugf("file download: data join close %s: %v", addr, err)
s.Logger.Errorf("file download: data join close %s", addr)
s.Logger.Debugf("api download: data join close %s: %v", reference, err)
s.Logger.Errorf("api download: data join close %s", reference)
}
}()
bpr := bufio.NewReader(pr)
if b, err := bpr.Peek(4096); err != nil && err != io.EOF && len(b) == 0 {
s.Logger.Debugf("file download: data join %s: %v", addr, err)
s.Logger.Errorf("file download: data join %s", addr)
if b, err := bpr.Peek(defaultBufSize); err != nil && err != io.EOF && len(b) == 0 {
s.Logger.Debugf("api download: data join %s: %v", reference, err)
s.Logger.Errorf("api download: data join %s", reference)
jsonhttp.NotFound(w, nil)
return
}
w.Header().Set("ETag", fmt.Sprintf("%q", e.Reference()))
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", metaData.Filename))
w.Header().Set("Content-Type", metaData.MimeType)
// include additional headers
for name, values := range additionalHeaders {
var v string
for _, value := range values {
if v != "" {
v += "; "
}
v += value
}
w.Header().Set(name, v)
}
w.Header().Set("ETag", fmt.Sprintf("%q", reference))
w.Header().Set("Content-Length", fmt.Sprintf("%d", dataSize))
w.Header().Set("Decompressed-Content-Length", fmt.Sprintf("%d", dataSize))
if _, err = io.Copy(w, bpr); err != nil {
s.Logger.Debugf("file download: data read %s: %v", addr, err)
s.Logger.Errorf("file download: data read %s", addr)
s.Logger.Debugf("api download: data read %s: %v", reference, err)
s.Logger.Errorf("api download: data read %s", reference)
}
}
......@@ -54,6 +54,10 @@ func (s *server) setupRouting() {
"POST": http.HandlerFunc(s.chunkUploadHandler),
})
handle(router, "/bzz:/{address}/{path:.*}", jsonhttp.MethodHandler{
"GET": http.HandlerFunc(s.bzzDownloadHandler),
})
s.Handler = web.ChainHandlers(
logging.NewHTTPAccessLogHandler(s.Logger, logrus.InfoLevel, "api access"),
handlers.CompressHandler,
......
// Copyright 2020 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package jsonmanifest
import (
"encoding/json"
"net/http"
"github.com/ethersphere/bee/pkg/manifest"
"github.com/ethersphere/bee/pkg/swarm"
)
var _ manifest.Parser = (*JSONParser)(nil)
type JSONParser struct{}
func NewParser() *JSONParser {
return &JSONParser{}
}
func (m *JSONParser) Parse(bytes []byte) (manifest.Interface, error) {
mi := &JSONManifest{}
err := json.Unmarshal(bytes, mi)
return mi, err
}
var _ manifest.Interface = (*JSONManifest)(nil)
type JSONManifest struct {
Entries map[string]JSONEntry `json:"entries,omitempty"`
}
func NewManifest() *JSONManifest {
return &JSONManifest{
Entries: make(map[string]JSONEntry),
}
}
func (m *JSONManifest) Add(path string, entry manifest.Entry) {
m.Entries[path] = JSONEntry{
Reference: entry.GetReference(),
Name: entry.GetName(),
Headers: entry.GetHeaders(),
}
}
func (m *JSONManifest) Remove(path string) {
delete(m.Entries, path)
}
func (m *JSONManifest) FindEntry(path string) (manifest.Entry, error) {
if entry, ok := m.Entries[path]; ok {
return entry, nil
}
return nil, manifest.ErrNotFound
}
func (m *JSONManifest) Serialize() ([]byte, error) {
return json.Marshal(m)
}
var _ manifest.Entry = (*JSONEntry)(nil)
type JSONEntry struct {
Reference swarm.Address `json:"reference"`
Name string `json:"name"`
Headers http.Header `json:"headers"`
}
func (me JSONEntry) GetReference() swarm.Address {
return me.Reference
}
func (me JSONEntry) GetName() string {
return me.Name
}
func (me JSONEntry) GetHeaders() http.Header {
return me.Headers
}
// Copyright 2020 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package manifest
import (
"errors"
"net/http"
"github.com/ethersphere/bee/pkg/swarm"
)
var ErrNotFound = errors.New("manifest: not found")
// Parser for manifest
type Parser interface {
// Parse parses the encoded manifest data and returns the result
Parse(bytes []byte) (Interface, error)
}
// Interface for operations with manifest
type Interface interface {
// Add a manifest entry to specified path
Add(string, Entry)
// Remove reference from file on specified path
Remove(string)
// FindEntry returns manifest entry if one is found on specified path
FindEntry(string) (Entry, error)
// Serialize return encoded manifest
Serialize() ([]byte, error)
}
// Entry represents single manifest entry
type Entry interface {
// GetReference returns address of the entry file
GetReference() swarm.Address
// GetName returns the name of the file for the entry, if added
GetName() string
// GetHeaders returns the headers for manifest entry, if configured
GetHeaders() http.Header
}
......@@ -28,6 +28,7 @@ import (
memkeystore "github.com/ethersphere/bee/pkg/keystore/mem"
"github.com/ethersphere/bee/pkg/localstore"
"github.com/ethersphere/bee/pkg/logging"
"github.com/ethersphere/bee/pkg/manifest/jsonmanifest"
"github.com/ethersphere/bee/pkg/metrics"
"github.com/ethersphere/bee/pkg/netstore"
"github.com/ethersphere/bee/pkg/p2p"
......@@ -300,12 +301,15 @@ func NewBee(o Options) (*Bee, error) {
b.pullerCloser = puller
manifestParser := jsonmanifest.NewParser()
var apiService api.Service
if o.APIAddr != "" {
// API server
apiService = api.New(api.Options{
Tags: tagg,
Storer: ns,
ManifestParser: manifestParser,
CORSAllowedOrigins: o.CORSAllowedOrigins,
Logger: logger,
Tracer: tracer,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment