Merge pull request 'Implement external release assets' (#1445) from maltejur/forgejo:forgejo-external-attachments into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/1445
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-07-30 15:50:57 +00:00
commit 94933470cd
22 changed files with 826 additions and 119 deletions

View file

@ -74,6 +74,8 @@ var migrations = []*Migration{
NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser), NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser),
// v18 -> v19 // v18 -> v19
NewMigration("Create the `following_repo` table", CreateFollowingRepoTable), NewMigration("Create the `following_repo` table", CreateFollowingRepoTable),
// v19 -> v20
NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable),
} }
// GetCurrentDBVersion returns the current Forgejo database version. // GetCurrentDBVersion returns the current Forgejo database version.

View 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))
}

View file

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/validation"
) )
// Attachment represent a attachment of issue/comment/release. // Attachment represent a attachment of issue/comment/release.
@ -31,6 +32,7 @@ type Attachment struct {
NoAutoTime bool `xorm:"-"` NoAutoTime bool `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"created"` CreatedUnix timeutil.TimeStamp `xorm:"created"`
CustomDownloadURL string `xorm:"-"` CustomDownloadURL string `xorm:"-"`
ExternalURL string
} }
func init() { func init() {
@ -59,6 +61,10 @@ func (a *Attachment) RelativePath() string {
// DownloadURL returns the download url of the attached file // DownloadURL returns the download url of the attached file
func (a *Attachment) DownloadURL() string { func (a *Attachment) DownloadURL() string {
if a.ExternalURL != "" {
return a.ExternalURL
}
if a.CustomDownloadURL != "" { if a.CustomDownloadURL != "" {
return a.CustomDownloadURL return a.CustomDownloadURL
} }
@ -86,6 +92,23 @@ func (err ErrAttachmentNotExist) Unwrap() error {
return util.ErrNotExist 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 // GetAttachmentByID returns attachment by given id
func GetAttachmentByID(ctx context.Context, id int64) (*Attachment, error) { func GetAttachmentByID(ctx context.Context, id int64) (*Attachment, error) {
attach := &Attachment{} attach := &Attachment{}
@ -221,12 +244,18 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str
if attach.UUID == "" { if attach.UUID == "" {
return fmt.Errorf("attachment uuid should be not blank") 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) _, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach)
return err return err
} }
// UpdateAttachment updates the given attachment in database // UpdateAttachment updates the given attachment in database
func UpdateAttachment(ctx context.Context, atta *Attachment) error { 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") sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count")
if atta.ID != 0 && atta.UUID == "" { if atta.ID != 0 && atta.UUID == "" {
sess = sess.ID(atta.ID) sess = sess.ID(atta.ID)

View file

@ -18,10 +18,14 @@ type Attachment struct {
Created time.Time `json:"created_at"` Created time.Time `json:"created_at"`
UUID string `json:"uuid"` UUID string `json:"uuid"`
DownloadURL string `json:"browser_download_url"` DownloadURL string `json:"browser_download_url"`
// Enum: attachment,external
Type string `json:"type"`
} }
// EditAttachmentOptions options for editing attachments // EditAttachmentOptions options for editing attachments
// swagger:model // swagger:model
type EditAttachmentOptions struct { type EditAttachmentOptions struct {
Name string `json:"name"` Name string `json:"name"`
// (Can only be set if existing attachment is of external type)
DownloadURL string `json:"browser_download_url"`
} }

View file

@ -2721,6 +2721,12 @@ release.add_tag = Create tag
release.releases_for = Releases for %s release.releases_for = Releases for %s
release.tags_for = Tags for %s release.tags_for = Tags for %s
release.system_generated = This attachment is automatically generated. 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.name = Branch name
branch.already_exists = A branch named "%s" already exists. branch.already_exists = A branch named "%s" already exists.

View file

@ -247,7 +247,7 @@ func CreateRelease(ctx *context.APIContext) {
IsTag: false, IsTag: false,
Repo: ctx.Repo.Repository, 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) { if repo_model.IsErrReleaseAlreadyExist(err) {
ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err) ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err)
} else if models.IsErrProtectedTagName(err) { } else if models.IsErrProtectedTagName(err) {
@ -274,7 +274,7 @@ func CreateRelease(ctx *context.APIContext) {
rel.Publisher = ctx.Doer rel.Publisher = ctx.Doer
rel.Target = form.Target 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) ctx.Error(http.StatusInternalServerError, "UpdateRelease", err)
return return
} }
@ -351,7 +351,7 @@ func EditRelease(ctx *context.APIContext) {
if form.HideArchiveLinks != nil { if form.HideArchiveLinks != nil {
rel.HideArchiveLinks = *form.HideArchiveLinks 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) ctx.Error(http.StatusInternalServerError, "UpdateRelease", err)
return return
} }

View file

@ -5,7 +5,10 @@ package repo
import ( import (
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/url"
"path"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -179,11 +182,18 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
// description: name of the attachment // description: name of the attachment
// type: string // type: string
// required: false // 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 // - name: attachment
// in: formData // in: formData
// description: attachment to upload // description: attachment to upload (this parameter is incompatible with `external_url`)
// type: file // type: file
// required: false // required: false
// - name: external_url
// in: formData
// description: url to external asset (this parameter is incompatible with `attachment`)
// type: string
// required: false
// responses: // responses:
// "201": // "201":
// "$ref": "#/responses/Attachment" // "$ref": "#/responses/Attachment"
@ -205,31 +215,40 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
} }
// Get uploaded file from request // Get uploaded file from request
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 isForm {
_, _, err := ctx.Req.FormFile("attachment")
hasAttachmentFile = err == nil
} else {
hasAttachmentFile = ctx.Req.Body != nil
}
if hasAttachmentFile && hasExternalURL {
ctx.Error(http.StatusBadRequest, "DuplicateAttachment", "'attachment' and 'external_url' are mutually exclusive")
} else if hasAttachmentFile {
var content io.ReadCloser var content io.ReadCloser
var filename string
var size int64 = -1 var size int64 = -1
if strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") { if isForm {
file, header, err := ctx.Req.FormFile("attachment") var header *multipart.FileHeader
if err != nil { content, header, _ = ctx.Req.FormFile("attachment")
ctx.Error(http.StatusInternalServerError, "GetFile", err)
return
}
defer file.Close()
content = file
size = header.Size size = header.Size
defer content.Close()
if filename == "" {
filename = header.Filename filename = header.Filename
if name := ctx.FormString("name"); name != "" {
filename = name
} }
} else { } else {
content = ctx.Req.Body content = ctx.Req.Body
filename = ctx.FormString("name") defer content.Close()
} }
if filename == "" { if filename == "" {
ctx.Error(http.StatusBadRequest, "CreateReleaseAttachment", "Could not determine name of attachment.") ctx.Error(http.StatusBadRequest, "MissingName", "Missing 'name' parameter")
return return
} }
@ -250,6 +269,42 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
} }
ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) 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 // EditReleaseAttachment updates the given attachment
@ -322,8 +377,21 @@ func EditReleaseAttachment(ctx *context.APIContext) {
attach.Name = form.Name 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 { 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)) ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach))
} }

