Add support for Creation With Upload extension

See tus/tus-resumable-upload-protocol#88 for the current proposal
This commit is contained in:
Marius 2016-08-28 22:06:37 +02:00
parent 6b1e8a8bda
commit dc673402d2
5 changed files with 166 additions and 38 deletions

View File

@ -46,7 +46,7 @@ func TestConcatPartial(t *testing.T) {
Method: "OPTIONS", Method: "OPTIONS",
URL: "", URL: "",
ResHeader: map[string]string{ ResHeader: map[string]string{
"Tus-Extension": "creation,concatenation", "Tus-Extension": "creation,creation-with-upload,concatenation",
}, },
Code: http.StatusOK, Code: http.StatusOK,
}).Run(handler, t) }).Run(handler, t)

View File

@ -20,7 +20,7 @@ func TestOptions(t *testing.T) {
Method: "OPTIONS", Method: "OPTIONS",
Code: http.StatusOK, Code: http.StatusOK,
ResHeader: map[string]string{ ResHeader: map[string]string{
"Tus-Extension": "creation", "Tus-Extension": "creation,creation-with-upload",
"Tus-Version": "1.0.0", "Tus-Version": "1.0.0",
"Tus-Resumable": "1.0.0", "Tus-Resumable": "1.0.0",
"Tus-Max-Size": "400", "Tus-Max-Size": "400",

View File

@ -1,44 +1,57 @@
package tusd_test package tusd_test
import ( import (
"bytes"
"io"
"io/ioutil"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
. "github.com/tus/tusd" . "github.com/tus/tusd"
) )
type postStore struct { type postStore struct {
t *testing.T t *assert.Assertions
zeroStore zeroStore
} }
func (s postStore) NewUpload(info FileInfo) (string, error) { func (s postStore) NewUpload(info FileInfo) (string, error) {
if info.Size != 300 { s.t.Equal(int64(300), info.Size)
s.t.Errorf("Expected size to be 300 (got %v)", info.Size)
}
metaData := info.MetaData metaData := info.MetaData
if len(metaData) != 2 { s.t.Equal(2, len(metaData))
s.t.Errorf("Expected two elements in metadata") s.t.Equal("hello", metaData["foo"])
} s.t.Equal("world", metaData["bar"])
if v := metaData["foo"]; v != "hello" {
s.t.Errorf("Expected foo element to be 'hello' but got %s", v)
}
if v := metaData["bar"]; v != "world" {
s.t.Errorf("Expected bar element to be 'world' but got %s", v)
}
return "foo", nil return "foo", nil
} }
func (s postStore) WriteChunk(id string, offset int64, src io.Reader) (int64, error) {
s.t.Equal(int64(0), offset)
data, err := ioutil.ReadAll(src)
s.t.Nil(err)
s.t.Equal("hello", string(data))
return 5, nil
}
func (s postStore) ConcatUploads(id string, uploads []string) error {
s.t.True(false, "concatenation should not be attempted")
return nil
}
func TestPost(t *testing.T) { func TestPost(t *testing.T) {
a := assert.New(t)
handler, _ := NewHandler(Config{ handler, _ := NewHandler(Config{
MaxSize: 400, MaxSize: 400,
BasePath: "files", BasePath: "files",
DataStore: postStore{ DataStore: postStore{
t: t, t: a,
}, },
}) })
@ -87,7 +100,7 @@ func TestPost(t *testing.T) {
MaxSize: 400, MaxSize: 400,
BasePath: "files", BasePath: "files",
DataStore: postStore{ DataStore: postStore{
t: t, t: a,
}, },
RespectForwardedHeaders: true, RespectForwardedHeaders: true,
}) })
@ -141,3 +154,70 @@ func TestPost(t *testing.T) {
}, },
}).Run(handler, t) }).Run(handler, t)
} }
func TestPostWithUpload(t *testing.T) {
a := assert.New(t)
handler, _ := NewHandler(Config{
MaxSize: 400,
BasePath: "files",
DataStore: postStore{
t: a,
},
})
(&httpTest{
Name: "Successful request",
Method: "POST",
ReqHeader: map[string]string{
"Tus-Resumable": "1.0.0",
"Upload-Length": "300",
"Content-Type": "application/offset+octet-stream",
"Upload-Metadata": "foo aGVsbG8=, bar d29ybGQ=",
},
ReqBody: strings.NewReader("hello"),
Code: http.StatusCreated,
ResHeader: map[string]string{
"Location": "http://tus.io/files/foo",
"Upload-Offset": "5",
},
}).Run(handler, t)
(&httpTest{
Name: "Exceeding upload size",
Method: "POST",
ReqHeader: map[string]string{
"Tus-Resumable": "1.0.0",
"Upload-Length": "300",
"Content-Type": "application/offset+octet-stream",
"Upload-Metadata": "foo aGVsbG8=, bar d29ybGQ=",
},
ReqBody: bytes.NewReader(make([]byte, 400)),
Code: http.StatusRequestEntityTooLarge,
}).Run(handler, t)
(&httpTest{
Name: "Incorrect content type",
Method: "POST",
ReqHeader: map[string]string{
"Tus-Resumable": "1.0.0",
"Content-Type": "application/false",
},
ReqBody: strings.NewReader("hello"),
Code: http.StatusBadRequest,
}).Run(handler, t)
(&httpTest{
Name: "Upload and final concatenation",
Method: "POST",
ReqHeader: map[string]string{
"Tus-Resumable": "1.0.0",
"Upload-Length": "300",
"Content-Type": "application/offset+octet-stream",
"Upload-Metadata": "foo aGVsbG8=, bar d29ybGQ=",
"Upload-Concat": "final; http://tus.io/files/a http://tus.io/files/b",
},
ReqBody: strings.NewReader("hello"),
Code: http.StatusForbidden,
}).Run(handler, t)
}

