You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

549 lines
13 KiB

Git LFS support v2 (#122) * Import github.com/git-lfs/lfs-test-server as lfs module base Imported commit is 3968aac269a77b73924649b9412ae03f7ccd3198 Removed: Dockerfile CONTRIBUTING.md mgmt* script/ vendor/ kvlogger.go .dockerignore .gitignore README.md * Remove config, add JWT support from github.com/mgit-at/lfs-test-server Imported commit f0cdcc5a01599c5a955dc1bbf683bb4acecdba83 * Add LFS settings * Add LFS meta object model * Add LFS routes and initialization * Import github.com/dgrijalva/jwt-go into vendor/ * Adapt LFS module: handlers, routing, meta store * Move LFS routes to /user/repo/info/lfs/* * Add request header checks to LFS BatchHandler / PostHandler * Implement LFS basic authentication * Rework JWT secret generation / load * Implement LFS SSH token authentication with JWT Specification: https://github.com/github/git-lfs/tree/master/docs/api * Integrate LFS settings into install process * Remove LFS objects when repository is deleted Only removes objects from content store when deleted repo is the only referencing repository * Make LFS module stateless Fixes bug where LFS would not work after installation without restarting Gitea * Change 500 'Internal Server Error' to 400 'Bad Request' * Change sql query to xorm call * Remove unneeded type from LFS module * Change internal imports to code.gitea.io/gitea/ * Add Gitea authors copyright * Change basic auth realm to "gitea-lfs" * Add unique indexes to LFS model * Use xorm count function in LFS check on repository delete * Return io.ReadCloser from content store and close after usage * Add LFS info to runWeb() * Export LFS content store base path * LFS file download from UI * Work around git-lfs client issue with unauthenticated requests Returning a dummy Authorization header for unauthenticated requests lets git-lfs client skip asking for auth credentials See: https://github.com/github/git-lfs/issues/1088 * Fix unauthenticated UI downloads from public repositories * Authentication check order, Finish LFS file view logic * Ignore LFS hooks if installed for current OS user Fixes Gitea UI actions for repositories tracking LFS files. Checks for minimum needed git version by parsing the semantic version string. * Hide LFS metafile diff from commit view, marking as binary * Show LFS notice if file in commit view is tracked * Add notbefore/nbf JWT claim * Correct lint suggestions - comments for structs and functions - Add comments to LFS model - Function comment for GetRandomBytesAsBase64 - LFS server function comments and lint variable suggestion * Move secret generation code out of conditional Ensures no LFS code may run with an empty secret * Do not hand out JWT tokens if LFS server support is disabled
8 years ago
Git LFS support v2 (#122) * Import github.com/git-lfs/lfs-test-server as lfs module base Imported commit is 3968aac269a77b73924649b9412ae03f7ccd3198 Removed: Dockerfile CONTRIBUTING.md mgmt* script/ vendor/ kvlogger.go .dockerignore .gitignore README.md * Remove config, add JWT support from github.com/mgit-at/lfs-test-server Imported commit f0cdcc5a01599c5a955dc1bbf683bb4acecdba83 * Add LFS settings * Add LFS meta object model * Add LFS routes and initialization * Import github.com/dgrijalva/jwt-go into vendor/ * Adapt LFS module: handlers, routing, meta store * Move LFS routes to /user/repo/info/lfs/* * Add request header checks to LFS BatchHandler / PostHandler * Implement LFS basic authentication * Rework JWT secret generation / load * Implement LFS SSH token authentication with JWT Specification: https://github.com/github/git-lfs/tree/master/docs/api * Integrate LFS settings into install process * Remove LFS objects when repository is deleted Only removes objects from content store when deleted repo is the only referencing repository * Make LFS module stateless Fixes bug where LFS would not work after installation without restarting Gitea * Change 500 'Internal Server Error' to 400 'Bad Request' * Change sql query to xorm call * Remove unneeded type from LFS module * Change internal imports to code.gitea.io/gitea/ * Add Gitea authors copyright * Change basic auth realm to "gitea-lfs" * Add unique indexes to LFS model * Use xorm count function in LFS check on repository delete * Return io.ReadCloser from content store and close after usage * Add LFS info to runWeb() * Export LFS content store base path * LFS file download from UI * Work around git-lfs client issue with unauthenticated requests Returning a dummy Authorization header for unauthenticated requests lets git-lfs client skip asking for auth credentials See: https://github.com/github/git-lfs/issues/1088 * Fix unauthenticated UI downloads from public repositories * Authentication check order, Finish LFS file view logic * Ignore LFS hooks if installed for current OS user Fixes Gitea UI actions for repositories tracking LFS files. Checks for minimum needed git version by parsing the semantic version string. * Hide LFS metafile diff from commit view, marking as binary * Show LFS notice if file in commit view is tracked * Add notbefore/nbf JWT claim * Correct lint suggestions - comments for structs and functions - Add comments to LFS model - Function comment for GetRandomBytesAsBase64 - LFS server function comments and lint variable suggestion * Move secret generation code out of conditional Ensures no LFS code may run with an empty secret * Do not hand out JWT tokens if LFS server support is disabled
8 years ago
  1. package lfs
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "code.gitea.io/gitea/models"
  13. "code.gitea.io/gitea/modules/context"
  14. "code.gitea.io/gitea/modules/log"
  15. "code.gitea.io/gitea/modules/setting"
  16. "github.com/dgrijalva/jwt-go"
  17. "gopkg.in/macaron.v1"
  18. )
  19. const (
  20. contentMediaType = "application/vnd.git-lfs"
  21. metaMediaType = contentMediaType + "+json"
  22. )
  23. // RequestVars contain variables from the HTTP request. Variables from routing, json body decoding, and
  24. // some headers are stored.
  25. type RequestVars struct {
  26. Oid string
  27. Size int64
  28. User string
  29. Password string
  30. Repo string
  31. Authorization string
  32. }
  33. // BatchVars contains multiple RequestVars processed in one batch operation.
  34. // https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md
  35. type BatchVars struct {
  36. Transfers []string `json:"transfers,omitempty"`
  37. Operation string `json:"operation"`
  38. Objects []*RequestVars `json:"objects"`
  39. }
  40. // BatchResponse contains multiple object metadata Representation structures
  41. // for use with the batch API.
  42. type BatchResponse struct {
  43. Transfer string `json:"transfer,omitempty"`
  44. Objects []*Representation `json:"objects"`
  45. }
  46. // Representation is object medata as seen by clients of the lfs server.
  47. type Representation struct {
  48. Oid string `json:"oid"`
  49. Size int64 `json:"size"`
  50. Actions map[string]*link `json:"actions"`
  51. Error *ObjectError `json:"error,omitempty"`
  52. }
  53. // ObjectError defines the JSON structure returned to the client in case of an error
  54. type ObjectError struct {
  55. Code int `json:"code"`
  56. Message string `json:"message"`
  57. }
  58. // ObjectLink builds a URL linking to the object.
  59. func (v *RequestVars) ObjectLink() string {
  60. return fmt.Sprintf("%s%s/%s/info/lfs/objects/%s", setting.AppURL, v.User, v.Repo, v.Oid)
  61. }
  62. // link provides a structure used to build a hypermedia representation of an HTTP link.
  63. type link struct {
  64. Href string `json:"href"`
  65. Header map[string]string `json:"header,omitempty"`
  66. ExpiresAt time.Time `json:"expires_at,omitempty"`
  67. }
  68. // ObjectOidHandler is the main request routing entry point into LFS server functions
  69. func ObjectOidHandler(ctx *context.Context) {
  70. if !setting.LFS.StartServer {
  71. writeStatus(ctx, 404)
  72. return
  73. }
  74. if ctx.Req.Method == "GET" || ctx.Req.Method == "HEAD" {
  75. if MetaMatcher(ctx.Req) {
  76. GetMetaHandler(ctx)
  77. return
  78. }
  79. if ContentMatcher(ctx.Req) || len(ctx.Params("filename")) > 0 {
  80. GetContentHandler(ctx)
  81. return
  82. }
  83. } else if ctx.Req.Method == "PUT" && ContentMatcher(ctx.Req) {
  84. PutHandler(ctx)
  85. return
  86. }
  87. }
  88. // GetContentHandler gets the content from the content store
  89. func GetContentHandler(ctx *context.Context) {
  90. rv := unpack(ctx)
  91. meta, err := models.GetLFSMetaObjectByOid(rv.Oid)
  92. if err != nil {
  93. writeStatus(ctx, 404)
  94. return
  95. }
  96. repository, err := models.GetRepositoryByID(meta.RepositoryID)
  97. if err != nil {
  98. writeStatus(ctx, 404)
  99. return
  100. }
  101. if !authenticate(ctx, repository, rv.Authorization, false) {
  102. requireAuth(ctx)
  103. return
  104. }
  105. // Support resume download using Range header
  106. var fromByte int64
  107. statusCode := 200
  108. if rangeHdr := ctx.Req.Header.Get("Range"); rangeHdr != "" {
  109. regex := regexp.MustCompile(`bytes=(\d+)\-.*`)
  110. match := regex.FindStringSubmatch(rangeHdr)
  111. if match != nil && len(match) > 1 {
  112. statusCode = 206
  113. fromByte, _ = strconv.ParseInt(match[1], 10, 32)
  114. ctx.Resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", fromByte, meta.Size-1, meta.Size-fromByte))
  115. }
  116. }
  117. contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
  118. content, err := contentStore.Get(meta, fromByte)
  119. if err != nil {
  120. writeStatus(ctx, 404)
  121. return
  122. }
  123. ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(meta.Size, 10))
  124. ctx.Resp.Header().Set("Content-Type", "application/octet-stream")
  125. filename := ctx.Params("filename")
  126. if len(filename) > 0 {
  127. decodedFilename, err := base64.RawURLEncoding.DecodeString(filename)
  128. if err == nil {
  129. ctx.Resp.Header().Set("Content-Disposition", "attachment; filename=\""+string(decodedFilename)+"\"")
  130. }
  131. }
  132. ctx.Resp.WriteHeader(statusCode)
  133. io.Copy(ctx.Resp, content)
  134. content.Close()
  135. logRequest(ctx.Req, statusCode)
  136. }
  137. // GetMetaHandler retrieves metadata about the object
  138. func GetMetaHandler(ctx *context.Context) {
  139. rv := unpack(ctx)
  140. meta, err := models.GetLFSMetaObjectByOid(rv.Oid)
  141. if err != nil {
  142. writeStatus(ctx, 404)
  143. return
  144. }
  145. repository, err := models.GetRepositoryByID(meta.RepositoryID)
  146. if err != nil {
  147. writeStatus(ctx, 404)
  148. return
  149. }
  150. if !authenticate(ctx, repository, rv.Authorization, false) {
  151. requireAuth(ctx)
  152. return
  153. }
  154. ctx.Resp.Header().Set("Content-Type", metaMediaType)
  155. if ctx.Req.Method == "GET" {
  156. enc := json.NewEncoder(ctx.Resp)
  157. enc.Encode(Represent(rv, meta, true, false))
  158. }
  159. logRequest(ctx.Req, 200)
  160. }
  161. // PostHandler instructs the client how to upload data
  162. func PostHandler(ctx *context.Context) {
  163. if !setting.LFS.StartServer {
  164. writeStatus(ctx, 404)
  165. return
  166. }
  167. if !MetaMatcher(ctx.Req) {
  168. writeStatus(ctx, 400)
  169. return
  170. }
  171. rv := unpack(ctx)
  172. repositoryString := rv.User + "/" + rv.Repo
  173. repository, err := models.GetRepositoryByRef(repositoryString)
  174. if err != nil {
  175. log.Debug("Could not find repository: %s - %s", repositoryString, err)
  176. writeStatus(ctx, 404)
  177. return
  178. }
  179. if !authenticate(ctx, repository, rv.Authorization, true) {
  180. requireAuth(ctx)
  181. }
  182. meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
  183. if err != nil {
  184. writeStatus(ctx, 404)
  185. return
  186. }
  187. ctx.Resp.Header().Set("Content-Type", metaMediaType)
  188. sentStatus := 202
  189. contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
  190. if meta.Existing && contentStore.Exists(meta) {
  191. sentStatus = 200
  192. }
  193. ctx.Resp.WriteHeader(sentStatus)
  194. enc := json.NewEncoder(ctx.Resp)
  195. enc.Encode(Represent(rv, meta, meta.Existing, true))
  196. logRequest(ctx.Req, sentStatus)
  197. }
  198. // BatchHandler provides the batch api
  199. func BatchHandler(ctx *context.Context) {
  200. if !setting.LFS.StartServer {
  201. writeStatus(ctx, 404)
  202. return
  203. }
  204. if !MetaMatcher(ctx.Req) {
  205. writeStatus(ctx, 400)
  206. return
  207. }
  208. bv := unpackbatch(ctx)
  209. var responseObjects []*Representation
  210. // Create a response object
  211. for _, object := range bv.Objects {
  212. repositoryString := object.User + "/" + object.Repo
  213. repository, err := models.GetRepositoryByRef(repositoryString)
  214. if err != nil {
  215. log.Debug("Could not find repository: %s - %s", repositoryString, err)
  216. writeStatus(ctx, 404)
  217. return
  218. }
  219. requireWrite := false
  220. if bv.Operation == "upload" {
  221. requireWrite = true
  222. }
  223. if !authenticate(ctx, repository, object.Authorization, requireWrite) {
  224. requireAuth(ctx)
  225. return
  226. }
  227. meta, err := models.GetLFSMetaObjectByOid(object.Oid)
  228. contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
  229. if err == nil && contentStore.Exists(meta) { // Object is found and exists
  230. responseObjects = append(responseObjects, Represent(object, meta, true, false))
  231. continue
  232. }
  233. // Object is not found
  234. meta, err = models.NewLFSMetaObject(&models.LFSMetaObject{Oid: object.Oid, Size: object.Size, RepositoryID: repository.ID})
  235. if err == nil {
  236. responseObjects = append(responseObjects, Represent(object, meta, meta.Existing, true))
  237. }
  238. }
  239. ctx.Resp.Header().Set("Content-Type", metaMediaType)
  240. respobj := &BatchResponse{Objects: responseObjects}
  241. enc := json.NewEncoder(ctx.Resp)
  242. enc.Encode(respobj)
  243. logRequest(ctx.Req, 200)
  244. }
  245. // PutHandler receives data from the client and puts it into the content store
  246. func PutHandler(ctx *context.Context) {
  247. rv := unpack(ctx)
  248. meta, err := models.GetLFSMetaObjectByOid(rv.Oid)
  249. if err != nil {
  250. writeStatus(ctx, 404)
  251. return
  252. }
  253. repository, err := models.GetRepositoryByID(meta.RepositoryID)
  254. if err != nil {
  255. writeStatus(ctx, 404)
  256. return
  257. }
  258. if !authenticate(ctx, repository, rv.Authorization, true) {
  259. requireAuth(ctx)
  260. return
  261. }
  262. contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
  263. if err := contentStore.Put(meta, ctx.Req.Body().ReadCloser()); err != nil {
  264. models.RemoveLFSMetaObjectByOid(rv.Oid)
  265. ctx.Resp.WriteHeader(500)
  266. fmt.Fprintf(ctx.Resp, `{"message":"%s"}`, err)
  267. return
  268. }
  269. logRequest(ctx.Req, 200)
  270. }
  271. // Represent takes a RequestVars and Meta and turns it into a Representation suitable
  272. // for json encoding
  273. func Represent(rv *RequestVars, meta *models.LFSMetaObject, download, upload bool) *Representation {
  274. rep := &Representation{
  275. Oid: meta.Oid,
  276. Size: meta.Size,
  277. Actions: make(map[string]*link),
  278. }
  279. header := make(map[string]string)
  280. header["Accept"] = contentMediaType
  281. if rv.Authorization == "" {
  282. //https://github.com/github/git-lfs/issues/1088
  283. header["Authorization"] = "Authorization: Basic dummy"
  284. } else {
  285. header["Authorization"] = rv.Authorization
  286. }
  287. if download {
  288. rep.Actions["download"] = &link{Href: rv.ObjectLink(), Header: header}
  289. }
  290. if upload {
  291. rep.Actions["upload"] = &link{Href: rv.ObjectLink(), Header: header}
  292. }
  293. return rep
  294. }
  295. // ContentMatcher provides a mux.MatcherFunc that only allows requests that contain
  296. // an Accept header with the contentMediaType
  297. func ContentMatcher(r macaron.Request) bool {
  298. mediaParts := strings.Split(r.Header.Get("Accept"), ";")
  299. mt := mediaParts[0]
  300. return mt == contentMediaType
  301. }
  302. // MetaMatcher provides a mux.MatcherFunc that only allows requests that contain
  303. // an Accept header with the metaMediaType
  304. func MetaMatcher(r macaron.Request) bool {
  305. mediaParts := strings.Split(r.Header.Get("Accept"), ";")
  306. mt := mediaParts[0]
  307. return mt == metaMediaType
  308. }
  309. func unpack(ctx *context.Context) *RequestVars {
  310. r := ctx.Req
  311. rv := &RequestVars{
  312. User: ctx.Params("username"),
  313. Repo: strings.TrimSuffix(ctx.Params("reponame"), ".git"),
  314. Oid: ctx.Params("oid"),
  315. Authorization: r.Header.Get("Authorization"),
  316. }
  317. if r.Method == "POST" { // Maybe also check if +json
  318. var p RequestVars
  319. dec := json.NewDecoder(r.Body().ReadCloser())
  320. err := dec.Decode(&p)
  321. if err != nil {
  322. return rv
  323. }
  324. rv.Oid = p.Oid
  325. rv.Size = p.Size
  326. }
  327. return rv
  328. }
  329. // TODO cheap hack, unify with unpack
  330. func unpackbatch(ctx *context.Context) *BatchVars {
  331. r := ctx.Req
  332. var bv BatchVars
  333. dec := json.NewDecoder(r.Body().ReadCloser())
  334. err := dec.Decode(&bv)
  335. if err != nil {
  336. return &bv
  337. }
  338. for i := 0; i < len(bv.Objects); i++ {
  339. bv.Objects[i].User = ctx.Params("username")
  340. bv.Objects[i].Repo = strings.TrimSuffix(ctx.Params("reponame"), ".git")
  341. bv.Objects[i].Authorization = r.Header.Get("Authorization")
  342. }
  343. return &bv
  344. }
  345. func writeStatus(ctx *context.Context, status int) {
  346. message := http.StatusText(status)
  347. mediaParts := strings.Split(ctx.Req.Header.Get("Accept"), ";")
  348. mt := mediaParts[0]
  349. if strings.HasSuffix(mt, "+json") {
  350. message = `{"message":"` + message + `"}`
  351. }
  352. ctx.Resp.WriteHeader(status)
  353. fmt.Fprint(ctx.Resp, message)
  354. logRequest(ctx.Req, status)
  355. }
  356. func logRequest(r macaron.Request, status int) {
  357. log.Debug("LFS request - Method: %s, URL: %s, Status %d", r.Method, r.URL, status)
  358. }
  359. // authenticate uses the authorization string to determine whether
  360. // or not to proceed. This server assumes an HTTP Basic auth format.
  361. func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool {
  362. accessMode := models.AccessModeRead
  363. if requireWrite {
  364. accessMode = models.AccessModeWrite
  365. }
  366. if !repository.IsPrivate && !requireWrite {
  367. return true
  368. }
  369. if ctx.IsSigned {
  370. accessCheck, _ := models.HasAccess(ctx.User, repository, accessMode)
  371. return accessCheck
  372. }
  373. if authorization == "" {
  374. return false
  375. }
  376. if authenticateToken(repository, authorization, requireWrite) {
  377. return true
  378. }
  379. if !strings.HasPrefix(authorization, "Basic ") {
  380. return false
  381. }
  382. c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic "))
  383. if err != nil {
  384. return false
  385. }
  386. cs := string(c)
  387. i := strings.IndexByte(cs, ':')
  388. if i < 0 {
  389. return false
  390. }
  391. user, password := cs[:i], cs[i+1:]
  392. userModel, err := models.GetUserByName(user)
  393. if err != nil {
  394. return false
  395. }
  396. if !userModel.ValidatePassword(password) {
  397. return false
  398. }
  399. accessCheck, _ := models.HasAccess(userModel, repository, accessMode)
  400. return accessCheck
  401. }
  402. func authenticateToken(repository *models.Repository, authorization string, requireWrite bool) bool {
  403. if !strings.HasPrefix(authorization, "Bearer ") {
  404. return false
  405. }
  406. token, err := jwt.Parse(authorization[7:], func(t *jwt.Token) (interface{}, error) {
  407. if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
  408. return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
  409. }
  410. return setting.LFS.JWTSecretBytes, nil
  411. })
  412. if err != nil {
  413. return false
  414. }
  415. claims, claimsOk := token.Claims.(jwt.MapClaims)
  416. if !token.Valid || !claimsOk {
  417. return false
  418. }
  419. opStr, ok := claims["op"].(string)
  420. if !ok {
  421. return false
  422. }
  423. if requireWrite && opStr != "upload" {
  424. return false
  425. }
  426. repoID, ok := claims["repo"].(float64)
  427. if !ok {
  428. return false
  429. }
  430. if repository.ID != int64(repoID) {
  431. return false
  432. }
  433. return true
  434. }
  435. func requireAuth(ctx *context.Context) {
  436. ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
  437. writeStatus(ctx, 401)
  438. }