Drag and Drop
This commit is contained in:
parent
3ce683f79b
commit
f736533160
8 changed files with 117 additions and 130 deletions
|
@ -758,6 +758,7 @@ func UploadFilePost(ctx *context.Context) {
|
|||
TreePath: form.TreePath,
|
||||
Message: message,
|
||||
Files: form.Files,
|
||||
FullPaths: form.FullPaths,
|
||||
Signoff: form.Signoff,
|
||||
Author: gitIdentity,
|
||||
Committer: gitIdentity,
|
||||
|
|
|
@ -51,6 +51,7 @@ import (
|
|||
|
||||
_ "code.gitea.io/gitea/modules/session" // to registers all internal adapters
|
||||
|
||||
"code.forgejo.org/go-chi/binding"
|
||||
"code.forgejo.org/go-chi/captcha"
|
||||
chi_middleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
@ -356,20 +357,6 @@ func registerRoutes(m *web.Route) {
|
|||
}
|
||||
}
|
||||
|
||||
annexEnabled := func(ctx *context.Context) {
|
||||
if !setting.Annex.Enabled {
|
||||
ctx.Error(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
annexP2PHTTPEnabled := func(ctx *context.Context) {
|
||||
if setting.Annex.DisableP2PHTTP {
|
||||
ctx.Error(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
federationEnabled := func(ctx *context.Context) {
|
||||
if !setting.Federation.Enabled {
|
||||
ctx.Error(http.StatusNotFound)
|
||||
|
@ -969,9 +956,6 @@ func registerRoutes(m *web.Route) {
|
|||
// ***** END: Organization *****
|
||||
|
||||
// ***** START: Repository *****
|
||||
m.Group("", func() {
|
||||
m.Methods("GET,POST", "/git-annex-p2phttp/git-annex/{uuid}/*", repo.AnnexP2PHTTP)
|
||||
}, ignSignInAndCsrf, annexEnabled, annexP2PHTTPEnabled)
|
||||
m.Group("/repo", func() {
|
||||
m.Get("/create", repo.Create)
|
||||
m.Post("/create", web.Bind(forms.CreateRepoForm{}), repo.CreatePost)
|
||||
|
@ -1272,7 +1256,7 @@ func registerRoutes(m *web.Route) {
|
|||
Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost)
|
||||
m.Combo("/_upload/*", repo.MustBeAbleToUpload).
|
||||
Get(repo.UploadFile).
|
||||
Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost)
|
||||
Post(BindUpload(forms.UploadRepoFileForm{}), repo.UploadFilePost)
|
||||
m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch).
|
||||
Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost)
|
||||
m.Combo("/_cherrypick/{sha:([a-f0-9]{4,64})}/*").Get(repo.CherryPick).
|
||||
|
@ -1652,12 +1636,6 @@ func registerRoutes(m *web.Route) {
|
|||
})
|
||||
}, ignSignInAndCsrf, lfsServerEnabled)
|
||||
|
||||
m.Group("", func() {
|
||||
// for git-annex
|
||||
m.Methods("GET,OPTIONS", "/config", repo.GetConfig) // needed by clients reading annex.uuid during `git annex initremote`
|
||||
m.Methods("GET,OPTIONS", "/annex/objects/{hash1}/{hash2}/{keyDir}/{key}", repo.GetAnnexObject)
|
||||
}, ignSignInAndCsrf, annexEnabled, context.UserAssignmentWeb())
|
||||
|
||||
gitHTTPRouters(m)
|
||||
})
|
||||
})
|
||||
|
@ -1694,3 +1672,20 @@ func registerRoutes(m *web.Route) {
|
|||
ctx.NotFound("", nil)
|
||||
})
|
||||
}
|
||||
|
||||
func BindUpload(f forms.UploadRepoFileForm) http.HandlerFunc {
|
||||
return func(resp http.ResponseWriter, req *http.Request) {
|
||||
theObj := new(forms.UploadRepoFileForm) // create a new form obj for every request but not use obj directly
|
||||
data := middleware.GetContextData(req.Context())
|
||||
binding.Bind(req, theObj)
|
||||
files := theObj.Files
|
||||
var fullpaths []string
|
||||
for _, fileID := range files {
|
||||
fullPath := req.Form.Get("files_fullpath[" + fileID + "]")
|
||||
fullpaths = append(fullpaths, fullPath)
|
||||
}
|
||||
theObj.FullPaths = fullpaths
|
||||
data.GetData()["__form"] = theObj
|
||||
middleware.AssignForm(theObj, data)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -671,6 +671,7 @@ type UploadRepoFileForm struct {
|
|||
CommitChoice string `binding:"Required;MaxSize(50)"`
|
||||
NewBranchName string `binding:"GitRefName;MaxSize(100)"`
|
||||
Files []string
|
||||
FullPaths []string
|
||||
CommitMailID int64 `binding:"Required"`
|
||||
Signoff bool
|
||||
}
|
||||
|
@ -740,17 +741,6 @@ type SaveTopicForm struct {
|
|||
Topics []string `binding:"topics;Required;"`
|
||||
}
|
||||
|
||||
// DeadlineForm hold the validation rules for deadlines
|
||||
type DeadlineForm struct {
|
||||
DateString string `form:"date" binding:"Required;Size(10)"`
|
||||
}
|
||||
|
||||
// Validate validates the fields
|
||||
func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
|
||||
ctx := context.GetValidateContext(req)
|
||||
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
|
||||
}
|
||||
|
||||
type CommitNotesForm struct {
|
||||
Notes string
|
||||
}
|
||||
|
|
|
@ -6,16 +6,15 @@ package files
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"html"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/annex"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/lfs"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
@ -31,6 +30,7 @@ type UploadRepoFileOptions struct {
|
|||
Author *IdentityOptions
|
||||
Committer *IdentityOptions
|
||||
Files []string // In UUID format.
|
||||
FullPaths []string
|
||||
Signoff bool
|
||||
}
|
||||
|
||||
|
@ -59,16 +59,24 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
|||
return nil
|
||||
}
|
||||
|
||||
uploads, err := repo_model.GetUploadsByUUIDs(ctx, opts.Files)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetUploadsByUUIDs [uuids: %v]: %w", opts.Files, err)
|
||||
if len(opts.Files) != len(opts.FullPaths) {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, len(uploads))
|
||||
infos := make([]uploadInfo, len(uploads))
|
||||
for i, upload := range uploads {
|
||||
uploads := make([]*repo_model.Upload, len(opts.Files))
|
||||
names := make([]string, len(opts.Files))
|
||||
infos := make([]uploadInfo, len(opts.Files))
|
||||
|
||||
for i := 0; i < len(opts.Files); i++ {
|
||||
var err error
|
||||
uploads[i], err = repo_model.GetUploadByUUID(ctx, opts.Files[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetUploadByUUID [uuids: %v]: %w", opts.Files[i], err)
|
||||
}
|
||||
uploads[i].Name = fileNameSanitize(html.UnescapeString(opts.FullPaths[i]))
|
||||
|
||||
// Check file is not lfs locked, will return nil if lock setting not enabled
|
||||
filepath := path.Join(opts.TreePath, upload.Name)
|
||||
filepath := path.Join(opts.TreePath, uploads[i].Name)
|
||||
lfsLock, err := git_model.GetTreePathLock(ctx, repo.ID, filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -81,8 +89,8 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
|||
return git_model.ErrLFSFileLocked{RepoID: repo.ID, Path: filepath, UserName: u.Name}
|
||||
}
|
||||
|
||||
names[i] = upload.Name
|
||||
infos[i] = uploadInfo{upload: upload}
|
||||
names[i] = uploads[i].Name
|
||||
infos[i] = uploadInfo{upload: uploads[i]}
|
||||
}
|
||||
|
||||
t, err := NewTemporaryUploadRepository(ctx, repo)
|
||||
|
@ -92,7 +100,7 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
|||
defer t.Close()
|
||||
|
||||
hasOldBranch := true
|
||||
if err = t.Clone(opts.OldBranch, false); err != nil {
|
||||
if err = t.Clone(opts.OldBranch, true); err != nil {
|
||||
if !git.IsErrBranchNotExist(err) || !repo.IsEmpty {
|
||||
return err
|
||||
}
|
||||
|
@ -108,30 +116,10 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
|||
}
|
||||
}
|
||||
|
||||
r, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||
if err != nil {
|
||||
// Copy uploaded files into repository.
|
||||
if err := copyUploadedLFSFilesIntoRepository(infos, t, opts.TreePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if annex.IsAnnexRepo(r) {
|
||||
// Initialize annex privately in temporary clone
|
||||
if err := t.InitPrivateAnnex(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Copy uploaded files into git-annex repository
|
||||
if err := copyUploadedFilesIntoAnnexRepository(infos, t, opts.TreePath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Move all annexed content in the temporary repository, i.e. everything we have just added, to the origin
|
||||
author, committer := GetAuthorAndCommitterUsers(opts.Author, opts.Committer, doer)
|
||||
if err := moveAnnexedFilesToOrigin(t, author, committer); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Copy uploaded files into repository.
|
||||
if err := copyUploadedLFSFilesIntoRepository(infos, t, opts.TreePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Now write the tree
|
||||
treeHash, err := t.WriteTree()
|
||||
|
@ -178,6 +166,19 @@ func UploadRepoFiles(ctx context.Context, repo *repo_model.Repository, doer *use
|
|||
return repo_model.DeleteUploads(ctx, uploads...)
|
||||
}
|
||||
|
||||
// From forgejo/services/repository/generate.go (but now allows /)
|
||||
var fileNameSanitizeRegexp = regexp.MustCompile(`(?i)\.\.|[<>:\"\\|?*\x{0000}-\x{001F}]|^(con|prn|aux|nul|com\d|lpt\d)$`)
|
||||
|
||||
// Sanitize user input to valid OS filenames
|
||||
//
|
||||
// Based on https://github.com/sindresorhus/filename-reserved-regex
|
||||
// Adds ".." to prevent directory traversal
|
||||
func fileNameSanitize(s string) string {
|
||||
// Added this because I am not sure what Windows will deliver us \ or / but we need /.
|
||||
s = strings.ReplaceAll(s, "\\", "/")
|
||||
return strings.TrimSpace(fileNameSanitizeRegexp.ReplaceAllString(s, "_"))
|
||||
}
|
||||
|
||||
func copyUploadedLFSFilesIntoRepository(infos []uploadInfo, t *TemporaryUploadRepository, treePath string) error {
|
||||
var storeInLFSFunc func(string) (bool, error)
|
||||
|
||||
|
@ -269,57 +270,3 @@ func uploadToLFSContentStore(info uploadInfo, contentStore *lfs.ContentStore) er
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyUploadedFilesIntoAnnexRepository(infos []uploadInfo, t *TemporaryUploadRepository, treePath string) error {
|
||||
for i := range len(infos) {
|
||||
if err := copyUploadedFileIntoAnnexRepository(&infos[i], t, treePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyUploadedFileIntoAnnexRepository(info *uploadInfo, t *TemporaryUploadRepository, treePath string) error {
|
||||
pathInRepo := path.Join(t.basePath, treePath, info.upload.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(pathInRepo), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(info.upload.LocalPath(), pathInRepo); err != nil {
|
||||
// Rename didn't work, try copy and remove
|
||||
inputFile, err := os.Open(info.upload.LocalPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open source file: %v", err)
|
||||
}
|
||||
defer inputFile.Close()
|
||||
outputFile, err := os.Create(pathInRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open dest file: %v", err)
|
||||
}
|
||||
defer outputFile.Close()
|
||||
_, err = io.Copy(outputFile, inputFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not copy to dest from source: %v", err)
|
||||
}
|
||||
inputFile.Close()
|
||||
err = os.Remove(info.upload.LocalPath())
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove source file: %v", err)
|
||||
}
|
||||
}
|
||||
return t.AddAnnex(pathInRepo)
|
||||
}
|
||||
|
||||
func moveAnnexedFilesToOrigin(t *TemporaryUploadRepository, author, committer *user_model.User) error {
|
||||
authorSig := author.NewGitSig()
|
||||
committerSig := committer.NewGitSig()
|
||||
env := append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME="+authorSig.Name,
|
||||
"GIT_AUTHOR_EMAIL="+authorSig.Email,
|
||||
"GIT_COMMITTER_NAME="+committerSig.Name,
|
||||
"GIT_COMMITTER_EMAIL="+committerSig.Email,
|
||||
)
|
||||
if _, _, err := git.NewCommand(t.ctx, "annex", "move", "--to", "origin").RunStdString(&git.RunOpts{Dir: t.basePath, Env: env}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -75,8 +75,6 @@
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button id="commit-button" type="submit" class="ui primary button">
|
||||
{{if eq .commit_choice "commit-to-new-branch"}}{{ctx.Locale.Tr "repo.editor.propose_file_change"}}{{else}}{{ctx.Locale.Tr "repo.editor.commit_changes"}}{{end}}
|
||||
</button>
|
||||
<button id="commit-button" type="submit" class="ui primary button">{{if eq .commit_choice "commit-to-new-branch"}}{{ctx.Locale.Tr "repo.editor.propose_file_change"}}{{else}}{{ctx.Locale.Tr "repo.editor.commit_changes"}}{{end}}</button>
|
||||
<a class="ui button red" href="{{$.BranchLink}}/{{PathEscapeSegments .TreePath}}">{{ctx.Locale.Tr "repo.editor.cancel"}}</a>
|
||||
</div>
|
||||
|
|
|
@ -48,6 +48,10 @@ func DeclareGitRepos(t *testing.T) func() {
|
|||
CommitMsg: "Another commit which mentions @user1 in the title\nand @user2 in the text",
|
||||
},
|
||||
}),
|
||||
newRepo(t, 2, "file-uploads", []FileChanges{{
|
||||
Filename: "README.md",
|
||||
Versions: []string{"# File upload test\nUse this repo to test various file upload features in new branches."},
|
||||
}}),
|
||||
// add your repo declarations here
|
||||
}
|
||||
|
||||
|
@ -68,7 +72,7 @@ func newRepo(t *testing.T, userID int64, repoName string, fileChanges []FileChan
|
|||
for _, file := range fileChanges {
|
||||
for i, version := range file.Versions {
|
||||
operation := "update"
|
||||
if i == 0 {
|
||||
if i == 0 && file.Filename != "README.md" {
|
||||
operation = "create"
|
||||
}
|
||||
|
||||
|
|
46
tests/e2e/repo-files.test.e2e.ts
Normal file
46
tests/e2e/repo-files.test.e2e.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
// @watch start
|
||||
// templates/repo/editor/**
|
||||
// web_src/js/features/common-global.js
|
||||
// routers/web/web.go
|
||||
// services/repository/files/upload.go
|
||||
// @watch end
|
||||
|
||||
/// <reference lib="dom"/>
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
import { test, dynamic_id, save_visual } from './utils_e2e.ts';
|
||||
|
||||
test.use({ user: 'user2' });
|
||||
|
||||
test('drap and drop upload', async ({ page }, workerInfo) => {
|
||||
const response = await page.goto(`/user2/file-uploads/_upload/main/`);
|
||||
expect(response?.status()).toBe(200); // Status OK
|
||||
|
||||
const testID = dynamic_id();
|
||||
const dropzone = page.getByRole('button', { name: 'Drop files or click here to upload.' });
|
||||
|
||||
// create the virtual files
|
||||
const dataTransfer = await page.evaluateHandle(() => {
|
||||
const dt = new DataTransfer();
|
||||
|
||||
// add items in different folders
|
||||
dt.items.add(new File(["Filecontent (dir1/file1.txt)"], 'dir1/file1.txt', { type: 'text/plain' }));
|
||||
dt.items.add(new File(["Another file's content (double/nested/file.txt)"], 'double/nested/file.txt', { type: 'text/plain' }));
|
||||
dt.items.add(new File(["Root file (root_file.txt)"], 'root_file.txt', { type: 'text/plain' }));
|
||||
|
||||
return dt;
|
||||
});
|
||||
// and drop them to the upload area
|
||||
await dropzone.dispatchEvent('drop', { dataTransfer });
|
||||
|
||||
await page.getByText('new branch').click();
|
||||
await save_visual(page);
|
||||
await page.getByRole('textbox', { name: 'Name the new branch for this' }).fill(testID);
|
||||
await page.getByRole('button', { name: 'Propose file change' }).click();
|
||||
|
||||
// check that nested file structure is preserved
|
||||
await expect(page.getByRole('link', { name: 'dir1/file1.txt' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'double/nested/file.txt' })).toBeVisible();
|
||||
await expect(page.locator('#diff-file-boxes').getByRole('link', { name: 'root_file.txt', exact: true })).toBeVisible();
|
||||
await save_visual(page);
|
||||
});
|
|
@ -229,7 +229,8 @@ export function initDropzone(el) {
|
|||
this.on('success', (file, data) => {
|
||||
file.uuid = data.uuid;
|
||||
const $input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
|
||||
$dropzone.find('.files').append($input);
|
||||
const $inputPath = $(`<input type="hidden" name="files_fullpath[${data.uuid}]" value="${htmlEscape(file.fullPath || file.name)}">`);
|
||||
$dropzone.find('.files').append($input).append($inputPath);
|
||||
// Create a "Copy Link" element, to conveniently copy the image
|
||||
// or file link as Markdown to the clipboard
|
||||
const copyLinkElement = document.createElement('div');
|
||||
|
@ -250,7 +251,12 @@ export function initDropzone(el) {
|
|||
file.previewTemplate.append(copyLinkElement);
|
||||
});
|
||||
this.on('removedfile', (file) => {
|
||||
// Remove the hidden input for the file
|
||||
$(`#${file.uuid}`).remove();
|
||||
|
||||
// Remove the hidden input for files_fullpath
|
||||
$(`input[name="files_fullpath[${file.uuid}]"]`).remove();
|
||||
|
||||
if ($dropzone.data('remove-url')) {
|
||||
POST($dropzone.data('remove-url'), {
|
||||
data: new URLSearchParams({file: file.uuid}),
|
||||
|
@ -357,7 +363,7 @@ export function initGlobalLinkActions() {
|
|||
});
|
||||
}
|
||||
|
||||
function initGlobalShowModal() {
|
||||
export function initGlobalShowModal() {
|
||||
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
|
||||
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
|
||||
// * First, try to query '#target'
|
||||
|
|
Loading…
Add table
Reference in a new issue