mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-08-19 08:51:10 +00:00
Sent user activities to distant federated server (#8792)
This PR is part of #4767. It contains * a refactoring of validation error messages * adds the ability to send user-activities to distant federated servers Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8792 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org> Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de> Co-committed-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
This commit is contained in:
parent
6b6fa21b25
commit
c081f20776
22 changed files with 885 additions and 130 deletions
|
@ -13,13 +13,6 @@ forgejo.org/models
|
|||
IsErrSHANotFound
|
||||
IsErrMergeDivergingFastForwardOnly
|
||||
|
||||
forgejo.org/models/activities
|
||||
GetActivityByID
|
||||
NewFederatedUserActivity
|
||||
CreateUserActivity
|
||||
GetFollowingFeeds
|
||||
FederatedUserActivity.loadActor
|
||||
|
||||
forgejo.org/models/auth
|
||||
WebAuthnCredentials
|
||||
|
||||
|
@ -63,8 +56,6 @@ forgejo.org/models/user
|
|||
GetUserAllSettings
|
||||
DeleteUserSetting
|
||||
GetFederatedUser
|
||||
GetFederatedUserByUserID
|
||||
GetFollowersForUser
|
||||
|
||||
forgejo.org/modules/activitypub
|
||||
NewContext
|
||||
|
@ -99,16 +90,10 @@ forgejo.org/modules/forgefed
|
|||
NewForgeUndoLike
|
||||
ForgeUndoLike.UnmarshalJSON
|
||||
ForgeUndoLike.Validate
|
||||
NewForgeUserActivityFromAp
|
||||
NewForgeUserActivity
|
||||
ForgeUserActivity.Validate
|
||||
NewPersonIDFromModel
|
||||
GetItemByType
|
||||
JSONUnmarshalerFn
|
||||
NotEmpty
|
||||
NewForgeUserActivityNoteFromAp
|
||||
newNote
|
||||
ForgeUserActivityNote.Validate
|
||||
ToRepository
|
||||
OnRepository
|
||||
|
||||
|
@ -236,8 +221,8 @@ forgejo.org/routers/web/org
|
|||
forgejo.org/services/context
|
||||
GetPrivateContext
|
||||
|
||||
forgejo.org/services/convert
|
||||
ToActivityPubPersonFeedItem
|
||||
forgejo.org/services/federation
|
||||
FollowRemoteActor
|
||||
|
||||
forgejo.org/services/repository
|
||||
IsErrForkAlreadyExist
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -37,6 +37,8 @@ _testmain.go
|
|||
|
||||
*coverage.out
|
||||
coverage.all
|
||||
coverage.html
|
||||
coverage.html.gz
|
||||
coverage/
|
||||
cpu.out
|
||||
|
||||
|
@ -129,3 +131,4 @@ prime/
|
|||
|
||||
# Manpage
|
||||
/man
|
||||
tests/integration/api_activitypub_person_inbox_useractivity_test.go
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"forgejo.org/modules/validation"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_NewForgeFollowValidation(t *testing.T) {
|
||||
|
@ -17,15 +18,13 @@ func Test_NewForgeFollowValidation(t *testing.T) {
|
|||
sut.Actor = ap.IRI("example.org/alice")
|
||||
sut.Object = ap.IRI("example.org/bob")
|
||||
|
||||
if err, _ := validation.IsValid(sut); !err {
|
||||
t.Errorf("sut is invalid: %v\n", err)
|
||||
}
|
||||
valid, err := validation.IsValid(sut)
|
||||
assert.True(t, valid, "sut is invalid: %v\n", err)
|
||||
|
||||
sut = ForgeFollow{}
|
||||
sut.Actor = ap.IRI("example.org/alice")
|
||||
sut.Object = ap.IRI("example.org/bob")
|
||||
|
||||
if err, _ := validation.IsValid(sut); err {
|
||||
t.Errorf("sut is valid: %v\n", err)
|
||||
}
|
||||
valid, err = validation.IsValid(sut)
|
||||
assert.False(t, valid, "sut is valid: %v\n", err)
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import (
|
|||
"forgejo.org/modules/validation"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_NewForgeLike(t *testing.T) {
|
||||
|
@ -22,21 +24,14 @@ func Test_NewForgeLike(t *testing.T) {
|
|||
objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1"
|
||||
startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-07")
|
||||
sut, err := NewForgeLike(actorIRI, objectIRI, startTime)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v\n", err)
|
||||
}
|
||||
if valid, _ := validation.IsValid(sut); !valid {
|
||||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
require.NoError(t, err, "unexpected error: %v\n", err)
|
||||
|
||||
valid, _ := validation.IsValid(sut)
|
||||
assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
|
||||
|
||||
got, err := sut.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Errorf("MarshalJSON() error = \"%v\"", err)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("MarshalJSON() got = %q, want %q", got, want)
|
||||
}
|
||||
require.NoError(t, err, "MarshalJSON() error = %q", err)
|
||||
assert.True(t, reflect.DeepEqual(got, want), "MarshalJSON()\n got: %q,\n want: %q", got, want)
|
||||
}
|
||||
|
||||
func Test_LikeMarshalJSON(t *testing.T) {
|
||||
|
@ -66,13 +61,8 @@ func Test_LikeMarshalJSON(t *testing.T) {
|
|||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := tt.item.MarshalJSON()
|
||||
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
|
||||
t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want)
|
||||
}
|
||||
assert.False(t, (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error(), "MarshalJSON()\n got: %v,\n want: %v", err, tt.wantErr)
|
||||
assert.True(t, reflect.DeepEqual(got, tt.want), "MarshalJSON()\n got: %q\n want: %q", got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -89,8 +79,8 @@ func Test_LikeUnmarshalJSON(t *testing.T) {
|
|||
item: []byte(`{"type":"Like","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"}`),
|
||||
want: &ForgeLike{
|
||||
Activity: ap.Activity{
|
||||
Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"),
|
||||
Type: "Like",
|
||||
Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"),
|
||||
Object: ap.IRI("https://codeberg.org/api/activitypub/repository-id/1"),
|
||||
},
|
||||
},
|
||||
|
@ -107,12 +97,10 @@ func Test_LikeUnmarshalJSON(t *testing.T) {
|
|||
t.Run(name, func(t *testing.T) {
|
||||
got := new(ForgeLike)
|
||||
err := got.UnmarshalJSON(test.item)
|
||||
if (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()) {
|
||||
t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr)
|
||||
return
|
||||
}
|
||||
assert.False(t, (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()), "UnmarshalJSON()\n error: %v\n wantErr: %v", err, test.wantErr)
|
||||
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Errorf("UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error())
|
||||
assert.Errorf(t, err, "UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -120,46 +108,47 @@ func Test_LikeUnmarshalJSON(t *testing.T) {
|
|||
|
||||
func Test_ForgeLikeValidation(t *testing.T) {
|
||||
// Successful
|
||||
|
||||
sut := new(ForgeLike)
|
||||
sut.UnmarshalJSON([]byte(`{"type":"Like",
|
||||
"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
|
||||
"object":"https://codeberg.org/api/activitypub/repository-id/1",
|
||||
"startTime": "2014-12-31T23:00:00-08:00"}`))
|
||||
if res, _ := validation.IsValid(sut); !res {
|
||||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
valid, _ := validation.IsValid(sut)
|
||||
assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
|
||||
|
||||
// Errors
|
||||
|
||||
sut.UnmarshalJSON([]byte(`{"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
|
||||
"object":"https://codeberg.org/api/activitypub/repository-id/1",
|
||||
"startTime": "2014-12-31T23:00:00-08:00"}`))
|
||||
if err := validateAndCheckError(sut, "type should not be empty"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
validate := sut.Validate()
|
||||
assert.Len(t, validate, 2)
|
||||
assert.Equal(t,
|
||||
"Field type contains the value , which is not in allowed subset [Like]",
|
||||
validate[1])
|
||||
|
||||
sut.UnmarshalJSON([]byte(`{"type":"bad-type",
|
||||
"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
|
||||
"object":"https://codeberg.org/api/activitypub/repository-id/1",
|
||||
"startTime": "2014-12-31T23:00:00-08:00"}`))
|
||||
if err := validateAndCheckError(sut, "Field type contains the value bad-type, which is not in allowed subset [Like]"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
validate = sut.Validate()
|
||||
assert.Len(t, validate, 1)
|
||||
assert.Equal(t,
|
||||
"Field type contains the value bad-type, which is not in allowed subset [Like]",
|
||||
validate[0])
|
||||
|
||||
sut.UnmarshalJSON([]byte(`{"type":"Like",
|
||||
"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
|
||||
"object":"https://codeberg.org/api/activitypub/repository-id/1",
|
||||
"startTime": "not a date"}`))
|
||||
if err := validateAndCheckError(sut, "StartTime was invalid."); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
validate = sut.Validate()
|
||||
assert.Len(t, validate, 1)
|
||||
assert.Equal(t,
|
||||
"StartTime was invalid.",
|
||||
validate[0])
|
||||
}
|
||||
|
||||
func TestActivityValidation_Attack(t *testing.T) {
|
||||
sut := new(ForgeLike)
|
||||
sut.UnmarshalJSON([]byte(`{rubbish}`))
|
||||
if len(sut.Validate()) != 5 {
|
||||
t.Errorf("5 validation errors expected but was: %v\n", len(sut.Validate()))
|
||||
}
|
||||
assert.Len(t, sut.Validate(), 5)
|
||||
}
|
||||
|
|
|
@ -173,7 +173,7 @@ func TestActivityValidationUndo(t *testing.T) {
|
|||
"startTime":"2024-03-27T00:00:00Z",
|
||||
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
|
||||
"object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}}`))
|
||||
if err := validateAndCheckError(sut, "type should not be empty"); err != nil {
|
||||
if err := validateAndCheckError(sut, "Value type should not be empty"); err != nil {
|
||||
t.Error(*err)
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"forgejo.org/modules/validation"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_ForgeUserActivityValidation(t *testing.T) {
|
||||
|
@ -34,7 +35,6 @@ func Test_ForgeUserActivityValidation(t *testing.T) {
|
|||
|
||||
sut.Note = note
|
||||
|
||||
if res, _ := validation.IsValid(sut); !res {
|
||||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
valid, _ := validation.IsValid(sut)
|
||||
assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
|
|
|
@ -115,7 +115,7 @@ func TestPersonIdValidation(t *testing.T) {
|
|||
|
||||
result, err := validation.IsValid(sut)
|
||||
assert.False(t, result)
|
||||
require.EqualError(t, err, "Validation Error: forgefed.PersonID: path should not be empty\npath: \"\" has to be a person specific api path")
|
||||
require.EqualError(t, err, "Validation Error: forgefed.PersonID: Value path should not be empty\npath: \"\" has to be a person specific api path")
|
||||
|
||||
sut = PersonID{}
|
||||
sut.ID = "1"
|
||||
|
@ -166,38 +166,28 @@ func TestWebfingerId(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestShouldThrowErrorOnInvalidInput(t *testing.T) {
|
||||
var err any
|
||||
_, err = NewPersonID("", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("empty input should be invalid.")
|
||||
tests := []struct {
|
||||
input string
|
||||
username string
|
||||
expectErr bool
|
||||
}{
|
||||
{"", "forgejo", true},
|
||||
{"http://localhost:3000/api/v1/something", "forgejo", true},
|
||||
{"./api/v1/something", "forgejo", true},
|
||||
{"http://1.2.3.4/api/v1/something", "forgejo", true},
|
||||
{"http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo", true},
|
||||
{"https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo", true},
|
||||
{"https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo", true},
|
||||
{"https://an.other.host/api/v1/activitypub/user-id/1", "forgejo", false},
|
||||
}
|
||||
_, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("localhost uris are not external")
|
||||
}
|
||||
_, err = NewPersonID("./api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("relative uris are not allowed")
|
||||
}
|
||||
_, err = NewPersonID("http://1.2.3.4/api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not be ip-4 based")
|
||||
}
|
||||
_, err = NewPersonID("http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not be ip-6 based")
|
||||
}
|
||||
_, err = NewPersonID("https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not contain relative path elements")
|
||||
}
|
||||
_, err = NewPersonID("https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not contain unparsed elements")
|
||||
}
|
||||
_, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
|
||||
if err != nil {
|
||||
t.Errorf("this uri should be valid but was: %v", err)
|
||||
|
||||
for _, tt := range tests {
|
||||
_, err := NewPersonID(tt.input, tt.username)
|
||||
if tt.expectErr {
|
||||
assert.Error(t, err, "Expected an error for input: %s", tt.input)
|
||||
} else {
|
||||
assert.NoError(t, err, "Expected no error for input: %s, but got: %v", tt.input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -221,14 +211,11 @@ func Test_PersonUnmarshalJSON(t *testing.T) {
|
|||
}
|
||||
sut := new(ForgePerson)
|
||||
err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
|
||||
if err != nil {
|
||||
t.Errorf("UnmarshalJSON() unexpected error: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "UnmarshalJSON() unexpected error: %q", err)
|
||||
|
||||
x, _ := expected.MarshalJSON()
|
||||
y, _ := sut.MarshalJSON()
|
||||
if !reflect.DeepEqual(x, y) {
|
||||
t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y)
|
||||
}
|
||||
assert.True(t, reflect.DeepEqual(x, y), "UnmarshalJSON()\n got: %q,\n want: %q", x, y)
|
||||
|
||||
expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{
|
||||
"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
|
||||
|
@ -244,9 +231,7 @@ func Test_PersonUnmarshalJSON(t *testing.T) {
|
|||
"\n", ""),
|
||||
"\t", "")
|
||||
err = sut.UnmarshalJSON([]byte(expectedStr))
|
||||
if err != nil {
|
||||
t.Errorf("UnmarshalJSON() unexpected error: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "UnmarshalJSON() unexpected error: %q", err)
|
||||
result, _ := sut.MarshalJSON()
|
||||
assert.JSONEq(t, expectedStr, string(result), "Expected string is not equal")
|
||||
}
|
||||
|
@ -254,9 +239,8 @@ func Test_PersonUnmarshalJSON(t *testing.T) {
|
|||
func TestForgePersonValidation(t *testing.T) {
|
||||
sut := new(ForgePerson)
|
||||
sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
|
||||
if res, _ := validation.IsValid(sut); !res {
|
||||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
valid, _ := validation.IsValid(sut)
|
||||
assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
|
||||
func TestAsloginName(t *testing.T) {
|
||||
|
|
|
@ -58,7 +58,7 @@ func TestActorIdValidation(t *testing.T) {
|
|||
sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/"
|
||||
result := sut.Validate()
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "ID should not be empty", result[0])
|
||||
assert.Equal(t, "Value ID should not be empty", result[0])
|
||||
|
||||
sut = ActorID{}
|
||||
sut.ID = "1"
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"forgejo.org/modules/validation"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_UserActivityNoteValidation(t *testing.T) {
|
||||
|
@ -22,7 +23,6 @@ func Test_UserActivityNoteValidation(t *testing.T) {
|
|||
}
|
||||
sut.URL = ap.IRI("example.org/user-id/57")
|
||||
|
||||
if res, _ := validation.IsValid(sut); !res {
|
||||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
valid, _ := validation.IsValid(sut)
|
||||
assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ func IsValid(v Validateable) (bool, error) {
|
|||
|
||||
func ValidateIDExists(value ap.Item, name string) []string {
|
||||
if value == nil {
|
||||
return []string{fmt.Sprintf("%v should not be nil", name)}
|
||||
return []string{fmt.Sprintf("Field %v must not be nil", name)}
|
||||
}
|
||||
return ValidateNotEmpty(value.GetID().String(), name)
|
||||
}
|
||||
|
@ -76,12 +76,12 @@ func ValidateNotEmpty(value any, name string) []string {
|
|||
if isValid {
|
||||
return []string{}
|
||||
}
|
||||
return []string{fmt.Sprintf("%v should not be empty", name)}
|
||||
return []string{fmt.Sprintf("Value %v should not be empty", name)}
|
||||
}
|
||||
|
||||
func ValidateMaxLen(value string, maxLen int, name string) []string {
|
||||
if utf8.RuneCountInString(value) > maxLen {
|
||||
return []string{fmt.Sprintf("Value %v was longer than %v", name, maxLen)}
|
||||
return []string{fmt.Sprintf("Value %v is longer than expected length %v", name, maxLen)}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
|
|
@ -6,9 +6,12 @@ package activitypub
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"forgejo.org/models/activities"
|
||||
"forgejo.org/modules/activitypub"
|
||||
"forgejo.org/modules/forgefed"
|
||||
"forgejo.org/modules/log"
|
||||
"forgejo.org/modules/web"
|
||||
"forgejo.org/routers/api/v1/utils"
|
||||
"forgejo.org/services/context"
|
||||
"forgejo.org/services/convert"
|
||||
"forgejo.org/services/federation"
|
||||
|
@ -78,3 +81,162 @@ func PersonInbox(ctx *context.APIContext) {
|
|||
}
|
||||
responseServiceResult(ctx, result)
|
||||
}
|
||||
|
||||
// PersonFeed returns the recorded activities in the user's feed
|
||||
func PersonFeed(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user-id/{user-id}/outbox activitypub activitypubPersonFeed
|
||||
// ---
|
||||
// summary: List the user's recorded activity
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: user-id
|
||||
// in: path
|
||||
// description: user ID of the user
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/PersonFeed"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
listOptions := utils.GetListOptions(ctx)
|
||||
opts := activities.GetFollowingFeedsOptions{
|
||||
ListOptions: listOptions,
|
||||
}
|
||||
items, count, err := activities.GetFollowingFeeds(ctx, ctx.ContextUser.ID, opts)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "GetFollowingFeeds", err)
|
||||
return
|
||||
}
|
||||
ctx.SetTotalCountHeader(count)
|
||||
|
||||
feed := ap.OrderedCollectionNew(ap.IRI(ctx.ContextUser.APActorID() + "/outbox"))
|
||||
feed.AttributedTo = ap.IRI(ctx.ContextUser.APActorID())
|
||||
for _, item := range items {
|
||||
if err := feed.OrderedItems.Append(convert.ToActivityPubPersonFeedItem(item)); err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "OrderedItems.Append", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(feed)
|
||||
if err != nil {
|
||||
ctx.ServerError("MarshalJSON", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
if _, err = ctx.Resp.Write(binary); err != nil {
|
||||
log.Error("write to resp err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getActivity(ctx *context.APIContext, id int64) (*forgefed.ForgeUserActivity, error) {
|
||||
action, err := activities.GetActivityByID(ctx, id)
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "GetActivityByID", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if action.UserID != action.ActUserID || action.ActUserID != ctx.ContextUser.ID {
|
||||
ctx.NotFound()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
actions := activities.ActionList{action}
|
||||
if err := actions.LoadAttributes(ctx); err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "action.LoadAttributes", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activity, err := convert.ActionToForgeUserActivity(ctx, actions[0])
|
||||
if err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "ActionToForgeUserActivity", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &activity, nil
|
||||
}
|
||||
|
||||
// PersonActivity returns a user's given activity
|
||||
func PersonActivity(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user-id/{user-id}/activities/{activity-id}/activity activitypub activitypubPersonActivity
|
||||
// ---
|
||||
// summary: Get a specific activity of the user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: user-id
|
||||
// in: path
|
||||
// description: user ID of the user
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: activity-id
|
||||
// in: path
|
||||
// description: activity ID of the sought activity
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
id := ctx.ParamsInt64("activity-id")
|
||||
activity, err := getActivity(ctx, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(activity)
|
||||
if err != nil {
|
||||
ctx.ServerError("MarshalJSON", err)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
if _, err = ctx.Resp.Write(binary); err != nil {
|
||||
log.Error("write to resp err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PersonActivity returns the Object part of a user's given activity
|
||||
func PersonActivityNote(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user-id/{user-id}/activities/{activity-id} activitypub activitypubPersonActivityNote
|
||||
// ---
|
||||
// summary: Get a specific activity object of the user
|
||||
// produces:
|
||||
// - application/json
|
||||
// parameters:
|
||||
// - name: user-id
|
||||
// in: path
|
||||
// description: user ID of the user
|
||||
// type: integer
|
||||
// required: true
|
||||
// - name: activity-id
|
||||
// in: path
|
||||
// description: activity ID of the sought activity
|
||||
// type: integer
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
id := ctx.ParamsInt64("activity-id")
|
||||
activity, err := getActivity(ctx, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(activity.Object)
|
||||
if err != nil {
|
||||
ctx.ServerError("MarshalJSON", err)
|
||||
return
|
||||
}
|
||||
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
if _, err = ctx.Resp.Write(binary); err != nil {
|
||||
log.Error("write to resp err: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -844,6 +844,11 @@ func Routes() *web.Route {
|
|||
activitypub.ReqHTTPUserSignature(),
|
||||
bind(ap.Activity{}),
|
||||
activitypub.PersonInbox)
|
||||
m.Group("/activities/{activity-id}", func() {
|
||||
m.Get("", activitypub.PersonActivityNote)
|
||||
m.Get("/activity", activitypub.PersonActivity)
|
||||
})
|
||||
m.Get("/outbox", activitypub.ReqHTTPUserSignature(), activitypub.PersonFeed)
|
||||
}, context.UserIDAssignmentAPI(), checkTokenPublicOnly())
|
||||
m.Group("/actor", func() {
|
||||
m.Get("", activitypub.Actor)
|
||||
|
|
|
@ -13,3 +13,10 @@ type swaggerResponseActivityPub struct {
|
|||
// in:body
|
||||
Body api.ActivityPub `json:"body"`
|
||||
}
|
||||
|
||||
// Personfeed
|
||||
// swagger:response PersonFeed
|
||||
type swaggerResponsePersonFeed struct {
|
||||
// in:body
|
||||
Body []api.APPersonFollowItem `json:"body"`
|
||||
}
|
||||
|
|
177
services/convert/activitypub_user_action.go
Normal file
177
services/convert/activitypub_user_action.go
Normal file
|
@ -0,0 +1,177 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
activities_model "forgejo.org/models/activities"
|
||||
issues_model "forgejo.org/models/issues"
|
||||
fm "forgejo.org/modules/forgefed"
|
||||
"forgejo.org/modules/json"
|
||||
"forgejo.org/modules/markup"
|
||||
"forgejo.org/modules/markup/markdown"
|
||||
)
|
||||
|
||||
func ActionToForgeUserActivity(ctx context.Context, action *activities_model.Action) (fm.ForgeUserActivity, error) {
|
||||
render := func(format string, args ...any) string {
|
||||
return fmt.Sprintf(`<a href="%s" rel="nofollow">%s</a> %s`, action.ActUser.HTMLURL(), action.GetActDisplayName(ctx), fmt.Sprintf(format, args...))
|
||||
}
|
||||
renderIssue := func(issue *issues_model.Issue) string {
|
||||
return fmt.Sprintf(`<a href="%s" rel="nofollow">%s#%d</a>`,
|
||||
issue.HTMLURL(),
|
||||
action.GetRepoPath(ctx),
|
||||
issue.Index,
|
||||
)
|
||||
}
|
||||
renderRepo := func() string {
|
||||
return fmt.Sprintf(`<a href="%s" rel="nofollow">%s</a>`, action.Repo.HTMLURL(), action.GetRepoPath(ctx))
|
||||
}
|
||||
renderBranch := func() string {
|
||||
return fmt.Sprintf(`<a href="%s" rel="nofollow">%s</a>`, action.GetRefLink(ctx), action.GetBranch())
|
||||
}
|
||||
renderTag := func() string {
|
||||
return fmt.Sprintf(`<a href="%s" rel="nofollow">%s</a>`, action.GetRefLink(ctx), action.GetTag())
|
||||
}
|
||||
|
||||
makeUserActivity := func(format string, args ...any) (fm.ForgeUserActivity, error) {
|
||||
return fm.NewForgeUserActivity(action.ActUser, action.ID, render(format, args...))
|
||||
}
|
||||
|
||||
switch action.OpType {
|
||||
case activities_model.ActionCreateRepo:
|
||||
return makeUserActivity("created a new repository: %s", renderRepo())
|
||||
case activities_model.ActionRenameRepo:
|
||||
return makeUserActivity("renamed a repository: %s", renderRepo())
|
||||
case activities_model.ActionStarRepo:
|
||||
return makeUserActivity("starred a repository: %s", renderRepo())
|
||||
case activities_model.ActionWatchRepo:
|
||||
return makeUserActivity("started watching a repository: %s", renderRepo())
|
||||
case activities_model.ActionCommitRepo:
|
||||
type PushCommit struct {
|
||||
Sha1 string
|
||||
Message string
|
||||
AuthorEmail string
|
||||
AuthorName string
|
||||
CommitterEmail string
|
||||
CommitterName string
|
||||
Timestamp time.Time
|
||||
}
|
||||
type PushCommits struct {
|
||||
Commits []*PushCommit
|
||||
HeadCommit *PushCommit
|
||||
CompareURL string
|
||||
Len int
|
||||
}
|
||||
|
||||
commits := &PushCommits{}
|
||||
if err := json.Unmarshal([]byte(action.GetContent()), commits); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
commitsHTML := ""
|
||||
renderCommit := func(commit *PushCommit) string {
|
||||
return fmt.Sprintf(`<li><a href="%s" rel="nofollow">%s</a> <pre>%s</pre></li>`,
|
||||
fmt.Sprintf("%s/commit/%s", action.GetRepoAbsoluteLink(ctx), url.PathEscape(commit.Sha1)),
|
||||
commit.Sha1,
|
||||
html.EscapeString(commit.Message),
|
||||
)
|
||||
}
|
||||
for _, commit := range commits.Commits {
|
||||
commitsHTML += renderCommit(commit)
|
||||
}
|
||||
return makeUserActivity("pushed to %s at %s: <ul>%s</ul>", renderBranch(), renderRepo(), commitsHTML)
|
||||
case activities_model.ActionCreateIssue:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("opened issue %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionCreatePullRequest:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("opened pull request %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionTransferRepo:
|
||||
return makeUserActivity("transferred %s", renderRepo())
|
||||
case activities_model.ActionPushTag:
|
||||
return makeUserActivity("pushed %s at %s", renderTag(), renderRepo())
|
||||
case activities_model.ActionCommentIssue:
|
||||
renderedComment, err := markdown.RenderString(&markup.RenderContext{
|
||||
Ctx: ctx,
|
||||
}, action.Comment.Content)
|
||||
if err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
|
||||
return makeUserActivity(`<a href="%s" rel="nofollow">commented</a> on %s: <blockquote>%s</blockquote>`,
|
||||
action.GetCommentHTMLURL(ctx),
|
||||
renderIssue(action.Comment.Issue),
|
||||
renderedComment,
|
||||
)
|
||||
case activities_model.ActionMergePullRequest:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("merged pull request %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionCloseIssue:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("closed issue %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionReopenIssue:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("reopened issue %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionClosePullRequest:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("closed pull request %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionReopenPullRequest:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("reopened pull request %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionDeleteTag:
|
||||
return makeUserActivity("deleted tag %s at %s", action.GetTag(), renderRepo())
|
||||
case activities_model.ActionDeleteBranch:
|
||||
return makeUserActivity("deleted branch %s at %s", action.GetBranch(), renderRepo())
|
||||
case activities_model.ActionApprovePullRequest:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("approved pull request %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionRejectPullRequest:
|
||||
if err := action.LoadIssue(ctx); err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
return makeUserActivity("rejected pull request %s", renderIssue(action.Issue))
|
||||
case activities_model.ActionCommentPull:
|
||||
renderedComment, err := markdown.RenderString(&markup.RenderContext{
|
||||
Ctx: ctx,
|
||||
}, action.Comment.Content)
|
||||
if err != nil {
|
||||
return fm.ForgeUserActivity{}, err
|
||||
}
|
||||
|
||||
return makeUserActivity(`<a href="%s" rel="nofollow">commented</a> on %s: <blockquote>%s</blockquote>`,
|
||||
action.GetCommentHTMLURL(ctx),
|
||||
renderIssue(action.Comment.Issue),
|
||||
renderedComment,
|
||||
)
|
||||
case activities_model.ActionMirrorSyncPush:
|
||||
case activities_model.ActionMirrorSyncCreate:
|
||||
case activities_model.ActionMirrorSyncDelete:
|
||||
case activities_model.ActionPublishRelease:
|
||||
case activities_model.ActionPullReviewDismissed:
|
||||
case activities_model.ActionPullRequestReadyForReview:
|
||||
case activities_model.ActionAutoMergePullRequest:
|
||||
}
|
||||
|
||||
return makeUserActivity("performed an unrecognised action: %s", action.OpType.String())
|
||||
}
|
22
services/federation/person_inbox_accept.go
Normal file
22
services/federation/person_inbox_accept.go
Normal file
|
@ -0,0 +1,22 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"forgejo.org/modules/log"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func processPersonInboxAccept(activity *ap.Activity) (ServiceResult, error) {
|
||||
if activity.Object.GetType() != ap.FollowType {
|
||||
log.Error("Invalid object type for Accept activity: %v", activity.Object.GetType())
|
||||
return ServiceResult{}, NewErrNotAcceptablef("invalid object type for Accept activity: %v", activity.Object.GetType())
|
||||
}
|
||||
|
||||
// We currently do not do anything here, we just drop it.
|
||||
return NewServiceResultStatusOnly(http.StatusNoContent), nil
|
||||
}
|
55
services/federation/person_inbox_create.go
Normal file
55
services/federation/person_inbox_create.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"forgejo.org/models/activities"
|
||||
"forgejo.org/models/user"
|
||||
fm "forgejo.org/modules/forgefed"
|
||||
"forgejo.org/modules/log"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func processPersonInboxCreate(ctx context.Context, user *user.User, activity *ap.Activity) (ServiceResult, error) {
|
||||
createAct, err := fm.NewForgeUserActivityFromAp(*activity)
|
||||
if err != nil {
|
||||
log.Error("Invalid user activity: %v, %v", activity, err)
|
||||
return ServiceResult{}, NewErrNotAcceptablef("Invalid user activity: %v", err)
|
||||
}
|
||||
|
||||
actorURI := createAct.Actor.GetLink().String()
|
||||
federatedBaseUser, _, _, err := findFederatedUser(ctx, actorURI)
|
||||
if err != nil {
|
||||
log.Error("Federated user not found (%s): %v", actorURI, err)
|
||||
return ServiceResult{}, NewErrNotAcceptablef("federated user not found (%s): %v", actorURI, err)
|
||||
}
|
||||
if federatedBaseUser == nil {
|
||||
log.Error("Federated user not found (%s): %v", actorURI, err)
|
||||
return ServiceResult{}, NewErrNotAcceptablef("federated user not found (%s): %v", actorURI, err)
|
||||
}
|
||||
|
||||
federatedUserActivity, err := activities.NewFederatedUserActivity(
|
||||
user.ID,
|
||||
federatedBaseUser.ID,
|
||||
createAct.Actor.GetLink().String(),
|
||||
createAct.Note.Content.String(),
|
||||
createAct.Note.URL.GetID().String(),
|
||||
*activity,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("Error creating federatedUserActivity (%s): %v", actorURI, err)
|
||||
return ServiceResult{}, NewErrNotAcceptablef("Error creating federatedUserActivity: %v", err)
|
||||
}
|
||||
|
||||
if err := activities.CreateUserActivity(ctx, &federatedUserActivity); err != nil {
|
||||
log.Error("Unable to record activity: %v", err)
|
||||
return ServiceResult{}, NewErrNotAcceptablef("Unable to record activity: %v", err)
|
||||
}
|
||||
|
||||
return NewServiceResultStatusOnly(http.StatusNoContent), nil
|
||||
}
|
|
@ -5,21 +5,56 @@ package federation
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/modules/forgefed"
|
||||
"forgejo.org/modules/log"
|
||||
context_service "forgejo.org/services/context"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/go-ap/jsonld"
|
||||
)
|
||||
|
||||
func ProcessPersonInbox(ctx context.Context, user *user.User, activity *ap.Activity) (ServiceResult, error) {
|
||||
switch activity.Type {
|
||||
case ap.CreateType:
|
||||
return processPersonInboxCreate(ctx, user, activity)
|
||||
case ap.FollowType:
|
||||
return processPersonFollow(ctx, user, activity)
|
||||
case ap.UndoType:
|
||||
return processPersonInboxUndo(ctx, user, activity)
|
||||
case ap.AcceptType:
|
||||
return processPersonInboxAccept(activity)
|
||||
}
|
||||
|
||||
log.Error("Unsupported PersonInbox activity: %v", activity.Type)
|
||||
return ServiceResult{}, NewErrNotAcceptablef("unsupported activity: %v", activity.Type)
|
||||
}
|
||||
|
||||
func FollowRemoteActor(ctx *context_service.APIContext, localUser *user.User, actorURI string) error {
|
||||
_, federatedUser, federationHost, err := FindOrCreateFederatedUser(ctx.Base, actorURI)
|
||||
if err != nil {
|
||||
log.Error("Federated user not found (%s): %v", actorURI, err)
|
||||
ctx.Error(http.StatusNotAcceptable, "Federated user not found", err)
|
||||
return err
|
||||
}
|
||||
|
||||
followReq, err := forgefed.NewForgeFollow(localUser.APActorID(), actorURI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI)).
|
||||
Marshal(followReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostURL := federationHost.AsURL()
|
||||
return deliveryQueue.Push(deliveryQueueItem{
|
||||
InboxURL: hostURL.JoinPath(federatedUser.InboxPath).String(),
|
||||
Doer: localUser,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
|
83
services/federation/user_activity.go
Normal file
83
services/federation/user_activity.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
activities_model "forgejo.org/models/activities"
|
||||
"forgejo.org/models/forgefed"
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/structs"
|
||||
"forgejo.org/services/convert"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/go-ap/jsonld"
|
||||
)
|
||||
|
||||
func SendUserActivity(ctx context.Context, doer *user.User, activity *activities_model.Action) error {
|
||||
followers, err := user.GetFollowersForUser(ctx, doer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userActivity, err := convert.ActionToForgeUserActivity(ctx, activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, err := jsonld.WithContext(
|
||||
jsonld.IRI(ap.ActivityBaseURI),
|
||||
).Marshal(userActivity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, follower := range followers {
|
||||
_, federatedUserFollower, err := user.GetFederatedUserByUserID(ctx, follower.FollowingUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
federationHost, err := forgefed.GetFederationHost(ctx, federatedUserFollower.FederationHostID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostURL := federationHost.AsURL()
|
||||
if err := deliveryQueue.Push(deliveryQueueItem{
|
||||
InboxURL: hostURL.JoinPath(federatedUserFollower.InboxPath).String(),
|
||||
Doer: doer,
|
||||
Payload: payload,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotifyActivityPubFollowers(ctx context.Context, actions []activities_model.Action) error {
|
||||
if !setting.Federation.Enabled {
|
||||
return nil
|
||||
}
|
||||
for _, act := range actions {
|
||||
if act.Repo != nil {
|
||||
if act.Repo.IsPrivate {
|
||||
continue
|
||||
}
|
||||
if act.Repo.Owner.KeepActivityPrivate || act.Repo.Owner.Visibility != structs.VisibleTypePublic {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if act.ActUser.KeepActivityPrivate || act.ActUser.Visibility != structs.VisibleTypePublic {
|
||||
continue
|
||||
}
|
||||
if err := SendUserActivity(ctx, act.ActUser, &act); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -19,6 +19,7 @@ import (
|
|||
"forgejo.org/modules/repository"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/util"
|
||||
federation_service "forgejo.org/services/federation"
|
||||
notify_service "forgejo.org/services/notify"
|
||||
)
|
||||
|
||||
|
@ -40,21 +41,19 @@ func NewNotifier() notify_service.Notifier {
|
|||
}
|
||||
|
||||
func notifyAll(ctx context.Context, action *activities_model.Action) error {
|
||||
_, err := activities_model.NotifyWatchers(ctx, action)
|
||||
out, err := activities_model.NotifyWatchers(ctx, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
// return federation_service.NotifyActivityPubFollowers(ctx, out)
|
||||
return federation_service.NotifyActivityPubFollowers(ctx, out)
|
||||
}
|
||||
|
||||
func notifyAllActions(ctx context.Context, acts []*activities_model.Action) error {
|
||||
_, err := activities_model.NotifyWatchersActions(ctx, acts)
|
||||
out, err := activities_model.NotifyWatchersActions(ctx, acts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
// return federation_service.NotifyActivityPubFollowers(ctx, out)
|
||||
return federation_service.NotifyActivityPubFollowers(ctx, out)
|
||||
}
|
||||
|
||||
func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {
|
||||
|
|
126
templates/swagger/v1_json.tmpl
generated
126
templates/swagger/v1_json.tmpl
generated
|
@ -142,6 +142,72 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user-id/{user-id}/activities/{activity-id}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Get a specific activity object of the user",
|
||||
"operationId": "activitypubPersonActivityNote",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "user ID of the user",
|
||||
"name": "user-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "activity ID of the sought activity",
|
||||
"name": "activity-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user-id/{user-id}/activities/{activity-id}/activity": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Get a specific activity of the user",
|
||||
"operationId": "activitypubPersonActivity",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "user ID of the user",
|
||||
"name": "user-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "activity ID of the sought activity",
|
||||
"name": "activity-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user-id/{user-id}/inbox": {
|
||||
"post": {
|
||||
"produces": [
|
||||
|
@ -168,6 +234,35 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user-id/{user-id}/outbox": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "List the user's recorded activity",
|
||||
"operationId": "activitypubPersonFeed",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "user ID of the user",
|
||||
"name": "user-id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/PersonFeed"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/cron": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
@ -21121,6 +21216,28 @@
|
|||
},
|
||||
"x-go-package": "forgejo.org/services/context"
|
||||
},
|
||||
"APPersonFollowItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actor_id": {
|
||||
"type": "string",
|
||||
"x-go-name": "ActorID"
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"x-go-name": "Note"
|
||||
},
|
||||
"original_item": {
|
||||
"type": "string",
|
||||
"x-go-name": "OriginalItem"
|
||||
},
|
||||
"original_url": {
|
||||
"type": "string",
|
||||
"x-go-name": "OriginalURL"
|
||||
}
|
||||
},
|
||||
"x-go-package": "forgejo.org/modules/structs"
|
||||
},
|
||||
"AccessToken": {
|
||||
"type": "object",
|
||||
"title": "AccessToken represents an API access token.",
|
||||
|
@ -29566,6 +29683,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"PersonFeed": {
|
||||
"description": "Personfeed",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/APPersonFollowItem"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PublicKey": {
|
||||
"description": "PublicKey",
|
||||
"schema": {
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
// Copyright 2022, 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/activitypub"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/routers"
|
||||
"forgejo.org/services/contexttest"
|
||||
"forgejo.org/services/federation"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
federation.Init()
|
||||
|
||||
mock := test.NewFederationServerMock()
|
||||
federatedSrv := mock.DistantServer(t)
|
||||
defer federatedSrv.Close()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, localUrl *url.URL) {
|
||||
defer test.MockVariableValue(&setting.AppURL, localUrl.String())()
|
||||
|
||||
distantURL := federatedSrv.URL
|
||||
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
|
||||
|
||||
localUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
|
||||
localUser2Inbox := fmt.Sprintf("%v/inbox", localUser2URL)
|
||||
localSession2 := loginUser(t, localUser2.LoginName)
|
||||
localSecssion2Token := getTokenForLoggedInUser(t, localSession2, auth_model.AccessTokenScopeWriteIssue)
|
||||
|
||||
repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, localUser2, tests.DeclarativeRepoOptions{})
|
||||
defer f()
|
||||
|
||||
// follow (distant follows local)
|
||||
followActivity := []byte(fmt.Sprintf(
|
||||
`{"type":"Follow",`+
|
||||
`"actor":"%s",`+
|
||||
`"object":"%s"}`,
|
||||
distantUser15URL,
|
||||
localUser2URL,
|
||||
))
|
||||
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
|
||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||
require.NoError(t, err)
|
||||
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
|
||||
mock.ApActor.KeyID(federatedSrv.URL))
|
||||
require.NoError(t, err)
|
||||
resp, err := c.Post(followActivity, localUser2Inbox)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
// local action which triggers a user activity
|
||||
IssueURL := fmt.Sprintf("/api/v1/repos/%s/issues?state=all", repo.FullName())
|
||||
req := NewRequestWithJSON(t, "POST", IssueURL, &structs.CreateIssueOption{
|
||||
Title: "ActivityFeed test",
|
||||
Body: "Nothing to see here!",
|
||||
}).AddTokenAuth(localSecssion2Token)
|
||||
MakeRequest(t, req, http.StatusCreated)
|
||||
|
||||
// distant request outbox
|
||||
localUser2Outbox := fmt.Sprintf("%v/outbox", localUser2URL)
|
||||
resp, err = c.Get(localUser2Outbox)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// distant request activity & activity note
|
||||
localUser2ActivityNote := fmt.Sprintf("%v/activities/1", localUser2URL)
|
||||
localUser2Activity := fmt.Sprintf("%v/activities/1/activity", localUser2URL)
|
||||
resp, err = c.Get(localUser2ActivityNote)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
resp, err = c.Get(localUser2Activity)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// check for activity on distant inbox
|
||||
assert.Contains(t, mock.LastPost, "user2</a> opened issue")
|
||||
})
|
||||
}
|
|
@ -39,9 +39,6 @@ func TestActivityPubPerson(t *testing.T) {
|
|||
localUserName := "user2"
|
||||
localUserURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", localUrl, localUserID)
|
||||
|
||||
// distantURL := federatedSrv.URL
|
||||
// distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
|
||||
|
||||
// Unsigned request
|
||||
t.Run("UnsignedRequest", func(t *testing.T) {
|
||||
req := NewRequest(t, "GET", localUserURL)
|
||||
|
@ -111,3 +108,30 @@ func TestActivityPubPersonInbox(t *testing.T) {
|
|||
assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityPubPersonOutbox(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
mock := test.NewFederationServerMock()
|
||||
federatedSrv := mock.DistantServer(t)
|
||||
defer federatedSrv.Close()
|
||||
|
||||
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
||||
defer test.MockVariableValue(&setting.AppURL, u.String())()
|
||||
user2outboxurl := u.JoinPath("/api/v1/activitypub/user-id/2/outbox").String()
|
||||
|
||||
ctx, _ := contexttest.MockAPIContext(t, user2outboxurl)
|
||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
c, err := cf.WithKeysDirect(ctx, mock.Persons[0].PrivKey,
|
||||
mock.Persons[0].KeyID(federatedSrv.URL))
|
||||
require.NoError(t, err)
|
||||
|
||||
// request outbox
|
||||
resp, err := c.Get(user2outboxurl)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue