rewrite tusd
* expose tusd.DataStore and extracted FileStore * use pat for routing * allow absolute BasePaths * requires StripPrefix * add support for 1.0 core * update date in license
This commit is contained in:
parent
3db2976bd5
commit
a70bd4cfa3
|
@ -1,2 +1,2 @@
|
||||||
/tus_data
|
tusd/data
|
||||||
/gopath
|
cover.out
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
Copyright (c) 2013 Transloadit Ltd and Contributors
|
Copyright (c) 2013-2015 Transloadit Ltd and Contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
|
|
|
@ -10,7 +10,7 @@ In the future tusd may be extended with additional functionality to make it
|
||||||
suitable as a standalone production upload server, but for now this is not a
|
suitable as a standalone production upload server, but for now this is not a
|
||||||
priority.
|
priority.
|
||||||
|
|
||||||
**Protocol version:** 0.2.1
|
**Protocol version:** 1.0.0
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package tusd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetaData map[string]string
|
||||||
|
|
||||||
|
type FileInfo struct {
|
||||||
|
Id string
|
||||||
|
Size int64
|
||||||
|
Offset int64
|
||||||
|
MetaData MetaData
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataStore interface {
|
||||||
|
NewUpload(size int64, metaData MetaData) (string, error)
|
||||||
|
WriteChunk(id string, offset int64, src io.Reader) error
|
||||||
|
GetInfo(id string) (FileInfo, error)
|
||||||
|
}
|
9
dev.sh
9
dev.sh
|
@ -1,9 +0,0 @@
|
||||||
#!/usr/bin/bash
|
|
||||||
|
|
||||||
# usage: source dev.sh
|
|
||||||
#
|
|
||||||
# dev.sh simplifies development by setting up a local GOPATH.
|
|
||||||
export GOPATH=`pwd`/gopath
|
|
||||||
src_dir="${GOPATH}/src/github.com/tus/tusd"
|
|
||||||
mkdir -p "${src_dir}"
|
|
||||||
ln -fs "`pwd`/src" "${src_dir}"
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
package filestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/tus/tusd"
|
||||||
|
"github.com/tus/tusd/uid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultFilePerm = os.FileMode(0666)
|
||||||
|
|
||||||
|
type FileStore struct {
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) NewUpload(size int64, metaData tusd.MetaData) (id string, err error) {
|
||||||
|
id = uid.Uid()
|
||||||
|
info := tusd.FileInfo{
|
||||||
|
Id: id,
|
||||||
|
Size: size,
|
||||||
|
Offset: 0,
|
||||||
|
MetaData: metaData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .bin file with no content
|
||||||
|
file, err := os.OpenFile(store.binPath(id), os.O_CREATE|os.O_WRONLY, defaultFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// writeInfo creates the file by itself if necessary
|
||||||
|
err = store.writeInfo(id, info)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) WriteChunk(id string, offset int64, src io.Reader) error {
|
||||||
|
file, err := os.OpenFile(store.binPath(id), os.O_WRONLY|os.O_APPEND, defaultFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
n, err := io.Copy(file, src)
|
||||||
|
if n > 0 {
|
||||||
|
if err := store.setOffset(id, offset+n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) GetInfo(id string) (tusd.FileInfo, error) {
|
||||||
|
info := tusd.FileInfo{}
|
||||||
|
data, err := ioutil.ReadFile(store.infoPath(id))
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(data, &info)
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) binPath(id string) string {
|
||||||
|
return store.Path + "/" + id + ".bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) infoPath(id string) string {
|
||||||
|
return store.Path + "/" + id + ".info"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) writeInfo(id string, info tusd.FileInfo) error {
|
||||||
|
data, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ioutil.WriteFile(store.infoPath(id), data, defaultFilePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store FileStore) setOffset(id string, offset int64) error {
|
||||||
|
info, err := store.GetInfo(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// never decrement the offset
|
||||||
|
if info.Offset >= offset {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Offset = offset
|
||||||
|
return store.writeInfo(id, info)
|
||||||
|
}
|
|
@ -0,0 +1,273 @@
|
||||||
|
package tusd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/bmizerany/pat"
|
||||||
|
)
|
||||||
|
|
||||||
|
var logger = log.New(os.Stdout, "[tusd] ", 0)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnsupportedVersion = errors.New("unsupported version")
|
||||||
|
ErrMaxSizeExceeded = errors.New("maximum size exceeded")
|
||||||
|
ErrInvalidEntityLength = errors.New("missing or invalid Entity-Length header")
|
||||||
|
ErrInvalidOffset = errors.New("missing or invalid Offset header")
|
||||||
|
ErrNotFound = errors.New("upload not found")
|
||||||
|
ErrFileLocked = errors.New("file currently locked")
|
||||||
|
ErrIllegalOffset = errors.New("illegal offset")
|
||||||
|
ErrSizeExceeded = errors.New("resource's size exceeded")
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrStatusCodes = map[error]int{
|
||||||
|
ErrUnsupportedVersion: http.StatusPreconditionFailed,
|
||||||
|
ErrMaxSizeExceeded: http.StatusRequestEntityTooLarge,
|
||||||
|
ErrInvalidEntityLength: http.StatusBadRequest,
|
||||||
|
ErrInvalidOffset: http.StatusBadRequest,
|
||||||
|
ErrNotFound: http.StatusNotFound,
|
||||||
|
ErrFileLocked: 423, // Locked (WebDAV) (RFC 4918)
|
||||||
|
ErrIllegalOffset: http.StatusConflict,
|
||||||
|
ErrSizeExceeded: http.StatusRequestEntityTooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DataStore DataStore
|
||||||
|
// MaxSize defines how many bytes may be stored in one single upload. If its
|
||||||
|
// value is is 0 or smaller no limit will be enforced.
|
||||||
|
MaxSize int64
|
||||||
|
// BasePath defines the URL path used for handling uploads, e.g. "/files/".
|
||||||
|
// If no trailing slash is presented it will be added. You may specify an
|
||||||
|
// absolute URL containing a scheme, e.g. "http://tus.io"
|
||||||
|
BasePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
config Config
|
||||||
|
dataStore DataStore
|
||||||
|
isBasePathAbs bool
|
||||||
|
basePath string
|
||||||
|
routeHandler http.Handler
|
||||||
|
locks map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(config Config) (*Handler, error) {
|
||||||
|
base := config.BasePath
|
||||||
|
uri, err := url.Parse(base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure base path ends with slash to remove logic from absFileUrl
|
||||||
|
if base != "" && string(base[len(base)-1]) != "/" {
|
||||||
|
base += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure base path begins with slash if not absolute (starts with scheme)
|
||||||
|
if !uri.IsAbs() && len(base) > 0 && string(base[0]) != "/" {
|
||||||
|
base = "/" + base
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := pat.New()
|
||||||
|
|
||||||
|
handler := &Handler{
|
||||||
|
config: config,
|
||||||
|
dataStore: config.DataStore,
|
||||||
|
basePath: base,
|
||||||
|
isBasePathAbs: uri.IsAbs(),
|
||||||
|
routeHandler: mux,
|
||||||
|
locks: make(map[string]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.Post("", http.HandlerFunc(handler.postFile))
|
||||||
|
mux.Head(":id", http.HandlerFunc(handler.headFile))
|
||||||
|
mux.Add("PATCH", ":id", http.HandlerFunc(handler.patchFile))
|
||||||
|
|
||||||
|
return handler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
go logger.Println(r.Method, r.URL.Path)
|
||||||
|
|
||||||
|
header := w.Header()
|
||||||
|
|
||||||
|
if origin := r.Header.Get("Origin"); origin != "" {
|
||||||
|
header.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
// Preflight request
|
||||||
|
header.Set("Access-Control-Allow-Methods", "POST, HEAD, PATCH, OPTIONS")
|
||||||
|
header.Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Entity-Length, Offset, TUS-Resumable")
|
||||||
|
header.Set("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Actual request
|
||||||
|
header.Set("Access-Control-Expose-Headers", "Offset, Location, Entity-Length, TUS-Version, TUS-Resumable, TUS-Max-Size, TUS-Extension")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current version used by the server
|
||||||
|
header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
|
||||||
|
// Set appropriated headers in case of OPTIONS method allowing protocol
|
||||||
|
// discovery and end with an 204 No Content
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
if handler.config.MaxSize > 0 {
|
||||||
|
header.Set("TUS-Max-Size", strconv.FormatInt(handler.config.MaxSize, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Set("TUS-Version", "1.0.0")
|
||||||
|
header.Set("TUS-Extension", "file-creation")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if the version sent by the client is supported
|
||||||
|
if r.Header.Get("TUS-Resumable") != "1.0.0" {
|
||||||
|
handler.sendError(w, ErrUnsupportedVersion)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with routing the request
|
||||||
|
handler.routeHandler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) postFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
size, err := strconv.ParseInt(r.Header.Get("Entity-Length"), 10, 64)
|
||||||
|
if err != nil || size < 0 {
|
||||||
|
handler.sendError(w, ErrInvalidEntityLength)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test whether the size is still allowed
|
||||||
|
if size > handler.config.MaxSize {
|
||||||
|
handler.sendError(w, ErrMaxSizeExceeded)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// @TODO: implement metadata extension
|
||||||
|
meta := make(map[string]string)
|
||||||
|
|
||||||
|
id, err := handler.dataStore.NewUpload(size, meta)
|
||||||
|
if err != nil {
|
||||||
|
handler.sendError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := handler.absFileUrl(r, id)
|
||||||
|
w.Header().Set("Location", url)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) headFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get(":id")
|
||||||
|
info, err := handler.dataStore.GetInfo(id)
|
||||||
|
if err != nil {
|
||||||
|
// Interpret os.ErrNotExist as 404 Not Found
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
err = ErrNotFound
|
||||||
|
}
|
||||||
|
handler.sendError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Entity-Length", strconv.FormatInt(info.Size, 10))
|
||||||
|
w.Header().Set("Offset", strconv.FormatInt(info.Offset, 10))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) patchFile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get(":id")
|
||||||
|
|
||||||
|
// Ensure file is not locked
|
||||||
|
if _, ok := handler.locks[id]; ok {
|
||||||
|
handler.sendError(w, ErrFileLocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock file for further writes (heads are allowed)
|
||||||
|
handler.locks[id] = true
|
||||||
|
|
||||||
|
// File will be unlocked regardless of an error or success
|
||||||
|
defer func() {
|
||||||
|
delete(handler.locks, id)
|
||||||
|
}()
|
||||||
|
|
||||||
|
info, err := handler.dataStore.GetInfo(id)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
err = ErrNotFound
|
||||||
|
}
|
||||||
|
handler.sendError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the offsets match
|
||||||
|
offset, err := strconv.ParseInt(r.Header.Get("Offset"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
handler.sendError(w, ErrInvalidOffset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset != info.Offset {
|
||||||
|
handler.sendError(w, ErrIllegalOffset)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Content-Length if possible
|
||||||
|
length := r.ContentLength
|
||||||
|
|
||||||
|
// Test if this upload fits into the file's size
|
||||||
|
if offset+length > info.Size {
|
||||||
|
handler.sendError(w, ErrSizeExceeded)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxSize := info.Size - offset
|
||||||
|
if length > 0 {
|
||||||
|
maxSize = length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit the
|
||||||
|
reader := io.LimitReader(r.Body, maxSize)
|
||||||
|
|
||||||
|
err = handler.dataStore.WriteChunk(id, offset, reader)
|
||||||
|
if err != nil {
|
||||||
|
handler.sendError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) sendError(w http.ResponseWriter, err error) {
|
||||||
|
status, ok := ErrStatusCodes[err]
|
||||||
|
if !ok {
|
||||||
|
status = 500
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
w.Write([]byte(err.Error() + "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) absFileUrl(r *http.Request, id string) string {
|
||||||
|
if handler.isBasePathAbs {
|
||||||
|
return handler.basePath + id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read origin and protocol from request
|
||||||
|
url := "http://"
|
||||||
|
if r.TLS != nil {
|
||||||
|
url = "https://"
|
||||||
|
}
|
||||||
|
|
||||||
|
url += r.Host + handler.basePath + id
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
|
@ -0,0 +1,381 @@
|
||||||
|
package tusd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type zeroStore struct{}
|
||||||
|
|
||||||
|
func (store zeroStore) NewUpload(size int64, metaData MetaData) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
func (store zeroStore) WriteChunk(id string, offset int64, src io.Reader) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store zeroStore) GetInfo(id string) (FileInfo, error) {
|
||||||
|
return FileInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORS(t *testing.T) {
|
||||||
|
handler, _ := NewHandler(Config{})
|
||||||
|
|
||||||
|
// Test preflight request
|
||||||
|
req, _ := http.NewRequest("OPTIONS", "", nil)
|
||||||
|
req.Header.Set("Origin", "tus.io")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected 204 No Content for OPTIONS request (got %v)", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := []string{
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"Access-Control-Max-Age",
|
||||||
|
}
|
||||||
|
for _, header := range headers {
|
||||||
|
if _, ok := w.HeaderMap[header]; !ok {
|
||||||
|
t.Errorf("Header '%s' not contained in response", header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
origin := w.HeaderMap.Get("Access-Control-Allow-Origin")
|
||||||
|
if origin != "tus.io" {
|
||||||
|
t.Errorf("Allowed origin not 'tus.io' but '%s'", origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test actual request
|
||||||
|
req, _ = http.NewRequest("GET", "", nil)
|
||||||
|
req.Header.Set("Origin", "tus.io")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
origin = w.HeaderMap.Get("Access-Control-Allow-Origin")
|
||||||
|
if origin != "tus.io" {
|
||||||
|
t.Errorf("Allowed origin not 'tus.io' but '%s'", origin)
|
||||||
|
}
|
||||||
|
if _, ok := w.HeaderMap["Access-Control-Expose-Headers"]; !ok {
|
||||||
|
t.Error("Expose-Headers not contained in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtocolDiscovery(t *testing.T) {
|
||||||
|
handler, _ := NewHandler(Config{
|
||||||
|
MaxSize: 400,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test successful OPTIONS request
|
||||||
|
req, _ := http.NewRequest("OPTIONS", "", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected 204 No Content for OPTIONS request (got %v)", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"TUS-Extension": "file-creation",
|
||||||
|
"TUS-Version": "1.0.0",
|
||||||
|
"TUS-Resumable": "1.0.0",
|
||||||
|
"TUS-Max-Size": "400",
|
||||||
|
}
|
||||||
|
for header, value := range headers {
|
||||||
|
if v := w.HeaderMap.Get(header); value != v {
|
||||||
|
t.Errorf("Header '%s' not contained in response", header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid or unsupported version
|
||||||
|
req, _ = http.NewRequest("GET", "", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "foo")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusPreconditionFailed {
|
||||||
|
t.Errorf("Expected 412 Precondition Failed (got %v)", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type postStore struct {
|
||||||
|
t *testing.T
|
||||||
|
zeroStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s postStore) NewUpload(size int64, metaData MetaData) (string, error) {
|
||||||
|
if size != 300 {
|
||||||
|
s.t.Errorf("Expected size to be 300 (got %v)", size)
|
||||||
|
}
|
||||||
|
return "foo", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCreation(t *testing.T) {
|
||||||
|
handler, _ := NewHandler(Config{
|
||||||
|
MaxSize: 400,
|
||||||
|
BasePath: "files",
|
||||||
|
DataStore: postStore{
|
||||||
|
t: t,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test successful request
|
||||||
|
req, _ := http.NewRequest("POST", "", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Entity-Length", "300")
|
||||||
|
req.Host = "tus.io"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Errorf("Expected 201 Created for OPTIONS request (got %v)", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if location := w.HeaderMap.Get("Location"); location != "http://tus.io/files/foo" {
|
||||||
|
t.Errorf("Unexpected location header (got '%v')", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test exceeding MaxSize
|
||||||
|
req, _ = http.NewRequest("POST", "", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Entity-Length", "500")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("Expected %v for OPTIONS request (got %v)", http.StatusRequestEntityTooLarge, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type headStore struct {
|
||||||
|
zeroStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s headStore) GetInfo(id string) (FileInfo, error) {
|
||||||
|
if id != "yes" {
|
||||||
|
return FileInfo{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileInfo{
|
||||||
|
Offset: 11,
|
||||||
|
Size: 44,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetInfo(t *testing.T) {
|
||||||
|
handler, _ := NewHandler(Config{
|
||||||
|
BasePath: "https://buy.art/",
|
||||||
|
DataStore: headStore{},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test successful request
|
||||||
|
req, _ := http.NewRequest("HEAD", "yes", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusNoContent, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
"Offset": "11",
|
||||||
|
"Entity-Length": "44",
|
||||||
|
}
|
||||||
|
for header, value := range headers {
|
||||||
|
if v := w.HeaderMap.Get(header); value != v {
|
||||||
|
t.Errorf("Unexpected header value '%s': %v", header, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test non-existing file
|
||||||
|
req, _ = http.NewRequest("HEAD", "no", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type patchStore struct {
|
||||||
|
zeroStore
|
||||||
|
t *testing.T
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s patchStore) GetInfo(id string) (FileInfo, error) {
|
||||||
|
if id != "yes" {
|
||||||
|
return FileInfo{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileInfo{
|
||||||
|
Offset: 5,
|
||||||
|
Size: 20,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s patchStore) WriteChunk(id string, offset int64, src io.Reader) error {
|
||||||
|
if s.called {
|
||||||
|
s.t.Errorf("WriteChunk must be called only once")
|
||||||
|
}
|
||||||
|
s.called = true
|
||||||
|
|
||||||
|
if offset != 5 {
|
||||||
|
s.t.Errorf("Expected offset to be 5 (got %v)", offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != "hello" {
|
||||||
|
s.t.Errorf("Expected source to be 'hello'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatch(t *testing.T) {
|
||||||
|
handler, _ := NewHandler(Config{
|
||||||
|
MaxSize: 100,
|
||||||
|
DataStore: patchStore{
|
||||||
|
t: t,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test successful request
|
||||||
|
req, _ := http.NewRequest("PATCH", "yes", strings.NewReader("hello"))
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Offset", "5")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusNoContent, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test non-existing file
|
||||||
|
req, _ = http.NewRequest("PATCH", "no", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Offset", "0")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test wrong offset
|
||||||
|
req, _ = http.NewRequest("PATCH", "yes", nil)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Offset", "4")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusConflict, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test exceeding file size
|
||||||
|
req, _ = http.NewRequest("PATCH", "yes", strings.NewReader("hellothisismorethan15bytes"))
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Offset", "5")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusRequestEntityTooLarge, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type overflowPatchStore struct {
|
||||||
|
zeroStore
|
||||||
|
t *testing.T
|
||||||
|
called bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s overflowPatchStore) GetInfo(id string) (FileInfo, error) {
|
||||||
|
if id != "yes" {
|
||||||
|
return FileInfo{}, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileInfo{
|
||||||
|
Offset: 5,
|
||||||
|
Size: 20,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s overflowPatchStore) WriteChunk(id string, offset int64, src io.Reader) error {
|
||||||
|
if s.called {
|
||||||
|
s.t.Errorf("WriteChunk must be called only once")
|
||||||
|
}
|
||||||
|
s.called = true
|
||||||
|
|
||||||
|
if offset != 5 {
|
||||||
|
s.t.Errorf("Expected offset to be 5 (got %v)", offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
s.t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) != 15 {
|
||||||
|
s.t.Errorf("Expected 15 bytes got %v", len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noEOFReader implements io.Reader, io.Writer, io.Closer but does not return
|
||||||
|
// an io.EOF when the internal buffer is empty. This way we can simulate slow
|
||||||
|
// networks.
|
||||||
|
type noEOFReader struct {
|
||||||
|
closed bool
|
||||||
|
buffer []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *noEOFReader) Read(dst []byte) (int, error) {
|
||||||
|
if r.closed && len(r.buffer) == 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
n := copy(dst, r.buffer)
|
||||||
|
r.buffer = r.buffer[n:]
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *noEOFReader) Close() error {
|
||||||
|
r.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *noEOFReader) Write(src []byte) (int, error) {
|
||||||
|
r.buffer = append(r.buffer, src...)
|
||||||
|
return len(src), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPatchOverflow(t *testing.T) {
|
||||||
|
handler, _ := NewHandler(Config{
|
||||||
|
MaxSize: 100,
|
||||||
|
DataStore: overflowPatchStore{
|
||||||
|
t: t,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
body := &noEOFReader{}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
body.Write([]byte("hellothisismorethan15bytes"))
|
||||||
|
body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test too big body exceeding file size
|
||||||
|
req, _ := http.NewRequest("PATCH", "yes", body)
|
||||||
|
req.Header.Set("TUS-Resumable", "1.0.0")
|
||||||
|
req.Header.Set("Offset", "5")
|
||||||
|
req.Header.Set("Content-Length", "3")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected %v (got %v)", http.StatusNoContent, w.Code)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,72 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# This script demonstrates basic interaction with tusd form BASH/curl.
|
|
||||||
# Can also be used as a simple way to test, or extend to see how it
|
|
||||||
# responds to edge cases or learn the basic tech.
|
|
||||||
#
|
|
||||||
# Compatible with tus resumable upload protocol 0.1
|
|
||||||
|
|
||||||
# Constants
|
|
||||||
SERVICE="localhost:1080"
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
set -e
|
|
||||||
__FILE__="$(test -L "${0}" && readlink "${0}" || echo "${0}")"
|
|
||||||
__DIR__="$(cd "$(dirname "${__FILE__}")"; echo $(pwd);)"
|
|
||||||
|
|
||||||
# POST requests the upload location
|
|
||||||
echo -ne "POST '${SERVICE}' \t\t\t\t\t\t\t"
|
|
||||||
location=$(curl -s \
|
|
||||||
--include \
|
|
||||||
--request POST \
|
|
||||||
--header 'Content-Range: bytes */26' \
|
|
||||||
${SERVICE}/files |awk -F': ' '/^Location/ {print $2}' |tr -d '\r')
|
|
||||||
# `tr -d '\r'` is required or location will have one in it ---^
|
|
||||||
echo "<-- Location: ${location}"
|
|
||||||
|
|
||||||
|
|
||||||
# PUT some data
|
|
||||||
echo -ne "PUT '${SERVICE}${location}' \t\t"
|
|
||||||
status=$(curl -s \
|
|
||||||
--include \
|
|
||||||
--request PUT \
|
|
||||||
--header 'Content-Length: 3' \
|
|
||||||
--header 'Content-Range: bytes 0-2/26' \
|
|
||||||
--data 'abc' \
|
|
||||||
${SERVICE}${location} |head -1 |tr -d '\r')
|
|
||||||
echo "<-- ${status}"
|
|
||||||
|
|
||||||
# check that data with HEAD
|
|
||||||
echo -ne "HEAD '${SERVICE}${location}' \t\t"
|
|
||||||
has_range=$(curl -s -I -X HEAD ${SERVICE}${location} |awk -F': ' '/^Range/ {print $2}' |tr -d '\r')
|
|
||||||
echo "<-- Range: ${has_range}"
|
|
||||||
|
|
||||||
# NB: getting partials is not supported and results in a
|
|
||||||
# CopyN of size %!s(int64=26) failed with: EOF
|
|
||||||
# should you try uncommenting this:
|
|
||||||
#echo -ne "GET '${SERVICE}${location}' \t\t"
|
|
||||||
#has_content=$(curl -s ${SERVICE}${location})
|
|
||||||
#echo "<-- ${has_content}"
|
|
||||||
|
|
||||||
|
|
||||||
# PUT some data
|
|
||||||
echo -ne "PUT '${SERVICE}${location}' \t\t"
|
|
||||||
status=$(curl -s \
|
|
||||||
--include \
|
|
||||||
--request PUT \
|
|
||||||
--header 'Content-Length: 3' \
|
|
||||||
--header 'Content-Range: bytes 23-25/26' \
|
|
||||||
--data 'xyz' \
|
|
||||||
${SERVICE}${location} |head -1 |tr -d '\r')
|
|
||||||
echo "<-- ${status}"
|
|
||||||
|
|
||||||
# check that data with HEAD
|
|
||||||
echo -ne "HEAD '${SERVICE}${location}' \t\t"
|
|
||||||
has_range=$(curl -s -I -X HEAD ${SERVICE}${location} |awk -F': ' '/^Range/ {print $2}' |tr -d '\r')
|
|
||||||
echo "<-- Range: ${has_range}"
|
|
||||||
|
|
||||||
# get that data with GET
|
|
||||||
echo -ne "GET '${SERVICE}${location}' \t\t"
|
|
||||||
has_content=$(curl -s ${SERVICE}${location})
|
|
||||||
echo "<-- ${has_content}"
|
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
tushttp "github.com/tus/tusd/src/http"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const basePath = "/files/"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
|
||||||
log.Printf("tusd started")
|
|
||||||
|
|
||||||
addr := ":1080"
|
|
||||||
if envPort := os.Getenv("TUSD_PORT"); envPort != "" {
|
|
||||||
addr = ":" + envPort
|
|
||||||
}
|
|
||||||
|
|
||||||
maxSize := int64(1024 * 1024 * 1024)
|
|
||||||
if envMaxSize := os.Getenv("TUSD_DATA_STORE_MAXSIZE"); envMaxSize != "" {
|
|
||||||
parsed, err := strconv.ParseInt(envMaxSize, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
panic("bad TUSD_DATA_STORE_MAXSIZE: " + err.Error())
|
|
||||||
}
|
|
||||||
maxSize = parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := os.Getenv("TUSD_DATA_DIR")
|
|
||||||
if dir == "" {
|
|
||||||
if workingDir, err := os.Getwd(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
} else {
|
|
||||||
dir = filepath.Join(workingDir, "tus_data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tusConfig := tushttp.HandlerConfig{
|
|
||||||
Dir: dir,
|
|
||||||
MaxSize: maxSize,
|
|
||||||
BasePath: basePath,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("handler config: %+v", tusConfig)
|
|
||||||
|
|
||||||
tusHandler, err := tushttp.NewHandler(tusConfig)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
http.HandleFunc(basePath, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Allow CORS for almost everything. This needs to be revisted / limited to
|
|
||||||
// routes and methods that need it.
|
|
||||||
|
|
||||||
// Domains allowed to make requests
|
|
||||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
|
||||||
// Methods clients are allowed to use
|
|
||||||
w.Header().Add("Access-Control-Allow-Methods", "HEAD,GET,PUT,POST,PATCH,DELETE")
|
|
||||||
// Headers clients are allowed to send
|
|
||||||
w.Header().Add("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Content-Disposition, Final-Length, Offset")
|
|
||||||
// Headers clients are allowed to receive
|
|
||||||
w.Header().Add("Access-Control-Expose-Headers", "Location, Range, Content-Disposition, Offset")
|
|
||||||
|
|
||||||
if r.Method == "OPTIONS" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tusHandler.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
go handleUploads(tusHandler)
|
|
||||||
|
|
||||||
// On http package's default action, a broken http connection will cause io.Copy() stuck because it always suppose more data will coming and wait for them infinitely
|
|
||||||
// To prevent it happen, we should set a specific timeout value on http server
|
|
||||||
s := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: nil,
|
|
||||||
ReadTimeout: 8 * time.Second,
|
|
||||||
WriteTimeout: 8 * time.Second,
|
|
||||||
MaxHeaderBytes: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("servering clients at http://localhost%s", addr)
|
|
||||||
if err := s.ListenAndServe(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleUploads(tus *tushttp.Handler) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case err := <-tus.Error:
|
|
||||||
log.Printf("error: %s", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// chunk holds the offsets for a partial piece of data
|
|
||||||
type chunk struct {
|
|
||||||
Start int64 `json:"start"`
|
|
||||||
End int64 `json:"end"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Size returns the number of bytes between Start and End.
|
|
||||||
func (c chunk) Size() int64 {
|
|
||||||
return c.End - c.Start + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// chunkSet holds a set of chunks and helps with adding/merging new chunks into
|
|
||||||
// set set.
|
|
||||||
type chunkSet []chunk
|
|
||||||
|
|
||||||
// Add merges a newChunk into a chunkSet. This may lead to the chunk being
|
|
||||||
// combined with one or more adjecent chunks, possibly shrinking the chunkSet
|
|
||||||
// down to a single member.
|
|
||||||
func (c *chunkSet) Add(newChunk chunk) {
|
|
||||||
if newChunk.Size() <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
*c = append(*c, newChunk)
|
|
||||||
sort.Sort(c)
|
|
||||||
|
|
||||||
// merge chunks that can be combined
|
|
||||||
for i := 0; i < len(*c)-1; i++ {
|
|
||||||
current := (*c)[i]
|
|
||||||
next := (*c)[i+1]
|
|
||||||
|
|
||||||
if current.End+1 < next.Start {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
*c = append((*c)[0:i], (*c)[i+1:]...)
|
|
||||||
|
|
||||||
if current.End > next.End {
|
|
||||||
(*c)[i].End = current.End
|
|
||||||
}
|
|
||||||
|
|
||||||
if current.Start < next.Start {
|
|
||||||
(*c)[i].Start = current.Start
|
|
||||||
}
|
|
||||||
|
|
||||||
i--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c chunkSet) Len() int {
|
|
||||||
return len(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c chunkSet) Less(i, j int) bool {
|
|
||||||
return c[i].Start < c[j].Start
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c chunkSet) Swap(i, j int) {
|
|
||||||
c[i], c[j] = c[j], c[i]
|
|
||||||
}
|
|
|
@ -1,99 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
var chunkSet_AddTests = []struct {
|
|
||||||
Name string
|
|
||||||
Add []chunk
|
|
||||||
Expect []chunk
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Name: "add one",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 5}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "add twice",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 1, End: 5}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 5}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "append",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 7, End: 10}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 5}, {Start: 7, End: 10}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "insert",
|
|
||||||
Add: []chunk{{Start: 0, End: 5}, {Start: 12, End: 15}, {Start: 7, End: 10}},
|
|
||||||
Expect: []chunk{{Start: 0, End: 5}, {Start: 7, End: 10}, {Start: 12, End: 15}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "prepend",
|
|
||||||
Add: []chunk{{Start: 5, End: 10}, {Start: 1, End: 3}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 3}, {Start: 5, End: 10}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "grow start",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 0, End: 5}},
|
|
||||||
Expect: []chunk{{Start: 0, End: 5}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "grow end",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 1, End: 6}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 6}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "grow end with multiple items",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 7, End: 10}, {Start: 8, End: 15}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 5}, {Start: 7, End: 15}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "grow exact end match",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 6, End: 6}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 6}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "sink",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 2, End: 3}},
|
|
||||||
Expect: []chunk{{Start: 1, End: 5}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "swallow",
|
|
||||||
Add: []chunk{{Start: 1, End: 5}, {Start: 6, End: 10}, {Start: 0, End: 11}},
|
|
||||||
Expect: []chunk{{Start: 0, End: 11}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "ignore 0 byte chunks",
|
|
||||||
Add: []chunk{{Start: 0, End: -1}},
|
|
||||||
Expect: []chunk{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "ignore invalid chunks",
|
|
||||||
Add: []chunk{{Start: 0, End: -2}},
|
|
||||||
Expect: []chunk{},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_chunkSet_Add(t *testing.T) {
|
|
||||||
for _, test := range chunkSet_AddTests {
|
|
||||||
var chunks chunkSet
|
|
||||||
for _, chunk := range test.Add {
|
|
||||||
chunks.Add(chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := fmt.Sprintf("%+v", test.Expect)
|
|
||||||
got := fmt.Sprintf("%+v", chunks)
|
|
||||||
|
|
||||||
if got != expected {
|
|
||||||
t.Errorf(
|
|
||||||
"Failed test '%s':\nexpected: %s\ngot: %s",
|
|
||||||
test.Name,
|
|
||||||
expected,
|
|
||||||
got,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
package http
|
|
|
@ -1,261 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultFilePerm = 0666
|
|
||||||
|
|
||||||
// @TODO should not be exported for now, the API isn't stable / done well
|
|
||||||
type dataStore struct {
|
|
||||||
dir string
|
|
||||||
maxSize int64
|
|
||||||
|
|
||||||
// infoLocksLock locks the infosLocks map
|
|
||||||
infoLocksLock *sync.Mutex
|
|
||||||
// infoLocks locks the .info files
|
|
||||||
infoLocks map[string]*sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDataStore(dir string, maxSize int64) *dataStore {
|
|
||||||
store := &dataStore{
|
|
||||||
dir: dir,
|
|
||||||
maxSize: maxSize,
|
|
||||||
infoLocksLock: &sync.Mutex{},
|
|
||||||
infoLocks: make(map[string]*sync.RWMutex),
|
|
||||||
}
|
|
||||||
go store.gcLoop()
|
|
||||||
return store
|
|
||||||
}
|
|
||||||
|
|
||||||
// infoLock returns the lock for the .info file of the given file id.
|
|
||||||
func (s *dataStore) infoLock(id string) *sync.RWMutex {
|
|
||||||
s.infoLocksLock.Lock()
|
|
||||||
defer s.infoLocksLock.Unlock()
|
|
||||||
|
|
||||||
lock := s.infoLocks[id]
|
|
||||||
if lock == nil {
|
|
||||||
lock = &sync.RWMutex{}
|
|
||||||
s.infoLocks[id] = lock
|
|
||||||
}
|
|
||||||
return lock
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) CreateFile(id string, finalLength int64, meta map[string]string) error {
|
|
||||||
file, err := os.OpenFile(s.filePath(id), os.O_CREATE|os.O_WRONLY, defaultFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
s.infoLock(id).Lock()
|
|
||||||
defer s.infoLock(id).Unlock()
|
|
||||||
|
|
||||||
return s.writeInfo(id, FileInfo{FinalLength: finalLength, Meta: meta})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) WriteFileChunk(id string, offset int64, src io.Reader) error {
|
|
||||||
file, err := os.OpenFile(s.filePath(id), os.O_WRONLY, defaultFilePerm)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
if n, err := file.Seek(offset, os.SEEK_SET); err != nil {
|
|
||||||
return err
|
|
||||||
} else if n != offset {
|
|
||||||
return errors.New("WriteFileChunk: seek failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := io.Copy(file, src)
|
|
||||||
if n > 0 {
|
|
||||||
if err := s.setOffset(id, offset+n); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) ReadFile(id string) (io.ReadCloser, error) {
|
|
||||||
return os.Open(s.filePath(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) GetInfo(id string) (FileInfo, error) {
|
|
||||||
s.infoLock(id).RLock()
|
|
||||||
defer s.infoLock(id).RUnlock()
|
|
||||||
|
|
||||||
return s.getInfo(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInfo is the same as GetInfo, but does not apply any locks, requiring
|
|
||||||
// the caller to take care of this.
|
|
||||||
func (s *dataStore) getInfo(id string) (FileInfo, error) {
|
|
||||||
info := FileInfo{}
|
|
||||||
data, err := ioutil.ReadFile(s.infoPath(id))
|
|
||||||
if err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.Unmarshal(data, &info)
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) writeInfo(id string, info FileInfo) error {
|
|
||||||
data, err := json.Marshal(info)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ioutil.WriteFile(s.infoPath(id), data, defaultFilePerm)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setOffset updates the offset of a file, unless the current offset on disk is
|
|
||||||
// already greater.
|
|
||||||
func (s *dataStore) setOffset(id string, offset int64) error {
|
|
||||||
s.infoLock(id).Lock()
|
|
||||||
defer s.infoLock(id).Unlock()
|
|
||||||
|
|
||||||
info, err := s.getInfo(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// never decrement the offset
|
|
||||||
if info.Offset >= offset {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Offset = offset
|
|
||||||
return s.writeInfo(id, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) filePath(id string) string {
|
|
||||||
return path.Join(s.dir, id) + ".bin"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *dataStore) infoPath(id string) string {
|
|
||||||
return path.Join(s.dir, id) + ".info"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: This works for now, but it would be better if we would trigger gc()
|
|
||||||
// manually whenever a storage operation will need more space, telling gc() how
|
|
||||||
// much space we need. If the amount of space required fits into the max, we
|
|
||||||
// can simply ignore the gc request, otherwise delete just as much as we need.
|
|
||||||
func (s *dataStore) gcLoop() {
|
|
||||||
for {
|
|
||||||
if before, after, err := s.gc(); err != nil {
|
|
||||||
log.Printf("dataStore: gc error: %s", err)
|
|
||||||
} else if before != after {
|
|
||||||
log.Printf("dataStore: gc before: %d, after: %d", before, after)
|
|
||||||
}
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BUG: gc could interfer with active uploads if storage pressure is high. To
|
|
||||||
// fix this we need a mechanism to detect this scenario and reject new storage
|
|
||||||
// ops if the current storage ops require all of the available dataStore size.
|
|
||||||
|
|
||||||
// gc shrinks the amount of bytes used by the dataStore to <= maxSize by
|
|
||||||
// deleting the oldest files according to their mtime.
|
|
||||||
func (s *dataStore) gc() (before int64, after int64, err error) {
|
|
||||||
dataDir, err := os.Open(s.dir)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer dataDir.Close()
|
|
||||||
|
|
||||||
stats, err := dataDir.Readdir(-1)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sortableStats := sortableFiles(stats)
|
|
||||||
sort.Sort(sortableStats)
|
|
||||||
|
|
||||||
deleted := make(map[string]bool, len(sortableStats))
|
|
||||||
|
|
||||||
// Delete enough files so that we are <= maxSize
|
|
||||||
for _, stat := range sortableStats {
|
|
||||||
size := stat.Size()
|
|
||||||
before += size
|
|
||||||
|
|
||||||
if before <= s.maxSize {
|
|
||||||
after += size
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := stat.Name()
|
|
||||||
fullPath := path.Join(s.dir, name)
|
|
||||||
if err = os.Remove(fullPath); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deleted[fullPath] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure we did not delete a .info file but forgot the .bin or vice-versa.
|
|
||||||
for fullPath, _ := range deleted {
|
|
||||||
ext := path.Ext(fullPath)
|
|
||||||
base := fullPath[0 : len(fullPath)-len(ext)]
|
|
||||||
|
|
||||||
counterPath := ""
|
|
||||||
if ext == ".bin" {
|
|
||||||
counterPath = base + ".info"
|
|
||||||
} else if ext == ".info" {
|
|
||||||
counterPath = base + ".bin"
|
|
||||||
}
|
|
||||||
|
|
||||||
if counterPath == "" || deleted[counterPath] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
stat, statErr := os.Stat(counterPath)
|
|
||||||
if statErr != nil {
|
|
||||||
if os.IsNotExist(statErr) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err = statErr
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Remove(counterPath)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
after -= stat.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
type sortableFiles []os.FileInfo
|
|
||||||
|
|
||||||
func (s sortableFiles) Len() int {
|
|
||||||
return len(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s sortableFiles) Less(i, j int) bool {
|
|
||||||
return s[i].ModTime().After(s[j].ModTime())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s sortableFiles) Swap(i, j int) {
|
|
||||||
s[i], s[j] = s[j], s[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileInfo struct {
|
|
||||||
Offset int64
|
|
||||||
FinalLength int64
|
|
||||||
Meta map[string]string
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
// Package http contains a client and server implementation of the tus protocol
|
|
||||||
// and is meant to be used by other applications.
|
|
||||||
package http
|
|
|
@ -1,252 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var fileUrlMatcher = regexp.MustCompile("^/([a-z0-9]{32})$")
|
|
||||||
|
|
||||||
// HandlerConfig holds the configuration for a tus Handler.
|
|
||||||
type HandlerConfig struct {
|
|
||||||
// Dir points to a filesystem path used by tus to store uploaded and partial
|
|
||||||
// files. Will be created if does not exist yet. Required.
|
|
||||||
Dir string
|
|
||||||
|
|
||||||
// MaxSize defines how many bytes may be stored inside Dir. Exceeding this
|
|
||||||
// limit will cause the oldest upload files to be deleted until enough space
|
|
||||||
// is available again. Required.
|
|
||||||
MaxSize int64
|
|
||||||
|
|
||||||
// BasePath defines the url path used for handling uploads, e.g. "/files/".
|
|
||||||
// Must contain a trailling "/". Requests not matching this base path will
|
|
||||||
// cause a 404, so make sure you dispatch only appropriate requests to the
|
|
||||||
// handler. Required.
|
|
||||||
BasePath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHandler returns an initialized Handler. An error may occur if the
|
|
||||||
// config.Dir is not writable.
|
|
||||||
func NewHandler(config HandlerConfig) (*Handler, error) {
|
|
||||||
// Ensure the data store directory exists
|
|
||||||
if err := os.MkdirAll(config.Dir, 0777); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
errChan := make(chan error)
|
|
||||||
|
|
||||||
return &Handler{
|
|
||||||
store: newDataStore(config.Dir, config.MaxSize),
|
|
||||||
config: config,
|
|
||||||
Error: errChan,
|
|
||||||
sendError: errChan,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler is a http.Handler that implements tus resumable upload protocol.
|
|
||||||
type Handler struct {
|
|
||||||
store *dataStore
|
|
||||||
config HandlerConfig
|
|
||||||
|
|
||||||
// Error provides error events for logging purposes.
|
|
||||||
Error <-chan error
|
|
||||||
// same chan as Error, used for sending.
|
|
||||||
sendError chan<- error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP processes an incoming request according to the tus protocol.
|
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Verify that url matches BasePath
|
|
||||||
absPath := r.URL.Path
|
|
||||||
if !strings.HasPrefix(absPath, h.config.BasePath) {
|
|
||||||
err := errors.New("unknown url: " + absPath + " - does not match BasePath: " + h.config.BasePath)
|
|
||||||
h.err(err, w, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// example relPath results: "/", "/f81d4fae7dec11d0a76500a0c91e6bf6", etc.
|
|
||||||
relPath := absPath[len(h.config.BasePath)-1:]
|
|
||||||
|
|
||||||
// file creation request
|
|
||||||
if relPath == "/" {
|
|
||||||
if r.Method == "POST" {
|
|
||||||
h.createFile(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle invalid method
|
|
||||||
w.Header().Set("Allow", "POST")
|
|
||||||
err := errors.New(r.Method + " used against file creation url. Only POST is allowed.")
|
|
||||||
h.err(err, w, http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches := fileUrlMatcher.FindStringSubmatch(relPath); matches != nil {
|
|
||||||
id := matches[1]
|
|
||||||
if r.Method == "PATCH" {
|
|
||||||
h.patchFile(w, r, id)
|
|
||||||
return
|
|
||||||
} else if r.Method == "HEAD" {
|
|
||||||
h.headFile(w, r, id)
|
|
||||||
return
|
|
||||||
} else if r.Method == "GET" {
|
|
||||||
h.getFile(w, r, id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle invalid method
|
|
||||||
allowed := "HEAD,PATCH"
|
|
||||||
w.Header().Set("Allow", allowed)
|
|
||||||
err := errors.New(r.Method + " used against file creation url. Allowed: " + allowed)
|
|
||||||
h.err(err, w, http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle unknown url
|
|
||||||
err := errors.New("unknown url: " + absPath + " - does not match file pattern")
|
|
||||||
h.err(err, w, http.StatusNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) createFile(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := uid()
|
|
||||||
|
|
||||||
finalLength, err := getPositiveIntHeader(r, "Final-Length")
|
|
||||||
if err != nil {
|
|
||||||
h.err(err, w, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// @TODO: Define meta data extension and implement it here
|
|
||||||
// @TODO: Make max finalLength configurable, reply with error if exceeded.
|
|
||||||
// This should go into the protocol as well.
|
|
||||||
if err := h.store.CreateFile(id, finalLength, nil); err != nil {
|
|
||||||
h.err(err, w, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Location", h.absUrl(r, "/"+id))
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) patchFile(w http.ResponseWriter, r *http.Request, id string) {
|
|
||||||
offset, err := getPositiveIntHeader(r, "Offset")
|
|
||||||
if err != nil {
|
|
||||||
h.err(err, w, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := h.store.GetInfo(id)
|
|
||||||
if err != nil {
|
|
||||||
h.err(err, w, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if offset > info.Offset {
|
|
||||||
err = fmt.Errorf("Offset: %d exceeds current offset: %d", offset, info.Offset)
|
|
||||||
h.err(err, w, http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// @TODO Test offset < current offset
|
|
||||||
|
|
||||||
err = h.store.WriteFileChunk(id, offset, r.Body)
|
|
||||||
if err != nil {
|
|
||||||
// @TODO handle 404 properly (goes for all h.err calls)
|
|
||||||
h.err(err, w, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) headFile(w http.ResponseWriter, r *http.Request, id string) {
|
|
||||||
info, err := h.store.GetInfo(id)
|
|
||||||
if err != nil {
|
|
||||||
w.Header().Set("Content-Length", "0")
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Offset", fmt.Sprintf("%d", info.Offset))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET requests on files aren't part of the protocol yet,
|
|
||||||
// but it is implemented here anyway for the demo. It still lacks the meta data
|
|
||||||
// extension in order to send the proper content type header.
|
|
||||||
func (h *Handler) getFile(w http.ResponseWriter, r *http.Request, fileId string) {
|
|
||||||
info, err := h.store.GetInfo(fileId)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
h.err(err, w, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
h.err(err, w, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := h.store.ReadFile(fileId)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
h.err(err, w, http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
h.err(err, w, http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
defer data.Close()
|
|
||||||
|
|
||||||
w.Header().Set("Offset", strconv.FormatInt(info.Offset, 10))
|
|
||||||
|
|
||||||
// @TODO: Once the meta extension is done, send the proper content type here
|
|
||||||
//w.Header().Set("Content-Type", info.Meta.ContentType)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Length", strconv.FormatInt(info.FinalLength, 10))
|
|
||||||
|
|
||||||
if _, err := io.CopyN(w, data, info.FinalLength); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPositiveIntHeader(r *http.Request, key string) (int64, error) {
|
|
||||||
val := r.Header.Get(key)
|
|
||||||
if val == "" {
|
|
||||||
return 0, errors.New(key + " header must not be empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
intVal, err := strconv.ParseInt(val, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.New("invalid " + key + " header: " + err.Error())
|
|
||||||
} else if intVal < 0 {
|
|
||||||
return 0, errors.New(key + " header must be > 0")
|
|
||||||
}
|
|
||||||
return intVal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// absUrl turn a relPath (e.g. "/foo") into an absolute url (e.g.
|
|
||||||
// "http://example.com/foo").
|
|
||||||
//
|
|
||||||
// @TODO: Look at r.TLS to determine the url scheme.
|
|
||||||
// @TODO: Make url prefix user configurable (optional) to deal with reverse
|
|
||||||
// proxies. This could be done by turning BasePath into BaseURL that
|
|
||||||
// that could be relative or absolute.
|
|
||||||
func (h *Handler) absUrl(r *http.Request, relPath string) string {
|
|
||||||
return "http://" + r.Host + path.Clean(h.config.BasePath+relPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// err sends a http error response and publishes to the Error channel.
|
|
||||||
func (h *Handler) err(err error, w http.ResponseWriter, status int) {
|
|
||||||
w.WriteHeader(status)
|
|
||||||
io.WriteString(w, err.Error()+"\n")
|
|
||||||
|
|
||||||
// non-blocking send
|
|
||||||
select {
|
|
||||||
case h.sendError <- err:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,353 +0,0 @@
|
||||||
// handler_test.go focuses on functional tests that verify that the Handler
|
|
||||||
// implements the tus protocol correctly.
|
|
||||||
|
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
const basePath = "/files/"
|
|
||||||
|
|
||||||
func Setup() *TestSetup {
|
|
||||||
dir, err := ioutil.TempDir("", "tus_handler_test")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
config := HandlerConfig{
|
|
||||||
Dir: dir,
|
|
||||||
MaxSize: 1024 * 1024,
|
|
||||||
BasePath: basePath,
|
|
||||||
}
|
|
||||||
|
|
||||||
handler, err := NewHandler(config)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
server := httptest.NewServer(handler)
|
|
||||||
return &TestSetup{
|
|
||||||
Handler: handler,
|
|
||||||
Server: server,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestSetup struct {
|
|
||||||
Handler *Handler
|
|
||||||
Server *httptest.Server
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TestSetup) Teardown() {
|
|
||||||
s.Server.Close()
|
|
||||||
if err := os.RemoveAll(s.Handler.config.Dir); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var Protocol_FileCreation_Tests = []struct {
|
|
||||||
Description string
|
|
||||||
*TestRequest
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Description: "Bad method",
|
|
||||||
TestRequest: &TestRequest{
|
|
||||||
Method: "PUT",
|
|
||||||
ExpectStatusCode: http.StatusMethodNotAllowed,
|
|
||||||
ExpectHeaders: map[string]string{"Allow": "POST"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Missing Final-Length header",
|
|
||||||
TestRequest: &TestRequest{
|
|
||||||
ExpectStatusCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Invalid Final-Length header",
|
|
||||||
TestRequest: &TestRequest{
|
|
||||||
Headers: map[string]string{"Final-Length": "fuck"},
|
|
||||||
ExpectStatusCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Negative Final-Length header",
|
|
||||||
TestRequest: &TestRequest{
|
|
||||||
Headers: map[string]string{"Final-Length": "-10"},
|
|
||||||
ExpectStatusCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Valid Request",
|
|
||||||
TestRequest: &TestRequest{
|
|
||||||
Headers: map[string]string{"Final-Length": "1024"},
|
|
||||||
ExpectStatusCode: http.StatusCreated,
|
|
||||||
MatchHeaders: map[string]*regexp.Regexp{
|
|
||||||
"Location": regexp.MustCompile("^http://.+" + regexp.QuoteMeta(basePath) + "[a-z0-9]{32}$"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocol_FileCreation(t *testing.T) {
|
|
||||||
setup := Setup()
|
|
||||||
defer setup.Teardown()
|
|
||||||
|
|
||||||
for _, test := range Protocol_FileCreation_Tests {
|
|
||||||
t.Logf("test: %s", test.Description)
|
|
||||||
|
|
||||||
test.Url = setup.Server.URL + setup.Handler.config.BasePath
|
|
||||||
if test.Method == "" {
|
|
||||||
test.Method = "POST"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := test.Do(); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var Protocol_Core_Tests = []struct {
|
|
||||||
Description string
|
|
||||||
FinalLength int64
|
|
||||||
Requests []TestRequest
|
|
||||||
ExpectFileContent string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
Description: "Bad method",
|
|
||||||
FinalLength: 1024,
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PUT",
|
|
||||||
ExpectStatusCode: http.StatusMethodNotAllowed,
|
|
||||||
ExpectHeaders: map[string]string{"Allow": "HEAD,PATCH"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Missing Offset header",
|
|
||||||
FinalLength: 5,
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{Method: "PATCH", Body: "hello", ExpectStatusCode: http.StatusBadRequest},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Negative Offset header",
|
|
||||||
FinalLength: 5,
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "-10"},
|
|
||||||
Body: "hello",
|
|
||||||
ExpectStatusCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Invalid Offset header",
|
|
||||||
FinalLength: 5,
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "lalala"},
|
|
||||||
Body: "hello",
|
|
||||||
ExpectStatusCode: http.StatusBadRequest,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Single PATCH Upload",
|
|
||||||
FinalLength: 5,
|
|
||||||
ExpectFileContent: "hello",
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "0"},
|
|
||||||
Body: "hello",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Simple Resume",
|
|
||||||
FinalLength: 11,
|
|
||||||
ExpectFileContent: "hello world",
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "0"},
|
|
||||||
Body: "hello",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: "HEAD",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
ExpectHeaders: map[string]string{"Offset": "5"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "5"},
|
|
||||||
Body: " world",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Overlapping Resume",
|
|
||||||
FinalLength: 11,
|
|
||||||
ExpectFileContent: "hello world",
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "0"},
|
|
||||||
Body: "hello wo",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: "HEAD",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
ExpectHeaders: map[string]string{"Offset": "8"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "5"},
|
|
||||||
Body: " world",
|
|
||||||
ExpectStatusCode: http.StatusOK,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Description: "Offset exceeded",
|
|
||||||
FinalLength: 5,
|
|
||||||
Requests: []TestRequest{
|
|
||||||
{
|
|
||||||
Method: "PATCH",
|
|
||||||
Headers: map[string]string{"Offset": "1"},
|
|
||||||
// Not sure if this is the right status to use. Once the parallel
|
|
||||||
// chunks protocol spec is done, we can use NotImplemented as a
|
|
||||||
// status until we implement support for this.
|
|
||||||
ExpectStatusCode: http.StatusForbidden,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProtocol_Core(t *testing.T) {
|
|
||||||
setup := Setup()
|
|
||||||
defer setup.Teardown()
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
for _, test := range Protocol_Core_Tests {
|
|
||||||
t.Logf("test: %s", test.Description)
|
|
||||||
|
|
||||||
location := createFile(setup, test.FinalLength)
|
|
||||||
for i, request := range test.Requests {
|
|
||||||
t.Logf("- request #%d: %s", i+1, request.Method)
|
|
||||||
request.Url = location
|
|
||||||
if err := request.Do(); err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue Tests
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if test.ExpectFileContent != "" {
|
|
||||||
id := regexp.MustCompile("[a-z0-9]{32}$").FindString(location)
|
|
||||||
reader, err := setup.Handler.store.ReadFile(id)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue Tests
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := ioutil.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
t.Error(err)
|
|
||||||
continue Tests
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(content) != test.ExpectFileContent {
|
|
||||||
t.Errorf("expected content: %s, got: %s", test.ExpectFileContent, content)
|
|
||||||
continue Tests
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRequest is a test helper that performs and validates requests according
|
|
||||||
// to the struct fields below.
|
|
||||||
type TestRequest struct {
|
|
||||||
Method string
|
|
||||||
Url string
|
|
||||||
Headers map[string]string
|
|
||||||
ExpectStatusCode int
|
|
||||||
ExpectHeaders map[string]string
|
|
||||||
MatchHeaders map[string]*regexp.Regexp
|
|
||||||
Response *http.Response
|
|
||||||
Body string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *TestRequest) Do() error {
|
|
||||||
req, err := http.NewRequest(r.Method, r.Url, strings.NewReader(r.Body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, val := range r.Headers {
|
|
||||||
req.Header.Set(key, val)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
if res.StatusCode != r.ExpectStatusCode {
|
|
||||||
return fmt.Errorf("unexpected status code: %d, expected: %d", res.StatusCode, r.ExpectStatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, val := range r.ExpectHeaders {
|
|
||||||
if got := res.Header.Get(key); got != val {
|
|
||||||
return fmt.Errorf("expected \"%s: %s\" header, but got: \"%s: %s\"", key, val, key, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, matcher := range r.MatchHeaders {
|
|
||||||
got := res.Header.Get(key)
|
|
||||||
if !matcher.MatchString(got) {
|
|
||||||
return fmt.Errorf("expected %s header to match: %s but got: %s", key, matcher.String(), got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Response = res
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createFile is a test helper that creates a new file and returns the url.
|
|
||||||
func createFile(setup *TestSetup, finalLength int64) (url string) {
|
|
||||||
req := TestRequest{
|
|
||||||
Method: "POST",
|
|
||||||
Url: setup.Server.URL + setup.Handler.config.BasePath,
|
|
||||||
Headers: map[string]string{"Final-Length": fmt.Sprintf("%d", finalLength)},
|
|
||||||
ExpectStatusCode: http.StatusCreated,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := req.Do(); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
location := req.Response.Header.Get("Location")
|
|
||||||
if location == "" {
|
|
||||||
panic("empty Location header")
|
|
||||||
}
|
|
||||||
|
|
||||||
return location
|
|
||||||
}
|
|
|
@ -1,21 +0,0 @@
|
||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func BenchmarkFmtString(b *testing.B) {
|
|
||||||
id := []byte("1234567891234567")
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
fmt.Sprintf("%x", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func BenchmarkHexString(b *testing.B) {
|
|
||||||
id := []byte("1234567891234567")
|
|
||||||
for i := 0; i < b.N; i++ {
|
|
||||||
hex.EncodeToString(id)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tus/tusd"
|
||||||
|
"github.com/tus/tusd/filestore"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
|
||||||
|
store := filestore.FileStore{
|
||||||
|
Path: "./data/",
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := tusd.NewHandler(tusd.Config{
|
||||||
|
MaxSize: 1024 * 1024 * 1024,
|
||||||
|
BasePath: "files/",
|
||||||
|
DataStore: store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Handle("/files/", http.StripPrefix("/files/", handler))
|
||||||
|
err = http.ListenAndServe(":1080", nil)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package http
|
package uid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
@ -11,7 +11,7 @@ import (
|
||||||
// without the dashes and significant bits.
|
// without the dashes and significant bits.
|
||||||
//
|
//
|
||||||
// See: http://en.wikipedia.org/wiki/UUID#Random_UUID_probability_of_duplicates
|
// See: http://en.wikipedia.org/wiki/UUID#Random_UUID_probability_of_duplicates
|
||||||
func uid() string {
|
func Uid() string {
|
||||||
id := make([]byte, 16)
|
id := make([]byte, 16)
|
||||||
_, err := io.ReadFull(rand.Reader, id)
|
_, err := io.ReadFull(rand.Reader, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -19,6 +19,5 @@ func uid() string {
|
||||||
// for random bits.
|
// for random bits.
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hex.EncodeToString(id)
|
return hex.EncodeToString(id)
|
||||||
}
|
}
|
Loading…
Reference in New Issue