Implement external assets
This commit is contained in:
		
					parent
					
						
							
								2e234300a2
							
						
					
				
			
			
				commit
				
					
						a61e7c7a39
					
				
			
		
					 22 changed files with 826 additions and 119 deletions
				
			
		|  | @ -74,6 +74,8 @@ var migrations = []*Migration{ | |||
| 	NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser), | ||||
| 	// v18 -> v19 | ||||
| 	NewMigration("Create the `following_repo` table", CreateFollowingRepoTable), | ||||
| 	// v19 -> v20 | ||||
| 	NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable), | ||||
| } | ||||
| 
 | ||||
| // GetCurrentDBVersion returns the current Forgejo database version. | ||||
|  |  | |||
							
								
								
									
										14
									
								
								models/forgejo_migrations/v19.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								models/forgejo_migrations/v19.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| // Copyright 2024 The Forgejo Authors. All rights reserved. | ||||
| // SPDX-License-Identifier: MIT | ||||
| 
 | ||||
| package forgejo_migrations //nolint:revive | ||||
| 
 | ||||
| import "xorm.io/xorm" | ||||
| 
 | ||||
| func AddExternalURLColumnToAttachmentTable(x *xorm.Engine) error { | ||||
| 	type Attachment struct { | ||||
| 		ID          int64 `xorm:"pk autoincr"` | ||||
| 		ExternalURL string | ||||
| 	} | ||||
| 	return x.Sync(new(Attachment)) | ||||
| } | ||||
|  | @ -14,6 +14,7 @@ import ( | |||
| 	"code.gitea.io/gitea/modules/storage" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/modules/validation" | ||||
| ) | ||||
| 
 | ||||
| // Attachment represent a attachment of issue/comment/release. | ||||
|  | @ -31,6 +32,7 @@ type Attachment struct { | |||
| 	NoAutoTime        bool               `xorm:"-"` | ||||
| 	CreatedUnix       timeutil.TimeStamp `xorm:"created"` | ||||
| 	CustomDownloadURL string             `xorm:"-"` | ||||
| 	ExternalURL       string | ||||
| } | ||||
| 
 | ||||
