diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 78c13f33a0..5ca12a99e1 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -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. diff --git a/models/forgejo_migrations/v19.go b/models/forgejo_migrations/v19.go new file mode 100644 index 0000000000..69b7746eb1 --- /dev/null +++ b/models/forgejo_migrations/v19.go @@ -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)) +} diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 546e409de7..128bcebb60 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -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) diff --git a/modules/structs/attachment.go b/modules/structs/attachment.go index 38beca5e99..c8a2c6634b 100644 --- a/modules/structs/attachment.go +++ b/modules/structs/attachment.go @@ -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"` } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 40807f238a..69a07f1947 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -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. diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index 1544a64273..979ab42b31 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -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 } diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go index 59fd83e3a2..5e43f2987a 100644 --- a/routers/api/v1/repo/release_attachment.go +++ b/routers/api/v1/repo/release_attachment.go @@ -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)) } diff --git a/routers/web/repo/attachment.go b/routers/web/repo/attachment.go index f0c5622aec..b42effd8c3 100644 --- a/routers/web/repo/attachment.go +++ b/routers/web/repo/attachment.go @@ -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 diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 3927e3d2d9..2266debd6e 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -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") diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index 4481966b4a..c911945e5d 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -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) diff --git a/services/convert/attachment.go b/services/convert/attachment.go index 4a8f10f7b0..d632c94c18 100644 --- a/services/convert/attachment.go +++ b/services/convert/attachment.go @@ -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, } } diff --git a/services/f3/driver/release.go b/services/f3/driver/release.go index d0672b8965..fab9222c11 100644 --- a/services/f3/driver/release.go +++ b/services/f3/driver/release.go @@ -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) diff --git a/services/release/release.go b/services/release/release.go index 5062af1436..11740e4cc8 100644 --- a/services/release/release.go +++ b/services/release/release.go @@ -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. diff --git a/services/release/release_test.go b/services/release/release_test.go index eac1879f87..cf4421a17d 100644 --- a/services/release/release_test.go +++ b/services/release/release_test.go @@ -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) { diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index bcb71e5f60..cc5c6702f3 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -72,7 +72,9 @@ diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index b653fc0c2c..9278e7c28b 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -63,15 +63,45 @@ {{range .attachments}}
- - - {{ctx.Locale.TrN .DownloadCount "repo.release.download_count_one" "repo.release.download_count_few" (ctx.Locale.PrettyNumber .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}} +
+ {{if .ExternalURL}} + {{svg "octicon-link-external" 16 "tw-mr-2"}} + {{else}} + {{svg "octicon-package" 16 "tw-mr-2"}} + {{end}} +
+ + + {{if .ExternalURL}} + + {{else}} + {{ctx.Locale.TrN + .DownloadCount "repo.release.download_count_one" + "repo.release.download_count_few" (ctx.Locale.PrettyNumber + .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}} + {{end}}
- + {{ctx.Locale.Tr "remove"}}
{{end}} +
+
+
+ {{svg "octicon-link-external" 16 "tw-mr-2"}} +
+ + +
+ + {{ctx.Locale.Tr "remove"}} + +
+ + {{ctx.Locale.Tr "repo.release.add_external_asset"}} + {{if .IsAttachmentEnabled}}
{{template "repo/upload" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index dacec3ed1a..27c448397e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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" diff --git a/tests/e2e/release.test.e2e.js b/tests/e2e/release.test.e2e.js new file mode 100644 index 0000000000..7e08a30fbe --- /dev/null +++ b/tests/e2e/release.test.e2e.js @@ -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'); +}); diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go index c49e6ef92e..a5e769e39f 100644 --- a/tests/integration/api_releases_test.go +++ b/tests/integration/api_releases_test.go @@ -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) +} diff --git a/tests/integration/mirror_pull_test.go b/tests/integration/mirror_pull_test.go index 77050c4bbc..ced828f002 100644 --- a/tests/integration/mirror_pull_test.go +++ b/tests/integration/mirror_pull_test.go @@ -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) diff --git a/tests/integration/webhook_test.go b/tests/integration/webhook_test.go index ec85d12b07..4c2b42f880 100644 --- a/tests/integration/webhook_test.go +++ b/tests/integration/webhook_test.go @@ -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) diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js index 3784bed2b1..0db9b8ac73 100644 --- a/web_src/js/features/repo-release.js +++ b/web_src/js/features/repo-release.js @@ -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, + ); + }); +}