├── .dockerignore ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── config.go ├── content_store.go ├── content_store_test.go ├── go.mod ├── go.sum ├── kvlogger.go ├── main.go ├── meta_store.go ├── meta_store_test.go ├── mgmt.go ├── mgmt ├── css │ └── primer.css └── templates │ ├── body.tmpl │ ├── config.tmpl │ ├── locks.tmpl │ ├── objects.tmpl │ └── users.tmpl ├── script └── release ├── server.go ├── server_test.go ├── tracking_listener.go └── tus.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | lfs-content 3 | lfs.db 4 | Procfile 5 | Readme.md 6 | TODO 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | lfs.db 3 | lfs-test-server 4 | lfs-test-server-out.* 5 | lfs-test-server-release.* 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to LFS Test Server 2 | 3 | Hi there! We're thrilled that you'd like to contribute to this project. Your 4 | help is essential for keeping it great. 5 | 6 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 7 | 8 | ## Submitting a pull request 9 | 10 | 0. [Fork][] and clone the repository 11 | 0. Configure and install the dependencies: `go build` 12 | 0. Make sure the tests pass on your machine: `go test` 13 | 0. Create a new branch: `git checkout -b my-branch-name` 14 | 0. Make your change, add tests, and make sure the tests still pass 15 | 0. Push to your fork and [submit a pull request][pr] 16 | 0. Pat your self on the back and wait for your pull request to be reviewed. 17 | 18 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 19 | 20 | - Follow the [style guide][style] where possible. 21 | - Write tests. 22 | - Update documentation as necessary. 23 | - Keep your change as focused as possible. If there are multiple changes you 24 | would like to make that are not dependent upon each other, consider submitting 25 | them as separate pull requests. 26 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 27 | 28 | ## Resources 29 | 30 | - [Contributing to Open Source on GitHub](https://guides.github.com/activities/contributing-to-open-source/) 31 | - [Using Pull Requests](https://help.github.com/articles/using-pull-requests/) 32 | - [GitHub Help](https://help.github.com) 33 | 34 | [![GoDoc](https://godoc.org/github.com/github/lfs-test-server?status.svg)](https://godoc.org/github.com/github/lfs-test-server) 35 | 36 | [fork]: https://github.com/github/lfs-test-server/fork 37 | [pr]: https://github.com/github/lfs-test-server/compare 38 | [style]: https://github.com/golang/go/wiki/CodeReviewComments 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14.7 2 | MAINTAINER GitHub, Inc. 3 | 4 | WORKDIR /go/src/github.com/git-lfs/lfs-test-server 5 | 6 | COPY . . 7 | 8 | RUN go build 9 | 10 | EXPOSE 8080 11 | 12 | CMD /go/src/github.com/git-lfs/lfs-test-server/lfs-test-server 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) GitHub, Inc. and LFS Test Server contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LFS Test Server 2 | ====== 3 | 4 | [rel]: https://github.com/github/lfs-test-server/releases 5 | [lfs]: https://github.com/github/git-lfs 6 | [api]: https://github.com/github/git-lfs/tree/master/docs/api#readme 7 | 8 | LFS Test Server is an example server that implements the [Git LFS API][api]. It 9 | is intended to be used for testing the [Git LFS][lfs] client and is not in a 10 | production ready state. 11 | 12 | LFS Test Server is written in Go, with pre-compiled binaries available for Mac, 13 | Windows, Linux, and FreeBSD. 14 | 15 | See [CONTRIBUTING.md](CONTRIBUTING.md) for info on working on LFS Test Server and 16 | sending patches. 17 | 18 | ## Installing 19 | 20 | Use the Go installer: 21 | 22 | ``` 23 | $ go install github.com/git-lfs/lfs-test-server@latest 24 | ``` 25 | 26 | 27 | ## Building 28 | 29 | To build from source, use the Go tools: 30 | 31 | ``` 32 | $ go get github.com/git-lfs/lfs-test-server 33 | ``` 34 | 35 | 36 | ## Running 37 | 38 | Running the binary will start an LFS server on `localhost:8080` by default. 39 | There are few things that can be configured via environment variables: 40 | 41 | LFS_LISTEN # The address:port the server listens on, default: "tcp://:8080" 42 | LFS_HOST # The host used when the server generates URLs, default: "localhost:8080" 43 | LFS_METADB # The database file the server uses to store meta information, default: "lfs.db" 44 | LFS_CONTENTPATH # The path where LFS files are store, default: "lfs-content" 45 | LFS_ADMINUSER # An administrator username, default: not set 46 | LFS_ADMINPASS # An administrator password, default: not set 47 | LFS_CERT # Certificate file for tls 48 | LFS_KEY # tls key 49 | LFS_SCHEME # set to 'https' to override default http 50 | LFS_USETUS # set to 'true' to enable tusd (tus.io) resumable upload server; tusd must be on PATH, installed separately 51 | LFS_TUSHOST # The host used to start the tusd upload server, default "localhost:1080" 52 | 53 | If the `LFS_ADMINUSER` and `LFS_ADMINPASS` variables are set, a 54 | rudimentary admin interface can be accessed via 55 | `http://$LFS_HOST/mgmt`. Here you can add and remove users, which must 56 | be done before you can use the server with the client. If either of 57 | these variables are not set (which is the default), the administrative 58 | interface is disabled. 59 | 60 | To use the LFS test server with the Git LFS client, configure it in the repository's `.lfsconfig`: 61 | 62 | 63 | ``` 64 | [lfs] 65 | url = "http://localhost:8080/" 66 | 67 | ``` 68 | 69 | HTTPS: 70 | 71 | NOTE: If using https with a self signed cert also disable cert checking in the client repo. 72 | 73 | ``` 74 | [lfs] 75 | url = "https://localhost:8080/" 76 | 77 | [http] 78 | sslverify = false 79 | 80 | ``` 81 | 82 | 83 | An example usage: 84 | 85 | 86 | Generate a key pair 87 | ``` 88 | openssl req -x509 -sha256 -nodes -days 2100 -newkey rsa:2048 -keyout mine.key -out mine.crt 89 | ``` 90 | 91 | Make yourself a run script 92 | 93 | ``` 94 | #!/bin/bash 95 | 96 | set -eu 97 | set -o pipefail 98 | 99 | 100 | LFS_LISTEN="tcp://:9999" 101 | LFS_HOST="127.0.0.1:9999" 102 | LFS_CONTENTPATH="content" 103 | LFS_ADMINUSER="" 104 | LFS_ADMINPASS="" 105 | LFS_CERT="mine.crt" 106 | LFS_KEY="mine.key" 107 | LFS_SCHEME="https" 108 | 109 | export LFS_LISTEN LFS_HOST LFS_CONTENTPATH LFS_ADMINUSER LFS_ADMINPASS LFS_CERT LFS_KEY LFS_SCHEME 110 | 111 | ./lfs-test-server 112 | 113 | ``` 114 | 115 | Build the server 116 | 117 | ``` 118 | go build 119 | 120 | ``` 121 | 122 | Run 123 | 124 | ``` 125 | bash run.sh 126 | 127 | ``` 128 | 129 | Check the managment page 130 | 131 | browser: https://localhost:9999/mgmt 132 | 133 | 134 | ## Debugging 135 | 136 | `lfs-test-server` supports a basic cmd to lookup `OID's` via the cmdline to help in debugging, eg. investigating client problems with a particular `OID` and it's properties. 137 | In this mode `lfs-test-server` expects the same configuration as when running in daemon mode, but will just executing the requested cmd and then exit. 138 | 139 | This is especially helpful in server environments where it's not always possible to get to the web interface easily or where it's just too slow because of DB size. 140 | 141 | `lfs-test-server cmd ` 142 | 143 | Outputs the full OID record 144 | 145 | # Example 146 | 147 | ``` 148 | % . /etc/default/lfs-instancefoo # to source server config 149 | % ./lfs-test-server cmd 7c9414fe21ad7b45ffb6e72da86f9a9e13dbb2971365ae7bcb8cc7fbbba7419c 150 | &{Oid:7c9414fe21ad7b45ffb6e72da86f9a9e13dbb2971365ae7bcb8cc7fbbba7419c Size:3334144 Existing:false} 151 | ``` 152 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | // Configuration holds application configuration. Values will be pulled from 11 | // environment variables, prefixed by keyPrefix. Default values can be added 12 | // via tags. 13 | type Configuration struct { 14 | Listen string `config:"tcp://:8080"` 15 | Host string `config:"localhost:8080"` 16 | ExtOrigin string `config:""` // consider lfs-test-server may behind a reverse proxy 17 | MetaDB string `config:"lfs.db"` 18 | ContentPath string `config:"lfs-content"` 19 | AdminUser string `config:""` 20 | AdminPass string `config:""` 21 | Cert string `config:""` 22 | Key string `config:""` 23 | Scheme string `config:"http"` 24 | Public string `config:"public"` 25 | UseTus string `config:"false"` 26 | TusHost string `config:"localhost:1080"` 27 | } 28 | 29 | func (c *Configuration) IsHTTPS() bool { 30 | return strings.Contains(Config.Scheme, "https") 31 | } 32 | 33 | func (c *Configuration) IsPublic() bool { 34 | switch Config.Public { 35 | case "1", "true", "TRUE": 36 | return true 37 | } 38 | return false 39 | } 40 | 41 | func (c *Configuration) IsUsingTus() bool { 42 | switch Config.UseTus { 43 | case "1", "true", "TRUE": 44 | return true 45 | } 46 | return false 47 | } 48 | 49 | // Config is the global app configuration 50 | var Config = &Configuration{} 51 | 52 | const keyPrefix = "LFS" 53 | 54 | func init() { 55 | te := reflect.TypeOf(Config).Elem() 56 | ve := reflect.ValueOf(Config).Elem() 57 | 58 | for i := 0; i < te.NumField(); i++ { 59 | sf := te.Field(i) 60 | name := sf.Name 61 | field := ve.FieldByName(name) 62 | 63 | envVar := strings.ToUpper(fmt.Sprintf("%s_%s", keyPrefix, name)) 64 | env := os.Getenv(envVar) 65 | tag := sf.Tag.Get("config") 66 | 67 | if env == "" && tag != "" { 68 | env = tag 69 | } 70 | 71 | field.SetString(env) 72 | } 73 | 74 | if port := os.Getenv("PORT"); port != "" { 75 | // If $PORT is set, override LFS_LISTEN. This is useful for deploying to Heroku. 76 | Config.Listen = "tcp://:" + port 77 | } 78 | 79 | if Config.ExtOrigin == "" { 80 | Config.ExtOrigin = fmt.Sprintf("%s://%s", Config.Scheme, Config.Host) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /content_store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "errors" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | var ( 13 | errHashMismatch = errors.New("Content hash does not match OID") 14 | errSizeMismatch = errors.New("Content size does not match") 15 | ) 16 | 17 | // ContentStore provides a simple file system based storage. 18 | type ContentStore struct { 19 | basePath string 20 | } 21 | 22 | // NewContentStore creates a ContentStore at the base directory. 23 | func NewContentStore(base string) (*ContentStore, error) { 24 | if err := os.MkdirAll(base, 0750); err != nil { 25 | return nil, err 26 | } 27 | 28 | return &ContentStore{base}, nil 29 | } 30 | 31 | // Get takes a Meta object and retreives the content from the store, returning 32 | // it as an io.ReaderCloser. If fromByte > 0, the reader starts from that byte 33 | func (s *ContentStore) Get(meta *MetaObject, fromByte int64) (io.ReadCloser, error) { 34 | path := filepath.Join(s.basePath, transformKey(meta.Oid)) 35 | 36 | f, err := os.Open(path) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if fromByte > 0 { 41 | _, err = f.Seek(fromByte, os.SEEK_CUR) 42 | } 43 | return f, err 44 | } 45 | 46 | // Put takes a Meta object and an io.Reader and writes the content to the store. 47 | func (s *ContentStore) Put(meta *MetaObject, r io.Reader) error { 48 | path := filepath.Join(s.basePath, transformKey(meta.Oid)) 49 | tmpPath := path + ".tmp" 50 | 51 | dir := filepath.Dir(path) 52 | if err := os.MkdirAll(dir, 0750); err != nil { 53 | return err 54 | } 55 | 56 | file, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0640) 57 | if err != nil { 58 | return err 59 | } 60 | defer os.Remove(tmpPath) 61 | 62 | hash := sha256.New() 63 | hw := io.MultiWriter(hash, file) 64 | 65 | written, err := io.Copy(hw, r) 66 | if err != nil { 67 | file.Close() 68 | return err 69 | } 70 | file.Close() 71 | 72 | if written != meta.Size { 73 | return errSizeMismatch 74 | } 75 | 76 | shaStr := hex.EncodeToString(hash.Sum(nil)) 77 | if shaStr != meta.Oid { 78 | return errHashMismatch 79 | } 80 | 81 | if err := os.Rename(tmpPath, path); err != nil { 82 | return err 83 | } 84 | return nil 85 | } 86 | 87 | // Exists returns true if the object exists in the content store. 88 | func (s *ContentStore) Exists(meta *MetaObject) bool { 89 | path := filepath.Join(s.basePath, transformKey(meta.Oid)) 90 | if _, err := os.Stat(path); os.IsNotExist(err) { 91 | return false 92 | } 93 | return true 94 | } 95 | 96 | func transformKey(key string) string { 97 | if len(key) < 5 { 98 | return key 99 | } 100 | 101 | return filepath.Join(key[0:2], key[2:4], key[4:len(key)]) 102 | } 103 | -------------------------------------------------------------------------------- /content_store_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | var contentStore *ContentStore 12 | 13 | func TestContentStorePut(t *testing.T) { 14 | setup() 15 | defer teardown() 16 | 17 | m := &MetaObject{ 18 | Oid: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", 19 | Size: 12, 20 | } 21 | 22 | b := bytes.NewBuffer([]byte("test content")) 23 | 24 | if err := contentStore.Put(m, b); err != nil { 25 | t.Fatalf("expected put to succeed, got: %s", err) 26 | } 27 | 28 | path := "content-store-test/6a/e8/a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" 29 | if _, err := os.Stat(path); os.IsNotExist(err) { 30 | t.Fatalf("expected content to exist after putting") 31 | } 32 | } 33 | 34 | func TestContentStorePutHashMismatch(t *testing.T) { 35 | setup() 36 | defer teardown() 37 | 38 | m := &MetaObject{ 39 | Oid: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", 40 | Size: 12, 41 | } 42 | 43 | b := bytes.NewBuffer([]byte("bogus content")) 44 | 45 | if err := contentStore.Put(m, b); err == nil { 46 | t.Fatal("expected put with bogus content to fail") 47 | } 48 | 49 | path := "content-store-test/6a/e8/a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" 50 | if _, err := os.Stat(path); err == nil { 51 | t.Fatalf("expected content to not exist after putting bogus content") 52 | } 53 | } 54 | 55 | func TestContentStorePutSizeMismatch(t *testing.T) { 56 | setup() 57 | defer teardown() 58 | 59 | m := &MetaObject{ 60 | Oid: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", 61 | Size: 14, 62 | } 63 | 64 | b := bytes.NewBuffer([]byte("test content")) 65 | 66 | if err := contentStore.Put(m, b); err == nil { 67 | t.Fatal("expected put with bogus size to fail") 68 | } 69 | 70 | path := "content-store-test/6a/e8/a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72" 71 | if _, err := os.Stat(path); err == nil { 72 | t.Fatalf("expected content to not exist after putting bogus size") 73 | } 74 | } 75 | 76 | func TestContentStoreGet(t *testing.T) { 77 | setup() 78 | defer teardown() 79 | 80 | m := &MetaObject{ 81 | Oid: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", 82 | Size: 12, 83 | } 84 | 85 | b := bytes.NewBuffer([]byte("test content")) 86 | 87 | if err := contentStore.Put(m, b); err != nil { 88 | t.Fatalf("expected put to succeed, got: %s", err) 89 | } 90 | 91 | r, err := contentStore.Get(m, 0) 92 | if err != nil { 93 | t.Fatalf("expected get to succeed, got: %s", err) 94 | } else { 95 | defer r.Close() 96 | } 97 | 98 | by, _ := ioutil.ReadAll(r) 99 | if string(by) != "test content" { 100 | t.Fatalf("expected to read content, got: %s", string(by)) 101 | } 102 | } 103 | 104 | func TestContentStoreGetWithRange(t *testing.T) { 105 | setup() 106 | defer teardown() 107 | 108 | m := &MetaObject{ 109 | Oid: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", 110 | Size: 12, 111 | } 112 | 113 | b := bytes.NewBuffer([]byte("test content")) 114 | 115 | if err := contentStore.Put(m, b); err != nil { 116 | t.Fatalf("expected put to succeed, got: %s", err) 117 | } 118 | 119 | r, err := contentStore.Get(m, 5) 120 | if err != nil { 121 | t.Fatalf("expected get to succeed, got: %s", err) 122 | } else { 123 | defer r.Close() 124 | } 125 | 126 | by, _ := ioutil.ReadAll(r) 127 | if string(by) != "content" { 128 | t.Fatalf("expected to read content, got: %s", string(by)) 129 | } 130 | } 131 | 132 | func TestContenStoreGetNonExisting(t *testing.T) { 133 | setup() 134 | defer teardown() 135 | 136 | _, err := contentStore.Get(&MetaObject{Oid: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, 0) 137 | if err == nil { 138 | t.Fatalf("expected to get an error, but content existed") 139 | } 140 | } 141 | 142 | func TestContentStoreExists(t *testing.T) { 143 | setup() 144 | defer teardown() 145 | 146 | m := &MetaObject{ 147 | Oid: "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72", 148 | Size: 12, 149 | } 150 | 151 | b := bytes.NewBuffer([]byte("test content")) 152 | 153 | if contentStore.Exists(m) { 154 | t.Fatalf("expected content to not exist yet") 155 | } 156 | 157 | if err := contentStore.Put(m, b); err != nil { 158 | t.Fatalf("expected put to succeed, got: %s", err) 159 | } 160 | 161 | if !contentStore.Exists(m) { 162 | t.Fatalf("expected content to exist") 163 | } 164 | } 165 | 166 | func setup() { 167 | store, err := NewContentStore("content-store-test") 168 | if err != nil { 169 | fmt.Printf("error initializing content store: %s\n", err) 170 | os.Exit(1) 171 | } 172 | contentStore = store 173 | } 174 | 175 | func teardown() { 176 | os.RemoveAll("content-store-test") 177 | } 178 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/git-lfs/lfs-test-server 2 | 3 | require ( 4 | github.com/boltdb/bolt v1.3.1 5 | github.com/gorilla/context v1.1.2 6 | github.com/gorilla/mux v1.8.1 7 | golang.org/x/sys v0.15.0 // indirect 8 | ) 9 | 10 | go 1.16 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= 2 | github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= 3 | github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 4 | github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 5 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 6 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 7 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 8 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 9 | -------------------------------------------------------------------------------- /kvlogger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path" 8 | "runtime" 9 | "strings" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | var ( 15 | pid int 16 | hostname string 17 | ) 18 | 19 | func init() { 20 | pid = os.Getpid() 21 | h, err := os.Hostname() 22 | if err != nil { 23 | hostname = "localhost" 24 | } else { 25 | hostname = h 26 | } 27 | } 28 | 29 | type kv map[string]interface{} 30 | 31 | // KVLogger provides a logger that logs data in key/value pairs. 32 | type KVLogger struct { 33 | w io.Writer 34 | mu sync.Mutex 35 | } 36 | 37 | // NewKVLogger creates a KVLogger that writes to `out`. 38 | func NewKVLogger(out io.Writer) *KVLogger { 39 | return &KVLogger{w: out} 40 | } 41 | 42 | // Log logs the key/value pairs to the logger's output. 43 | func (l *KVLogger) Log(data kv) { 44 | var file string 45 | var line int 46 | var ok bool 47 | 48 | _, file, line, ok = runtime.Caller(2) 49 | if ok { 50 | file = path.Base(file) 51 | } else { 52 | file = "???" 53 | line = 0 54 | } 55 | 56 | out := fmt.Sprintf("%s %s lfs[%d] [%s:%d]: ", time.Now().UTC().Format(time.RFC3339), hostname, pid, file, line) 57 | var vals []string 58 | 59 | for k, v := range data { 60 | vals = append(vals, fmt.Sprintf("%s=%v", k, v)) 61 | } 62 | out += strings.Join(vals, " ") 63 | 64 | l.mu.Lock() 65 | fmt.Fprint(l.w, out+"\n") 66 | l.mu.Unlock() 67 | } 68 | 69 | // Fatal is equivalent to Log() follwed by a call to os.Exit(1) 70 | func (l *KVLogger) Fatal(data kv) { 71 | l.Log(data) 72 | os.Exit(1) 73 | } 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "embed" 6 | "fmt" 7 | "net" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | const ( 15 | contentMediaType = "application/vnd.git-lfs" 16 | metaMediaType = contentMediaType + "+json" 17 | version = "0.4.0" 18 | ) 19 | 20 | var ( 21 | logger = NewKVLogger(os.Stdout) 22 | ) 23 | 24 | //go:embed all:mgmt 25 | var embedded embed.FS 26 | 27 | // tcpKeepAliveListener sets TCP keep-alive timeouts on accepted 28 | // connections. It's used by ListenAndServe and ListenAndServeTLS so 29 | // dead TCP connections (e.g. closing laptop mid-download) eventually 30 | // go away. 31 | type tcpKeepAliveListener struct { 32 | *net.TCPListener 33 | } 34 | 35 | func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) { 36 | tc, err := ln.AcceptTCP() 37 | if err != nil { 38 | return 39 | } 40 | tc.SetKeepAlive(true) 41 | tc.SetKeepAlivePeriod(3 * time.Minute) 42 | return tc, nil 43 | } 44 | 45 | func wrapHttps(l net.Listener, cert, key string) (net.Listener, error) { 46 | var err error 47 | 48 | config := &tls.Config{} 49 | 50 | if config.NextProtos == nil { 51 | config.NextProtos = []string{"http/1.1"} 52 | } 53 | 54 | config.Certificates = make([]tls.Certificate, 1) 55 | config.Certificates[0], err = tls.LoadX509KeyPair(cert, key) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | netListener := l.(*TrackingListener).Listener 61 | 62 | tlsListener := tls.NewListener(tcpKeepAliveListener{netListener.(*net.TCPListener)}, config) 63 | return tlsListener, nil 64 | } 65 | 66 | func maincmd() { 67 | // cmdline interface: cmd oid -> returns object from db or error 68 | metaStore, err := NewMetaStore(Config.MetaDB) 69 | if err != nil { 70 | logger.Fatal(kv{"fn": "maincmd", "err": "Could not open the meta store: " + err.Error()}) 71 | } 72 | 73 | oid := os.Args[2] 74 | meta, err := metaStore.UnsafeGet(&RequestVars{Oid: oid}) 75 | if err != nil { 76 | logger.Fatal(kv{"fn": "maincmd", "err": "Could not find object: " + err.Error()}) 77 | } 78 | fmt.Printf("%+v\n", meta) 79 | } 80 | 81 | func main() { 82 | if len(os.Args) == 2 && os.Args[1] == "-v" { 83 | fmt.Println(version) 84 | os.Exit(0) 85 | } 86 | if len(os.Args) > 2 && os.Args[1] == "cmd" { 87 | maincmd() 88 | os.Exit(0) 89 | } 90 | 91 | var listener net.Listener 92 | 93 | tl, err := NewTrackingListener(Config.Listen) 94 | if err != nil { 95 | logger.Fatal(kv{"fn": "main", "err": "Could not create listener: " + err.Error()}) 96 | } 97 | 98 | listener = tl 99 | 100 | if Config.IsHTTPS() { 101 | logger.Log(kv{"fn": "main", "msg": "Using https"}) 102 | listener, err = wrapHttps(tl, Config.Cert, Config.Key) 103 | if err != nil { 104 | logger.Fatal(kv{"fn": "main", "err": "Could not create https listener: " + err.Error()}) 105 | } 106 | } 107 | 108 | metaStore, err := NewMetaStore(Config.MetaDB) 109 | if err != nil { 110 | logger.Fatal(kv{"fn": "main", "err": "Could not open the meta store: " + err.Error()}) 111 | } 112 | 113 | contentStore, err := NewContentStore(Config.ContentPath) 114 | if err != nil { 115 | logger.Fatal(kv{"fn": "main", "err": "Could not open the content store: " + err.Error()}) 116 | } 117 | 118 | c := make(chan os.Signal, 1) 119 | signal.Notify(c, syscall.SIGHUP) 120 | go func(c chan os.Signal, listener net.Listener) { 121 | for { 122 | sig := <-c 123 | switch sig { 124 | case syscall.SIGHUP: // Graceful shutdown 125 | tl.Close() 126 | } 127 | } 128 | }(c, tl) 129 | 130 | logger.Log(kv{"fn": "main", "msg": "listening", "pid": os.Getpid(), "addr": Config.Listen, "version": version}) 131 | 132 | app := NewApp(contentStore, metaStore) 133 | if Config.IsUsingTus() { 134 | tusServer.Start() 135 | } 136 | app.Serve(listener) 137 | tl.WaitForChildren() 138 | if Config.IsUsingTus() { 139 | tusServer.Stop() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /meta_store.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "math" 10 | "sort" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/boltdb/bolt" 15 | ) 16 | 17 | // MetaStore implements a metadata storage. It stores user credentials and Meta information 18 | // for objects. The storage is handled by boltdb. 19 | type MetaStore struct { 20 | db *bolt.DB 21 | } 22 | 23 | var ( 24 | errNoBucket = errors.New("Bucket not found") 25 | errObjectNotFound = errors.New("Object not found") 26 | errNotOwner = errors.New("Attempt to delete other user's lock") 27 | ) 28 | 29 | var ( 30 | usersBucket = []byte("users") 31 | objectsBucket = []byte("objects") 32 | locksBucket = []byte("locks") 33 | ) 34 | 35 | // NewMetaStore creates a new MetaStore using the boltdb database at dbFile. 36 | func NewMetaStore(dbFile string) (*MetaStore, error) { 37 | db, err := bolt.Open(dbFile, 0600, &bolt.Options{Timeout: 1 * time.Second}) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | db.Update(func(tx *bolt.Tx) error { 43 | if _, err := tx.CreateBucketIfNotExists(usersBucket); err != nil { 44 | return err 45 | } 46 | 47 | if _, err := tx.CreateBucketIfNotExists(objectsBucket); err != nil { 48 | return err 49 | } 50 | 51 | if _, err := tx.CreateBucketIfNotExists(locksBucket); err != nil { 52 | return err 53 | } 54 | 55 | return nil 56 | }) 57 | 58 | return &MetaStore{db: db}, nil 59 | } 60 | 61 | // Get retrieves the Meta information for an object given information in 62 | // RequestVars 63 | func (s *MetaStore) Get(v *RequestVars) (*MetaObject, error) { 64 | meta, error := s.UnsafeGet(v) 65 | return meta, error 66 | } 67 | 68 | // Get retrieves the Meta information for an object given information in 69 | // RequestVars 70 | // DO NOT CHECK authentication, as it is supposed to have been done before 71 | func (s *MetaStore) UnsafeGet(v *RequestVars) (*MetaObject, error) { 72 | var meta MetaObject 73 | 74 | err := s.db.View(func(tx *bolt.Tx) error { 75 | bucket := tx.Bucket(objectsBucket) 76 | if bucket == nil { 77 | return errNoBucket 78 | } 79 | 80 | value := bucket.Get([]byte(v.Oid)) 81 | if len(value) == 0 { 82 | return errObjectNotFound 83 | } 84 | 85 | dec := gob.NewDecoder(bytes.NewBuffer(value)) 86 | return dec.Decode(&meta) 87 | }) 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return &meta, nil 94 | } 95 | 96 | // Put writes meta information from RequestVars to the store. 97 | func (s *MetaStore) Put(v *RequestVars) (*MetaObject, error) { 98 | // Check if it exists first 99 | if meta, err := s.Get(v); err == nil { 100 | meta.Existing = true 101 | return meta, nil 102 | } 103 | 104 | var buf bytes.Buffer 105 | enc := gob.NewEncoder(&buf) 106 | meta := MetaObject{Oid: v.Oid, Size: v.Size} 107 | err := enc.Encode(meta) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | err = s.db.Update(func(tx *bolt.Tx) error { 113 | bucket := tx.Bucket(objectsBucket) 114 | if bucket == nil { 115 | return errNoBucket 116 | } 117 | 118 | err = bucket.Put([]byte(v.Oid), buf.Bytes()) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | }) 125 | 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | return &meta, nil 131 | } 132 | 133 | // Delete removes the meta information from RequestVars to the store. 134 | func (s *MetaStore) Delete(v *RequestVars) error { 135 | err := s.db.Update(func(tx *bolt.Tx) error { 136 | bucket := tx.Bucket(objectsBucket) 137 | if bucket == nil { 138 | return errNoBucket 139 | } 140 | 141 | err := bucket.Delete([]byte(v.Oid)) 142 | if err != nil { 143 | return err 144 | } 145 | 146 | return nil 147 | }) 148 | 149 | return err 150 | } 151 | 152 | // AddLocks write locks to the store for the repo. 153 | func (s *MetaStore) AddLocks(repo string, l ...Lock) error { 154 | err := s.db.Update(func(tx *bolt.Tx) error { 155 | bucket := tx.Bucket(locksBucket) 156 | if bucket == nil { 157 | return errNoBucket 158 | } 159 | 160 | var locks []Lock 161 | data := bucket.Get([]byte(repo)) 162 | if data != nil { 163 | if err := json.Unmarshal(data, &locks); err != nil { 164 | return err 165 | } 166 | } 167 | locks = append(locks, l...) 168 | sort.Sort(LocksByCreatedAt(locks)) 169 | data, err := json.Marshal(&locks) 170 | if err != nil { 171 | return err 172 | } 173 | 174 | return bucket.Put([]byte(repo), data) 175 | }) 176 | return err 177 | } 178 | 179 | // Locks retrieves locks for the repo from the store 180 | func (s *MetaStore) Locks(repo string) ([]Lock, error) { 181 | var locks []Lock 182 | err := s.db.View(func(tx *bolt.Tx) error { 183 | bucket := tx.Bucket(locksBucket) 184 | if bucket == nil { 185 | return errNoBucket 186 | } 187 | 188 | data := bucket.Get([]byte(repo)) 189 | if data != nil { 190 | if err := json.Unmarshal(data, &locks); err != nil { 191 | return err 192 | } 193 | } 194 | return nil 195 | }) 196 | return locks, err 197 | } 198 | 199 | // FilteredLocks return filtered locks for the repo 200 | func (s *MetaStore) FilteredLocks(repo, path, cursor, limit string) (locks []Lock, next string, err error) { 201 | locks, err = s.Locks(repo) 202 | if err != nil { 203 | return 204 | } 205 | 206 | if cursor != "" { 207 | lastSeen := -1 208 | for i, l := range locks { 209 | if l.Id == cursor { 210 | lastSeen = i 211 | break 212 | } 213 | } 214 | 215 | if lastSeen > -1 { 216 | locks = locks[lastSeen:] 217 | } else { 218 | err = fmt.Errorf("cursor (%s) not found", cursor) 219 | return 220 | } 221 | } 222 | 223 | if path != "" { 224 | var filtered []Lock 225 | for _, l := range locks { 226 | if l.Path == path { 227 | filtered = append(filtered, l) 228 | } 229 | } 230 | 231 | locks = filtered 232 | } 233 | 234 | if limit != "" { 235 | var size int 236 | size, err = strconv.Atoi(limit) 237 | if err != nil || size < 0 { 238 | locks = make([]Lock, 0) 239 | err = fmt.Errorf("Invalid limit amount: %s", limit) 240 | return 241 | } 242 | 243 | size = int(math.Min(float64(size), float64(len(locks)))) 244 | if size+1 < len(locks) { 245 | next = locks[size].Id 246 | } 247 | locks = locks[:size] 248 | } 249 | 250 | return locks, next, nil 251 | } 252 | 253 | // DeleteLock removes lock for the repo by id from the store 254 | func (s *MetaStore) DeleteLock(repo, user, id string, force bool) (*Lock, error) { 255 | var deleted *Lock 256 | err := s.db.Update(func(tx *bolt.Tx) error { 257 | bucket := tx.Bucket(locksBucket) 258 | if bucket == nil { 259 | return errNoBucket 260 | } 261 | 262 | var locks []Lock 263 | data := bucket.Get([]byte(repo)) 264 | if data != nil { 265 | if err := json.Unmarshal(data, &locks); err != nil { 266 | return err 267 | } 268 | } 269 | newLocks := make([]Lock, 0, len(locks)) 270 | 271 | var lock Lock 272 | for _, l := range locks { 273 | if l.Id == id { 274 | if l.Owner.Name != user && !force { 275 | return errNotOwner 276 | } 277 | lock = l 278 | } else if len(l.Id) > 0 { 279 | newLocks = append(newLocks, l) 280 | } 281 | } 282 | if lock.Id == "" { 283 | return nil 284 | } 285 | deleted = &lock 286 | 287 | if len(newLocks) == 0 { 288 | return bucket.Delete([]byte(repo)) 289 | } 290 | 291 | data, err := json.Marshal(&newLocks) 292 | if err != nil { 293 | return err 294 | } 295 | return bucket.Put([]byte(repo), data) 296 | }) 297 | return deleted, err 298 | } 299 | 300 | type LocksByCreatedAt []Lock 301 | 302 | func (c LocksByCreatedAt) Len() int { return len(c) } 303 | func (c LocksByCreatedAt) Less(i, j int) bool { return c[i].LockedAt.Before(c[j].LockedAt) } 304 | func (c LocksByCreatedAt) Swap(i, j int) { c[i], c[j] = c[j], c[i] } 305 | 306 | // Close closes the underlying boltdb. 307 | func (s *MetaStore) Close() { 308 | s.db.Close() 309 | } 310 | 311 | // AddUser adds user credentials to the meta store. 312 | func (s *MetaStore) AddUser(user, pass string) error { 313 | err := s.db.Update(func(tx *bolt.Tx) error { 314 | bucket := tx.Bucket(usersBucket) 315 | if bucket == nil { 316 | return errNoBucket 317 | } 318 | 319 | err := bucket.Put([]byte(user), []byte(pass)) 320 | if err != nil { 321 | return err 322 | } 323 | return nil 324 | }) 325 | 326 | return err 327 | } 328 | 329 | // DeleteUser removes user credentials from the meta store. 330 | func (s *MetaStore) DeleteUser(user string) error { 331 | err := s.db.Update(func(tx *bolt.Tx) error { 332 | bucket := tx.Bucket(usersBucket) 333 | if bucket == nil { 334 | return errNoBucket 335 | } 336 | 337 | err := bucket.Delete([]byte(user)) 338 | return err 339 | }) 340 | 341 | return err 342 | } 343 | 344 | // MetaUser encapsulates information about a meta store user 345 | type MetaUser struct { 346 | Name string 347 | } 348 | 349 | // Users returns all MetaUsers in the meta store 350 | func (s *MetaStore) Users() ([]*MetaUser, error) { 351 | var users []*MetaUser 352 | 353 | err := s.db.View(func(tx *bolt.Tx) error { 354 | bucket := tx.Bucket(usersBucket) 355 | if bucket == nil { 356 | return errNoBucket 357 | } 358 | 359 | bucket.ForEach(func(k, v []byte) error { 360 | users = append(users, &MetaUser{string(k)}) 361 | return nil 362 | }) 363 | return nil 364 | }) 365 | 366 | return users, err 367 | } 368 | 369 | // Objects returns all MetaObjects in the meta store 370 | func (s *MetaStore) Objects() ([]*MetaObject, error) { 371 | var objects []*MetaObject 372 | 373 | err := s.db.View(func(tx *bolt.Tx) error { 374 | bucket := tx.Bucket(objectsBucket) 375 | if bucket == nil { 376 | return errNoBucket 377 | } 378 | 379 | bucket.ForEach(func(k, v []byte) error { 380 | var meta MetaObject 381 | dec := gob.NewDecoder(bytes.NewBuffer(v)) 382 | err := dec.Decode(&meta) 383 | if err != nil { 384 | return err 385 | } 386 | objects = append(objects, &meta) 387 | return nil 388 | }) 389 | return nil 390 | }) 391 | 392 | return objects, err 393 | } 394 | 395 | // AllLocks return all locks in the store, lock path is prepended with repo 396 | func (s *MetaStore) AllLocks() ([]Lock, error) { 397 | var locks []Lock 398 | err := s.db.View(func(tx *bolt.Tx) error { 399 | bucket := tx.Bucket(locksBucket) 400 | if bucket == nil { 401 | return errNoBucket 402 | } 403 | 404 | bucket.ForEach(func(k, v []byte) error { 405 | var l []Lock 406 | if err := json.Unmarshal(v, &l); err != nil { 407 | return err 408 | } 409 | for _, lv := range l { 410 | lv.Path = fmt.Sprintf("%s:%s", k, lv.Path) 411 | locks = append(locks, lv) 412 | } 413 | return nil 414 | }) 415 | 416 | return nil 417 | }) 418 | return locks, err 419 | } 420 | 421 | // Authenticate authorizes user with password and returns the user name 422 | func (s *MetaStore) Authenticate(user, password string) (string, bool) { 423 | // check admin 424 | if len(user) > 0 && len(password) > 0 { 425 | if ok := checkBasicAuth(user, password, true); ok { 426 | return user, true 427 | } 428 | } 429 | 430 | value := "" 431 | 432 | s.db.View(func(tx *bolt.Tx) error { 433 | bucket := tx.Bucket(usersBucket) 434 | if bucket == nil { 435 | return errNoBucket 436 | } 437 | 438 | value = string(bucket.Get([]byte(user))) 439 | return nil 440 | }) 441 | 442 | return user, value != "" && value == password 443 | } 444 | -------------------------------------------------------------------------------- /meta_store_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var ( 11 | metaStoreTest *MetaStore 12 | ) 13 | 14 | func TestGetMeta(t *testing.T) { 15 | setupMeta() 16 | defer teardownMeta() 17 | 18 | meta, err := metaStoreTest.Get(&RequestVars{Oid: contentOid}) 19 | if err != nil { 20 | t.Fatalf("Error retreiving meta: %s", err) 21 | } 22 | 23 | if meta.Oid != contentOid { 24 | t.Errorf("expected to get content oid, got: %s", meta.Oid) 25 | } 26 | 27 | if meta.Size != contentSize { 28 | t.Errorf("expected to get content size, got: %d", meta.Size) 29 | } 30 | } 31 | 32 | func TestPutMeta(t *testing.T) { 33 | setupMeta() 34 | defer teardownMeta() 35 | 36 | meta, err := metaStoreTest.Put(&RequestVars{Oid: nonExistingOid, Size: 42}) 37 | if err != nil { 38 | t.Errorf("expected put to succeed, got : %s", err) 39 | } 40 | 41 | if meta.Existing { 42 | t.Errorf("expected meta to not have existed") 43 | } 44 | 45 | meta, err = metaStoreTest.Get(&RequestVars{Oid: nonExistingOid}) 46 | if err != nil { 47 | t.Errorf("expected to be able to retreive new put, got : %s", err) 48 | } 49 | 50 | if meta.Oid != nonExistingOid { 51 | t.Errorf("expected oids to match, got: %s", meta.Oid) 52 | } 53 | 54 | if meta.Size != 42 { 55 | t.Errorf("expected sizes to match, got: %d", meta.Size) 56 | } 57 | 58 | meta, err = metaStoreTest.Put(&RequestVars{Oid: nonExistingOid, Size: 42}) 59 | if err != nil { 60 | t.Errorf("expected put to succeed, got : %s", err) 61 | } 62 | 63 | if !meta.Existing { 64 | t.Errorf("expected meta to now exist") 65 | } 66 | } 67 | 68 | func TestLocks(t *testing.T) { 69 | setupMeta() 70 | defer teardownMeta() 71 | 72 | for i := 0; i < 5; i++ { 73 | lock := NewTestLock(randomLockId(), fmt.Sprintf("path-%d", i), fmt.Sprintf("user-%d", i)) 74 | if err := metaStoreTest.AddLocks(testRepo, lock); err != nil { 75 | t.Errorf("expected AddLocks to succeed, got : %s", err) 76 | } 77 | } 78 | 79 | locks, err := metaStoreTest.Locks(testRepo) 80 | if err != nil { 81 | t.Errorf("expected Locks to succeed, got : %s", err) 82 | } 83 | if len(locks) != 5 { 84 | t.Errorf("expected returned lock count to match, got: %d", len(locks)) 85 | } 86 | } 87 | 88 | func TestFilteredLocks(t *testing.T) { 89 | setupMeta() 90 | defer teardownMeta() 91 | 92 | testLocks := make([]Lock, 0, 5) 93 | for i := 0; i < 5; i++ { 94 | lock := NewTestLock(randomLockId(), fmt.Sprintf("path-%d", i), fmt.Sprintf("user-%d", i)) 95 | testLocks = append(testLocks, lock) 96 | } 97 | if err := metaStoreTest.AddLocks(testRepo, testLocks...); err != nil { 98 | t.Errorf("expected AddLocks to succeed, got : %s", err) 99 | } 100 | 101 | locks, next, err := metaStoreTest.FilteredLocks(testRepo, "", "", "3") 102 | if err != nil { 103 | t.Errorf("expected FilteredLocks to succeed, got : %s", err) 104 | } 105 | if len(locks) != 3 { 106 | t.Errorf("expected locks count to match limit, got: %d", len(locks)) 107 | } 108 | if next == "" { 109 | t.Errorf("expected next to exist") 110 | } 111 | 112 | locks, next, err = metaStoreTest.FilteredLocks(testRepo, "", next, "2") 113 | if err != nil { 114 | t.Errorf("expected FilteredLocks to succeed, got : %s", err) 115 | } 116 | if len(locks) != 2 { 117 | t.Errorf("expected locks count to match limit, got: %d", len(locks)) 118 | } 119 | if next != "" { 120 | t.Errorf("expected next to not exist, got: %s", next) 121 | } 122 | } 123 | 124 | func TestAddLocks(t *testing.T) { 125 | setupMeta() 126 | defer teardownMeta() 127 | 128 | lock := NewTestLock(lockId, lockPath, testUser) 129 | if err := metaStoreTest.AddLocks(testRepo, lock); err != nil { 130 | t.Errorf("expected AddLocks to succeed, got : %s", err) 131 | } 132 | 133 | locks, _, err := metaStoreTest.FilteredLocks(testRepo, lock.Path, "", "1") 134 | if err != nil { 135 | t.Errorf("expected FilteredLocks to succeed, got : %s", err) 136 | } 137 | if len(locks) != 1 { 138 | t.Errorf("expected lock to be existed") 139 | } 140 | if locks[0].Id != lockId { 141 | t.Errorf("expected lockId to match, got: %s", locks[0]) 142 | } 143 | } 144 | 145 | func TestDeleteLock(t *testing.T) { 146 | setupMeta() 147 | defer teardownMeta() 148 | 149 | lock := NewTestLock(lockId, lockPath, testUser) 150 | if err := metaStoreTest.AddLocks(testRepo, lock); err != nil { 151 | t.Errorf("expected AddLocks to succeed, got : %s", err) 152 | } 153 | 154 | deleted, err := metaStoreTest.DeleteLock(testRepo, testUser, lock.Id, false) 155 | if err != nil { 156 | t.Errorf("expected DeleteLock to succeed, got : %s", err) 157 | } 158 | if deleted == nil || deleted.Id != lock.Id { 159 | t.Errorf("expected deleted lock to be returned, got : %s", deleted) 160 | } 161 | } 162 | 163 | func TestDeleteLockNotOwner(t *testing.T) { 164 | setupMeta() 165 | defer teardownMeta() 166 | 167 | lock := NewTestLock(lockId, lockPath, testUser) 168 | if err := metaStoreTest.AddLocks(testRepo, lock); err != nil { 169 | t.Errorf("expected AddLocks to succeed, got : %s", err) 170 | } 171 | 172 | deleted, err := metaStoreTest.DeleteLock(testRepo, testUser1, lock.Id, false) 173 | if err == nil || deleted != nil { 174 | t.Errorf("expected DeleteLock to failed") 175 | } 176 | 177 | if err != errNotOwner { 178 | t.Errorf("expected DeleteLock error match, got: %s", err) 179 | } 180 | } 181 | 182 | func TestDeleteLockNotOwnerForce(t *testing.T) { 183 | setupMeta() 184 | defer teardownMeta() 185 | 186 | lock := NewTestLock(lockId, lockPath, testUser) 187 | if err := metaStoreTest.AddLocks(testRepo, lock); err != nil { 188 | t.Errorf("expected AddLocks to succeed, got : %s", err) 189 | } 190 | 191 | deleted, err := metaStoreTest.DeleteLock(testRepo, testUser1, lock.Id, true) 192 | if err != nil { 193 | t.Errorf("expected DeleteLock(force) to succeed, got : %s", err) 194 | } 195 | if deleted == nil || deleted.Id != lock.Id { 196 | t.Errorf("expected deleted lock to be returned, got : %s", deleted) 197 | } 198 | } 199 | 200 | func TestDeleteLockNonExisting(t *testing.T) { 201 | setupMeta() 202 | defer teardownMeta() 203 | 204 | lock := NewTestLock(lockId, lockPath, testUser) 205 | if err := metaStoreTest.AddLocks(testRepo, lock); err != nil { 206 | t.Errorf("expected AddLocks to succeed, got : %s", err) 207 | } 208 | 209 | deleted, err := metaStoreTest.DeleteLock(testRepo, testUser, nonExistingLockId, false) 210 | if err != nil { 211 | t.Errorf("expected DeleteLock to succeed, got : %s", err) 212 | } 213 | if deleted != nil { 214 | t.Errorf("expected nil returned, got : %s", deleted) 215 | } 216 | } 217 | 218 | func NewTestLock(id, path, user string) Lock { 219 | return Lock{ 220 | Id: id, 221 | Path: path, 222 | Owner: User{ 223 | Name: user, 224 | }, 225 | LockedAt: time.Now(), 226 | } 227 | } 228 | 229 | func setupMeta() { 230 | store, err := NewMetaStore("test-meta-store.db") 231 | if err != nil { 232 | fmt.Printf("error initializing test meta store: %s\n", err) 233 | os.Exit(1) 234 | } 235 | 236 | metaStoreTest = store 237 | if err := metaStoreTest.AddUser(testUser, testPass); err != nil { 238 | teardownMeta() 239 | fmt.Printf("error adding test user to meta store: %s\n", err) 240 | os.Exit(1) 241 | } 242 | 243 | rv := &RequestVars{Oid: contentOid, Size: contentSize} 244 | if _, err := metaStoreTest.Put(rv); err != nil { 245 | teardownMeta() 246 | fmt.Printf("error seeding test meta store: %s\n", err) 247 | os.Exit(1) 248 | } 249 | } 250 | 251 | func teardownMeta() { 252 | metaStoreTest.Close() 253 | os.RemoveAll("test-meta-store.db") 254 | } 255 | -------------------------------------------------------------------------------- /mgmt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | type pageData struct { 13 | Name string 14 | Config *Configuration 15 | Users []*MetaUser 16 | Objects []*MetaObject 17 | Locks []Lock 18 | Oid string 19 | } 20 | 21 | func (a *App) addMgmt(r *mux.Router) { 22 | r.HandleFunc("/mgmt", basicAuth(a.indexHandler)).Methods("GET") 23 | r.HandleFunc("/mgmt/objects", basicAuth(a.objectsHandler)).Methods("GET") 24 | r.HandleFunc("/mgmt/raw/{oid}", basicAuth(a.objectsRawHandler)).Methods("GET") 25 | r.HandleFunc("/mgmt/locks", basicAuth(a.locksHandler)).Methods("GET") 26 | r.HandleFunc("/mgmt/users", basicAuth(a.usersHandler)).Methods("GET") 27 | r.HandleFunc("/mgmt/add", basicAuth(a.addUserHandler)).Methods("POST") 28 | r.HandleFunc("/mgmt/del", basicAuth(a.delUserHandler)).Methods("POST") 29 | 30 | r.HandleFunc("/mgmt/css/{file}", basicAuth(cssHandler)) 31 | } 32 | 33 | func cssHandler(w http.ResponseWriter, r *http.Request) { 34 | file := mux.Vars(r)["file"] 35 | f, err := embedded.Open(fmt.Sprintf("mgmt/css/%s", file)) 36 | if err != nil { 37 | writeStatus(w, r, 404) 38 | return 39 | } 40 | 41 | w.Header().Set("Content-Type", "text/css") 42 | 43 | io.Copy(w, f) 44 | f.Close() 45 | } 46 | 47 | func checkBasicAuth(user string, pass string, ok bool) bool { 48 | if !ok { 49 | return false 50 | } 51 | 52 | if user != Config.AdminUser || pass != Config.AdminPass { 53 | return false 54 | } 55 | return true 56 | } 57 | 58 | func basicAuth(h http.HandlerFunc) http.HandlerFunc { 59 | return func(w http.ResponseWriter, r *http.Request) { 60 | if Config.AdminUser == "" || Config.AdminPass == "" { 61 | writeStatus(w, r, 404) 62 | return 63 | } 64 | 65 | user, pass, ok := r.BasicAuth() 66 | 67 | ret := checkBasicAuth(user, pass, ok) 68 | if !ret { 69 | w.Header().Set("WWW-Authenticate", "Basic realm=mgmt") 70 | writeStatus(w, r, 401) 71 | return 72 | } 73 | 74 | h(w, r) 75 | logRequest(r, 200) 76 | } 77 | } 78 | 79 | func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { 80 | if err := render(w, "config.tmpl", pageData{Name: "index", Config: Config}); err != nil { 81 | writeStatus(w, r, 404) 82 | } 83 | } 84 | 85 | func (a *App) objectsHandler(w http.ResponseWriter, r *http.Request) { 86 | objects, err := a.metaStore.Objects() 87 | if err != nil { 88 | fmt.Fprintf(w, "Error retrieving objects: %s", err) 89 | return 90 | } 91 | 92 | if err := render(w, "objects.tmpl", pageData{Name: "objects", Objects: objects}); err != nil { 93 | writeStatus(w, r, 404) 94 | } 95 | } 96 | 97 | func (a *App) objectsRawHandler(w http.ResponseWriter, r *http.Request) { 98 | vars := mux.Vars(r) 99 | rv := &RequestVars{Oid: vars["oid"]} 100 | 101 | meta, err := a.metaStore.UnsafeGet(rv) 102 | if err != nil { 103 | writeStatus(w, r, 404) 104 | return 105 | } 106 | 107 | content, err := a.contentStore.Get(meta, 0) 108 | if err != nil { 109 | writeStatus(w, r, 404) 110 | return 111 | } 112 | defer content.Close() 113 | 114 | w.Header().Set("Content-Type", "application/octet-stream") 115 | w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s;", vars["oid"])) 116 | w.Header().Set("Content-Transfer-Encoding", "binary") 117 | w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size)) 118 | io.Copy(w, content) 119 | } 120 | 121 | func (a *App) locksHandler(w http.ResponseWriter, r *http.Request) { 122 | locks, err := a.metaStore.AllLocks() 123 | if err != nil { 124 | fmt.Fprintf(w, "Error retrieving locks: %s", err) 125 | return 126 | } 127 | 128 | if err := render(w, "locks.tmpl", pageData{Name: "locks", Locks: locks}); err != nil { 129 | writeStatus(w, r, 404) 130 | } 131 | } 132 | 133 | func (a *App) usersHandler(w http.ResponseWriter, r *http.Request) { 134 | users, err := a.metaStore.Users() 135 | if err != nil { 136 | fmt.Fprintf(w, "Error retrieving users: %s", err) 137 | return 138 | } 139 | 140 | if err := render(w, "users.tmpl", pageData{Name: "users", Users: users}); err != nil { 141 | writeStatus(w, r, 404) 142 | } 143 | } 144 | 145 | func (a *App) addUserHandler(w http.ResponseWriter, r *http.Request) { 146 | user := r.FormValue("name") 147 | pass := r.FormValue("password") 148 | if user == "" || pass == "" { 149 | fmt.Fprint(w, "Invalid username or password") 150 | return 151 | } 152 | 153 | if err := a.metaStore.AddUser(user, pass); err != nil { 154 | fmt.Fprintf(w, "Error adding user: %s", err) 155 | return 156 | } 157 | 158 | http.Redirect(w, r, "/mgmt/users", 302) 159 | } 160 | 161 | func (a *App) delUserHandler(w http.ResponseWriter, r *http.Request) { 162 | user := r.FormValue("name") 163 | if user == "" { 164 | fmt.Fprint(w, "Invalid username") 165 | return 166 | } 167 | 168 | if err := a.metaStore.DeleteUser(user); err != nil { 169 | fmt.Fprintf(w, "Error deleting user: %s", err) 170 | return 171 | } 172 | 173 | http.Redirect(w, r, "/mgmt/users", 302) 174 | } 175 | 176 | func render(w http.ResponseWriter, tmpl string, data pageData) error { 177 | body, err := embedded.ReadFile("mgmt/templates/body.tmpl") 178 | if err != nil { 179 | return err 180 | } 181 | bodyString := string(body) 182 | 183 | content, err := embedded.ReadFile(fmt.Sprintf("mgmt/templates/%s", tmpl)) 184 | if err != nil { 185 | return err 186 | } 187 | contentString := string(content) 188 | 189 | t := template.Must(template.New("main").Parse(bodyString)) 190 | t.New("content").Parse(contentString) 191 | 192 | return t.Execute(w, data) 193 | } 194 | -------------------------------------------------------------------------------- /mgmt/css/primer.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.1 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:bold}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-0.5em}sub{bottom:-0.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}input[type="number"]::-webkit-inner-spin-button,input[type="number"]::-webkit-outer-spin-button{height:auto}input[type="search"]{-webkit-appearance:textfield;box-sizing:content-box}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:bold}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}*{box-sizing:border-box}input,select,textarea,button{font:13px/1.4 Helvetica,arial,nimbussansl,liberationsans,freesans,clean,sans-serif,"Segoe UI Emoji","Segoe UI Symbol"}body{font:13px/1.4 Helvetica,arial,nimbussansl,liberationsans,freesans,clean,sans-serif,"Segoe UI Emoji","Segoe UI Symbol";color:#333;background-color:#fff}a{color:#4183c4;text-decoration:none}a:hover,a:active{text-decoration:underline}hr,.rule{height:0;margin:15px 0;overflow:hidden;background:transparent;border:0;border-bottom:1px solid #ddd}hr:before,.rule:before{display:table;content:""}hr:after,.rule:after{display:table;clear:both;content:""}h1,h2,h3,h4,h5,h6{margin-top:15px;margin-bottom:15px;line-height:1.1}h1{font-size:30px}h2{font-size:21px}h3{font-size:16px}h4{font-size:14px}h5{font-size:12px}h6{font-size:11px}small{font-size:90%}blockquote{margin:0}.lead{margin-bottom:30px;font-size:20px;font-weight:300;color:#555}.text-muted{color:#999}.text-danger{color:#bd2c00}.text-emphasized{font-weight:bold;color:#333}ul,ol{padding:0;margin-top:0;margin-bottom:0}ol ol,ul ol{list-style-type:lower-roman}ul ul ol,ul ol ol,ol ul ol,ol ol ol{list-style-type:lower-alpha}dd{margin-left:0}tt,code{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace;font-size:12px}pre{margin-top:0;margin-bottom:0;font:12px Consolas,"Liberation Mono",Menlo,Courier,monospace}.container{width:980px;margin-right:auto;margin-left:auto}.container:before{display:table;content:""}.container:after{display:table;clear:both;content:""}.columns{margin-right:-10px;margin-left:-10px}.columns:before{display:table;content:""}.columns:after{display:table;clear:both;content:""}.column{float:left;padding-right:10px;padding-left:10px}.one-third{width:33.333333%}.two-thirds{width:66.666667%}.one-fourth{width:25%}.one-half{width:50%}.three-fourths{width:75%}.one-fifth{width:20%}.four-fifths{width:80%}.single-column{padding-right:10px;padding-left:10px}.table-column{display:table-cell;width:1%;padding-right:10px;padding-left:10px;vertical-align:top}fieldset{padding:0;margin:0;border:0}label{font-size:13px;font-weight:bold}.form-control,input[type="text"],input[type="password"],input[type="email"],input[type="number"],input[type="tel"],input[type="url"],textarea{min-height:34px;padding:7px 8px;font-size:13px;color:#333;vertical-align:middle;background-color:#fff;background-repeat:no-repeat;background-position:right center;border:1px solid #ccc;border-radius:3px;outline:none;box-shadow:inset 0 1px 2px rgba(0,0,0,0.075)}.form-control.focus,.form-control:focus,input[type="text"].focus,input[type="text"]:focus,.focused .drag-and-drop,input[type="password"].focus,input[type="password"]:focus,input[type="email"].focus,input[type="email"]:focus,input[type="number"].focus,input[type="number"]:focus,input[type="tel"].focus,input[type="tel"]:focus,input[type="url"].focus,input[type="url"]:focus,textarea.focus,textarea:focus{border-color:#51a7e8;box-shadow:inset 0 1px 2px rgba(0,0,0,0.075),0 0 5px rgba(81,167,232,0.5)}input.input-contrast,.input-contrast{background-color:#fafafa}input.input-contrast:focus,.input-contrast:focus{background-color:#fff}::-webkit-input-placeholder,:-moz-placeholder{color:#aaa}::-webkit-validation-bubble-message{font-size:12px;color:#fff;background:#9c2400;border:0;border-radius:3px;-webkit-box-shadow:1px 1px 1px rgba(0,0,0,0.1)}input::-webkit-validation-bubble-icon{display:none}::-webkit-validation-bubble-arrow{background-color:#9c2400;border:solid 1px #9c2400;-webkit-box-shadow:1px 1px 1px rgba(0,0,0,0.1)}input.input-mini{min-height:26px;padding-top:4px;padding-bottom:4px;font-size:12px}input.input-large{padding:6px 10px;font-size:16px}.input-block{display:block;width:100%}.input-monospace{font-family:Consolas,"Liberation Mono",Menlo,Courier,monospace}dl.form{margin:15px 0}dl.form input[type="text"],dl.form input[type="password"],dl.form input[type="email"],dl.form input[type="url"],dl.form textarea{background-color:#fafafa}dl.form input[type="text"]:focus,dl.form .focused .drag-and-drop,.focused dl.form .drag-and-drop,dl.form input[type="password"]:focus,dl.form input[type="email"]:focus,dl.form input[type="url"]:focus,dl.form textarea:focus{background-color:#fff}dl.form>dt{margin:0 0 6px}dl.form>dt label{position:relative}dl.form.flattened>dt{float:left;margin:0;line-height:32px}dl.form.flattened>dd{line-height:32px}dl.form>dd input[type="text"],dl.form>dd input[type="password"],dl.form>dd input[type="email"],dl.form>dd input[type="url"]{width:440px;max-width:100%;margin-right:5px;background-position-x:98%}dl.form>dd input.shorter{width:130px}dl.form>dd input.short{width:250px}dl.form>dd input.long{width:100%}dl.form>dd textarea{width:100%;height:200px;min-height:200px}dl.form>dd textarea.short{height:50px;min-height:50px}dl.form>dd h4{margin:4px 0 0}dl.form>dd h4.is-error{color:#bd2c00}dl.form>dd h4.is-success{color:#6cc644}dl.form>dd h4+p.note{margin-top:0}dl.form.required>dt>label:after{padding-left:5px;color:#9f1006;content:"*"}.note{min-height:17px;margin:4px 0 2px;font-size:12px;color:#777}.note .spinner{margin-right:3px;vertical-align:middle}.form-checkbox{padding-left:20px;margin:15px 0;vertical-align:middle}.form-checkbox label em.highlight{position:relative;left:-4px;padding:2px 4px;font-style:normal;background:#fffbdc;border-radius:3px}.form-checkbox input[type=checkbox],.form-checkbox input[type=radio]{float:left;margin:2px 0 0 -20px;vertical-align:middle}.form-checkbox .note{display:block;margin:0;font-size:12px;font-weight:normal;color:#666}dl.form .success,dl.form .error,dl.form .indicator{display:none;font-size:12px;font-weight:bold}dl.form.loading{opacity:0.5}dl.form.loading .indicator{display:inline}dl.form.loading .spinner{display:inline-block;vertical-align:middle}dl.form.successful .success{display:inline;color:#390}dl.form.errored>dt label{color:#900}dl.form.errored .error{display:inline;color:#900}dl.form.errored dd.error,dl.form.errored dd.warning{display:inline-block;padding:5px;font-size:11px;color:#494620;background:#f7ea57;border:1px solid #c0b536;border-top-color:#fff;border-bottom-right-radius:3px;border-bottom-left-radius:3px}dl.form.warn .warning{display:inline;color:#900}dl.form.warn dd.warning{display:inline-block;padding:5px;font-size:11px;color:#494620;background:#f7ea57;border:1px solid #c0b536;border-top-color:#fff;border-bottom-right-radius:3px;border-bottom-left-radius:3px}dl.form .form-note{display:inline-block;padding:5px;margin-top:-1px;font-size:11px;color:#494620;background:#f7ea57;border:1px solid #c0b536;border-top-color:#fff;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.hfields{margin:15px 0}.hfields:before{display:table;content:""}.hfields:after{display:table;clear:both;content:""}.hfields dl.form{float:left;margin:0 30px 0 0}.hfields dl.form>dt label{display:inline-block;margin:5px 0 0;color:#666}.hfields dl.form>dt label img{position:relative;top:-2px}.hfields .btn{float:left;margin:28px 25px 0 -20px}.hfields select{margin-top:5px}html.no-dnd-uploads .drag-and-drop{min-height:32px}html.no-dnd-uploads .drag-and-drop .default{display:none}html.no-dnd-uploads .upload-enabled textarea{border-bottom:1px solid #ddd}.drag-and-drop{padding:7px 10px;margin:0;font-size:13px;line-height:16px;color:#aaa;background-color:#fafafa;border:1px solid #ccc;border-top:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.drag-and-drop .default,.drag-and-drop .loading,.drag-and-drop .error{display:none}.drag-and-drop .error{color:#bd2c00}.drag-and-drop img{vertical-align:top}.is-default .drag-and-drop .default{display:inline-block}.is-uploading .drag-and-drop .loading{display:inline-block}.is-bad-file .drag-and-drop .bad-file{display:inline-block}.is-too-big .drag-and-drop .too-big{display:inline-block}.is-empty .drag-and-drop .empty{display:inline-block}.is-bad-browser .drag-and-drop .bad-browser{display:inline-block}.drag-and-drop-error-info{font-weight:normal;color:#aaa}.drag-and-drop-error-info a{color:#4183c4}.is-failed .drag-and-drop .failed-request{display:inline-block}.manual-file-chooser{position:absolute;width:240px;padding:5px;margin-left:-80px;cursor:pointer;opacity:0.0001}.manual-file-chooser:hover+.manual-file-chooser-text{text-decoration:underline}.btn .manual-file-chooser{top:0;padding:0;line-height:34px}.upload-enabled textarea{display:block;border-bottom:1px dashed #ddd;border-bottom-right-radius:0;border-bottom-left-radius:0}.focused .drag-and-drop{box-shadow:rgba(81,167,232,0.5) 0 0 3px}.dragover textarea,.dragover .drag-and-drop{box-shadow:#c9ff00 0 0 3px}.previewable-comment-form{position:relative}.previewable-comment-form .tabnav{position:relative;padding:10px 10px 0}.previewable-comment-form .comment{border:1px solid #cacaca}.previewable-comment-form .comment-header .comment-header-actions{display:none}.previewable-comment-form .comment-form-error{margin-bottom:10px}.previewable-comment-form .write-content,.previewable-comment-form .preview-content{display:none;padding:0 10px 10px}.previewable-comment-form.write-selected .write-content,.previewable-comment-form.preview-selected .preview-content{display:block}.previewable-comment-form textarea{display:block;width:100%;min-height:100px;max-height:500px;padding:10px;resize:vertical}.previewable-comment-form textarea.fullscreen-contents:focus{border:0;box-shadow:none}div.composer{margin-top:0;border:0}.composer .comment-form-textarea{height:200px;min-height:200px}.composer-infobar{height:35px;padding:0 10px;margin-bottom:10px;border-bottom:1px solid #eee}.composer .tabnav{margin:0 0 10px}.infobar-widget.milestone{position:relative;float:right}.infobar-widget.milestone .select-menu-modal-holder{right:0}.infobar-widget.assignee{float:left}.infobar-widget.assignee .css-truncate-target{max-width:110px}.infobar-widget .text,.infobar-widget .avatar,.infobar-widget .select-menu{display:inline-block;vertical-align:top}.infobar-widget .text{margin-top:3px}.infobar-widget .text a{font-weight:bold;color:#333}.infobar-widget .progress-bar{width:200px;overflow:hidden;line-height:18px}input::-webkit-outer-spin-button,input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.input-group{display:table}.input-group input{position:relative;width:100%}.input-group input:focus{z-index:2}.input-group input[type="text"]+.btn{margin-left:0}.input-group.inline{display:inline-table}.input-group input,.input-group-button{display:table-cell}.input-group-button{width:1%;vertical-align:middle}.input-group input:first-child,.input-group-button:first-child .btn{border-top-right-radius:0;border-bottom-right-radius:0}.input-group-button:first-child .btn{margin-right:-1px}.input-group input:last-child,.input-group-button:last-child .btn{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-button:last-child .btn{margin-left:-1px}h2.account{margin:15px 0 0;font-size:18px;font-weight:normal;color:#666}p.explain{position:relative;font-size:12px;color:#666}p.explain strong{color:#333}p.explain .octicon{margin-right:5px;color:#bbb}p.explain .minibutton{top:-4px;float:right}.options-content p.explain{padding:10px 10px 0;margin-top:0;border-top:1px solid #ddd}.form-actions:before{display:table;content:""}.form-actions:after{display:table;clear:both;content:""}.form-actions .btn{float:right}.form-actions .btn+.btn{margin-right:5px}.form-warning{padding:8px 10px;margin:10px 0;font-size:14px;color:#333;background:#ffffe2;border:1px solid #e7e4c2;border-radius:4px}.form-warning p{margin:0;line-height:1.5}.form-warning strong{color:#000}.form-warning a{font-weight:bold}.status-indicator{font:normal normal 16px/1 "octicons";display:inline-block;text-decoration:none;-webkit-font-smoothing:antialiased;margin-left:5px}.status-indicator-success:before{color:#6cc644;content:"\f03a"}.status-indicator-failed:before{color:#bd2c00;content:"\f02d"}.clearfix:before{display:table;content:""}.clearfix:after{display:table;clear:both;content:""}.right{float:right}.left{float:left}.text-right{text-align:right}.text-left{text-align:left}.danger{color:#c00}.mute{color:#000}.text-diff-added{color:#55a532}.text-diff-deleted{color:#bd2c00}.text-open,.text-success{color:#6cc644}.text-closed{color:#bd2c00}.text-reverted{color:#bd2c00}.text-merged{color:#6e5494}.text-renamed{color:#fffa5d}.text-pending{color:#cea61b}.text-error,.text-failure{color:#bd2c00}.muted-link{color:#777}.muted-link:hover{color:#4183c4;text-decoration:none}.hidden{display:none}.warning{padding:0.5em;margin-bottom:0.8em;font-weight:bold;background-color:#fffccc}.error_box{padding:1em;font-weight:bold;background-color:#ffebe8;border:1px solid #dd3c10}.flash-messages{margin-top:15px;margin-bottom:15px}.flash,.flash-global{position:relative;font-size:14px;line-height:1.6;color:#246;background-color:#e2eef9;border:solid 1px #bac6d3}.flash.flash-warn,.flash-global.flash-warn{color:#4c4a42;background-color:#fff9ea;border-color:#dfd8c2}.flash.flash-error,.flash-global.flash-error{color:#911;background-color:#fcdede;border-color:#d2b2b2}.flash .flash-close,.flash-global .flash-close{float:right;padding:17px;margin-top:-15px;margin-right:-15px;margin-left:20px;color:inherit;text-decoration:none;cursor:pointer;opacity:0.6}.flash .flash-close:hover,.flash-global .flash-close:hover{opacity:1}.flash p:last-child,.flash-global p:last-child{margin-bottom:0}.flash .flash-action,.flash-global .flash-action{float:right;margin-top:-4px;margin-left:20px}.flash a,.flash-global a{font-weight:bold}.flash{padding:15px;border-radius:3px}.flash+.flash{margin-top:5px}.flash-with-icon{padding-left:40px}.flash-with-icon>.octicon{float:left;margin-top:3px;margin-left:-25px}.flash-global{padding:10px;margin-top:-1px;border-width:1px 0}.flash-global h2,.flash-global p{margin-top:0;margin-bottom:0;font-size:14px;line-height:1.4}.flash-global .flash-action{margin-top:5px}.flash-title{margin-top:0;margin-bottom:5px}.avatar{display:inline-block;overflow:hidden;line-height:1;vertical-align:middle;border-radius:3px}.avatar-small{border-radius:2px}.avatar-link{float:left;line-height:1}.avatar-group-item{display:inline-block;margin-bottom:3px}.avatar-parent-child{position:relative}.avatar-child{position:absolute;right:-15%;bottom:-9%;border-radius:2px;box-shadow:-2px -2px 0 rgba(255,255,255,0.8)}.blankslate{position:relative;padding:30px;text-align:center;background-color:#fafafa;border:1px solid #e5e5e5;border-radius:3px;box-shadow:inset 0 0 10px rgba(0,0,0,0.05)}.blankslate.clean-background{background:none;border:0;box-shadow:none}.blankslate.capped{border-radius:0 0 3px 3px}.blankslate.spacious{padding:100px 60px 120px}.blankslate.has-fixed-width{width:485px;margin:0 auto}.blankslate.large-format h3{margin:0.75em 0;font-size:20px}.blankslate.large-format p{font-size:16px}.blankslate.large-format p.has-fixed-width{width:540px;margin:0 auto;text-align:left}.blankslate.large-format .mega-octicon{width:40px;height:40px;font-size:40px;color:#aaa}.blankslate.large-format .octicon-inbox{font-size:48px;line-height:40px}.blankslate code{padding:2px 5px 3px;font-size:14px;background:#fff;border:1px solid #eee;border-radius:3px}.blankslate>.mega-octicon{color:#aaa}.blankslate .mega-octicon+.mega-octicon{margin-left:10px}.tabnav+.blankslate{margin-top:20px}.blankslate .context-loader.large-format-loader{padding-top:50px}.spinner-forking{display:block;margin:20px auto 40px}.forking-repo{margin:40px 0;text-align:center}.forking-repo h3{margin-bottom:10px;font-size:28px;font-weight:300}.forking-repo h4{margin:0 0 30px;font-size:16px;font-weight:300}.counter{display:inline-block;padding:2px 5px;font-size:11px;font-weight:bold;line-height:1;color:#777;background-color:#eee;border-radius:20px}.btn{position:relative;display:inline-block;padding:6px 12px;font-size:13px;font-weight:bold;line-height:20px;color:#333;white-space:nowrap;vertical-align:middle;cursor:pointer;background-color:#eee;background-image:linear-gradient(#fcfcfc, #eee);border:1px solid #d5d5d5;border-radius:3px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-appearance:none}.btn i{font-style:normal;font-weight:500;opacity:0.6}.btn .octicon{vertical-align:text-top}.btn .counter{text-shadow:none;background-color:#e5e5e5}.btn:focus{text-decoration:none;border-color:#51a7e8;outline:none;box-shadow:0 0 5px rgba(81,167,232,0.5)}.btn:hover,.btn:active,.btn.zeroclipboard-is-hover,.btn.zeroclipboard-is-active{text-decoration:none;background-color:#ddd;background-image:linear-gradient(#eee, #ddd);border-color:#ccc}.btn:active,.btn.selected,.btn.selected:hover,.btn.zeroclipboard-is-active{background-color:#dcdcdc;background-image:none;border-color:#b5b5b5;box-shadow:inset 0 2px 4px rgba(0,0,0,0.15)}.btn:disabled,.btn:disabled:hover,.btn.disabled,.btn.disabled:hover{color:rgba(102,102,102,0.5);cursor:default;background-color:rgba(229,229,229,0.5);background-image:none;border-color:rgba(197,197,197,0.5);box-shadow:none}.btn-primary{color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.15);background-color:#60b044;background-image:linear-gradient(#8add6d, #60b044);border-color:#5ca941}.btn-primary .counter{color:#60b044;background-color:#fff}.btn-primary:hover{color:#fff;background-color:#569e3d;background-image:linear-gradient(#79d858, #569e3d);border-color:#4a993e}.btn-primary:active,.btn-primary.selected{text-shadow:0 1px 0 rgba(0,0,0,0.15);background-color:#569e3d;background-image:none;border-color:#418737}.btn-primary:disabled,.btn-primary:disabled:hover,.btn-primary.disabled,.btn-primary.disabled:hover{color:#fefefe;background-color:#add39f;background-image:linear-gradient(#c3ecb4, #add39f);border-color:#b9dcac #b9dcac #a7c89b}.btn-danger{color:#900}.btn-danger:hover{color:#fff;background-color:#b33630;background-image:linear-gradient(#dc5f59, #b33630);border-color:#cd504a}.btn-danger:active,.btn-danger.selected{color:#fff;background-color:#b33630;background-image:none;border-color:#9f312c}.btn-danger:disabled,.btn-danger:disabled:hover,.btn-danger.disabled,.btn-danger.disabled:hover{color:#cb7f7f;background-color:#efefef;background-image:linear-gradient(#fefefe, #efefef);border-color:#e1e1e1}.btn-danger:hover .counter,.btn-danger:active .counter,.btn-danger.selected .counter{color:#b33630;background-color:#fff}.btn-outline{color:#4183c4;background-color:#fff;background-image:none;border:1px solid #e5e5e5}.btn-outline .counter{background-color:#eee}.btn-outline:hover,.btn-outline:active,.btn-outline.selected,.btn-outline.selected:hover,.btn-outline.zeroclipboard-is-hover,.btn-outline.zeroclipboard-is-active{color:#fff;background-color:#4183c4;background-image:none;border-color:#4183c4}.btn-outline:hover .counter,.btn-outline:active .counter,.btn-outline.selected .counter,.btn-outline.selected:hover .counter,.btn-outline.zeroclipboard-is-hover .counter,.btn-outline.zeroclipboard-is-active .counter{color:#4183c4;background-color:#fff}.btn-outline:disabled,.btn-outline:disabled:hover,.btn-outline.disabled,.btn-outline.disabled:hover{color:#777;background-color:#fff;background-image:none;border-color:#e5e5e5}.btn-with-count{float:left;border-top-right-radius:0;border-bottom-right-radius:0}.btn-sm{padding:2px 10px}.hidden-text-expander{display:block}.hidden-text-expander.inline{position:relative;top:-1px;display:inline-block;margin-left:5px;line-height:0}.hidden-text-expander a{display:inline-block;height:12px;padding:0 5px;font-size:12px;font-weight:bold;line-height:6px;color:#555;text-decoration:none;vertical-align:middle;background:#ddd;border-radius:1px}.hidden-text-expander a:hover{text-decoration:none;background-color:#ccc}.hidden-text-expander a:active{color:#fff;background-color:#4183c4}.social-count{float:left;padding:2px 7px;font-size:11px;font-weight:bold;line-height:20px;color:#333;vertical-align:middle;background-color:#fff;border:1px solid #ddd;border-left:0;border-top-right-radius:3px;border-bottom-right-radius:3px}.social-count:hover,.social-count:active{text-decoration:none}.social-count:hover{color:#4183c4;cursor:pointer}.btn-block{display:block;width:100%;text-align:center}.btn-group{display:inline-block;vertical-align:middle}.btn-group:before{display:table;content:""}.btn-group:after{display:table;clear:both;content:""}.btn-group .btn{position:relative;float:left}.btn-group .btn:not(:first-child):not(:last-child){border-radius:0}.btn-group .btn:first-child{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group .btn:last-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .btn:hover,.btn-group .btn:focus,.btn-group .btn:active,.btn-group .btn.selected{z-index:2}.btn-group .btn+.btn{margin-left:-1px;box-shadow:inset 1px 0 0 rgba(255,255,255,0.2)}.btn-group .btn+.btn:hover{box-shadow:none}.btn-group .btn+.btn:active,.btn-group .btn+.btn.selected{box-shadow:inset 0 3px 5px rgba(0,0,0,0.15)}.btn-group .button_to+.button_to{margin-left:-1px}.btn-group .button_to{float:left}.btn-group .button_to .btn{border-radius:0}.btn-group .button_to:first-child .btn{border-top-left-radius:3px;border-bottom-left-radius:3px}.btn-group .button_to:last-child .btn{border-top-right-radius:3px;border-bottom-right-radius:3px}.btn-group+.btn-group,.btn-group+.btn{margin-left:5px}.btn-link{display:inline-block;padding:0;font-size:inherit;color:#4183c4;white-space:nowrap;cursor:pointer;background-color:transparent;border:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-appearance:none}.btn-link:hover,.btn-link:focus{text-decoration:underline}.btn-link:focus{outline:none}.menu{margin-bottom:15px;list-style:none;background-color:#fff;border:1px solid #d8d8d8;border-radius:3px}.menu-item{position:relative;display:block;padding:8px 10px;text-shadow:0 1px 0 #fff;border-bottom:1px solid #eee}.menu-item:first-child{border-top:0;border-top-right-radius:2px;border-top-left-radius:2px}.menu-item:first-child:before{border-top-left-radius:2px}.menu-item:last-child{border-bottom:0;border-bottom-right-radius:2px;border-bottom-left-radius:2px}.menu-item:last-child:before{border-bottom-left-radius:2px}.menu-item:hover{text-decoration:none;background-color:#f9f9f9}.menu-item.selected{font-weight:bold;color:#222;cursor:default;background-color:#fff}.menu-item.selected:before{position:absolute;top:0;left:0;bottom:0;width:2px;content:"";background-color:#d26911}.menu-item .octicon{margin-right:5px;width:16px;color:#333;text-align:center}.menu-item .counter{float:right;margin-left:5px}.menu-item .menu-warning{float:right;color:#d26911}.menu-item .avatar{float:left;margin-right:5px}.menu-item.alert .counter{color:#bd2c00}.menu-heading{display:block;padding:8px 10px;margin-top:0;margin-bottom:0;font-size:13px;font-weight:bold;line-height:20px;color:#555;background-color:#f7f7f7;border-bottom:1px solid #eee}.menu-heading:hover{text-decoration:none}.menu-heading:first-child{border-top-right-radius:2px;border-top-left-radius:2px}.menu-heading:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px;border-bottom:0}.tabnav{margin-top:0;margin-bottom:15px;border-bottom:1px solid #ddd}.tabnav .counter{margin-left:5px}.tabnav-tabs{margin-bottom:-1px}.tabnav-tab{display:inline-block;padding:8px 12px;font-size:14px;line-height:20px;color:#666;text-decoration:none;border:1px solid transparent;border-bottom:0}.tabnav-tab.selected{color:#333;background-color:#fff;border-color:#ddd;border-radius:3px 3px 0 0}.tabnav-tab:hover{text-decoration:none}.tabnav-extra{display:inline-block;padding-top:10px;margin-left:10px;font-size:12px;color:#666}.tabnav-extra>.octicon{margin-right:2px}a.tabnav-extra:hover{color:#4183c4;text-decoration:none}.tabnav-btn{margin-left:10px}.filter-list{list-style-type:none}.filter-list.small .filter-item{padding:4px 10px;margin:0 0 2px;font-size:12px}.filter-list.pjax-active .filter-item{color:#777;background-color:transparent}.filter-list.pjax-active .filter-item.pjax-active{color:#fff;background-color:#4183c4}.filter-item{position:relative;display:block;padding:8px 10px;margin-bottom:5px;overflow:hidden;font-size:14px;color:#777;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;border-radius:3px}.filter-item:hover{text-decoration:none;background-color:#eee}.filter-item.selected{color:#fff;background-color:#4183c4}.filter-item.selected .octicon-remove-close{float:right;opacity:0.8}.filter-item .count{float:right;font-weight:bold}.filter-item .bar{position:absolute;top:2px;right:0;bottom:2px;z-index:-1;display:inline-block;background-color:#f1f1f1}.state{display:inline-block;padding:4px 8px;font-weight:bold;line-height:20px;color:#fff;text-align:center;border-radius:3px;background-color:#999}.state-open,.state-proposed,.state-reopened{background-color:#6cc644}.state-merged{background-color:#6e5494}.state-closed{background-color:#bd2c00}.state-renamed{background-color:#fffa5d}.tooltipped{position:relative}.tooltipped:after{position:absolute;z-index:1000000;display:none;padding:5px 8px;font:normal normal 11px/1.5 Helvetica,arial,nimbussansl,liberationsans,freesans,clean,sans-serif,"Segoe UI Emoji","Segoe UI Symbol";color:#fff;text-align:center;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-wrap:break-word;white-space:pre;pointer-events:none;content:attr(aria-label);background:rgba(0,0,0,0.8);border-radius:3px;-webkit-font-smoothing:subpixel-antialiased}.tooltipped:before{position:absolute;z-index:1000001;display:none;width:0;height:0;color:rgba(0,0,0,0.8);pointer-events:none;content:"";border:5px solid transparent}.tooltipped:hover:before,.tooltipped:hover:after,.tooltipped:active:before,.tooltipped:active:after,.tooltipped:focus:before,.tooltipped:focus:after{display:inline-block;text-decoration:none}.tooltipped-multiline:hover:after,.tooltipped-multiline:active:after,.tooltipped-multiline:focus:after{display:table-cell}.tooltipped-s:after,.tooltipped-se:after,.tooltipped-sw:after{top:100%;right:50%;margin-top:5px}.tooltipped-s:before,.tooltipped-se:before,.tooltipped-sw:before{top:auto;right:50%;bottom:-5px;margin-right:-5px;border-bottom-color:rgba(0,0,0,0.8)}.tooltipped-se:after{right:auto;left:50%;margin-left:-15px}.tooltipped-sw:after{margin-right:-15px}.tooltipped-n:after,.tooltipped-ne:after,.tooltipped-nw:after{right:50%;bottom:100%;margin-bottom:5px}.tooltipped-n:before,.tooltipped-ne:before,.tooltipped-nw:before{top:-5px;right:50%;bottom:auto;margin-right:-5px;border-top-color:rgba(0,0,0,0.8)}.tooltipped-ne:after{right:auto;left:50%;margin-left:-15px}.tooltipped-nw:after{margin-right:-15px}.tooltipped-s:after,.tooltipped-n:after{-webkit-transform:translateX(50%);-ms-transform:translateX(50%);transform:translateX(50%)}.tooltipped-w:after{right:100%;bottom:50%;margin-right:5px;-webkit-transform:translateY(50%);-ms-transform:translateY(50%);transform:translateY(50%)}.tooltipped-w:before{top:50%;bottom:50%;left:-5px;margin-top:-5px;border-left-color:rgba(0,0,0,0.8)}.tooltipped-e:after{bottom:50%;left:100%;margin-left:5px;-webkit-transform:translateY(50%);-ms-transform:translateY(50%);transform:translateY(50%)}.tooltipped-e:before{top:50%;right:-5px;bottom:50%;margin-top:-5px;border-right-color:rgba(0,0,0,0.8)}.tooltipped-multiline:after{width:-moz-max-content;width:-webkit-max-content;max-width:250px;word-break:break-word;word-wrap:normal;white-space:pre-line;border-collapse:separate}.tooltipped-multiline.tooltipped-s:after,.tooltipped-multiline.tooltipped-n:after{right:auto;left:50%;-webkit-transform:translateX(-50%);-ms-transform:translateX(-50%);transform:translateX(-50%)}.tooltipped-multiline.tooltipped-w:after,.tooltipped-multiline.tooltipped-e:after{right:100%}@media screen and (min-width: 0\0){.tooltipped-multiline:after{width:250px}}.tooltipped-sticky:before,.tooltipped-sticky:after{display:inline-block}.tooltipped-sticky.tooltipped-multiline:after{display:table-cell}.fullscreen-overlay-enabled.dark-theme .tooltipped:after{color:#000;background:rgba(255,255,255,0.8)}.fullscreen-overlay-enabled.dark-theme .tooltipped .tooltipped-s:before,.fullscreen-overlay-enabled.dark-theme .tooltipped .tooltipped-se:before,.fullscreen-overlay-enabled.dark-theme .tooltipped .tooltipped-sw:before{border-bottom-color:rgba(255,255,255,0.8)}.fullscreen-overlay-enabled.dark-theme .tooltipped.tooltipped-n:before,.fullscreen-overlay-enabled.dark-theme .tooltipped.tooltipped-ne:before,.fullscreen-overlay-enabled.dark-theme .tooltipped.tooltipped-nw:before{border-top-color:rgba(255,255,255,0.8)}.fullscreen-overlay-enabled.dark-theme .tooltipped.tooltipped-e:before{border-right-color:rgba(255,255,255,0.8)}.fullscreen-overlay-enabled.dark-theme .tooltipped.tooltipped-w:before{border-left-color:rgba(255,255,255,0.8)}.flex-table{display:table}.flex-table-item{display:table-cell;width:1%;white-space:nowrap;vertical-align:middle}.flex-table-item-primary{width:99%}.css-truncate.css-truncate-target,.css-truncate .css-truncate-target{display:inline-block;max-width:125px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:top}.css-truncate.expandable.zeroclipboard-is-hover .css-truncate-target,.css-truncate.expandable.zeroclipboard-is-hover.css-truncate-target,.css-truncate.expandable:hover .css-truncate-target,.css-truncate.expandable:hover.css-truncate-target{max-width:10000px !important} -------------------------------------------------------------------------------- /mgmt/templates/body.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | LFS Test Server Management 5 | 19 | 20 | 21 |
22 |
23 |