| func init() { | ||||
|  | @ -59,6 +61,10 @@ func (a *Attachment) RelativePath() string { | |||
| 
 | ||||
| // DownloadURL returns the download url of the attached file | ||||
| func (a *Attachment) DownloadURL() string { | ||||
| 	if a.ExternalURL != "" { | ||||
| 		return a.ExternalURL | ||||
| 	} | ||||
| 
 | ||||
| 	if a.CustomDownloadURL != "" { | ||||
| 		return a.CustomDownloadURL | ||||
| 	} | ||||
|  | @ -86,6 +92,23 @@ func (err ErrAttachmentNotExist) Unwrap() error { | |||
| 	return util.ErrNotExist | ||||
| } | ||||
| 
 | ||||
| type ErrInvalidExternalURL struct { | ||||
| 	ExternalURL string | ||||
| } | ||||
| 
 | ||||
| func IsErrInvalidExternalURL(err error) bool { | ||||
| 	_, ok := err.(ErrInvalidExternalURL) | ||||
| 	return ok | ||||
| } | ||||
| 
 | ||||
| func (err ErrInvalidExternalURL) Error() string { | ||||
| 	return fmt.Sprintf("invalid external URL: '%s'", err.ExternalURL) | ||||
| } | ||||
| 
 | ||||
| func (err ErrInvalidExternalURL) Unwrap() error { | ||||
| 	return util.ErrPermissionDenied | ||||
| } | ||||
| 
 | ||||
| // GetAttachmentByID returns attachment by given id | ||||
| func GetAttachmentByID(ctx context.Context, id int64) (*Attachment, error) { | ||||
| 	attach := &Attachment{} | ||||
|  | @ -221,12 +244,18 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str | |||
| 	if attach.UUID == "" { | ||||
| 		return fmt.Errorf("attachment uuid should be not blank") | ||||
| 	} | ||||
| 	if attach.ExternalURL != "" && !validation.IsValidExternalURL(attach.ExternalURL) { | ||||
| 		return ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} | ||||
| 	} | ||||
| 	_, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach) | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| // UpdateAttachment updates the given attachment in database | ||||
| func UpdateAttachment(ctx context.Context, atta *Attachment) error { | ||||
| 	if atta.ExternalURL != "" && !validation.IsValidExternalURL(atta.ExternalURL) { | ||||
| 		return ErrInvalidExternalURL{ExternalURL: atta.ExternalURL} | ||||
| 	} | ||||
| 	sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count") | ||||
| 	if atta.ID != 0 && atta.UUID == "" { | ||||
| 		sess = sess.ID(atta.ID) | ||||
|  |  | |||
|  | @ -18,10 +18,14 @@ type Attachment struct { | |||
| 	Created     time.Time `json:"created_at"` | ||||
| 	UUID        string    `json:"uuid"` | ||||
| 	DownloadURL string    `json:"browser_download_url"` | ||||
| 	// Enum: attachment,external | ||||
| 	Type string `json:"type"` | ||||
| } | ||||
| 
 | ||||
| // EditAttachmentOptions options for editing attachments | ||||
| // swagger:model | ||||
| type EditAttachmentOptions struct { | ||||
| 	Name string `json:"name"` | ||||
| 	// (Can only be set if existing attachment is of external type) | ||||
| 	DownloadURL string `json:"browser_download_url"` | ||||
| } | ||||
|  |  | |||
|  | @ -2721,6 +2721,12 @@ release.add_tag = Create tag | |||
| release.releases_for = Releases for %s | ||||
| release.tags_for = Tags for %s | ||||
| release.system_generated = This attachment is automatically generated. | ||||
| release.type_attachment = Attachment | ||||
| release.type_external_asset = External Asset | ||||
| release.asset_name = Asset Name | ||||
| release.asset_external_url = External URL | ||||
| release.add_external_asset = Add External Asset | ||||
| release.invalid_external_url = Invalid External URL: "%s" | ||||
| 
 | ||||
| branch.name = Branch name | ||||
| branch.already_exists = A branch named "%s" already exists. | ||||
|  |  | |||
|  | @ -247,7 +247,7 @@ func CreateRelease(ctx *context.APIContext) { | |||
| 			IsTag:            false, | ||||
| 			Repo:             ctx.Repo.Repository, | ||||
| 		} | ||||
| 		if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, ""); err != nil { | ||||
| 		if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, "", nil); err != nil { | ||||
| 			if repo_model.IsErrReleaseAlreadyExist(err) { | ||||
| 				ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err) | ||||
| 			} else if models.IsErrProtectedTagName(err) { | ||||
|  | @ -274,7 +274,7 @@ func CreateRelease(ctx *context.APIContext) { | |||
| 		rel.Publisher = ctx.Doer | ||||
| 		rel.Target = form.Target | ||||
| 
 | ||||
| 		if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil, true); err != nil { | ||||
| 		if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, nil); err != nil { | ||||
| 			ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) | ||||
| 			return | ||||
| 		} | ||||
|  | @ -351,7 +351,7 @@ func EditRelease(ctx *context.APIContext) { | |||
| 	if form.HideArchiveLinks != nil { | ||||
| 		rel.HideArchiveLinks = *form.HideArchiveLinks | ||||
| 	} | ||||
| 	if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil, false); err != nil { | ||||
| 	if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, nil); err != nil { | ||||
| 		ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) | ||||
| 		return | ||||
| 	} | ||||
|  |  | |||
|  | @ -5,7 +5,10 @@ package repo | |||
| 
 | ||||
| import ( | ||||
| 	"io" | ||||
| 	"mime/multipart" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"path" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	repo_model "code.gitea.io/gitea/models/repo" | ||||
|  | @ -179,11 +182,18 @@ func CreateReleaseAttachment(ctx *context.APIContext) { | |||
| 	//   description: name of the attachment | ||||
| 	//   type: string | ||||
| 	//   required: false | ||||
| 	// # There is no good way to specify "either 'attachment' or 'external_url' is required" with OpenAPI | ||||
| 	// # https://github.com/OAI/OpenAPI-Specification/issues/256 | ||||
| 	// - name: attachment | ||||
| 	//   in: formData | ||||
| 	//   description: attachment to upload | ||||
| 	//   description: attachment to upload (this parameter is incompatible with `external_url`) | ||||
| 	//   type: file | ||||
| 	//   required: false | ||||
| 	// - name: external_url | ||||
| 	//   in: formData | ||||
| 	//   description: url to external asset (this parameter is incompatible with `attachment`) | ||||
| 	//   type: string | ||||
| 	//   required: false | ||||
| 	// responses: | ||||
| 	//   "201": | ||||
| 	//     "$ref": "#/responses/Attachment" | ||||
|  | @ -205,51 +215,96 @@ func CreateReleaseAttachment(ctx *context.APIContext) { | |||
| 	} | ||||
| 
 | ||||
| 	// Get uploaded file from request | ||||
| 	var content io.ReadCloser | ||||
| 	var filename string | ||||
| 	var size int64 = -1 | ||||
| 	var isForm, hasAttachmentFile, hasExternalURL bool | ||||
| 	externalURL := ctx.FormString("external_url") | ||||
| 	hasExternalURL = externalURL != "" | ||||
| 	filename := ctx.FormString("name") | ||||
| 	isForm = strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") | ||||
| 
 | ||||
| 	if strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") { | ||||
| 		file, header, err := ctx.Req.FormFile("attachment") | ||||
| 		if err != nil { | ||||
| 			ctx.Error(http.StatusInternalServerError, "GetFile", err) | ||||
| 			return | ||||
| 		} | ||||
| 		defer file.Close() | ||||
| 
 | ||||
| 		content = file | ||||
| 		size = header.Size | ||||
| 		filename = header.Filename | ||||
| 		if name := ctx.FormString("name"); name != "" { | ||||
| 			filename = name | ||||
| 		} | ||||
| 	if isForm { | ||||
| 		_, _, err := ctx.Req.FormFile("attachment") | ||||
| 		hasAttachmentFile = err == nil | ||||
| 	} else { | ||||
| 		content = ctx.Req.Body | ||||
| 		filename = ctx.FormString("name") | ||||
| 		hasAttachmentFile = ctx.Req.Body != nil | ||||
| 	} | ||||
| 
 | ||||
| 	if filename == "" { | ||||
| 		ctx.Error(http.StatusBadRequest, "CreateReleaseAttachment", "Could not determine name of attachment.") | ||||
| 		return | ||||
| 	} | ||||
| 	if hasAttachmentFile && hasExternalURL { | ||||
| 		ctx.Error(http.StatusBadRequest, "DuplicateAttachment", "'attachment' and 'external_url' are mutually exclusive") | ||||
| 	} else if hasAttachmentFile { | ||||
| 		var content io.ReadCloser | ||||
| 		var size int64 = -1 | ||||
| 
 | ||||
| 	// Create a new attachment and save the file | ||||
| 	attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ | ||||
| 		Name:       filename, | ||||
| 		UploaderID: ctx.Doer.ID, | ||||
| 		RepoID:     ctx.Repo.Repository.ID, | ||||
| 		ReleaseID:  releaseID, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		if upload.IsErrFileTypeForbidden(err) { | ||||
| 			ctx.Error(http.StatusBadRequest, "DetectContentType", err) | ||||
| 		if isForm { | ||||
| 			var header *multipart.FileHeader | ||||
| 			content, header, _ = ctx.Req.FormFile("attachment") | ||||
| 			size = header.Size | ||||
| 			defer content.Close() | ||||
| 			if filename == "" { | ||||
| 				filename = header.Filename | ||||
| 			} | ||||
| 		} else { | ||||
| 			content = ctx.Req.Body | ||||
| 			defer content.Close() | ||||
| 		} | ||||
| 
 | ||||
| 		if filename == "" { | ||||
| 			ctx.Error(http.StatusBadRequest, "MissingName", "Missing 'name' parameter") | ||||
| 			return | ||||
| 		} | ||||
| 		ctx.Error(http.StatusInternalServerError, "NewAttachment", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) | ||||
| 		// Create a new attachment and save the file | ||||
| 		attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ | ||||
| 			Name:       filename, | ||||
| 			UploaderID: ctx.Doer.ID, | ||||
| 			RepoID:     ctx.Repo.Repository.ID, | ||||
| 			ReleaseID:  releaseID, | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			if upload.IsErrFileTypeForbidden(err) { | ||||
| 				ctx.Error(http.StatusBadRequest, "DetectContentType", err) | ||||
| 				return | ||||
| 			} | ||||
| 			ctx.Error(http.StatusInternalServerError, "NewAttachment", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) | ||||
| 	} else if hasExternalURL { | ||||
| 		url, err := url.Parse(externalURL) | ||||
| 		if err != nil { | ||||
| 			ctx.Error(http.StatusBadRequest, "InvalidExternalURL", err) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		if filename == "" { | ||||
| 			filename = path.Base(url.Path) | ||||
| 
 | ||||
| 			if filename == "." { | ||||
| 				// Url path is empty | ||||
| 				filename = url.Host | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		attach, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{ | ||||
| 			Name:        filename, | ||||
| 			UploaderID:  ctx.Doer.ID, | ||||
| 			RepoID:      ctx.Repo.Repository.ID, | ||||
| 			ReleaseID:   releaseID, | ||||
| 			ExternalURL: url.String(), | ||||
| 		}) | ||||
| 		if err != nil { | ||||
| 			if repo_model.IsErrInvalidExternalURL(err) { | ||||
| 				ctx.Error(http.StatusBadRequest, "NewExternalAttachment", err) | ||||
| 			} else { | ||||
| 				ctx.Error(http.StatusInternalServerError, "NewExternalAttachment", err) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) | ||||
| 	} else { | ||||
| 		ctx.Error(http.StatusBadRequest, "MissingAttachment", "One of 'attachment' or 'external_url' is required") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // EditReleaseAttachment updates the given attachment | ||||
|  | @ -322,8 +377,21 @@ func EditReleaseAttachment(ctx *context.APIContext) { | |||
| 		attach.Name = form.Name | ||||
| 	} | ||||
| 
 | ||||
| 	if form.DownloadURL != "" { | ||||
| 		if attach.ExternalURL == "" { | ||||
| 			ctx.Error(http.StatusBadRequest, "EditAttachment", "existing attachment is not external") | ||||
| 			return | ||||
| 		} | ||||
| 		attach.ExternalURL = form.DownloadURL | ||||
| 	} | ||||
| 
 | ||||
| 	if err := repo_model.UpdateAttachment(ctx, attach); err != nil { | ||||
| 		ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach) | ||||
| 		if repo_model.IsErrInvalidExternalURL(err) { | ||||
| 			ctx.Error(http.StatusBadRequest, "UpdateAttachment", err) | ||||
| 		} else { | ||||
| 			ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) | ||||
| } | ||||
|  |  | |||
|  | @ -122,6 +122,11 @@ func ServeAttachment(ctx *context.Context, uuid string) { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if attach.ExternalURL != "" { | ||||
| 		ctx.Redirect(attach.ExternalURL) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if err := attach.IncreaseDownloadCount(ctx); err != nil { | ||||
| 		ctx.ServerError("IncreaseDownloadCount", err) | ||||
| 		return | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ import ( | |||
| 	"code.gitea.io/gitea/models/unit" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/base" | ||||
| 	"code.gitea.io/gitea/modules/container" | ||||
| 	"code.gitea.io/gitea/modules/git" | ||||
| 	"code.gitea.io/gitea/modules/gitrepo" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
|  | @ -491,9 +492,44 @@ func NewReleasePost(ctx *context.Context) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	var attachmentUUIDs []string | ||||
| 	attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) | ||||
| 	attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) | ||||
| 
 | ||||
| 	if setting.Attachment.Enabled { | ||||
| 		attachmentUUIDs = form.Files | ||||
| 		for _, uuid := range form.Files { | ||||
| 			attachmentChanges.Add(&releaseservice.AttachmentChange{ | ||||
| 				Action: "add", | ||||
| 				Type:   "attachment", | ||||
| 				UUID:   uuid, | ||||
| 			}) | ||||
| 		} | ||||
| 
 | ||||
| 		const namePrefix = "attachment-new-name-" | ||||
| 		const exturlPrefix = "attachment-new-exturl-" | ||||
| 		for k, v := range ctx.Req.Form { | ||||
| 			isNewName := strings.HasPrefix(k, namePrefix) | ||||
| 			isNewExturl := strings.HasPrefix(k, exturlPrefix) | ||||
| 			if isNewName || isNewExturl { | ||||
| 				var id string | ||||
| 				if isNewName { | ||||
| 					id = k[len(namePrefix):] | ||||
| 				} else if isNewExturl { | ||||
| 					id = k[len(exturlPrefix):] | ||||
| 				} | ||||
| 				if _, ok := attachmentChangesByID[id]; !ok { | ||||
| 					attachmentChangesByID[id] = &releaseservice.AttachmentChange{ | ||||
| 						Action: "add", | ||||
| 						Type:   "external", | ||||
| 					} | ||||
| 					attachmentChanges.Add(attachmentChangesByID[id]) | ||||
| 				} | ||||
| 				if isNewName { | ||||
| 					attachmentChangesByID[id].Name = v[0] | ||||
| 				} else if isNewExturl { | ||||
| 					attachmentChangesByID[id].ExternalURL = v[0] | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) | ||||
|  | @ -553,7 +589,7 @@ func NewReleasePost(ctx *context.Context) { | |||
| 			IsTag:            false, | ||||
| 		} | ||||
| 
 | ||||
| 		if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, attachmentUUIDs, msg); err != nil { | ||||
| 		if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, msg, attachmentChanges.Values()); err != nil { | ||||
| 			ctx.Data["Err_TagName"] = true | ||||
| 			switch { | ||||
| 			case repo_model.IsErrReleaseAlreadyExist(err): | ||||
|  | @ -562,6 +598,8 @@ func NewReleasePost(ctx *context.Context) { | |||
| 				ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form) | ||||
| 			case models.IsErrProtectedTagName(err): | ||||
| 				ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form) | ||||
| 			case repo_model.IsErrInvalidExternalURL(err): | ||||
| 				ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) | ||||
| 			default: | ||||
| 				ctx.ServerError("CreateRelease", err) | ||||
| 			} | ||||
|  | @ -583,9 +621,14 @@ func NewReleasePost(ctx *context.Context) { | |||
| 		rel.HideArchiveLinks = form.HideArchiveLinks | ||||
| 		rel.IsTag = false | ||||
| 
 | ||||
| 		if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, attachmentUUIDs, nil, nil, true); err != nil { | ||||
| 		if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, attachmentChanges.Values()); err != nil { | ||||
| 			ctx.Data["Err_TagName"] = true | ||||
| 			ctx.ServerError("UpdateRelease", err) | ||||
| 			switch { | ||||
| 			case repo_model.IsErrInvalidExternalURL(err): | ||||
| 				ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) | ||||
| 			default: | ||||
| 				ctx.ServerError("UpdateRelease", err) | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | @ -667,6 +710,15 @@ func EditReleasePost(ctx *context.Context) { | |||
| 	ctx.Data["prerelease"] = rel.IsPrerelease | ||||
| 	ctx.Data["hide_archive_links"] = rel.HideArchiveLinks | ||||
| 
 | ||||
| 	rel.Repo = ctx.Repo.Repository | ||||
| 	if err := rel.LoadAttributes(ctx); err != nil { | ||||
| 		ctx.ServerError("LoadAttributes", err) | ||||
| 		return | ||||
| 	} | ||||
| 	// TODO: If an error occurs, do not forget the attachment edits the user made | ||||
| 	// when displaying the error message. | ||||
| 	ctx.Data["attachments"] = rel.Attachments | ||||
| 
 | ||||
| 	if ctx.HasError() { | ||||
| 		ctx.HTML(http.StatusOK, tplReleaseNew) | ||||
| 		return | ||||
|  | @ -674,15 +726,67 @@ func EditReleasePost(ctx *context.Context) { | |||
| 
 | ||||
| 	const delPrefix = "attachment-del-" | ||||
| 	const editPrefix = "attachment-edit-" | ||||
| 	var addAttachmentUUIDs, delAttachmentUUIDs []string | ||||
| 	editAttachments := make(map[string]string) // uuid -> new name | ||||
| 	const newPrefix = "attachment-new-" | ||||
| 	const namePrefix = "name-" | ||||
| 	const exturlPrefix = "exturl-" | ||||
| 	attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) | ||||
| 	attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) | ||||
| 
 | ||||
| 	if setting.Attachment.Enabled { | ||||
| 		addAttachmentUUIDs = form.Files | ||||
| 		for _, uuid := range form.Files { | ||||
| 			attachmentChanges.Add(&releaseservice.AttachmentChange{ | ||||
| 				Action: "add", | ||||
| 				Type:   "attachment", | ||||
| 				UUID:   uuid, | ||||
| 			}) | ||||
| 		} | ||||
| 
 | ||||
| 		for k, v := range ctx.Req.Form { | ||||
| 			if strings.HasPrefix(k, delPrefix) && v[0] == "true" { | ||||
| 				delAttachmentUUIDs = append(delAttachmentUUIDs, k[len(delPrefix):]) | ||||
| 			} else if strings.HasPrefix(k, editPrefix) { | ||||
| 				editAttachments[k[len(editPrefix):]] = v[0] | ||||
| 				attachmentChanges.Add(&releaseservice.AttachmentChange{ | ||||
| 					Action: "delete", | ||||
| 					UUID:   k[len(delPrefix):], | ||||
| 				}) | ||||
| 			} else { | ||||
| 				isUpdatedName := strings.HasPrefix(k, editPrefix+namePrefix) | ||||
| 				isUpdatedExturl := strings.HasPrefix(k, editPrefix+exturlPrefix) | ||||
| 				isNewName := strings.HasPrefix(k, newPrefix+namePrefix) | ||||
| 				isNewExturl := strings.HasPrefix(k, newPrefix+exturlPrefix) | ||||
| 
 | ||||
| 				if isUpdatedName || isUpdatedExturl || isNewName || isNewExturl { | ||||
| 					var uuid string | ||||
| 
 | ||||
| 					if isUpdatedName { | ||||
| 						uuid = k[len(editPrefix+namePrefix):] | ||||
| 					} else if isUpdatedExturl { | ||||
| 						uuid = k[len(editPrefix+exturlPrefix):] | ||||
| 					} else if isNewName { | ||||
| 						uuid = k[len(newPrefix+namePrefix):] | ||||
| 					} else if isNewExturl { | ||||
| 						uuid = k[len(newPrefix+exturlPrefix):] | ||||
| 					} | ||||
| 
 | ||||
| 					if _, ok := attachmentChangesByID[uuid]; !ok { | ||||
| 						attachmentChangesByID[uuid] = &releaseservice.AttachmentChange{ | ||||
| 							Type: "attachment", | ||||
| 							UUID: uuid, | ||||
| 						} | ||||
| 						attachmentChanges.Add(attachmentChangesByID[uuid]) | ||||
| 					} | ||||
| 
 | ||||
| 					if isUpdatedName || isUpdatedExturl { | ||||
| 						attachmentChangesByID[uuid].Action = "update" | ||||
| 					} else if isNewName || isNewExturl { | ||||
| 						attachmentChangesByID[uuid].Action = "add" | ||||
| 					} | ||||
| 
 | ||||
| 					if isUpdatedName || isNewName { | ||||
| 						attachmentChangesByID[uuid].Name = v[0] | ||||
| 					} else if isUpdatedExturl || isNewExturl { | ||||
| 						attachmentChangesByID[uuid].ExternalURL = v[0] | ||||
| 						attachmentChangesByID[uuid].Type = "external" | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | @ -692,9 +796,13 @@ func EditReleasePost(ctx *context.Context) { | |||
| 	rel.IsDraft = len(form.Draft) > 0 | ||||
| 	rel.IsPrerelease = form.Prerelease | ||||
| 	rel.HideArchiveLinks = form.HideArchiveLinks | ||||
| 	if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, | ||||
| 		rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments, false); err != nil { | ||||
| 		ctx.ServerError("UpdateRelease", err) | ||||
| 	if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, attachmentChanges.Values()); err != nil { | ||||
| 		switch { | ||||
| 		case repo_model.IsErrInvalidExternalURL(err): | ||||
| 			ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) | ||||
| 		default: | ||||
| 			ctx.ServerError("UpdateRelease", err) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.Redirect(ctx.Repo.RepoLink + "/releases") | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import ( | |||
| 	repo_model "code.gitea.io/gitea/models/repo" | ||||
| 	"code.gitea.io/gitea/modules/storage" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/modules/validation" | ||||
| 	"code.gitea.io/gitea/services/context/upload" | ||||
| 
 | ||||
| 	"github.com/google/uuid" | ||||
|  | @ -43,6 +44,28 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R | |||
| 	return attach, err | ||||
| } | ||||
| 
 | ||||
| func NewExternalAttachment(ctx context.Context, attach *repo_model.Attachment) (*repo_model.Attachment, error) { | ||||
| 	if attach.RepoID == 0 { | ||||
| 		return nil, fmt.Errorf("attachment %s should belong to a repository", attach.Name) | ||||
| 	} | ||||
| 	if attach.ExternalURL == "" { | ||||
| 		return nil, fmt.Errorf("attachment %s should have a external url", attach.Name) | ||||
| 	} | ||||
| 	if !validation.IsValidExternalURL(attach.ExternalURL) { | ||||
| 		return nil, repo_model.ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} | ||||
| 	} | ||||
| 
 | ||||
| 	attach.UUID = uuid.New().String() | ||||
| 
 | ||||
| 	eng := db.GetEngine(ctx) | ||||
| 	if attach.NoAutoTime { | ||||
| 		eng.NoAutoTime() | ||||
| 	} | ||||
| 	_, err := eng.Insert(attach) | ||||
| 
 | ||||
| 	return attach, err | ||||
| } | ||||
| 
 | ||||
| // UploadAttachment upload new attachment into storage and update database | ||||
| func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, fileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) { | ||||
| 	buf := make([]byte, 1024) | ||||
|  |  | |||
|  | @ -9,6 +9,10 @@ import ( | |||
| ) | ||||
| 
 | ||||
| func WebAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachment) string { | ||||
| 	if attach.ExternalURL != "" { | ||||
| 		return attach.ExternalURL | ||||
| 	} | ||||
| 
 | ||||
| 	return attach.DownloadURL() | ||||
| } | ||||
| 
 | ||||
|  | @ -28,6 +32,12 @@ func ToAPIAttachment(repo *repo_model.Repository, a *repo_model.Attachment) *api | |||
| 
 | ||||
| // toAttachment converts models.Attachment to api.Attachment for API usage | ||||
| func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Attachment { | ||||
| 	var typeName string | ||||
| 	if a.ExternalURL != "" { | ||||
| 		typeName = "external" | ||||
| 	} else { | ||||
| 		typeName = "attachment" | ||||
| 	} | ||||
| 	return &api.Attachment{ | ||||
| 		ID:            a.ID, | ||||
| 		Name:          a.Name, | ||||
|  | @ -36,6 +46,7 @@ func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDown | |||
| 		Size:          a.Size, | ||||
| 		UUID:          a.UUID, | ||||
| 		DownloadURL:   getDownloadURL(repo, a), // for web request json and api request json, return different download urls | ||||
| 		Type:          typeName, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -129,7 +129,7 @@ func (o *release) Put(ctx context.Context) generic.NodeID { | |||
| 		panic(err) | ||||
| 	} | ||||
| 	defer gitRepo.Close() | ||||
| 	if err := release_service.CreateRelease(gitRepo, o.forgejoRelease, nil, ""); err != nil { | ||||
| 	if err := release_service.CreateRelease(gitRepo, o.forgejoRelease, "", nil); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	o.Trace("release created %d", o.forgejoRelease.ID) | ||||
|  |  | |||
|  | @ -23,9 +23,18 @@ import ( | |||
| 	"code.gitea.io/gitea/modules/storage" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| 	"code.gitea.io/gitea/services/attachment" | ||||
| 	notify_service "code.gitea.io/gitea/services/notify" | ||||
| ) | ||||
| 
 | ||||
| type AttachmentChange struct { | ||||
| 	Action      string // "add", "delete", "update | ||||
| 	Type        string // "attachment", "external" | ||||
| 	UUID        string | ||||
| 	Name        string | ||||
| 	ExternalURL string | ||||
| } | ||||
| 
 | ||||
| func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Release, msg string) (bool, error) { | ||||
| 	err := rel.LoadAttributes(ctx) | ||||
| 	if err != nil { | ||||
|  | @ -128,7 +137,7 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel | |||
| } | ||||
| 
 | ||||
| // CreateRelease creates a new release of repository. | ||||
| func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentUUIDs []string, msg string) error { | ||||
| func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, msg string, attachmentChanges []*AttachmentChange) error { | ||||
| 	has, err := repo_model.IsReleaseExist(gitRepo.Ctx, rel.RepoID, rel.TagName) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
|  | @ -147,7 +156,42 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU | |||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err = repo_model.AddReleaseAttachments(gitRepo.Ctx, rel.ID, attachmentUUIDs); err != nil { | ||||
| 	addAttachmentUUIDs := make(container.Set[string]) | ||||
| 
 | ||||
| 	for _, attachmentChange := range attachmentChanges { | ||||
| 		if attachmentChange.Action != "add" { | ||||
| 			return fmt.Errorf("can only create new attachments when creating release") | ||||
| 		} | ||||
| 		switch attachmentChange.Type { | ||||
| 		case "attachment": | ||||
| 			if attachmentChange.UUID == "" { | ||||
| 				return fmt.Errorf("new attachment should have a uuid") | ||||
| 			} | ||||
| 			addAttachmentUUIDs.Add(attachmentChange.UUID) | ||||
| 		case "external": | ||||
| 			if attachmentChange.Name == "" || attachmentChange.ExternalURL == "" { | ||||
| 				return fmt.Errorf("new external attachment should have a name and external url") | ||||
| 			} | ||||
| 
 | ||||
| 			_, err = attachment.NewExternalAttachment(gitRepo.Ctx, &repo_model.Attachment{ | ||||
| 				Name:        attachmentChange.Name, | ||||
| 				UploaderID:  rel.PublisherID, | ||||
| 				RepoID:      rel.RepoID, | ||||
| 				ReleaseID:   rel.ID, | ||||
| 				ExternalURL: attachmentChange.ExternalURL, | ||||
| 			}) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		default: | ||||
| 			if attachmentChange.Type == "" { | ||||
| 				return fmt.Errorf("missing attachment type") | ||||
| 			} | ||||
| 			return fmt.Errorf("unknown attachment type: '%q'", attachmentChange.Type) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = repo_model.AddReleaseAttachments(gitRepo.Ctx, rel.ID, addAttachmentUUIDs.Values()); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
|  | @ -198,8 +242,7 @@ func CreateNewTag(ctx context.Context, doer *user_model.User, repo *repo_model.R | |||
| // addAttachmentUUIDs accept a slice of new created attachments' uuids which will be reassigned release_id as the created release | ||||
| // delAttachmentUUIDs accept a slice of attachments' uuids which will be deleted from the release | ||||
| // editAttachments accept a map of attachment uuid to new attachment name which will be updated with attachments. | ||||
| func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, | ||||
| 	addAttachmentUUIDs, delAttachmentUUIDs []string, editAttachments map[string]string, createdFromTag bool, | ||||
| func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, createdFromTag bool, attachmentChanges []*AttachmentChange, | ||||
| ) error { | ||||
| 	if rel.ID == 0 { | ||||
| 		return errors.New("UpdateRelease only accepts an exist release") | ||||
|  | @ -220,14 +263,64 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo | |||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err = repo_model.AddReleaseAttachments(ctx, rel.ID, addAttachmentUUIDs); err != nil { | ||||
| 	addAttachmentUUIDs := make(container.Set[string]) | ||||
| 	delAttachmentUUIDs := make(container.Set[string]) | ||||
| 	updateAttachmentUUIDs := make(container.Set[string]) | ||||
| 	updateAttachments := make(container.Set[*AttachmentChange]) | ||||
| 
 | ||||
| 	for _, attachmentChange := range attachmentChanges { | ||||
| 		switch attachmentChange.Action { | ||||
| 		case "add": | ||||
| 			switch attachmentChange.Type { | ||||
| 			case "attachment": | ||||
| 				if attachmentChange.UUID == "" { | ||||
| 					return fmt.Errorf("new attachment should have a uuid (%s)}", attachmentChange.Name) | ||||
| 				} | ||||
| 				addAttachmentUUIDs.Add(attachmentChange.UUID) | ||||
| 			case "external": | ||||
| 				if attachmentChange.Name == "" || attachmentChange.ExternalURL == "" { | ||||
| 					return fmt.Errorf("new external attachment should have a name and external url") | ||||
| 				} | ||||
| 				_, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{ | ||||
| 					Name:        attachmentChange.Name, | ||||
| 					UploaderID:  doer.ID, | ||||
| 					RepoID:      rel.RepoID, | ||||
| 					ReleaseID:   rel.ID, | ||||
| 					ExternalURL: attachmentChange.ExternalURL, | ||||
| 				}) | ||||
| 				if err != nil { | ||||
| 					return err | ||||
| 				} | ||||
| 			default: | ||||
| 				if attachmentChange.Type == "" { | ||||
| 					return fmt.Errorf("missing attachment type") | ||||
| 				} | ||||
| 				return fmt.Errorf("unknown attachment type: %q", attachmentChange.Type) | ||||
| 			} | ||||
| 		case "delete": | ||||
| 			if attachmentChange.UUID == "" { | ||||
| 				return fmt.Errorf("attachment deletion should have a uuid") | ||||
| 			} | ||||
| 			delAttachmentUUIDs.Add(attachmentChange.UUID) | ||||
| 		case "update": | ||||
| 			updateAttachmentUUIDs.Add(attachmentChange.UUID) | ||||
| 			updateAttachments.Add(attachmentChange) | ||||
| 		default: | ||||
| 			if attachmentChange.Action == "" { | ||||
| 				return fmt.Errorf("missing attachment action") | ||||
| 			} | ||||
| 			return fmt.Errorf("unknown attachment action: %q", attachmentChange.Action) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = repo_model.AddReleaseAttachments(ctx, rel.ID, addAttachmentUUIDs.Values()); err != nil { | ||||
| 		return fmt.Errorf("AddReleaseAttachments: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	deletedUUIDs := make(container.Set[string]) | ||||
| 	if len(delAttachmentUUIDs) > 0 { | ||||
| 		// Check attachments | ||||
| 		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs) | ||||
| 		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs.Values()) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", delAttachmentUUIDs, err) | ||||
| 		} | ||||
|  | @ -246,15 +339,11 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if len(editAttachments) > 0 { | ||||
| 		updateAttachmentsList := make([]string, 0, len(editAttachments)) | ||||
| 		for k := range editAttachments { | ||||
| 			updateAttachmentsList = append(updateAttachmentsList, k) | ||||
| 		} | ||||
| 	if len(updateAttachmentUUIDs) > 0 { | ||||
| 		// Check attachments | ||||
| 		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentsList) | ||||
| 		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentUUIDs.Values()) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentsList, err) | ||||
| 			return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentUUIDs, err) | ||||
| 		} | ||||
| 		for _, attach := range attachments { | ||||
| 			if attach.ReleaseID != rel.ID { | ||||
|  | @ -264,15 +353,16 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo | |||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 		for uuid, newName := range editAttachments { | ||||
| 			if !deletedUUIDs.Contains(uuid) { | ||||
| 				if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{ | ||||
| 					UUID: uuid, | ||||
| 					Name: newName, | ||||
| 				}, "name"); err != nil { | ||||
| 					return err | ||||
| 				} | ||||
| 	for attachmentChange := range updateAttachments { | ||||
| 		if !deletedUUIDs.Contains(attachmentChange.UUID) { | ||||
| 			if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{ | ||||
| 				UUID:        attachmentChange.UUID, | ||||
| 				Name:        attachmentChange.Name, | ||||
| 				ExternalURL: attachmentChange.ExternalURL, | ||||
| 			}, "name", "external_url"); err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | @ -281,7 +371,7 @@ func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repo | |||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	for _, uuid := range delAttachmentUUIDs { | ||||
| 	for _, uuid := range delAttachmentUUIDs.Values() { | ||||
| 		if err := storage.Attachments.Delete(repo_model.AttachmentRelativePath(uuid)); err != nil { | ||||
| 			// Even delete files failed, but the attachments has been removed from database, so we | ||||
| 			// should not return error but only record the error on logs. | ||||
|  |  | |||
|  | @ -47,7 +47,7 @@ func TestRelease_Create(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 
 | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ | ||||
| 		RepoID:       repo.ID, | ||||
|  | @ -61,7 +61,7 @@ func TestRelease_Create(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 
 | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ | ||||
| 		RepoID:       repo.ID, | ||||
|  | @ -75,7 +75,7 @@ func TestRelease_Create(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 
 | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ | ||||
| 		RepoID:       repo.ID, | ||||
|  | @ -89,7 +89,7 @@ func TestRelease_Create(t *testing.T) { | |||
| 		IsDraft:      true, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 
 | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ | ||||
| 		RepoID:       repo.ID, | ||||
|  | @ -103,7 +103,7 @@ func TestRelease_Create(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: true, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 
 | ||||
| 	testPlayload := "testtest" | ||||
| 
 | ||||
|  | @ -127,7 +127,67 @@ func TestRelease_Create(t *testing.T) { | |||
| 		IsPrerelease: false, | ||||
| 		IsTag:        true, | ||||
| 	} | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &release, []string{attach.UUID}, "test")) | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &release, "test", []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action: "add", | ||||
| 			Type:   "attachment", | ||||
| 			UUID:   attach.UUID, | ||||
| 		}, | ||||
| 	})) | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, &release)) | ||||
| 	assert.Len(t, release.Attachments, 1) | ||||
| 	assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) | ||||
| 	assert.EqualValues(t, attach.Name, release.Attachments[0].Name) | ||||
| 	assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL) | ||||
| 
 | ||||
| 	release = repo_model.Release{ | ||||
| 		RepoID:       repo.ID, | ||||
| 		Repo:         repo, | ||||
| 		PublisherID:  user.ID, | ||||
| 		Publisher:    user, | ||||
| 		TagName:      "v0.1.6", | ||||
| 		Target:       "65f1bf2", | ||||
| 		Title:        "v0.1.6 is released", | ||||
| 		Note:         "v0.1.6 is released", | ||||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        true, | ||||
| 	} | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, &release, "", []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action:      "add", | ||||
| 			Type:        "external", | ||||
| 			Name:        "test", | ||||
| 			ExternalURL: "https://forgejo.org/", | ||||
| 		}, | ||||
| 	})) | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, &release)) | ||||
| 	assert.Len(t, release.Attachments, 1) | ||||
| 	assert.EqualValues(t, "test", release.Attachments[0].Name) | ||||
| 	assert.EqualValues(t, "https://forgejo.org/", release.Attachments[0].ExternalURL) | ||||
| 
 | ||||
| 	release = repo_model.Release{ | ||||
| 		RepoID:       repo.ID, | ||||
| 		Repo:         repo, | ||||
| 		PublisherID:  user.ID, | ||||
| 		Publisher:    user, | ||||
| 		TagName:      "v0.1.7", | ||||
| 		Target:       "65f1bf2", | ||||
| 		Title:        "v0.1.7 is released", | ||||
| 		Note:         "v0.1.7 is released", | ||||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        true, | ||||
| 	} | ||||
| 	assert.Error(t, CreateRelease(gitRepo, &repo_model.Release{}, "", []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action: "add", | ||||
| 			Type:   "external", | ||||
| 			Name:   "Click me", | ||||
| 			// Invalid URL (API URL of current instance), this should result in an error | ||||
| 			ExternalURL: "https://try.gitea.io/api/v1/user/follow", | ||||
| 		}, | ||||
| 	})) | ||||
| } | ||||
| 
 | ||||
| func TestRelease_Update(t *testing.T) { | ||||
|  | @ -153,13 +213,13 @@ func TestRelease_Update(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 	release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1") | ||||
| 	assert.NoError(t, err) | ||||
| 	releaseCreatedUnix := release.CreatedUnix | ||||
| 	time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp | ||||
| 	release.Note = "Changed note" | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) | ||||
| 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) | ||||
|  | @ -177,13 +237,13 @@ func TestRelease_Update(t *testing.T) { | |||
| 		IsDraft:      true, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 	release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1") | ||||
| 	assert.NoError(t, err) | ||||
| 	releaseCreatedUnix = release.CreatedUnix | ||||
| 	time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp | ||||
| 	release.Title = "Changed title" | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) | ||||
| 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) | ||||
|  | @ -201,14 +261,14 @@ func TestRelease_Update(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: true, | ||||
| 		IsTag:        false, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*AttachmentChange{})) | ||||
| 	release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1") | ||||
| 	assert.NoError(t, err) | ||||
| 	releaseCreatedUnix = release.CreatedUnix | ||||
| 	time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp | ||||
| 	release.Title = "Changed title" | ||||
| 	release.Note = "Changed note" | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) | ||||
| 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) | ||||
|  | @ -227,13 +287,13 @@ func TestRelease_Update(t *testing.T) { | |||
| 		IsPrerelease: false, | ||||
| 		IsTag:        false, | ||||
| 	} | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, release, nil, "")) | ||||
| 	assert.NoError(t, CreateRelease(gitRepo, release, "", []*AttachmentChange{})) | ||||
| 	assert.Greater(t, release.ID, int64(0)) | ||||
| 
 | ||||
| 	release.IsDraft = false | ||||
| 	tagName := release.TagName | ||||
| 
 | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) | ||||
| 	release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.Equal(t, tagName, release.TagName) | ||||
|  | @ -247,29 +307,79 @@ func TestRelease_Update(t *testing.T) { | |||
| 	}, strings.NewReader(samplePayload), int64(len([]byte(samplePayload)))) | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, []string{attach.UUID}, nil, nil, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action: "add", | ||||
| 			Type:   "attachment", | ||||
| 			UUID:   attach.UUID, | ||||
| 		}, | ||||
| 	})) | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) | ||||
| 	assert.Len(t, release.Attachments, 1) | ||||
| 	assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) | ||||
| 	assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) | ||||
| 	assert.EqualValues(t, attach.Name, release.Attachments[0].Name) | ||||
| 	assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL) | ||||
| 
 | ||||
| 	// update the attachment name | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, map[string]string{ | ||||
| 		attach.UUID: "test2.txt", | ||||
| 	}, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action: "update", | ||||
| 			Name:   "test2.txt", | ||||
| 			UUID:   attach.UUID, | ||||
| 		}, | ||||
| 	})) | ||||
| 	release.Attachments = nil | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) | ||||
| 	assert.Len(t, release.Attachments, 1) | ||||
| 	assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) | ||||
| 	assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) | ||||
| 	assert.EqualValues(t, "test2.txt", release.Attachments[0].Name) | ||||
| 	assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL) | ||||
| 
 | ||||
