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.

http.go 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. // Copyright 2014 The Gogs Authors. All rights reserved.
  2. // Copyright 2019 The Gitea Authors. All rights reserved.
  3. // Use of this source code is governed by a MIT-style
  4. // license that can be found in the LICENSE file.
  5. package repo
  6. import (
  7. "bytes"
  8. "compress/gzip"
  9. "fmt"
  10. "net/http"
  11. "os"
  12. "os/exec"
  13. "path"
  14. "regexp"
  15. "strconv"
  16. "strings"
  17. "sync"
  18. "time"
  19. "code.gitea.io/gitea/models"
  20. "code.gitea.io/gitea/models/auth"
  21. "code.gitea.io/gitea/models/db"
  22. "code.gitea.io/gitea/models/perm"
  23. repo_model "code.gitea.io/gitea/models/repo"
  24. "code.gitea.io/gitea/models/unit"
  25. user_model "code.gitea.io/gitea/models/user"
  26. "code.gitea.io/gitea/modules/context"
  27. "code.gitea.io/gitea/modules/git"
  28. "code.gitea.io/gitea/modules/log"
  29. "code.gitea.io/gitea/modules/process"
  30. "code.gitea.io/gitea/modules/setting"
  31. "code.gitea.io/gitea/modules/structs"
  32. "code.gitea.io/gitea/modules/util"
  33. repo_service "code.gitea.io/gitea/services/repository"
  34. )
  35. // httpBase implementation git smart HTTP protocol
  36. func httpBase(ctx *context.Context) (h *serviceHandler) {
  37. if setting.Repository.DisableHTTPGit {
  38. ctx.Resp.WriteHeader(http.StatusForbidden)
  39. _, err := ctx.Resp.Write([]byte("Interacting with repositories by HTTP protocol is not allowed"))
  40. if err != nil {
  41. log.Error(err.Error())
  42. }
  43. return
  44. }
  45. if len(setting.Repository.AccessControlAllowOrigin) > 0 {
  46. allowedOrigin := setting.Repository.AccessControlAllowOrigin
  47. // Set CORS headers for browser-based git clients
  48. ctx.Resp.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
  49. ctx.Resp.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent")
  50. // Handle preflight OPTIONS request
  51. if ctx.Req.Method == "OPTIONS" {
  52. if allowedOrigin == "*" {
  53. ctx.Status(http.StatusOK)
  54. } else if allowedOrigin == "null" {
  55. ctx.Status(http.StatusForbidden)
  56. } else {
  57. origin := ctx.Req.Header.Get("Origin")
  58. if len(origin) > 0 && origin == allowedOrigin {
  59. ctx.Status(http.StatusOK)
  60. } else {
  61. ctx.Status(http.StatusForbidden)
  62. }
  63. }
  64. return
  65. }
  66. }
  67. username := ctx.Params(":username")
  68. reponame := strings.TrimSuffix(ctx.Params(":reponame"), ".git")
  69. if ctx.FormString("go-get") == "1" {
  70. context.EarlyResponseForGoGetMeta(ctx)
  71. return
  72. }
  73. var isPull, receivePack bool
  74. service := ctx.FormString("service")
  75. if service == "git-receive-pack" ||
  76. strings.HasSuffix(ctx.Req.URL.Path, "git-receive-pack") {
  77. isPull = false
  78. receivePack = true
  79. } else if service == "git-upload-pack" ||
  80. strings.HasSuffix(ctx.Req.URL.Path, "git-upload-pack") {
  81. isPull = true
  82. } else if service == "git-upload-archive" ||
  83. strings.HasSuffix(ctx.Req.URL.Path, "git-upload-archive") {
  84. isPull = true
  85. } else {
  86. isPull = ctx.Req.Method == "GET"
  87. }
  88. var accessMode perm.AccessMode
  89. if isPull {
  90. accessMode = perm.AccessModeRead
  91. } else {
  92. accessMode = perm.AccessModeWrite
  93. }
  94. isWiki := false
  95. var unitType = unit.TypeCode
  96. var wikiRepoName string
  97. if strings.HasSuffix(reponame, ".wiki") {
  98. isWiki = true
  99. unitType = unit.TypeWiki
  100. wikiRepoName = reponame
  101. reponame = reponame[:len(reponame)-5]
  102. }
  103. owner, err := user_model.GetUserByName(username)
  104. if err != nil {
  105. if user_model.IsErrUserNotExist(err) {
  106. if redirectUserID, err := user_model.LookupUserRedirect(username); err == nil {
  107. context.RedirectToUser(ctx, username, redirectUserID)
  108. } else {
  109. ctx.NotFound(fmt.Sprintf("User %s does not exist", username), nil)
  110. }
  111. } else {
  112. ctx.ServerError("GetUserByName", err)
  113. }
  114. return
  115. }
  116. if !owner.IsOrganization() && !owner.IsActive {
  117. ctx.PlainText(http.StatusForbidden, "Repository cannot be accessed. You cannot push or open issues/pull-requests.")
  118. return
  119. }
  120. repoExist := true
  121. repo, err := repo_model.GetRepositoryByName(owner.ID, reponame)
  122. if err != nil {
  123. if repo_model.IsErrRepoNotExist(err) {
  124. if redirectRepoID, err := repo_model.LookupRedirect(owner.ID, reponame); err == nil {
  125. context.RedirectToRepo(ctx, redirectRepoID)
  126. return
  127. }
  128. repoExist = false
  129. } else {
  130. ctx.ServerError("GetRepositoryByName", err)
  131. return
  132. }
  133. }
  134. // Don't allow pushing if the repo is archived
  135. if repoExist && repo.IsArchived && !isPull {
  136. ctx.PlainText(http.StatusForbidden, "This repo is archived. You can view files and clone it, but cannot push or open issues/pull-requests.")
  137. return
  138. }
  139. // Only public pull don't need auth.
  140. isPublicPull := repoExist && !repo.IsPrivate && isPull
  141. var (
  142. askAuth = !isPublicPull || setting.Service.RequireSignInView
  143. environ []string
  144. )
  145. // don't allow anonymous pulls if organization is not public
  146. if isPublicPull {
  147. if err := repo.GetOwner(db.DefaultContext); err != nil {
  148. ctx.ServerError("GetOwner", err)
  149. return
  150. }
  151. askAuth = askAuth || (repo.Owner.Visibility != structs.VisibleTypePublic)
  152. }
  153. // check access
  154. if askAuth {
  155. // rely on the results of Contexter
  156. if !ctx.IsSigned {
  157. // TODO: support digit auth - which would be Authorization header with digit
  158. ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=\".\"")
  159. ctx.Error(http.StatusUnauthorized)
  160. return
  161. }
  162. if ctx.IsBasicAuth && ctx.Data["IsApiToken"] != true {
  163. _, err = auth.GetTwoFactorByUID(ctx.User.ID)
  164. if err == nil {
  165. // TODO: This response should be changed to "invalid credentials" for security reasons once the expectation behind it (creating an app token to authenticate) is properly documented
  166. ctx.PlainText(http.StatusUnauthorized, "Users with two-factor authentication enabled cannot perform HTTP/HTTPS operations via plain username and password. Please create and use a personal access token on the user settings page")
  167. return
  168. } else if !auth.IsErrTwoFactorNotEnrolled(err) {
  169. ctx.ServerError("IsErrTwoFactorNotEnrolled", err)
  170. return
  171. }
  172. }
  173. if !ctx.User.IsActive || ctx.User.ProhibitLogin {
  174. ctx.PlainText(http.StatusForbidden, "Your account is disabled.")
  175. return
  176. }
  177. if repoExist {
  178. p, err := models.GetUserRepoPermission(repo, ctx.User)
  179. if err != nil {
  180. ctx.ServerError("GetUserRepoPermission", err)
  181. return
  182. }
  183. // Because of special ref "refs/for" .. , need delay write permission check
  184. if git.SupportProcReceive {
  185. accessMode = perm.AccessModeRead
  186. }
  187. if !p.CanAccess(accessMode, unitType) {
  188. ctx.PlainText(http.StatusForbidden, "User permission denied")
  189. return
  190. }
  191. if !isPull && repo.IsMirror {
  192. ctx.PlainText(http.StatusForbidden, "mirror repository is read-only")
  193. return
  194. }
  195. }
  196. environ = []string{
  197. models.EnvRepoUsername + "=" + username,
  198. models.EnvRepoName + "=" + reponame,
  199. models.EnvPusherName + "=" + ctx.User.Name,
  200. models.EnvPusherID + fmt.Sprintf("=%d", ctx.User.ID),
  201. models.EnvIsDeployKey + "=false",
  202. models.EnvAppURL + "=" + setting.AppURL,
  203. }
  204. if !ctx.User.KeepEmailPrivate {
  205. environ = append(environ, models.EnvPusherEmail+"="+ctx.User.Email)
  206. }
  207. if isWiki {
  208. environ = append(environ, models.EnvRepoIsWiki+"=true")
  209. } else {
  210. environ = append(environ, models.EnvRepoIsWiki+"=false")
  211. }
  212. }
  213. if !repoExist {
  214. if !receivePack {
  215. ctx.PlainText(http.StatusNotFound, "Repository not found")
  216. return
  217. }
  218. if isWiki { // you cannot send wiki operation before create the repository
  219. ctx.PlainText(http.StatusNotFound, "Repository not found")
  220. return
  221. }
  222. if owner.IsOrganization() && !setting.Repository.EnablePushCreateOrg {
  223. ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for organizations.")
  224. return
  225. }
  226. if !owner.IsOrganization() && !setting.Repository.EnablePushCreateUser {
  227. ctx.PlainText(http.StatusForbidden, "Push to create is not enabled for users.")
  228. return
  229. }
  230. // Return dummy payload if GET receive-pack
  231. if ctx.Req.Method == http.MethodGet {
  232. dummyInfoRefs(ctx)
  233. return
  234. }
  235. repo, err = repo_service.PushCreateRepo(ctx.User, owner, reponame)
  236. if err != nil {
  237. log.Error("pushCreateRepo: %v", err)
  238. ctx.Status(http.StatusNotFound)
  239. return
  240. }
  241. }
  242. if isWiki {
  243. // Ensure the wiki is enabled before we allow access to it
  244. if _, err := repo.GetUnit(unit.TypeWiki); err != nil {
  245. if repo_model.IsErrUnitTypeNotExist(err) {
  246. ctx.PlainText(http.StatusForbidden, "repository wiki is disabled")
  247. return
  248. }
  249. log.Error("Failed to get the wiki unit in %-v Error: %v", repo, err)
  250. ctx.ServerError("GetUnit(UnitTypeWiki) for "+repo.FullName(), err)
  251. return
  252. }
  253. }
  254. environ = append(environ, models.EnvRepoID+fmt.Sprintf("=%d", repo.ID))
  255. w := ctx.Resp
  256. r := ctx.Req
  257. cfg := &serviceConfig{
  258. UploadPack: true,
  259. ReceivePack: true,
  260. Env: environ,
  261. }
  262. r.URL.Path = strings.ToLower(r.URL.Path) // blue: In case some repo name has upper case name
  263. dir := repo_model.RepoPath(username, reponame)
  264. if isWiki {
  265. dir = repo_model.RepoPath(username, wikiRepoName)
  266. }
  267. return &serviceHandler{cfg, w, r, dir, cfg.Env}
  268. }
  269. var (
  270. infoRefsCache []byte
  271. infoRefsOnce sync.Once
  272. )
  273. func dummyInfoRefs(ctx *context.Context) {
  274. infoRefsOnce.Do(func() {
  275. tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-info-refs-cache")
  276. if err != nil {
  277. log.Error("Failed to create temp dir for git-receive-pack cache: %v", err)
  278. return
  279. }
  280. defer func() {
  281. if err := util.RemoveAll(tmpDir); err != nil {
  282. log.Error("RemoveAll: %v", err)
  283. }
  284. }()
  285. if err := git.InitRepository(tmpDir, true); err != nil {
  286. log.Error("Failed to init bare repo for git-receive-pack cache: %v", err)
  287. return
  288. }
  289. refs, err := git.NewCommand("receive-pack", "--stateless-rpc", "--advertise-refs", ".").RunInDirBytes(tmpDir)
  290. if err != nil {
  291. log.Error(fmt.Sprintf("%v - %s", err, string(refs)))
  292. }
  293. log.Debug("populating infoRefsCache: \n%s", string(refs))
  294. infoRefsCache = refs
  295. })
  296. ctx.RespHeader().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
  297. ctx.RespHeader().Set("Pragma", "no-cache")
  298. ctx.RespHeader().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
  299. ctx.RespHeader().Set("Content-Type", "application/x-git-receive-pack-advertisement")
  300. _, _ = ctx.Write(packetWrite("# service=git-receive-pack\n"))
  301. _, _ = ctx.Write([]byte("0000"))
  302. _, _ = ctx.Write(infoRefsCache)
  303. }
  304. type serviceConfig struct {
  305. UploadPack bool
  306. ReceivePack bool
  307. Env []string
  308. }
  309. type serviceHandler struct {
  310. cfg *serviceConfig
  311. w http.ResponseWriter
  312. r *http.Request
  313. dir string
  314. environ []string
  315. }
  316. func (h *serviceHandler) setHeaderNoCache() {
  317. h.w.Header().Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT")
  318. h.w.Header().Set("Pragma", "no-cache")
  319. h.w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
  320. }
  321. func (h *serviceHandler) setHeaderCacheForever() {
  322. now := time.Now().Unix()
  323. expires := now + 31536000
  324. h.w.Header().Set("Date", fmt.Sprintf("%d", now))
  325. h.w.Header().Set("Expires", fmt.Sprintf("%d", expires))
  326. h.w.Header().Set("Cache-Control", "public, max-age=31536000")
  327. }
  328. func containsParentDirectorySeparator(v string) bool {
  329. if !strings.Contains(v, "..") {
  330. return false
  331. }
  332. for _, ent := range strings.FieldsFunc(v, isSlashRune) {
  333. if ent == ".." {
  334. return true
  335. }
  336. }
  337. return false
  338. }
  339. func isSlashRune(r rune) bool { return r == '/' || r == '\\' }
  340. func (h *serviceHandler) sendFile(contentType, file string) {
  341. if containsParentDirectorySeparator(file) {
  342. log.Error("request file path contains invalid path: %v", file)
  343. h.w.WriteHeader(http.StatusBadRequest)
  344. return
  345. }
  346. reqFile := path.Join(h.dir, file)
  347. fi, err := os.Stat(reqFile)
  348. if os.IsNotExist(err) {
  349. h.w.WriteHeader(http.StatusNotFound)
  350. return
  351. }
  352. h.w.Header().Set("Content-Type", contentType)
  353. h.w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
  354. h.w.Header().Set("Last-Modified", fi.ModTime().Format(http.TimeFormat))
  355. http.ServeFile(h.w, h.r, reqFile)
  356. }
  357. // one or more key=value pairs separated by colons
  358. var safeGitProtocolHeader = regexp.MustCompile(`^[0-9a-zA-Z]+=[0-9a-zA-Z]+(:[0-9a-zA-Z]+=[0-9a-zA-Z]+)*$`)
  359. func getGitConfig(option, dir string) string {
  360. out, err := git.NewCommand("config", option).RunInDir(dir)
  361. if err != nil {
  362. log.Error("%v - %s", err, out)
  363. }
  364. return out[0 : len(out)-1]
  365. }
  366. func getConfigSetting(service, dir string) bool {
  367. service = strings.ReplaceAll(service, "-", "")
  368. setting := getGitConfig("http."+service, dir)
  369. if service == "uploadpack" {
  370. return setting != "false"
  371. }
  372. return setting == "true"
  373. }
  374. func hasAccess(service string, h serviceHandler, checkContentType bool) bool {
  375. if checkContentType {
  376. if h.r.Header.Get("Content-Type") != fmt.Sprintf("application/x-git-%s-request", service) {
  377. return false
  378. }
  379. }
  380. if !(service == "upload-pack" || service == "receive-pack") {
  381. return false
  382. }
  383. if service == "receive-pack" {
  384. return h.cfg.ReceivePack
  385. }
  386. if service == "upload-pack" {
  387. return h.cfg.UploadPack
  388. }
  389. return getConfigSetting(service, h.dir)
  390. }
  391. func serviceRPC(h serviceHandler, service string) {
  392. defer func() {
  393. if err := h.r.Body.Close(); err != nil {
  394. log.Error("serviceRPC: Close: %v", err)
  395. }
  396. }()
  397. if !hasAccess(service, h, true) {
  398. h.w.WriteHeader(http.StatusUnauthorized)
  399. return
  400. }
  401. h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-result", service))
  402. var err error
  403. var reqBody = h.r.Body
  404. // Handle GZIP.
  405. if h.r.Header.Get("Content-Encoding") == "gzip" {
  406. reqBody, err = gzip.NewReader(reqBody)
  407. if err != nil {
  408. log.Error("Fail to create gzip reader: %v", err)
  409. h.w.WriteHeader(http.StatusInternalServerError)
  410. return
  411. }
  412. }
  413. // set this for allow pre-receive and post-receive execute
  414. h.environ = append(h.environ, "SSH_ORIGINAL_COMMAND="+service)
  415. if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) {
  416. h.environ = append(h.environ, "GIT_PROTOCOL="+protocol)
  417. }
  418. // ctx, cancel := gocontext.WithCancel(git.DefaultContext)
  419. ctx, _, finished := process.GetManager().AddContext(h.r.Context(), fmt.Sprintf("%s %s %s [repo_path: %s]", git.GitExecutable, service, "--stateless-rpc", h.dir))
  420. defer finished()
  421. var stderr bytes.Buffer
  422. cmd := exec.CommandContext(ctx, git.GitExecutable, service, "--stateless-rpc", h.dir)
  423. cmd.Dir = h.dir
  424. cmd.Env = append(os.Environ(), h.environ...)
  425. cmd.Stdout = h.w
  426. cmd.Stdin = reqBody
  427. cmd.Stderr = &stderr
  428. if err := cmd.Run(); err != nil {
  429. log.Error("Fail to serve RPC(%s) in %s: %v - %s", service, h.dir, err, stderr.String())
  430. return
  431. }
  432. }
  433. // ServiceUploadPack implements Git Smart HTTP protocol
  434. func ServiceUploadPack(ctx *context.Context) {
  435. h := httpBase(ctx)
  436. if h != nil {
  437. serviceRPC(*h, "upload-pack")
  438. }
  439. }
  440. // ServiceReceivePack implements Git Smart HTTP protocol
  441. func ServiceReceivePack(ctx *context.Context) {
  442. h := httpBase(ctx)
  443. if h != nil {
  444. serviceRPC(*h, "receive-pack")
  445. }
  446. }
  447. func getServiceType(r *http.Request) string {
  448. serviceType := r.FormValue("service")
  449. if !strings.HasPrefix(serviceType, "git-") {
  450. return ""
  451. }
  452. return strings.Replace(serviceType, "git-", "", 1)
  453. }
  454. func updateServerInfo(dir string) []byte {
  455. out, err := git.NewCommand("update-server-info").RunInDirBytes(dir)
  456. if err != nil {
  457. log.Error(fmt.Sprintf("%v - %s", err, string(out)))
  458. }
  459. return out
  460. }
  461. func packetWrite(str string) []byte {
  462. s := strconv.FormatInt(int64(len(str)+4), 16)
  463. if len(s)%4 != 0 {
  464. s = strings.Repeat("0", 4-len(s)%4) + s
  465. }
  466. return []byte(s + str)
  467. }
  468. // GetInfoRefs implements Git dumb HTTP
  469. func GetInfoRefs(ctx *context.Context) {
  470. h := httpBase(ctx)
  471. if h == nil {
  472. return
  473. }
  474. h.setHeaderNoCache()
  475. if hasAccess(getServiceType(h.r), *h, false) {
  476. service := getServiceType(h.r)
  477. if protocol := h.r.Header.Get("Git-Protocol"); protocol != "" && safeGitProtocolHeader.MatchString(protocol) {
  478. h.environ = append(h.environ, "GIT_PROTOCOL="+protocol)
  479. }
  480. h.environ = append(os.Environ(), h.environ...)
  481. refs, err := git.NewCommand(service, "--stateless-rpc", "--advertise-refs", ".").RunInDirTimeoutEnv(h.environ, -1, h.dir)
  482. if err != nil {
  483. log.Error(fmt.Sprintf("%v - %s", err, string(refs)))
  484. }
  485. h.w.Header().Set("Content-Type", fmt.Sprintf("application/x-git-%s-advertisement", service))
  486. h.w.WriteHeader(http.StatusOK)
  487. _, _ = h.w.Write(packetWrite("# service=git-" + service + "\n"))
  488. _, _ = h.w.Write([]byte("0000"))
  489. _, _ = h.w.Write(refs)
  490. } else {
  491. updateServerInfo(h.dir)
  492. h.sendFile("text/plain; charset=utf-8", "info/refs")
  493. }
  494. }
  495. // GetTextFile implements Git dumb HTTP
  496. func GetTextFile(p string) func(*context.Context) {
  497. return func(ctx *context.Context) {
  498. h := httpBase(ctx)
  499. if h != nil {
  500. h.setHeaderNoCache()
  501. file := ctx.Params("file")
  502. if file != "" {
  503. h.sendFile("text/plain", "objects/info/"+file)
  504. } else {
  505. h.sendFile("text/plain", p)
  506. }
  507. }
  508. }
  509. }
  510. // GetInfoPacks implements Git dumb HTTP
  511. func GetInfoPacks(ctx *context.Context) {
  512. h := httpBase(ctx)
  513. if h != nil {
  514. h.setHeaderCacheForever()
  515. h.sendFile("text/plain; charset=utf-8", "objects/info/packs")
  516. }
  517. }
  518. // GetLooseObject implements Git dumb HTTP
  519. func GetLooseObject(ctx *context.Context) {
  520. h := httpBase(ctx)
  521. if h != nil {
  522. h.setHeaderCacheForever()
  523. h.sendFile("application/x-git-loose-object", fmt.Sprintf("objects/%s/%s",
  524. ctx.Params("head"), ctx.Params("hash")))
  525. }
  526. }
  527. // GetPackFile implements Git dumb HTTP
  528. func GetPackFile(ctx *context.Context) {
  529. h := httpBase(ctx)
  530. if h != nil {
  531. h.setHeaderCacheForever()
  532. h.sendFile("application/x-git-packed-objects", "objects/pack/pack-"+ctx.Params("file")+".pack")
  533. }
  534. }
  535. // GetIdxFile implements Git dumb HTTP
  536. func GetIdxFile(ctx *context.Context) {
  537. h := httpBase(ctx)
  538. if h != nil {
  539. h.setHeaderCacheForever()
  540. h.sendFile("application/x-git-packed-objects-toc", "objects/pack/pack-"+ctx.Params("file")+".idx")
  541. }
  542. }