View file

@ -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 { if err := attach.IncreaseDownloadCount(ctx); err != nil {
ctx.ServerError("IncreaseDownloadCount", err) ctx.ServerError("IncreaseDownloadCount", err)
return return

View file

@ -18,6 +18,7 @@ import (
"code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
@ -491,9 +492,44 @@ func NewReleasePost(ctx *context.Context) {
return return
} }
var attachmentUUIDs []string attachmentChanges := make(container.Set[*releaseservice.AttachmentChange])
attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange)
if setting.Attachment.Enabled { 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) rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName)
@ -553,7 +589,7 @@ func NewReleasePost(ctx *context.Context) {
IsTag: false, 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 ctx.Data["Err_TagName"] = true
switch { switch {
case repo_model.IsErrReleaseAlreadyExist(err): 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) ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form)
case models.IsErrProtectedTagName(err): case models.IsErrProtectedTagName(err):
ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form) 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: default:
ctx.ServerError("CreateRelease", err) ctx.ServerError("CreateRelease", err)
} }
@ -583,9 +621,14 @@ func NewReleasePost(ctx *context.Context) {
rel.HideArchiveLinks = form.HideArchiveLinks rel.HideArchiveLinks = form.HideArchiveLinks
rel.IsTag = false 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.Data["Err_TagName"] = true
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) ctx.ServerError("UpdateRelease", err)
}
return return
} }
} }
@ -667,6 +710,15 @@ func EditReleasePost(ctx *context.Context) {
ctx.Data["prerelease"] = rel.IsPrerelease ctx.Data["prerelease"] = rel.IsPrerelease
ctx.Data["hide_archive_links"] = rel.HideArchiveLinks 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() { if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew) ctx.HTML(http.StatusOK, tplReleaseNew)
return return
@ -674,15 +726,67 @@ func EditReleasePost(ctx *context.Context) {
const delPrefix = "attachment-del-" const delPrefix = "attachment-del-"
const editPrefix = "attachment-edit-" const editPrefix = "attachment-edit-"
var addAttachmentUUIDs, delAttachmentUUIDs []string const newPrefix = "attachment-new-"
editAttachments := make(map[string]string) // uuid -> new name const namePrefix = "name-"
const exturlPrefix = "exturl-"
attachmentChanges := make(container.Set[*releaseservice.AttachmentChange])
attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange)
if setting.Attachment.Enabled { 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 { for k, v := range ctx.Req.Form {
if strings.HasPrefix(k, delPrefix) && v[0] == "true" { if strings.HasPrefix(k, delPrefix) && v[0] == "true" {
delAttachmentUUIDs = append(delAttachmentUUIDs, k[len(delPrefix):]) attachmentChanges.Add(&releaseservice.AttachmentChange{
} else if strings.HasPrefix(k, editPrefix) { Action: "delete",
editAttachments[k[len(editPrefix):]] = v[0] 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.IsDraft = len(form.Draft) > 0
rel.IsPrerelease = form.Prerelease rel.IsPrerelease = form.Prerelease
rel.HideArchiveLinks = form.HideArchiveLinks rel.HideArchiveLinks = form.HideArchiveLinks
if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, attachmentChanges.Values()); err != nil {
rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments, false); 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) ctx.ServerError("UpdateRelease", err)
}
return return
} }
ctx.Redirect(ctx.Repo.RepoLink + "/releases") ctx.Redirect(ctx.Repo.RepoLink + "/releases")

View file

@ -13,6 +13,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/validation"
"code.gitea.io/gitea/services/context/upload" "code.gitea.io/gitea/services/context/upload"
"github.com/google/uuid" "github.com/google/uuid"
@ -43,6 +44,28 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R
return attach, err 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 // 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) { func UploadAttachment(ctx context.Context, file io.Reader, allowedTypes string, fileSize int64, attach *repo_model.Attachment) (*repo_model.Attachment, error) {
buf := make([]byte, 1024) buf := make([]byte, 1024)

View file

@ -9,6 +9,10 @@ import (
) )
func WebAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachment) string { func WebAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachment) string {
if attach.ExternalURL != "" {
return attach.ExternalURL
}
return attach.DownloadURL() 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 // 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 { 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{ return &api.Attachment{
ID: a.ID, ID: a.ID,
Name: a.Name, Name: a.Name,
@ -36,6 +46,7 @@ func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDown
Size: a.Size, Size: a.Size,
UUID: a.UUID, UUID: a.UUID,
DownloadURL: getDownloadURL(repo, a), // for web request json and api request json, return different download urls DownloadURL: getDownloadURL(repo, a), // for web request json and api request json, return different download urls
Type: typeName,
} }
} }

