summaryrefslogtreecommitdiffstats
path: root/modules/lfs/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'modules/lfs/server.go')
-rw-r--r--modules/lfs/server.go549
1 files changed, 549 insertions, 0 deletions
diff --git a/modules/lfs/server.go b/modules/lfs/server.go
new file mode 100644
index 0000000000..f82cb70364
--- /dev/null
+++ b/modules/lfs/server.go
@@ -0,0 +1,549 @@
+package lfs
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "github.com/dgrijalva/jwt-go"
+ "gopkg.in/macaron.v1"
+)
+
+const (
+ contentMediaType = "application/vnd.git-lfs"
+ metaMediaType = contentMediaType + "+json"
+)
+
+// RequestVars contain variables from the HTTP request. Variables from routing, json body decoding, and
+// some headers are stored.
+type RequestVars struct {
+ Oid string
+ Size int64
+ User string
+ Password string
+ Repo string
+ Authorization string
+}
+
+// BatchVars contains multiple RequestVars processed in one batch operation.
+// https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
+type BatchVars struct {
+ Transfers []string `json:"transfers,omitempty"`
+ Operation string `json:"operation"`
+ Objects []*RequestVars `json:"objects"`
+}
+
+// BatchResponse contains multiple object metadata Representation structures
+// for use with the batch API.
+type BatchResponse struct {
+ Transfer string `json:"transfer,omitempty"`
+ Objects []*Representation `json:"objects"`
+}
+
+// Representation is object medata as seen by clients of the lfs server.
+type Representation struct {
+ Oid string `json:"oid"`
+ Size int64 `json:"size"`
+ Actions map[string]*link `json:"actions"`
+ Error *ObjectError `json:"error,omitempty"`
+}
+
+// ObjectError defines the JSON structure returned to the client in case of an error
+type ObjectError struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+// ObjectLink builds a URL linking to the object.
+func (v *RequestVars) ObjectLink() string {
+ return fmt.Sprintf("%s%s/%s/info/lfs/objects/%s", setting.AppURL, v.User, v.Repo, v.Oid)
+}
+
+// link provides a structure used to build a hypermedia representation of an HTTP link.
+type link struct {
+ Href string `json:"href"`
+ Header map[string]string `json:"header,omitempty"`
+ ExpiresAt time.Time `json:"expires_at,omitempty"`
+}
+
+// ObjectOidHandler is the main request routing entry point into LFS server functions
+func ObjectOidHandler(ctx *context.Context) {
+
+ if !setting.LFS.StartServer {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if ctx.Req.Method == "GET" || ctx.Req.Method == "HEAD" {
+ if MetaMatcher(ctx.Req) {
+ GetMetaHandler(ctx)
+ return
+ }
+ if ContentMatcher(ctx.Req) || len(ctx.Params("filename")) > 0 {
+ GetContentHandler(ctx)
+ return
+ }
+ } else if ctx.Req.Method == "PUT" && ContentMatcher(ctx.Req) {
+ PutHandler(ctx)
+ return
+ }
+
+}
+
+// GetContentHandler gets the content from the content store
+func GetContentHandler(ctx *context.Context) {
+
+ rv := unpack(ctx)
+
+ meta, err := models.GetLFSMetaObjectByOid(rv.Oid)
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ repository, err := models.GetRepositoryByID(meta.RepositoryID)
+
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if !authenticate(ctx, repository, rv.Authorization, false) {
+ requireAuth(ctx)
+ return
+ }
+
+ // Support resume download using Range header
+ var fromByte int64
+ statusCode := 200
+ if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" {
+ regex := regexp.MustCompile(`bytes=(\d+)\-.*`)
+ match := regex.FindStringSubmatch(rangeHdr)
+ if match != nil && len(match) > 1 {
+ statusCode = 206
+ fromByte, _ = strconv.ParseInt(match[1], 10, 32)
+ ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, meta.Size-1, int64(meta.Size)-fromByte))
+ }
+ }
+
+ contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
+ content, err := contentStore.Get(meta, fromByte)
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
+ ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
+
+ filename := ctx.Params("filename")
+ if len(filename) > 0 {
+ decodedFilename, err := base64.RawURLEncoding.DecodeString(filename)
+ if err == nil {
+ ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"")
+ }
+ }
+
+ ctx.Resp.WriteHeader(statusCode)
+ io.Copy(ctx.Resp, content)
+ content.Close()
+ logRequest(ctx.Req, statusCode)
+}
+
+// GetMetaHandler retrieves metadata about the object
+func GetMetaHandler(ctx *context.Context) {
+
+ rv := unpack(ctx)
+
+ meta, err := models.GetLFSMetaObjectByOid(rv.Oid)
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ repository, err := models.GetRepositoryByID(meta.RepositoryID)
+
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if !authenticate(ctx, repository, rv.Authorization, false) {
+ requireAuth(ctx)
+ return
+ }
+
+ ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+ if ctx.Req.Method == "GET" {
+ enc := json.NewEncoder(ctx.Resp)
+ enc.Encode(Represent(rv, meta, true, false))
+ }
+
+ logRequest(ctx.Req, 200)
+}
+
+// PostHandler instructs the client how to upload data
+func PostHandler(ctx *context.Context) {
+
+ if !setting.LFS.StartServer {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if !MetaMatcher(ctx.Req) {
+ writeStatus(ctx, 400)
+ return
+ }
+
+ rv := unpack(ctx)
+
+ repositoryString := rv.User + "/" + rv.Repo
+ repository, err := models.GetRepositoryByRef(repositoryString)
+
+ if err != nil {
+ log.Debug("Could not find repository: %s - %s", repositoryString, err)
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if !authenticate(ctx, repository, rv.Authorization, true) {
+ requireAuth(ctx)
+ }
+
+ meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
+
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+ sentStatus := 202
+ contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
+ if meta.Existing && contentStore.Exists(meta) {
+ sentStatus = 200
+ }
+ ctx.Resp.WriteHeader(sentStatus)
+
+ enc := json.NewEncoder(ctx.Resp)
+ enc.Encode(Represent(rv, meta, meta.Existing, true))
+ logRequest(ctx.Req, sentStatus)
+}
+
+// BatchHandler provides the batch api
+func BatchHandler(ctx *context.Context) {
+
+ if !setting.LFS.StartServer {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if !MetaMatcher(ctx.Req) {
+ writeStatus(ctx, 400)
+ return
+ }
+
+ bv := unpackbatch(ctx)
+
+ var responseObjects []*Representation
+
+ // Create a response object
+ for _, object := range bv.Objects {
+
+ repositoryString := object.User + "/" + object.Repo
+ repository, err := models.GetRepositoryByRef(repositoryString)
+
+ if err != nil {
+ log.Debug("Could not find repository: %s - %s", repositoryString, err)
+ writeStatus(ctx, 404)
+ return
+ }
+
+ requireWrite := false
+ if bv.Operation == "upload" {
+ requireWrite = true
+ }
+
+ if !authenticate(ctx, repository, object.Authorization, requireWrite) {
+ requireAuth(ctx)
+ return
+ }
+
+ meta, err := models.GetLFSMetaObjectByOid(object.Oid)
+
+ contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
+ if err == nil && contentStore.Exists(meta) { // Object is found and exists
+ responseObjects = append(responseObjects, Represent(object, meta, true, false))
+ continue
+ }
+
+ // Object is not found
+ meta, err = models.NewLFSMetaObject(&models.LFSMetaObject{Oid: object.Oid, Size: object.Size, RepositoryID: repository.ID})
+
+ if err == nil {
+ responseObjects = append(responseObjects, Represent(object, meta, meta.Existing, true))
+ }
+ }
+
+ ctx.Resp.Header().Set("Content-Type", metaMediaType)
+
+ respobj := &BatchResponse{Objects: responseObjects}
+
+ enc := json.NewEncoder(ctx.Resp)
+ enc.Encode(respobj)
+ logRequest(ctx.Req, 200)
+}
+
+// PutHandler receives data from the client and puts it into the content store
+func PutHandler(ctx *context.Context) {
+ rv := unpack(ctx)
+
+ meta, err := models.GetLFSMetaObjectByOid(rv.Oid)
+
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ repository, err := models.GetRepositoryByID(meta.RepositoryID)
+
+ if err != nil {
+ writeStatus(ctx, 404)
+ return
+ }
+
+ if !authenticate(ctx, repository, rv.Authorization, true) {
+ requireAuth(ctx)
+ return
+ }
+
+ contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
+ if err := contentStore.Put(meta, ctx.Req.Body().ReadCloser()); err != nil {
+ models.RemoveLFSMetaObjectByOid(rv.Oid)
+ ctx.Resp.WriteHeader(500)
+ fmt.Fprintf(ctx.Resp, `{"message":"%s"}`, err)
+ return
+ }
+
+ logRequest(ctx.Req, 200)
+}
+
+// Represent takes a RequestVars and Meta and turns it into a Representation suitable
+// for json encoding
+func Represent(rv *RequestVars, meta *models.LFSMetaObject, download, upload bool) *Representation {
+ rep := &Representation{
+ Oid: meta.Oid,
+ Size: meta.Size,
+ Actions: make(map[string]*link),
+ }
+
+ header := make(map[string]string)
+ header["Accept"] = contentMediaType
+
+ if rv.Authorization == "" {
+ //https://github.com/github/git-lfs/issues/1088
+ header["Authorization"] = "Authorization: Basic dummy"
+ } else {
+ header["Authorization"] = rv.Authorization
+ }
+
+ if download {
+ rep.Actions["download"] = &link{Href: rv.ObjectLink(), Header: header}
+ }
+
+ if upload {
+ rep.Actions["upload"] = &link{Href: rv.ObjectLink(), Header: header}
+ }
+
+ return rep
+}
+
+// ContentMatcher provides a mux.MatcherFunc that only allows requests that contain
+// an Accept header with the contentMediaType
+func ContentMatcher(r macaron.Request) bool {
+ mediaParts := strings.Split(r.Header.Get("Accept"), ";")
+ mt := mediaParts[0]
+ return mt == contentMediaType
+}
+
+// MetaMatcher provides a mux.MatcherFunc that only allows requests that contain
+// an Accept header with the metaMediaType
+func MetaMatcher(r macaron.Request) bool {
+ mediaParts := strings.Split(r.Header.Get("Accept"), ";")
+ mt := mediaParts[0]
+ return mt == metaMediaType
+}
+
+func unpack(ctx *context.Context) *RequestVars {
+ r := ctx.Req
+ rv := &RequestVars{
+ User: ctx.Params("username"),
+ Repo: strings.TrimSuffix(ctx.Params("reponame"), ".git"),
+ Oid: ctx.Params("oid"),
+ Authorization: r.Header.Get("Authorization"),
+ }
+
+ if r.Method == "POST" { // Maybe also check if +json
+ var p RequestVars
+ dec := json.NewDecoder(r.Body().ReadCloser())
+ err := dec.Decode(&p)
+ if err != nil {
+ return rv
+ }
+
+ rv.Oid = p.Oid
+ rv.Size = p.Size
+ }
+
+ return rv
+}
+
+// TODO cheap hack, unify with unpack
+func unpackbatch(ctx *context.Context) *BatchVars {
+
+ r := ctx.Req
+ var bv BatchVars
+
+ dec := json.NewDecoder(r.Body().ReadCloser())
+ err := dec.Decode(&bv)
+ if err != nil {
+ return &bv
+ }
+
+ for i := 0; i < len(bv.Objects); i++ {
+ bv.Objects[i].User = ctx.Params("username")
+ bv.Objects[i].Repo = strings.TrimSuffix(ctx.Params("reponame"), ".git")
+ bv.Objects[i].Authorization = r.Header.Get("Authorization")
+ }
+
+ return &bv
+}
+
+func writeStatus(ctx *context.Context, status int) {
+ message := http.StatusText(status)
+
+ mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";")
+ mt := mediaParts[0]
+ if strings.HasSuffix(mt, "+json") {
+ message = `{"message":"` + message + `"}`
+ }
+
+ ctx.Resp.WriteHeader(status)
+ fmt.Fprint(ctx.Resp, message)
+ logRequest(ctx.Req, status)
+}
+
+func logRequest(r macaron.Request, status int) {
+ log.Debug("LFS request - Method: %s, URL: %s, Status %d", r.Method, r.URL, status)
+}
+
+// authenticate uses the authorization string to determine whether
+// or not to proceed. This server assumes an HTTP Basic auth format.
+func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool {
+
+ accessMode := models.AccessModeRead
+ if requireWrite {
+ accessMode = models.AccessModeWrite
+ }
+
+ if !repository.IsPrivate && !requireWrite {
+ return true
+ }
+
+ if ctx.IsSigned {
+ accessCheck, _ := models.HasAccess(ctx.User, repository, accessMode)
+ return accessCheck
+ }
+
+ if authorization == "" {
+ return false
+ }
+
+ if authenticateToken(repository, authorization, requireWrite) {
+ return true
+ }
+
+ if !strings.HasPrefix(authorization, "Basic ") {
+ return false
+ }
+
+ c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic "))
+ if err != nil {
+ return false
+ }
+ cs := string(c)
+ i := strings.IndexByte(cs, ':')
+ if i < 0 {
+ return false
+ }
+ user, password := cs[:i], cs[i+1:]
+
+ userModel, err := models.GetUserByName(user)
+ if err != nil {
+ return false
+ }
+
+ if !userModel.ValidatePassword(password) {
+ return false
+ }
+
+ accessCheck, _ := models.HasAccess(userModel, repository, accessMode)
+ return accessCheck
+}
+
+func authenticateToken(repository *models.Repository, authorization string, requireWrite bool) bool {
+ if !strings.HasPrefix(authorization, "Bearer ") {
+ return false
+ }
+
+ token, err := jwt.Parse(authorization[7:], func(t *jwt.Token) (interface{}, error) {
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return setting.LFS.JWTSecretBytes, nil
+ })
+ if err != nil {
+ return false
+ }
+ claims, claimsOk := token.Claims.(jwt.MapClaims)
+ if !token.Valid || !claimsOk {
+ return false
+ }
+
+ opStr, ok := claims["op"].(string)
+ if !ok {
+ return false
+ }
+
+ if requireWrite && opStr != "upload" {
+ return false
+ }
+
+ repoID, ok := claims["repo"].(float64)
+ if !ok {
+ return false
+ }
+
+ if repository.ID != int64(repoID) {
+ return false
+ }
+
+ return true
+}
+
+func requireAuth(ctx *context.Context) {
+ ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
+ writeStatus(ctx, 401)
+}