385dc6a992
This PR reworked the Find pointer files feature in Settings -> LFS page. When a LFS object is missing from database but exists in LFS content store, admin can associate it to the repository by clicking the Associate button. This PR is not perfect (because the LFS module itself should be improved too), it's just a nice-to-have feature to help users recover their LFS repositories (eg: database was lost / table was truncated)
545 lines
15 KiB
Go
545 lines
15 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// Use of this source code is governed by a MIT-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package repo
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
gotemplate "html/template"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"code.gitea.io/gitea/models"
|
|
"code.gitea.io/gitea/modules/base"
|
|
"code.gitea.io/gitea/modules/charset"
|
|
"code.gitea.io/gitea/modules/context"
|
|
"code.gitea.io/gitea/modules/git"
|
|
"code.gitea.io/gitea/modules/git/pipeline"
|
|
"code.gitea.io/gitea/modules/lfs"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/storage"
|
|
"code.gitea.io/gitea/modules/typesniffer"
|
|
"code.gitea.io/gitea/modules/util"
|
|
)
|
|
|
|
const (
|
|
tplSettingsLFS base.TplName = "repo/settings/lfs"
|
|
tplSettingsLFSLocks base.TplName = "repo/settings/lfs_locks"
|
|
tplSettingsLFSFile base.TplName = "repo/settings/lfs_file"
|
|
tplSettingsLFSFileFind base.TplName = "repo/settings/lfs_file_find"
|
|
tplSettingsLFSPointers base.TplName = "repo/settings/lfs_pointers"
|
|
)
|
|
|
|
// LFSFiles shows a repository's LFS files
|
|
func LFSFiles(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSFiles", nil)
|
|
return
|
|
}
|
|
page := ctx.FormInt("page")
|
|
if page <= 1 {
|
|
page = 1
|
|
}
|
|
total, err := models.CountLFSMetaObjects(ctx.Repo.Repository.ID)
|
|
if err != nil {
|
|
ctx.ServerError("LFSFiles", err)
|
|
return
|
|
}
|
|
ctx.Data["Total"] = total
|
|
|
|
pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5)
|
|
ctx.Data["Title"] = ctx.Tr("repo.settings.lfs")
|
|
ctx.Data["PageIsSettingsLFS"] = true
|
|
lfsMetaObjects, err := models.GetLFSMetaObjects(ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum)
|
|
if err != nil {
|
|
ctx.ServerError("LFSFiles", err)
|
|
return
|
|
}
|
|
ctx.Data["LFSFiles"] = lfsMetaObjects
|
|
ctx.Data["Page"] = pager
|
|
ctx.HTML(http.StatusOK, tplSettingsLFS)
|
|
}
|
|
|
|
// LFSLocks shows a repository's LFS locks
|
|
func LFSLocks(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSLocks", nil)
|
|
return
|
|
}
|
|
ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
|
|
|
|
page := ctx.FormInt("page")
|
|
if page <= 1 {
|
|
page = 1
|
|
}
|
|
total, err := models.CountLFSLockByRepoID(ctx.Repo.Repository.ID)
|
|
if err != nil {
|
|
ctx.ServerError("LFSLocks", err)
|
|
return
|
|
}
|
|
ctx.Data["Total"] = total
|
|
|
|
pager := context.NewPagination(int(total), setting.UI.ExplorePagingNum, page, 5)
|
|
ctx.Data["Title"] = ctx.Tr("repo.settings.lfs_locks")
|
|
ctx.Data["PageIsSettingsLFS"] = true
|
|
lfsLocks, err := models.GetLFSLockByRepoID(ctx.Repo.Repository.ID, pager.Paginater.Current(), setting.UI.ExplorePagingNum)
|
|
if err != nil {
|
|
ctx.ServerError("LFSLocks", err)
|
|
return
|
|
}
|
|
ctx.Data["LFSLocks"] = lfsLocks
|
|
|
|
if len(lfsLocks) == 0 {
|
|
ctx.Data["Page"] = pager
|
|
ctx.HTML(http.StatusOK, tplSettingsLFSLocks)
|
|
return
|
|
}
|
|
|
|
// Clone base repo.
|
|
tmpBasePath, err := models.CreateTemporaryPath("locks")
|
|
if err != nil {
|
|
log.Error("Failed to create temporary path: %v", err)
|
|
ctx.ServerError("LFSLocks", err)
|
|
return
|
|
}
|
|
defer func() {
|
|
if err := models.RemoveTemporaryPath(tmpBasePath); err != nil {
|
|
log.Error("LFSLocks: RemoveTemporaryPath: %v", err)
|
|
}
|
|
}()
|
|
|
|
if err := git.Clone(ctx.Repo.Repository.RepoPath(), tmpBasePath, git.CloneRepoOptions{
|
|
Bare: true,
|
|
Shared: true,
|
|
}); err != nil {
|
|
log.Error("Failed to clone repository: %s (%v)", ctx.Repo.Repository.FullName(), err)
|
|
ctx.ServerError("LFSLocks", fmt.Errorf("Failed to clone repository: %s (%v)", ctx.Repo.Repository.FullName(), err))
|
|
return
|
|
}
|
|
|
|
gitRepo, err := git.OpenRepository(tmpBasePath)
|
|
if err != nil {
|
|
log.Error("Unable to open temporary repository: %s (%v)", tmpBasePath, err)
|
|
ctx.ServerError("LFSLocks", fmt.Errorf("Failed to open new temporary repository in: %s %v", tmpBasePath, err))
|
|
return
|
|
}
|
|
defer gitRepo.Close()
|
|
|
|
filenames := make([]string, len(lfsLocks))
|
|
|
|
for i, lock := range lfsLocks {
|
|
filenames[i] = lock.Path
|
|
}
|
|
|
|
if err := gitRepo.ReadTreeToIndex(ctx.Repo.Repository.DefaultBranch); err != nil {
|
|
log.Error("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err)
|
|
ctx.ServerError("LFSLocks", fmt.Errorf("Unable to read the default branch to the index: %s (%v)", ctx.Repo.Repository.DefaultBranch, err))
|
|
return
|
|
}
|
|
|
|
name2attribute2info, err := gitRepo.CheckAttribute(git.CheckAttributeOpts{
|
|
Attributes: []string{"lockable"},
|
|
Filenames: filenames,
|
|
CachedOnly: true,
|
|
})
|
|
if err != nil {
|
|
log.Error("Unable to check attributes in %s (%v)", tmpBasePath, err)
|
|
ctx.ServerError("LFSLocks", err)
|
|
return
|
|
}
|
|
|
|
lockables := make([]bool, len(lfsLocks))
|
|
for i, lock := range lfsLocks {
|
|
attribute2info, has := name2attribute2info[lock.Path]
|
|
if !has {
|
|
continue
|
|
}
|
|
if attribute2info["lockable"] != "set" {
|
|
continue
|
|
}
|
|
lockables[i] = true
|
|
}
|
|
ctx.Data["Lockables"] = lockables
|
|
|
|
filelist, err := gitRepo.LsFiles(filenames...)
|
|
if err != nil {
|
|
log.Error("Unable to lsfiles in %s (%v)", tmpBasePath, err)
|
|
ctx.ServerError("LFSLocks", err)
|
|
return
|
|
}
|
|
|
|
filemap := make(map[string]bool, len(filelist))
|
|
for _, name := range filelist {
|
|
filemap[name] = true
|
|
}
|
|
|
|
linkable := make([]bool, len(lfsLocks))
|
|
for i, lock := range lfsLocks {
|
|
linkable[i] = filemap[lock.Path]
|
|
}
|
|
ctx.Data["Linkable"] = linkable
|
|
|
|
ctx.Data["Page"] = pager
|
|
ctx.HTML(http.StatusOK, tplSettingsLFSLocks)
|
|
}
|
|
|
|
// LFSLockFile locks a file
|
|
func LFSLockFile(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSLocks", nil)
|
|
return
|
|
}
|
|
originalPath := ctx.FormString("path")
|
|
lockPath := originalPath
|
|
if len(lockPath) == 0 {
|
|
ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath))
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
|
|
return
|
|
}
|
|
if lockPath[len(lockPath)-1] == '/' {
|
|
ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_lock_directory", originalPath))
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
|
|
return
|
|
}
|
|
lockPath = path.Clean("/" + lockPath)[1:]
|
|
if len(lockPath) == 0 {
|
|
ctx.Flash.Error(ctx.Tr("repo.settings.lfs_invalid_locking_path", originalPath))
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
|
|
return
|
|
}
|
|
|
|
_, err := models.CreateLFSLock(ctx.Repo.Repository, &models.LFSLock{
|
|
Path: lockPath,
|
|
OwnerID: ctx.User.ID,
|
|
})
|
|
if err != nil {
|
|
if models.IsErrLFSLockAlreadyExist(err) {
|
|
ctx.Flash.Error(ctx.Tr("repo.settings.lfs_lock_already_exists", originalPath))
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
|
|
return
|
|
}
|
|
ctx.ServerError("LFSLockFile", err)
|
|
return
|
|
}
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
|
|
}
|
|
|
|
// LFSUnlock forcibly unlocks an LFS lock
|
|
func LFSUnlock(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSUnlock", nil)
|
|
return
|
|
}
|
|
_, err := models.DeleteLFSLockByID(ctx.ParamsInt64("lid"), ctx.Repo.Repository, ctx.User, true)
|
|
if err != nil {
|
|
ctx.ServerError("LFSUnlock", err)
|
|
return
|
|
}
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs/locks")
|
|
}
|
|
|
|
// LFSFileGet serves a single LFS file
|
|
func LFSFileGet(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSFileGet", nil)
|
|
return
|
|
}
|
|
ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
|
|
oid := ctx.Params("oid")
|
|
ctx.Data["Title"] = oid
|
|
ctx.Data["PageIsSettingsLFS"] = true
|
|
meta, err := models.GetLFSMetaObjectByOid(ctx.Repo.Repository.ID, oid)
|
|
if err != nil {
|
|
if err == models.ErrLFSObjectNotExist {
|
|
ctx.NotFound("LFSFileGet", nil)
|
|
return
|
|
}
|
|
ctx.ServerError("LFSFileGet", err)
|
|
return
|
|
}
|
|
ctx.Data["LFSFile"] = meta
|
|
dataRc, err := lfs.ReadMetaObject(meta.Pointer)
|
|
if err != nil {
|
|
ctx.ServerError("LFSFileGet", err)
|
|
return
|
|
}
|
|
defer dataRc.Close()
|
|
buf := make([]byte, 1024)
|
|
n, err := util.ReadAtMost(dataRc, buf)
|
|
if err != nil {
|
|
ctx.ServerError("Data", err)
|
|
return
|
|
}
|
|
buf = buf[:n]
|
|
|
|
st := typesniffer.DetectContentType(buf)
|
|
ctx.Data["IsTextFile"] = st.IsText()
|
|
isRepresentableAsText := st.IsRepresentableAsText()
|
|
|
|
fileSize := meta.Size
|
|
ctx.Data["FileSize"] = meta.Size
|
|
ctx.Data["RawFileLink"] = fmt.Sprintf("%s%s/%s.git/info/lfs/objects/%s/%s", setting.AppURL, url.PathEscape(ctx.Repo.Repository.OwnerName), url.PathEscape(ctx.Repo.Repository.Name), url.PathEscape(meta.Oid), "direct")
|
|
switch {
|
|
case isRepresentableAsText:
|
|
if st.IsSvgImage() {
|
|
ctx.Data["IsImageFile"] = true
|
|
}
|
|
|
|
if fileSize >= setting.UI.MaxDisplayFileSize {
|
|
ctx.Data["IsFileTooLarge"] = true
|
|
break
|
|
}
|
|
|
|
rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc))
|
|
|
|
// Building code view blocks with line number on server side.
|
|
fileContent, _ := io.ReadAll(rd)
|
|
|
|
var output bytes.Buffer
|
|
lines := strings.Split(string(fileContent), "\n")
|
|
//Remove blank line at the end of file
|
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
for index, line := range lines {
|
|
line = gotemplate.HTMLEscapeString(line)
|
|
if index != len(lines)-1 {
|
|
line += "\n"
|
|
}
|
|
output.WriteString(fmt.Sprintf(`<li class="L%d" rel="L%d">%s</li>`, index+1, index+1, line))
|
|
}
|
|
ctx.Data["FileContent"] = gotemplate.HTML(output.String())
|
|
|
|
output.Reset()
|
|
for i := 0; i < len(lines); i++ {
|
|
output.WriteString(fmt.Sprintf(`<span id="L%d">%d</span>`, i+1, i+1))
|
|
}
|
|
ctx.Data["LineNums"] = gotemplate.HTML(output.String())
|
|
|
|
case st.IsPDF():
|
|
ctx.Data["IsPDFFile"] = true
|
|
case st.IsVideo():
|
|
ctx.Data["IsVideoFile"] = true
|
|
case st.IsAudio():
|
|
ctx.Data["IsAudioFile"] = true
|
|
case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
|
|
ctx.Data["IsImageFile"] = true
|
|
}
|
|
ctx.HTML(http.StatusOK, tplSettingsLFSFile)
|
|
}
|
|
|
|
// LFSDelete disassociates the provided oid from the repository and if the lfs file is no longer associated with any repositories - deletes it
|
|
func LFSDelete(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSDelete", nil)
|
|
return
|
|
}
|
|
oid := ctx.Params("oid")
|
|
count, err := models.RemoveLFSMetaObjectByOid(ctx.Repo.Repository.ID, oid)
|
|
if err != nil {
|
|
ctx.ServerError("LFSDelete", err)
|
|
return
|
|
}
|
|
// FIXME: Warning: the LFS store is not locked - and can't be locked - there could be a race condition here
|
|
// Please note a similar condition happens in models/repo.go DeleteRepository
|
|
if count == 0 {
|
|
oidPath := path.Join(oid[0:2], oid[2:4], oid[4:])
|
|
err = storage.LFS.Delete(oidPath)
|
|
if err != nil {
|
|
ctx.ServerError("LFSDelete", err)
|
|
return
|
|
}
|
|
}
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs")
|
|
}
|
|
|
|
// LFSFileFind guesses a sha for the provided oid (or uses the provided sha) and then finds the commits that contain this sha
|
|
func LFSFileFind(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSFind", nil)
|
|
return
|
|
}
|
|
oid := ctx.FormString("oid")
|
|
size := ctx.FormInt64("size")
|
|
if len(oid) == 0 || size == 0 {
|
|
ctx.NotFound("LFSFind", nil)
|
|
return
|
|
}
|
|
sha := ctx.FormString("sha")
|
|
ctx.Data["Title"] = oid
|
|
ctx.Data["PageIsSettingsLFS"] = true
|
|
var hash git.SHA1
|
|
if len(sha) == 0 {
|
|
pointer := lfs.Pointer{Oid: oid, Size: size}
|
|
hash = git.ComputeBlobHash([]byte(pointer.StringContent()))
|
|
sha = hash.String()
|
|
} else {
|
|
hash = git.MustIDFromString(sha)
|
|
}
|
|
ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
|
|
ctx.Data["Oid"] = oid
|
|
ctx.Data["Size"] = size
|
|
ctx.Data["SHA"] = sha
|
|
|
|
results, err := pipeline.FindLFSFile(ctx.Repo.GitRepo, hash)
|
|
if err != nil && err != io.EOF {
|
|
log.Error("Failure in FindLFSFile: %v", err)
|
|
ctx.ServerError("LFSFind: FindLFSFile.", err)
|
|
return
|
|
}
|
|
|
|
ctx.Data["Results"] = results
|
|
ctx.HTML(http.StatusOK, tplSettingsLFSFileFind)
|
|
}
|
|
|
|
// LFSPointerFiles will search the repository for pointer files and report which are missing LFS files in the content store
|
|
func LFSPointerFiles(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSFileGet", nil)
|
|
return
|
|
}
|
|
ctx.Data["PageIsSettingsLFS"] = true
|
|
err := git.LoadGitVersion()
|
|
if err != nil {
|
|
log.Fatal("Error retrieving git version: %v", err)
|
|
}
|
|
ctx.Data["LFSFilesLink"] = ctx.Repo.RepoLink + "/settings/lfs"
|
|
|
|
err = func() error {
|
|
pointerChan := make(chan lfs.PointerBlob)
|
|
errChan := make(chan error, 1)
|
|
go lfs.SearchPointerBlobs(ctx, ctx.Repo.GitRepo, pointerChan, errChan)
|
|
|
|
numPointers := 0
|
|
var numAssociated, numNoExist, numAssociatable int
|
|
|
|
type pointerResult struct {
|
|
SHA string
|
|
Oid string
|
|
Size int64
|
|
InRepo bool
|
|
Exists bool
|
|
Accessible bool
|
|
Associatable bool
|
|
}
|
|
|
|
results := []pointerResult{}
|
|
|
|
contentStore := lfs.NewContentStore()
|
|
repo := ctx.Repo.Repository
|
|
|
|
for pointerBlob := range pointerChan {
|
|
numPointers++
|
|
|
|
result := pointerResult{
|
|
SHA: pointerBlob.Hash,
|
|
Oid: pointerBlob.Oid,
|
|
Size: pointerBlob.Size,
|
|
}
|
|
|
|
if _, err := models.GetLFSMetaObjectByOid(repo.ID, pointerBlob.Oid); err != nil {
|
|
if err != models.ErrLFSObjectNotExist {
|
|
return err
|
|
}
|
|
} else {
|
|
result.InRepo = true
|
|
}
|
|
|
|
result.Exists, err = contentStore.Exists(pointerBlob.Pointer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if result.Exists {
|
|
if !result.InRepo {
|
|
// Can we fix?
|
|
// OK well that's "simple"
|
|
// - we need to check whether current user has access to a repo that has access to the file
|
|
result.Associatable, err = models.LFSObjectAccessible(ctx.User, pointerBlob.Oid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !result.Associatable {
|
|
associated, err := models.LFSObjectIsAssociated(pointerBlob.Oid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.Associatable = !associated
|
|
}
|
|
}
|
|
}
|
|
|
|
result.Accessible = result.InRepo || result.Associatable
|
|
|
|
if result.InRepo {
|
|
numAssociated++
|
|
}
|
|
if !result.Exists {
|
|
numNoExist++
|
|
}
|
|
if result.Associatable {
|
|
numAssociatable++
|
|
}
|
|
|
|
results = append(results, result)
|
|
}
|
|
|
|
err, has := <-errChan
|
|
if has {
|
|
return err
|
|
}
|
|
|
|
ctx.Data["Pointers"] = results
|
|
ctx.Data["NumPointers"] = numPointers
|
|
ctx.Data["NumAssociated"] = numAssociated
|
|
ctx.Data["NumAssociatable"] = numAssociatable
|
|
ctx.Data["NumNoExist"] = numNoExist
|
|
ctx.Data["NumNotAssociated"] = numPointers - numAssociated
|
|
|
|
return nil
|
|
}()
|
|
if err != nil {
|
|
ctx.ServerError("LFSPointerFiles", err)
|
|
return
|
|
}
|
|
|
|
ctx.HTML(http.StatusOK, tplSettingsLFSPointers)
|
|
}
|
|
|
|
// LFSAutoAssociate auto associates accessible lfs files
|
|
func LFSAutoAssociate(ctx *context.Context) {
|
|
if !setting.LFS.StartServer {
|
|
ctx.NotFound("LFSAutoAssociate", nil)
|
|
return
|
|
}
|
|
oids := ctx.FormStrings("oid")
|
|
metas := make([]*models.LFSMetaObject, len(oids))
|
|
for i, oid := range oids {
|
|
idx := strings.IndexRune(oid, ' ')
|
|
if idx < 0 || idx+1 > len(oid) {
|
|
ctx.ServerError("LFSAutoAssociate", fmt.Errorf("Illegal oid input: %s", oid))
|
|
return
|
|
}
|
|
var err error
|
|
metas[i] = &models.LFSMetaObject{}
|
|
metas[i].Size, err = strconv.ParseInt(oid[idx+1:], 10, 64)
|
|
if err != nil {
|
|
ctx.ServerError("LFSAutoAssociate", fmt.Errorf("Illegal oid input: %s %v", oid, err))
|
|
return
|
|
}
|
|
metas[i].Oid = oid[:idx]
|
|
//metas[i].RepositoryID = ctx.Repo.Repository.ID
|
|
}
|
|
if err := models.LFSAutoAssociate(metas, ctx.User, ctx.Repo.Repository.ID); err != nil {
|
|
ctx.ServerError("LFSAutoAssociate", err)
|
|
return
|
|
}
|
|
ctx.Redirect(ctx.Repo.RepoLink + "/settings/lfs")
|
|
}
|