View File

@ -44,7 +44,7 @@ func TestTerminate(t *testing.T) {
Method: "OPTIONS", Method: "OPTIONS",
URL: "", URL: "",
ResHeader: map[string]string{ ResHeader: map[string]string{
"Tus-Extension": "creation,termination", "Tus-Extension": "creation,creation-with-upload,termination",
}, },
Code: http.StatusOK, Code: http.StatusOK,
}).Run(handler, t) }).Run(handler, t)

View File

@ -89,7 +89,7 @@ func NewUnroutedHandler(config Config) (*UnroutedHandler, error) {
} }
// Only promote extesions using the Tus-Extension header which are implemented // Only promote extesions using the Tus-Extension header which are implemented
extensions := "creation" extensions := "creation,creation-with-upload"
if config.StoreComposer.UsesTerminater { if config.StoreComposer.UsesTerminater {
extensions += ",termination" extensions += ",termination"
} }
@ -191,6 +191,16 @@ func (handler *UnroutedHandler) Middleware(h http.Handler) http.Handler {
// PostFile creates a new file upload using the datastore after validating the // PostFile creates a new file upload using the datastore after validating the
// length and parsing the metadata. // length and parsing the metadata.
func (handler *UnroutedHandler) PostFile(w http.ResponseWriter, r *http.Request) { func (handler *UnroutedHandler) PostFile(w http.ResponseWriter, r *http.Request) {
// Check for presence of application/offset+octet-stream
containsChunk := false
if contentType := r.Header.Get("Content-Type"); contentType != "" {
if contentType != "application/offset+octet-stream" {
handler.sendError(w, r, ErrInvalidContentType)
return
}
containsChunk = true
}
// Only use the proper Upload-Concat header if the concatenation extension // Only use the proper Upload-Concat header if the concatenation extension
// is even supported by the data store. // is even supported by the data store.
var concatHeader string var concatHeader string
@ -210,6 +220,12 @@ func (handler *UnroutedHandler) PostFile(w http.ResponseWriter, r *http.Request)
// Upload-Length header) // Upload-Length header)
var size int64 var size int64
if isFinal { if isFinal {
// A final upload must not contain a chunk within the creation request
if containsChunk {
handler.sendError(w, r, ErrModifyFinal)
return
}
size, err = handler.sizeOfUploads(partialUploads) size, err = handler.sizeOfUploads(partialUploads)
if err != nil { if err != nil {
handler.sendError(w, r, err) handler.sendError(w, r, err)
@ -246,6 +262,13 @@ func (handler *UnroutedHandler) PostFile(w http.ResponseWriter, r *http.Request)
return return
} }
// Add the Location header directly after creating the new resource to even
// include it in cases of failure when an error is returned
url := handler.absFileURL(r, id)
w.Header().Set("Location", url)
go handler.Metrics.incUploadsCreated()
if isFinal { if isFinal {
if err := handler.composer.Concater.ConcatUploads(id, partialUploads); err != nil { if err := handler.composer.Concater.ConcatUploads(id, partialUploads); err != nil {
handler.sendError(w, r, err) handler.sendError(w, r, err)
@ -257,15 +280,26 @@ func (handler *UnroutedHandler) PostFile(w http.ResponseWriter, r *http.Request)
info.ID = id info.ID = id
handler.CompleteUploads <- info handler.CompleteUploads <- info
} }
go handler.Metrics.incUploadsFinished()
} }
url := handler.absFileURL(r, id) if containsChunk {
w.Header().Set("Location", url) if handler.composer.UsesLocker {
w.WriteHeader(http.StatusCreated) locker := handler.composer.Locker
if err := locker.LockUpload(id); err != nil {
handler.sendError(w, r, err)
return
}
go handler.Metrics.incUploadsCreated() defer locker.UnlockUpload(id)
}
if err := handler.writeChunk(id, info, w, r); err != nil {
handler.sendError(w, r, err)
return
}
}
w.WriteHeader(http.StatusCreated)
} }
// HeadFile returns the length and offset for the HEAD request // HeadFile returns the length and offset for the HEAD request
@ -372,13 +406,23 @@ func (handler *UnroutedHandler) PatchFile(w http.ResponseWriter, r *http.Request
return return
} }
if err := handler.writeChunk(id, info, w, r); err != nil {
handler.sendError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// PatchFile adds a chunk to an upload. Only allowed enough space is left.
func (handler *UnroutedHandler) writeChunk(id string, info FileInfo, w http.ResponseWriter, r *http.Request) error {
// Get Content-Length if possible // Get Content-Length if possible
length := r.ContentLength length := r.ContentLength
offset := info.Offset
// Test if this upload fits into the file's size // Test if this upload fits into the file's size
if offset+length > info.Size { if offset+length > info.Size {
handler.sendError(w, r, ErrSizeExceeded) return ErrSizeExceeded
return
} }
maxSize := info.Size - offset maxSize := info.Size - offset
@ -386,13 +430,18 @@ func (handler *UnroutedHandler) PatchFile(w http.ResponseWriter, r *http.Request
maxSize = length maxSize = length
} }
// Limit the data read from the request's body to the allowed maxiumum var bytesWritten int64
reader := io.LimitReader(r.Body, maxSize) // Prevent a nil pointer derefernce when accessing the body which may not be
// available in the case of a malicious request.
if r.Body != nil {
// Limit the data read from the request's body to the allowed maxiumum
reader := io.LimitReader(r.Body, maxSize)
bytesWritten, err := handler.composer.Core.WriteChunk(id, offset, reader) var err error
if err != nil { bytesWritten, err = handler.composer.Core.WriteChunk(id, offset, reader)
handler.sendError(w, r, err) if err != nil {
return return err
}
} }
// Send new offset to client // Send new offset to client
@ -405,8 +454,7 @@ func (handler *UnroutedHandler) PatchFile(w http.ResponseWriter, r *http.Request
// ... allow custom mechanism to finish and cleanup the upload // ... allow custom mechanism to finish and cleanup the upload
if handler.composer.UsesFinisher { if handler.composer.UsesFinisher {
if err := handler.composer.Finisher.FinishUpload(id); err != nil { if err := handler.composer.Finisher.FinishUpload(id); err != nil {
handler.sendError(w, r, err) return err
return
} }
} }
@ -419,7 +467,7 @@ func (handler *UnroutedHandler) PatchFile(w http.ResponseWriter, r *http.Request
go handler.Metrics.incUploadsFinished() go handler.Metrics.incUploadsFinished()
} }
w.WriteHeader(http.StatusNoContent) return nil
} }
// GetFile handles requests to download a file using a GET request. This is not // GetFile handles requests to download a file using a GET request. This is not