| 	// delete the attachment | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, []string{attach.UUID}, nil, false)) | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action: "delete", | ||||
| 			UUID:   attach.UUID, | ||||
| 		}, | ||||
| 	})) | ||||
| 	release.Attachments = nil | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) | ||||
| 	assert.Empty(t, release.Attachments) | ||||
| 
 | ||||
| 	// Add new external attachment | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action:      "add", | ||||
| 			Type:        "external", | ||||
| 			Name:        "test", | ||||
| 			ExternalURL: "https://forgejo.org/", | ||||
| 		}, | ||||
| 	})) | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) | ||||
| 	assert.Len(t, release.Attachments, 1) | ||||
| 	assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) | ||||
| 	assert.EqualValues(t, "test", release.Attachments[0].Name) | ||||
| 	assert.EqualValues(t, "https://forgejo.org/", release.Attachments[0].ExternalURL) | ||||
| 	externalAttachmentUUID := release.Attachments[0].UUID | ||||
| 
 | ||||
| 	// update the attachment name | ||||
| 	assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ | ||||
| 		{ | ||||
| 			Action:      "update", | ||||
| 			Name:        "test2", | ||||
| 			UUID:        externalAttachmentUUID, | ||||
| 			ExternalURL: "https://about.gitea.com/", | ||||
| 		}, | ||||
| 	})) | ||||
| 	release.Attachments = nil | ||||
| 	assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) | ||||
| 	assert.Len(t, release.Attachments, 1) | ||||
| 	assert.EqualValues(t, externalAttachmentUUID, release.Attachments[0].UUID) | ||||
| 	assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) | ||||
| 	assert.EqualValues(t, "test2", release.Attachments[0].Name) | ||||
| 	assert.EqualValues(t, "https://about.gitea.com/", release.Attachments[0].ExternalURL) | ||||
| } | ||||
| 
 | ||||
