loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement external assets

+823 -116
+2
models/forgejo_migrations/migrate.go
··· 74 74 NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser), 75 75 // v18 -> v19 76 76 NewMigration("Create the `following_repo` table", CreateFollowingRepoTable), 77 + // v19 -> v20 78 + NewMigration("Add external_url to attachment table", AddExternalURLColumnToAttachmentTable), 77 79 } 78 80 79 81 // GetCurrentDBVersion returns the current Forgejo database version.
+14
models/forgejo_migrations/v19.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgejo_migrations //nolint:revive 5 + 6 + import "xorm.io/xorm" 7 + 8 + func AddExternalURLColumnToAttachmentTable(x *xorm.Engine) error { 9 + type Attachment struct { 10 + ID int64 `xorm:"pk autoincr"` 11 + ExternalURL string 12 + } 13 + return x.Sync(new(Attachment)) 14 + }
+29
models/repo/attachment.go
··· 14 14 "code.gitea.io/gitea/modules/storage" 15 15 "code.gitea.io/gitea/modules/timeutil" 16 16 "code.gitea.io/gitea/modules/util" 17 + "code.gitea.io/gitea/modules/validation" 17 18 ) 18 19 19 20 // Attachment represent a attachment of issue/comment/release. ··· 31 32 NoAutoTime bool `xorm:"-"` 32 33 CreatedUnix timeutil.TimeStamp `xorm:"created"` 33 34 CustomDownloadURL string `xorm:"-"` 35 + ExternalURL string 34 36 } 35 37 36 38 func init() { ··· 59 61 60 62 // DownloadURL returns the download url of the attached file 61 63 func (a *Attachment) DownloadURL() string { 64 + if a.ExternalURL != "" { 65 + return a.ExternalURL 66 + } 67 + 62 68 if a.CustomDownloadURL != "" { 63 69 return a.CustomDownloadURL 64 70 } ··· 84 90 85 91 func (err ErrAttachmentNotExist) Unwrap() error { 86 92 return util.ErrNotExist 93 + } 94 + 95 + type ErrInvalidExternalURL struct { 96 + ExternalURL string 97 + } 98 + 99 + func IsErrInvalidExternalURL(err error) bool { 100 + _, ok := err.(ErrInvalidExternalURL) 101 + return ok 102 + } 103 + 104 + func (err ErrInvalidExternalURL) Error() string { 105 + return fmt.Sprintf("invalid external URL: '%s'", err.ExternalURL) 106 + } 107 + 108 + func (err ErrInvalidExternalURL) Unwrap() error { 109 + return util.ErrPermissionDenied 87 110 } 88 111 89 112 // GetAttachmentByID returns attachment by given id ··· 221 244 if attach.UUID == "" { 222 245 return fmt.Errorf("attachment uuid should be not blank") 223 246 } 247 + if attach.ExternalURL != "" && !validation.IsValidExternalURL(attach.ExternalURL) { 248 + return ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} 249 + } 224 250 _, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach) 225 251 return err 226 252 } 227 253 228 254 // UpdateAttachment updates the given attachment in database 229 255 func UpdateAttachment(ctx context.Context, atta *Attachment) error { 256 + if atta.ExternalURL != "" && !validation.IsValidExternalURL(atta.ExternalURL) { 257 + return ErrInvalidExternalURL{ExternalURL: atta.ExternalURL} 258 + } 230 259 sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count") 231 260 if atta.ID != 0 && atta.UUID == "" { 232 261 sess = sess.ID(atta.ID)
+4
modules/structs/attachment.go
··· 18 18 Created time.Time `json:"created_at"` 19 19 UUID string `json:"uuid"` 20 20 DownloadURL string `json:"browser_download_url"` 21 + // Enum: attachment,external 22 + Type string `json:"type"` 21 23 } 22 24 23 25 // EditAttachmentOptions options for editing attachments 24 26 // swagger:model 25 27 type EditAttachmentOptions struct { 26 28 Name string `json:"name"` 29 + // (Can only be set if existing attachment is of external type) 30 + DownloadURL string `json:"browser_download_url"` 27 31 }
+6
options/locale/locale_en-US.ini
··· 2721 2721 release.releases_for = Releases for %s 2722 2722 release.tags_for = Tags for %s 2723 2723 release.system_generated = This attachment is automatically generated. 2724 + release.type_attachment = Attachment 2725 + release.type_external_asset = External Asset 2726 + release.asset_name = Asset Name 2727 + release.asset_external_url = External URL 2728 + release.add_external_asset = Add External Asset 2729 + release.invalid_external_url = Invalid External URL: "%s" 2724 2730 2725 2731 branch.name = Branch name 2726 2732 branch.already_exists = A branch named "%s" already exists.
+3 -3
routers/api/v1/repo/release.go
··· 247 247 IsTag: false, 248 248 Repo: ctx.Repo.Repository, 249 249 } 250 - if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, ""); err != nil { 250 + if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, "", nil); err != nil { 251 251 if repo_model.IsErrReleaseAlreadyExist(err) { 252 252 ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err) 253 253 } else if models.IsErrProtectedTagName(err) { ··· 274 274 rel.Publisher = ctx.Doer 275 275 rel.Target = form.Target 276 276 277 - if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil, true); err != nil { 277 + if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, nil); err != nil { 278 278 ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) 279 279 return 280 280 } ··· 351 351 if form.HideArchiveLinks != nil { 352 352 rel.HideArchiveLinks = *form.HideArchiveLinks 353 353 } 354 - if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, nil, nil, nil, false); err != nil { 354 + if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, nil); err != nil { 355 355 ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) 356 356 return 357 357 }
+104 -36
routers/api/v1/repo/release_attachment.go
··· 5 5 6 6 import ( 7 7 "io" 8 + "mime/multipart" 8 9 "net/http" 10 + "net/url" 11 + "path" 9 12 "strings" 10 13 11 14 repo_model "code.gitea.io/gitea/models/repo" ··· 179 182 // description: name of the attachment 180 183 // type: string 181 184 // required: false 185 + // # There is no good way to specify "either 'attachment' or 'external_url' is required" with OpenAPI 186 + // # https://github.com/OAI/OpenAPI-Specification/issues/256 182 187 // - name: attachment 183 188 // in: formData 184 - // description: attachment to upload 189 + // description: attachment to upload (this parameter is incompatible with `external_url`) 185 190 // type: file 191 + // required: false 192 + // - name: external_url 193 + // in: formData 194 + // description: url to external asset (this parameter is incompatible with `attachment`) 195 + // type: string 186 196 // required: false 187 197 // responses: 188 198 // "201": ··· 205 215 } 206 216 207 217 // Get uploaded file from request 208 - var content io.ReadCloser 209 - var filename string 210 - var size int64 = -1 218 + var isForm, hasAttachmentFile, hasExternalURL bool 219 + externalURL := ctx.FormString("external_url") 220 + hasExternalURL = externalURL != "" 221 + filename := ctx.FormString("name") 222 + isForm = strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") 223 + 224 + if isForm { 225 + _, _, err := ctx.Req.FormFile("attachment") 226 + hasAttachmentFile = err == nil 227 + } else { 228 + hasAttachmentFile = ctx.Req.Body != nil 229 + } 230 + 231 + if hasAttachmentFile && hasExternalURL { 232 + ctx.Error(http.StatusBadRequest, "DuplicateAttachment", "'attachment' and 'external_url' are mutually exclusive") 233 + } else if hasAttachmentFile { 234 + var content io.ReadCloser 235 + var size int64 = -1 236 + 237 + if isForm { 238 + var header *multipart.FileHeader 239 + content, header, _ = ctx.Req.FormFile("attachment") 240 + size = header.Size 241 + defer content.Close() 242 + if filename == "" { 243 + filename = header.Filename 244 + } 245 + } else { 246 + content = ctx.Req.Body 247 + defer content.Close() 248 + } 249 + 250 + if filename == "" { 251 + ctx.Error(http.StatusBadRequest, "MissingName", "Missing 'name' parameter") 252 + return 253 + } 211 254 212 - if strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") { 213 - file, header, err := ctx.Req.FormFile("attachment") 255 + // Create a new attachment and save the file 256 + attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ 257 + Name: filename, 258 + UploaderID: ctx.Doer.ID, 259 + RepoID: ctx.Repo.Repository.ID, 260 + ReleaseID: releaseID, 261 + }) 214 262 if err != nil { 215 - ctx.Error(http.StatusInternalServerError, "GetFile", err) 263 + if upload.IsErrFileTypeForbidden(err) { 264 + ctx.Error(http.StatusBadRequest, "DetectContentType", err) 265 + return 266 + } 267 + ctx.Error(http.StatusInternalServerError, "NewAttachment", err) 216 268 return 217 269 } 218 - defer file.Close() 219 270 220 - content = file 221 - size = header.Size 222 - filename = header.Filename 223 - if name := ctx.FormString("name"); name != "" { 224 - filename = name 271 + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) 272 + } else if hasExternalURL { 273 + url, err := url.Parse(externalURL) 274 + if err != nil { 275 + ctx.Error(http.StatusBadRequest, "InvalidExternalURL", err) 276 + return 225 277 } 226 - } else { 227 - content = ctx.Req.Body 228 - filename = ctx.FormString("name") 229 - } 230 278 231 - if filename == "" { 232 - ctx.Error(http.StatusBadRequest, "CreateReleaseAttachment", "Could not determine name of attachment.") 233 - return 234 - } 279 + if filename == "" { 280 + filename = path.Base(url.Path) 235 281 236 - // Create a new attachment and save the file 237 - attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ 238 - Name: filename, 239 - UploaderID: ctx.Doer.ID, 240 - RepoID: ctx.Repo.Repository.ID, 241 - ReleaseID: releaseID, 242 - }) 243 - if err != nil { 244 - if upload.IsErrFileTypeForbidden(err) { 245 - ctx.Error(http.StatusBadRequest, "DetectContentType", err) 282 + if filename == "." { 283 + // Url path is empty 284 + filename = url.Host 285 + } 286 + } 287 + 288 + attach, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{ 289 + Name: filename, 290 + UploaderID: ctx.Doer.ID, 291 + RepoID: ctx.Repo.Repository.ID, 292 + ReleaseID: releaseID, 293 + ExternalURL: url.String(), 294 + }) 295 + if err != nil { 296 + if repo_model.IsErrInvalidExternalURL(err) { 297 + ctx.Error(http.StatusBadRequest, "NewExternalAttachment", err) 298 + } else { 299 + ctx.Error(http.StatusInternalServerError, "NewExternalAttachment", err) 300 + } 246 301 return 247 302 } 248 - ctx.Error(http.StatusInternalServerError, "NewAttachment", err) 249 - return 303 + 304 + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) 305 + } else { 306 + ctx.Error(http.StatusBadRequest, "MissingAttachment", "One of 'attachment' or 'external_url' is required") 250 307 } 251 - 252 - ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) 253 308 } 254 309 255 310 // EditReleaseAttachment updates the given attachment ··· 322 377 attach.Name = form.Name 323 378 } 324 379 380 + if form.DownloadURL != "" { 381 + if attach.ExternalURL == "" { 382 + ctx.Error(http.StatusBadRequest, "EditAttachment", "existing attachment is not external") 383 + return 384 + } 385 + attach.ExternalURL = form.DownloadURL 386 + } 387 + 325 388 if err := repo_model.UpdateAttachment(ctx, attach); err != nil { 326 - ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach) 389 + if repo_model.IsErrInvalidExternalURL(err) { 390 + ctx.Error(http.StatusBadRequest, "UpdateAttachment", err) 391 + } else { 392 + ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err) 393 + } 394 + return 327 395 } 328 396 ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) 329 397 }
+5
routers/web/repo/attachment.go
··· 122 122 } 123 123 } 124 124 125 + if attach.ExternalURL != "" { 126 + ctx.Redirect(attach.ExternalURL) 127 + return 128 + } 129 + 125 130 if err := attach.IncreaseDownloadCount(ctx); err != nil { 126 131 ctx.ServerError("IncreaseDownloadCount", err) 127 132 return
+122 -14
routers/web/repo/release.go
··· 18 18 "code.gitea.io/gitea/models/unit" 19 19 user_model "code.gitea.io/gitea/models/user" 20 20 "code.gitea.io/gitea/modules/base" 21 + "code.gitea.io/gitea/modules/container" 21 22 "code.gitea.io/gitea/modules/git" 22 23 "code.gitea.io/gitea/modules/gitrepo" 23 24 "code.gitea.io/gitea/modules/log" ··· 491 492 return 492 493 } 493 494 494 - var attachmentUUIDs []string 495 + attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) 496 + attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) 497 + 495 498 if setting.Attachment.Enabled { 496 - attachmentUUIDs = form.Files 499 + for _, uuid := range form.Files { 500 + attachmentChanges.Add(&releaseservice.AttachmentChange{ 501 + Action: "add", 502 + Type: "attachment", 503 + UUID: uuid, 504 + }) 505 + } 506 + 507 + const namePrefix = "attachment-new-name-" 508 + const exturlPrefix = "attachment-new-exturl-" 509 + for k, v := range ctx.Req.Form { 510 + isNewName := strings.HasPrefix(k, namePrefix) 511 + isNewExturl := strings.HasPrefix(k, exturlPrefix) 512 + if isNewName || isNewExturl { 513 + var id string 514 + if isNewName { 515 + id = k[len(namePrefix):] 516 + } else if isNewExturl { 517 + id = k[len(exturlPrefix):] 518 + } 519 + if _, ok := attachmentChangesByID[id]; !ok { 520 + attachmentChangesByID[id] = &releaseservice.AttachmentChange{ 521 + Action: "add", 522 + Type: "external", 523 + } 524 + attachmentChanges.Add(attachmentChangesByID[id]) 525 + } 526 + if isNewName { 527 + attachmentChangesByID[id].Name = v[0] 528 + } else if isNewExturl { 529 + attachmentChangesByID[id].ExternalURL = v[0] 530 + } 531 + } 532 + } 497 533 } 498 534 499 535 rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) ··· 553 589 IsTag: false, 554 590 } 555 591 556 - if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, attachmentUUIDs, msg); err != nil { 592 + if err = releaseservice.CreateRelease(ctx.Repo.GitRepo, rel, msg, attachmentChanges.Values()); err != nil { 557 593 ctx.Data["Err_TagName"] = true 558 594 switch { 559 595 case repo_model.IsErrReleaseAlreadyExist(err): ··· 562 598 ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_invalid"), tplReleaseNew, &form) 563 599 case models.IsErrProtectedTagName(err): 564 600 ctx.RenderWithErr(ctx.Tr("repo.release.tag_name_protected"), tplReleaseNew, &form) 601 + case repo_model.IsErrInvalidExternalURL(err): 602 + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) 565 603 default: 566 604 ctx.ServerError("CreateRelease", err) 567 605 } ··· 583 621 rel.HideArchiveLinks = form.HideArchiveLinks 584 622 rel.IsTag = false 585 623 586 - if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, attachmentUUIDs, nil, nil, true); err != nil { 624 + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, attachmentChanges.Values()); err != nil { 587 625 ctx.Data["Err_TagName"] = true 588 - ctx.ServerError("UpdateRelease", err) 626 + switch { 627 + case repo_model.IsErrInvalidExternalURL(err): 628 + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) 629 + default: 630 + ctx.ServerError("UpdateRelease", err) 631 + } 589 632 return 590 633 } 591 634 } ··· 667 710 ctx.Data["prerelease"] = rel.IsPrerelease 668 711 ctx.Data["hide_archive_links"] = rel.HideArchiveLinks 669 712 713 + rel.Repo = ctx.Repo.Repository 714 + if err := rel.LoadAttributes(ctx); err != nil { 715 + ctx.ServerError("LoadAttributes", err) 716 + return 717 + } 718 + // TODO: If an error occurs, do not forget the attachment edits the user made 719 + // when displaying the error message. 720 + ctx.Data["attachments"] = rel.Attachments 721 + 670 722 if ctx.HasError() { 671 723 ctx.HTML(http.StatusOK, tplReleaseNew) 672 724 return ··· 674 726 675 727 const delPrefix = "attachment-del-" 676 728 const editPrefix = "attachment-edit-" 677 - var addAttachmentUUIDs, delAttachmentUUIDs []string 678 - editAttachments := make(map[string]string) // uuid -> new name 729 + const newPrefix = "attachment-new-" 730 + const namePrefix = "name-" 731 + const exturlPrefix = "exturl-" 732 + attachmentChanges := make(container.Set[*releaseservice.AttachmentChange]) 733 + attachmentChangesByID := make(map[string]*releaseservice.AttachmentChange) 734 + 679 735 if setting.Attachment.Enabled { 680 - addAttachmentUUIDs = form.Files 736 + for _, uuid := range form.Files { 737 + attachmentChanges.Add(&releaseservice.AttachmentChange{ 738 + Action: "add", 739 + Type: "attachment", 740 + UUID: uuid, 741 + }) 742 + } 743 + 681 744 for k, v := range ctx.Req.Form { 682 745 if strings.HasPrefix(k, delPrefix) && v[0] == "true" { 683 - delAttachmentUUIDs = append(delAttachmentUUIDs, k[len(delPrefix):]) 684 - } else if strings.HasPrefix(k, editPrefix) { 685 - editAttachments[k[len(editPrefix):]] = v[0] 746 + attachmentChanges.Add(&releaseservice.AttachmentChange{ 747 + Action: "delete", 748 + UUID: k[len(delPrefix):], 749 + }) 750 + } else { 751 + isUpdatedName := strings.HasPrefix(k, editPrefix+namePrefix) 752 + isUpdatedExturl := strings.HasPrefix(k, editPrefix+exturlPrefix) 753 + isNewName := strings.HasPrefix(k, newPrefix+namePrefix) 754 + isNewExturl := strings.HasPrefix(k, newPrefix+exturlPrefix) 755 + 756 + if isUpdatedName || isUpdatedExturl || isNewName || isNewExturl { 757 + var uuid string 758 + 759 + if isUpdatedName { 760 + uuid = k[len(editPrefix+namePrefix):] 761 + } else if isUpdatedExturl { 762 + uuid = k[len(editPrefix+exturlPrefix):] 763 + } else if isNewName { 764 + uuid = k[len(newPrefix+namePrefix):] 765 + } else if isNewExturl { 766 + uuid = k[len(newPrefix+exturlPrefix):] 767 + } 768 + 769 + if _, ok := attachmentChangesByID[uuid]; !ok { 770 + attachmentChangesByID[uuid] = &releaseservice.AttachmentChange{ 771 + Type: "attachment", 772 + UUID: uuid, 773 + } 774 + attachmentChanges.Add(attachmentChangesByID[uuid]) 775 + } 776 + 777 + if isUpdatedName || isUpdatedExturl { 778 + attachmentChangesByID[uuid].Action = "update" 779 + } else if isNewName || isNewExturl { 780 + attachmentChangesByID[uuid].Action = "add" 781 + } 782 + 783 + if isUpdatedName || isNewName { 784 + attachmentChangesByID[uuid].Name = v[0] 785 + } else if isUpdatedExturl || isNewExturl { 786 + attachmentChangesByID[uuid].ExternalURL = v[0] 787 + attachmentChangesByID[uuid].Type = "external" 788 + } 789 + } 686 790 } 687 791 } 688 792 } ··· 692 796 rel.IsDraft = len(form.Draft) > 0 693 797 rel.IsPrerelease = form.Prerelease 694 798 rel.HideArchiveLinks = form.HideArchiveLinks 695 - if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, 696 - rel, addAttachmentUUIDs, delAttachmentUUIDs, editAttachments, false); err != nil { 697 - ctx.ServerError("UpdateRelease", err) 799 + if err = releaseservice.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, attachmentChanges.Values()); err != nil { 800 + switch { 801 + case repo_model.IsErrInvalidExternalURL(err): 802 + ctx.RenderWithErr(ctx.Tr("repo.release.invalid_external_url", err.(repo_model.ErrInvalidExternalURL).ExternalURL), tplReleaseNew, &form) 803 + default: 804 + ctx.ServerError("UpdateRelease", err) 805 + } 698 806 return 699 807 } 700 808 ctx.Redirect(ctx.Repo.RepoLink + "/releases")
+23
services/attachment/attachment.go
··· 13 13 repo_model "code.gitea.io/gitea/models/repo" 14 14 "code.gitea.io/gitea/modules/storage" 15 15 "code.gitea.io/gitea/modules/util" 16 + "code.gitea.io/gitea/modules/validation" 16 17 "code.gitea.io/gitea/services/context/upload" 17 18 18 19 "github.com/google/uuid" ··· 39 40 _, err = eng.Insert(attach) 40 41 return err 41 42 }) 43 + 44 + return attach, err 45 + } 46 + 47 + func NewExternalAttachment(ctx context.Context, attach *repo_model.Attachment) (*repo_model.Attachment, error) { 48 + if attach.RepoID == 0 { 49 + return nil, fmt.Errorf("attachment %s should belong to a repository", attach.Name) 50 + } 51 + if attach.ExternalURL == "" { 52 + return nil, fmt.Errorf("attachment %s should have a external url", attach.Name) 53 + } 54 + if !validation.IsValidExternalURL(attach.ExternalURL) { 55 + return nil, repo_model.ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} 56 + } 57 + 58 + attach.UUID = uuid.New().String() 59 + 60 + eng := db.GetEngine(ctx) 61 + if attach.NoAutoTime { 62 + eng.NoAutoTime() 63 + } 64 + _, err := eng.Insert(attach) 42 65 43 66 return attach, err 44 67 }
+11
services/convert/attachment.go
··· 9 9 ) 10 10 11 11 func WebAssetDownloadURL(repo *repo_model.Repository, attach *repo_model.Attachment) string { 12 + if attach.ExternalURL != "" { 13 + return attach.ExternalURL 14 + } 15 + 12 16 return attach.DownloadURL() 13 17 } 14 18 ··· 28 32 29 33 // toAttachment converts models.Attachment to api.Attachment for API usage 30 34 func toAttachment(repo *repo_model.Repository, a *repo_model.Attachment, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Attachment { 35 + var typeName string 36 + if a.ExternalURL != "" { 37 + typeName = "external" 38 + } else { 39 + typeName = "attachment" 40 + } 31 41 return &api.Attachment{ 32 42 ID: a.ID, 33 43 Name: a.Name, ··· 36 46 Size: a.Size, 37 47 UUID: a.UUID, 38 48 DownloadURL: getDownloadURL(repo, a), // for web request json and api request json, return different download urls 49 + Type: typeName, 39 50 } 40 51 } 41 52
+1 -1
services/f3/driver/release.go
··· 129 129 panic(err) 130 130 } 131 131 defer gitRepo.Close() 132 - if err := release_service.CreateRelease(gitRepo, o.forgejoRelease, nil, ""); err != nil { 132 + if err := release_service.CreateRelease(gitRepo, o.forgejoRelease, "", nil); err != nil { 133 133 panic(err) 134 134 } 135 135 o.Trace("release created %d", o.forgejoRelease.ID)
+112 -22
services/release/release.go
··· 23 23 "code.gitea.io/gitea/modules/storage" 24 24 "code.gitea.io/gitea/modules/timeutil" 25 25 "code.gitea.io/gitea/modules/util" 26 + "code.gitea.io/gitea/services/attachment" 26 27 notify_service "code.gitea.io/gitea/services/notify" 27 28 ) 29 + 30 + type AttachmentChange struct { 31 + Action string // "add", "delete", "update 32 + Type string // "attachment", "external" 33 + UUID string 34 + Name string 35 + ExternalURL string 36 + } 28 37 29 38 func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Release, msg string) (bool, error) { 30 39 err := rel.LoadAttributes(ctx) ··· 128 137 } 129 138 130 139 // CreateRelease creates a new release of repository. 131 - func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentUUIDs []string, msg string) error { 140 + func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, msg string, attachmentChanges []*AttachmentChange) error { 132 141 has, err := repo_model.IsReleaseExist(gitRepo.Ctx, rel.RepoID, rel.TagName) 133 142 if err != nil { 134 143 return err ··· 147 156 return err 148 157 } 149 158 150 - if err = repo_model.AddReleaseAttachments(gitRepo.Ctx, rel.ID, attachmentUUIDs); err != nil { 159 + addAttachmentUUIDs := make(container.Set[string]) 160 + 161 + for _, attachmentChange := range attachmentChanges { 162 + if attachmentChange.Action != "add" { 163 + return fmt.Errorf("can only create new attachments when creating release") 164 + } 165 + switch attachmentChange.Type { 166 + case "attachment": 167 + if attachmentChange.UUID == "" { 168 + return fmt.Errorf("new attachment should have a uuid") 169 + } 170 + addAttachmentUUIDs.Add(attachmentChange.UUID) 171 + case "external": 172 + if attachmentChange.Name == "" || attachmentChange.ExternalURL == "" { 173 + return fmt.Errorf("new external attachment should have a name and external url") 174 + } 175 + 176 + _, err = attachment.NewExternalAttachment(gitRepo.Ctx, &repo_model.Attachment{ 177 + Name: attachmentChange.Name, 178 + UploaderID: rel.PublisherID, 179 + RepoID: rel.RepoID, 180 + ReleaseID: rel.ID, 181 + ExternalURL: attachmentChange.ExternalURL, 182 + }) 183 + if err != nil { 184 + return err 185 + } 186 + default: 187 + if attachmentChange.Type == "" { 188 + return fmt.Errorf("missing attachment type") 189 + } 190 + return fmt.Errorf("unknown attachment type: '%q'", attachmentChange.Type) 191 + } 192 + } 193 + 194 + if err = repo_model.AddReleaseAttachments(gitRepo.Ctx, rel.ID, addAttachmentUUIDs.Values()); err != nil { 151 195 return err 152 196 } 153 197 ··· 198 242 // addAttachmentUUIDs accept a slice of new created attachments' uuids which will be reassigned release_id as the created release 199 243 // delAttachmentUUIDs accept a slice of attachments' uuids which will be deleted from the release 200 244 // editAttachments accept a map of attachment uuid to new attachment name which will be updated with attachments. 201 - func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, 202 - addAttachmentUUIDs, delAttachmentUUIDs []string, editAttachments map[string]string, createdFromTag bool, 245 + func UpdateRelease(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, rel *repo_model.Release, createdFromTag bool, attachmentChanges []*AttachmentChange, 203 246 ) error { 204 247 if rel.ID == 0 { 205 248 return errors.New("UpdateRelease only accepts an exist release") ··· 220 263 return err 221 264 } 222 265 223 - if err = repo_model.AddReleaseAttachments(ctx, rel.ID, addAttachmentUUIDs); err != nil { 266 + addAttachmentUUIDs := make(container.Set[string]) 267 + delAttachmentUUIDs := make(container.Set[string]) 268 + updateAttachmentUUIDs := make(container.Set[string]) 269 + updateAttachments := make(container.Set[*AttachmentChange]) 270 + 271 + for _, attachmentChange := range attachmentChanges { 272 + switch attachmentChange.Action { 273 + case "add": 274 + switch attachmentChange.Type { 275 + case "attachment": 276 + if attachmentChange.UUID == "" { 277 + return fmt.Errorf("new attachment should have a uuid (%s)}", attachmentChange.Name) 278 + } 279 + addAttachmentUUIDs.Add(attachmentChange.UUID) 280 + case "external": 281 + if attachmentChange.Name == "" || attachmentChange.ExternalURL == "" { 282 + return fmt.Errorf("new external attachment should have a name and external url") 283 + } 284 + _, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{ 285 + Name: attachmentChange.Name, 286 + UploaderID: doer.ID, 287 + RepoID: rel.RepoID, 288 + ReleaseID: rel.ID, 289 + ExternalURL: attachmentChange.ExternalURL, 290 + }) 291 + if err != nil { 292 + return err 293 + } 294 + default: 295 + if attachmentChange.Type == "" { 296 + return fmt.Errorf("missing attachment type") 297 + } 298 + return fmt.Errorf("unknown attachment type: %q", attachmentChange.Type) 299 + } 300 + case "delete": 301 + if attachmentChange.UUID == "" { 302 + return fmt.Errorf("attachment deletion should have a uuid") 303 + } 304 + delAttachmentUUIDs.Add(attachmentChange.UUID) 305 + case "update": 306 + updateAttachmentUUIDs.Add(attachmentChange.UUID) 307 + updateAttachments.Add(attachmentChange) 308 + default: 309 + if attachmentChange.Action == "" { 310 + return fmt.Errorf("missing attachment action") 311 + } 312 + return fmt.Errorf("unknown attachment action: %q", attachmentChange.Action) 313 + } 314 + } 315 + 316 + if err = repo_model.AddReleaseAttachments(ctx, rel.ID, addAttachmentUUIDs.Values()); err != nil { 224 317 return fmt.Errorf("AddReleaseAttachments: %w", err) 225 318 } 226 319 227 320 deletedUUIDs := make(container.Set[string]) 228 321 if len(delAttachmentUUIDs) > 0 { 229 322 // Check attachments 230 - attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs) 323 + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, delAttachmentUUIDs.Values()) 231 324 if err != nil { 232 325 return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", delAttachmentUUIDs, err) 233 326 } ··· 246 339 } 247 340 } 248 341 249 - if len(editAttachments) > 0 { 250 - updateAttachmentsList := make([]string, 0, len(editAttachments)) 251 - for k := range editAttachments { 252 - updateAttachmentsList = append(updateAttachmentsList, k) 253 - } 342 + if len(updateAttachmentUUIDs) > 0 { 254 343 // Check attachments 255 - attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentsList) 344 + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, updateAttachmentUUIDs.Values()) 256 345 if err != nil { 257 - return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentsList, err) 346 + return fmt.Errorf("GetAttachmentsByUUIDs [uuids: %v]: %w", updateAttachmentUUIDs, err) 258 347 } 259 348 for _, attach := range attachments { 260 349 if attach.ReleaseID != rel.ID { ··· 264 353 } 265 354 } 266 355 } 356 + } 267 357 268 - for uuid, newName := range editAttachments { 269 - if !deletedUUIDs.Contains(uuid) { 270 - if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{ 271 - UUID: uuid, 272 - Name: newName, 273 - }, "name"); err != nil { 274 - return err 275 - } 358 + for attachmentChange := range updateAttachments { 359 + if !deletedUUIDs.Contains(attachmentChange.UUID) { 360 + if err = repo_model.UpdateAttachmentByUUID(ctx, &repo_model.Attachment{ 361 + UUID: attachmentChange.UUID, 362 + Name: attachmentChange.Name, 363 + ExternalURL: attachmentChange.ExternalURL, 364 + }, "name", "external_url"); err != nil { 365 + return err 276 366 } 277 367 } 278 368 } ··· 281 371 return err 282 372 } 283 373 284 - for _, uuid := range delAttachmentUUIDs { 374 + for _, uuid := range delAttachmentUUIDs.Values() { 285 375 if err := storage.Attachments.Delete(repo_model.AttachmentRelativePath(uuid)); err != nil { 286 376 // Even delete files failed, but the attachments has been removed from database, so we 287 377 // should not return error but only record the error on logs.
+129 -19
services/release/release_test.go
··· 47 47 IsDraft: false, 48 48 IsPrerelease: false, 49 49 IsTag: false, 50 - }, nil, "")) 50 + }, "", []*AttachmentChange{})) 51 51 52 52 assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ 53 53 RepoID: repo.ID, ··· 61 61 IsDraft: false, 62 62 IsPrerelease: false, 63 63 IsTag: false, 64 - }, nil, "")) 64 + }, "", []*AttachmentChange{})) 65 65 66 66 assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ 67 67 RepoID: repo.ID, ··· 75 75 IsDraft: false, 76 76 IsPrerelease: false, 77 77 IsTag: false, 78 - }, nil, "")) 78 + }, "", []*AttachmentChange{})) 79 79 80 80 assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ 81 81 RepoID: repo.ID, ··· 89 89 IsDraft: true, 90 90 IsPrerelease: false, 91 91 IsTag: false, 92 - }, nil, "")) 92 + }, "", []*AttachmentChange{})) 93 93 94 94 assert.NoError(t, CreateRelease(gitRepo, &repo_model.Release{ 95 95 RepoID: repo.ID, ··· 103 103 IsDraft: false, 104 104 IsPrerelease: true, 105 105 IsTag: false, 106 - }, nil, "")) 106 + }, "", []*AttachmentChange{})) 107 107 108 108 testPlayload := "testtest" 109 109 ··· 127 127 IsPrerelease: false, 128 128 IsTag: true, 129 129 } 130 - assert.NoError(t, CreateRelease(gitRepo, &release, []string{attach.UUID}, "test")) 130 + assert.NoError(t, CreateRelease(gitRepo, &release, "test", []*AttachmentChange{ 131 + { 132 + Action: "add", 133 + Type: "attachment", 134 + UUID: attach.UUID, 135 + }, 136 + })) 137 + assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, &release)) 138 + assert.Len(t, release.Attachments, 1) 139 + assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) 140 + assert.EqualValues(t, attach.Name, release.Attachments[0].Name) 141 + assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL) 142 + 143 + release = repo_model.Release{ 144 + RepoID: repo.ID, 145 + Repo: repo, 146 + PublisherID: user.ID, 147 + Publisher: user, 148 + TagName: "v0.1.6", 149 + Target: "65f1bf2", 150 + Title: "v0.1.6 is released", 151 + Note: "v0.1.6 is released", 152 + IsDraft: false, 153 + IsPrerelease: false, 154 + IsTag: true, 155 + } 156 + assert.NoError(t, CreateRelease(gitRepo, &release, "", []*AttachmentChange{ 157 + { 158 + Action: "add", 159 + Type: "external", 160 + Name: "test", 161 + ExternalURL: "https://forgejo.org/", 162 + }, 163 + })) 164 + assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, &release)) 165 + assert.Len(t, release.Attachments, 1) 166 + assert.EqualValues(t, "test", release.Attachments[0].Name) 167 + assert.EqualValues(t, "https://forgejo.org/", release.Attachments[0].ExternalURL) 168 + 169 + release = repo_model.Release{ 170 + RepoID: repo.ID, 171 + Repo: repo, 172 + PublisherID: user.ID, 173 + Publisher: user, 174 + TagName: "v0.1.7", 175 + Target: "65f1bf2", 176 + Title: "v0.1.7 is released", 177 + Note: "v0.1.7 is released", 178 + IsDraft: false, 179 + IsPrerelease: false, 180 + IsTag: true, 181 + } 182 + assert.Error(t, CreateRelease(gitRepo, &repo_model.Release{}, "", []*AttachmentChange{ 183 + { 184 + Action: "add", 185 + Type: "external", 186 + Name: "Click me", 187 + // Invalid URL (API URL of current instance), this should result in an error 188 + ExternalURL: "https://try.gitea.io/api/v1/user/follow", 189 + }, 190 + })) 131 191 } 132 192 133 193 func TestRelease_Update(t *testing.T) { ··· 153 213 IsDraft: false, 154 214 IsPrerelease: false, 155 215 IsTag: false, 156 - }, nil, "")) 216 + }, "", []*AttachmentChange{})) 157 217 release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.1.1") 158 218 assert.NoError(t, err) 159 219 releaseCreatedUnix := release.CreatedUnix 160 220 time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp 161 221 release.Note = "Changed note" 162 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) 222 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) 163 223 release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) 164 224 assert.NoError(t, err) 165 225 assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) ··· 177 237 IsDraft: true, 178 238 IsPrerelease: false, 179 239 IsTag: false, 180 - }, nil, "")) 240 + }, "", []*AttachmentChange{})) 181 241 release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.2.1") 182 242 assert.NoError(t, err) 183 243 releaseCreatedUnix = release.CreatedUnix 184 244 time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp 185 245 release.Title = "Changed title" 186 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) 246 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) 187 247 release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) 188 248 assert.NoError(t, err) 189 249 assert.Less(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) ··· 201 261 IsDraft: false, 202 262 IsPrerelease: true, 203 263 IsTag: false, 204 - }, nil, "")) 264 + }, "", []*AttachmentChange{})) 205 265 release, err = repo_model.GetRelease(db.DefaultContext, repo.ID, "v1.3.1") 206 266 assert.NoError(t, err) 207 267 releaseCreatedUnix = release.CreatedUnix 208 268 time.Sleep(2 * time.Second) // sleep 2 seconds to ensure a different timestamp 209 269 release.Title = "Changed title" 210 270 release.Note = "Changed note" 211 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) 271 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) 212 272 release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) 213 273 assert.NoError(t, err) 214 274 assert.Equal(t, int64(releaseCreatedUnix), int64(release.CreatedUnix)) ··· 227 287 IsPrerelease: false, 228 288 IsTag: false, 229 289 } 230 - assert.NoError(t, CreateRelease(gitRepo, release, nil, "")) 290 + assert.NoError(t, CreateRelease(gitRepo, release, "", []*AttachmentChange{})) 231 291 assert.Greater(t, release.ID, int64(0)) 232 292 233 293 release.IsDraft = false 234 294 tagName := release.TagName 235 295 236 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, nil, false)) 296 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{})) 237 297 release, err = repo_model.GetReleaseByID(db.DefaultContext, release.ID) 238 298 assert.NoError(t, err) 239 299 assert.Equal(t, tagName, release.TagName) ··· 247 307 }, strings.NewReader(samplePayload), int64(len([]byte(samplePayload)))) 248 308 assert.NoError(t, err) 249 309 250 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, []string{attach.UUID}, nil, nil, false)) 310 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ 311 + { 312 + Action: "add", 313 + Type: "attachment", 314 + UUID: attach.UUID, 315 + }, 316 + })) 251 317 assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) 252 318 assert.Len(t, release.Attachments, 1) 253 319 assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) 254 320 assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) 255 321 assert.EqualValues(t, attach.Name, release.Attachments[0].Name) 322 + assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL) 256 323 257 324 // update the attachment name 258 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, nil, map[string]string{ 259 - attach.UUID: "test2.txt", 260 - }, false)) 325 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ 326 + { 327 + Action: "update", 328 + Name: "test2.txt", 329 + UUID: attach.UUID, 330 + }, 331 + })) 261 332 release.Attachments = nil 262 333 assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) 263 334 assert.Len(t, release.Attachments, 1) 264 335 assert.EqualValues(t, attach.UUID, release.Attachments[0].UUID) 265 336 assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) 266 337 assert.EqualValues(t, "test2.txt", release.Attachments[0].Name) 338 + assert.EqualValues(t, attach.ExternalURL, release.Attachments[0].ExternalURL) 267 339 268 340 // delete the attachment 269 - assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, nil, []string{attach.UUID}, nil, false)) 341 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ 342 + { 343 + Action: "delete", 344 + UUID: attach.UUID, 345 + }, 346 + })) 270 347 release.Attachments = nil 271 348 assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) 272 349 assert.Empty(t, release.Attachments) 350 + 351 + // Add new external attachment 352 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ 353 + { 354 + Action: "add", 355 + Type: "external", 356 + Name: "test", 357 + ExternalURL: "https://forgejo.org/", 358 + }, 359 + })) 360 + assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) 361 + assert.Len(t, release.Attachments, 1) 362 + assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) 363 + assert.EqualValues(t, "test", release.Attachments[0].Name) 364 + assert.EqualValues(t, "https://forgejo.org/", release.Attachments[0].ExternalURL) 365 + externalAttachmentUUID := release.Attachments[0].UUID 366 + 367 + // update the attachment name 368 + assert.NoError(t, UpdateRelease(db.DefaultContext, user, gitRepo, release, false, []*AttachmentChange{ 369 + { 370 + Action: "update", 371 + Name: "test2", 372 + UUID: externalAttachmentUUID, 373 + ExternalURL: "https://about.gitea.com/", 374 + }, 375 + })) 376 + release.Attachments = nil 377 + assert.NoError(t, repo_model.GetReleaseAttachments(db.DefaultContext, release)) 378 + assert.Len(t, release.Attachments, 1) 379 + assert.EqualValues(t, externalAttachmentUUID, release.Attachments[0].UUID) 380 + assert.EqualValues(t, release.ID, release.Attachments[0].ReleaseID) 381 + assert.EqualValues(t, "test2", release.Attachments[0].Name) 382 + assert.EqualValues(t, "https://about.gitea.com/", release.Attachments[0].ExternalURL) 273 383 } 274 384 275 385 func TestRelease_createTag(t *testing.T) {
+22 -10
templates/repo/release/list.tmpl
··· 72 72 <ul class="list"> 73 73 {{if $hasArchiveLinks}} 74 74 <li> 75 - <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> 75 + <a class="archive-link tw-flex-1 flex-text-inline tw-font-bold" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.zip" rel="nofollow"> 76 + {{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP) 77 + </a> 76 78 <div class="tw-mr-1"> 77 79 <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> 78 80 </div> ··· 81 83 </span> 82 84 </li> 83 85 <li class="{{if $hasReleaseAttachment}}start-gap{{end}}"> 84 - <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> 86 + <a class="archive-link tw-flex-1 flex-text-inline tw-font-bold" href="{{$.RepoLink}}/archive/{{$release.TagName | PathEscapeSegments}}.tar.gz" rel="nofollow"> 87 + {{svg "octicon-file-zip" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.release.source_code"}} (ZIP) 88 + </a> 85 89 <div class="tw-mr-1"> 86 90 <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> 87 91 </div> ··· 92 96 {{if $hasReleaseAttachment}}<hr>{{end}} 93 97 {{end}} 94 98 {{range $release.Attachments}} 95 - <li> 96 - <a target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> 97 - <strong>{{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}}</strong> 98 - </a> 99 - <div> 100 - <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> 101 - </div> 102 - </li> 99 + {{if .ExternalURL}} 100 + <li> 101 + <a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> 102 + {{svg "octicon-link-external" 16 "tw-mr-1"}}{{.Name}} 103 + </a> 104 + </li> 105 + {{else}} 106 + <li> 107 + <a class="tw-flex-1 flex-text-inline tw-font-bold" target="_blank" rel="nofollow" href="{{.DownloadURL}}" download> 108 + {{svg "octicon-package" 16 "tw-mr-1"}}{{.Name}} 109 + </a> 110 + <div> 111 + <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> 112 + </div> 113 + </li> 114 + {{end}} 103 115 {{end}} 104 116 </ul> 105 117 </details>
+34 -4
templates/repo/release/new.tmpl
··· 63 63 {{range .attachments}} 64 64 <div class="field flex-text-block" id="attachment-{{.ID}}"> 65 65 <div class="flex-text-inline tw-flex-1"> 66 - <input name="attachment-edit-{{.UUID}}" class="attachment_edit" required value="{{.Name}}"> 67 - <input name="attachment-del-{{.UUID}}" type="hidden" value="false"> 68 - <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> 66 + <div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_attachment"}}"> 67 + {{if .ExternalURL}} 68 + {{svg "octicon-link-external" 16 "tw-mr-2"}} 69 + {{else}} 70 + {{svg "octicon-package" 16 "tw-mr-2"}} 71 + {{end}} 72 + </div> 73 + <input name="attachment-edit-name-{{.UUID}}" placeholder="{{ctx.Locale.Tr "repo.release.asset_name"}}" class="attachment_edit" required value="{{.Name}}"> 74 + <input name="attachment-del-{{.UUID}}" type="hidden" 75 + value="false"> 76 + {{if .ExternalURL}} 77 + <input name="attachment-edit-exturl-{{.UUID}}" placeholder="{{ctx.Locale.Tr "repo.release.asset_external_url"}}" class="attachment_edit" required value="{{.ExternalURL}}"> 78 + {{else}} 79 + <span class="ui text grey tw-whitespace-nowrap tw-ml-auto tw-pl-3">{{ctx.Locale.TrN 80 + .DownloadCount "repo.release.download_count_one" 81 + "repo.release.download_count_few" (ctx.Locale.PrettyNumber 82 + .DownloadCount)}} · {{.Size | ctx.Locale.TrSize}}</span> 83 + {{end}} 69 84 </div> 70 - <a class="ui mini compact red button remove-rel-attach" data-id="{{.ID}}" data-uuid="{{.UUID}}"> 85 + <a class="ui mini red button remove-rel-attach tw-ml-3" data-id="{{.ID}}" data-uuid="{{.UUID}}"> 71 86 {{ctx.Locale.Tr "remove"}} 72 87 </a> 73 88 </div> 74 89 {{end}} 90 + <div class="field flex-text-block tw-hidden" id="attachment-template"> 91 + <div class="flex-text-inline tw-flex-1"> 92 + <div class="flex-text-inline tw-shrink-0" title="{{ctx.Locale.Tr "repo.release.type_external_asset"}}"> 93 + {{svg "octicon-link-external" 16 "tw-mr-2"}} 94 + </div> 95 + <input name="attachment-template-new-name" placeholder="{{ctx.Locale.Tr "repo.release.asset_name"}}" class="attachment_edit"> 96 + <input name="attachment-template-new-exturl" placeholder="{{ctx.Locale.Tr "repo.release.asset_external_url"}}" class="attachment_edit"> 97 + </div> 98 + <a class="ui mini red button remove-rel-attach tw-ml-3"> 99 + {{ctx.Locale.Tr "remove"}} 100 + </a> 101 + </div> 102 + <a class="ui mini button tw-float-right tw-mb-4 tw-mt-2" id="add-external-link"> 103 + {{ctx.Locale.Tr "repo.release.add_external_asset"}} 104 + </a> 75 105 {{if .IsAttachmentEnabled}} 76 106 <div class="field"> 77 107 {{template "repo/upload" .}}
+20 -1
templates/swagger/v1_json.tmpl
··· 13623 13623 }, 13624 13624 { 13625 13625 "type": "file", 13626 - "description": "attachment to upload", 13626 + "description": "attachment to upload (this parameter is incompatible with `external_url`)", 13627 13627 "name": "attachment", 13628 + "in": "formData" 13629 + }, 13630 + { 13631 + "type": "string", 13632 + "description": "url to external asset (this parameter is incompatible with `attachment`)", 13633 + "name": "external_url", 13628 13634 "in": "formData" 13629 13635 } 13630 13636 ], ··· 19001 19007 "format": "int64", 19002 19008 "x-go-name": "Size" 19003 19009 }, 19010 + "type": { 19011 + "type": "string", 19012 + "enum": [ 19013 + "attachment", 19014 + "external" 19015 + ], 19016 + "x-go-name": "Type" 19017 + }, 19004 19018 "uuid": { 19005 19019 "type": "string", 19006 19020 "x-go-name": "UUID" ··· 20979 20993 "description": "EditAttachmentOptions options for editing attachments", 20980 20994 "type": "object", 20981 20995 "properties": { 20996 + "browser_download_url": { 20997 + "description": "(Can only be set if existing attachment is of external type)", 20998 + "type": "string", 20999 + "x-go-name": "DownloadURL" 21000 + }, 20982 21001 "name": { 20983 21002 "type": "string", 20984 21003 "x-go-name": "Name"
+67
tests/e2e/release.test.e2e.js
··· 1 + // @ts-check 2 + import {test, expect} from '@playwright/test'; 3 + import {login_user, save_visual, load_logged_in_context} from './utils_e2e.js'; 4 + 5 + test.beforeAll(async ({browser}, workerInfo) => { 6 + await login_user(browser, workerInfo, 'user2'); 7 + }); 8 + 9 + test.describe.configure({ 10 + timeout: 30000, 11 + }); 12 + 13 + test('External Release Attachments', async ({browser, isMobile}, workerInfo) => { 14 + test.skip(isMobile); 15 + 16 + const context = await load_logged_in_context(browser, workerInfo, 'user2'); 17 + /** @type {import('@playwright/test').Page} */ 18 + const page = await context.newPage(); 19 + 20 + // Click "New Release" 21 + await page.goto('/user2/repo2/releases'); 22 + await page.click('.button.small.primary'); 23 + 24 + // Fill out form and create new release 25 + await page.fill('input[name=tag_name]', '2.0'); 26 + await page.fill('input[name=title]', '2.0'); 27 + await page.click('#add-external-link'); 28 + await page.click('#add-external-link'); 29 + await page.fill('input[name=attachment-new-name-2]', 'Test'); 30 + await page.fill('input[name=attachment-new-exturl-2]', 'https://forgejo.org/'); 31 + await page.click('.remove-rel-attach'); 32 + save_visual(page); 33 + await page.click('.button.small.primary'); 34 + 35 + // Validate release page and click edit 36 + await expect(page.locator('.download[open] li')).toHaveCount(3); 37 + await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test'); 38 + await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://forgejo.org/'); 39 + save_visual(page); 40 + await page.locator('.octicon-pencil').first().click(); 41 + 42 + // Validate edit page and edit the release 43 + await expect(page.locator('.attachment_edit:visible')).toHaveCount(2); 44 + await expect(page.locator('.attachment_edit:visible').nth(0)).toHaveValue('Test'); 45 + await expect(page.locator('.attachment_edit:visible').nth(1)).toHaveValue('https://forgejo.org/'); 46 + await page.locator('.attachment_edit:visible').nth(0).fill('Test2'); 47 + await page.locator('.attachment_edit:visible').nth(1).fill('https://gitea.io/'); 48 + await page.click('#add-external-link'); 49 + await expect(page.locator('.attachment_edit:visible')).toHaveCount(4); 50 + await page.locator('.attachment_edit:visible').nth(2).fill('Test3'); 51 + await page.locator('.attachment_edit:visible').nth(3).fill('https://gitea.com/'); 52 + save_visual(page); 53 + await page.click('.button.small.primary'); 54 + 55 + // Validate release page and click edit 56 + await expect(page.locator('.download[open] li')).toHaveCount(4); 57 + await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test2'); 58 + await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://gitea.io/'); 59 + await expect(page.locator('.download[open] li:nth-of-type(4)')).toContainText('Test3'); 60 + await expect(page.locator('.download[open] li:nth-of-type(4) a')).toHaveAttribute('href', 'https://gitea.com/'); 61 + save_visual(page); 62 + await page.locator('.octicon-pencil').first().click(); 63 + 64 + // Delete release 65 + await page.click('.delete-button'); 66 + await page.click('.button.ok'); 67 + });
+67
tests/integration/api_releases_test.go
··· 347 347 348 348 assert.EqualValues(t, "stream.bin", attachment.Name) 349 349 assert.EqualValues(t, 104, attachment.Size) 350 + assert.EqualValues(t, "attachment", attachment.Type) 350 351 }) 351 352 } 352 353 ··· 385 386 assert.Equal(t, int64(1), release.ArchiveDownloadCount.TarGz) 386 387 assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip) 387 388 } 389 + 390 + func TestAPIExternalAssetRelease(t *testing.T) { 391 + defer tests.PrepareTestEnv(t)() 392 + 393 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) 394 + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) 395 + session := loginUser(t, owner.LowerName) 396 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) 397 + 398 + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") 399 + 400 + 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)). 401 + AddTokenAuth(token) 402 + resp := MakeRequest(t, req, http.StatusCreated) 403 + 404 + var attachment *api.Attachment 405 + DecodeJSON(t, resp, &attachment) 406 + 407 + assert.EqualValues(t, "test-asset", attachment.Name) 408 + assert.EqualValues(t, 0, attachment.Size) 409 + assert.EqualValues(t, "https://forgejo.org/", attachment.DownloadURL) 410 + assert.EqualValues(t, "external", attachment.Type) 411 + } 412 + 413 + func TestAPIDuplicateAssetRelease(t *testing.T) { 414 + defer tests.PrepareTestEnv(t)() 415 + 416 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) 417 + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) 418 + session := loginUser(t, owner.LowerName) 419 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) 420 + 421 + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") 422 + 423 + filename := "image.png" 424 + buff := generateImg() 425 + body := &bytes.Buffer{} 426 + 427 + writer := multipart.NewWriter(body) 428 + part, err := writer.CreateFormFile("attachment", filename) 429 + assert.NoError(t, err) 430 + _, err = io.Copy(part, &buff) 431 + assert.NoError(t, err) 432 + err = writer.Close() 433 + assert.NoError(t, err) 434 + 435 + 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). 436 + AddTokenAuth(token) 437 + req.Header.Add("Content-Type", writer.FormDataContentType()) 438 + MakeRequest(t, req, http.StatusBadRequest) 439 + } 440 + 441 + func TestAPIMissingAssetRelease(t *testing.T) { 442 + defer tests.PrepareTestEnv(t)() 443 + 444 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) 445 + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) 446 + session := loginUser(t, owner.LowerName) 447 + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) 448 + 449 + r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test") 450 + 451 + req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset", owner.Name, repo.Name, r.ID)). 452 + AddTokenAuth(token) 453 + MakeRequest(t, req, http.StatusBadRequest) 454 + }
+1 -1
tests/integration/mirror_pull_test.go
··· 78 78 IsDraft: false, 79 79 IsPrerelease: false, 80 80 IsTag: true, 81 - }, nil, "")) 81 + }, "", []*release_service.AttachmentChange{})) 82 82 83 83 _, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID) 84 84 assert.NoError(t, err)
+3 -3
tests/integration/webhook_test.go
··· 111 111 IsDraft: false, 112 112 IsPrerelease: false, 113 113 IsTag: false, 114 - }, nil, "")) 114 + }, "", nil)) 115 115 116 116 // check the newly created hooktasks 117 117 hookTasksLenBefore := len(hookTasks) ··· 125 125 126 126 t.Run("UpdateRelease", func(t *testing.T) { 127 127 rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.1"}) 128 - assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, nil, nil, nil, false)) 128 + assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, false, nil)) 129 129 130 130 // check the newly created hooktasks 131 131 hookTasksLenBefore := len(hookTasks) ··· 157 157 158 158 t.Run("UpdateRelease", func(t *testing.T) { 159 159 rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.2"}) 160 - assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, nil, nil, nil, true)) 160 + assert.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, true, nil)) 161 161 162 162 // check the newly created hooktasks 163 163 hookTasksLenBefore := len(hookTasks)
+44 -2
web_src/js/features/repo-release.js
··· 6 6 el.addEventListener('click', (e) => { 7 7 const uuid = e.target.getAttribute('data-uuid'); 8 8 const id = e.target.getAttribute('data-id'); 9 - document.querySelector(`input[name='attachment-del-${uuid}']`).value = 'true'; 9 + document.querySelector(`input[name='attachment-del-${uuid}']`).value = 10 + 'true'; 10 11 hideElem(`#attachment-${id}`); 11 12 }); 12 13 } ··· 17 18 18 19 initTagNameEditor(); 19 20 initRepoReleaseEditor(); 21 + initAddExternalLinkButton(); 20 22 } 21 23 22 24 function initTagNameEditor() { ··· 45 47 } 46 48 47 49 function initRepoReleaseEditor() { 48 - const editor = document.querySelector('.repository.new.release .combo-markdown-editor'); 50 + const editor = document.querySelector( 51 + '.repository.new.release .combo-markdown-editor', 52 + ); 49 53 if (!editor) { 50 54 return; 51 55 } 52 56 initComboMarkdownEditor(editor); 53 57 } 58 + 59 + let newAttachmentCount = 0; 60 + 61 + function initAddExternalLinkButton() { 62 + const addExternalLinkButton = document.getElementById('add-external-link'); 63 + if (!addExternalLinkButton) return; 64 + 65 + addExternalLinkButton.addEventListener('click', () => { 66 + newAttachmentCount += 1; 67 + const attachmentTemplate = document.getElementById('attachment-template'); 68 + 69 + const newAttachment = attachmentTemplate.cloneNode(true); 70 + newAttachment.id = `attachment-N${newAttachmentCount}`; 71 + newAttachment.classList.remove('tw-hidden'); 72 + 73 + const attachmentName = newAttachment.querySelector( 74 + 'input[name="attachment-template-new-name"]', 75 + ); 76 + attachmentName.name = `attachment-new-name-${newAttachmentCount}`; 77 + attachmentName.required = true; 78 + 79 + const attachmentExtUrl = newAttachment.querySelector( 80 + 'input[name="attachment-template-new-exturl"]', 81 + ); 82 + attachmentExtUrl.name = `attachment-new-exturl-${newAttachmentCount}`; 83 + attachmentExtUrl.required = true; 84 + 85 + const attachmentDel = newAttachment.querySelector('.remove-rel-attach'); 86 + attachmentDel.addEventListener('click', () => { 87 + newAttachment.remove(); 88 + }); 89 + 90 + attachmentTemplate.parentNode.insertBefore( 91 + newAttachment, 92 + attachmentTemplate, 93 + ); 94 + }); 95 + }