LFS Test Server

24 |
25 |
26 | 27 |
28 |
29 |
30 | 36 |
37 |
38 | {{template "content" .}} 39 |
40 |
41 | 42 | 43 | -------------------------------------------------------------------------------- /mgmt/templates/config.tmpl: -------------------------------------------------------------------------------- 1 |
2 |

URL: {{.Config.ExtOrigin}}

3 |

Listen Address: {{.Config.Listen}}

4 |

Database: {{.Config.MetaDB}}

5 |

Content: {{.Config.ContentPath}}

6 |
7 |
8 |

To configure a repository to use this LFS server, add the following to the repository's Git config or .lfsconfig file:

9 |
10 | [lfs]
11 |     url = "{{.Config.ExtOrigin}}"
12 | 
13 | 
14 | 15 | {{if eq .Config.Scheme "https"}} 16 |

Your server is configured to use https. If you're using self signed certificates, or are getting SSL errors, you can add the following to your .gitconfig file:

17 |
18 | [http]
19 |     sslverify = false
20 | 
21 | 
22 | {{end}} 23 |
24 | -------------------------------------------------------------------------------- /mgmt/templates/locks.tmpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{range .Locks}} 10 | 11 | 12 | 13 | 14 | 15 | 16 | {{end}} 17 |
IDPathOwnerLockedAt
{{.Id}}{{.Path}}{{.Owner.Name}}{{.LockedAt.Format "2006-01-02 15:04:05"}}
18 |
19 | -------------------------------------------------------------------------------- /mgmt/templates/objects.tmpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | {{range .Objects}} 8 | 9 | 10 | 11 | 12 | {{end}} 13 |
OIDSize
{{.Oid}}{{.Size}}
14 |
15 | -------------------------------------------------------------------------------- /mgmt/templates/users.tmpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | {{range .Users}} 4 | 5 | 6 | 7 | 8 | {{end}} 9 |
{{.Name}}
10 |
11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # This script will generate a release on github/lfs-test-server. 4 | # Ensure that you've bumped version in main.go, then run the script. 5 | # The script does the following 6 | # * Ensure the build succeeds (and pulls the version from the build) 7 | # * Ensure the tests pass 8 | # * Cross compiles for supported platforms 9 | # * Creates the release on github/lfs-test-server 10 | # * Uploads binary assets to the release. 11 | 12 | 13 | go build -o lfs-test-server 14 | rc=$?; if [[ $rc != 0 ]]; then echo "Build failed."; exit $rc; fi 15 | version=$(./lfs-test-server -v) 16 | 17 | while true; do 18 | read -p "Release version $version? [y/n] " yn 19 | case $yn in 20 | [Yy]* ) break;; 21 | [Nn]* ) exit;; 22 | * ) echo "Please answer yes or no.";; 23 | esac 24 | done 25 | 26 | # Make sure tests pass 27 | echo "Running tests..." 28 | go test 29 | rc=$?; if [[ $rc != 0 ]]; then echo "Tests failed, cannot release."; exit $rc; fi 30 | 31 | # Build all files 32 | rm -rf dist 33 | mkdir dist 34 | 35 | echo "Building darwin amd64" 36 | mkdir -p dist/lfs-test-server-darwin-amd64 37 | GOOS=darwin GOARCH=amd64 go build -o dist/lfs-test-server-darwin-amd64/lfs-test-server 38 | cp README.md dist/lfs-test-server-darwin-amd64 39 | cp LICENSE dist/lfs-test-server-darwin-amd64 40 | cd dist && tar zcf lfs-test-server-darwin-amd64-$version.tar.gz lfs-test-server-darwin-amd64; cd .. 41 | 42 | echo "Building linux 386" 43 | mkdir -p dist/lfs-test-server-linux-386 44 | GOOS=linux GOARCH=386 go build -o dist/lfs-test-server-linux-386/lfs-test-server 45 | cp README.md dist/lfs-test-server-linux-386 46 | cp LICENSE dist/lfs-test-server-linux-386 47 | cd dist && tar zcf lfs-test-server-linux-386-$version.tar.gz lfs-test-server-linux-386; cd .. 48 | 49 | echo "Building linux amd64" 50 | mkdir -p dist/lfs-test-server-linux-amd64 51 | GOOS=linux GOARCH=amd64 go build -o dist/lfs-test-server-linux-amd64/lfs-test-server 52 | cp README.md dist/lfs-test-server-linux-amd64 53 | cp LICENSE dist/lfs-test-server-linux-amd64 54 | cd dist && tar zcf lfs-test-server-linux-amd64-$version.tar.gz lfs-test-server-linux-amd64; cd .. 55 | 56 | echo "Building freebsd 386" 57 | mkdir -p dist/lfs-test-server-freebsd-386 58 | GOOS=freebsd GOARCH=386 go build -o dist/lfs-test-server-freebsd-386/lfs-test-server 59 | cp README.md dist/lfs-test-server-freebsd-386 60 | cp LICENSE dist/lfs-test-server-freebsd-386 61 | cd dist && tar zcf lfs-test-server-freebsd-386-$version.tar.gz lfs-test-server-freebsd-386; cd .. 62 | 63 | echo "Building freebsd amd64" 64 | mkdir -p dist/lfs-test-server-freebsd-amd64 65 | GOOS=freebsd GOARCH=amd64 go build -o dist/lfs-test-server-freebsd-amd64/lfs-test-server 66 | cp README.md dist/lfs-test-server-freebsd-amd64 67 | cp LICENSE dist/lfs-test-server-freebsd-amd64 68 | cd dist && tar zcf lfs-test-server-freebsd-amd64-$version.tar.gz lfs-test-server-freebsd-amd64; cd .. 69 | 70 | echo "Building windows 386" 71 | mkdir -p dist/lfs-test-server-windows-386 72 | GOOS=windows GOARCH=386 go build -o dist/lfs-test-server-windows-386/lfs-test-server.exe 73 | cp README.md dist/lfs-test-server-windows-386 74 | cp LICENSE dist/lfs-test-server-windows-386 75 | cd dist && zip -q -j lfs-test-server-windows-386-$version.zip lfs-test-server-windows-386/*; cd .. 76 | 77 | echo "Building windows amd64" 78 | mkdir -p dist/lfs-test-server-windows-amd64 79 | GOOS=windows GOARCH=amd64 go build -o dist/lfs-test-server-windows-amd64/lfs-test-server.exe 80 | cp README.md dist/lfs-test-server-windows-amd64 81 | cp LICENSE dist/lfs-test-server-windows-amd64 82 | cd dist && zip -q -j lfs-test-server-windows-amd64-$version.zip lfs-test-server-windows-amd64/*; cd .. 83 | 84 | # Create the release 85 | tmpl=`mktemp lfs-test-server-release.XXXXXXXXX` 86 | out=`mktemp lfs-test-server-out.XXXXXXXX` 87 | payload=$(cat < $tmpl 99 | 100 | ${EDITOR:-vim} $tmpl 101 | 102 | curl -n -X POST -d @$tmpl -o $out https://api.github.com/repos/git-lfs/lfs-test-server/releases 103 | id=$(cat $out | jq -r ".id") 104 | 105 | if [[ $id == "null" ]]; then echo "Failed creating release."; cat $out; exit 1; fi 106 | echo "Created release id: $id" 107 | 108 | 109 | # Upload each file to the release 110 | upload=$(cat $out | jq -r ".upload_url" | sed s/"{?name}"//) 111 | echo "Uploading lfs-test-server-darwin-amd64-$version.tar.gz" 112 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-darwin-amd64-$version.tar.gz "$upload?name=lfs-test-server-darwin-amd64-$version.tar.gz&label=Mac%20AMD64" 113 | 114 | echo "Uploading lfs-test-server-linux-386-$version.tar.gz" 115 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-linux-386-$version.tar.gz "$upload?name=lfs-test-server-linux-386-$version.tar.gz&label=Linux%20386" 116 | 117 | echo "Uploading lfs-test-server-linux-amd64-$version.tar.gz" 118 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-linux-amd64-$version.tar.gz "$upload?name=lfs-test-server-linux-amd64-$version.tar.gz&label=Linux%20AMD64" 119 | 120 | echo "Uploading lfs-test-server-freebsd-386-$version.tar.gz" 121 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-freebsd-386-$version.tar.gz "$upload?name=lfs-test-server-freebsd-386-$version.tar.gz&label=FreeBSD%20386" 122 | 123 | echo "Uploading lfs-test-server-freebsd-amd64-$version.tar.gz" 124 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-freebsd-amd64-$version.tar.gz "$upload?name=lfs-test-server-freebsd-amd64-$version.tar.gz&label=FreeBSD%20AMD64" 125 | 126 | echo "Uploading lfs-test-server-windows-386-$version.zip" 127 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-windows-386-$version.zip "$upload?name=lfs-test-server-windows-386-$version.zip&label=Windows%20386" 128 | 129 | echo "Uploading lfs-test-server-windows-amd64-$version" 130 | curl -n -o $out -H "Content-Type: application/octet-stream" -X POST --data-binary @dist/lfs-test-server-windows-amd64-$version.zip "$upload?name=lfs-test-server-windows-amd64-$version.zip&label=Windows%20AMD64" 131 | 132 | rm -f $tmpl $out 133 | rm -rf dist 134 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "regexp" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/gorilla/context" 16 | "github.com/gorilla/mux" 17 | ) 18 | 19 | // RequestVars contain variables from the HTTP request. Variables from routing, json body decoding, and 20 | // some headers are stored. 21 | type RequestVars struct { 22 | Oid string 23 | Size int64 24 | User string 25 | Password string 26 | Repo string 27 | Authorization string 28 | } 29 | 30 | type BatchVars struct { 31 | Transfers []string `json:"transfers,omitempty"` 32 | Operation string `json:"operation"` 33 | Objects []*RequestVars `json:"objects"` 34 | } 35 | 36 | // MetaObject is object metadata as seen by the object and metadata stores. 37 | type MetaObject struct { 38 | Oid string `json:"oid"` 39 | Size int64 `json:"size"` 40 | Existing bool 41 | } 42 | 43 | type BatchResponse struct { 44 | Transfer string `json:"transfer,omitempty"` 45 | Objects []*Representation `json:"objects"` 46 | } 47 | 48 | // Representation is object medata as seen by clients of the lfs server. 49 | type Representation struct { 50 | Oid string `json:"oid"` 51 | Size int64 `json:"size"` 52 | Actions map[string]*link `json:"actions"` 53 | Error *ObjectError `json:"error,omitempty"` 54 | } 55 | 56 | type ObjectError struct { 57 | Code int `json:"code"` 58 | Message string `json:"message"` 59 | } 60 | 61 | type User struct { 62 | Name string `json:"name"` 63 | } 64 | 65 | type Lock struct { 66 | Id string `json:"id"` 67 | Path string `json:"path"` 68 | Owner User `json:"owner"` 69 | LockedAt time.Time `json:"locked_at"` 70 | } 71 | 72 | type LockRequest struct { 73 | Path string `json:"path"` 74 | } 75 | 76 | type LockResponse struct { 77 | Lock *Lock `json:"lock"` 78 | Message string `json:"message,omitempty"` 79 | } 80 | 81 | type UnlockRequest struct { 82 | Force bool `json:"force"` 83 | } 84 | 85 | type UnlockResponse struct { 86 | Lock *Lock `json:"lock"` 87 | Message string `json:"message,omitempty"` 88 | } 89 | 90 | type LockList struct { 91 | Locks []Lock `json:"locks"` 92 | NextCursor string `json:"next_cursor,omitempty"` 93 | Message string `json:"message,omitempty"` 94 | } 95 | 96 | type VerifiableLockRequest struct { 97 | Cursor string `json:"cursor,omitempty"` 98 | Limit int `json:"limit,omitempty"` 99 | } 100 | 101 | type VerifiableLockList struct { 102 | Ours []Lock `json:"ours"` 103 | Theirs []Lock `json:"theirs"` 104 | NextCursor string `json:"next_cursor,omitempty"` 105 | Message string `json:"message,omitempty"` 106 | } 107 | 108 | // DownloadLink builds a URL to download the object. 109 | func (v *RequestVars) DownloadLink() string { 110 | return v.internalLink("objects") 111 | } 112 | 113 | // UploadLink builds a URL to upload the object. 114 | func (v *RequestVars) UploadLink(useTus bool) string { 115 | if useTus { 116 | return v.tusLink() 117 | } 118 | return v.internalLink("objects") 119 | } 120 | 121 | func (v *RequestVars) internalLink(subpath string) string { 122 | path := "" 123 | 124 | if len(v.User) > 0 { 125 | path += fmt.Sprintf("/%s", v.User) 126 | } 127 | 128 | if len(v.Repo) > 0 { 129 | path += fmt.Sprintf("/%s", v.Repo) 130 | } 131 | 132 | path += fmt.Sprintf("/%s/%s", subpath, v.Oid) 133 | 134 | return fmt.Sprintf("%s%s", Config.ExtOrigin, path) 135 | } 136 | 137 | func (v *RequestVars) tusLink() string { 138 | link, err := tusServer.Create(v.Oid, v.Size) 139 | if err != nil { 140 | logger.Fatal(kv{"fn": fmt.Sprintf("Unable to create tus link for %s: %v", v.Oid, err)}) 141 | } 142 | return link 143 | } 144 | 145 | func (v *RequestVars) VerifyLink() string { 146 | path := fmt.Sprintf("/verify/%s", v.Oid) 147 | 148 | return fmt.Sprintf("%s%s", Config.ExtOrigin, path) 149 | } 150 | 151 | // link provides a structure used to build a hypermedia representation of an HTTP link. 152 | type link struct { 153 | Href string `json:"href"` 154 | Header map[string]string `json:"header,omitempty"` 155 | ExpiresAt time.Time `json:"expires_at,omitempty"` 156 | } 157 | 158 | // App links a Router, ContentStore, and MetaStore to provide the LFS server. 159 | type App struct { 160 | router *mux.Router 161 | contentStore *ContentStore 162 | metaStore *MetaStore 163 | } 164 | 165 | // NewApp creates a new App using the ContentStore and MetaStore provided 166 | func NewApp(content *ContentStore, meta *MetaStore) *App { 167 | app := &App{contentStore: content, metaStore: meta} 168 | 169 | r := mux.NewRouter() 170 | 171 | r.HandleFunc("/{user}/{repo}/objects/batch", app.requireAuth(app.BatchHandler)).Methods("POST").MatcherFunc(MetaMatcher) 172 | 173 | route := "/{user}/{repo}/objects/{oid}" 174 | r.HandleFunc(route, app.requireAuth(app.GetContentHandler)).Methods("GET", "HEAD").MatcherFunc(ContentMatcher) 175 | r.HandleFunc(route, app.requireAuth(app.GetMetaHandler)).Methods("GET", "HEAD").MatcherFunc(MetaMatcher) 176 | r.HandleFunc(route, app.requireAuth(app.PutHandler)).Methods("PUT").MatcherFunc(ContentMatcher) 177 | 178 | r.HandleFunc("/{user}/{repo}/objects", app.requireAuth(app.PostHandler)).Methods("POST").MatcherFunc(MetaMatcher) 179 | 180 | r.HandleFunc("/{user}/{repo}/locks", app.requireAuth(app.LocksHandler)).Methods("GET").MatcherFunc(MetaMatcher) 181 | r.HandleFunc("/{user}/{repo}/locks/verify", app.requireAuth(app.LocksVerifyHandler)).Methods("POST").MatcherFunc(MetaMatcher) 182 | r.HandleFunc("/{user}/{repo}/locks", app.requireAuth(app.CreateLockHandler)).Methods("POST").MatcherFunc(MetaMatcher) 183 | r.HandleFunc("/{user}/{repo}/locks/{id}/unlock", app.requireAuth(app.DeleteLockHandler)).Methods("POST").MatcherFunc(MetaMatcher) 184 | 185 | r.HandleFunc("/objects/batch", app.requireAuth(app.BatchHandler)).Methods("POST").MatcherFunc(MetaMatcher) 186 | 187 | route = "/objects/{oid}" 188 | r.HandleFunc(route, app.requireAuth(app.GetContentHandler)).Methods("GET", "HEAD").MatcherFunc(ContentMatcher) 189 | r.HandleFunc(route, app.requireAuth(app.GetMetaHandler)).Methods("GET", "HEAD").MatcherFunc(MetaMatcher) 190 | r.HandleFunc(route, app.requireAuth(app.PutHandler)).Methods("PUT").MatcherFunc(ContentMatcher) 191 | 192 | r.HandleFunc("/objects", app.requireAuth(app.PostHandler)).Methods("POST").MatcherFunc(MetaMatcher) 193 | 194 | r.HandleFunc("/verify/{oid}", app.VerifyHandler).Methods("POST") 195 | 196 | app.addMgmt(r) 197 | 198 | app.router = r 199 | 200 | return app 201 | } 202 | 203 | func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { 204 | b := make([]byte, 16) 205 | _, err := rand.Read(b) 206 | if err == nil { 207 | context.Set(r, "RequestID", fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])) 208 | } 209 | 210 | a.router.ServeHTTP(w, r) 211 | } 212 | 213 | // Serve calls http.Serve with the provided Listener and the app's router 214 | func (a *App) Serve(l net.Listener) error { 215 | return http.Serve(l, a) 216 | } 217 | 218 | // GetContentHandler gets the content from the content store 219 | func (a *App) GetContentHandler(w http.ResponseWriter, r *http.Request) { 220 | rv := unpack(r) 221 | meta, err := a.metaStore.Get(rv) 222 | if err != nil { 223 | writeStatus(w, r, 404) 224 | return 225 | } 226 | 227 | // Support resume download using Range header 228 | var fromByte int64 229 | statusCode := 200 230 | if rangeHdr := r.Header.Get("Range"); rangeHdr != "" { 231 | regex := regexp.MustCompile(`bytes=(\d+)\-.*`) 232 | match := regex.FindStringSubmatch(rangeHdr) 233 | if match != nil && len(match) > 1 { 234 | statusCode = 206 235 | fromByte, _ = strconv.ParseInt(match[1], 10, 64) 236 | w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, meta.Size-1, int64(meta.Size)-fromByte)) 237 | } 238 | } 239 | 240 | content, err := a.contentStore.Get(meta, fromByte) 241 | if err != nil { 242 | writeStatus(w, r, 404) 243 | return 244 | } 245 | defer content.Close() 246 | 247 | w.WriteHeader(statusCode) 248 | io.Copy(w, content) 249 | logRequest(r, statusCode) 250 | } 251 | 252 | // GetMetaHandler retrieves metadata about the object 253 | func (a *App) GetMetaHandler(w http.ResponseWriter, r *http.Request) { 254 | rv := unpack(r) 255 | meta, err := a.metaStore.Get(rv) 256 | if err != nil { 257 | writeStatus(w, r, 404) 258 | return 259 | } 260 | 261 | w.Header().Set("Content-Type", metaMediaType) 262 | 263 | if r.Method == "GET" { 264 | enc := json.NewEncoder(w) 265 | enc.Encode(a.Represent(rv, meta, true, false, false)) 266 | } 267 | 268 | logRequest(r, 200) 269 | } 270 | 271 | // PostHandler instructs the client how to upload data 272 | func (a *App) PostHandler(w http.ResponseWriter, r *http.Request) { 273 | rv := unpack(r) 274 | meta, err := a.metaStore.Put(rv) 275 | if err != nil { 276 | writeStatus(w, r, 404) 277 | return 278 | } 279 | 280 | w.Header().Set("Content-Type", metaMediaType) 281 | 282 | sentStatus := 202 283 | if meta.Existing && a.contentStore.Exists(meta) { 284 | sentStatus = 200 285 | } 286 | w.WriteHeader(sentStatus) 287 | 288 | enc := json.NewEncoder(w) 289 | enc.Encode(a.Represent(rv, meta, meta.Existing, true, false)) 290 | logRequest(r, sentStatus) 291 | } 292 | 293 | // BatchHandler provides the batch api 294 | func (a *App) BatchHandler(w http.ResponseWriter, r *http.Request) { 295 | bv := unpackBatch(r) 296 | 297 | var responseObjects []*Representation 298 | 299 | var useTus bool 300 | if bv.Operation == "upload" && Config.IsUsingTus() { 301 | for _, t := range bv.Transfers { 302 | if t == "tus" { 303 | useTus = true 304 | break 305 | } 306 | } 307 | } 308 | 309 | // Create a response object 310 | for _, object := range bv.Objects { 311 | meta, err := a.metaStore.Get(object) 312 | if err == nil && a.contentStore.Exists(meta) { // Object is found and exists 313 | responseObjects = append(responseObjects, a.Represent(object, meta, true, false, false)) 314 | continue 315 | } 316 | 317 | // Object is not found 318 | if bv.Operation == "upload" { 319 | meta, err = a.metaStore.Put(object) 320 | if err == nil { 321 | responseObjects = append(responseObjects, a.Represent(object, meta, false, true, useTus)) 322 | } 323 | } else { 324 | rep := &Representation{ 325 | Oid: object.Oid, 326 | Size: object.Size, 327 | Error: &ObjectError{ 328 | Code: 404, 329 | Message: "Not found", 330 | }, 331 | } 332 | responseObjects = append(responseObjects, rep) 333 | } 334 | } 335 | 336 | w.Header().Set("Content-Type", metaMediaType) 337 | 338 | respobj := &BatchResponse{Objects: responseObjects} 339 | // Respond with TUS support if advertised 340 | if useTus { 341 | respobj.Transfer = "tus" 342 | } 343 | 344 | enc := json.NewEncoder(w) 345 | enc.Encode(respobj) 346 | logRequest(r, 200) 347 | } 348 | 349 | // PutHandler receives data from the client and puts it into the content store 350 | func (a *App) PutHandler(w http.ResponseWriter, r *http.Request) { 351 | rv := unpack(r) 352 | meta, err := a.metaStore.Get(rv) 353 | if err != nil { 354 | writeStatus(w, r, 404) 355 | return 356 | } 357 | 358 | if err := a.contentStore.Put(meta, r.Body); err != nil { 359 | a.metaStore.Delete(rv) 360 | w.WriteHeader(500) 361 | fmt.Fprintf(w, `{"message":"%s"}`, err) 362 | return 363 | } 364 | 365 | logRequest(r, 200) 366 | } 367 | 368 | func (a *App) VerifyHandler(w http.ResponseWriter, r *http.Request) { 369 | vars := mux.Vars(r) 370 | oid := vars["oid"] 371 | err := tusServer.Finish(oid, a.contentStore) 372 | 373 | if err != nil { 374 | logger.Fatal(kv{"fn": "VerifyHandler", "err": fmt.Sprintf("Failed to verify %s: %v", oid, err)}) 375 | } 376 | 377 | logRequest(r, 200) 378 | } 379 | 380 | func (a *App) LocksHandler(w http.ResponseWriter, r *http.Request) { 381 | vars := mux.Vars(r) 382 | repo := vars["repo"] 383 | 384 | enc := json.NewEncoder(w) 385 | ll := &LockList{} 386 | 387 | w.Header().Set("Content-Type", metaMediaType) 388 | 389 | locks, nextCursor, err := a.metaStore.FilteredLocks(repo, 390 | r.FormValue("path"), 391 | r.FormValue("cursor"), 392 | r.FormValue("limit")) 393 | 394 | if err != nil { 395 | ll.Message = err.Error() 396 | } else { 397 | ll.Locks = locks 398 | ll.NextCursor = nextCursor 399 | } 400 | 401 | enc.Encode(ll) 402 | 403 | logRequest(r, 200) 404 | } 405 | 406 | func (a *App) LocksVerifyHandler(w http.ResponseWriter, r *http.Request) { 407 | vars := mux.Vars(r) 408 | repo := vars["repo"] 409 | user := context.Get(r, "USER") 410 | 411 | dec := json.NewDecoder(r.Body) 412 | enc := json.NewEncoder(w) 413 | 414 | w.Header().Set("Content-Type", metaMediaType) 415 | 416 | reqBody := &VerifiableLockRequest{} 417 | if err := dec.Decode(reqBody); err != nil { 418 | w.WriteHeader(http.StatusBadRequest) 419 | enc.Encode(&VerifiableLockList{Message: err.Error()}) 420 | return 421 | } 422 | 423 | // Limit is optional 424 | limit := reqBody.Limit 425 | if limit == 0 { 426 | limit = 100 427 | } 428 | 429 | ll := &VerifiableLockList{} 430 | locks, nextCursor, err := a.metaStore.FilteredLocks(repo, "", 431 | reqBody.Cursor, 432 | strconv.Itoa(limit)) 433 | if err != nil { 434 | ll.Message = err.Error() 435 | } else { 436 | ll.NextCursor = nextCursor 437 | 438 | for _, l := range locks { 439 | if l.Owner.Name == user { 440 | ll.Ours = append(ll.Ours, l) 441 | } else { 442 | ll.Theirs = append(ll.Theirs, l) 443 | } 444 | } 445 | } 446 | 447 | enc.Encode(ll) 448 | 449 | logRequest(r, 200) 450 | } 451 | 452 | func (a *App) CreateLockHandler(w http.ResponseWriter, r *http.Request) { 453 | vars := mux.Vars(r) 454 | repo := vars["repo"] 455 | user := context.Get(r, "USER").(string) 456 | 457 | dec := json.NewDecoder(r.Body) 458 | enc := json.NewEncoder(w) 459 | 460 | w.Header().Set("Content-Type", metaMediaType) 461 | 462 | var lockRequest LockRequest 463 | if err := dec.Decode(&lockRequest); err != nil { 464 | w.WriteHeader(http.StatusBadRequest) 465 | enc.Encode(&LockResponse{Message: err.Error()}) 466 | return 467 | } 468 | 469 | locks, _, err := a.metaStore.FilteredLocks(repo, lockRequest.Path, "", "1") 470 | if err != nil { 471 | w.WriteHeader(http.StatusInternalServerError) 472 | enc.Encode(&LockResponse{Message: err.Error()}) 473 | return 474 | } 475 | if len(locks) > 0 { 476 | w.WriteHeader(http.StatusConflict) 477 | enc.Encode(&LockResponse{Message: "lock already created"}) 478 | return 479 | } 480 | 481 | lock := &Lock{ 482 | Id: randomLockId(), 483 | Path: lockRequest.Path, 484 | Owner: User{Name: user}, 485 | LockedAt: time.Now(), 486 | } 487 | 488 | if err := a.metaStore.AddLocks(repo, *lock); err != nil { 489 | w.WriteHeader(http.StatusInternalServerError) 490 | enc.Encode(&LockResponse{Message: err.Error()}) 491 | return 492 | } 493 | 494 | w.WriteHeader(http.StatusCreated) 495 | enc.Encode(&LockResponse{ 496 | Lock: lock, 497 | }) 498 | 499 | logRequest(r, 200) 500 | } 501 | 502 | func (a *App) DeleteLockHandler(w http.ResponseWriter, r *http.Request) { 503 | vars := mux.Vars(r) 504 | repo := vars["repo"] 505 | lockId := vars["id"] 506 | user := context.Get(r, "USER").(string) 507 | 508 | dec := json.NewDecoder(r.Body) 509 | enc := json.NewEncoder(w) 510 | 511 | w.Header().Set("Content-Type", metaMediaType) 512 | 513 | var unlockRequest UnlockRequest 514 | 515 | if len(lockId) == 0 { 516 | w.WriteHeader(http.StatusBadRequest) 517 | enc.Encode(&UnlockResponse{Message: "invalid lock id"}) 518 | return 519 | } 520 | 521 | if err := dec.Decode(&unlockRequest); err != nil { 522 | w.WriteHeader(http.StatusBadRequest) 523 | enc.Encode(&UnlockResponse{Message: err.Error()}) 524 | return 525 | } 526 | 527 | l, err := a.metaStore.DeleteLock(repo, user, lockId, unlockRequest.Force) 528 | if err != nil { 529 | if err == errNotOwner { 530 | w.WriteHeader(http.StatusForbidden) 531 | } else { 532 | w.WriteHeader(http.StatusInternalServerError) 533 | } 534 | enc.Encode(&UnlockResponse{Message: err.Error()}) 535 | return 536 | } 537 | if l == nil { 538 | w.WriteHeader(http.StatusNotFound) 539 | enc.Encode(&UnlockResponse{Message: "unable to find lock"}) 540 | return 541 | } 542 | 543 | enc.Encode(&UnlockResponse{Lock: l}) 544 | 545 | logRequest(r, 200) 546 | } 547 | 548 | // Represent takes a RequestVars and Meta and turns it into a Representation suitable 549 | // for json encoding 550 | func (a *App) Represent(rv *RequestVars, meta *MetaObject, download, upload, useTus bool) *Representation { 551 | rep := &Representation{ 552 | Oid: meta.Oid, 553 | Size: meta.Size, 554 | Actions: make(map[string]*link), 555 | } 556 | 557 | header := make(map[string]string) 558 | verifyHeader := make(map[string]string) 559 | 560 | header["Accept"] = contentMediaType 561 | 562 | if len(rv.Authorization) > 0 { 563 | header["Authorization"] = rv.Authorization 564 | verifyHeader["Authorization"] = rv.Authorization 565 | } 566 | 567 | if download { 568 | rep.Actions["download"] = &link{Href: rv.DownloadLink(), Header: header} 569 | } 570 | 571 | if upload { 572 | rep.Actions["upload"] = &link{Href: rv.UploadLink(useTus), Header: header} 573 | if useTus { 574 | rep.Actions["verify"] = &link{Href: rv.VerifyLink(), Header: verifyHeader} 575 | } 576 | } 577 | return rep 578 | } 579 | 580 | func (a *App) requireAuth(h http.HandlerFunc) http.HandlerFunc { 581 | return func(w http.ResponseWriter, r *http.Request) { 582 | if !Config.IsPublic() { 583 | user, password, _ := r.BasicAuth() 584 | if user, ret := a.metaStore.Authenticate(user, password); !ret { 585 | w.Header().Set("WWW-Authenticate", "Basic realm=git-lfs-server") 586 | writeStatus(w, r, 401) 587 | return 588 | } else { 589 | context.Set(r, "USER", user) 590 | } 591 | } 592 | h(w, r) 593 | } 594 | } 595 | 596 | // ContentMatcher provides a mux.MatcherFunc that only allows requests that contain 597 | // an Accept header with the contentMediaType 598 | func ContentMatcher(r *http.Request, m *mux.RouteMatch) bool { 599 | mediaParts := strings.Split(r.Header.Get("Accept"), ";") 600 | mt := mediaParts[0] 601 | return mt == contentMediaType 602 | } 603 | 604 | // MetaMatcher provides a mux.MatcherFunc that only allows requests that contain 605 | // an Accept header with the metaMediaType 606 | func MetaMatcher(r *http.Request, m *mux.RouteMatch) bool { 607 | mediaParts := strings.Split(r.Header.Get("Accept"), ";") 608 | mt := mediaParts[0] 609 | return mt == metaMediaType 610 | } 611 | 612 | func randomLockId() string { 613 | var id [20]byte 614 | rand.Read(id[:]) 615 | return fmt.Sprintf("%x", id[:]) 616 | } 617 | 618 | func unpack(r *http.Request) *RequestVars { 619 | vars := mux.Vars(r) 620 | rv := &RequestVars{ 621 | User: vars["user"], 622 | Repo: vars["repo"], 623 | Oid: vars["oid"], 624 | Authorization: r.Header.Get("Authorization"), 625 | } 626 | 627 | if r.Method == "POST" { // Maybe also check if +json 628 | var p RequestVars 629 | dec := json.NewDecoder(r.Body) 630 | err := dec.Decode(&p) 631 | if err != nil { 632 | return rv 633 | } 634 | 635 | rv.Oid = p.Oid 636 | rv.Size = p.Size 637 | } 638 | 639 | return rv 640 | } 641 | 642 | // TODO cheap hack, unify with unpack 643 | func unpackBatch(r *http.Request) *BatchVars { 644 | vars := mux.Vars(r) 645 | 646 | var bv BatchVars 647 | 648 | dec := json.NewDecoder(r.Body) 649 | err := dec.Decode(&bv) 650 | if err != nil { 651 | return &bv 652 | } 653 | 654 | for i := 0; i < len(bv.Objects); i++ { 655 | bv.Objects[i].User = vars["user"] 656 | bv.Objects[i].Repo = vars["repo"] 657 | bv.Objects[i].Authorization = r.Header.Get("Authorization") 658 | } 659 | 660 | return &bv 661 | } 662 | 663 | func writeStatus(w http.ResponseWriter, r *http.Request, status int) { 664 | message := http.StatusText(status) 665 | 666 | mediaParts := strings.Split(r.Header.Get("Accept"), ";") 667 | mt := mediaParts[0] 668 | if strings.HasSuffix(mt, "+json") { 669 | message = `{"message":"` + message + `"}` 670 | } 671 | 672 | w.WriteHeader(status) 673 | fmt.Fprint(w, message) 674 | logRequest(r, status) 675 | } 676 | 677 | func logRequest(r *http.Request, status int) { 678 | logger.Log(kv{"method": r.Method, "url": r.URL, "status": status, "request_id": context.Get(r, "RequestID")}) 679 | } 680 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "testing" 12 | ) 13 | 14 | func TestGetAuthed(t *testing.T) { 15 | res, err := api("GET", "/user/repo/objects/"+contentOid, contentMediaType, testUser, testPass, nil) 16 | if err != nil { 17 | t.Fatalf("request error: %s", err) 18 | } 19 | 20 | if res.StatusCode != 200 { 21 | t.Fatalf("expected status 200, got %d", res.StatusCode) 22 | } 23 | 24 | by, err := ioutil.ReadAll(res.Body) 25 | if err != nil { 26 | t.Fatalf("expected response to contain content, got error: %s", err) 27 | } 28 | 29 | if string(by) != content { 30 | t.Fatalf("expected content to be `content`, got: %s", string(by)) 31 | } 32 | } 33 | 34 | func TestGetAuthedWithRange(t *testing.T) { 35 | req, err := http.NewRequest("GET", lfsServer.URL+"/user/repo/objects/"+contentOid, nil) 36 | if err != nil { 37 | t.Fatalf("request error: %s", err) 38 | } 39 | req.SetBasicAuth(testUser, testPass) 40 | req.Header.Set("Accept", contentMediaType) 41 | fromByte := 5 42 | req.Header.Set("Range", fmt.Sprintf("bytes=%d-", fromByte)) 43 | 44 | res, err := http.DefaultClient.Do(req) 45 | if err != nil { 46 | t.Fatalf("response error: %s", err) 47 | } 48 | 49 | if res.StatusCode != 206 { 50 | t.Fatalf("expected status 206, got %d", res.StatusCode) 51 | } 52 | if cr := res.Header.Get("Content-Range"); len(cr) > 0 { 53 | expected := fmt.Sprintf("bytes %d-%d/%d", fromByte, len(content)-1, len(content)-fromByte) 54 | if cr != expected { 55 | t.Fatalf("expected Content-Range header of %q, got %q", expected, cr) 56 | } 57 | } else { 58 | t.Fatalf("missing Content-Range header in response") 59 | } 60 | 61 | by, err := ioutil.ReadAll(res.Body) 62 | if err != nil { 63 | t.Fatalf("expected response to contain content, got error: %s", err) 64 | } 65 | 66 | if string(by) != content[fromByte:] { 67 | t.Fatalf("expected content to be `content`, got: %s", string(by)) 68 | } 69 | } 70 | 71 | func TestGetUnAuthed(t *testing.T) { 72 | res, err := api("GET", "/user/repo/objects/"+contentOid, contentMediaType, "", "", nil) 73 | if err != nil { 74 | t.Fatalf("request error: %s", err) 75 | } 76 | 77 | if res.StatusCode != 401 { 78 | t.Fatalf("expected status 401, got %d", res.StatusCode) 79 | } 80 | } 81 | 82 | func TestGetBadAuth(t *testing.T) { 83 | res, err := api("GET", "/user/repo/objects/"+contentOid, contentMediaType, testUser, testPass+"123", nil) 84 | if err != nil { 85 | t.Fatalf("request error: %s", err) 86 | } 87 | 88 | if res.StatusCode != 401 { 89 | t.Fatalf("expected status 401, got %d", res.StatusCode) 90 | } 91 | } 92 | 93 | func TestGetMetaAuthed(t *testing.T) { 94 | res, err := api("GET", "/bilbo/repo/objects/"+contentOid, metaMediaType, testUser, testPass, nil) 95 | if err != nil { 96 | t.Fatalf("request error: %s", err) 97 | } 98 | 99 | if res.StatusCode != 200 { 100 | t.Fatalf("expected status 200, got %d", res.StatusCode) 101 | } 102 | 103 | var meta Representation 104 | dec := json.NewDecoder(res.Body) 105 | dec.Decode(&meta) 106 | 107 | if meta.Oid != contentOid { 108 | t.Fatalf("expected to see oid `%s` in meta, got: `%s`", contentOid, meta.Oid) 109 | } 110 | 111 | if meta.Size != contentSize { 112 | t.Fatalf("expected to see a size of `%d`, got: `%d`", contentSize, meta.Size) 113 | } 114 | 115 | download := meta.Actions["download"] 116 | if download.Href != "http://localhost:8080/bilbo/repo/objects/"+contentOid { 117 | t.Fatalf("expected download link, got %s", download.Href) 118 | } 119 | } 120 | 121 | func TestGetMetaUnAuthed(t *testing.T) { 122 | res, err := api("GET", "/user/repo/objects/"+contentOid, metaMediaType, "", "", nil) 123 | if err != nil { 124 | t.Fatalf("request error: %s", err) 125 | } 126 | 127 | if res.StatusCode != 401 { 128 | t.Fatalf("expected status 401, got %d", res.StatusCode) 129 | } 130 | } 131 | 132 | func TestPostAuthedNewObject(t *testing.T) { 133 | buf := bytes.NewBufferString(fmt.Sprintf(`{"oid":"%s", "size":1234}`, nonExistingOid)) 134 | res, err := api("POST", "/bilbo/repo/objects", metaMediaType, testUser, testPass, buf) 135 | if err != nil { 136 | t.Fatalf("request error: %s", err) 137 | } 138 | 139 | if res.StatusCode != 202 { 140 | t.Fatalf("expected status 202, got %d", res.StatusCode) 141 | } 142 | 143 | var meta Representation 144 | dec := json.NewDecoder(res.Body) 145 | dec.Decode(&meta) 146 | 147 | if meta.Oid != nonExistingOid { 148 | t.Fatalf("expected to see oid `%s` in meta, got: `%s`", nonExistingOid, meta.Oid) 149 | } 150 | 151 | if meta.Size != 1234 { 152 | t.Fatalf("expected to see a size of `1234`, got: `%d`", meta.Size) 153 | } 154 | 155 | if download, ok := meta.Actions["download"]; ok { 156 | t.Fatalf("expected POST to not contain a download link, got %s", download.Href) 157 | } 158 | 159 | upload, ok := meta.Actions["upload"] 160 | if !ok { 161 | t.Fatal("expected upload link to be present") 162 | } 163 | 164 | if upload.Href != "http://localhost:8080/bilbo/repo/objects/"+nonExistingOid { 165 | t.Fatalf("expected upload link, got %s", upload.Href) 166 | } 167 | } 168 | 169 | func TestPostAuthedExistingObject(t *testing.T) { 170 | buf := bytes.NewBufferString(fmt.Sprintf(`{"oid":"%s", "size":%d}`, contentOid, contentSize)) 171 | res, err := api("POST", "/bilbo/repo/objects", metaMediaType, testUser, testPass, buf) 172 | if err != nil { 173 | t.Fatalf("request error: %s", err) 174 | } 175 | if res.StatusCode != 200 { 176 | t.Fatalf("expected status 200, got %d", res.StatusCode) 177 | } 178 | 179 | var meta Representation 180 | dec := json.NewDecoder(res.Body) 181 | dec.Decode(&meta) 182 | 183 | if meta.Oid != contentOid { 184 | t.Fatalf("expected to see oid `%s` in meta, got: `%s`", contentOid, meta.Oid) 185 | } 186 | 187 | if meta.Size != contentSize { 188 | t.Fatalf("expected to see a size of `%d`, got: `%d`", contentSize, meta.Size) 189 | } 190 | 191 | download := meta.Actions["download"] 192 | if download.Href != "http://localhost:8080/bilbo/repo/objects/"+contentOid { 193 | t.Fatalf("expected download link, got %s", download.Href) 194 | } 195 | 196 | upload, ok := meta.Actions["upload"] 197 | if !ok { 198 | t.Fatalf("expected upload link to be present") 199 | } 200 | 201 | if upload.Href != "http://localhost:8080/bilbo/repo/objects/"+contentOid { 202 | t.Fatalf("expected upload link, got %s", upload.Href) 203 | } 204 | } 205 | 206 | func TestPostUnAuthed(t *testing.T) { 207 | buf := bytes.NewBufferString(fmt.Sprintf(`{"oid":"%s", "size":%d}`, contentOid, contentSize)) 208 | res, err := api("POST", "/bilbo/readonly/objects", metaMediaType, "", "", buf) 209 | if err != nil { 210 | t.Fatalf("response error: %s", err) 211 | } 212 | 213 | if res.StatusCode != 401 { 214 | t.Fatalf("expected status 401, got %d", res.StatusCode) 215 | } 216 | } 217 | 218 | func TestPut(t *testing.T) { 219 | req, err := http.NewRequest("PUT", lfsServer.URL+"/user/repo/objects/"+contentOid, nil) 220 | if err != nil { 221 | t.Fatalf("request error: %s", err) 222 | } 223 | req.SetBasicAuth(testUser, testPass) 224 | req.Header.Set("Accept", contentMediaType) 225 | req.Header.Set("Content-Type", "application/octet-stream") 226 | req.Body = ioutil.NopCloser(bytes.NewBuffer([]byte(content))) 227 | 228 | res, err := http.DefaultClient.Do(req) 229 | if err != nil { 230 | t.Fatalf("response error: %s", err) 231 | } 232 | 233 | if res.StatusCode != 200 { 234 | t.Fatalf("expected status 200, got %d", res.StatusCode) 235 | } 236 | 237 | r, err := testContentStore.Get(&MetaObject{Oid: contentOid}, 0) 238 | if err != nil { 239 | t.Fatalf("error retreiving from content store: %s", err) 240 | } else { 241 | defer r.Close() 242 | } 243 | c, err := ioutil.ReadAll(r) 244 | if err != nil { 245 | t.Fatalf("error reading content: %s", err) 246 | } 247 | if string(c) != content { 248 | t.Fatalf("expected content, got `%s`", string(c)) 249 | } 250 | } 251 | 252 | func TestMediaTypesRequired(t *testing.T) { 253 | m := []string{"GET", "PUT", "POST", "HEAD"} 254 | for _, method := range m { 255 | res, err := api(method, "/user/repo/objects/"+contentOid, "", testUser, testPass, nil) 256 | if err != nil { 257 | t.Fatalf("request error: %s", err) 258 | } 259 | if res.StatusCode != 404 { 260 | t.Fatalf("expected status 404, got %d", res.StatusCode) 261 | } 262 | } 263 | } 264 | 265 | func TestMediaTypesParsed(t *testing.T) { 266 | accept := contentMediaType + "; charset=utf-8" 267 | res, err := api("GET", "/user/repo/objects/"+contentOid, accept, testUser, testPass, nil) 268 | if err != nil { 269 | t.Fatalf("request error: %s", err) 270 | } 271 | if res.StatusCode != 200 { 272 | t.Fatalf("expected status 200, got %d", res.StatusCode) 273 | } 274 | } 275 | 276 | func TestLocksList(t *testing.T) { 277 | res, err := api("GET", "/user/repo/locks", metaMediaType, testUser, testPass, nil) 278 | if err != nil { 279 | t.Fatalf("request error: %s", err) 280 | } 281 | 282 | if res.StatusCode != 200 { 283 | t.Fatalf("expected status 200, got %d", res.StatusCode) 284 | } 285 | 286 | body, err := ioutil.ReadAll(res.Body) 287 | if err != nil { 288 | t.Fatalf("expected response to contain content, got error: %s", err) 289 | } 290 | 291 | var list LockList 292 | if err := json.Unmarshal(body, &list); err != nil { 293 | t.Fatalf("expected response body to be LockList, got error: %s", err) 294 | } 295 | if len(list.Locks) != 1 { 296 | t.Errorf("expected returned lock count to match, got: %d", len(list.Locks)) 297 | } 298 | if list.Locks[0].Id != lockId { 299 | t.Errorf("expected lockId to match, got: %s", list.Locks[0].Id) 300 | } 301 | } 302 | 303 | func TestLocksListUnAuthed(t *testing.T) { 304 | res, err := api("GET", "/user/repo/locks", metaMediaType, "", "", nil) 305 | if err != nil { 306 | t.Fatalf("request error: %s", err) 307 | } 308 | 309 | if res.StatusCode != 401 { 310 | t.Fatalf("expected status 401, got %d", res.StatusCode) 311 | } 312 | } 313 | 314 | func TestLocksVerify(t *testing.T) { 315 | buf := bytes.NewBufferString(fmt.Sprintf(`{"cursor": "", "limit": 0}`)) 316 | res, err := api("POST", "/user/repo/locks/verify", metaMediaType, testUser, testPass, buf) 317 | if err != nil { 318 | t.Fatalf("request error: %s", err) 319 | } 320 | 321 | if res.StatusCode != 200 { 322 | t.Fatalf("expected status 200, got %d", res.StatusCode) 323 | } 324 | 325 | body, err := ioutil.ReadAll(res.Body) 326 | if err != nil { 327 | t.Fatalf("expected response to contain content, got error: %s", err) 328 | } 329 | 330 | var list VerifiableLockList 331 | if err := json.Unmarshal(body, &list); err != nil { 332 | t.Fatalf("expected response body to be VerifiableLockList, got error: %s", err) 333 | } 334 | } 335 | 336 | func TestLocksVerifyUnAuthed(t *testing.T) { 337 | buf := bytes.NewBufferString(fmt.Sprintf(`{"cursor": "", "limit": 0}`)) 338 | res, err := api("POST", "/user/repo/locks/verify", metaMediaType, "", "", buf) 339 | if err != nil { 340 | t.Fatalf("request error: %s", err) 341 | } 342 | 343 | if res.StatusCode != 401 { 344 | t.Fatalf("expected status 401, got %d", res.StatusCode) 345 | } 346 | } 347 | 348 | func TestLock(t *testing.T) { 349 | path := "TestLock" 350 | lock, err := createLock(testUser, testPass, path) 351 | if err != nil { 352 | t.Fatalf("create lock error: %s", err) 353 | } 354 | if lock == nil { 355 | t.Errorf("expected lock to be created, got: %s", lock) 356 | } 357 | if lock.Owner.Name != testUser { 358 | t.Errorf("expected lock owner to be match, got: %s", lock.Owner.Name) 359 | } 360 | if lock.Path != path { 361 | t.Errorf("expected lock path to be match, got: %s", lock.Path) 362 | } 363 | } 364 | 365 | func TestLockExists(t *testing.T) { 366 | l, err := createLock(testUser, testPass, "TestLockExists") 367 | if err != nil { 368 | t.Fatalf("create lock error: %s", err) 369 | } 370 | 371 | buf := bytes.NewBufferString(fmt.Sprintf(`{"path":"%s"}`, l.Path)) 372 | res, err := api("POST", "/user/repo/locks", metaMediaType, testUser, testPass, buf) 373 | if err != nil { 374 | t.Fatalf("request error: %s", err) 375 | } 376 | 377 | if res.StatusCode != 409 { 378 | t.Fatalf("expected status 409, got %d", res.StatusCode) 379 | } 380 | } 381 | 382 | func TestLockUnAuthed(t *testing.T) { 383 | buf := bytes.NewBufferString(fmt.Sprintf(`{"path":"%s"}`, "TestLockUnAuthed")) 384 | res, err := api("POST", "/user/repo/locks", metaMediaType, "", "", buf) 385 | if err != nil { 386 | t.Fatalf("request error: %s", err) 387 | } 388 | 389 | if res.StatusCode != 401 { 390 | t.Fatalf("expected status 401, got %d", res.StatusCode) 391 | } 392 | } 393 | 394 | func TestUnlock(t *testing.T) { 395 | l, err := createLock(testUser, testPass, "TestUnlock") 396 | if err != nil { 397 | t.Fatalf("create lock error: %s", err) 398 | } 399 | 400 | buf := bytes.NewBufferString(fmt.Sprintf(`{"force": %t}`, false)) 401 | res, err := api("POST", "/user/repo/locks/"+l.Id+"/unlock", metaMediaType, testUser, testPass, buf) 402 | if err != nil { 403 | t.Fatalf("request error: %s", err) 404 | } 405 | if res.StatusCode != 200 { 406 | t.Fatalf("expected status 200, got %d", res.StatusCode) 407 | } 408 | 409 | body, err := ioutil.ReadAll(res.Body) 410 | if err != nil { 411 | t.Fatalf("expected response to contain content, got error: %s", err) 412 | } 413 | 414 | var unlockResponse UnlockResponse 415 | if err := json.Unmarshal(body, &unlockResponse); err != nil { 416 | t.Fatalf("expected response body to be UnlockResponse, got error: %s", err) 417 | } 418 | lock := unlockResponse.Lock 419 | if lock == nil || lock.Id != l.Id { 420 | t.Errorf("expected deleted lock to be returned, got: %s", lock) 421 | } 422 | } 423 | 424 | func TestUnLockUnAuthed(t *testing.T) { 425 | l, err := createLock(testUser, testPass, "TestUnLockUnAuthed") 426 | if err != nil { 427 | t.Fatalf("create lock error: %s", err) 428 | } 429 | 430 | buf := bytes.NewBufferString(fmt.Sprintf(`{"force": %t}`, false)) 431 | res, err := api("POST", "/user/repo/locks/"+l.Id+"/unlock", metaMediaType, "", "", buf) 432 | if err != nil { 433 | t.Fatalf("request error: %s", err) 434 | } 435 | 436 | if res.StatusCode != 401 { 437 | t.Fatalf("expected status 401, got %d", res.StatusCode) 438 | } 439 | } 440 | 441 | func TestUnlockNotOwner(t *testing.T) { 442 | l, err := createLock(testUser, testPass, "TestUnlockNotOwner") 443 | if err != nil { 444 | t.Fatalf("create lock error: %s", err) 445 | } 446 | 447 | buf := bytes.NewBufferString(fmt.Sprintf(`{"force": %t}`, false)) 448 | res, err := api("POST", "/user/repo/locks/"+l.Id+"/unlock", metaMediaType, testUser1, testPass1, buf) 449 | if err != nil { 450 | t.Fatalf("request error: %s", err) 451 | } 452 | if res.StatusCode != 403 { 453 | t.Fatalf("expected status 403, got %d", res.StatusCode) 454 | } 455 | } 456 | 457 | func TestUnlockNotOwnerForce(t *testing.T) { 458 | l, err := createLock(testUser, testPass, "TestUnlockNotOwnerForce") 459 | if err != nil { 460 | t.Fatalf("create lock error: %s", err) 461 | } 462 | 463 | buf := bytes.NewBufferString(fmt.Sprintf(`{"force": %t}`, true)) 464 | res, err := api("POST", "/user/repo/locks/"+l.Id+"/unlock", metaMediaType, testUser1, testPass1, buf) 465 | if err != nil { 466 | t.Fatalf("request error: %s", err) 467 | } 468 | if res.StatusCode != 200 { 469 | t.Fatalf("expected status 200, got %d", res.StatusCode) 470 | } 471 | } 472 | 473 | func createLock(username, password, path string) (*Lock, error) { 474 | buf := bytes.NewBufferString(fmt.Sprintf(`{"path":"%s"}`, path)) 475 | res, err := api("POST", "/user/repo/locks", metaMediaType, username, password, buf) 476 | if err != nil { 477 | return nil, fmt.Errorf("request error: %s", err) 478 | } 479 | 480 | if res.StatusCode != 201 { 481 | return nil, fmt.Errorf("expected status 201, got %d", res.StatusCode) 482 | } 483 | 484 | body, err := ioutil.ReadAll(res.Body) 485 | if err != nil { 486 | return nil, fmt.Errorf("expected response to contain content, got error: %s", err) 487 | } 488 | 489 | var lockResponse LockResponse 490 | if err := json.Unmarshal(body, &lockResponse); err != nil { 491 | return nil, fmt.Errorf("expected response body to be LockResponse, got error: %s", err) 492 | } 493 | return lockResponse.Lock, nil 494 | } 495 | 496 | // simple http client for making api request 497 | func api(method, path, accept, username, password string, body *bytes.Buffer) (*http.Response, error) { 498 | req, err := http.NewRequest(method, lfsServer.URL+path, nil) 499 | if err != nil { 500 | return nil, err 501 | } 502 | if accept != "" { 503 | req.Header.Set("Accept", accept) 504 | } 505 | if username != "" || password != "" { 506 | req.SetBasicAuth(username, password) 507 | } 508 | if (method == "POST" || method == "PUT") && body != nil { 509 | req.Body = ioutil.NopCloser(body) 510 | } 511 | 512 | return http.DefaultClient.Do(req) 513 | } 514 | 515 | var ( 516 | lfsServer *httptest.Server 517 | testMetaStore *MetaStore 518 | testContentStore *ContentStore 519 | ) 520 | 521 | const ( 522 | testUser = "bilbo" 523 | testPass = "baggins" 524 | testUser1 = "bilbo1" 525 | testPass1 = "baggins1" 526 | testRepo = "repo" 527 | content = "this is my content" 528 | contentSize = int64(len(content)) 529 | contentOid = "f97e1b2936a56511b3b6efc99011758e4700d60fb1674d31445d1ee40b663f24" 530 | nonExistingOid = "aec070645fe53ee3b3763059376134f058cc337247c978add178b6ccdfb0019f" 531 | lockId = "3cfec93346f7ff337c60f2da50cd86740715e2f6" 532 | nonExistingLockId = "f310c1555a2485e2e5229ea015a94c9d590763d3" 533 | lockPath = "this/is/lock/path" 534 | ) 535 | 536 | func TestMain(m *testing.M) { 537 | os.Remove("lfs-test.db") 538 | 539 | var err error 540 | testMetaStore, err = NewMetaStore("lfs-test.db") 541 | if err != nil { 542 | fmt.Printf("Error creating meta store: %s", err) 543 | os.Exit(1) 544 | } 545 | 546 | testContentStore, err = NewContentStore("lfs-content-test") 547 | if err != nil { 548 | fmt.Printf("Error creating content store: %s", err) 549 | os.Exit(1) 550 | } 551 | 552 | if err := seedMetaStore(); err != nil { 553 | fmt.Printf("Error seeding meta store: %s", err) 554 | os.Exit(1) 555 | } 556 | 557 | if err := seedContentStore(); err != nil { 558 | fmt.Printf("Error seeding content store: %s", err) 559 | os.Exit(1) 560 | } 561 | 562 | app := NewApp(testContentStore, testMetaStore) 563 | lfsServer = httptest.NewServer(app) 564 | 565 | logger = NewKVLogger(ioutil.Discard) 566 | 567 | ret := m.Run() 568 | 569 | lfsServer.Close() 570 | testMetaStore.Close() 571 | os.Remove("lfs-test.db") 572 | os.RemoveAll("lfs-content-test") 573 | 574 | os.Exit(ret) 575 | } 576 | 577 | func seedMetaStore() error { 578 | if err := testMetaStore.AddUser(testUser, testPass); err != nil { 579 | return err 580 | } 581 | if err := testMetaStore.AddUser(testUser1, testPass1); err != nil { 582 | return err 583 | } 584 | 585 | rv := &RequestVars{Oid: contentOid, Size: contentSize} 586 | if _, err := testMetaStore.Put(rv); err != nil { 587 | return err 588 | } 589 | 590 | lock := NewTestLock(lockId, lockPath, testUser) 591 | if err := testMetaStore.AddLocks(testRepo, lock); err != nil { 592 | return err 593 | } 594 | 595 | return nil 596 | } 597 | 598 | func seedContentStore() error { 599 | meta := &MetaObject{Oid: contentOid, Size: contentSize} 600 | buf := bytes.NewBuffer([]byte(content)) 601 | if err := testContentStore.Put(meta, buf); err != nil { 602 | return err 603 | } 604 | 605 | return nil 606 | } 607 | -------------------------------------------------------------------------------- /tracking_listener.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/url" 7 | "os" 8 | "strconv" 9 | "sync" 10 | ) 11 | 12 | // TrackingListener tracks incoming connections so that application shutdown can 13 | // wait until all in progress connections are finished before exiting. 14 | type TrackingListener struct { 15 | wg sync.WaitGroup 16 | connections map[net.Conn]bool 17 | cm sync.Mutex 18 | net.Listener 19 | } 20 | 21 | // NewTrackingListener creates a new TrackingListener, listening on the supplied 22 | // address. 23 | func NewTrackingListener(addr string) (*TrackingListener, error) { 24 | var listener net.Listener 25 | 26 | a, err := url.Parse(addr) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | switch a.Scheme { 32 | case "fd": 33 | fd, err := strconv.Atoi(a.Host) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | f := os.NewFile(uintptr(fd), "trackinglistener") 39 | listener, err = net.FileListener(f) 40 | if err != nil { 41 | return nil, err 42 | } 43 | case "tcp", "tcp4", "tcp6": 44 | laddr, err := net.ResolveTCPAddr(a.Scheme, a.Host) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | listener, err = net.ListenTCP(a.Scheme, laddr) 50 | if err != nil { 51 | return nil, err 52 | } 53 | default: 54 | return nil, fmt.Errorf("Unsupported listener protocol: %s", a.Scheme) 55 | } 56 | 57 | return &TrackingListener{Listener: listener, connections: make(map[net.Conn]bool)}, nil 58 | } 59 | 60 | // Accept wraps the underlying net.Listener's Accept(), keeping track of all connections 61 | // accepted. 62 | func (l *TrackingListener) Accept() (net.Conn, error) { 63 | l.wg.Add(1) 64 | conn, err := l.Listener.Accept() 65 | if err != nil { 66 | l.wg.Done() 67 | return nil, err 68 | } 69 | 70 | c := &trackedConn{ 71 | Conn: conn, 72 | listener: l, 73 | } 74 | 75 | return c, nil 76 | } 77 | 78 | // WaitForChildren is called during shutdown. It will return once all the existing 79 | // connections have finished. 80 | func (l *TrackingListener) WaitForChildren() { 81 | l.wg.Wait() 82 | logger.Log(kv{"fn": "shutdown"}) 83 | } 84 | 85 | type trackedConn struct { 86 | net.Conn 87 | listener *TrackingListener 88 | once sync.Once 89 | } 90 | 91 | func (c *trackedConn) Close() error { 92 | c.once.Do(c.listener.wg.Done) 93 | 94 | return c.Conn.Close() 95 | } 96 | -------------------------------------------------------------------------------- /tus.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type TusServer struct { 16 | serverMutex sync.Mutex 17 | tusProcess *exec.Cmd 18 | dataPath string 19 | tusBaseUrl string 20 | httpClient *http.Client 21 | oidToTusUrl map[string]string 22 | } 23 | 24 | var ( 25 | tusServer *TusServer = &TusServer{} 26 | ) 27 | 28 | // Start launches the tus server & stores uploads in the given contentPath 29 | func (t *TusServer) Start() { 30 | t.serverMutex.Lock() 31 | defer t.serverMutex.Unlock() 32 | 33 | if t.tusProcess != nil { 34 | return 35 | } 36 | 37 | t.dataPath = filepath.Join(os.TempDir(), "lfs_tusserver") 38 | hostparts := strings.Split(Config.TusHost, ":") 39 | host := "localhost" 40 | port := "1080" 41 | if len(hostparts) > 0 { 42 | host = hostparts[0] 43 | } 44 | if len(hostparts) > 1 { 45 | port = hostparts[1] 46 | } 47 | t.tusProcess = exec.Command("tusd", 48 | "-upload-dir", t.dataPath, 49 | "-host", host, 50 | "-port", port) 51 | // Make sure tus server is started before continuing 52 | var procWait sync.WaitGroup 53 | procWait.Add(1) 54 | go func(p *exec.Cmd) { 55 | 56 | stdout, err := p.StdoutPipe() 57 | if err != nil { 58 | panic(fmt.Sprintf("Error getting tus server stdout: %v", err)) 59 | } 60 | stderr, err := p.StderrPipe() 61 | if err != nil { 62 | panic(fmt.Sprintf("Error getting tus server stderr: %v", err)) 63 | } 64 | err = p.Start() 65 | if err != nil { 66 | panic(fmt.Sprintf("Error starting tus server: %v", err)) 67 | } 68 | go func() { 69 | scanner := bufio.NewScanner(stdout) 70 | for scanner.Scan() { 71 | logger.Log(kv{"fn": "tusout", "msg": scanner.Text()}) 72 | } 73 | }() 74 | go func() { 75 | scanner := bufio.NewScanner(stderr) 76 | for scanner.Scan() { 77 | logger.Log(kv{"fn": "tuserr", "msg": scanner.Text()}) 78 | } 79 | }() 80 | time.Sleep(2) 81 | procWait.Done() 82 | defer p.Wait() 83 | 84 | }(t.tusProcess) 85 | procWait.Wait() 86 | logger.Log(kv{"fn": "Start", "msg": "Tus server started"}) 87 | t.tusBaseUrl = fmt.Sprintf("http://%s:%s/files/", host, port) 88 | t.httpClient = &http.Client{} 89 | t.oidToTusUrl = make(map[string]string) 90 | } 91 | 92 | func (t *TusServer) Stop() { 93 | t.serverMutex.Lock() 94 | defer t.serverMutex.Unlock() 95 | if t.tusProcess != nil { 96 | t.tusProcess.Process.Kill() 97 | t.tusProcess = nil 98 | } 99 | logger.Log(kv{"fn": "Stop", "msg": "Tus server stopped"}) 100 | } 101 | 102 | // Create a new upload URL for the given object 103 | // Required to call CREATE on the tus API before uploading but not part of LFS API 104 | func (t *TusServer) Create(oid string, size int64) (string, error) { 105 | t.serverMutex.Lock() 106 | defer t.serverMutex.Unlock() 107 | req, err := http.NewRequest("POST", t.tusBaseUrl, nil) 108 | if err != nil { 109 | return "", err 110 | } 111 | req.Header.Set("Tus-Resumable", "1.0.0") 112 | req.Header.Set("Upload-Length", fmt.Sprintf("%d", size)) 113 | req.Header.Set("Upload-Metadata", fmt.Sprintf("oid %s", oid)) 114 | 115 | res, err := t.httpClient.Do(req) 116 | if err != nil { 117 | return "", err 118 | } 119 | if res.StatusCode != 201 { 120 | return "", fmt.Errorf("Expected tus status code 201, got %d", res.StatusCode) 121 | } 122 | loc := res.Header.Get("Location") 123 | if len(loc) == 0 { 124 | return "", fmt.Errorf("Missing Location header in tus response") 125 | } 126 | t.oidToTusUrl[oid] = loc 127 | return loc, nil 128 | } 129 | 130 | // Move the finished uploaded data from TUS to the content store (called by verify) 131 | func (t *TusServer) Finish(oid string, store *ContentStore) error { 132 | t.serverMutex.Lock() 133 | defer t.serverMutex.Unlock() 134 | 135 | loc, ok := t.oidToTusUrl[oid] 136 | if !ok { 137 | return fmt.Errorf("Unable to find upload for %s", oid) 138 | } 139 | parts := strings.Split(loc, "/") 140 | filename := filepath.Join(t.dataPath, fmt.Sprintf("%s.bin", parts[len(parts)-1])) 141 | stat, err := os.Stat(filename) 142 | if err != nil { 143 | return err 144 | } 145 | meta := &MetaObject{Oid: oid, Size: stat.Size(), Existing: false} 146 | f, err := os.Open(filename) 147 | if err != nil { 148 | return err 149 | } 150 | defer f.Close() 151 | err = store.Put(meta, f) 152 | if err == nil { 153 | os.Remove(filename) 154 | // tus also stores a .info file, remove that 155 | os.Remove(filepath.Join(t.dataPath, fmt.Sprintf("%s.info", parts[len(parts)-1]))) 156 | } 157 | return err 158 | } 159 | --------------------------------------------------------------------------------