| func TestRelease_createTag(t *testing.T) { | ||||
|  |  | |||
|  | @ -72,7 +72,9 @@ | |||
| 								<ul class="list"> | ||||
| 									{{if $hasArchiveLinks}} | ||||
| 										<li> | ||||
| 											<a class="archive-link tw-flex-1" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.zip" rel="nofollow"><strong>{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP)</strong></a> | ||||
| 											<a class="archive-link tw-flex-1 flex-text-inline tw-font-bold" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.zip" rel="nofollow"> | ||||
| 												{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP) | ||||
| 											</a> | ||||
| 											<div class="tw-mr-1"> | ||||
| 												<span class="text grey">{{ctx.Locale.TrN .Release.ArchiveDownloadCount.Zip "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .Release.ArchiveDownloadCount.Zip)}}</span> | ||||
| 											</div> | ||||
|  | @ -81,7 +83,9 @@ | |||
| 											</span> | ||||
| 										</li> | ||||
| 										<li class="{{if $hasReleaseAttachment}}start-gap{{end}}"> | ||||
| 											<a class="archive-link tw-flex-1" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow"><strong>{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (TAR.GZ)</strong></a> | ||||
| 											<a class="archive-link tw-flex-1 flex-text-inline tw-font-bold" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow"> | ||||
| 												{{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP) | ||||
| 											</a> | ||||
| 											<div class="tw-mr-1"> | ||||
| 												<span class="text grey">{{ctx.Locale.TrN .Release.ArchiveDownloadCount.TarGz "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .Release.ArchiveDownloadCount.TarGz)}}</span> | ||||
| 											</div> | ||||
|  | @ -92,14 +96,22 @@ | |||
| 										{{if $hasReleaseAttachment}}<hr>{{end}} | ||||
| 									{{end}} | ||||
| 									{{range $release.Attachments}} | ||||
| 										<li> | ||||
| 											<a target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> | ||||
| 												<strong>{{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}}</strong> | ||||
| 											</a> | ||||
| 											<div> | ||||
| 												<span class="text grey">{{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span> | ||||
| 											</div> | ||||
| 										</li> | ||||
| 										{{if .ExternalURL}} | ||||
| 											<li> | ||||
| 												<a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> | ||||
| 													{{svg "octicon-link-external" 16 "tw-mr-1"}}{{.Name}} | ||||
| 												</a> | ||||
| 											</li> | ||||
| 										{{else}} | ||||
| 											<li> | ||||
| 												<a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> | ||||
| 													{{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}} | ||||
| 												</a> | ||||
| 												<div> | ||||
| 													<span class="text grey">{{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span> | ||||
| 												</div> | ||||
| 											</li> | ||||
| 										{{end}} | ||||
| 									{{end}} | ||||
| 								</ul> | ||||
| 							</details> | ||||
|  |  | |||
|  | @ -63,15 +63,45 @@ | |||
| 				{{range .attachments}} | ||||
| 					<div class="field flex-text-block" id="attachment-{{.ID}}"> | ||||
| 						<div class="flex-text-inline tw-flex-1"> | ||||
| 							<input name="attachment-edit-{{.UUID}}"  class="attachment_edit" required value="{{.Name}}"> | ||||
| 							<input name="attachment-del-{{.UUID}}" type="hidden" value="false"> | ||||
| 							<span class="ui text grey tw-whitespace-nowrap">{{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span> | ||||
| 							<div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_attachment"}}"> | ||||
| 								{{if .ExternalURL}} | ||||
| 									{{svg "octicon-link-external" 16 "tw-mr-2"}} | ||||
| 								{{else}} | ||||
| 									{{svg "octicon-package" 16 "tw-mr-2"}} | ||||
| 								{{end}} | ||||
| 							</div> | ||||
| 							<input name="attachment-edit-name-{{.UUID}}" placeholder="{{ctx.Locale.Tr "repo.release.asset_name"}}" class="attachment_edit" required value="{{.Name}}"> | ||||
| 							<input name="attachment-del-{{.UUID}}" type="hidden" | ||||
| 							value="false"> | ||||
| 							{{if .ExternalURL}} | ||||
| 								<input name="attachment-edit-exturl-{{.UUID}}" placeholder="{{ctx.Locale.Tr "repo.release.asset_external_url"}}" class="attachment_edit" required value="{{.ExternalURL}}"> | ||||
| 							{{else}} | ||||
| 								<span class="ui text grey tw-whitespace-nowrap tw-ml-auto tw-pl-3">{{ctx.Locale.TrN | ||||
| 								.DownloadCount "repo.release.download_count_one" | ||||
| 								"repo.release.download_count_few" (ctx.Locale.PrettyNumber | ||||
| 								.DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span> | ||||
| 							{{end}} | ||||
| 						</div> | ||||
| 						<a class="ui mini compact red button remove-rel-attach" data-id="{{.ID}}" data-uuid="{{.UUID}}"> | ||||
| 						<a class="ui mini red button remove-rel-attach tw-ml-3" data-id="{{.ID}}" data-uuid="{{.UUID}}"> | ||||
| 							{{ctx.Locale.Tr "remove"}} | ||||
| 						</a> | ||||
| 					</div> | ||||
| 				{{end}} | ||||
| 				<div class="field flex-text-block tw-hidden" id="attachment-template"> | ||||
| 					<div class="flex-text-inline tw-flex-1"> | ||||
| 						<div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_external_asset"}}"> | ||||
| 							{{svg "octicon-link-external" 16 "tw-mr-2"}} | ||||
| 						</div> | ||||
| 						<input name="attachment-template-new-name" placeholder="{{ctx.Locale.Tr "repo.release.asset_name"}}" class="attachment_edit"> | ||||
| 						<input name="attachment-template-new-exturl" placeholder="{{ctx.Locale.Tr "repo.release.asset_external_url"}}" class="attachment_edit"> | ||||
| 					</div> | ||||
| 					<a class="ui mini red button remove-rel-attach tw-ml-3"> | ||||
| 						{{ctx.Locale.Tr "remove"}} | ||||
| 					</a> | ||||
| 				</div> | ||||
| 				<a class="ui mini button tw-float-right tw-mb-4 tw-mt-2" id="add-external-link"> | ||||
| 					{{ctx.Locale.Tr "repo.release.add_external_asset"}} | ||||
| 				</a> | ||||
| 				{{if .IsAttachmentEnabled}} | ||||
| 					<div class="field"> | ||||
| 						{{template "repo/upload" .}} | ||||
|  |  | |||
							
								
								
									
										21
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										21
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							|  | @ -13623,9 +13623,15 @@ | |||
|           }, | ||||
|           { | ||||
|             "type": "file", | ||||
|             "description": "attachment to upload", | ||||
|             "description": "attachment to upload (this parameter is incompatible with `external_url`)", | ||||
|             "name": "attachment", | ||||
|             "in": "formData" | ||||
|           }, | ||||
|           { | ||||
|             "type": "string", | ||||
|             "description": "url to external asset (this parameter is incompatible with `attachment`)", | ||||
|             "name": "external_url", | ||||
|             "in": "formData" | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|  | @ -19001,6 +19007,14 @@ | |||
|           "format": "int64", | ||||
|           "x-go-name": "Size" | ||||
|         }, | ||||
|         "type": { | ||||
|           "type": "string", | ||||
|           "enum": [ | ||||
|             "attachment", | ||||
|             "external" | ||||
|           ], | ||||
|           "x-go-name": "Type" | ||||
|         }, | ||||
|         "uuid": { | ||||
|           "type": "string", | ||||
|           "x-go-name": "UUID" | ||||
|  | @ -20979,6 +20993,11 @@ | |||
|       "description": "EditAttachmentOptions options for editing attachments", | ||||
|       "type": "object", | ||||
|       "properties": { | ||||
|         "browser_download_url": { | ||||
|           "description": "(Can only be set if existing attachment is of external type)", | ||||
|           "type": "string", | ||||
|           "x-go-name": "DownloadURL" | ||||
|         }, | ||||
|         "name": { | ||||
|           "type": "string", | ||||
|           "x-go-name": "Name" | ||||
|  |  | |||
							
								
								
									
										67
									
								
								tests/e2e/release.test.e2e.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								tests/e2e/release.test.e2e.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,67 @@ | |||
| // @ts-check
 | ||||
| import {test, expect} from '@playwright/test'; | ||||
| import {login_user, save_visual, load_logged_in_context} from './utils_e2e.js'; | ||||
| 
 | ||||
| test.beforeAll(async ({browser}, workerInfo) => { | ||||
|   await login_user(browser, workerInfo, 'user2'); | ||||
| }); | ||||
| 
 | ||||
| test.describe.configure({ | ||||
|   timeout: 30000, | ||||
| }); | ||||
| 
 | ||||
| test('External Release Attachments', async ({browser, isMobile}, workerInfo) => { | ||||
|   test.skip(isMobile); | ||||
| 
 | ||||
|   const context = await load_logged_in_context(browser, workerInfo, 'user2'); | ||||
|   /** @type {import('@playwright/test').Page} */ | ||||
|   const page = await context.newPage(); | ||||
| 
 | ||||
|   // Click "New Release"
 | ||||
|   await page.goto('/user2/repo2/releases'); | ||||
|   await page.click('.button.small.primary'); | ||||
| 
 | ||||
|   // Fill out form and create new release
 | ||||
|   await page.fill('input[name=tag_name]', '2.0'); | ||||
|   await page.fill('input[name=title]', '2.0'); | ||||
|   await page.click('#add-external-link'); | ||||
|   await page.click('#add-external-link'); | ||||
|   await page.fill('input[name=attachment-new-name-2]', 'Test'); | ||||
|   await page.fill('input[name=attachment-new-exturl-2]', 'https://forgejo.org/'); | ||||
|   await page.click('.remove-rel-attach'); | ||||
|   save_visual(page); | ||||
|   await page.click('.button.small.primary'); | ||||
| 
 | ||||
|   // Validate release page and click edit
 | ||||
|   await expect(page.locator('.download[open] li')).toHaveCount(3); | ||||
|   await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test'); | ||||
|   await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://forgejo.org/'); | ||||
|   save_visual(page); | ||||
|   await page.locator('.octicon-pencil').first().click(); | ||||
| 
 | ||||
|   // Validate edit page and edit the release
 | ||||
|   await expect(page.locator('.attachment_edit:visible')).toHaveCount(2); | ||||
|   await expect(page.locator('.attachment_edit:visible').nth(0)).toHaveValue('Test'); | ||||
|   await expect(page.locator('.attachment_edit:visible').nth(1)).toHaveValue('https://forgejo.org/'); | ||||
|   await page.locator('.attachment_edit:visible').nth(0).fill('Test2'); | ||||
|   await page.locator('.attachment_edit:visible').nth(1).fill('https://gitea.io/'); | ||||
|   await page.click('#add-external-link'); | ||||
|   await expect(page.locator('.attachment_edit:visible')).toHaveCount(4); | ||||
|   await page.locator('.attachment_edit:visible').nth(2).fill('Test3'); | ||||
|   await page.locator('.attachment_edit:visible').nth(3).fill('https://gitea.com/'); | ||||
|   save_visual(page); | ||||
|   await page.click('.button.small.primary'); | ||||
| 
 | ||||
|   // Validate release page and click edit
 | ||||
|   await expect(page.locator('.download[open] li')).toHaveCount(4); | ||||
|   await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test2'); | ||||
|   await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://gitea.io/'); | ||||
|   await expect(page.locator('.download[open] li:nth-of-type(4)')).toContainText('Test3'); | ||||
|   await expect(page.locator('.download[open] li:nth-of-type(4) a')).toHaveAttribute('href', 'https://gitea.com/'); | ||||
|   save_visual(page); | ||||
|   await page.locator('.octicon-pencil').first().click(); | ||||
| 
 | ||||
|   // Delete release
 | ||||
|   await page.click('.delete-button'); | ||||
|   await page.click('.button.ok'); | ||||
| }); | ||||
|  | @ -347,6 +347,7 @@ func TestAPIUploadAssetRelease(t *testing.T) { | |||
| 
 | ||||
| 		assert.EqualValues(t, "stream.bin", attachment.Name) | ||||
| 		assert.EqualValues(t, 104, attachment.Size) | ||||
| 		assert.EqualValues(t, "attachment", attachment.Type) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
|  | @ -385,3 +386,69 @@ func TestAPIGetReleaseArchiveDownloadCount(t *testing.T) { | |||
| 	assert.Equal(t, int64(1), release.ArchiveDownloadCount.TarGz) | ||||
| 	assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip) | ||||
| } | ||||
| 
 | ||||
| func TestAPIExternalAssetRelease(t *testing.T) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 
 | ||||
| 	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) | ||||
| 	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) | ||||
| 	session := loginUser(t, owner.LowerName) | ||||
| 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) | ||||
| 
 | ||||
| 	r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") | ||||
| 
 | ||||
| 	req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID)). | ||||
| 		AddTokenAuth(token) | ||||
| 	resp := MakeRequest(t, req, http.StatusCreated) | ||||
| 
 | ||||
| 	var attachment *api.Attachment | ||||
| 	DecodeJSON(t, resp, &attachment) | ||||
| 
 | ||||
| 	assert.EqualValues(t, "test-asset", attachment.Name) | ||||
| 	assert.EqualValues(t, 0, attachment.Size) | ||||
| 	assert.EqualValues(t, "https://forgejo.org/", attachment.DownloadURL) | ||||
| 	assert.EqualValues(t, "external", attachment.Type) | ||||
| } | ||||
| 
 | ||||
| func TestAPIDuplicateAssetRelease(t *testing.T) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 
 | ||||
| 	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) | ||||
| 	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) | ||||
| 	session := loginUser(t, owner.LowerName) | ||||
| 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) | ||||
| 
 | ||||
| 	r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") | ||||
| 
 | ||||
| 	filename := "image.png" | ||||
| 	buff := generateImg() | ||||
| 	body := &bytes.Buffer{} | ||||
| 
 | ||||
| 	writer := multipart.NewWriter(body) | ||||
| 	part, err := writer.CreateFormFile("attachment", filename) | ||||
| 	assert.NoError(t, err) | ||||
| 	_, err = io.Copy(part, &buff) | ||||
| 	assert.NoError(t, err) | ||||
| 	err = writer.Close() | ||||
| 	assert.NoError(t, err) | ||||
| 
 | ||||
| 	req := NewRequestWithBody(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID), body). | ||||
| 		AddTokenAuth(token) | ||||
| 	req.Header.Add("Content-Type", writer.FormDataContentType()) | ||||
| 	MakeRequest(t, req, http.StatusBadRequest) | ||||
| } | ||||
| 
 | ||||
| func TestAPIMissingAssetRelease(t *testing.T) { | ||||
| 	defer tests.PrepareTestEnv(t)() | ||||
| 
 | ||||
| 	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) | ||||
| 	owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) | ||||
| 	session := loginUser(t, owner.LowerName) | ||||
| 	token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) | ||||
| 
 | ||||
| 	r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") | ||||
| 
 | ||||
| 	req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset", owner.Name, repo.Name, r.ID)). | ||||
| 		AddTokenAuth(token) | ||||
| 	MakeRequest(t, req, http.StatusBadRequest) | ||||
| } | ||||
|  |  | |||
|  | @ -78,7 +78,7 @@ func TestMirrorPull(t *testing.T) { | |||
| 		IsDraft:      false, | ||||
| 		IsPrerelease: false, | ||||
| 		IsTag:        true, | ||||
| 	}, nil, "")) | ||||
| 	}, "", []*release_service.AttachmentChange{})) | ||||
| 
 | ||||
