diff --git a/.forgejo/workflows/renovate.yml b/.forgejo/workflows/renovate.yml index cddda0fa09..b584f94cf8 100644 --- a/.forgejo/workflows/renovate.yml +++ b/.forgejo/workflows/renovate.yml @@ -28,7 +28,7 @@ jobs: runs-on: docker container: - image: data.forgejo.org/renovate/renovate:40.40.0 + image: data.forgejo.org/renovate/renovate:40.48.4 steps: - name: Load renovate repo cache diff --git a/Makefile b/Makefile index 759443456d..9774200b06 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 # renovate: datasour DEADCODE_PACKAGE ?= golang.org/x/tools/cmd/deadcode@v0.34.0 # renovate: datasource=go GOMOCK_PACKAGE ?= go.uber.org/mock/mockgen@v0.5.2 # renovate: datasource=go GOPLS_PACKAGE ?= golang.org/x/tools/gopls@v0.18.1 # renovate: datasource=go -RENOVATE_NPM_PACKAGE ?= renovate@40.40.0 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate +RENOVATE_NPM_PACKAGE ?= renovate@40.48.4 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate # https://github.com/disposable-email-domains/disposable-email-domains/commits/main/ DISPOSABLE_EMAILS_SHA ?= 0c27e671231d27cf66370034d7f6818037416989 # renovate: ... diff --git a/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet b/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet index 31b7d4f9b2..108cab0eb1 100644 --- a/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet +++ b/contrib/gitea-monitoring-mixin/dashboards/overview.libsonnet @@ -408,7 +408,7 @@ local addIssueLabelsOverrides(labels) = regex: '', type: 'query', multi: true, - allValue: '.+' + allValue: '.+', }, ) .addTemplate( @@ -423,7 +423,7 @@ local addIssueLabelsOverrides(labels) = regex: '', type: 'query', multi: true, - allValue: '.+' + allValue: '.+', }, ) .addTemplate( diff --git a/models/actions/runner.go b/models/actions/runner.go index 4d5056b425..bece1ae301 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -16,6 +16,7 @@ import ( repo_model "forgejo.org/models/repo" "forgejo.org/models/shared/types" user_model "forgejo.org/models/user" + "forgejo.org/modules/log" "forgejo.org/modules/optional" "forgejo.org/modules/timeutil" "forgejo.org/modules/translation" @@ -353,3 +354,53 @@ func FixRunnersWithoutBelongingRepo(ctx context.Context) (int64, error) { } return res.RowsAffected() } + +func DeleteOfflineRunners(ctx context.Context, olderThan timeutil.TimeStamp, globalOnly bool) error { + log.Info("Doing: DeleteOfflineRunners") + + if olderThan.AsTime().After(timeutil.TimeStampNow().AddDuration(-RunnerOfflineTime).AsTime()) { + return fmt.Errorf("invalid `cron.cleanup_offline_runners.older_than`value: must be at least %q", RunnerOfflineTime) + } + + cond := builder.Or( + // never online + builder.And(builder.Eq{"last_online": 0}, builder.Lt{"created": olderThan}), + // was online but offline + builder.And(builder.Gt{"last_online": 0}, builder.Lt{"last_online": olderThan}), + ) + + if globalOnly { + cond = builder.And(cond, builder.Eq{"owner_id": 0}, builder.Eq{"repo_id": 0}) + } + + if err := db.Iterate( + ctx, + cond, + func(ctx context.Context, r *ActionRunner) error { + if err := DeleteRunner(ctx, r); err != nil { + return fmt.Errorf("DeleteOfflineRunners: %w", err) + } + lastOnline := r.LastOnline.AsTime() + olderThanTime := olderThan.AsTime() + if !lastOnline.IsZero() && lastOnline.Before(olderThanTime) { + log.Info( + "Deleted runner [ID: %d, Name: %s], last online %s ago", + r.ID, r.Name, olderThanTime.Sub(lastOnline).String(), + ) + } else { + log.Info( + "Deleted runner [ID: %d, Name: %s], unused since %s ago", + r.ID, r.Name, olderThanTime.Sub(r.Created.AsTime()).String(), + ) + } + + return nil + }, + ); err != nil { + return err + } + + log.Info("Finished: DeleteOfflineRunners") + + return nil +} diff --git a/models/actions/runner_test.go b/models/actions/runner_test.go index 0623e66046..1916c35a76 100644 --- a/models/actions/runner_test.go +++ b/models/actions/runner_test.go @@ -6,10 +6,12 @@ import ( "encoding/binary" "fmt" "testing" + "time" auth_model "forgejo.org/models/auth" "forgejo.org/models/db" "forgejo.org/models/unittest" + "forgejo.org/modules/timeutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -73,3 +75,68 @@ func TestDeleteRunner(t *testing.T) { idAsBinary[6], idAsBinary[7]) assert.Equal(t, idAsHexadecimal, after.UUID[19:]) } + +func TestDeleteOfflineRunnersRunnerGlobalOnly(t *testing.T) { + baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) + timeutil.MockSet(baseTime) + defer timeutil.MockUnset() + + require.NoError(t, unittest.PrepareTestDatabase()) + + olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour) + + require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, true)) + + // create at test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678}) + // last_online test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001}) + // created one month ago but a repo + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000002}) + // last online one hour ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003}) + // last online 10 seconds ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004}) + // created 1 month ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000005}) + // created 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006}) + // last online 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007}) +} + +func TestDeleteOfflineRunnersAll(t *testing.T) { + baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) + timeutil.MockSet(baseTime) + defer timeutil.MockUnset() + + require.NoError(t, unittest.PrepareTestDatabase()) + + olderThan := timeutil.TimeStampNow().Add(-timeutil.Hour) + + require.NoError(t, DeleteOfflineRunners(db.DefaultContext, olderThan, false)) + + // create at test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 12345678}) + // last_online test base time + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000001}) + // created one month ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000002}) + // last online one hour ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000003}) + // last online 10 seconds ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000004}) + // created 1 month ago + unittest.AssertNotExistsBean(t, &ActionRunner{ID: 10000005}) + // created 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000006}) + // last online 1 hour ago + unittest.AssertExistsAndLoadBean(t, &ActionRunner{ID: 10000007}) +} + +func TestDeleteOfflineRunnersErrorOnInvalidOlderThanValue(t *testing.T) { + baseTime := time.Date(2024, 5, 19, 7, 40, 32, 0, time.UTC) + timeutil.MockSet(baseTime) + defer timeutil.MockUnset() + require.Error(t, DeleteOfflineRunners(db.DefaultContext, timeutil.TimeStampNow(), false)) +} diff --git a/models/fixtures/action_runner.yml b/models/fixtures/action_runner.yml index 94deac998e..fcf26d49b6 100644 --- a/models/fixtures/action_runner.yml +++ b/models/fixtures/action_runner.yml @@ -18,3 +18,122 @@ created: 1716104432 updated: 1716104432 deleted: ~ +- id: 10000001 + uuid: 10d3b248-6460-4bf5-b819-1f5b3109e10f + name: global-online + version: v6.3.1+7-gc4c0ca0 + owner_id: 0 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: 7e9ed71f64e98ce1f70e94c63f3cb6c41a8cb0b90de3e1daf7ec5c35361d60ed44da67c5ac393b7aaf443dcfc766007dc828 + token_salt: WUcgZWl7mW + last_online: 1716104422 + last_active: 0 + agent_labels: '["docker"]' + created: 1716104431 + updated: 1716104422 + deleted: ~ +- id: 10000002 + uuid: 1d188484-dd97-4a70-b707-5e87b578ab6b + name: repo-never-used + version: v6.3.1+7-gc4c0ca0 + owner_id: 0 + repo_id: 1 + description: "" + base: 0 + repo_range: "" + token_hash: 51e88c17ac8b54dd101dc2e4f530a71643c703adba7170f4b1a28f1cb483b4cfb107798c521e0532ef3c6480b64518a5c6a5 + token_salt: 4rh8ncXYIO + last_online: 0 + last_active: 0 + agent_labels: '["docker"]' + created: 1713512432 + updated: 1713512432 + deleted: ~ +- id: 10000003 + uuid: 7a039c6b-b0b2-4cf5-a93d-715d617f99e2 + name: global-offline + version: v6.3.1+7-gc4c0ca0 + owner_id: 0 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: c76960c56bc6069f0d1648991ec626500abe8c15286f5c355d565c3b5ba945d7d6f1272a6c77849e592528179511b94f5d69 + token_salt: TFMe2jhOkB + last_online: 1715499632 + last_active: 0 + agent_labels: '["docker"]' + created: 1715499632 + updated: 1715499632 + deleted: ~ +- id: 10000004 + uuid: 93ca7fdd-faca-4df6-a474-8345263ef10b + name: user-online + version: v6.3.1+7-gc4c0ca0 + owner_id: 1 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: 6ddf7f0f2301d2b3f66418145dc497a6d09fa6586e659afcb5ae2a0c5b639561d795aff8062537db9df73b396842ea826134 + token_salt: QcdGuReAp4 + last_online: 1716104422 + last_active: 0 + agent_labels: '["docker"]' + created: 1716104431 + updated: 1716104422 + deleted: ~ +- id: 10000005 + uuid: a8534df6-c4be-40f4-9714-903b69d973d9 + name: user-never-used + version: v6.3.1+7-gc4c0ca0 + owner_id: 1 + repo_id: 0 + description: desc + base: 0 + repo_range: "" + token_hash: 4441de7defcfc3d21baa608dec66a562cf23307abddaabdbb836907ac5f48c8780c354891916c525b79ec7af8e95be7a09b4 + token_salt: ONNqIOnj3t + last_online: 0 + last_active: 0 + agent_labels: '["docker"]' + created: 1713512433 + updated: 1713512433 + deleted: ~ +- id: 10000006 + uuid: e1c5bb6c-de68-4335-8955-5192f76708ac + name: orga-fresh-created + version: v6.3.1+7-gc4c0ca0 + owner_id: 35 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: a61f9ee48c6847d243ace0a8936efe80af9277c7bc46d6da6e03d1d406608b8023ee66600ad24f0effaa8e3338f92ac97ac9 + token_salt: fZJKjrFGWA + last_online: 0 + last_active: 0 + agent_labels: '["docker"]' + created: 1716100832 + updated: 1716100832 + deleted: ~ +- id: 10000007 + uuid: ff755f06-948e-479b-8031-5b3e9f123e32 + name: orga-offline + version: v6.3.1+7-gc4c0ca0 + owner_id: 35 + repo_id: 0 + description: "" + base: 0 + repo_range: "" + token_hash: 9372efb38f9b64efe65065380abe2f24ef34a59d9619f4cdc08f1151e9849f0b6e722aa10538e8730288de6e2f09acdac695 + token_salt: TnU7iiIdCb + last_online: 1716100832 + last_active: 0 + agent_labels: '["docker"]' + created: 1736085520 + updated: 1716100832 + deleted: ~ diff --git a/models/fixtures/team.yml b/models/fixtures/team.yml index 149fe90888..a863f1203a 100644 --- a/models/fixtures/team.yml +++ b/models/fixtures/team.yml @@ -239,3 +239,15 @@ num_members: 2 includes_all_repositories: false can_create_org_repo: false + +- + id: 25 + org_id: 17 + lower_name: super-user + name: super-user + description: "" + authorize: 3 + num_repos: 0 + num_members: 0 + includes_all_repositories: 0 + can_create_org_repo: 0 diff --git a/models/fixtures/team_unit.yml b/models/fixtures/team_unit.yml index e8f8d0e422..4d282a7eb5 100644 --- a/models/fixtures/team_unit.yml +++ b/models/fixtures/team_unit.yml @@ -329,3 +329,10 @@ team_id: 22 type: 3 access_mode: 1 + +- + id: 84 + org_id: 17 + team_id: 25 + type: 3 + access_mode: 3 diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 1a03185cf1..00aa182540 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -642,7 +642,7 @@ num_following: 0 num_stars: 0 num_repos: 2 - num_teams: 3 + num_teams: 4 num_members: 4 visibility: 0 repo_admin_change_team_access: false diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 4a09ef14f2..6d903be5f8 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -235,7 +235,7 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str if attach.UUID == "" { return errors.New("attachment uuid should be not blank") } - if attach.ExternalURL != "" && !validation.IsValidExternalURL(attach.ExternalURL) { + if attach.ExternalURL != "" && !validation.IsValidReleaseAssetURL(attach.ExternalURL) { return ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} } _, err := db.GetEngine(ctx).Where("uuid=?", attach.UUID).Cols(cols...).Update(attach) @@ -244,7 +244,7 @@ func UpdateAttachmentByUUID(ctx context.Context, attach *Attachment, cols ...str // UpdateAttachment updates the given attachment in database func UpdateAttachment(ctx context.Context, atta *Attachment) error { - if atta.ExternalURL != "" && !validation.IsValidExternalURL(atta.ExternalURL) { + if atta.ExternalURL != "" && !validation.IsValidReleaseAssetURL(atta.ExternalURL) { return ErrInvalidExternalURL{ExternalURL: atta.ExternalURL} } sess := db.GetEngine(ctx).Cols("name", "issue_id", "release_id", "comment_id", "download_count") diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index cd35367bc9..c5f0658d4e 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -35,6 +35,8 @@ type ServeHeaderOptions struct { Filename string CacheDuration time.Duration // defaults to 5 minutes LastModified time.Time + AdditionalHeaders http.Header + RedirectStatusCode int } // ServeSetHeaders sets necessary content serve headers @@ -82,6 +84,12 @@ func ServeSetHeaders(w http.ResponseWriter, opts *ServeHeaderOptions) { // http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat)) } + + if opts.AdditionalHeaders != nil { + for k, v := range opts.AdditionalHeaders { + header[k] = v + } + } } // ServeData download file from io.Reader diff --git a/modules/packages/container/metadata.go b/modules/packages/container/metadata.go index ec9d834357..6cac77b7ff 100644 --- a/modules/packages/container/metadata.go +++ b/modules/packages/container/metadata.go @@ -84,6 +84,13 @@ func ParseImageConfig(mt string, r io.Reader) (*Metadata, error) { func parseOCIImageConfig(r io.Reader) (*Metadata, error) { var image oci.Image if err := json.NewDecoder(r).Decode(&image); err != nil { + // Handle empty config blobs (common in OCI artifacts) + if err == io.EOF { + return &Metadata{ + Type: TypeOCI, + Platform: DefaultPlatform, + }, nil + } return nil, err } diff --git a/modules/packages/container/metadata_test.go b/modules/packages/container/metadata_test.go index 6c8c6ea5b9..5596c95751 100644 --- a/modules/packages/container/metadata_test.go +++ b/modules/packages/container/metadata_test.go @@ -4,6 +4,7 @@ package container import ( + "io" "strings" "testing" @@ -60,3 +61,49 @@ func TestParseImageConfig(t *testing.T) { assert.Equal(t, projectURL, metadata.ProjectURL) assert.Equal(t, repositoryURL, metadata.RepositoryURL) } + +func TestParseImageConfigEmptyBlob(t *testing.T) { + t.Run("Empty config blob (EOF)", func(t *testing.T) { + // Test empty reader (simulates empty config blob common in OCI artifacts) + metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("")) + require.NoError(t, err) + + assert.Equal(t, TypeOCI, metadata.Type) + assert.Equal(t, DefaultPlatform, metadata.Platform) + assert.Empty(t, metadata.Description) + assert.Empty(t, metadata.Authors) + assert.Empty(t, metadata.Labels) + assert.Empty(t, metadata.Manifests) + }) + + t.Run("Empty JSON object", func(t *testing.T) { + // Test minimal valid JSON config + metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("{}")) + require.NoError(t, err) + + assert.Equal(t, TypeOCI, metadata.Type) + assert.Equal(t, DefaultPlatform, metadata.Platform) + assert.Empty(t, metadata.Description) + assert.Empty(t, metadata.Authors) + }) + + t.Run("Invalid JSON still returns error", func(t *testing.T) { + // Test that actual JSON errors (not EOF) are still returned + _, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader("{invalid json")) + require.Error(t, err) + assert.NotEqual(t, io.EOF, err) + }) + + t.Run("OCI artifact with empty config", func(t *testing.T) { + // Test OCI artifact scenario with minimal config + configOCI := `{"config": {}}` + metadata, err := ParseImageConfig(oci.MediaTypeImageManifest, strings.NewReader(configOCI)) + require.NoError(t, err) + + assert.Equal(t, TypeOCI, metadata.Type) + assert.Equal(t, DefaultPlatform, metadata.Platform) + assert.Empty(t, metadata.Description) + assert.Empty(t, metadata.Authors) + assert.Empty(t, metadata.ImageLayers) + }) +} diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 1f573564e6..4b28dead03 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -75,6 +75,11 @@ func IsValidExternalURL(uri string) bool { return true } +// IsValidReleaseAssetURL checks if the URL is valid for external release assets +func IsValidReleaseAssetURL(uri string) bool { + return IsValidURL(uri) +} + // IsValidExternalTrackerURLFormat checks if URL matches required syntax for external trackers func IsValidExternalTrackerURLFormat(uri string) bool { if !IsValidExternalURL(uri) { diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index e43276a122..7479ab80af 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2985,8 +2985,6 @@ teams.invite_team_member.list = Pending invitations teams.delete_team_title = Delete team teams.delete_team_desc = Deleting a team revokes repository access from its members. Continue? teams.delete_team_success = The team has been deleted. -teams.read_permission_desc = This team grants Read access: members can view and clone team repositories. -teams.write_permission_desc = This team grants Write access: members can read from and push to team repositories. teams.admin_permission_desc = This team grants Administrator access: members can read from, push to and add collaborators to team repositories. teams.create_repo_permission_desc = Additionally, this team grants Create repository permission: members can create new repositories in organization. teams.repositories = Team repositories diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 3015be3ecd..96d0c4bc63 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -92,5 +92,6 @@ "discussion.locked": "This discussion has been locked. Commenting is limited to contributors.", "editor.textarea.tab_hint": "Line already indented. Press Tab again or Escape to leave the editor.", "editor.textarea.shift_tab_hint": "No indentation on this line. Press Shift + Tab again or Escape to leave the editor.", + "admin.dashboard.cleanup_offline_runners": "Cleanup offline runners", "meta.last_line": "Thank you for translating Forgejo! This line isn't seen by the users but it serves other purposes in the translation management. You can place a fun fact in the translation instead of translating it." } diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 5276dd5706..191a4aa455 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -4,6 +4,7 @@ package container import ( + "bytes" "errors" "fmt" "io" @@ -62,9 +63,6 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { if h.ContentType != "" { resp.Header().Set("Content-Type", h.ContentType) } - if h.ContentLength != 0 { - resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) - } if h.UploadUUID != "" { resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID) } @@ -72,17 +70,29 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { resp.Header().Set("Docker-Content-Digest", h.ContentDigest) resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest)) } + if h.ContentLength >= 0 { + resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) + } resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") resp.WriteHeader(h.Status) } func jsonResponse(ctx *context.Context, status int, obj any) { - setResponseHeaders(ctx.Resp, &containerHeaders{ - Status: status, - ContentType: "application/json", - }) - if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil { + // Buffer the JSON content first to calculate correct Content-Length + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(obj); err != nil { log.Error("JSON encode: %v", err) + return + } + + setResponseHeaders(ctx.Resp, &containerHeaders{ + Status: status, + ContentType: "application/json", + ContentLength: int64(buf.Len()), + }) + + if _, err := buf.WriteTo(ctx.Resp); err != nil { + log.Error("JSON write: %v", err) } } @@ -691,33 +701,30 @@ func DeleteManifest(ctx *context.Context) { func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) { serveDirectReqParams := make(url.Values) serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType)) - s, u, _, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams) + s, u, pf, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams) if err != nil { + if errors.Is(err, packages_model.ErrPackageFileNotExist) { + apiError(ctx, http.StatusNotFound, err) + return + } apiError(ctx, http.StatusInternalServerError, err) return } - headers := &containerHeaders{ - ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest), - ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), - ContentLength: pfd.Blob.Size, - Status: http.StatusOK, + opts := &context.ServeHeaderOptions{ + ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), + RedirectStatusCode: http.StatusTemporaryRedirect, + AdditionalHeaders: map[string][]string{ + "Docker-Distribution-Api-Version": {"registry/2.0"}, + }, } - if u != nil { - headers.Status = http.StatusTemporaryRedirect - headers.Location = u.String() - - setResponseHeaders(ctx.Resp, headers) - return + if d := pfd.Properties.GetByName(container_module.PropertyDigest); d != "" { + opts.AdditionalHeaders["Docker-Content-Digest"] = []string{d} + opts.AdditionalHeaders["ETag"] = []string{fmt.Sprintf(`"%s"`, d)} } - defer s.Close() - - setResponseHeaders(ctx.Resp, headers) - if _, err := io.Copy(ctx.Resp, s); err != nil { - log.Error("Error whilst copying content to response: %v", err) - } + helper.ServePackageFile(ctx, s, u, pf, opts) } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery @@ -725,7 +732,7 @@ func GetTagList(ctx *context.Context) { image := ctx.Params("image") if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil { - if err == packages_model.ErrPackageNotExist { + if errors.Is(err, packages_model.ErrPackageNotExist) { apiErrorDefined(ctx, errNameUnknown) } else { apiError(ctx, http.StatusInternalServerError, err) diff --git a/routers/api/packages/container/container_test.go b/routers/api/packages/container/container_test.go new file mode 100644 index 0000000000..2ed38d846d --- /dev/null +++ b/routers/api/packages/container/container_test.go @@ -0,0 +1,124 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package container + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetResponseHeaders(t *testing.T) { + t.Run("Content-Length for empty content", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 0, // Empty blob + ContentDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }) + + assert.Equal(t, "0", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("Content-Length for non-empty content", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 1024, + ContentDigest: "sha256:abcd1234", + }) + + assert.Equal(t, "1024", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:abcd1234", recorder.Header().Get("Docker-Content-Digest")) + }) + + t.Run("All headers set correctly", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusAccepted, + ContentLength: 512, + ContentDigest: "sha256:test123", + ContentType: "application/vnd.oci.image.manifest.v1+json", + Location: "/v2/test/repo/blobs/uploads/uuid123", + Range: "0-511", + UploadUUID: "uuid123", + }) + + assert.Equal(t, "512", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:test123", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", recorder.Header().Get("Content-Type")) + assert.Equal(t, "/v2/test/repo/blobs/uploads/uuid123", recorder.Header().Get("Location")) + assert.Equal(t, "0-511", recorder.Header().Get("Range")) + assert.Equal(t, "uuid123", recorder.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, `"sha256:test123"`, recorder.Header().Get("ETag")) + assert.Equal(t, http.StatusAccepted, recorder.Code) + }) +} + +// TestResponseHeadersForEmptyBlobs tests the core fix for ORAS empty blob support +func TestResponseHeadersForEmptyBlobs(t *testing.T) { + t.Run("Content-Length set for empty blob", func(t *testing.T) { + recorder := httptest.NewRecorder() + + // This tests the main fix: empty blobs should have Content-Length: 0 + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 0, // Empty blob (like empty config in ORAS artifacts) + ContentDigest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }) + + // The key fix: Content-Length should be set even for 0-byte blobs + assert.Equal(t, "0", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, `"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"`, recorder.Header().Get("ETag")) + assert.Equal(t, http.StatusOK, recorder.Code) + }) + + t.Run("Content-Length set for regular blob", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusOK, + ContentLength: 1024, + ContentDigest: "sha256:abcd1234", + }) + + assert.Equal(t, "1024", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:abcd1234", recorder.Header().Get("Docker-Content-Digest")) + }) + + t.Run("All headers set correctly", func(t *testing.T) { + recorder := httptest.NewRecorder() + + setResponseHeaders(recorder, &containerHeaders{ + Status: http.StatusAccepted, + ContentLength: 512, + ContentDigest: "sha256:test123", + ContentType: "application/vnd.oci.image.manifest.v1+json", + Location: "/v2/test/repo/blobs/uploads/uuid123", + Range: "0-511", + UploadUUID: "uuid123", + }) + + assert.Equal(t, "512", recorder.Header().Get("Content-Length")) + assert.Equal(t, "sha256:test123", recorder.Header().Get("Docker-Content-Digest")) + assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", recorder.Header().Get("Content-Type")) + assert.Equal(t, "/v2/test/repo/blobs/uploads/uuid123", recorder.Header().Get("Location")) + assert.Equal(t, "0-511", recorder.Header().Get("Range")) + assert.Equal(t, "uuid123", recorder.Header().Get("Docker-Upload-Uuid")) + assert.Equal(t, "registry/2.0", recorder.Header().Get("Docker-Distribution-Api-Version")) + assert.Equal(t, `"sha256:test123"`, recorder.Header().Get("ETag")) + assert.Equal(t, http.StatusAccepted, recorder.Code) + }) +} diff --git a/routers/api/packages/helper/helper.go b/routers/api/packages/helper/helper.go index 99c0867bbb..f9b91d9a09 100644 --- a/routers/api/packages/helper/helper.go +++ b/routers/api/packages/helper/helper.go @@ -39,16 +39,9 @@ func LogAndProcessError(ctx *context.Context, status int, obj any, cb func(strin } } -// Serves the content of the package file +// ServePackageFile Serves the content of the package file // If the url is set it will redirect the request, otherwise the content is copied to the response. func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf *packages_model.PackageFile, forceOpts ...*context.ServeHeaderOptions) { - if u != nil { - ctx.Redirect(u.String()) - return - } - - defer s.Close() - var opts *context.ServeHeaderOptions if len(forceOpts) > 0 { opts = forceOpts[0] @@ -59,5 +52,12 @@ func ServePackageFile(ctx *context.Context, s io.ReadSeekCloser, u *url.URL, pf } } + if u != nil { + ctx.Redirect(u.String(), opts.RedirectStatusCode) + return + } + + defer s.Close() + ctx.ServeContent(s, opts) } diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index fde5286e60..918be0f185 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -126,3 +126,9 @@ func CleanupLogs(ctx context.Context) error { log.Info("Removed %d logs", count) return nil } + +// CleanupOfflineRunners removes offline runners +func CleanupOfflineRunners(ctx context.Context, duration time.Duration, globalOnly bool) error { + olderThan := timeutil.TimeStampNow().AddDuration(-duration) + return actions_model.DeleteOfflineRunners(ctx, olderThan, globalOnly) +} diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index 365bd7faf6..b6f763842b 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -51,7 +51,7 @@ func NewExternalAttachment(ctx context.Context, attach *repo_model.Attachment) ( if attach.ExternalURL == "" { return nil, fmt.Errorf("attachment %s should have a external url", attach.Name) } - if !validation.IsValidExternalURL(attach.ExternalURL) { + if !validation.IsValidReleaseAssetURL(attach.ExternalURL) { return nil, repo_model.ErrInvalidExternalURL{ExternalURL: attach.ExternalURL} } diff --git a/services/context/base.go b/services/context/base.go index 0275ea8a99..dc3d226bb0 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -250,7 +250,7 @@ func (b *Base) PlainText(status int, text string) { // Redirect redirects the request func (b *Base) Redirect(location string, status ...int) { code := http.StatusSeeOther - if len(status) == 1 { + if len(status) == 1 && status[0] > 0 { code = status[0] } diff --git a/services/context/base_test.go b/services/context/base_test.go index bf746766d9..9e058d8f24 100644 --- a/services/context/base_test.go +++ b/services/context/base_test.go @@ -36,6 +36,7 @@ func TestRedirect(t *testing.T) { cleanup() has := resp.Header().Get("Set-Cookie") == "i_like_gitea=dummy" assert.Equal(t, c.keep, has, "url = %q", c.url) + assert.Equal(t, http.StatusSeeOther, resp.Code) } req, _ = http.NewRequest("GET", "/", nil) @@ -47,3 +48,24 @@ func TestRedirect(t *testing.T) { assert.Equal(t, "/other", resp.Header().Get("HX-Redirect")) assert.Equal(t, http.StatusNoContent, resp.Code) } + +func TestRedirectOptionalStatus(t *testing.T) { + defer test.MockVariableValue(&setting.AppURL, "http://localhost:3000/")() + req, _ := http.NewRequest("GET", "/", nil) + + cases := []struct { + expected int + actual int + }{ + {expected: 303}, + {http.StatusTemporaryRedirect, 307}, + {http.StatusPermanentRedirect, 308}, + } + for _, c := range cases { + resp := httptest.NewRecorder() + b, cleanup := NewBaseContext(resp, req) + b.Redirect("/", c.actual) + cleanup() + assert.Equal(t, c.expected, resp.Code) + } +} diff --git a/services/cron/setting.go b/services/cron/setting.go index 7fd4c4e1d8..2db6c15370 100644 --- a/services/cron/setting.go +++ b/services/cron/setting.go @@ -46,6 +46,13 @@ type CleanupHookTaskConfig struct { NumberToKeep int } +// CleanupOfflineRunnersConfig represents a cron task with settings to clean up offline-runner +type CleanupOfflineRunnersConfig struct { + BaseConfig + OlderThan time.Duration + GlobalScopeOnly bool +} + // GetSchedule returns the schedule for the base config func (b *BaseConfig) GetSchedule() string { return b.Schedule diff --git a/services/cron/tasks_actions.go b/services/cron/tasks_actions.go index a7fd3cd0bc..2cd484fa69 100644 --- a/services/cron/tasks_actions.go +++ b/services/cron/tasks_actions.go @@ -5,6 +5,7 @@ package cron import ( "context" + "time" user_model "forgejo.org/models/user" "forgejo.org/modules/setting" @@ -20,6 +21,7 @@ func initActionsTasks() { registerCancelAbandonedJobs() registerScheduleTasks() registerActionsCleanup() + registerOfflineRunnersCleanup() } func registerStopZombieTasks() { @@ -74,3 +76,22 @@ func registerActionsCleanup() { return actions_service.Cleanup(ctx) }) } + +func registerOfflineRunnersCleanup() { + RegisterTaskFatal("cleanup_offline_runners", &CleanupOfflineRunnersConfig{ + BaseConfig: BaseConfig{ + Enabled: false, + RunAtStart: false, + Schedule: "@midnight", + }, + GlobalScopeOnly: true, + OlderThan: time.Hour * 24, + }, func(ctx context.Context, _ *user_model.User, cfg Config) error { + c := cfg.(*CleanupOfflineRunnersConfig) + return actions_service.CleanupOfflineRunners( + ctx, + c.OlderThan, + c.GlobalScopeOnly, + ) + }) +} diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index c9f80259e2..faf0c336a0 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -42,11 +42,8 @@
  • {{ctx.Locale.Tr "org.teams.can_create_org_repo"}}
  • {{end}} - {{if (eq .Team.AccessMode 2)}} -

    {{ctx.Locale.Tr "org.settings.permission"}}

    - {{ctx.Locale.Tr "org.teams.write_permission_desc"}} - {{else if (eq .Team.AccessMode 3)}} -

    {{ctx.Locale.Tr "org.settings.permission"}}

    +

    {{ctx.Locale.Tr "org.settings.permission"}}

    + {{if (eq .Team.AccessMode 3)}} {{ctx.Locale.Tr "org.teams.admin_permission_desc"}} {{else}} diff --git a/templates/repo/release/list.tmpl b/templates/repo/release/list.tmpl index 54571f065a..b8f50b1f4d 100644 --- a/templates/repo/release/list.tmpl +++ b/templates/repo/release/list.tmpl @@ -98,7 +98,7 @@ {{range $release.Attachments}} {{if .ExternalURL}}
  • - + {{svg "octicon-link-external" 16 "tw-mr-1"}}{{.Name}}
  • diff --git a/tests/e2e/org-teams-overview.test.e2e.ts b/tests/e2e/org-teams-overview.test.e2e.ts new file mode 100644 index 0000000000..d968b1e6df --- /dev/null +++ b/tests/e2e/org-teams-overview.test.e2e.ts @@ -0,0 +1,116 @@ +// @watch start +// templates/org/team/sidebar.tmpl +// @watch end +/* eslint playwright/expect-expect: ["error", { "assertFunctionNames": ["assertPermissionsDetails", "assertRestrictedAccess", "assertOwnerPermissions"] }] */ +import {expect, type Page} from '@playwright/test'; +import {test} from './utils_e2e.ts'; + +type Permission = 'No access' | 'Write' | 'Read'; + +const UNIT_VALUES = [ + 'Code', + 'Issues', + 'Pull requests', + 'Releases', + 'Wiki', + 'External Wiki', + 'External issues', + 'Projects', + 'Packages', + 'Actions', +] as const; + +type Unit = typeof UNIT_VALUES[number]; + +const assertPermission = async (page: Page, name: Unit, permission: Permission) => { + await expect.soft(page.getByRole('row', {name}).getByRole('cell').nth(1)).toHaveText(permission); +}; + +const testTeamUrl = '/org/org17/teams/test_team'; +const reviewTeamUrl = '/org/org17/teams/review_team'; +const ownersUrl = '/org/org17/teams/owners'; +const adminUrl = '/org/org17/teams/super-user'; + +const cases: Record = { + [testTeamUrl]: {write: ['Issues']}, + [reviewTeamUrl]: {read: ['Code']}, +}; + +const assertOwnerPermissions = async (page: Page, code: number = 200) => { + const response = await page.goto(ownersUrl); + expect(response?.status()).toBe(code); + + await expect(page.getByText('Owners have full access to all repositories and have administrator access to the organization.')).toBeVisible(); +}; + +const assertAdminPermissions = async (page: Page, code: number = 200) => { + const response = await page.goto(adminUrl); + expect(response?.status()).toBe(code); + + await expect(page.getByText('This team grants Administrator access: members can read from, push to and add collaborators to team repositories.')).toBeVisible(); +}; + +const assertRestrictedAccess = async (page: Page, ...urls: string[]) => { + for (const url of urls) { + expect((await page.goto(url))?.status(), 'should not see any details').toBe(404); + } +}; + +const assertPermissionsDetails = async (page: Page, url: (keyof typeof cases)) => { + const response = await page.goto(url); + expect(response?.status()).toBe(200); + + const per = cases[url]; + + for (const unit of UNIT_VALUES) { + if (per.read?.includes(unit)) { + await assertPermission(page, unit, 'Read'); + } else if (per.write?.includes(unit)) { + await assertPermission(page, unit, 'Write'); + } else { + await assertPermission(page, unit, 'No access'); + } + } +}; + +test.describe('Orga team overview', () => { + test.describe('admin', () => { + test.use({user: 'user1'}); + + test('should see all', async ({page}) => { + await assertPermissionsDetails(page, testTeamUrl); + await assertPermissionsDetails(page, reviewTeamUrl); + await assertOwnerPermissions(page); + await assertAdminPermissions(page); + }); + }); + + test.describe('owner', () => { + test.use({user: 'user18'}); + + test('should see all', async ({page}) => { + await assertPermissionsDetails(page, testTeamUrl); + await assertPermissionsDetails(page, reviewTeamUrl); + await assertOwnerPermissions(page); + await assertAdminPermissions(page); + }); + }); + + test.describe('reviewer team', () => { + test.use({user: 'user29'}); + + test('should only see permissions for `reviewer team` and restricted access to other resources', async ({page}) => { + await assertPermissionsDetails(page, reviewTeamUrl); + await assertRestrictedAccess(page, ownersUrl, testTeamUrl, adminUrl); + }); + }); + + test.describe('test_team', () => { + test.use({user: 'user2'}); + + test('should only see permissions for test_team and restricted access to other resources', async ({page}) => { + await assertPermissionsDetails(page, testTeamUrl); + await assertRestrictedAccess(page, ownersUrl, reviewTeamUrl, adminUrl); + }); + }); +}); diff --git a/tests/e2e/utils_e2e_test.go b/tests/e2e/utils_e2e_test.go index f892d6c518..e121c604c3 100644 --- a/tests/e2e/utils_e2e_test.go +++ b/tests/e2e/utils_e2e_test.go @@ -94,6 +94,8 @@ func createSessions(t testing.TB) { "user1", "user2", "user12", + "user18", + "user29", "user40", } diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go index 104fdf4f67..9351dd9c20 100644 --- a/tests/integration/api_admin_test.go +++ b/tests/integration/api_admin_test.go @@ -333,11 +333,11 @@ func TestAPICron(t *testing.T) { AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, "28", resp.Header().Get("X-Total-Count")) + assert.Equal(t, "29", resp.Header().Get("X-Total-Count")) var crons []api.Cron DecodeJSON(t, resp, &crons) - assert.Len(t, crons, 28) + assert.Len(t, crons, 29) }) t.Run("Execute", func(t *testing.T) { diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go index 0e977e9ae7..fb092c91c7 100644 --- a/tests/integration/api_packages_container_test.go +++ b/tests/integration/api_packages_container_test.go @@ -56,7 +56,7 @@ func TestPackageContainer(t *testing.T) { return values } - images := []string{"test", "te/st"} + images := []string{"test", "te/st", "oras-artifact"} tags := []string{"latest", "main"} multiTag := "multi" @@ -177,6 +177,90 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, "registry/2.0", resp.Header().Get("Docker-Distribution-Api-Version")) }) + t.Run("ORAS Artifact Upload", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + image := "oras-artifact" + url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image) + + // Empty config blob (common in ORAS artifacts) + emptyConfigDigest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + emptyConfigContent := "" + + // Upload empty config blob + req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, emptyConfigDigest), bytes.NewReader([]byte(emptyConfigContent))). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, emptyConfigDigest), resp.Header().Get("Location")) + assert.Equal(t, emptyConfigDigest, resp.Header().Get("Docker-Content-Digest")) + + // Verify empty blob exists and has correct Content-Length + req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, emptyConfigDigest)). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "0", resp.Header().Get("Content-Length")) // This was the main fix + assert.Equal(t, emptyConfigDigest, resp.Header().Get("Docker-Content-Digest")) + + // Upload a small data blob (e.g., artifacthub metadata) + artifactData := `{"name":"test-artifact","version":"1.0.0"}` + artifactDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(artifactData))) + + req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, artifactDigest), bytes.NewReader([]byte(artifactData))). + AddTokenAuth(userToken) + resp = MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, artifactDigest), resp.Header().Get("Location")) + + // Create OCI artifact manifest + artifactManifest := fmt.Sprintf(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.cncf.artifacthub.config.v1+yaml", + "config": { + "mediaType": "application/vnd.cncf.artifacthub.config.v1+yaml", + "digest": "%s", + "size": %d + }, + "layers": [ + { + "mediaType": "application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml", + "digest": "%s", + "size": %d + } + ] + }`, emptyConfigDigest, len(emptyConfigContent), artifactDigest, len(artifactData)) + + artifactManifestDigest := fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(artifactManifest))) + + // Upload artifact manifest + req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/artifact-v1", url), bytes.NewReader([]byte(artifactManifest))). + AddTokenAuth(userToken). + SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json") + resp = MakeRequest(t, req, http.StatusCreated) + assert.Equal(t, fmt.Sprintf("/v2/%s/%s/manifests/artifact-v1", user.Name, image), resp.Header().Get("Location")) + assert.Equal(t, artifactManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + // Verify manifest can be retrieved + req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/artifact-v1", url)). + AddTokenAuth(userToken). + SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json") + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/vnd.oci.image.manifest.v1+json", resp.Header().Get("Content-Type")) + assert.Equal(t, artifactManifestDigest, resp.Header().Get("Docker-Content-Digest")) + + // Verify package was created with correct metadata + pvs, err := packages_model.GetVersionsByPackageType(db.DefaultContext, user.ID, packages_model.TypeContainer) + require.NoError(t, err) + + found := false + for _, pv := range pvs { + if pv.LowerVersion == "artifact-v1" { + found = true + break + } + } + assert.True(t, found, "ORAS artifact package should be created") + }) + for _, image := range images { t.Run(fmt.Sprintf("[Image:%s]", image), func(t *testing.T) { url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image) @@ -430,14 +514,19 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) }) - t.Run("GetManifest", func(t *testing.T) { + t.Run("GetManifest unknown-tag", func(t *testing.T) { defer tests.PrintCurrentTest(t)() req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/unknown-tag", url)). AddTokenAuth(userToken) MakeRequest(t, req, http.StatusNotFound) + }) - req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). + t.Run("GetManifest serv indirect", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, false)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). AddTokenAuth(userToken) resp := MakeRequest(t, req, http.StatusOK) @@ -446,6 +535,25 @@ func TestPackageContainer(t *testing.T) { assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest")) assert.Equal(t, manifestContent, resp.Body.String()) }) + + t.Run("GetManifest serv direct", func(t *testing.T) { + if setting.Packages.Storage.Type != setting.MinioStorageType { + t.Skip("Test skipped for non-Minio-storage.") + return + } + + defer tests.PrintCurrentTest(t)() + defer test.MockVariableValue(&setting.Packages.Storage.MinioConfig.ServeDirect, true)() + + req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)). + AddTokenAuth(userToken) + resp := MakeRequest(t, req, http.StatusTemporaryRedirect) + + assert.Empty(t, resp.Header().Get("Content-Length")) + assert.NotEmpty(t, resp.Header().Get("Location")) + assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type")) + assert.Empty(t, resp.Header().Get("Docker-Content-Digest")) + }) }) } @@ -580,36 +688,76 @@ func TestPackageContainer(t *testing.T) { t.Run("GetTagList", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - cases := []struct { + var cases []struct { URL string ExpectedTags []string ExpectedLink string - }{ - { - URL: fmt.Sprintf("%s/tags/list", url), - ExpectedTags: []string{"latest", "main", "multi"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, - { - URL: fmt.Sprintf("%s/tags/list?n=0", url), - ExpectedTags: []string{}, - ExpectedLink: "", - }, - { - URL: fmt.Sprintf("%s/tags/list?n=2", url), - ExpectedTags: []string{"latest", "main"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, - { - URL: fmt.Sprintf("%s/tags/list?last=main", url), - ExpectedTags: []string{"multi"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, - { - URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), - ExpectedTags: []string{"main"}, - ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), - }, + } + + if image == "oras-artifact" { + cases = []struct { + URL string + ExpectedTags []string + ExpectedLink string + }{ + { + URL: fmt.Sprintf("%s/tags/list", url), + ExpectedTags: []string{"artifact-v1", "latest", "main", "multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=0", url), + ExpectedTags: []string{}, + ExpectedLink: "", + }, + { + URL: fmt.Sprintf("%s/tags/list?n=2", url), + ExpectedTags: []string{"artifact-v1", "latest"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?last=main", url), + ExpectedTags: []string{"multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), + ExpectedTags: []string{"main"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + } + } else { + cases = []struct { + URL string + ExpectedTags []string + ExpectedLink string + }{ + { + URL: fmt.Sprintf("%s/tags/list", url), + ExpectedTags: []string{"latest", "main", "multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=0", url), + ExpectedTags: []string{}, + ExpectedLink: "", + }, + { + URL: fmt.Sprintf("%s/tags/list?n=2", url), + ExpectedTags: []string{"latest", "main"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?last=main", url), + ExpectedTags: []string{"multi"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + { + URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url), + ExpectedTags: []string{"main"}, + ExpectedLink: fmt.Sprintf(`; rel="next"`, user.Name, image), + }, + } } for _, c := range cases { @@ -636,7 +784,11 @@ func TestPackageContainer(t *testing.T) { var apiPackages []*api.Package DecodeJSON(t, resp, &apiPackages) - assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..." + if image == "oras-artifact" { + assert.Len(t, apiPackages, 5) // "artifact-v1", "latest", "main", "multi", "sha256:..." + } else { + assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..." + } }) t.Run("Delete", func(t *testing.T) { diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go index d5f7960ab1..f25948989a 100644 --- a/tests/integration/api_releases_test.go +++ b/tests/integration/api_releases_test.go @@ -430,6 +430,30 @@ func TestAPIExternalAssetRelease(t *testing.T) { assert.Equal(t, "external", attachment.Type) } +func TestAPIAllowedAPIURLInRelease(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") + internalURL := "https://localhost:3003/api/packages/owner/generic/test/1.0.0/test.txt" + + req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=%s", owner.Name, repo.Name, r.ID, url.QueryEscape(internalURL))). + AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + + var attachment *api.Attachment + DecodeJSON(t, resp, &attachment) + + assert.Equal(t, "test-asset", attachment.Name) + assert.EqualValues(t, 0, attachment.Size) + assert.Equal(t, internalURL, attachment.DownloadURL) + assert.Equal(t, "external", attachment.Type) +} + func TestAPIDuplicateAssetRelease(t *testing.T) { defer tests.PrepareTestEnv(t)()