2017-05-11 14:39:54 +00:00
|
|
|
// This implements a very basic Swift server
|
|
|
|
// Everything is stored in memory
|
|
|
|
//
|
|
|
|
// This comes from the https://github.com/mitchellh/goamz
|
|
|
|
// and was adapted for Swift
|
|
|
|
//
|
|
|
|
package swifttest
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"crypto/hmac"
|
|
|
|
"crypto/md5"
|
|
|
|
"crypto/rand"
|
|
|
|
"crypto/sha1"
|
|
|
|
"encoding/hex"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"io/ioutil"
|
|
|
|
"log"
|
|
|
|
"mime"
|
|
|
|
"net"
|
|
|
|
"net/http"
|
|
|
|
"net/http/httptest"
|
|
|
|
"net/url"
|
|
|
|
"path"
|
|
|
|
"regexp"
|
|
|
|
"sort"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"sync/atomic"
|
|
|
|
"testing"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/ncw/swift"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
DEBUG = false
|
|
|
|
TEST_ACCOUNT = "swifttest"
|
|
|
|
)
|
|
|
|
|
|
|
|
type HandlerOverrideFunc func(w http.ResponseWriter, r *http.Request, recorder *httptest.ResponseRecorder)
|
|
|
|
|
|
|
|
type SwiftServer struct {
|
2017-09-30 14:27:27 +00:00
|
|
|
// `sync/atomic` expects the first word in an allocated struct to be 64-bit
|
|
|
|
// aligned on both ARM and x86-32.
|
|
|
|
// See https://golang.org/pkg/sync/atomic/#pkg-note-BUG for more details.
|
|
|
|
reqId int64
|
2017-05-11 14:39:54 +00:00
|
|
|
sync.RWMutex
|
|
|
|
t *testing.T
|
|
|
|
mu sync.Mutex
|
|
|
|
Listener net.Listener
|
|
|
|
AuthURL string
|
|
|
|
URL string
|
|
|
|
Accounts map[string]*account
|
|
|
|
Sessions map[string]*session
|
|
|
|
override map[string]HandlerOverrideFunc
|
|
|
|
}
|
|
|
|
|
|
|
|
// The Folder type represents a container stored in an account
|
|
|
|
type Folder struct {
|
|
|
|
Count int64 `json:"count"`
|
|
|
|
Bytes int64 `json:"bytes"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// The Key type represents an item stored in an container.
|
|
|
|
type Key struct {
|
|
|
|
Key string `json:"name"`
|
|
|
|
LastModified string `json:"last_modified"`
|
|
|
|
Size int64 `json:"bytes"`
|
|
|
|
// ETag gives the hex-encoded MD5 sum of the contents,
|
|
|
|
// surrounded with double-quotes.
|
|
|
|
ETag string `json:"hash"`
|
|
|
|
ContentType string `json:"content_type"`
|
|
|
|
// Owner Owner
|
|
|
|
}
|
|
|
|
|
|
|
|
type Subdir struct {
|
|
|
|
Subdir string `json:"subdir"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type swiftError struct {
|
|
|
|
statusCode int
|
|
|
|
Code string
|
|
|
|
Message string
|
|
|
|
}
|
|
|
|
|
|
|
|
type action struct {
|
|
|
|
srv *SwiftServer
|
|
|
|
w http.ResponseWriter
|
|
|
|
req *http.Request
|
|
|
|
reqId string
|
|
|
|
user *account
|
|
|
|
}
|
|
|
|
|
|
|
|
type session struct {
|
|
|
|
username string
|
|
|
|
}
|
|
|
|
|
|
|
|
type metadata struct {
|
|
|
|
meta http.Header // metadata to return with requests.
|
|
|
|
}
|
|
|
|
|
|
|
|
type account struct {
|
|
|
|
sync.RWMutex
|
|
|
|
swift.Account
|
|
|
|
metadata
|
|
|
|
password string
|
|
|
|
ContainersLock sync.RWMutex
|
|
|
|
Containers map[string]*container
|
|
|
|
}
|
|
|
|
|
|
|
|
type object struct {
|
|
|
|
sync.RWMutex
|
|
|
|
metadata
|
|
|
|
name string
|
|
|
|
mtime time.Time
|
|
|
|
checksum []byte // also held as ETag in meta.
|
|
|
|
data []byte
|
|
|
|
content_type string
|
|
|
|
}
|
|
|
|
|
|
|
|
type container struct {
|
2017-09-30 14:27:27 +00:00
|
|
|
// `sync/atomic` expects the first word in an allocated struct to be 64-bit
|
|
|
|
// aligned on both ARM and x86-32.
|
|
|
|
// See https://golang.org/pkg/sync/atomic/#pkg-note-BUG for more details.
|
|
|
|
bytes int64
|
2017-05-11 14:39:54 +00:00
|
|
|
sync.RWMutex
|
|
|
|
metadata
|
|
|
|
name string
|
|
|
|
ctime time.Time
|
|
|
|
objects map[string]*object
|
|
|
|
}
|
|
|
|
|
|
|
|
type segment struct {
|
|
|
|
Path string `json:"path,omitempty"`
|
|
|
|
Hash string `json:"hash,omitempty"`
|
|
|
|
Size int64 `json:"size_bytes,omitempty"`
|
|
|
|
// When uploading a manifest, the attributes must be named `path`, `hash` and `size`
|
|
|
|
// but when querying the JSON content of a manifest with the `multipart-manifest=get`
|
|
|
|
// parameter, Swift names those attributes `name`, `etag` and `bytes`.
|
|
|
|
// We use all the different attributes names in this structure to be able to use
|
|
|
|
// the same structure for both uploading and retrieving.
|
|
|
|
Name string `json:"name,omitempty"`
|
|
|
|
Etag string `json:"etag,omitempty"`
|
|
|
|
Bytes int64 `json:"bytes,omitempty"`
|
|
|
|
ContentType string `json:"content_type,omitempty"`
|
|
|
|
LastModified string `json:"last_modified,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// A resource encapsulates the subject of an HTTP request.
|
|
|
|
// The resource referred to may or may not exist
|
|
|
|
// when the request is made.
|
|
|
|
type resource interface {
|
|
|
|
put(a *action) interface{}
|
|
|
|
get(a *action) interface{}
|
|
|
|
post(a *action) interface{}
|
|
|
|
delete(a *action) interface{}
|
|
|
|
copy(a *action) interface{}
|
|
|
|
}
|
|
|
|
|
|
|
|
type objectResource struct {
|
|
|
|
name string
|
|
|
|
version string
|
|
|
|
container *container // always non-nil.
|
|
|
|
object *object // may be nil.
|
|
|
|
}
|
|
|
|
|
|
|
|
type containerResource struct {
|
|
|
|
name string
|
|
|
|
container *container // non-nil if the container already exists.
|
|
|
|
}
|
|
|
|
|
|
|
|
var responseParams = map[string]bool{
|
|
|
|
"content-type": true,
|
|
|
|
"content-language": true,
|
|
|
|
"expires": true,
|
|
|
|
"cache-control": true,
|
|
|
|
"content-disposition": true,
|
|
|
|
"content-encoding": true,
|
|
|
|
}
|
|
|
|
|
|
|
|
func fatalf(code int, codeStr string, errf string, a ...interface{}) {
|
|
|
|
panic(&swiftError{
|
|
|
|
statusCode: code,
|
|
|
|
Code: codeStr,
|
|
|
|
Message: fmt.Sprintf(errf, a...),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m metadata) setMetadata(a *action, resource string) {
|
|
|
|
for key, values := range a.req.Header {
|
|
|
|
key = http.CanonicalHeaderKey(key)
|
|
|
|
if metaHeaders[key] || strings.HasPrefix(key, "X-"+strings.Title(resource)+"-Meta-") {
|
|
|
|
if values[0] != "" || resource == "object" {
|
|
|
|
m.meta[key] = values
|
|
|
|
} else {
|
|
|
|
m.meta.Del(key)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m metadata) getMetadata(a *action) {
|
|
|
|
h := a.w.Header()
|
|
|
|
for name, d := range m.meta {
|
|
|
|
h[name] = d
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *container) list(delimiter string, marker string, prefix string, parent string) (resp []interface{}) {
|
|
|
|
var tmp orderedObjects
|
|
|
|
|
|
|
|
c.RLock()
|
|
|
|
defer c.RUnlock()
|
|
|
|
|
|
|
|
// first get all matching objects and arrange them in alphabetical order.
|
|
|
|
for _, obj := range c.objects {
|
|
|
|
if strings.HasPrefix(obj.name, prefix) {
|
|
|
|
tmp = append(tmp, obj)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
sort.Sort(tmp)
|
|
|
|
|
|
|
|
var prefixes []string
|
|
|
|
for _, obj := range tmp {
|
|
|
|
if !strings.HasPrefix(obj.name, prefix) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
isPrefix := false
|
|
|
|
name := obj.name
|
|
|
|
if parent != "" {
|
|
|
|
if path.Dir(obj.name) != path.Clean(parent) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
} else if delimiter != "" {
|
|
|
|
if i := strings.Index(obj.name[len(prefix):], delimiter); i >= 0 {
|
|
|
|
name = obj.name[:len(prefix)+i+len(delimiter)]
|
|
|
|
if prefixes != nil && prefixes[len(prefixes)-1] == name {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
isPrefix = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if name <= marker {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if isPrefix {
|
|
|
|
prefixes = append(prefixes, name)
|
|
|
|
|
|
|
|
resp = append(resp, Subdir{
|
|
|
|
Subdir: name,
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
resp = append(resp, obj)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// GET on a container lists the objects in the container.
|
|
|
|
func (r containerResource) get(a *action) interface{} {
|
|
|
|
if r.container == nil {
|
|
|
|
fatalf(404, "NoSuchContainer", "The specified container does not exist")
|
|
|
|
}
|
|
|
|
|
|
|
|
r.container.RLock()
|
|
|
|
|
|
|
|
delimiter := a.req.Form.Get("delimiter")
|
|
|
|
marker := a.req.Form.Get("marker")
|
|
|
|
prefix := a.req.Form.Get("prefix")
|
|
|
|
format := a.req.URL.Query().Get("format")
|
|
|
|
parent := a.req.Form.Get("path")
|
|
|
|
|
|
|
|
a.w.Header().Set("X-Container-Bytes-Used", strconv.Itoa(int(r.container.bytes)))
|
|
|
|
a.w.Header().Set("X-Container-Object-Count", strconv.Itoa(len(r.container.objects)))
|
|
|
|
r.container.getMetadata(a)
|
|
|
|
|
|
|
|
if a.req.Method == "HEAD" {
|
|
|
|
r.container.RUnlock()
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
r.container.RUnlock()
|
|
|
|
|
|
|
|
objects := r.container.list(delimiter, marker, prefix, parent)
|
|
|
|
|
|
|
|
if format == "json" {
|
|
|
|
a.w.Header().Set("Content-Type", "application/json")
|
|
|
|
var resp []interface{}
|
|
|
|
for _, item := range objects {
|
|
|
|
if obj, ok := item.(*object); ok {
|
|
|
|
resp = append(resp, obj.Key())
|
|
|
|
} else {
|
|
|
|
resp = append(resp, item)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return resp
|
|
|
|
} else {
|
|
|
|
for _, item := range objects {
|
|
|
|
if obj, ok := item.(*object); ok {
|
|
|
|
a.w.Write([]byte(obj.name + "\n"))
|
|
|
|
} else if subdir, ok := item.(Subdir); ok {
|
|
|
|
a.w.Write([]byte(subdir.Subdir + "\n"))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// orderedContainers holds a slice of containers that can be sorted
|
|
|
|
// by name.
|
|
|
|
type orderedContainers []*container
|
|
|
|
|
|
|
|
func (s orderedContainers) Len() int {
|
|
|
|
return len(s)
|
|
|
|
}
|
|
|
|
func (s orderedContainers) Swap(i, j int) {
|
|
|
|
s[i], s[j] = s[j], s[i]
|
|
|
|
}
|
|
|
|
func (s orderedContainers) Less(i, j int) bool {
|
|
|
|
return s[i].name < s[j].name
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r containerResource) delete(a *action) interface{} {
|
|
|
|
b := r.container
|
|
|
|
if b == nil {
|
|
|
|
fatalf(404, "NoSuchContainer", "The specified container does not exist")
|
|
|
|
}
|
|
|
|
if len(b.objects) > 0 {
|
|
|
|
fatalf(409, "Conflict", "The container you tried to delete is not empty")
|
|
|
|
}
|
|
|
|
a.user.Lock()
|
|
|
|
delete(a.user.Containers, b.name)
|
|
|
|
a.user.Account.Containers--
|
|
|
|
a.user.Unlock()
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r containerResource) put(a *action) interface{} {
|
|
|
|
if a.req.URL.Query().Get("extract-archive") != "" {
|
|
|
|
fatalf(403, "Operation forbidden", "Bulk upload is not supported")
|
|
|
|
}
|
|
|
|
|
|
|
|
if r.container == nil {
|
|
|
|
if !validContainerName(r.name) {
|
|
|
|
fatalf(400, "InvalidContainerName", "The specified container is not valid")
|
|
|
|
}
|
|
|
|
r.container = &container{
|
|
|
|
name: r.name,
|
|
|
|
objects: make(map[string]*object),
|
|
|
|
metadata: metadata{
|
|
|
|
meta: make(http.Header),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
r.container.setMetadata(a, "container")
|
|
|
|
|
|
|
|
a.user.Lock()
|
|
|
|
a.user.Containers[r.name] = r.container
|
|
|
|
a.user.Account.Containers++
|
|
|
|
a.user.Unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r containerResource) post(a *action) interface{} {
|
|
|
|
if r.container == nil {
|
|
|
|
fatalf(400, "Method", "The resource could not be found.")
|
|
|
|
} else {
|
|
|
|
r.container.RLock()
|
|
|
|
defer r.container.RUnlock()
|
|
|
|
|
|
|
|
r.container.setMetadata(a, "container")
|
|
|
|
a.w.WriteHeader(201)
|
|
|
|
jsonMarshal(a.w, Folder{
|
|
|
|
Count: int64(len(r.container.objects)),
|
|
|
|
Bytes: r.container.bytes,
|
|
|
|
Name: r.container.name,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (containerResource) copy(a *action) interface{} { return notAllowed() }
|
|
|
|
|
|
|
|
// validContainerName returns whether name is a valid bucket name.
|
|
|
|
// Here are the rules, from:
|
|
|
|
// http://docs.openstack.org/api/openstack-object-storage/1.0/content/ch_object-storage-dev-api-storage.html
|
|
|
|
//
|
|
|
|
// Container names cannot exceed 256 bytes and cannot contain the / character.
|
|
|
|
//
|
|
|
|
func validContainerName(name string) bool {
|
|
|
|
if len(name) == 0 || len(name) > 256 {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
for _, r := range name {
|
|
|
|
switch {
|
|
|
|
case r == '/':
|
|
|
|
return false
|
|
|
|
default:
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// orderedObjects holds a slice of objects that can be sorted
|
|
|
|
// by name.
|
|
|
|
type orderedObjects []*object
|
|
|
|
|
|
|
|
func (s orderedObjects) Len() int {
|
|
|
|
return len(s)
|
|
|
|
}
|
|
|
|
func (s orderedObjects) Swap(i, j int) {
|
|
|
|
s[i], s[j] = s[j], s[i]
|
|
|
|
}
|
|
|
|
func (s orderedObjects) Less(i, j int) bool {
|
|
|
|
return s[i].name < s[j].name
|
|
|
|
}
|
|
|
|
|
|
|
|
func (obj *object) Key() Key {
|
|
|
|
return Key{
|
|
|
|
Key: obj.name,
|
|
|
|
LastModified: obj.mtime.Format("2006-01-02T15:04:05"),
|
|
|
|
Size: int64(len(obj.data)),
|
|
|
|
ETag: fmt.Sprintf("%x", obj.checksum),
|
|
|
|
ContentType: obj.content_type,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var metaHeaders = map[string]bool{
|
|
|
|
"Content-Type": true,
|
|
|
|
"Content-Encoding": true,
|
|
|
|
"Content-Disposition": true,
|
|
|
|
"X-Object-Manifest": true,
|
|
|
|
"X-Static-Large-Object": true,
|
|
|
|
}
|
|
|
|
|
|
|
|
var rangeRegexp = regexp.MustCompile("(bytes=)?([0-9]*)-([0-9]*)")
|
|
|
|
|
|
|
|
// GET on an object gets the contents of the object.
|
|
|
|
func (objr objectResource) get(a *action) interface{} {
|
|
|
|
var (
|
|
|
|
etag []byte
|
|
|
|
reader io.Reader
|
|
|
|
start int
|
|
|
|
end int = -1
|
|
|
|
)
|
|
|
|
obj := objr.object
|
|
|
|
if obj == nil {
|
|
|
|
fatalf(404, "Not Found", "The resource could not be found.")
|
|
|
|
}
|
|
|
|
|
|
|
|
obj.RLock()
|
|
|
|
defer obj.RUnlock()
|
|
|
|
|
|
|
|
h := a.w.Header()
|
|
|
|
// add metadata
|
|
|
|
obj.getMetadata(a)
|
|
|
|
|
|
|
|
if r := a.req.Header.Get("Range"); r != "" {
|
|
|
|
m := rangeRegexp.FindStringSubmatch(r)
|
|
|
|
if m[2] != "" {
|
|
|
|
start, _ = strconv.Atoi(m[2])
|
|
|
|
}
|
|
|
|
if m[3] != "" {
|
|
|
|
end, _ = strconv.Atoi(m[3])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
max := func(a int, b int) int {
|
|
|
|
if a > b {
|
|
|
|
return a
|
|
|
|
}
|
|
|
|
return b
|
|
|
|
}
|
|
|
|
|
|
|
|
if manifest, ok := obj.meta["X-Object-Manifest"]; ok {
|
|
|
|
var segments []io.Reader
|
|
|
|
components := strings.SplitN(manifest[0], "/", 2)
|
|
|
|
a.user.RLock()
|
|
|
|
segContainer := a.user.Containers[components[0]]
|
|
|
|
a.user.RUnlock()
|
|
|
|
prefix := components[1]
|
|
|
|
resp := segContainer.list("", "", prefix, "")
|
|
|
|
sum := md5.New()
|
|
|
|
cursor := 0
|
|
|
|
size := 0
|
|
|
|
for _, item := range resp {
|
|
|
|
if obj, ok := item.(*object); ok {
|
|
|
|
length := len(obj.data)
|
|
|
|
size += length
|
|
|
|
sum.Write([]byte(hex.EncodeToString(obj.checksum)))
|
|
|
|
if start >= cursor+length {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
segments = append(segments, bytes.NewReader(obj.data[max(0, start-cursor):]))
|
|
|
|
cursor += length
|
|
|
|
}
|
|
|
|
}
|
|
|
|
etag = sum.Sum(nil)
|
|
|
|
if end == -1 {
|
|
|
|
end = size - 1
|
|
|
|
}
|
|
|
|
reader = io.LimitReader(io.MultiReader(segments...), int64(end-start+1))
|
|
|
|
} else if value, ok := obj.meta["X-Static-Large-Object"]; ok && value[0] == "True" && a.req.URL.Query().Get("multipart-manifest") != "get" {
|
|
|
|
var segments []io.Reader
|
|
|
|
var segmentList []segment
|
|
|
|
json.Unmarshal(obj.data, &segmentList)
|
|
|
|
cursor := 0
|
|
|
|
size := 0
|
|
|
|
sum := md5.New()
|
|
|
|
for _, segment := range segmentList {
|
|
|
|
components := strings.SplitN(segment.Name[1:], "/", 2)
|
|
|
|
a.user.RLock()
|
|
|
|
segContainer := a.user.Containers[components[0]]
|
|
|
|
a.user.RUnlock()
|
|
|
|
objectName := components[1]
|
|
|
|
segObject := segContainer.objects[objectName]
|
|
|
|
length := len(segObject.data)
|
|
|
|
size += length
|
|
|
|
sum.Write([]byte(hex.EncodeToString(segObject.checksum)))
|
|
|
|
if start >= cursor+length {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
segments = append(segments, bytes.NewReader(segObject.data[max(0, start-cursor):]))
|
|
|
|
cursor += length
|
|
|
|
}
|
|
|
|
etag = sum.Sum(nil)
|
|
|
|
if end == -1 {
|
|
|
|
end = size - 1
|
|
|
|
}
|
|
|
|
reader = io.LimitReader(io.MultiReader(segments...), int64(end-start+1))
|
|
|
|
} else {
|
|
|
|
if end == -1 {
|
|
|
|
end = len(obj.data) - 1
|
|
|
|
}
|
|
|
|
etag = obj.checksum
|
|
|
|
reader = bytes.NewReader(obj.data[start : end+1])
|
|
|
|
}
|
|
|
|
|
2017-09-30 14:27:27 +00:00
|
|
|
etagHex := hex.EncodeToString(etag)
|
|
|
|
|
|
|
|
if a.req.Header.Get("If-None-Match") == etagHex {
|
|
|
|
a.w.WriteHeader(http.StatusNotModified)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2017-05-11 14:39:54 +00:00
|
|
|
h.Set("Content-Length", fmt.Sprint(end-start+1))
|
2017-09-30 14:27:27 +00:00
|
|
|
h.Set("ETag", etagHex)
|
2017-05-11 14:39:54 +00:00
|
|
|
h.Set("Last-Modified", obj.mtime.Format(http.TimeFormat))
|
|
|
|
|
|
|
|
if a.req.Method == "HEAD" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO avoid holding the lock when writing data.
|
|
|
|
_, err := io.Copy(a.w, reader)
|
|
|
|
if err != nil {
|
|
|
|
// we can't do much except just log the fact.
|
|
|
|
log.Printf("error writing data: %v", err)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// PUT on an object creates the object.
|
|
|
|
func (objr objectResource) put(a *action) interface{} {
|
|
|
|
var expectHash []byte
|
|
|
|
if c := a.req.Header.Get("ETag"); c != "" {
|
|
|
|
var err error
|
|
|
|
expectHash, err = hex.DecodeString(c)
|
|
|
|
if err != nil || len(expectHash) != md5.Size {
|
|
|
|
fatalf(400, "InvalidDigest", "The ETag you specified was invalid")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
sum := md5.New()
|
|
|
|
// TODO avoid holding lock while reading data.
|
|
|
|
data, err := ioutil.ReadAll(io.TeeReader(a.req.Body, sum))
|
|
|
|
if err != nil {
|
|
|
|
fatalf(400, "TODO", "read error")
|
|
|
|
}
|
|
|
|
gotHash := sum.Sum(nil)
|
|
|
|
if expectHash != nil && bytes.Compare(gotHash, expectHash) != 0 {
|
|
|
|
fatalf(422, "Bad ETag", "The ETag you specified did not match what we received")
|
|
|
|
}
|
|
|
|
if a.req.ContentLength >= 0 && int64(len(data)) != a.req.ContentLength {
|
|
|
|
fatalf(400, "IncompleteBody", "You did not provide the number of bytes specified by the Content-Length HTTP header")
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO is this correct, or should we erase all previous metadata?
|
|
|
|
obj := objr.object
|
|
|
|
if obj == nil {
|
|
|
|
obj = &object{
|
|
|
|
name: objr.name,
|
|
|
|
metadata: metadata{
|
|
|
|
meta: make(http.Header),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
atomic.AddInt64(&a.user.Objects, 1)
|
|
|
|
} else {
|
|
|
|
atomic.AddInt64(&objr.container.bytes, -int64(len(obj.data)))
|
|
|
|
atomic.AddInt64(&a.user.BytesUsed, -int64(len(obj.data)))
|
|
|
|
}
|
|
|
|
|
|
|
|
var content_type string
|
|
|
|
if content_type = a.req.Header.Get("Content-Type"); content_type == "" {
|
|
|
|
content_type = mime.TypeByExtension(obj.name)
|
|
|
|
if content_type == "" {
|
|
|
|
content_type = "application/octet-stream"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if a.req.URL.Query().Get("multipart-manifest") == "put" {
|
|
|
|
// TODO: check the content of the SLO
|
|
|
|
a.req.Header.Set("X-Static-Large-Object", "True")
|
|
|
|
|
|
|
|
var segments []segment
|
|
|
|
json.Unmarshal(data, &segments)
|
|
|
|
for i := range segments {
|
|
|
|
segments[i].Name = "/" + segments[i].Path
|
|
|
|
segments[i].Path = ""
|
|
|
|
segments[i].Hash = segments[i].Etag
|
|
|
|
segments[i].Etag = ""
|
|
|
|
segments[i].Bytes = segments[i].Size
|
|
|
|
segments[i].Size = 0
|
|
|
|
}
|
|
|
|
|
|
|
|
data, _ = json.Marshal(segments)
|
|
|
|
sum = md5.New()
|
|
|
|
sum.Write(data)
|
|
|
|
gotHash = sum.Sum(nil)
|
|
|
|
}
|
|
|
|
|
|
|
|
// PUT request has been successful - save data and metadata
|
|
|
|
obj.setMetadata(a, "object")
|
|
|
|
obj.content_type = content_type
|
|
|
|
obj.data = data
|
|
|
|
obj.checksum = gotHash
|
|
|
|
obj.mtime = time.Now().UTC()
|
|
|
|
objr.container.Lock()
|
|
|
|
objr.container.objects[objr.name] = obj
|
|
|
|
objr.container.bytes += int64(len(data))
|
|
|
|
objr.container.Unlock()
|
|
|
|
|
|
|
|
atomic.AddInt64(&a.user.BytesUsed, int64(len(data)))
|
|
|
|
|
|
|
|
h := a.w.Header()
|
|
|
|
h.Set("ETag", hex.EncodeToString(obj.checksum))
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (objr objectResource) delete(a *action) interface{} {
|
|
|
|
if objr.object == nil {
|
|
|
|
fatalf(404, "NoSuchKey", "The specified key does not exist.")
|
|
|
|
}
|
|
|
|
|
|
|
|
objr.container.Lock()
|
|
|
|
defer objr.container.Unlock()
|
|
|
|
|
|
|
|
objr.object.Lock()
|
|
|
|
defer objr.object.Unlock()
|
|
|
|
|
|
|
|
objr.container.bytes -= int64(len(objr.object.data))
|
|
|
|
delete(objr.container.objects, objr.name)
|
|
|
|
|
|
|
|
atomic.AddInt64(&a.user.BytesUsed, -int64(len(objr.object.data)))
|
|
|
|
atomic.AddInt64(&a.user.Objects, -1)
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (objr objectResource) post(a *action) interface{} {
|
|
|
|
objr.object.Lock()
|
|
|
|
defer objr.object.Unlock()
|
|
|
|
|
|
|
|
obj := objr.object
|
|
|
|
obj.setMetadata(a, "object")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (objr objectResource) copy(a *action) interface{} {
|
|
|
|
if objr.object == nil {
|
|
|
|
fatalf(404, "NoSuchKey", "The specified key does not exist.")
|
|
|
|
}
|
|
|
|
|
|
|
|
obj := objr.object
|
|
|
|
obj.RLock()
|
|
|
|
defer obj.RUnlock()
|
|
|
|
|
|
|
|
destination := a.req.Header.Get("Destination")
|
|
|
|
if destination == "" {
|
|
|
|
fatalf(400, "Bad Request", "You must provide a Destination header")
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
obj2 *object
|
|
|
|
objr2 objectResource
|
|
|
|
)
|
|
|
|
|
|
|
|
destURL, _ := url.Parse("/v1/AUTH_" + TEST_ACCOUNT + "/" + destination)
|
|
|
|
r := a.srv.resourceForURL(destURL)
|
|
|
|
switch t := r.(type) {
|
|
|
|
case objectResource:
|
|
|
|
objr2 = t
|
|
|
|
if objr2.object == nil {
|
|
|
|
obj2 = &object{
|
|
|
|
name: objr2.name,
|
|
|
|
metadata: metadata{
|
|
|
|
meta: make(http.Header),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
atomic.AddInt64(&a.user.Objects, 1)
|
|
|
|
} else {
|
|
|
|
obj2 = objr2.object
|
|
|
|
atomic.AddInt64(&objr2.container.bytes, -int64(len(obj2.data)))
|
|
|
|
atomic.AddInt64(&a.user.BytesUsed, -int64(len(obj2.data)))
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
fatalf(400, "Bad Request", "Destination must point to a valid object path")
|
|
|
|
}
|
|
|
|
|
|
|
|
if objr2.container.name != objr2.container.name && obj2.name != obj.name {
|
|
|
|
obj2.Lock()
|
|
|
|
defer obj2.Unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
obj2.content_type = obj.content_type
|
|
|
|
obj2.data = obj.data
|
|
|
|
obj2.checksum = obj.checksum
|
|
|
|
obj2.mtime = time.Now()
|
|
|
|
|
|
|
|
for key, values := range obj.metadata.meta {
|
|
|
|
obj2.metadata.meta[key] = values
|
|
|
|
}
|
|
|
|
obj2.setMetadata(a, "object")
|
|
|
|
|
|
|
|
objr2.container.Lock()
|
|
|
|
objr2.container.objects[objr2.name] = obj2
|
|
|
|
objr2.container.bytes += int64(len(obj.data))
|
|
|
|
objr2.container.Unlock()
|
|
|
|
|
|
|
|
atomic.AddInt64(&a.user.BytesUsed, int64(len(obj.data)))
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SwiftServer) serveHTTP(w http.ResponseWriter, req *http.Request) {
|
|
|
|
// ignore error from ParseForm as it's usually spurious.
|
|
|
|
req.ParseForm()
|
|
|
|
|
|
|
|
if fn := s.override[req.URL.Path]; fn != nil {
|
|
|
|
originalRW := w
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
w = recorder
|
|
|
|
defer func() {
|
|
|
|
fn(originalRW, req, recorder)
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
|
|
|
if DEBUG {
|
|
|
|
log.Printf("swifttest %q %q", req.Method, req.URL)
|
|
|
|
}
|
|
|
|
a := &action{
|
|
|
|
srv: s,
|
|
|
|
w: w,
|
|
|
|
req: req,
|
|
|
|
reqId: fmt.Sprintf("%09X", atomic.LoadInt64(&s.reqId)),
|
|
|
|
}
|
|
|
|
atomic.AddInt64(&s.reqId, 1)
|
|
|
|
|
|
|
|
var r resource
|
|
|
|
defer func() {
|
|
|
|
switch err := recover().(type) {
|
|
|
|
case *swiftError:
|
|
|
|
w.Header().Set("Content-Type", `text/plain; charset=utf-8`)
|
|
|
|
http.Error(w, err.Message, err.statusCode)
|
|
|
|
case nil:
|
|
|
|
default:
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
var resp interface{}
|
|
|
|
|
|
|
|
if req.URL.String() == "/v1.0" {
|
|
|
|
username := req.Header.Get("x-auth-user")
|
|
|
|
key := req.Header.Get("x-auth-key")
|
|
|
|
s.Lock()
|
|
|
|
defer s.Unlock()
|
|
|
|
if acct, ok := s.Accounts[username]; ok {
|
|
|
|
if acct.password == key {
|
|
|
|
r := make([]byte, 16)
|
|
|
|
_, _ = rand.Read(r)
|
|
|
|
id := fmt.Sprintf("%X", r)
|
|
|
|
w.Header().Set("X-Storage-Url", s.URL+"/AUTH_"+username)
|
|
|
|
w.Header().Set("X-Auth-Token", "AUTH_tk"+string(id))
|
|
|
|
w.Header().Set("X-Storage-Token", "AUTH_tk"+string(id))
|
|
|
|
s.Sessions[id] = &session{
|
|
|
|
username: username,
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
panic(notAuthorized())
|
|
|
|
}
|
|
|
|
|
|
|
|
if req.URL.String() == "/info" {
|
|
|
|
jsonMarshal(w, &swift.SwiftInfo{
|
|
|
|
"swift": map[string]interface{}{
|
|
|
|
"version": "1.2",
|
|
|
|
},
|
|
|
|
"tempurl": map[string]interface{}{
|
|
|
|
"methods": []string{"GET", "HEAD", "PUT"},
|
|
|
|
},
|
|
|
|
"slo": map[string]interface{}{
|
|
|
|
"max_manifest_segments": 1000,
|
|
|
|
"max_manifest_size": 2097152,
|
|
|
|
"min_segment_size": 1,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
r = s.resourceForURL(req.URL)
|
|
|
|
|
|
|
|
key := req.Header.Get("x-auth-token")
|
|
|
|
signature := req.URL.Query().Get("temp_url_sig")
|
|
|
|
expires := req.URL.Query().Get("temp_url_expires")
|
|
|
|
if key == "" && signature != "" && expires != "" {
|
|
|
|
accountName, _, _, _ := s.parseURL(req.URL)
|
|
|
|
secretKey := ""
|
|
|
|
s.RLock()
|
|
|
|
if account, ok := s.Accounts[accountName]; ok {
|
|
|
|
secretKey = account.meta.Get("X-Account-Meta-Temp-Url-Key")
|
|
|
|
}
|
|
|
|
s.RUnlock()
|
|
|
|
|
|
|
|
get_hmac := func(method string) string {
|
|
|
|
mac := hmac.New(sha1.New, []byte(secretKey))
|
|
|
|
body := fmt.Sprintf("%s\n%s\n%s", method, expires, req.URL.Path)
|
|
|
|
mac.Write([]byte(body))
|
|
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
|
|
}
|
|
|
|
|
|
|
|
if req.Method == "HEAD" {
|
|
|
|
if signature != get_hmac("GET") && signature != get_hmac("POST") && signature != get_hmac("PUT") {
|
|
|
|
panic(notAuthorized())
|
|
|
|
}
|
|
|
|
} else if signature != get_hmac(req.Method) {
|
|
|
|
panic(notAuthorized())
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
s.RLock()
|
|
|
|
session, ok := s.Sessions[key[7:]]
|
|
|
|
if !ok {
|
|
|
|
s.RUnlock()
|
|
|
|
panic(notAuthorized())
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
a.user = s.Accounts[session.username]
|
|
|
|
s.RUnlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
switch req.Method {
|
|
|
|
case "PUT":
|
|
|
|
resp = r.put(a)
|
|
|
|
case "GET", "HEAD":
|
|
|
|
resp = r.get(a)
|
|
|
|
case "DELETE":
|
|
|
|
resp = r.delete(a)
|
|
|
|
case "POST":
|
|
|
|
resp = r.post(a)
|
|
|
|
case "COPY":
|
|
|
|
resp = r.copy(a)
|
|
|
|
default:
|
|
|
|
fatalf(400, "MethodNotAllowed", "unknown http request method %q", req.Method)
|
|
|
|
}
|
|
|
|
|
|
|
|
content_type := req.Header.Get("Content-Type")
|
|
|
|
if resp != nil && req.Method != "HEAD" {
|
|
|
|
if strings.HasPrefix(content_type, "application/json") ||
|
|
|
|
req.URL.Query().Get("format") == "json" {
|
|
|
|
jsonMarshal(w, resp)
|
|
|
|
} else {
|
|
|
|
switch r := resp.(type) {
|
|
|
|
case string:
|
|
|
|
w.Write([]byte(r))
|
|
|
|
default:
|
|
|
|
w.Write(resp.([]byte))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SwiftServer) SetOverride(path string, fn HandlerOverrideFunc) {
|
|
|
|
s.override[path] = fn
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *SwiftServer) UnsetOverride(path string) {
|
|
|
|
delete(s.override, path)
|
|
|
|
}
|
|
|
|
|
|
|
|
func jsonMarshal(w io.Writer, x interface{}) {
|
|
|
|
if err := json.NewEncoder(w).Encode(x); err != nil {
|
|
|
|
panic(fmt.Errorf("error marshalling %#v: %v", x, err))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var pathRegexp = regexp.MustCompile("/v1/AUTH_([a-zA-Z0-9]+)(/([^/]+)(/(.*))?)?")
|
|
|
|
|
|
|
|
func (srv *SwiftServer) parseURL(u *url.URL) (account string, container string, object string, err error) {
|
|
|
|
m := pathRegexp.FindStringSubmatch(u.Path)
|
|
|
|
if m == nil {
|
|
|
|
return "", "", "", fmt.Errorf("Couldn't parse the specified URI")
|
|
|
|
}
|
|
|
|
account = m[1]
|
|
|
|
container = m[3]
|
|
|
|
object = m[5]
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// resourceForURL returns a resource object for the given URL.
|
|
|
|
func (srv *SwiftServer) resourceForURL(u *url.URL) (r resource) {
|
|
|
|
accountName, containerName, objectName, err := srv.parseURL(u)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
fatalf(404, "InvalidURI", err.Error())
|
|
|
|
}
|
|
|
|
|
|
|
|
srv.RLock()
|
|
|
|
account, ok := srv.Accounts[accountName]
|
|
|
|
if !ok {
|
|
|
|
//srv.RUnlock()
|
|
|
|
fatalf(404, "NoSuchAccount", "The specified account does not exist")
|
|
|
|
}
|
|
|
|
srv.RUnlock()
|
|
|
|
|
|
|
|
account.RLock()
|
|
|
|
if containerName == "" {
|
|
|
|
account.RUnlock()
|
|
|
|
return rootResource{}
|
|
|
|
}
|
|
|
|
account.RUnlock()
|
|
|
|
|
|
|
|
b := containerResource{
|
|
|
|
name: containerName,
|
|
|
|
container: account.Containers[containerName],
|
|
|
|
}
|
|
|
|
|
|
|
|
if objectName == "" {
|
|
|
|
return b
|
|
|
|
}
|
|
|
|
|
|
|
|
if b.container == nil {
|
|
|
|
fatalf(404, "NoSuchContainer", "The specified container does not exist")
|
|
|
|
}
|
|
|
|
|
|
|
|
objr := objectResource{
|
|
|
|
name: objectName,
|
|
|
|
version: u.Query().Get("versionId"),
|
|
|
|
container: b.container,
|
|
|
|
}
|
|
|
|
|
|
|
|
objr.container.RLock()
|
|
|
|
defer objr.container.RUnlock()
|
|
|
|
if obj := objr.container.objects[objr.name]; obj != nil {
|
|
|
|
objr.object = obj
|
|
|
|
}
|
|
|
|
return objr
|
|
|
|
}
|
|
|
|
|
|
|
|
// nullResource has error stubs for all resource methods.
|
|
|
|
type nullResource struct{}
|
|
|
|
|
|
|
|
func notAllowed() interface{} {
|
|
|
|
fatalf(400, "MethodNotAllowed", "The specified method is not allowed against this resource")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func notAuthorized() interface{} {
|
|
|
|
fatalf(401, "Unauthorized", "This server could not verify that you are authorized to access the document you requested.")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (nullResource) put(a *action) interface{} { return notAllowed() }
|
|
|
|
func (nullResource) get(a *action) interface{} { return notAllowed() }
|
|
|
|
func (nullResource) post(a *action) interface{} { return notAllowed() }
|
|
|
|
func (nullResource) delete(a *action) interface{} { return notAllowed() }
|
|
|
|
func (nullResource) copy(a *action) interface{} { return notAllowed() }
|
|
|
|
|
|
|
|
type rootResource struct{}
|
|
|
|
|
|
|
|
func (rootResource) put(a *action) interface{} { return notAllowed() }
|
|
|
|
func (rootResource) get(a *action) interface{} {
|
|
|
|
marker := a.req.Form.Get("marker")
|
|
|
|
prefix := a.req.Form.Get("prefix")
|
|
|
|
format := a.req.URL.Query().Get("format")
|
|
|
|
|
|
|
|
h := a.w.Header()
|
|
|
|
|
|
|
|
h.Set("X-Account-Bytes-Used", strconv.Itoa(int(atomic.LoadInt64(&a.user.BytesUsed))))
|
|
|
|
h.Set("X-Account-Container-Count", strconv.Itoa(int(atomic.LoadInt64(&a.user.Account.Containers))))
|
|
|
|
h.Set("X-Account-Object-Count", strconv.Itoa(int(atomic.LoadInt64(&a.user.Objects))))
|
|
|
|
|
|
|
|
a.user.RLock()
|
|
|
|
defer a.user.RUnlock()
|
|
|
|
|
|
|
|
// add metadata
|
|
|
|
a.user.metadata.getMetadata(a)
|
|
|
|
|
|
|
|
if a.req.Method == "HEAD" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var tmp orderedContainers
|
|
|
|
// first get all matching objects and arrange them in alphabetical order.
|
|
|
|
for _, container := range a.user.Containers {
|
|
|
|
if strings.HasPrefix(container.name, prefix) {
|
|
|
|
tmp = append(tmp, container)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
sort.Sort(tmp)
|
|
|
|
|
|
|
|
resp := make([]Folder, 0)
|
|
|
|
for _, container := range tmp {
|
|
|
|
if container.name <= marker {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if format == "json" {
|
|
|
|
resp = append(resp, Folder{
|
|
|
|
Count: int64(len(container.objects)),
|
|
|
|
Bytes: container.bytes,
|
|
|
|
Name: container.name,
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
a.w.Write([]byte(container.name + "\n"))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if format == "json" {
|
|
|
|
return resp
|
|
|
|
} else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r rootResource) post(a *action) interface{} {
|
|
|
|
a.user.Lock()
|
|
|
|
a.user.metadata.setMetadata(a, "account")
|
|
|
|
a.user.Unlock()
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (rootResource) delete(a *action) interface{} {
|
|
|
|
if a.req.URL.Query().Get("bulk-delete") == "1" {
|
|
|
|
fatalf(403, "Operation forbidden", "Bulk delete is not supported")
|
|
|
|
}
|
|
|
|
|
|
|
|
return notAllowed()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (rootResource) copy(a *action) interface{} { return notAllowed() }
|
|
|
|
|
|
|
|
func NewSwiftServer(address string) (*SwiftServer, error) {
|
|
|
|
if strings.Index(address, ":") == -1 {
|
|
|
|
address += ":0"
|
|
|
|
}
|
|
|
|
l, err := net.Listen("tcp", address)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("cannot listen on %s: %v", address, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
server := &SwiftServer{
|
|
|
|
Listener: l,
|
|
|
|
AuthURL: "http://" + l.Addr().String() + "/v1.0",
|
|
|
|
URL: "http://" + l.Addr().String() + "/v1",
|
|
|
|
Accounts: make(map[string]*account),
|
|
|
|
Sessions: make(map[string]*session),
|
|
|
|
override: make(map[string]HandlerOverrideFunc),
|
|
|
|
}
|
|
|
|
|
|
|
|
server.Accounts[TEST_ACCOUNT] = &account{
|
|
|
|
password: TEST_ACCOUNT,
|
|
|
|
metadata: metadata{
|
|
|
|
meta: make(http.Header),
|
|
|
|
},
|
|
|
|
Containers: make(map[string]*container),
|
|
|
|
}
|
|
|
|
|
|
|
|
go http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
|
|
server.serveHTTP(w, req)
|
|
|
|
}))
|
|
|
|
|
|
|
|
return server, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (srv *SwiftServer) Close() {
|
|
|
|
srv.Listener.Close()
|
|
|
|
}
|