| 	_, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID) | ||||
| 	assert.NoError(t, err) | ||||
|  |  | |||
|  | @ -111,7 +111,7 @@ func TestWebhookReleaseEvents(t *testing.T) { | |||
| 			IsDraft:      false, | ||||
| 			IsPrerelease: false, | ||||
| 			IsTag:        false, | ||||
| 		}, nil, "")) | ||||
| 		}, "", nil)) | ||||
| 
 | ||||
| 		// check the newly created hooktasks | ||||
| 		hookTasksLenBefore := len(hookTasks) | ||||
|  | @ -125,7 +125,7 @@ func TestWebhookReleaseEvents(t *testing.T) { | |||
| 
 | ||||
| 		t.Run("UpdateRelease", func(t *testing.T) { | ||||
| 			rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.1"}) | ||||
| 			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, nil, nil, nil, false)) | ||||
| 			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, false, nil)) | ||||
| 
 | ||||
| 			// check the newly created hooktasks | ||||
| 			hookTasksLenBefore := len(hookTasks) | ||||
|  | @ -157,7 +157,7 @@ func TestWebhookReleaseEvents(t *testing.T) { | |||
| 
 | ||||
| 		t.Run("UpdateRelease", func(t *testing.T) { | ||||
| 			rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.2"}) | ||||
| 			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, nil, nil, nil, true)) | ||||
| 			assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, true, nil)) | ||||
| 
 | ||||
