diff --git a/.deadcode-out b/.deadcode-out
index e63e4a3dc3..61c5bcb055 100644
--- a/.deadcode-out
+++ b/.deadcode-out
@@ -13,6 +13,13 @@ forgejo.org/models
IsErrSHANotFound
IsErrMergeDivergingFastForwardOnly
+forgejo.org/models/activities
+ GetActivityByID
+ NewFederatedUserActivity
+ CreateUserActivity
+ GetFollowingFeeds
+ FederatedUserActivity.loadActor
+
forgejo.org/models/auth
WebAuthnCredentials
@@ -54,9 +61,17 @@ forgejo.org/models/user
IsErrExternalLoginUserAlreadyExist
IsErrExternalLoginUserNotExist
NewFederatedUser
+ NewFederatedUserFollower
IsErrUserSettingIsNotExist
GetUserAllSettings
DeleteUserSetting
+ GetFederatedUser
+ GetFederatedUserByUserID
+ UpdateFederatedUser
+ GetFollowersForUser
+ AddFollower
+ RemoveFollower
+ IsFollowingAp
forgejo.org/modules/activitypub
NewContext
diff --git a/.forgejo/workflows/renovate.yml b/.forgejo/workflows/renovate.yml
index 98b93b5757..5aa6c8cd98 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.57.1
+ image: data.forgejo.org/renovate/renovate:41.1.4
steps:
- name: Load renovate repo cache
diff --git a/.forgejo/workflows/testing.yml b/.forgejo/workflows/testing.yml
index 86e74591f1..7a93bb66a8 100644
--- a/.forgejo/workflows/testing.yml
+++ b/.forgejo/workflows/testing.yml
@@ -115,6 +115,11 @@ jobs:
run: |
su forgejo -c 'make deps-frontend frontend'
- uses: ./.forgejo/workflows-composite/build-backend
+ - name: Decide to run all tests
+ id: run-all
+ if: contains(github.event.pull_request.labels.*.name, 'run-all-playwright-tests') || contains(github.event.pull_request.title, 'playwright')
+ run: |
+ echo "all=1" >> "$GITHUB_OUTPUT"
- name: Get changed files
id: changed-files
uses: https://data.forgejo.org/tj-actions/changed-files@v46
@@ -127,6 +132,7 @@ jobs:
USE_REPO_TEST_DIR: 1
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
CHANGED_FILES: ${{steps.changed-files.outputs.all_changed_files}}
+ RUN_ALL: ${{steps.run-all.all}}
- name: Upload test artifacts on failure
if: failure()
uses: https://data.forgejo.org/forgejo/upload-artifact@v4
diff --git a/Makefile b/Makefile
index 852c85ccd6..e770f2a989 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ GO_LICENSES_PACKAGE ?= github.com/google/go-licenses@v1.6.0 # renovate: datasour
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 # renovate: datasource=go
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
-RENOVATE_NPM_PACKAGE ?= renovate@40.57.1 # renovate: datasource=docker packageName=data.forgejo.org/renovate/renovate
+RENOVATE_NPM_PACKAGE ?= renovate@41.1.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/go.mod b/go.mod
index 87755b206a..bb2be827eb 100644
--- a/go.mod
+++ b/go.mod
@@ -41,7 +41,7 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/go-ap/activitypub v0.0.0-20231114162308-e219254dc5c9
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
- github.com/go-chi/chi/v5 v5.2.1
+ github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/cors v1.2.1
github.com/go-co-op/gocron v1.37.0
github.com/go-enry/go-enry/v2 v2.9.2
diff --git a/go.sum b/go.sum
index 54710930e8..639880e2ce 100644
--- a/go.sum
+++ b/go.sum
@@ -213,8 +213,8 @@ github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5La
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
-github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
-github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
+github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
diff --git a/models/actions/run.go b/models/actions/run.go
index 48756b7a08..55def805ed 100644
--- a/models/actions/run.go
+++ b/models/actions/run.go
@@ -55,6 +55,7 @@ type ActionRun struct {
PreviousDuration time.Duration
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
+ NotifyEmail bool
}
func init() {
diff --git a/models/activities/action.go b/models/activities/action.go
index 1e40546b97..8592f81414 100644
--- a/models/activities/action.go
+++ b/models/activities/action.go
@@ -442,6 +442,12 @@ func (a *Action) GetIssueContent(ctx context.Context) string {
return a.Issue.Content
}
+func GetActivityByID(ctx context.Context, id int64) (*Action, error) {
+ var act Action
+ _, err := db.GetEngine(ctx).ID(id).Get(&act)
+ return &act, err
+}
+
// GetFeedsOptions options for retrieving feeds
type GetFeedsOptions struct {
db.ListOptions
@@ -595,13 +601,14 @@ func DeleteOldActions(ctx context.Context, olderThan time.Duration) (err error)
}
// NotifyWatchers creates batch of actions for every watcher.
-func NotifyWatchers(ctx context.Context, actions ...*Action) error {
+func NotifyWatchers(ctx context.Context, actions ...*Action) ([]Action, error) {
var watchers []*repo_model.Watch
var repo *repo_model.Repository
var err error
var permCode []bool
var permIssue []bool
var permPR []bool
+ var out []Action
e := db.GetEngine(ctx)
@@ -612,14 +619,14 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
// Add feeds for user self and all watchers.
watchers, err = repo_model.GetWatchers(ctx, act.RepoID)
if err != nil {
- return fmt.Errorf("get watchers: %w", err)
+ return nil, fmt.Errorf("get watchers: %w", err)
}
// Be aware that optimizing this correctly into the `GetWatchers` SQL
// query is for most cases less performant than doing this.
blockedDoerUserIDs, err := user_model.ListBlockedByUsersID(ctx, act.ActUserID)
if err != nil {
- return fmt.Errorf("user_model.ListBlockedByUsersID: %w", err)
+ return nil, fmt.Errorf("user_model.ListBlockedByUsersID: %w", err)
}
if len(blockedDoerUserIDs) > 0 {
@@ -634,8 +641,9 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
// Add feed for actioner.
act.UserID = act.ActUserID
if _, err = e.Insert(act); err != nil {
- return fmt.Errorf("insert new actioner: %w", err)
+ return nil, fmt.Errorf("insert new actioner: %w", err)
}
+ out = append(out, *act)
if repoChanged {
act.loadRepo(ctx)
@@ -643,7 +651,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
// check repo owner exist.
if err := act.Repo.LoadOwner(ctx); err != nil {
- return fmt.Errorf("can't get repo owner: %w", err)
+ return nil, fmt.Errorf("can't get repo owner: %w", err)
}
} else if act.Repo == nil {
act.Repo = repo
@@ -654,7 +662,7 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
act.ID = 0
act.UserID = act.Repo.Owner.ID
if err = db.Insert(ctx, act); err != nil {
- return fmt.Errorf("insert new actioner: %w", err)
+ return nil, fmt.Errorf("insert new actioner: %w", err)
}
}
@@ -707,26 +715,29 @@ func NotifyWatchers(ctx context.Context, actions ...*Action) error {
}
if err = db.Insert(ctx, act); err != nil {
- return fmt.Errorf("insert new action: %w", err)
+ return nil, fmt.Errorf("insert new action: %w", err)
}
}
}
- return nil
+ return out, nil
}
// NotifyWatchersActions creates batch of actions for every watcher.
-func NotifyWatchersActions(ctx context.Context, acts []*Action) error {
+func NotifyWatchersActions(ctx context.Context, acts []*Action) ([]Action, error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
- return err
+ return nil, err
}
defer committer.Close()
+ var out []Action
for _, act := range acts {
- if err := NotifyWatchers(ctx, act); err != nil {
- return err
+ as, err := NotifyWatchers(ctx, act)
+ if err != nil {
+ return nil, err
}
+ out = append(out, as...)
}
- return committer.Commit()
+ return out, committer.Commit()
}
// DeleteIssueActions delete all actions related with issueID
diff --git a/models/activities/action_test.go b/models/activities/action_test.go
index bcc9c98cec..47dbd8ac2d 100644
--- a/models/activities/action_test.go
+++ b/models/activities/action_test.go
@@ -197,7 +197,8 @@ func TestNotifyWatchers(t *testing.T) {
RepoID: 1,
OpType: activities_model.ActionStarRepo,
}
- require.NoError(t, activities_model.NotifyWatchers(db.DefaultContext, action))
+ _, err := activities_model.NotifyWatchers(db.DefaultContext, action)
+ require.NoError(t, err)
// One watchers are inactive, thus action is only created for user 8, 1, 4, 11
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{
diff --git a/models/activities/federated_user_activity.go b/models/activities/federated_user_activity.go
new file mode 100644
index 0000000000..1ff3a855d0
--- /dev/null
+++ b/models/activities/federated_user_activity.go
@@ -0,0 +1,106 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "context"
+ "fmt"
+
+ "forgejo.org/models/db"
+ user_model "forgejo.org/models/user"
+ "forgejo.org/modules/json"
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/timeutil"
+ "forgejo.org/modules/validation"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+type FederatedUserActivity struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"NOT NULL"`
+ ActorID int64
+ ActorURI string
+ Actor *user_model.User `xorm:"-"` // transient
+ NoteContent string `xorm:"TEXT"`
+ NoteURL string `xorm:"VARCHAR(255)"`
+ OriginalNote string `xorm:"TEXT"`
+ Created timeutil.TimeStamp `xorm:"created"`
+}
+
+func init() {
+ db.RegisterModel(new(FederatedUserActivity))
+}
+
+func NewFederatedUserActivity(userID, actorID int64, actorURI, noteContent, noteURL string, originalNote ap.Activity) (FederatedUserActivity, error) {
+ jsonString, err := json.Marshal(originalNote)
+ if err != nil {
+ return FederatedUserActivity{}, err
+ }
+ result := FederatedUserActivity{
+ UserID: userID,
+ ActorID: actorID,
+ ActorURI: actorURI,
+ NoteContent: noteContent,
+ NoteURL: noteURL,
+ OriginalNote: string(jsonString),
+ }
+ if valid, err := validation.IsValid(result); !valid {
+ return FederatedUserActivity{}, err
+ }
+ return result, nil
+}
+
+func (federatedUserActivity FederatedUserActivity) Validate() []string {
+ var result []string
+ result = append(result, validation.ValidateNotEmpty(federatedUserActivity.UserID, "UserID")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUserActivity.ActorID, "ActorID")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUserActivity.ActorURI, "ActorURI")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUserActivity.NoteContent, "NoteContent")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUserActivity.NoteURL, "NoteURL")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUserActivity.OriginalNote, "OriginalNote")...)
+ return result
+}
+
+func CreateUserActivity(ctx context.Context, federatedUserActivity *FederatedUserActivity) error {
+ if valid, err := validation.IsValid(federatedUserActivity); !valid {
+ return err
+ }
+ _, err := db.GetEngine(ctx).Insert(federatedUserActivity)
+ return err
+}
+
+type GetFollowingFeedsOptions struct {
+ db.ListOptions
+}
+
+func GetFollowingFeeds(ctx context.Context, actorID int64, opts GetFollowingFeedsOptions) ([]*FederatedUserActivity, int64, error) {
+ log.Debug("user_id = %s", actorID)
+ sess := db.GetEngine(ctx).Where("user_id = ?", actorID)
+ opts.SetDefaultValues()
+ sess = db.SetSessionPagination(sess, &opts)
+
+ actions := make([]*FederatedUserActivity, 0, opts.PageSize)
+ count, err := sess.FindAndCount(&actions)
+ if err != nil {
+ return nil, 0, fmt.Errorf("FindAndCount: %w", err)
+ }
+ for _, act := range actions {
+ if err := act.loadActor(ctx); err != nil {
+ return nil, 0, err
+ }
+ }
+ return actions, count, err
+}
+
+func (federatedUserActivity *FederatedUserActivity) loadActor(ctx context.Context) error {
+ log.Debug("for activity %s", federatedUserActivity)
+ actorUser, _, err := user_model.GetFederatedUserByUserID(ctx, federatedUserActivity.ActorID)
+ if err != nil {
+ return err
+ }
+ federatedUserActivity.Actor = actorUser
+
+ return nil
+}
diff --git a/models/activities/federated_user_activity_test.go b/models/activities/federated_user_activity_test.go
new file mode 100644
index 0000000000..9bf4f77984
--- /dev/null
+++ b/models/activities/federated_user_activity_test.go
@@ -0,0 +1,24 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activities
+
+import (
+ "testing"
+
+ "forgejo.org/modules/validation"
+)
+
+func Test_FederatedUserActivityValidation(t *testing.T) {
+ sut := FederatedUserActivity{}
+ sut.UserID = 13
+ sut.ActorID = 33
+ sut.ActorURI = "33"
+ sut.NoteContent = "Any content!"
+ sut.NoteURL = "https://example.org/note/17"
+ sut.OriginalNote = "federatedUserActivityNote-17"
+
+ if res, _ := validation.IsValid(sut); !res {
+ t.Errorf("sut expected to be valid: %v\n", sut.Validate())
+ }
+}
diff --git a/models/fixtures/comment.yml b/models/fixtures/comment.yml
index 2c47196c05..34407d6f81 100644
--- a/models/fixtures/comment.yml
+++ b/models/fixtures/comment.yml
@@ -153,3 +153,304 @@
issue_id: 19 # in repo_id 58
content: '{"is_force_push":true,"commit_ids":["1978192d98bb1b65e11c2cf37da854fbf94bffd6", "9b93963cf6de4dc33f915bb67f192d099c301f43"]}'
created_unix: 1749734240
+
+-
+ id: 2000
+ type: 8 # milestone
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ milestone_id: 1
+ old_milestone_id: 0
+ created_unix: 946684820
+
+-
+ id: 2001
+ type: 8 # milestone
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ milestone_id: 2
+ old_milestone_id: 1
+ created_unix: 946684920
+
+-
+ id: 2002
+ type: 8 # milestone
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ milestone_id: 0
+ old_milestone_id: 2
+ created_unix: 946685020
+
+-
+ id: 2003
+ type: 8 # milestone
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ milestone_id: 10 # not exsting milestone
+ old_milestone_id: 0
+ created_unix: 946685080
+
+-
+ id: 2010
+ type: 30 # project
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ project_id: 1
+ old_project_id: 0
+ created_unix: 946685120
+
+-
+ id: 2011
+ type: 30 # project
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ project_id: 2
+ old_project_id: 1
+ created_unix: 946685220
+
+-
+ id: 2012
+ type: 30 # project
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ project_id: 0
+ old_project_id: 2
+ created_unix: 946685320
+
+-
+ id: 2013
+ type: 30 # project
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ project_id: 10 # not existing project
+ old_project_id: 0
+ created_unix: 946685420
+
+-
+ id: 2020
+ type: 7 # label
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ label_id: 1
+ content: 1 # add label
+ created_unix: 946685520
+
+-
+ id: 2021
+ type: 7 # label
+ poster_id: 1
+ issue_id: 1
+ label_id: 2
+ content: 1 # add label
+ created_unix: 946685620
+
+-
+ id: 2022
+ type: 7 # label
+ poster_id: 2
+ issue_id: 1 # in repo_id 1
+ label_id: 1
+ content: 0 # remove label
+ created_unix: 946685720
+
+-
+ id: 2023
+ type: 7 # label
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ label_id: 1
+ content: 1 # add label
+ created_unix: 946685720
+
+-
+ id: 2024
+ type: 7 # label
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ label_id: 2
+ content: 0 # remove label
+ created_unix: 946685720
+
+-
+ id: 2025
+ type: 7 # label
+ poster_id: 2
+ issue_id: 1 # in repo_id 1
+ label_id: 2
+ content: 1 # add label
+ created_unix: 946685820
+
+-
+ id: 2026
+ type: 7 # label
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ label_id: 1
+ content: 0 # remove label
+ created_unix: 946685920
+
+-
+ id: 2027
+ type: 7 # label
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ label_id: 2
+ content: 0 # remove label
+ created_unix: 946685920
+
+- id: 2040
+ type: 9 # assignee
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ assignee_id: 1 # self
+ removed_assignee: false # add assignee
+ created_unix: 946688020
+
+- id: 2041
+ type: 9 # assignee
+ poster_id: 2
+ issue_id: 1 # in repo_id 1
+ assignee_id: 1
+ removed_assignee: true # remove assignee
+ created_unix: 946688120
+
+- id: 2042
+ type: 9 # assignee
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ assignee_id: 2
+ removed_assignee: false # add assignee
+ created_unix: 946688220
+
+- id: 2043
+ type: 9 # assignee
+ poster_id: 2
+ issue_id: 1 # in repo_id 1
+ assignee_id: 2 # self
+ removed_assignee: true # remove assignee
+ created_unix: 946688320
+
+- id: 2050
+ type: 23 # lock
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ created_unix: 946688420
+
+- id: 2051
+ type: 24 # unlock
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ created_unix: 946688520
+
+- id: 2052
+ type: 23 # lock
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ content: "Too heated"
+ created_unix: 946688620
+
+- id: 2053
+ type: 24 # unlock
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ created_unix: 946688720
+
+- id: 2060
+ type: 36 # pin
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ created_unix: 946688820
+
+- id: 2061
+ type: 37 # unpin
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ created_unix: 946688920
+
+- id: 2070
+ type: 2 # close
+ poster_id: 1
+ issue_id: 1 # in repo_id 1
+ created_unix: 946689020
+
+- id: 2071
+ type: 1 # reopen
+ poster_id: 2
+ issue_id: 1 # in repo_id 1
+ created_unix: 946689220
+
+- id: 2072
+ type: 2 # close
+ poster_id: 1
+ issue_id: 2 # pull in repo_id 1
+ created_unix: 946689320
+
+- id: 2073
+ type: 1 # reopen
+ poster_id: 2
+ issue_id: 2 # pull in repo_id 1
+ created_unix: 946689420
+
+- id: 2080
+ type: 3 # issue reference
+ poster_id: 1
+ issue_id: 1 # issue in repo_id 1
+ ref_repo_id: 1
+ ref_issue_id: 5 # issue in repo_id 1
+ created_unix: 946689500
+
+- id: 2081
+ type: 3 # issue reference
+ poster_id: 1
+ issue_id: 1 # issue in repo_id 1
+ ref_repo_id: 1
+ ref_issue_id: 2 # pull in repo_id 1
+ created_unix: 946689600
+
+- id: 2082
+ type: 3 # issue reference
+ poster_id: 1
+ issue_id: 1 # issue in repo_id 1
+ ref_repo_id: 32
+ ref_issue_id: 16 # issue in repo_id 32
+ created_unix: 946689700
+
+- id: 2083
+ type: 3 # issue reference
+ poster_id: 1
+ issue_id: 1 # issue in repo_id 1
+ ref_repo_id: 10
+ ref_issue_id: 8 # pull in repo_id 10
+ created_unix: 946689800
+
+- id: 2090
+ type: 6 # pull reference
+ poster_id: 1
+ issue_id: 2 # pull in repo_id 1
+ ref_repo_id: 1
+ ref_issue_id: 1 # issue in repo_id 1
+ created_unix: 946689900
+
+- id: 2091
+ type: 6 # pull reference
+ poster_id: 1
+ issue_id: 2 # pull in repo_id 1
+ ref_repo_id: 1
+ ref_issue_id: 2 # pull in repo_id 1
+ created_unix: 946690000
+
+- id: 2092
+ type: 6 # pull reference
+ poster_id: 1
+ issue_id: 2 # pull in repo_id 1
+ ref_repo_id: 32
+ ref_issue_id: 16 # issue in repo_id 32
+ created_unix: 946690050
+
+- id: 2093
+ type: 6 # pull reference
+ poster_id: 1
+ issue_id: 2 # pull in repo_id 1
+ ref_repo_id: 10
+ ref_issue_id: 8 # pull in repo_id 10
+ created_unix: 946690100
diff --git a/models/forgefed/federationhost.go b/models/forgefed/federationhost.go
index 29f1b7d28e..978847bd95 100644
--- a/models/forgefed/federationhost.go
+++ b/models/forgefed/federationhost.go
@@ -6,6 +6,7 @@ package forgefed
import (
"database/sql"
"fmt"
+ "net/url"
"strings"
"time"
@@ -17,9 +18,9 @@ import (
// swagger:model
type FederationHost struct {
ID int64 `xorm:"pk autoincr"`
- HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
+ HostFqdn string `xorm:"host_fqdn UNIQUE(federation_host) INDEX VARCHAR(255) NOT NULL"`
+ HostPort uint16 `xorm:" UNIQUE(federation_host) INDEX NOT NULL DEFAULT 443"`
NodeInfo NodeInfo `xorm:"extends NOT NULL"`
- HostPort uint16 `xorm:"NOT NULL DEFAULT 443"`
HostSchema string `xorm:"NOT NULL DEFAULT 'https'"`
LatestActivity time.Time `xorm:"NOT NULL"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
@@ -42,6 +43,13 @@ func NewFederationHost(hostFqdn string, nodeInfo NodeInfo, port uint16, schema s
return result, nil
}
+func (host FederationHost) AsURL() url.URL {
+ return url.URL{
+ Scheme: host.HostSchema,
+ Host: fmt.Sprintf("%v:%v", host.HostFqdn, host.HostPort),
+ }
+}
+
// Validate collects error strings in a slice and returns this
func (host FederationHost) Validate() []string {
var result []string
diff --git a/models/forgefed/nodeinfo.go b/models/forgefed/nodeinfo.go
index 2461b5e499..38f51304c5 100644
--- a/models/forgefed/nodeinfo.go
+++ b/models/forgefed/nodeinfo.go
@@ -17,12 +17,14 @@ type (
)
const (
- ForgejoSourceType SoftwareNameType = "forgejo"
- GiteaSourceType SoftwareNameType = "gitea"
+ ForgejoSourceType SoftwareNameType = "forgejo"
+ GiteaSourceType SoftwareNameType = "gitea"
+ MastodonSourceType SoftwareNameType = "mastodon"
+ GoToSocialSourceType SoftwareNameType = "gotosocial"
)
var KnownSourceTypes = []any{
- ForgejoSourceType, GiteaSourceType,
+ ForgejoSourceType, GiteaSourceType, MastodonSourceType, GoToSocialSourceType,
}
// ------------------------------------------------ NodeInfoWellKnown ------------------------------------------------
diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go
index 21a2077d06..737350b019 100644
--- a/models/forgejo_migrations/migrate.go
+++ b/models/forgejo_migrations/migrate.go
@@ -103,6 +103,12 @@ var migrations = []*Migration{
NewMigration("Normalize repository.topics to empty slice instead of null", SetTopicsAsEmptySlice),
// v31 -> v32
NewMigration("Migrate maven package name concatenation", ChangeMavenArtifactConcatenation),
+ // v32 -> v33
+ NewMigration("Add federated user activity tables, update the `federated_user` table & add indexes", FederatedUserActivityMigration),
+ // v33 -> v34
+ NewMigration("Add `notify-email` column to `action_run` table", AddNotifyEmailToActionRun),
+ // v34 -> v35
+ NewMigration("Add index to `stopped` column in `action_run` table", AddIndexToActionRunStopped),
}
// GetCurrentDBVersion returns the current Forgejo database version.
diff --git a/models/forgejo_migrations/v33.go b/models/forgejo_migrations/v33.go
new file mode 100644
index 0000000000..272035fc23
--- /dev/null
+++ b/models/forgejo_migrations/v33.go
@@ -0,0 +1,126 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgejo_migrations //nolint:revive
+
+import (
+ "fmt"
+
+ "forgejo.org/modules/log"
+ "forgejo.org/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+func dropOldFederationHostIndexes(x *xorm.Engine) {
+ // drop unique index on HostFqdn
+ type FederationHost struct {
+ ID int64 `xorm:"pk autoincr"`
+ HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
+ }
+
+ err := x.DropIndexes(FederationHost{})
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+}
+
+func addFederatedUserActivityTables(x *xorm.Engine) {
+ type FederatedUserActivity struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"NOT NULL INDEX user_id"`
+ ActorID int64
+ ActorURI string
+ NoteContent string `xorm:"TEXT"`
+ NoteURL string `xorm:"VARCHAR(255)"`
+ OriginalNote string `xorm:"TEXT"`
+ Created timeutil.TimeStamp `xorm:"created"`
+ }
+
+ // add unique index on HostFqdn+HostPort
+ type FederationHost struct {
+ ID int64 `xorm:"pk autoincr"`
+ HostFqdn string `xorm:"host_fqdn UNIQUE(federation_host) INDEX VARCHAR(255) NOT NULL"`
+ HostPort uint16 `xorm:"UNIQUE(federation_host) INDEX NOT NULL DEFAULT 443"`
+ }
+
+ type FederatedUserFollower struct {
+ ID int64 `xorm:"pk autoincr"`
+
+ FollowedUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
+ FollowingUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
+ }
+
+ // Add InboxPath to FederatedUser & add index fo UserID
+ type FederatedUser struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"NOT NULL INDEX user_id"`
+ InboxPath string
+ }
+
+ var err error
+
+ err = x.Sync(&FederationHost{})
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+
+ err = x.Sync(&FederatedUserActivity{})
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+
+ err = x.Sync(&FederatedUserFollower{})
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+
+ err = x.Sync(&FederatedUser{})
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+
+ // Migrate
+ sessMigration := x.NewSession()
+ defer sessMigration.Close()
+ if err := sessMigration.Begin(); err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+ federatedUsers := make([]*FederatedUser, 0)
+ err = sessMigration.OrderBy("id").Find(&federatedUsers)
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+
+ for _, federatedUser := range federatedUsers {
+ if federatedUser.InboxPath != "" {
+ log.Info("migration[33]: This user was already migrated: %v", federatedUser)
+ } else {
+ // Migrate User.InboxPath
+ sql := "UPDATE `federated_user` SET `inbox_path` = ? WHERE `id` = ?"
+ if _, err := sessMigration.Exec(sql, fmt.Sprintf("/api/v1/activitypub/user-id/%v/inbox", federatedUser.UserID), federatedUser.ID); err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+ }
+ }
+
+ err = sessMigration.Commit()
+ if err != nil {
+ log.Warn("migration[33]: There was an issue: %v", err)
+ return
+ }
+}
+
+func FederatedUserActivityMigration(x *xorm.Engine) error {
+ dropOldFederationHostIndexes(x)
+ addFederatedUserActivityTables(x)
+ return nil
+}
diff --git a/models/forgejo_migrations/v33_test.go b/models/forgejo_migrations/v33_test.go
new file mode 100644
index 0000000000..664c704bbc
--- /dev/null
+++ b/models/forgejo_migrations/v33_test.go
@@ -0,0 +1,46 @@
+// Copyright 2025 The Forgejo Authors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package forgejo_migrations //nolint:revive
+
+import (
+ "testing"
+ "time"
+
+ migration_tests "forgejo.org/models/migrations/test"
+ "forgejo.org/modules/log"
+ ft "forgejo.org/modules/test"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_FederatedUserActivityMigration(t *testing.T) {
+ lc, cl := ft.NewLogChecker(log.DEFAULT, log.WARN)
+ lc.Filter("migration[33]")
+ defer cl()
+
+ // intentionally conflicting definition
+ type FederatedUser struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID string
+ }
+
+ // Prepare TestEnv
+ x, deferable := migration_tests.PrepareTestEnv(t, 0,
+ new(FederatedUser),
+ )
+ sessTest := x.NewSession()
+ sessTest.Insert(FederatedUser{UserID: "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" +
+ "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890" +
+ "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"})
+ sessTest.Commit()
+ defer deferable()
+ if x == nil || t.Failed() {
+ return
+ }
+
+ require.NoError(t, FederatedUserActivityMigration(x))
+ logFiltered, _ := lc.Check(5 * time.Second)
+ assert.NotEmpty(t, logFiltered)
+}
diff --git a/models/forgejo_migrations/v34.go b/models/forgejo_migrations/v34.go
new file mode 100644
index 0000000000..9e958b934f
--- /dev/null
+++ b/models/forgejo_migrations/v34.go
@@ -0,0 +1,14 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package forgejo_migrations //nolint:revive
+
+import "xorm.io/xorm"
+
+func AddNotifyEmailToActionRun(x *xorm.Engine) error {
+ type ActionRun struct {
+ ID int64
+ NotifyEmail bool
+ }
+ return x.Sync(new(ActionRun))
+}
diff --git a/models/forgejo_migrations/v35.go b/models/forgejo_migrations/v35.go
new file mode 100644
index 0000000000..0fb3b43e2c
--- /dev/null
+++ b/models/forgejo_migrations/v35.go
@@ -0,0 +1,19 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package forgejo_migrations //nolint:revive
+
+import (
+ "forgejo.org/modules/timeutil"
+
+ "xorm.io/xorm"
+)
+
+func AddIndexToActionRunStopped(x *xorm.Engine) error {
+ type ActionRun struct {
+ ID int64
+ Stopped timeutil.TimeStamp `xorm:"index"`
+ }
+
+ return x.Sync(&ActionRun{})
+}
diff --git a/models/issues/pull.go b/models/issues/pull.go
index 0781fd0a2d..188ef00814 100644
--- a/models/issues/pull.go
+++ b/models/issues/pull.go
@@ -5,6 +5,7 @@
package issues
import (
+ "bufio"
"context"
"errors"
"fmt"
@@ -923,31 +924,30 @@ func MergeBlockedByOutdatedBranch(protectBranch *git_model.ProtectedBranch, pr *
return protectBranch.BlockOnOutdatedBranch && pr.CommitsBehind > 0
}
-// GetCodeOwnersFromContent returns the code owners configuration
-// Return empty slice if files missing
+// GetCodeOwnersFromReader returns the code owners configuration
// Return warning messages on parsing errors
// We're trying to do the best we can when parsing a file.
// Invalid lines are skipped. Non-existent users and teams too.
-func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRule, []string) {
- if len(data) == 0 {
- return nil, nil
- }
+func GetCodeOwnersFromReader(ctx context.Context, rc io.ReadCloser, truncated bool) ([]*CodeOwnerRule, []string) {
+ defer rc.Close()
+ scanner := bufio.NewScanner(rc)
- rules := make([]*CodeOwnerRule, 0)
- lines := strings.Split(data, "\n")
- warnings := make([]string, 0)
+ var rules []*CodeOwnerRule
+ var warnings []string
+ line := 0
+ for scanner.Scan() {
+ line++
- for i, line := range lines {
- tokens := TokenizeCodeOwnersLine(line)
+ tokens := TokenizeCodeOwnersLine(scanner.Text())
if len(tokens) == 0 {
continue
} else if len(tokens) < 2 {
- warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", i+1))
+ warnings = append(warnings, fmt.Sprintf("Line: %d: incorrect format", line))
continue
}
rule, wr := ParseCodeOwnersLine(ctx, tokens)
for _, w := range wr {
- warnings = append(warnings, fmt.Sprintf("Line: %d: %s", i+1, w))
+ warnings = append(warnings, fmt.Sprintf("Line: %d: %s", line, w))
}
if rule == nil {
continue
@@ -955,6 +955,12 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
rules = append(rules, rule)
}
+ if err := scanner.Err(); err != nil {
+ warnings = append(warnings, err.Error())
+ }
+ if truncated {
+ warnings = append(warnings, fmt.Sprintf("File too big: truncated while on line %d", line))
+ }
return rules, warnings
}
diff --git a/models/user/federated_user.go b/models/user/federated_user.go
index c1833c7de3..d2a9c34c9e 100644
--- a/models/user/federated_user.go
+++ b/models/user/federated_user.go
@@ -11,19 +11,21 @@ import (
type FederatedUser struct {
ID int64 `xorm:"pk autoincr"`
- UserID int64 `xorm:"NOT NULL"`
+ UserID int64 `xorm:"NOT NULL INDEX user_id"`
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
- NormalizedOriginalURL string // This field is just to keep original information. Pls. do not use for search or as ID!
+ InboxPath string
+ NormalizedOriginalURL string // This field is just to keep original information. Pls. do not use for search or as ID!
}
-func NewFederatedUser(userID int64, externalID string, federationHostID int64, normalizedOriginalURL string) (FederatedUser, error) {
+func NewFederatedUser(userID int64, externalID string, federationHostID int64, inboxPath, normalizedOriginalURL string) (FederatedUser, error) {
result := FederatedUser{
UserID: userID,
ExternalID: externalID,
FederationHostID: federationHostID,
+ InboxPath: inboxPath,
NormalizedOriginalURL: normalizedOriginalURL,
}
if valid, err := validation.IsValid(result); !valid {
@@ -32,10 +34,11 @@ func NewFederatedUser(userID int64, externalID string, federationHostID int64, n
return result, nil
}
-func (user FederatedUser) Validate() []string {
+func (federatedUser FederatedUser) Validate() []string {
var result []string
- result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...)
- result = append(result, validation.ValidateNotEmpty(user.ExternalID, "ExternalID")...)
- result = append(result, validation.ValidateNotEmpty(user.FederationHostID, "FederationHostID")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUser.UserID, "UserID")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUser.ExternalID, "ExternalID")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUser.FederationHostID, "FederationHostID")...)
+ result = append(result, validation.ValidateNotEmpty(federatedUser.InboxPath, "InboxPath")...)
return result
}
diff --git a/models/user/federated_user_follower.go b/models/user/federated_user_follower.go
new file mode 100644
index 0000000000..db72c9b5ce
--- /dev/null
+++ b/models/user/federated_user_follower.go
@@ -0,0 +1,30 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import "forgejo.org/modules/validation"
+
+type FederatedUserFollower struct {
+ ID int64 `xorm:"pk autoincr"`
+ FollowedUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
+ FollowingUserID int64 `xorm:"NOT NULL unique(fuf_rel)"`
+}
+
+func NewFederatedUserFollower(followedUserID, federatedUserID int64) (FederatedUserFollower, error) {
+ result := FederatedUserFollower{
+ FollowedUserID: followedUserID,
+ FollowingUserID: federatedUserID,
+ }
+ if valid, err := validation.IsValid(result); !valid {
+ return FederatedUserFollower{}, err
+ }
+ return result, nil
+}
+
+func (user FederatedUserFollower) Validate() []string {
+ var result []string
+ result = append(result, validation.ValidateNotEmpty(user.FollowedUserID, "FollowedUserID")...)
+ result = append(result, validation.ValidateNotEmpty(user.FollowingUserID, "FollowingUserID")...)
+ return result
+}
diff --git a/models/user/federated_user_follower_test.go b/models/user/federated_user_follower_test.go
new file mode 100644
index 0000000000..e57ba01308
--- /dev/null
+++ b/models/user/federated_user_follower_test.go
@@ -0,0 +1,27 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package user
+
+import (
+ "testing"
+
+ "forgejo.org/modules/validation"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_FederatedUserFollowerValidation(t *testing.T) {
+ sut := FederatedUserFollower{
+ FollowedUserID: 12,
+ FollowingUserID: 1,
+ }
+ res, err := validation.IsValid(sut)
+ assert.Truef(t, res, "sut should be valid but was %q", err)
+
+ sut = FederatedUserFollower{
+ FollowedUserID: 1,
+ }
+ res, _ = validation.IsValid(sut)
+ assert.False(t, res, "sut should be invalid")
+}
diff --git a/models/user/federated_user_test.go b/models/user/federated_user_test.go
index 542798c9bc..be18339670 100644
--- a/models/user/federated_user_test.go
+++ b/models/user/federated_user_test.go
@@ -14,6 +14,7 @@ func Test_FederatedUserValidation(t *testing.T) {
UserID: 12,
ExternalID: "12",
FederationHostID: 1,
+ InboxPath: "/api/v1/activitypub/user-id/12/inbox",
}
if res, err := validation.IsValid(sut); !res {
t.Errorf("sut should be valid but was %q", err)
@@ -22,6 +23,7 @@ func Test_FederatedUserValidation(t *testing.T) {
sut = FederatedUser{
ExternalID: "12",
FederationHostID: 1,
+ InboxPath: "/api/v1/activitypub/user-id/12/inbox",
}
if res, _ := validation.IsValid(sut); res {
t.Error("sut should be invalid")
diff --git a/models/user/follow.go b/models/user/follow.go
index 5be0f73c35..e32c226385 100644
--- a/models/user/follow.go
+++ b/models/user/follow.go
@@ -11,6 +11,7 @@ import (
)
// Follow represents relations of user and their followers.
+// TODO: We should unify Activity-pub-following and classical following (see models/user/user_repository.go#IsFollowingAp)
type Follow struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(follow)"`
diff --git a/models/user/user_repository.go b/models/user/user_repository.go
index 299d3af64a..3f24efb1fb 100644
--- a/models/user/user_repository.go
+++ b/models/user/user_repository.go
@@ -1,4 +1,4 @@
-// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2024, 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
@@ -8,12 +8,14 @@ import (
"fmt"
"forgejo.org/models/db"
+ "forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/validation"
)
func init() {
db.RegisterModel(new(FederatedUser))
+ db.RegisterModel(new(FederatedUserFollower))
}
func CreateFederatedUser(ctx context.Context, user *User, federatedUser *FederatedUser) error {
@@ -30,7 +32,12 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat
if err != nil {
return err
}
- defer committer.Close()
+ defer func() {
+ err := committer.Close()
+ if err != nil {
+ log.Error("Error closing committer: %v", err)
+ }
+ }()
if err := CreateUser(ctx, user, &overwrite); err != nil {
return err
@@ -50,6 +57,14 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat
return committer.Commit()
}
+func (federatedUser *FederatedUser) UpdateFederatedUser(ctx context.Context) error {
+ if _, err := validation.IsValid(federatedUser); err != nil {
+ return err
+ }
+ _, err := db.GetEngine(ctx).ID(federatedUser.ID).Cols("inbox_path").Update(federatedUser)
+ return err
+}
+
func FindFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) {
federatedUser := new(FederatedUser)
user := new(User)
@@ -75,6 +90,41 @@ func FindFederatedUser(ctx context.Context, externalID string, federationHostID
return user, federatedUser, nil
}
+func GetFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) {
+ user, federatedUser, err := FindFederatedUser(ctx, externalID, federationHostID)
+ if err != nil {
+ return nil, nil, err
+ } else if federatedUser == nil {
+ return nil, nil, fmt.Errorf("FederatedUser for externalId = %v and federationHostId = %v does not exist", externalID, federationHostID)
+ }
+ return user, federatedUser, nil
+}
+
+func GetFederatedUserByUserID(ctx context.Context, userID int64) (*User, *FederatedUser, error) {
+ federatedUser := new(FederatedUser)
+ user := new(User)
+ has, err := db.GetEngine(ctx).Where("user_id=?", userID).Get(federatedUser)
+ if err != nil {
+ return nil, nil, err
+ } else if !has {
+ return nil, nil, fmt.Errorf("Federated user %v does not exist", federatedUser.UserID)
+ }
+ has, err = db.GetEngine(ctx).ID(federatedUser.UserID).Get(user)
+ if err != nil {
+ return nil, nil, err
+ } else if !has {
+ return nil, nil, fmt.Errorf("User %v for federated user is missing", federatedUser.UserID)
+ }
+
+ if res, err := validation.IsValid(*user); !res {
+ return nil, nil, err
+ }
+ if res, err := validation.IsValid(*federatedUser); !res {
+ return nil, nil, err
+ }
+ return user, federatedUser, nil
+}
+
func FindFederatedUserByKeyID(ctx context.Context, keyID string) (*User, *FederatedUser, error) {
federatedUser := new(FederatedUser)
user := new(User)
@@ -101,7 +151,85 @@ func FindFederatedUserByKeyID(ctx context.Context, keyID string) (*User, *Federa
return user, federatedUser, nil
}
+func UpdateFederatedUser(ctx context.Context, federatedUser *FederatedUser) error {
+ if res, err := validation.IsValid(federatedUser); !res {
+ return err
+ }
+ _, err := db.GetEngine(ctx).ID(federatedUser.ID).Update(federatedUser)
+ return err
+}
+
func DeleteFederatedUser(ctx context.Context, userID int64) error {
_, err := db.GetEngine(ctx).Delete(&FederatedUser{UserID: userID})
return err
}
+
+func GetFollowersForUser(ctx context.Context, user *User) ([]*FederatedUserFollower, error) {
+ if res, err := validation.IsValid(user); !res {
+ return nil, err
+ }
+ followers := make([]*FederatedUserFollower, 0, 8)
+
+ err := db.GetEngine(ctx).
+ Where("followed_user_id = ?", user.ID).
+ Find(&followers)
+ if err != nil {
+ return nil, err
+ }
+ for _, element := range followers {
+ if res, err := validation.IsValid(*element); !res {
+ return nil, err
+ }
+ }
+ return followers, nil
+}
+
+func AddFollower(ctx context.Context, followedUser *User, followingUser *FederatedUser) (*FederatedUserFollower, error) {
+ if res, err := validation.IsValid(followedUser); !res {
+ return nil, err
+ }
+ if res, err := validation.IsValid(followingUser); !res {
+ return nil, err
+ }
+
+ federatedUserFollower, err := NewFederatedUserFollower(followedUser.ID, followingUser.UserID)
+ if err != nil {
+ return nil, err
+ }
+ _, err = db.GetEngine(ctx).Insert(&federatedUserFollower)
+ if err != nil {
+ return nil, err
+ }
+
+ return &federatedUserFollower, err
+}
+
+func RemoveFollower(ctx context.Context, followedUser *User, followingUser *FederatedUser) error {
+ if res, err := validation.IsValid(followedUser); !res {
+ return err
+ }
+ if res, err := validation.IsValid(followingUser); !res {
+ return err
+ }
+
+ _, err := db.GetEngine(ctx).Delete(&FederatedUserFollower{
+ FollowedUserID: followedUser.ID,
+ FollowingUserID: followingUser.UserID,
+ })
+ return err
+}
+
+// TODO: We should unify Activity-pub-following and classical following (see models/user/follow.go)
+func IsFollowingAp(ctx context.Context, followedUser *User, followingUser *FederatedUser) (bool, error) {
+ if res, err := validation.IsValid(followedUser); !res {
+ return false, err
+ }
+ if res, err := validation.IsValid(followingUser); !res {
+ return false, err
+ }
+
+ return db.GetEngine(ctx).Get(&FederatedUserFollower{
+ FollowedUserID: followedUser.ID,
+ FollowingUserID: followingUser.UserID,
+ })
+}
diff --git a/models/user/user_system.go b/models/user/user_system.go
index 82805cc8ee..11f54591b7 100644
--- a/models/user/user_system.go
+++ b/models/user/user_system.go
@@ -12,6 +12,13 @@ import (
"forgejo.org/modules/structs"
)
+// IsSystem returns true if the user has a fixed
+// negative ID, is never stored in the database and
+// is generated on the fly when needed.
+func (u *User) IsSystem() bool {
+ return u.IsGhost() || u.IsActions()
+}
+
const (
GhostUserID = -1
GhostUserName = "Ghost"
diff --git a/models/user/user_test.go b/models/user/user_test.go
index 2a9e652a35..fd9d05653f 100644
--- a/models/user/user_test.go
+++ b/models/user/user_test.go
@@ -148,7 +148,7 @@ func TestAPActorID_APActorID(t *testing.T) {
assert.Equal(t, expected, url)
}
-func TestAPActorKeyID(t *testing.T) {
+func TestKeyID(t *testing.T) {
user := user_model.User{ID: 1}
url := user.APActorKeyID()
expected := "https://try.gitea.io/api/v1/activitypub/user-id/1#main-key"
diff --git a/modules/git/blob.go b/modules/git/blob.go
index 3fda358938..8c5c275146 100644
--- a/modules/git/blob.go
+++ b/modules/git/blob.go
@@ -12,7 +12,6 @@ import (
"forgejo.org/modules/log"
"forgejo.org/modules/typesniffer"
- "forgejo.org/modules/util"
)
// Blob represents a Git object.
@@ -25,42 +24,25 @@ type Blob struct {
repo *Repository
}
-// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
-// Calling the Close function on the result will discard all unread output.
-func (b *Blob) DataAsync() (io.ReadCloser, error) {
+func (b *Blob) newReader() (*bufio.Reader, int64, func(), error) {
wr, rd, cancel, err := b.repo.CatFileBatch(b.repo.Ctx)
if err != nil {
- return nil, err
+ return nil, 0, nil, err
}
_, err = wr.Write([]byte(b.ID.String() + "\n"))
if err != nil {
cancel()
- return nil, err
+ return nil, 0, nil, err
}
_, _, size, err := ReadBatchLine(rd)
if err != nil {
cancel()
- return nil, err
+ return nil, 0, nil, err
}
b.gotSize = true
b.size = size
-
- if size < 4096 {
- bs, err := io.ReadAll(io.LimitReader(rd, size))
- defer cancel()
- if err != nil {
- return nil, err
- }
- _, err = rd.Discard(1)
- return io.NopCloser(bytes.NewReader(bs)), err
- }
-
- return &blobReader{
- rd: rd,
- n: size,
- cancel: cancel,
- }, nil
+ return rd, size, cancel, err
}
// Size returns the uncompressed size of the blob
@@ -91,10 +73,36 @@ func (b *Blob) Size() int64 {
return b.size
}
+// DataAsync gets a ReadCloser for the contents of a blob without reading it all.
+// Calling the Close function on the result will discard all unread output.
+func (b *Blob) DataAsync() (io.ReadCloser, error) {
+ rd, size, cancel, err := b.newReader()
+ if err != nil {
+ return nil, err
+ }
+
+ if size < 4096 {
+ bs, err := io.ReadAll(io.LimitReader(rd, size))
+ defer cancel()
+ if err != nil {
+ return nil, err
+ }
+ _, err = rd.Discard(1)
+ return io.NopCloser(bytes.NewReader(bs)), err
+ }
+
+ return &blobReader{
+ rd: rd,
+ n: size,
+ cancel: cancel,
+ }, nil
+}
+
type blobReader struct {
- rd *bufio.Reader
- n int64
- cancel func()
+ rd *bufio.Reader
+ n int64 // number of bytes to read
+ additionalDiscard int64 // additional number of bytes to discard
+ cancel func()
}
func (b *blobReader) Read(p []byte) (n int, err error) {
@@ -117,7 +125,8 @@ func (b *blobReader) Close() error {
defer b.cancel()
- if err := DiscardFull(b.rd, b.n+1); err != nil {
+ // discard the unread bytes, the truncated bytes and the trailing newline
+ if err := DiscardFull(b.rd, b.n+b.additionalDiscard+1); err != nil {
return err
}
@@ -131,17 +140,35 @@ func (b *Blob) Name() string {
return b.name
}
-// GetBlobContent Gets the limited content of the blob as raw text
+// NewTruncatedReader return a blob-reader which silently truncates when the limit is reached (io.EOF will be returned)
+func (b *Blob) NewTruncatedReader(limit int64) (rc io.ReadCloser, fullSize int64, err error) {
+ r, fullSize, cancel, err := b.newReader()
+ if err != nil {
+ return nil, fullSize, err
+ }
+
+ limit = min(limit, fullSize)
+ return &blobReader{
+ rd: r,
+ n: limit,
+ additionalDiscard: fullSize - limit,
+ cancel: cancel,
+ }, fullSize, nil
+}
+
+// GetBlobContent Gets the truncated content of the blob as raw text
func (b *Blob) GetBlobContent(limit int64) (string, error) {
if limit <= 0 {
return "", nil
}
- dataRc, err := b.DataAsync()
+ rc, fullSize, err := b.NewTruncatedReader(limit)
if err != nil {
return "", err
}
- defer dataRc.Close()
- buf, err := util.ReadWithLimit(dataRc, int(limit))
+ defer rc.Close()
+
+ buf := make([]byte, min(fullSize, limit))
+ _, err = io.ReadFull(rc, buf)
return string(buf), err
}
diff --git a/modules/git/blob_test.go b/modules/git/blob_test.go
index 810964b33d..54115013d3 100644
--- a/modules/git/blob_test.go
+++ b/modules/git/blob_test.go
@@ -35,6 +35,106 @@ func TestBlob_Data(t *testing.T) {
assert.Equal(t, output, string(data))
}
+func TestBlob(t *testing.T) {
+ bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
+ repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
+ require.NoError(t, err)
+
+ defer repo.Close()
+
+ testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
+ require.NoError(t, err)
+
+ t.Run("GetBlobContent", func(t *testing.T) {
+ r, err := testBlob.GetBlobContent(100)
+ require.NoError(t, err)
+ require.Equal(t, "file2\n", r)
+
+ r, err = testBlob.GetBlobContent(-1)
+ require.NoError(t, err)
+ require.Empty(t, r)
+
+ r, err = testBlob.GetBlobContent(4)
+ require.NoError(t, err)
+ require.Equal(t, "file", r)
+
+ r, err = testBlob.GetBlobContent(6)
+ require.NoError(t, err)
+ require.Equal(t, "file2\n", r)
+ })
+
+ t.Run("NewTruncatedReader", func(t *testing.T) {
+ // read fewer than available
+ rc, size, err := testBlob.NewTruncatedReader(100)
+ require.NoError(t, err)
+ require.Equal(t, int64(6), size)
+
+ buf := make([]byte, 1)
+ n, err := rc.Read(buf)
+ require.NoError(t, err)
+ require.Equal(t, 1, n)
+ require.Equal(t, "f", string(buf))
+ n, err = rc.Read(buf)
+ require.NoError(t, err)
+ require.Equal(t, 1, n)
+ require.Equal(t, "i", string(buf))
+
+ require.NoError(t, rc.Close())
+
+ // read more than available
+ rc, size, err = testBlob.NewTruncatedReader(100)
+ require.NoError(t, err)
+ require.Equal(t, int64(6), size)
+
+ buf = make([]byte, 100)
+ n, err = rc.Read(buf)
+ require.NoError(t, err)
+ require.Equal(t, 6, n)
+ require.Equal(t, "file2\n", string(buf[:n]))
+
+ n, err = rc.Read(buf)
+ require.Error(t, err)
+ require.Equal(t, io.EOF, err)
+ require.Equal(t, 0, n)
+
+ require.NoError(t, rc.Close())
+
+ // read more than truncated
+ rc, size, err = testBlob.NewTruncatedReader(4)
+ require.NoError(t, err)
+ require.Equal(t, int64(6), size)
+
+ buf = make([]byte, 10)
+ n, err = rc.Read(buf)
+ require.NoError(t, err)
+ require.Equal(t, 4, n)
+ require.Equal(t, "file", string(buf[:n]))
+
+ n, err = rc.Read(buf)
+ require.Error(t, err)
+ require.Equal(t, io.EOF, err)
+ require.Equal(t, 0, n)
+
+ require.NoError(t, rc.Close())
+ })
+
+ t.Run("NonExisting", func(t *testing.T) {
+ nonExistingBlob, err := repo.GetBlob("00003ff740f9380390d5c9ddef4af18690000000")
+ require.NoError(t, err)
+
+ r, err := nonExistingBlob.GetBlobContent(100)
+ require.Error(t, err)
+ require.IsType(t, ErrNotExist{}, err)
+ require.Empty(t, r)
+
+ rc, size, err := nonExistingBlob.NewTruncatedReader(100)
+ require.Error(t, err)
+ require.IsType(t, ErrNotExist{}, err)
+ require.Empty(t, rc)
+ require.Empty(t, size)
+ })
+}
+
func Benchmark_Blob_Data(b *testing.B) {
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
repo, err := openRepositoryWithDefaultContext(bareRepo1Path)
diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go
index 717da464b9..e811d29994 100644
--- a/modules/markup/markdown/markdown.go
+++ b/modules/markup/markdown/markdown.go
@@ -267,8 +267,13 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
// RenderString renders Markdown string to HTML with all specific handling stuff and return string
func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
+ return RenderReader(ctx, strings.NewReader(content))
+}
+
+// RenderReader renders Markdown io.Reader to HTML with all specific handling stuff and return string
+func RenderReader(ctx *markup.RenderContext, input io.Reader) (template.HTML, error) {
var buf strings.Builder
- if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
+ if err := Render(ctx, input, &buf); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
diff --git a/modules/public/mime_types.go b/modules/public/mime_types.go
index 32bdf3bfa2..87ee2854ae 100644
--- a/modules/public/mime_types.go
+++ b/modules/public/mime_types.go
@@ -23,6 +23,11 @@ var wellKnownMimeTypesLower = map[string]string{
".wasm": "application/wasm",
".webp": "image/webp",
".xml": "text/xml; charset=utf-8",
+ ".glb": "model/gltf-binary",
+ ".gltf": "model/gltf+json",
+ ".obj": "model/obj",
+ ".stl": "model/stl",
+ ".3mf": "model/3mf",
// well, there are some types missing from the builtin list
".txt": "text/plain; charset=utf-8",
diff --git a/modules/structs/action.go b/modules/structs/action.go
index 2c42365c19..f47b228d75 100644
--- a/modules/structs/action.go
+++ b/modules/structs/action.go
@@ -78,3 +78,9 @@ type ActionRun struct {
// the url of this action run
HTMLURL string `json:"html_url"`
}
+
+// ListActionRunResponse return a list of ActionRun
+type ListActionRunResponse struct {
+ Entries []*ActionRun `json:"workflow_runs"`
+ TotalCount int64 `json:"total_count"`
+}
diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go
index 505367336c..b13f344738 100644
--- a/modules/structs/repo_actions.go
+++ b/modules/structs/repo_actions.go
@@ -32,23 +32,3 @@ type ActionTaskResponse struct {
Entries []*ActionTask `json:"workflow_runs"`
TotalCount int64 `json:"total_count"`
}
-
-// ActionRun represents an ActionRun
-type RepoActionRun struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- RunNumber int64 `json:"run_number"`
- Event string `json:"event"`
- Status string `json:"status"`
- HeadBranch string `json:"head_branch"`
- HeadSHA string `json:"head_sha"`
- WorkflowID string `json:"workflow_id"`
- URL string `json:"url"`
- TriggeringActor *User `json:"triggering_actor"`
-}
-
-// ListActionRunResponse return a list of ActionRun
-type ListRepoActionRunResponse struct {
- Entries []*RepoActionRun `json:"workflow_runs"`
- TotalCount int64 `json:"total_count"`
-}
diff --git a/modules/typesniffer/typesniffer.go b/modules/typesniffer/typesniffer.go
index a8fc70e54c..262feb2b05 100644
--- a/modules/typesniffer/typesniffer.go
+++ b/modules/typesniffer/typesniffer.go
@@ -24,6 +24,16 @@ const (
AvifMimeType = "image/avif"
// ApplicationOctetStream MIME type of binary files.
ApplicationOctetStream = "application/octet-stream"
+ // GLTFMimeType MIME type of GLTF files.
+ GLTFMimeType = "model/gltf+json"
+ // GLBMimeType MIME type of GLB files.
+ GLBMimeType = "model/gltf-binary"
+ // OBJMimeType MIME type of OBJ files.
+ OBJMimeType = "model/obj"
+ // STLMimeType MIME type of STL files.
+ STLMimeType = "model/stl"
+ // 3MFMimeType MIME type of 3MF files.
+ ThreeMFMimeType = "model/3mf"
)
var (
@@ -67,6 +77,36 @@ func (ct SniffedType) IsAudio() bool {
return strings.Contains(ct.contentType, "audio/")
}
+// Is3DModel detects if data is a 3D format
+func (ct SniffedType) Is3DModel() bool {
+ return strings.Contains(ct.contentType, "model/")
+}
+
+// IsGLTFFile detects if data is an SVG image format
+func (ct SniffedType) IsGLTF() bool {
+ return strings.Contains(ct.contentType, GLTFMimeType)
+}
+
+// IsGLBFile detects if data is an GLB image format
+func (ct SniffedType) IsGLB() bool {
+ return strings.Contains(ct.contentType, GLBMimeType)
+}
+
+// IsOBJFile detects if data is an OBJ image format
+func (ct SniffedType) IsOBJ() bool {
+ return strings.Contains(ct.contentType, OBJMimeType)
+}
+
+// IsSTLTextFile detects if data is an STL text format
+func (ct SniffedType) IsSTL() bool {
+ return strings.Contains(ct.contentType, STLMimeType)
+}
+
+// Is3MFFile detects if data is an 3MF image format
+func (ct SniffedType) Is3MF() bool {
+ return strings.Contains(ct.contentType, ThreeMFMimeType)
+}
+
// IsRepresentableAsText returns true if file content can be represented as
// plain text or is empty.
func (ct SniffedType) IsRepresentableAsText() bool {
@@ -75,7 +115,7 @@ func (ct SniffedType) IsRepresentableAsText() bool {
// IsBrowsableBinaryType returns whether a non-text type can be displayed in a browser
func (ct SniffedType) IsBrowsableBinaryType() bool {
- return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio()
+ return ct.IsImage() || ct.IsSvgImage() || ct.IsPDF() || ct.IsVideo() || ct.IsAudio() || ct.Is3DModel()
}
// GetMimeType returns the mime type
@@ -135,6 +175,13 @@ func DetectContentType(data []byte) SniffedType {
ct = "audio/ogg" // for most cases, it is used as an audio container
}
}
+
+ // GLTF is unsupported by http.DetectContentType
+ // hexdump -n 4 -C glTF.glb
+ if bytes.HasPrefix(data, []byte("glTF")) {
+ ct = GLBMimeType
+ }
+
return SniffedType{ct}
}
diff --git a/modules/typesniffer/typesniffer_test.go b/modules/typesniffer/typesniffer_test.go
index 8d80b4ddb4..176d3658bb 100644
--- a/modules/typesniffer/typesniffer_test.go
+++ b/modules/typesniffer/typesniffer_test.go
@@ -117,6 +117,14 @@ func TestIsAudio(t *testing.T) {
assert.True(t, DetectContentType([]byte("ID3Toy\n====\t* hi 🌞, ..."+"🌛"[0:2])).IsText()) // test ID3 tag with incomplete UTF8 char
}
+func TestIsGLB(t *testing.T) {
+ glb, _ := hex.DecodeString("676c5446")
+ assert.True(t, DetectContentType(glb).IsGLB())
+ assert.True(t, DetectContentType(glb).Is3DModel())
+ assert.False(t, DetectContentType([]byte("plain text")).IsGLB())
+ assert.False(t, DetectContentType([]byte("plain text")).Is3DModel())
+}
+
func TestDetectContentTypeFromReader(t *testing.T) {
mp3, _ := base64.StdEncoding.DecodeString("SUQzBAAAAAABAFRYWFgAAAASAAADbWFqb3JfYnJhbmQAbXA0MgBUWFhYAAAAEQAAA21pbm9yX3Zl")
st, err := DetectContentTypeFromReader(bytes.NewReader(mp3))
@@ -145,3 +153,15 @@ func TestDetectContentTypeAvif(t *testing.T) {
assert.True(t, st.IsImage())
}
+
+func TestDetectContentTypeModelGLB(t *testing.T) {
+ glb, err := hex.DecodeString("676c5446")
+ require.NoError(t, err)
+
+ st, err := DetectContentTypeFromReader(bytes.NewReader(glb))
+ require.NoError(t, err)
+
+ // print st for debugging
+ assert.Equal(t, "model/gltf-binary", st.GetMimeType())
+ assert.True(t, st.IsGLB())
+}
diff --git a/modules/util/io.go b/modules/util/io.go
index 1559b019a0..4c99004c0c 100644
--- a/modules/util/io.go
+++ b/modules/util/io.go
@@ -4,7 +4,6 @@
package util
import (
- "bytes"
"errors"
"io"
)
@@ -20,42 +19,6 @@ func ReadAtMost(r io.Reader, buf []byte) (n int, err error) {
return n, err
}
-// ReadWithLimit reads at most "limit" bytes from r into buf.
-// If EOF or ErrUnexpectedEOF occurs while reading, err will be nil.
-func ReadWithLimit(r io.Reader, n int) (buf []byte, err error) {
- return readWithLimit(r, 1024, n)
-}
-
-func readWithLimit(r io.Reader, batch, limit int) ([]byte, error) {
- if limit <= batch {
- buf := make([]byte, limit)
- n, err := ReadAtMost(r, buf)
- if err != nil {
- return nil, err
- }
- return buf[:n], nil
- }
- res := bytes.NewBuffer(make([]byte, 0, batch))
- bufFix := make([]byte, batch)
- eof := false
- for res.Len() < limit && !eof {
- bufTmp := bufFix
- if res.Len()+batch > limit {
- bufTmp = bufFix[:limit-res.Len()]
- }
- n, err := io.ReadFull(r, bufTmp)
- if err == io.EOF || err == io.ErrUnexpectedEOF {
- eof = true
- } else if err != nil {
- return nil, err
- }
- if _, err = res.Write(bufTmp[:n]); err != nil {
- return nil, err
- }
- }
- return res.Bytes(), nil
-}
-
// ErrNotEmpty is an error reported when there is a non-empty reader
var ErrNotEmpty = errors.New("not-empty")
diff --git a/modules/util/io_test.go b/modules/util/io_test.go
deleted file mode 100644
index 870e713646..0000000000
--- a/modules/util/io_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright 2023 The Gitea Authors. All rights reserved.
-// SPDX-License-Identifier: MIT
-
-package util
-
-import (
- "bytes"
- "errors"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-type readerWithError struct {
- buf *bytes.Buffer
-}
-
-func (r *readerWithError) Read(p []byte) (n int, err error) {
- if r.buf.Len() < 2 {
- return 0, errors.New("test error")
- }
- return r.buf.Read(p)
-}
-
-func TestReadWithLimit(t *testing.T) {
- bs := []byte("0123456789abcdef")
-
- // normal test
- buf, err := readWithLimit(bytes.NewBuffer(bs), 5, 2)
- require.NoError(t, err)
- assert.Equal(t, []byte("01"), buf)
-
- buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 5)
- require.NoError(t, err)
- assert.Equal(t, []byte("01234"), buf)
-
- buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 6)
- require.NoError(t, err)
- assert.Equal(t, []byte("012345"), buf)
-
- buf, err = readWithLimit(bytes.NewBuffer(bs), 5, len(bs))
- require.NoError(t, err)
- assert.Equal(t, []byte("0123456789abcdef"), buf)
-
- buf, err = readWithLimit(bytes.NewBuffer(bs), 5, 100)
- require.NoError(t, err)
- assert.Equal(t, []byte("0123456789abcdef"), buf)
-
- // test with error
- buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 10)
- require.NoError(t, err)
- assert.Equal(t, []byte("0123456789"), buf)
-
- buf, err = readWithLimit(&readerWithError{bytes.NewBuffer(bs)}, 5, 100)
- require.ErrorContains(t, err, "test error")
- assert.Empty(t, buf)
-
- // test public function
- buf, err = ReadWithLimit(bytes.NewBuffer(bs), 2)
- require.NoError(t, err)
- assert.Equal(t, []byte("01"), buf)
-
- buf, err = ReadWithLimit(bytes.NewBuffer(bs), 9999999)
- require.NoError(t, err)
- assert.Equal(t, []byte("0123456789abcdef"), buf)
-}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 101591d5a9..0bb128f8b3 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -768,8 +768,8 @@ update_profile_success = Your profile has been updated.
change_username = Your username has been changed.
change_username_prompt = Note: Changing your username also changes your account URL.
change_username_redirect_prompt = The old username will redirect until someone claims it.
-change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old username during the cooldown period.
-change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old username during the cooldown period.
+change_username_redirect_prompt.with_cooldown.one = The old username will be available to everyone after a cooldown period of %[1]d day. You can still reclaim the old username during the cooldown period.
+change_username_redirect_prompt.with_cooldown.few = The old username will be available to everyone after a cooldown period of %[1]d days. You can still reclaim the old username during the cooldown period.
continue = Continue
cancel = Cancel
language = Language
@@ -1694,15 +1694,13 @@ issues.close_comment_issue = Close with comment
issues.reopen_issue = Reopen
issues.reopen_comment_issue = Reopen with comment
issues.create_comment = Comment
-issues.closed_at = `closed this issue %[2]s`
-issues.reopened_at = `reopened this issue %[2]s`
-issues.commit_ref_at = `referenced this issue from a commit %[2]s`
-issues.ref_issue_from = `referenced this issue %[4]s %[2]s`
-issues.ref_pull_from = `referenced this pull request %[4]s %[2]s`
-issues.ref_closing_from = `referenced this issue from a pull request %[4]s that will close it, %[2]s`
-issues.ref_reopening_from = `referenced this issue from a pull request %[4]s that will reopen it, %[2]s`
-issues.ref_closed_from = `closed this issue %[4]s %[2]s`
-issues.ref_reopened_from = `reopened this issue %[4]s %[2]s`
+issues.closed_at = `closed this issue %s`
+issues.reopened_at = `reopened this issue %s`
+issues.commit_ref_at = `referenced this issue from a commit %s`
+issues.ref_issue_from = `referenced this issue %[3]s %[1]s`
+issues.ref_pull_from = `referenced this pull request %[3]s %[1]s`
+issues.ref_closing_from = `referenced this issue from a pull request %[3]s that will close it, %[1]s`
+issues.ref_reopening_from = `referenced this issue from a pull request %[3]s that will reopen it, %[1]s`
issues.ref_from = `from %[1]s`
issues.author = Author
issues.author.tooltip.issue = This user is the author of this issue.
@@ -2014,9 +2012,9 @@ pulls.update_branch_success = Branch update was successful
pulls.update_not_allowed = You are not allowed to update branch
pulls.outdated_with_base_branch = This branch is out-of-date with the base branch
pulls.close = Close pull request
-pulls.closed_at = `closed this pull request %[2]s`
-pulls.reopened_at = `reopened this pull request %[2]s`
-pulls.commit_ref_at = `referenced this pull request from a commit %[2]s`
+pulls.closed_at = `closed this pull request %s`
+pulls.reopened_at = `reopened this pull request %s`
+pulls.commit_ref_at = `referenced this pull request from a commit %s`
pulls.cmd_instruction_hint = View command line instructions
pulls.cmd_instruction_checkout_title = Checkout
pulls.cmd_instruction_checkout_desc = From your project repository, check out a new branch and test the changes.
@@ -2933,8 +2931,8 @@ settings.update_settings = Update settings
settings.update_setting_success = Organization settings have been updated.
settings.change_orgname_prompt = Note: Changing the organization name will also change your organization's URL and free the old name.
settings.change_orgname_redirect_prompt = The old name will redirect until it is claimed.
-settings.change_orgname_redirect_prompt.with_cooldown.one = The old organization name will be available to everyone after a cooldown period of %[1]d day, you can still reclaim the old name during the cooldown period.
-settings.change_orgname_redirect_prompt.with_cooldown.few = The old organization name will be available to everyone after a cooldown period of %[1]d days, you can still reclaim the old name during the cooldown period.
+settings.change_orgname_redirect_prompt.with_cooldown.one = The old organization name will be available to everyone after a cooldown period of %[1]d day. You can still reclaim the old name during the cooldown period.
+settings.change_orgname_redirect_prompt.with_cooldown.few = The old organization name will be available to everyone after a cooldown period of %[1]d days. You can still reclaim the old name during the cooldown period.
settings.update_avatar_success = The organization's avatar has been updated.
settings.delete = Delete organization
settings.delete_account = Delete this organization
diff --git a/package-lock.json b/package-lock.json
index 179c0e496c..9de06a8055 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0",
+ "@google/model-viewer": "4.1.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0",
"ansi_up": "6.0.5",
@@ -62,7 +63,7 @@
"devDependencies": {
"@axe-core/playwright": "4.10.2",
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
- "@playwright/test": "1.53.0",
+ "@playwright/test": "1.52.0",
"@stoplight/spectral-cli": "6.15.0",
"@stylistic/eslint-plugin": "4.4.1",
"@stylistic/stylelint-plugin": "3.1.2",
@@ -1222,6 +1223,22 @@
"dom-input-range": "^1.2.0"
}
},
+ "node_modules/@google/model-viewer": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.1.0.tgz",
+ "integrity": "sha512-7WB/jS6wfBfRl/tWhsUUvDMKFE1KlKME97coDLlZQfvJD0nCwjhES1lJ+k7wnmf7T3zMvCfn9mIjM/mgZapuig==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@monogrid/gainmap-js": "^3.1.0",
+ "lit": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ },
+ "peerDependencies": {
+ "three": "^0.172.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2004,6 +2021,21 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
+ "node_modules/@lit-labs/ssr-dom-shim": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
+ "integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@lit/reactive-element": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz",
+ "integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.2.0"
+ }
+ },
"node_modules/@mcaptcha/core-glue": {
"version": "0.1.0-alpha-5",
"resolved": "https://registry.npmjs.org/@mcaptcha/core-glue/-/core-glue-0.1.0-alpha-5.tgz",
@@ -2064,6 +2096,18 @@
"langium": "3.3.1"
}
},
+ "node_modules/@monogrid/gainmap-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
+ "integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==",
+ "license": "MIT",
+ "dependencies": {
+ "promise-worker-transferable": "^1.0.4"
+ },
+ "peerDependencies": {
+ "three": ">= 0.159.0"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz",
@@ -2143,13 +2187,13 @@
}
},
"node_modules/@playwright/test": {
- "version": "1.53.0",
- "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
- "integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
+ "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright": "1.53.0"
+ "playwright": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@@ -3493,8 +3537,7 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
@@ -8768,6 +8811,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/immer": {
"version": "9.0.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
@@ -9307,6 +9356,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/is-promise": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
+ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
+ "license": "MIT"
+ },
"node_modules/is-proto-prop": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-proto-prop/-/is-proto-prop-3.0.1.tgz",
@@ -10023,6 +10078,15 @@
"npm": ">=8"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -10051,6 +10115,37 @@
"uc.micro": "^2.0.0"
}
},
+ "node_modules/lit": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.0.tgz",
+ "integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit/reactive-element": "^2.1.0",
+ "lit-element": "^4.2.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-element": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz",
+ "integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@lit-labs/ssr-dom-shim": "^1.2.0",
+ "@lit/reactive-element": "^2.1.0",
+ "lit-html": "^3.3.0"
+ }
+ },
+ "node_modules/lit-html": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz",
+ "integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2"
+ }
+ },
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@@ -11859,13 +11954,13 @@
}
},
"node_modules/playwright": {
- "version": "1.53.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
- "integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
+ "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.53.0"
+ "playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
@@ -11878,9 +11973,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.53.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
- "integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
+ "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -12363,6 +12458,16 @@
"dev": true,
"license": "Unlicense"
},
+ "node_modules/promise-worker-transferable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
+ "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "is-promise": "^2.1.0",
+ "lie": "^3.0.2"
+ }
+ },
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -14425,6 +14530,13 @@
"node": ">=0.8"
}
},
+ "node_modules/three": {
+ "version": "0.172.0",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz",
+ "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/throttle-debounce": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.0.tgz",
diff --git a/package.json b/package.json
index 409edecd95..f7df1b3f38 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"@github/markdown-toolbar-element": "2.2.3",
"@github/quote-selection": "2.1.0",
"@github/text-expander-element": "2.8.0",
+ "@google/model-viewer": "4.1.0",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "19.14.0",
"ansi_up": "6.0.5",
@@ -61,7 +62,7 @@
"devDependencies": {
"@axe-core/playwright": "4.10.2",
"@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
- "@playwright/test": "1.53.0",
+ "@playwright/test": "1.52.0",
"@stoplight/spectral-cli": "6.15.0",
"@stylistic/eslint-plugin": "4.4.1",
"@stylistic/stylelint-plugin": "3.1.2",
@@ -78,8 +79,8 @@
"eslint-plugin-playwright": "2.2.0",
"eslint-plugin-regexp": "2.9.0",
"eslint-plugin-sonarjs": "3.0.2",
- "eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-toml": "0.12.0",
+ "eslint-plugin-unicorn": "59.0.1",
"eslint-plugin-vitest-globals": "1.5.0",
"eslint-plugin-vue": "10.2.0",
"eslint-plugin-vue-scoped-css": "2.10.0",
diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go
index 03089a18d3..dbc4933de6 100644
--- a/routers/api/v1/repo/action.go
+++ b/routers/api/v1/repo/action.go
@@ -748,7 +748,7 @@ func ListActionRuns(ctx *context.APIContext) {
// type: string
// responses:
// "200":
- // "$ref": "#/responses/RepoActionRunList"
+ // "$ref": "#/responses/ActionRunList"
// "400":
// "$ref": "#/responses/error"
// "403":
@@ -779,16 +779,16 @@ func ListActionRuns(ctx *context.APIContext) {
return
}
- res := new(api.ListRepoActionRunResponse)
+ res := new(api.ListActionRunResponse)
res.TotalCount = total
- res.Entries = make([]*api.RepoActionRun, len(runs))
+ res.Entries = make([]*api.ActionRun, len(runs))
for i, r := range runs {
- cr, err := convert.ToRepoActionRun(ctx, r)
- if err != nil {
- ctx.Error(http.StatusInternalServerError, "ToActionRun", err)
+ if err := r.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
return
}
+ cr := convert.ToActionRun(ctx, r, ctx.Doer)
res.Entries[i] = cr
}
@@ -821,7 +821,7 @@ func GetActionRun(ctx *context.APIContext) {
// required: true
// responses:
// "200":
- // "$ref": "#/responses/RepoActionRun"
+ // "$ref": "#/responses/ActionRun"
// "400":
// "$ref": "#/responses/error"
// "403":
@@ -839,16 +839,17 @@ func GetActionRun(ctx *context.APIContext) {
return
}
+ // Action runs lives in its own table, therefore we check that the
+ // run with the requested ID is owned by the repository
if ctx.Repo.Repository.ID != run.RepoID {
ctx.Error(http.StatusNotFound, "GetRunById", util.ErrNotExist)
return
}
- res, err := convert.ToRepoActionRun(ctx, run)
- if err != nil {
- ctx.Error(http.StatusInternalServerError, "ToRepoActionRun", err)
+ if err := run.LoadAttributes(ctx); err != nil {
+ ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
return
}
- ctx.JSON(http.StatusOK, res)
+ ctx.JSON(http.StatusOK, convert.ToActionRun(ctx, run, ctx.Doer))
}
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index bde0efea4e..cd4832e15f 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -463,16 +463,16 @@ type swaggerSyncForkInfo struct {
Body []api.SyncForkInfo `json:"body"`
}
-// RepoActionRunList
-// swagger:response RepoActionRunList
-type swaggerRepoActionRunList struct {
+// ActionRunList
+// swagger:response ActionRunList
+type swaggerActionRunList struct {
// in:body
- Body api.ListRepoActionRunResponse `json:"body"`
+ Body api.ListActionRunResponse `json:"body"`
}
-// RepoActionRun
-// swagger:response RepoActionRun
-type swaggerRepoActionRun struct {
+// ActionRun
+// swagger:response ActionRun
+type swaggerActionRun struct {
// in:body
- Body api.RepoActionRun `json:"body"`
+ Body api.ActionRun `json:"body"`
}
diff --git a/routers/web/org/home.go b/routers/web/org/home.go
index a3823565ed..8f14f8899c 100644
--- a/routers/web/org/home.go
+++ b/routers/web/org/home.go
@@ -175,10 +175,12 @@ func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repositor
return
}
- if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
- log.Error("failed to GetBlobContent: %v", err)
+ if rc, _, err := profileReadme.NewTruncatedReader(setting.UI.MaxDisplayFileSize); err != nil {
+ log.Error("failed to NewTruncatedReader: %v", err)
} else {
- if profileContent, err := markdown.RenderString(&markup.RenderContext{
+ defer rc.Close()
+
+ if profileContent, err := markdown.RenderReader(&markup.RenderContext{
Ctx: ctx,
GitRepo: profileGitRepo,
Links: markup.Links{
@@ -188,7 +190,7 @@ func prepareOrgProfileReadme(ctx *context.Context, profileGitRepo *git.Repositor
BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
},
Metas: map[string]string{"mode": "document"},
- }, bytes); err != nil {
+ }, rc); err != nil {
log.Error("failed to RenderString: %v", err)
} else {
ctx.Data["ProfileReadme"] = profileContent
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index d8f3bd8d9f..3f17e30cec 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1308,7 +1308,7 @@ func roleDescriptor(ctx stdCtx.Context, repo *repo_model.Repository, poster *use
}
// Special user that can't have associated contributions and permissions in the repo.
- if poster.IsGhost() || poster.IsActions() || poster.IsAPServerActor() {
+ if poster.IsSystem() || poster.IsAPServerActor() {
return roleDescriptor, nil
}
diff --git a/routers/web/repo/setting/lfs.go b/routers/web/repo/setting/lfs.go
index 2e9c34e8a7..b9cb86bd08 100644
--- a/routers/web/repo/setting/lfs.go
+++ b/routers/web/repo/setting/lfs.go
@@ -342,6 +342,20 @@ func LFSFileGet(ctx *context.Context) {
ctx.Data["IsVideoFile"] = true
case st.IsAudio():
ctx.Data["IsAudioFile"] = true
+ case st.Is3DModel():
+ ctx.Data["Is3DModelFile"] = true
+ switch {
+ case st.IsGLB():
+ ctx.Data["IsGLBFile"] = true
+ case st.IsSTL():
+ ctx.Data["IsSTLFile"] = true
+ case st.IsGLTF():
+ ctx.Data["IsGLTFFile"] = true
+ case st.IsOBJ():
+ ctx.Data["IsOBJFile"] = true
+ case st.Is3MF():
+ ctx.Data["Is3MFFile"] = true
+ }
case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
ctx.Data["IsImageFile"] = true
}
diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
index b65d1fbc92..6f35e19880 100644
--- a/routers/web/repo/setting/setting.go
+++ b/routers/web/repo/setting/setting.go
@@ -153,11 +153,9 @@ func UnitsPost(ctx *context.Context) {
})
deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki)
} else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() {
- var wikiPermissions repo_model.UnitAccessMode
+ wikiPermissions := repo_model.UnitAccessModeUnset
if form.GloballyWriteableWiki {
wikiPermissions = repo_model.UnitAccessModeWrite
- } else {
- wikiPermissions = repo_model.UnitAccessModeRead
}
units = append(units, repo_model.RepoUnit{
RepoID: repo.ID,
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index c7cc715fc1..bb3e1388a8 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -439,8 +439,8 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error())
}
} else if slices.Contains([]string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}, ctx.Repo.TreePath) {
- if data, err := blob.GetBlobContent(setting.UI.MaxDisplayFileSize); err == nil {
- _, warnings := issue_model.GetCodeOwnersFromContent(ctx, data)
+ if rc, size, err := blob.NewTruncatedReader(setting.UI.MaxDisplayFileSize); err == nil {
+ _, warnings := issue_model.GetCodeOwnersFromReader(ctx, rc, size > setting.UI.MaxDisplayFileSize)
if len(warnings) > 0 {
ctx.Data["FileWarning"] = strings.Join(warnings, "\n")
}
@@ -624,6 +624,20 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["IsVideoFile"] = true
case fInfo.st.IsAudio():
ctx.Data["IsAudioFile"] = true
+ case fInfo.st.Is3DModel():
+ ctx.Data["Is3DModelFile"] = true
+ switch {
+ case fInfo.st.IsGLB():
+ ctx.Data["IsGLBFile"] = true
+ case fInfo.st.IsSTL():
+ ctx.Data["IsSTLFile"] = true
+ case fInfo.st.IsGLTF():
+ ctx.Data["IsGLTFFile"] = true
+ case fInfo.st.IsOBJ():
+ ctx.Data["IsOBJFile"] = true
+ case fInfo.st.Is3MF():
+ ctx.Data["Is3MFFile"] = true
+ }
case fInfo.st.IsImage() && (setting.UI.SVG.Enabled || !fInfo.st.IsSvgImage()):
ctx.Data["IsImageFile"] = true
ctx.Data["CanCopyContent"] = true
diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go
index 58f6d4a5f2..78dd6c5e7c 100644
--- a/routers/web/user/profile.go
+++ b/routers/web/user/profile.go
@@ -264,10 +264,12 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
total = int(count)
case "overview":
- if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
- log.Error("failed to GetBlobContent: %v", err)
+ if rc, _, err := profileReadme.NewTruncatedReader(setting.UI.MaxDisplayFileSize); err != nil {
+ log.Error("failed to NewTruncatedReader: %v", err)
} else {
- if profileContent, err := markdown.RenderString(&markup.RenderContext{
+ defer rc.Close()
+
+ if profileContent, err := markdown.RenderReader(&markup.RenderContext{
Ctx: ctx,
GitRepo: profileGitRepo,
Links: markup.Links{
@@ -280,7 +282,7 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
BranchPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
},
Metas: map[string]string{"mode": "document"},
- }, bytes); err != nil {
+ }, rc); err != nil {
log.Error("failed to RenderString: %v", err)
} else {
ctx.Data["ProfileReadme"] = profileContent
diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go
index 0ddb1b21f5..400ee71f08 100644
--- a/routers/web/user/setting/profile.go
+++ b/routers/web/user/setting/profile.go
@@ -66,6 +66,9 @@ func ProfilePost(ctx *context.Context) {
ctx.Data["DisableGravatar"] = setting.Config().Picture.DisableGravatar.Value(ctx)
ctx.Data["CooldownPeriod"] = setting.Service.UsernameCooldownPeriod
ctx.Data["CommonPronouns"] = commonPronouns
+ ctx.Data["MaxAvatarFileSize"] = setting.Avatar.MaxFileSize
+ ctx.Data["MaxAvatarWidth"] = setting.Avatar.MaxWidth
+ ctx.Data["MaxAvatarHeight"] = setting.Avatar.MaxHeight
if ctx.HasError() {
ctx.HTML(http.StatusOK, tplSettingsProfile)
diff --git a/services/actions/notifier_helper.go b/services/actions/notifier_helper.go
index 9654186fbb..e240c996b5 100644
--- a/services/actions/notifier_helper.go
+++ b/services/actions/notifier_helper.go
@@ -345,6 +345,14 @@ func handleWorkflows(
Status: actions_model.StatusWaiting,
}
+ if workflow, err := model.ReadWorkflow(bytes.NewReader(dwf.Content)); err == nil {
+ notifications, err := workflow.Notifications()
+ if err != nil {
+ log.Error("Notifications: %w", err)
+ }
+ run.NotifyEmail = notifications
+ }
+
need, err := ifNeedApproval(ctx, run, input.Repo, input.Doer)
if err != nil {
log.Error("check if need approval for repo %d with user %d: %v", input.Repo.ID, input.Doer.ID, err)
diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go
index 3ec0807d5f..cf8b29ead7 100644
--- a/services/actions/schedule_tasks.go
+++ b/services/actions/schedule_tasks.go
@@ -4,6 +4,7 @@
package actions
import (
+ "bytes"
"context"
"errors"
"fmt"
@@ -18,6 +19,7 @@ import (
webhook_module "forgejo.org/modules/webhook"
"github.com/nektos/act/pkg/jobparser"
+ act_model "github.com/nektos/act/pkg/model"
"xorm.io/builder"
)
@@ -140,6 +142,16 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule)
return err
}
+ workflow, err := act_model.ReadWorkflow(bytes.NewReader(cron.Content))
+ if err != nil {
+ return err
+ }
+ notifications, err := workflow.Notifications()
+ if err != nil {
+ return err
+ }
+ run.NotifyEmail = notifications
+
// Parse the workflow specification from the cron schedule
workflows, err := jobparser.Parse(cron.Content, jobparser.WithVars(vars))
if err != nil {
diff --git a/services/actions/schedule_tasks_test.go b/services/actions/schedule_tasks_test.go
new file mode 100644
index 0000000000..7073985252
--- /dev/null
+++ b/services/actions/schedule_tasks_test.go
@@ -0,0 +1,121 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package actions
+
+import (
+ "testing"
+
+ actions_model "forgejo.org/models/actions"
+ repo_model "forgejo.org/models/repo"
+ "forgejo.org/models/unittest"
+ webhook_module "forgejo.org/modules/webhook"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateScheduleTask(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2})
+
+ assertConstant := func(t *testing.T, cron *actions_model.ActionSchedule, run *actions_model.ActionRun) {
+ t.Helper()
+ assert.Equal(t, cron.Title, run.Title)
+ assert.Equal(t, cron.RepoID, run.RepoID)
+ assert.Equal(t, cron.OwnerID, run.OwnerID)
+ assert.Equal(t, cron.WorkflowID, run.WorkflowID)
+ assert.Equal(t, cron.TriggerUserID, run.TriggerUserID)
+ assert.Equal(t, cron.Ref, run.Ref)
+ assert.Equal(t, cron.CommitSHA, run.CommitSHA)
+ assert.Equal(t, cron.Event, run.Event)
+ assert.Equal(t, cron.EventPayload, run.EventPayload)
+ assert.Equal(t, cron.ID, run.ScheduleID)
+ assert.Equal(t, actions_model.StatusWaiting, run.Status)
+ }
+
+ assertMutable := func(t *testing.T, expected, run *actions_model.ActionRun) {
+ t.Helper()
+ assert.Equal(t, expected.NotifyEmail, run.NotifyEmail)
+ }
+
+ testCases := []struct {
+ name string
+ cron actions_model.ActionSchedule
+ want []actions_model.ActionRun
+ }{
+ {
+ name: "simple",
+ cron: actions_model.ActionSchedule{
+ Title: "scheduletitle1",
+ RepoID: repo.ID,
+ OwnerID: repo.OwnerID,
+ WorkflowID: "some.yml",
+ TriggerUserID: repo.OwnerID,
+ Ref: "branch",
+ CommitSHA: "fakeSHA",
+ Event: webhook_module.HookEventSchedule,
+ EventPayload: "fakepayload",
+ Content: []byte(
+ `
+name: test
+on: push
+jobs:
+ job2:
+ runs-on: ubuntu-latest
+ steps:
+ - run: true
+`),
+ },
+ want: []actions_model.ActionRun{
+ {
+ Title: "scheduletitle1",
+ NotifyEmail: false,
+ },
+ },
+ },
+ {
+ name: "enable-email-notifications is true",
+ cron: actions_model.ActionSchedule{
+ Title: "scheduletitle2",
+ RepoID: repo.ID,
+ OwnerID: repo.OwnerID,
+ WorkflowID: "some.yml",
+ TriggerUserID: repo.OwnerID,
+ Ref: "branch",
+ CommitSHA: "fakeSHA",
+ Event: webhook_module.HookEventSchedule,
+ EventPayload: "fakepayload",
+ Content: []byte(
+ `
+name: test
+enable-email-notifications: true
+on: push
+jobs:
+ job2:
+ runs-on: ubuntu-latest
+ steps:
+ - run: true
+`),
+ },
+ want: []actions_model.ActionRun{
+ {
+ Title: "scheduletitle2",
+ NotifyEmail: true,
+ },
+ },
+ },
+ }
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ require.NoError(t, CreateScheduleTask(t.Context(), &testCase.cron))
+ require.Equal(t, len(testCase.want), unittest.GetCount(t, actions_model.ActionRun{RepoID: repo.ID}))
+ for _, expected := range testCase.want {
+ run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{Title: expected.Title})
+ assertConstant(t, &testCase.cron, run)
+ assertMutable(t, &expected, run)
+ }
+ unittest.AssertSuccessfulDelete(t, actions_model.ActionRun{RepoID: repo.ID})
+ })
+ }
+}
diff --git a/services/actions/workflows.go b/services/actions/workflows.go
index 7ec7c3abed..fbba3fd667 100644
--- a/services/actions/workflows.go
+++ b/services/actions/workflows.go
@@ -111,6 +111,11 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
return nil, nil, err
}
+ notifications, err := wf.Notifications()
+ if err != nil {
+ return nil, nil, err
+ }
+
run := &actions_model.ActionRun{
Title: title,
RepoID: repo.ID,
@@ -125,6 +130,7 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
EventPayload: string(p),
TriggerEvent: string(webhook.HookEventWorkflowDispatch),
Status: actions_model.StatusWaiting,
+ NotifyEmail: notifications,
}
vars, err := actions_model.GetVariablesOfRun(ctx, run)
diff --git a/services/convert/action.go b/services/convert/action.go
index 5e17172b45..703c1f1261 100644
--- a/services/convert/action.go
+++ b/services/convert/action.go
@@ -8,22 +8,17 @@ import (
actions_model "forgejo.org/models/actions"
access_model "forgejo.org/models/perm/access"
+ user_model "forgejo.org/models/user"
api "forgejo.org/modules/structs"
)
// ToActionRun convert actions_model.User to api.ActionRun
// the run needs all attributes loaded
-func ToActionRun(ctx context.Context, run *actions_model.ActionRun) *api.ActionRun {
+func ToActionRun(ctx context.Context, run *actions_model.ActionRun, doer *user_model.User) *api.ActionRun {
if run == nil {
return nil
}
- // The doer is the one whose perspective is used to view this ActionRun.
- // In the best case we use the user that created the webhook.
- // Unfortunately we don't know who that was.
- // So instead we use the repo owner, who is able to create webhooks and allow others to do so by making them repo admins.
- // This is pretty close to perfect.
- doer := run.Repo.Owner
permissionInRepo, _ := access_model.GetUserRepoPermission(ctx, run.Repo, doer)
return &api.ActionRun{
diff --git a/services/convert/convert.go b/services/convert/convert.go
index 48da9d7623..2ea24a1b51 100644
--- a/services/convert/convert.go
+++ b/services/convert/convert.go
@@ -222,29 +222,6 @@ func ToActionTask(ctx context.Context, t *actions_model.ActionTask) (*api.Action
}, nil
}
-// ToRepoActionRun convert a actions_model.ActionRun to an api.RepoActionRun
-func ToRepoActionRun(ctx context.Context, r *actions_model.ActionRun) (*api.RepoActionRun, error) {
- if err := r.LoadAttributes(ctx); err != nil {
- return nil, err
- }
-
- url := strings.TrimSuffix(setting.AppURL, "/") + r.Link()
- actor := ToUser(ctx, r.TriggerUser, nil)
-
- return &api.RepoActionRun{
- ID: r.ID,
- Name: r.Title,
- HeadBranch: r.PrettyRef(),
- HeadSHA: r.CommitSHA,
- RunNumber: r.Index,
- Event: r.TriggerEvent,
- Status: r.Status.String(),
- WorkflowID: r.WorkflowID,
- URL: url,
- TriggeringActor: actor,
- }, nil
-}
-
// ToVerification convert a git.Commit.Signature to an api.PayloadCommitVerification
func ToVerification(ctx context.Context, c *git.Commit) *api.PayloadCommitVerification {
verif := asymkey_model.ParseCommitWithSignature(ctx, c)
diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go
index 174c175f86..a3b719d1a7 100644
--- a/services/federation/federation_service.go
+++ b/services/federation/federation_service.go
@@ -211,6 +211,11 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
return nil, nil, err
}
+ inbox, err := url.ParseRequestURI(person.Inbox.GetLink().String())
+ if err != nil {
+ return nil, nil, err
+ }
+
newUser := user.User{
LowerName: strings.ToLower(name),
Name: name,
@@ -227,6 +232,7 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI
federatedUser := user.FederatedUser{
ExternalID: personID.ID,
FederationHostID: federationHostID,
+ InboxPath: inbox.Path,
NormalizedOriginalURL: personID.AsURI(),
}
diff --git a/services/feed/action.go b/services/feed/action.go
index a2cd0551a3..7d179bd1c8 100644
--- a/services/feed/action.go
+++ b/services/feed/action.go
@@ -39,6 +39,24 @@ func NewNotifier() notify_service.Notifier {
return &actionNotifier{}
}
+func notifyAll(ctx context.Context, action *activities_model.Action) error {
+ _, err := activities_model.NotifyWatchers(ctx, action)
+ if err != nil {
+ return err
+ }
+ return err
+ // return federation_service.NotifyActivityPubFollowers(ctx, out)
+}
+
+func notifyAllActions(ctx context.Context, acts []*activities_model.Action) error {
+ _, err := activities_model.NotifyWatchersActions(ctx, acts)
+ if err != nil {
+ return err
+ }
+ return nil
+ // return federation_service.NotifyActivityPubFollowers(ctx, out)
+}
+
func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
if err := issue.LoadPoster(ctx); err != nil {
log.Error("issue.LoadPoster: %v", err)
@@ -50,7 +68,7 @@ func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue
}
repo := issue.Repo
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: issue.Poster.ID,
ActUser: issue.Poster,
OpType: activities_model.ActionCreateIssue,
@@ -91,7 +109,7 @@ func (a *actionNotifier) IssueChangeStatus(ctx context.Context, doer *user_model
}
// Notify watchers for whatever action comes in, ignore if no action type.
- if err := activities_model.NotifyWatchers(ctx, act); err != nil {
+ if err := notifyAll(ctx, act); err != nil {
log.Error("NotifyWatchers: %v", err)
}
}
@@ -127,7 +145,7 @@ func (a *actionNotifier) CreateIssueComment(ctx context.Context, doer *user_mode
}
// Notify watchers for whatever action comes in, ignore if no action type.
- if err := activities_model.NotifyWatchers(ctx, act); err != nil {
+ if err := notifyAll(ctx, act); err != nil {
log.Error("NotifyWatchers: %v", err)
}
}
@@ -146,7 +164,7 @@ func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.
return
}
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: pull.Issue.Poster.ID,
ActUser: pull.Issue.Poster,
OpType: activities_model.ActionCreatePullRequest,
@@ -160,7 +178,7 @@ func (a *actionNotifier) NewPullRequest(ctx context.Context, pull *issues_model.
}
func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldRepoName string) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionRenameRepo,
@@ -174,7 +192,7 @@ func (a *actionNotifier) RenameRepository(ctx context.Context, doer *user_model.
}
func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, oldOwnerName string) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionTransferRepo,
@@ -188,7 +206,7 @@ func (a *actionNotifier) TransferRepository(ctx context.Context, doer *user_mode
}
func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionCreateRepo,
@@ -201,7 +219,7 @@ func (a *actionNotifier) CreateRepository(ctx context.Context, doer, u *user_mod
}
func (a *actionNotifier) ForkRepository(ctx context.Context, doer *user_model.User, oldRepo, repo *repo_model.Repository) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionCreateRepo,
@@ -266,13 +284,13 @@ func (a *actionNotifier) PullRequestReview(ctx context.Context, pr *issues_model
actions = append(actions, action)
}
- if err := activities_model.NotifyWatchersActions(ctx, actions); err != nil {
+ if err := notifyAllActions(ctx, actions); err != nil {
log.Error("notify watchers '%d/%d': %v", review.Reviewer.ID, review.Issue.RepoID, err)
}
}
func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionMergePullRequest,
@@ -286,7 +304,7 @@ func (*actionNotifier) MergePullRequest(ctx context.Context, doer *user_model.Us
}
func (*actionNotifier) AutoMergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionAutoMergePullRequest,
@@ -304,7 +322,7 @@ func (*actionNotifier) NotifyPullRevieweDismiss(ctx context.Context, doer *user_
if len(review.OriginalAuthor) > 0 {
reviewerName = review.OriginalAuthor
}
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: activities_model.ActionPullReviewDismissed,
@@ -342,7 +360,7 @@ func (a *actionNotifier) PushCommits(ctx context.Context, pusher *user_model.Use
opType = activities_model.ActionDeleteBranch
}
- if err = activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err = notifyAll(ctx, &activities_model.Action{
ActUserID: pusher.ID,
ActUser: pusher,
OpType: opType,
@@ -362,7 +380,7 @@ func (a *actionNotifier) CreateRef(ctx context.Context, doer *user_model.User, r
// has sent same action in `PushCommits`, so skip it.
return
}
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: opType,
@@ -381,7 +399,7 @@ func (a *actionNotifier) DeleteRef(ctx context.Context, doer *user_model.User, r
// has sent same action in `PushCommits`, so skip it.
return
}
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: doer.ID,
ActUser: doer,
OpType: opType,
@@ -405,7 +423,7 @@ func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model
return
}
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: repo.OwnerID,
ActUser: repo.MustOwner(ctx),
OpType: activities_model.ActionMirrorSyncPush,
@@ -420,7 +438,7 @@ func (a *actionNotifier) SyncPushCommits(ctx context.Context, pusher *user_model
}
func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName, refID string) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: repo.OwnerID,
ActUser: repo.MustOwner(ctx),
OpType: activities_model.ActionMirrorSyncCreate,
@@ -434,7 +452,7 @@ func (a *actionNotifier) SyncCreateRef(ctx context.Context, doer *user_model.Use
}
func (a *actionNotifier) SyncDeleteRef(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, refFullName git.RefName) {
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: repo.OwnerID,
ActUser: repo.MustOwner(ctx),
OpType: activities_model.ActionMirrorSyncDelete,
@@ -452,7 +470,7 @@ func (a *actionNotifier) NewRelease(ctx context.Context, rel *repo_model.Release
log.Error("LoadAttributes: %v", err)
return
}
- if err := activities_model.NotifyWatchers(ctx, &activities_model.Action{
+ if err := notifyAll(ctx, &activities_model.Action{
ActUserID: rel.PublisherID,
ActUser: rel.Publisher,
OpType: activities_model.ActionPublishRelease,
diff --git a/services/issue/pull.go b/services/issue/pull.go
index b0a0c47d88..2eef1fbfa8 100644
--- a/services/issue/pull.go
+++ b/services/issue/pull.go
@@ -43,8 +43,6 @@ type ReviewRequestNotifier struct {
}
func PullRequestCodeOwnersReview(ctx context.Context, issue *issues_model.Issue, pr *issues_model.PullRequest) ([]*ReviewRequestNotifier, error) {
- files := []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
-
if pr.IsWorkInProgress(ctx) {
return nil, nil
}
@@ -72,18 +70,17 @@ func PullRequestCodeOwnersReview(ctx context.Context, issue *issues_model.Issue,
return nil, err
}
- var data string
- for _, file := range files {
+ var rules []*issues_model.CodeOwnerRule
+ for _, file := range []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"} {
if blob, err := commit.GetBlobByPath(file); err == nil {
- data, err = blob.GetBlobContent(setting.UI.MaxDisplayFileSize)
+ rc, size, err := blob.NewTruncatedReader(setting.UI.MaxDisplayFileSize)
if err == nil {
+ rules, _ = issues_model.GetCodeOwnersFromReader(ctx, rc, size > setting.UI.MaxDisplayFileSize)
break
}
}
}
- rules, _ := issues_model.GetCodeOwnersFromContent(ctx, data)
-
// get the mergebase
mergeBase, err := getMergeBase(repo, pr, git.BranchPrefix+pr.BaseBranch, pr.GetGitRefName())
if err != nil {
diff --git a/services/mailer/mail_actions.go b/services/mailer/mail_actions.go
index 7c63603a98..09763e164e 100644
--- a/services/mailer/mail_actions.go
+++ b/services/mailer/mail_actions.go
@@ -23,19 +23,24 @@ func MailActionRun(run *actions_model.ActionRun, priorStatus actions_model.Statu
return nil
}
- if run.TriggerUser.Email != "" && run.TriggerUser.EmailNotificationsPreference != user_model.EmailNotificationsDisabled {
- if err := sendMailActionRun(run.TriggerUser, run, priorStatus, lastRun); err != nil {
- return err
- }
+ if !run.NotifyEmail {
+ return nil
}
- if run.Repo.Owner.Email != "" && run.Repo.Owner.Email != run.TriggerUser.Email && run.Repo.Owner.EmailNotificationsPreference != user_model.EmailNotificationsDisabled {
- if err := sendMailActionRun(run.Repo.Owner, run, priorStatus, lastRun); err != nil {
- return err
- }
+ user := run.TriggerUser
+ // this happens e.g. when this is a scheduled run
+ if user.IsSystem() {
+ user = run.Repo.Owner
+ }
+ if user.IsSystem() || user.Email == "" {
+ return nil
}
- return nil
+ if user.EmailNotificationsPreference == user_model.EmailNotificationsDisabled {
+ return nil
+ }
+
+ return sendMailActionRun(user, run, priorStatus, lastRun)
}
func sendMailActionRun(to *user_model.User, run *actions_model.ActionRun, priorStatus actions_model.Status, lastRun *actions_model.ActionRun) error {
diff --git a/services/mailer/mail_actions_now_done_test.go b/services/mailer/mail_actions_now_done_test.go
index 0d832f2b36..6a01ea7631 100644
--- a/services/mailer/mail_actions_now_done_test.go
+++ b/services/mailer/mail_actions_now_done_test.go
@@ -4,42 +4,53 @@
package mailer
import (
+ "slices"
"testing"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/db"
+ organization_model "forgejo.org/models/organization"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
+ "forgejo.org/modules/optional"
"forgejo.org/modules/setting"
+ "forgejo.org/modules/test"
notify_service "forgejo.org/services/notify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func getActionsNowDoneTestUsers(t *testing.T) []*user_model.User {
+func getActionsNowDoneTestUser(t *testing.T, name, email, notifications string) *user_model.User {
t.Helper()
- newTriggerUser := new(user_model.User)
- newTriggerUser.Name = "new_trigger_user"
- newTriggerUser.Language = "en_US"
- newTriggerUser.IsAdmin = false
- newTriggerUser.Email = "new_trigger_user@example.com"
- newTriggerUser.LastLoginUnix = 1693648327
- newTriggerUser.CreatedUnix = 1693648027
- newTriggerUser.EmailNotificationsPreference = user_model.EmailNotificationsEnabled
- require.NoError(t, user_model.CreateUser(db.DefaultContext, newTriggerUser))
+ user := new(user_model.User)
+ user.Name = name
+ user.Language = "en_US"
+ user.IsAdmin = false
+ user.Email = email
+ user.LastLoginUnix = 1693648327
+ user.CreatedUnix = 1693648027
+ opts := user_model.CreateUserOverwriteOptions{
+ AllowCreateOrganization: optional.Some(true),
+ EmailNotificationsPreference: ¬ifications,
+ }
+ require.NoError(t, user_model.AdminCreateUser(db.DefaultContext, user, &opts))
+ return user
+}
- newOwner := new(user_model.User)
- newOwner.Name = "new_owner"
- newOwner.Language = "en_US"
- newOwner.IsAdmin = false
- newOwner.Email = "new_owner@example.com"
- newOwner.LastLoginUnix = 1693648329
- newOwner.CreatedUnix = 1693648029
- newOwner.EmailNotificationsPreference = user_model.EmailNotificationsEnabled
- require.NoError(t, user_model.CreateUser(db.DefaultContext, newOwner))
-
- return []*user_model.User{newTriggerUser, newOwner}
+func getActionsNowDoneTestOrg(t *testing.T, name, email string, owner *user_model.User) *user_model.User {
+ t.Helper()
+ org := new(organization_model.Organization)
+ org.Name = name
+ org.Language = "en_US"
+ org.IsAdmin = false
+ // contact email for the organization, for display purposes but otherwise not used as of v12
+ org.Email = email
+ org.LastLoginUnix = 1693648327
+ org.CreatedUnix = 1693648027
+ org.Email = email
+ require.NoError(t, organization_model.CreateOrganization(db.DefaultContext, org, owner))
+ return (*user_model.User)(org)
}
func assertTranslatedLocaleMailActionsNowDone(t *testing.T, msgBody string) {
@@ -49,98 +60,181 @@ func assertTranslatedLocaleMailActionsNowDone(t *testing.T, msgBody string) {
func TestActionRunNowDoneNotificationMail(t *testing.T) {
ctx := t.Context()
- users := getActionsNowDoneTestUsers(t)
- defer CleanUpUsers(ctx, users)
- triggerUser := users[0]
- ownerUser := users[1]
+ defer test.MockVariableValue(&setting.Admin.DisableRegularOrgCreation, false)()
+
+ actionsUser := user_model.NewActionsUser()
+ require.NotEmpty(t, actionsUser.Email)
repo := repo_model.Repository{
Name: "some repo",
Description: "rockets are cool",
- Owner: ownerUser,
- OwnerID: ownerUser.ID,
}
// Do some funky stuff with the action run's ids:
// The run with the larger ID finished first.
// This is odd but something that must work.
- run1 := &actions_model.ActionRun{ID: 2, Repo: &repo, RepoID: repo.ID, Title: "some workflow", TriggerUser: triggerUser, TriggerUserID: triggerUser.ID, Status: actions_model.StatusFailure, Stopped: 1745821796, TriggerEvent: "workflow_dispatch"}
- run2 := &actions_model.ActionRun{ID: 1, Repo: &repo, RepoID: repo.ID, Title: "some workflow", TriggerUser: triggerUser, TriggerUserID: triggerUser.ID, Status: actions_model.StatusSuccess, Stopped: 1745822796, TriggerEvent: "push"}
+ run1 := &actions_model.ActionRun{ID: 2, Repo: &repo, RepoID: repo.ID, Title: "some workflow", Status: actions_model.StatusFailure, Stopped: 1745821796, TriggerEvent: "workflow_dispatch"}
+ run2 := &actions_model.ActionRun{ID: 1, Repo: &repo, RepoID: repo.ID, Title: "some workflow", Status: actions_model.StatusSuccess, Stopped: 1745822796, TriggerEvent: "push"}
+
+ assignUsers := func(triggerUser, owner *user_model.User) {
+ for _, run := range []*actions_model.ActionRun{run1, run2} {
+ run.TriggerUser = triggerUser
+ run.TriggerUserID = triggerUser.ID
+ run.NotifyEmail = true
+ }
+ repo.Owner = owner
+ repo.OwnerID = owner.ID
+ }
notify_service.RegisterNotifier(NewNotifier())
+ orgOwner := getActionsNowDoneTestUser(t, "org_owner", "org_owner@example.com", "disabled")
+ defer CleanUpUsers(ctx, []*user_model.User{orgOwner})
+
t.Run("DontSendNotificationEmailOnFirstActionSuccess", func(t *testing.T) {
+ user := getActionsNowDoneTestUser(t, "new_user", "new_user@example.com", "enabled")
+ defer CleanUpUsers(ctx, []*user_model.User{user})
+ assignUsers(user, user)
defer MockMailSettings(func(msgs ...*Message) {
assert.Fail(t, "no mail should be sent")
})()
notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, nil)
})
- t.Run("SendNotificationEmailOnActionRunFailed", func(t *testing.T) {
- mailSentToOwner := false
- mailSentToTriggerUser := false
+ t.Run("WorkflowEnableEmailNotificationIsFalse", func(t *testing.T) {
+ user := getActionsNowDoneTestUser(t, "new_user1", "new_user1@example.com", "enabled")
+ defer CleanUpUsers(ctx, []*user_model.User{user})
+ assignUsers(user, user)
defer MockMailSettings(func(msgs ...*Message) {
- assert.LessOrEqual(t, len(msgs), 2)
- for _, msg := range msgs {
- switch msg.To {
- case triggerUser.EmailTo():
- assert.False(t, mailSentToTriggerUser, "sent mail twice")
- mailSentToTriggerUser = true
- case ownerUser.EmailTo():
- assert.False(t, mailSentToOwner, "sent mail twice")
- mailSentToOwner = true
- default:
- assert.Fail(t, "sent mail to unknown sender", msg.To)
- }
- assert.Contains(t, msg.Body, triggerUser.HTMLURL())
- assert.Contains(t, msg.Body, triggerUser.Name)
- // what happened
- assert.Contains(t, msg.Body, "failed")
- // new status of run
- assert.Contains(t, msg.Body, "failure")
- // prior status of this run
- assert.Contains(t, msg.Body, "waiting")
- assertTranslatedLocaleMailActionsNowDone(t, msg.Body)
- }
+ assert.Fail(t, "no mail should be sent")
})()
- notify_service.ActionRunNowDone(ctx, run1, actions_model.StatusWaiting, nil)
- assert.True(t, mailSentToOwner)
- assert.True(t, mailSentToTriggerUser)
+ run2.NotifyEmail = false
+ notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, nil)
})
- t.Run("SendNotificationEmailOnActionRunRecovered", func(t *testing.T) {
- mailSentToOwner := false
- mailSentToTriggerUser := false
- defer MockMailSettings(func(msgs ...*Message) {
- assert.LessOrEqual(t, len(msgs), 2)
- for _, msg := range msgs {
- switch msg.To {
- case triggerUser.EmailTo():
- assert.False(t, mailSentToTriggerUser, "sent mail twice")
- mailSentToTriggerUser = true
- case ownerUser.EmailTo():
- assert.False(t, mailSentToOwner, "sent mail twice")
- mailSentToOwner = true
- default:
- assert.Fail(t, "sent mail to unknown sender", msg.To)
- }
- assert.Contains(t, msg.Body, triggerUser.HTMLURL())
- assert.Contains(t, msg.Body, triggerUser.Name)
- // what happened
- assert.Contains(t, msg.Body, "recovered")
- // old status of run
- assert.Contains(t, msg.Body, "failure")
- // new status of run
- assert.Contains(t, msg.Body, "success")
- // prior status of this run
- assert.Contains(t, msg.Body, "running")
- assertTranslatedLocaleMailActionsNowDone(t, msg.Body)
- }
- })()
- assert.NotNil(t, setting.MailService)
+ for _, testCase := range []struct {
+ name string
+ triggerUser *user_model.User
+ owner *user_model.User
+ expected string
+ expectMail bool
+ }{
+ {
+ // if the action is assigned a trigger user in a repository
+ // owned by a regular user, the mail is sent to the trigger user
+ name: "RegularTriggerUser",
+ triggerUser: getActionsNowDoneTestUser(t, "new_trigger_user0", "new_trigger_user0@example.com", user_model.EmailNotificationsEnabled),
+ owner: getActionsNowDoneTestUser(t, "new_owner0", "new_owner0@example.com", user_model.EmailNotificationsEnabled),
+ expected: "trigger",
+ expectMail: true,
+ },
+ {
+ // if the action is assigned to a system user (e.g. ActionsUser)
+ // in a repository owned by a regular user, the mail is sent to
+ // the user that owns the repository
+ name: "SystemTriggerUserAndRegularOwner",
+ triggerUser: actionsUser,
+ owner: getActionsNowDoneTestUser(t, "new_owner1", "new_owner1@example.com", user_model.EmailNotificationsEnabled),
+ expected: "owner",
+ expectMail: true,
+ },
+ {
+ // if the action is assigned a trigger user with disabled notifications in a repository
+ // owned by a regular user, no mail is sent
+ name: "RegularTriggerUserNotificationsDisabled",
+ triggerUser: getActionsNowDoneTestUser(t, "new_trigger_user2", "new_trigger_user2@example.com", user_model.EmailNotificationsDisabled),
+ owner: getActionsNowDoneTestUser(t, "new_owner2", "new_owner2@example.com", user_model.EmailNotificationsEnabled),
+ expectMail: false,
+ },
+ {
+ // if the action is assigned to a system user (e.g. ActionsUser)
+ // owned by a regular user with disabled notifications, no mail is sent
+ name: "SystemTriggerUserAndRegularOwnerNotificationsDisabled",
+ triggerUser: actionsUser,
+ owner: getActionsNowDoneTestUser(t, "new_owner3", "new_owner3@example.com", user_model.EmailNotificationsDisabled),
+ expectMail: false,
+ },
+ {
+ // if the action is assigned to a system user (e.g. ActionsUser)
+ // in a repository owned by an organization with an email contact, the mail is sent to
+ // this email contact
+ name: "SystemTriggerUserAndOrgOwner",
+ triggerUser: actionsUser,
+ owner: getActionsNowDoneTestOrg(t, "new_org1", "new_org_owner0@example.com", orgOwner),
+ expected: "owner",
+ expectMail: true,
+ },
+ {
+ // if the action is assigned to a system user (e.g. ActionsUser)
+ // in a repository owned by an organization without an email contact, no mail is sent
+ name: "SystemTriggerUserAndNoMailOrgOwner",
+ triggerUser: actionsUser,
+ owner: getActionsNowDoneTestOrg(t, "new_org2", "", orgOwner),
+ expectMail: false,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ assignUsers(testCase.triggerUser, testCase.owner)
+ defer CleanUpUsers(ctx, slices.DeleteFunc([]*user_model.User{testCase.triggerUser, testCase.owner}, func(user *user_model.User) bool {
+ return user.IsSystem()
+ }))
- notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, run1)
- assert.True(t, mailSentToOwner)
- assert.True(t, mailSentToTriggerUser)
- })
+ t.Run("SendNotificationEmailOnActionRunFailed", func(t *testing.T) {
+ mailSent := false
+ defer MockMailSettings(func(msgs ...*Message) {
+ assert.Len(t, msgs, 1)
+ msg := msgs[0]
+ assert.False(t, mailSent, "sent mail twice")
+ expectedEmail := testCase.triggerUser.Email
+ if testCase.expected == "owner" { // otherwise "trigger"
+ expectedEmail = testCase.owner.Email
+ }
+ require.Contains(t, msg.To, expectedEmail, "sent mail to unknown sender")
+ mailSent = true
+ assert.Contains(t, msg.Body, testCase.triggerUser.HTMLURL())
+ assert.Contains(t, msg.Body, testCase.triggerUser.Name)
+ // what happened
+ assert.Contains(t, msg.Body, "failed")
+ // new status of run
+ assert.Contains(t, msg.Body, "failure")
+ // prior status of this run
+ assert.Contains(t, msg.Body, "waiting")
+ assertTranslatedLocaleMailActionsNowDone(t, msg.Body)
+ })()
+ require.NotNil(t, setting.MailService)
+
+ notify_service.ActionRunNowDone(ctx, run1, actions_model.StatusWaiting, nil)
+ assert.Equal(t, testCase.expectMail, mailSent)
+ })
+
+ t.Run("SendNotificationEmailOnActionRunRecovered", func(t *testing.T) {
+ mailSent := false
+ defer MockMailSettings(func(msgs ...*Message) {
+ assert.Len(t, msgs, 1)
+ msg := msgs[0]
+ assert.False(t, mailSent, "sent mail twice")
+ expectedEmail := testCase.triggerUser.Email
+ if testCase.expected == "owner" { // otherwise "trigger"
+ expectedEmail = testCase.owner.Email
+ }
+ require.Contains(t, msg.To, expectedEmail, "sent mail to unknown sender")
+ mailSent = true
+ assert.Contains(t, msg.Body, testCase.triggerUser.HTMLURL())
+ assert.Contains(t, msg.Body, testCase.triggerUser.Name)
+ // what happened
+ assert.Contains(t, msg.Body, "recovered")
+ // old status of run
+ assert.Contains(t, msg.Body, "failure")
+ // new status of run
+ assert.Contains(t, msg.Body, "success")
+ // prior status of this run
+ assert.Contains(t, msg.Body, "running")
+ })()
+ require.NotNil(t, setting.MailService)
+
+ notify_service.ActionRunNowDone(ctx, run2, actions_model.StatusRunning, run1)
+ assert.Equal(t, testCase.expectMail, mailSent)
+ })
+ })
+ }
}
diff --git a/services/mailer/main_test.go b/services/mailer/main_test.go
index 47e5d5d175..5e9cbe3e99 100644
--- a/services/mailer/main_test.go
+++ b/services/mailer/main_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"forgejo.org/models/db"
+ organization_model "forgejo.org/models/organization"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/setting"
@@ -51,6 +52,11 @@ func MockMailSettings(send func(msgs ...*Message)) func() {
func CleanUpUsers(ctx context.Context, users []*user_model.User) {
for _, u := range users {
- db.DeleteByID[user_model.User](ctx, u.ID)
+ if u.IsOrganization() {
+ organization_model.DeleteOrganization(ctx, (*organization_model.Organization)(u))
+ } else {
+ db.DeleteByID[user_model.User](ctx, u.ID)
+ db.DeleteByBean(ctx, &user_model.EmailAddress{UID: u.ID})
+ }
}
}
diff --git a/services/webhook/notifier.go b/services/webhook/notifier.go
index b3201e5d10..009efc994f 100644
--- a/services/webhook/notifier.go
+++ b/services/webhook/notifier.go
@@ -894,9 +894,16 @@ func (m *webhookNotifier) ActionRunNowDone(ctx context.Context, run *actions_mod
Owner: run.TriggerUser,
}
+ // The doer is the one whose perspective is used to view this ActionRun.
+ // In the best case we use the user that created the webhook.
+ // Unfortunately we don't know who that was.
+ // So instead we use the repo owner, who is able to create webhooks and allow others to do so by making them repo admins.
+ // This is pretty close to perfect.
+ doer := run.Repo.Owner
+
payload := &api.ActionPayload{
- Run: convert.ToActionRun(ctx, run),
- LastRun: convert.ToActionRun(ctx, lastRun),
+ Run: convert.ToActionRun(ctx, run, doer),
+ LastRun: convert.ToActionRun(ctx, lastRun, doer),
PriorStatus: priorStatus.String(),
}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index 9eb9307f9f..aca77a92e5 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -1,7 +1,7 @@
{{template "base/alert"}}
{{range .Issue.Comments}}
{{if call $.ShouldShowCommentType .Type}}
- {{$createdStr:= DateUtils.TimeSince .CreatedUnix}}
+ {{$createdStr := HTMLFormat `%s` .EventTag .HashTag (DateUtils.TimeSince .CreatedUnix)}}