View file

@ -129,7 +129,7 @@ func (o *release) Put(ctx context.Context) generic.NodeID {
panic(err) panic(err)
} }
defer gitRepo.Close() 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) panic(err)
} }
o.Trace("release created %d", o.forgejoRelease.ID) o.Trace("release created %d", o.forgejoRelease.ID)

View file

@ -23,9 +23,18 @@ import (
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/attachment"
notify_service "code.gitea.io/gitea/services/notify" 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) { func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Release, msg string) (bool, error) {
err := rel.LoadAttributes(ctx) err := rel.LoadAttributes(ctx)
if err != nil { 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. // 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) has, err := repo_model.IsReleaseExist(gitRepo.Ctx, rel.RepoID, rel.TagName)
if err != nil { if err != nil {
return err return err
@ -147,7 +156,42 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU
return err 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 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 // 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 // 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. // 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, func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, createdFromTag bool, attachmentChanges []*AttachmentChange,
addAttachmentUUIDs, delAttachmentUUIDs []string, editAttachments map[string]string, createdFromTag bool,
) error { ) error {
if rel.ID == 0 { if rel.ID == 0 {
return errors.New("UpdateRelease only accepts an exist release") 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 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) return fmt.Errorf("AddReleaseAttachments: %w", err)
} }
deletedUUIDs := make(container.Set[string]) deletedUUIDs := make(container.Set[string])
if len(delAttachmentUUIDs) > 0 { if len(delAttachmentUUIDs) > 0 {
// Check attachments // Check attachments
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs) attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs.Values())
if err != nil { if err != nil {
return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", delAttachmentUUIDs, err) 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 { if len(updateAttachmentUUIDs) > 0 {
updateAttachmentsList := make([]string, 0, len(editAttachments))
for k := range editAttachments {
updateAttachmentsList = append(updateAttachmentsList, k)
}
// Check attachments // Check attachments
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentsList) attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentUUIDs.Values())
if err != nil { 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 { for _, attach := range attachments {
if attach.ReleaseID != rel.ID { 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 return err
} }
for _, uuid := range delAttachmentUUIDs { for _, uuid := range delAttachmentUUIDs.Values() {
if err := storage.Attachments.Delete(repo_model.AttachmentRelativePath(uuid)); err != nil { 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 // 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. // should not return error but only record the error on logs.

View file

@ -47,7 +47,7 @@ func TestRelease_Create(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
RepoID: repo.ID, RepoID: repo.ID,
@ -61,7 +61,7 @@ func TestRelease_Create(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
RepoID: repo.ID, RepoID: repo.ID,
@ -75,7 +75,7 @@ func TestRelease_Create(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
RepoID: repo.ID, RepoID: repo.ID,
@ -89,7 +89,7 @@ func TestRelease_Create(t *testing.T) {
IsDraft: true, IsDraft: true,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{
RepoID: repo.ID, RepoID: repo.ID,
@ -103,7 +103,7 @@ func TestRelease_Create(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: true, IsPrerelease: true,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
testPlayload := "testtest" testPlayload := "testtest"
@ -127,7 +127,67 @@ func TestRelease_Create(t *testing.T) {
IsPrerelease: false, IsPrerelease: false,
IsTag: true, 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) { func TestRelease_Update(t *testing.T) {
@ -153,13 +213,13 @@ func TestRelease_Update(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1") release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1")
assert.NoError(t, err) assert.NoError(t, err)
releaseCreatedUnix := release.CreatedUnix releaseCreatedUnix := release.CreatedUnix
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
release.Note = "Changed note" 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) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
@ -177,13 +237,13 @@ func TestRelease_Update(t *testing.T) {
IsDraft: true, IsDraft: true,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1") release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1")
assert.NoError(t, err) assert.NoError(t, err)
releaseCreatedUnix = release.CreatedUnix releaseCreatedUnix = release.CreatedUnix
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
release.Title = "Changed title" 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) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
@ -201,14 +261,14 @@ func TestRelease_Update(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: true, IsPrerelease: true,
IsTag: false, IsTag: false,
}, nil, "")) }, "", []*AttachmentChange{}))
release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1") release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1")
assert.NoError(t, err) assert.NoError(t, err)
releaseCreatedUnix = release.CreatedUnix releaseCreatedUnix = release.CreatedUnix
time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp
release.Title = "Changed title" release.Title = "Changed title"
release.Note = "Changed note" 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) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix))
@ -227,13 +287,13 @@ func TestRelease_Update(t *testing.T) {
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
} }
assert.NoError(t, CreateRelease(gitRepo, release, nil, "")) assert.NoError(t, CreateRelease(gitRepo, release, "", []*AttachmentChange{}))
assert.Greater(t, release.ID, int64(0)) assert.Greater(t, release.ID, int64(0))
release.IsDraft = false release.IsDraft = false
tagName := release.TagName 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) release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, tagName, release.TagName) assert.Equal(t, tagName, release.TagName)
@ -247,29 +307,79 @@ func TestRelease_Update(t *testing.T) {
}, strings.NewReader(samplePayload), int64(len([]byte(samplePayload)))) }, strings.NewReader(samplePayload), int64(len([]byte(samplePayload))))
assert.NoError(t, err) 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.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
assert.Len(t, release.Attachments, 1) assert.Len(t, release.Attachments, 1)
assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID)
assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID)
assert.EqualValues(t, attach.Name, release.Attachments[0].Name) assert.EqualValues(t, attach.Name, release.Attachments[0].Name)
assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL)
// update the attachment name // update the attachment name
assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, map[string]string{ assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{
attach.UUID: "test2.txt", {
}, false)) Action: "update",
Name: "test2.txt",
UUID: attach.UUID,
},
}))
release.Attachments = nil release.Attachments = nil
assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
assert.Len(t, release.Attachments, 1) assert.Len(t, release.Attachments, 1)
assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID)
assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID)
assert.EqualValues(t, "test2.txt", release.Attachments[0].Name) assert.EqualValues(t, "test2.txt", release.Attachments[0].Name)
assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL)
// delete the attachment // 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 release.Attachments = nil
assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release))
assert.Empty(t, release.Attachments) 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) { func TestRelease_createTag(t *testing.T) {

View file

@ -72,7 +72,9 @@
<ul class="list"> <ul class="list">
{{if $hasArchiveLinks}} {{if $hasArchiveLinks}}
<li> <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"> <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> <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> </div>
@ -81,7 +83,9 @@
</span> </span>
</li> </li>
<li class="{{if $hasReleaseAttachment}}start-gap{{end}}"> <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"> <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> <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> </div>
@ -92,15 +96,23 @@
{{if $hasReleaseAttachment}}<hr>{{end}} {{if $hasReleaseAttachment}}<hr>{{end}}
{{end}} {{end}}
{{range $release.Attachments}} {{range $release.Attachments}}
{{if .ExternalURL}}
<li> <li>
<a target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> <a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download>
<strong>{{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}}</strong> {{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> </a>
<div> <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> <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> </div>
</li> </li>
{{end}} {{end}}
{{end}}
</ul> </ul>
</details> </details>
{{end}} {{end}}

View file

@ -63,15 +63,45 @@
{{range .attachments}} {{range .attachments}}
<div class="field flex-text-block" id="attachment-{{.ID}}"> <div class="field flex-text-block" id="attachment-{{.ID}}">
<div class="flex-text-inline tw-flex-1"> <div class="flex-text-inline tw-flex-1">
<input name="attachment-edit-{{.UUID}}" class="attachment_edit" required value="{{.Name}}"> <div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_attachment"}}">
<input name="attachment-del-{{.UUID}}" type="hidden" value="false"> {{if .ExternalURL}}
<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> {{svg "octicon-link-external" 16 "tw-mr-2"}}
{{else}}
{{svg "octicon-package" 16 "tw-mr-2"}}
{{end}}
</div> </div>
<a class="ui mini compact red button remove-rel-attach" data-id="{{.ID}}" data-uuid="{{.UUID}}"> <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 red button remove-rel-attach tw-ml-3" data-id="{{.ID}}" data-uuid="{{.UUID}}">
{{ctx.Locale.Tr "remove"}} {{ctx.Locale.Tr "remove"}}
</a> </a>
</div> </div>
{{end}} {{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}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">
{{template "repo/upload" .}} {{template "repo/upload" .}}

View file

@ -13632,9 +13632,15 @@
}, },
{ {
"type": "file", "type": "file",
"description": "attachment to upload", "description": "attachment to upload (this parameter is incompatible with `external_url`)",
"name": "attachment", "name": "attachment",
"in": "formData" "in": "formData"
},
{
"type": "string",
"description": "url to external asset (this parameter is incompatible with `attachment`)",
"name": "external_url",
"in": "formData"
} }
], ],
"responses": { "responses": {
@ -19010,6 +19016,14 @@
"format": "int64", "format": "int64",
"x-go-name": "Size" "x-go-name": "Size"
}, },
"type": {
"type": "string",
"enum": [
"attachment",
"external"
],
"x-go-name": "Type"
},
"uuid": { "uuid": {
"type": "string", "type": "string",
"x-go-name": "UUID" "x-go-name": "UUID"
@ -20988,6 +21002,11 @@
"description": "EditAttachmentOptions options for editing attachments", "description": "EditAttachmentOptions options for editing attachments",
"type": "object", "type": "object",
"properties": { "properties": {
"browser_download_url": {
"description": "(Can only be set if existing attachment is of external type)",
"type": "string",
"x-go-name": "DownloadURL"
},
"name": { "name": {
"type": "string", "type": "string",
"x-go-name": "Name" "x-go-name": "Name"

View 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');
});

View file

@ -347,6 +347,7 @@ func TestAPIUploadAssetRelease(t *testing.T) {
assert.EqualValues(t, "stream.bin", attachment.Name) assert.EqualValues(t, "stream.bin", attachment.Name)
assert.EqualValues(t, 104, attachment.Size) 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(1), release.ArchiveDownloadCount.TarGz)
assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip) 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)
}

View file

@ -78,7 +78,7 @@ func TestMirrorPull(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: false, IsPrerelease: false,
IsTag: true, IsTag: true,
}, nil, "")) }, "", []*release_service.AttachmentChange{}))
_, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID) _, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID)
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -111,7 +111,7 @@ func TestWebhookReleaseEvents(t *testing.T) {
IsDraft: false, IsDraft: false,
IsPrerelease: false, IsPrerelease: false,
IsTag: false, IsTag: false,
}, nil, "")) }, "", nil))
// check the newly created hooktasks // check the newly created hooktasks
hookTasksLenBefore := len(hookTasks) hookTasksLenBefore := len(hookTasks)
@ -125,7 +125,7 @@ func TestWebhookReleaseEvents(t *testing.T) {
t.Run("UpdateRelease", func(t *testing.T) { t.Run("UpdateRelease", func(t *testing.T) {
rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.1"}) 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 // check the newly created hooktasks
hookTasksLenBefore := len(hookTasks) hookTasksLenBefore := len(hookTasks)
@ -157,7 +157,7 @@ func TestWebhookReleaseEvents(t *testing.T) {
t.Run("UpdateRelease", func(t *testing.T) { t.Run("UpdateRelease", func(t *testing.T) {
rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.2"}) 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 // check the newly created hooktasks
hookTasksLenBefore := len(hookTasks) hookTasksLenBefore := len(hookTasks)

View file

@ -6,7 +6,8 @@ export function initRepoRelease() {
el.addEventListener('click', (e) => { el.addEventListener('click', (e) => {
const uuid = e.target.getAttribute('data-uuid'); const uuid = e.target.getAttribute('data-uuid');
const id = e.target.getAttribute('data-id'); 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}`); hideElem(`#attachment-${id}`);
}); });
} }
@ -17,6 +18,7 @@ export function initRepoReleaseNew() {
initTagNameEditor(); initTagNameEditor();
initRepoReleaseEditor(); initRepoReleaseEditor();
initAddExternalLinkButton();
} }
function initTagNameEditor() { function initTagNameEditor() {
@ -45,9 +47,49 @@ function initTagNameEditor() {
} }
function initRepoReleaseEditor() { function initRepoReleaseEditor() {
const editor = document.querySelector('.repository.new.release .combo-markdown-editor'); const editor = document.querySelector(
'.repository.new.release .combo-markdown-editor',
);
if (!editor) { if (!editor) {
return; return;
} }
initComboMarkdownEditor(editor); 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,
);
});
}