| 			// check the newly created hooktasks | ||||
| 			hookTasksLenBefore := len(hookTasks) | ||||
|  |  | |||
|  | @ -6,7 +6,8 @@ export function initRepoRelease() { | |||
|     el.addEventListener('click', (e) => { | ||||
|       const uuid = e.target.getAttribute('data-uuid'); | ||||
|       const id = e.target.getAttribute('data-id'); | ||||
|       document.querySelector(`input[name='attachment-del-${uuid}']`).value = 'true'; | ||||
|       document.querySelector(`input[name='attachment-del-${uuid}']`).value = | ||||
|         'true'; | ||||
|       hideElem(`#attachment-${id}`); | ||||
|     }); | ||||
|   } | ||||
|  | @ -17,6 +18,7 @@ export function initRepoReleaseNew() { | |||
| 
 | ||||
|   initTagNameEditor(); | ||||
|   initRepoReleaseEditor(); | ||||
|   initAddExternalLinkButton(); | ||||
| } | ||||
| 
 | ||||
| function initTagNameEditor() { | ||||
|  | @ -45,9 +47,49 @@ function initTagNameEditor() { | |||
| } | ||||
| 
 | ||||
| function initRepoReleaseEditor() { | ||||
|   const editor = document.querySelector('.repository.new.release .combo-markdown-editor'); | ||||
|   const editor = document.querySelector( | ||||
|     '.repository.new.release .combo-markdown-editor', | ||||
|   ); | ||||
|   if (!editor) { | ||||
|     return; | ||||
|   } | ||||
|   initComboMarkdownEditor(editor); | ||||
| } | ||||
| 
 | ||||
| let newAttachmentCount = 0; | ||||
| 
 | ||||
| function initAddExternalLinkButton() { | ||||
|   const addExternalLinkButton = document.getElementById('add-external-link'); | ||||
|   if (!addExternalLinkButton) return; | ||||
| 
 | ||||
|   addExternalLinkButton.addEventListener('click', () => { | ||||
|     newAttachmentCount += 1; | ||||
|     const attachmentTemplate = document.getElementById('attachment-template'); | ||||
| 
 | ||||
|     const newAttachment = attachmentTemplate.cloneNode(true); | ||||
|     newAttachment.id = `attachment-N${newAttachmentCount}`; | ||||
|     newAttachment.classList.remove('tw-hidden'); | ||||
| 
 | ||||
|     const attachmentName = newAttachment.querySelector( | ||||
|       'input[name="attachment-template-new-name"]', | ||||
|     ); | ||||
|     attachmentName.name = `attachment-new-name-${newAttachmentCount}`; | ||||
|     attachmentName.required = true; | ||||
| 
 | ||||
|     const attachmentExtUrl = newAttachment.querySelector( | ||||
|       'input[name="attachment-template-new-exturl"]', | ||||
|     ); | ||||
|     attachmentExtUrl.name = `attachment-new-exturl-${newAttachmentCount}`; | ||||
|     attachmentExtUrl.required = true; | ||||
| 
 | ||||
|     const attachmentDel = newAttachment.querySelector('.remove-rel-attach'); | ||||
|     attachmentDel.addEventListener('click', () => { | ||||
|       newAttachment.remove(); | ||||
|     }); | ||||
| 
 | ||||
|     attachmentTemplate.parentNode.insertBefore( | ||||
|       newAttachment, | ||||
|       attachmentTemplate, | ||||
|     ); | ||||
|   }); | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Malte Jürgens
				Malte Jürgens