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:
Michael Jerger 2025-08-06 16:16:13 +02:00 committed by Earl Warren
commit c081f20776
22 changed files with 885 additions and 130 deletions

View file

@ -13,13 +13,6 @@ forgejo.org/models
IsErrSHANotFound IsErrSHANotFound
IsErrMergeDivergingFastForwardOnly IsErrMergeDivergingFastForwardOnly
forgejo.org/models/activities
GetActivityByID
NewFederatedUserActivity
CreateUserActivity
GetFollowingFeeds
FederatedUserActivity.loadActor
forgejo.org/models/auth forgejo.org/models/auth
WebAuthnCredentials WebAuthnCredentials
@ -63,8 +56,6 @@ forgejo.org/models/user
GetUserAllSettings GetUserAllSettings
DeleteUserSetting DeleteUserSetting
GetFederatedUser GetFederatedUser
GetFederatedUserByUserID
GetFollowersForUser
forgejo.org/modules/activitypub forgejo.org/modules/activitypub
NewContext NewContext
@ -99,16 +90,10 @@ forgejo.org/modules/forgefed
NewForgeUndoLike NewForgeUndoLike
ForgeUndoLike.UnmarshalJSON ForgeUndoLike.UnmarshalJSON
ForgeUndoLike.Validate ForgeUndoLike.Validate
NewForgeUserActivityFromAp
NewForgeUserActivity
ForgeUserActivity.Validate
NewPersonIDFromModel NewPersonIDFromModel
GetItemByType GetItemByType
JSONUnmarshalerFn JSONUnmarshalerFn
NotEmpty NotEmpty
NewForgeUserActivityNoteFromAp
newNote
ForgeUserActivityNote.Validate
ToRepository ToRepository
OnRepository OnRepository
@ -236,8 +221,8 @@ forgejo.org/routers/web/org
forgejo.org/services/context forgejo.org/services/context
GetPrivateContext GetPrivateContext
forgejo.org/services/convert forgejo.org/services/federation
ToActivityPubPersonFeedItem FollowRemoteActor
forgejo.org/services/repository forgejo.org/services/repository
IsErrForkAlreadyExist IsErrForkAlreadyExist

3
.gitignore vendored
View file

@ -37,6 +37,8 @@ _testmain.go
*coverage.out *coverage.out
coverage.all coverage.all
coverage.html
coverage.html.gz
coverage/ coverage/
cpu.out cpu.out
@ -129,3 +131,4 @@ prime/
# Manpage # Manpage
/man /man
tests/integration/api_activitypub_person_inbox_useractivity_test.go

View file

@ -9,6 +9,7 @@ import (
"forgejo.org/modules/validation" "forgejo.org/modules/validation"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
"github.com/stretchr/testify/assert"
) )
func Test_NewForgeFollowValidation(t *testing.T) { func Test_NewForgeFollowValidation(t *testing.T) {
@ -17,15 +18,13 @@ func Test_NewForgeFollowValidation(t *testing.T) {
sut.Actor = ap.IRI("example.org/alice") sut.Actor = ap.IRI("example.org/alice")
sut.Object = ap.IRI("example.org/bob") sut.Object = ap.IRI("example.org/bob")
if err, _ := validation.IsValid(sut); !err { valid, err := validation.IsValid(sut)
t.Errorf("sut is invalid: %v\n", err) assert.True(t, valid, "sut is invalid: %v\n", err)
}
sut = ForgeFollow{} sut = ForgeFollow{}
sut.Actor = ap.IRI("example.org/alice") sut.Actor = ap.IRI("example.org/alice")
sut.Object = ap.IRI("example.org/bob") sut.Object = ap.IRI("example.org/bob")
if err, _ := validation.IsValid(sut); err { valid, err = validation.IsValid(sut)
t.Errorf("sut is valid: %v\n", err) assert.False(t, valid, "sut is valid: %v\n", err)
}
} }

View file

@ -13,6 +13,8 @@ import (
"forgejo.org/modules/validation" "forgejo.org/modules/validation"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func Test_NewForgeLike(t *testing.T) { 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" objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1"
startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-07") startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-07")
sut, err := NewForgeLike(actorIRI, objectIRI, startTime) sut, err := NewForgeLike(actorIRI, objectIRI, startTime)
if err != nil { require.NoError(t, err, "unexpected error: %v\n", err)
t.Errorf("unexpected error: %v\n", err)
} valid, _ := validation.IsValid(sut)
if valid, _ := validation.IsValid(sut); !valid { assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
}
got, err := sut.MarshalJSON() got, err := sut.MarshalJSON()
if err != nil { require.NoError(t, err, "MarshalJSON() error = %q", err)
t.Errorf("MarshalJSON() error = \"%v\"", err) assert.True(t, reflect.DeepEqual(got, want), "MarshalJSON()\n got: %q,\n want: %q", got, want)
return
}
if !reflect.DeepEqual(got, want) {
t.Errorf("MarshalJSON() got = %q, want %q", got, want)
}
} }
func Test_LikeMarshalJSON(t *testing.T) { func Test_LikeMarshalJSON(t *testing.T) {
@ -66,13 +61,8 @@ func Test_LikeMarshalJSON(t *testing.T) {
for name, tt := range tests { for name, tt := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
got, err := tt.item.MarshalJSON() got, err := tt.item.MarshalJSON()
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { assert.False(t, (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error(), "MarshalJSON()\n got: %v,\n want: %v", err, tt.wantErr)
t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) assert.True(t, reflect.DeepEqual(got, tt.want), "MarshalJSON()\n got: %q\n want: %q", got, tt.want)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MarshalJSON() got = %q, 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"}`), 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{ want: &ForgeLike{
Activity: ap.Activity{ Activity: ap.Activity{
Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"),
Type: "Like", 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"), 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) { t.Run(name, func(t *testing.T) {
got := new(ForgeLike) got := new(ForgeLike)
err := got.UnmarshalJSON(test.item) err := got.UnmarshalJSON(test.item)
if (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()) { 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)
t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr)
return
}
if !reflect.DeepEqual(got, test.want) { 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) { func Test_ForgeLikeValidation(t *testing.T) {
// Successful // Successful
sut := new(ForgeLike) sut := new(ForgeLike)
sut.UnmarshalJSON([]byte(`{"type":"Like", sut.UnmarshalJSON([]byte(`{"type":"Like",
"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
"object":"https://codeberg.org/api/activitypub/repository-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1",
"startTime": "2014-12-31T23:00:00-08:00"}`)) "startTime": "2014-12-31T23:00:00-08:00"}`))
if res, _ := validation.IsValid(sut); !res { valid, _ := validation.IsValid(sut)
t.Errorf("sut expected to be valid: %v\n", sut.Validate()) assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
}
// Errors // Errors
sut.UnmarshalJSON([]byte(`{"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", sut.UnmarshalJSON([]byte(`{"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
"object":"https://codeberg.org/api/activitypub/repository-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1",
"startTime": "2014-12-31T23:00:00-08:00"}`)) "startTime": "2014-12-31T23:00:00-08:00"}`))
if err := validateAndCheckError(sut, "type should not be empty"); err != nil { validate := sut.Validate()
t.Error(err) 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", sut.UnmarshalJSON([]byte(`{"type":"bad-type",
"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
"object":"https://codeberg.org/api/activitypub/repository-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1",
"startTime": "2014-12-31T23:00:00-08:00"}`)) "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 { validate = sut.Validate()
t.Error(err) 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", sut.UnmarshalJSON([]byte(`{"type":"Like",
"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1",
"object":"https://codeberg.org/api/activitypub/repository-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1",
"startTime": "not a date"}`)) "startTime": "not a date"}`))
if err := validateAndCheckError(sut, "StartTime was invalid."); err != nil { validate = sut.Validate()
t.Error(err) assert.Len(t, validate, 1)
} assert.Equal(t,
"StartTime was invalid.",
validate[0])
} }
func TestActivityValidation_Attack(t *testing.T) { func TestActivityValidation_Attack(t *testing.T) {
sut := new(ForgeLike) sut := new(ForgeLike)
sut.UnmarshalJSON([]byte(`{rubbish}`)) sut.UnmarshalJSON([]byte(`{rubbish}`))
if len(sut.Validate()) != 5 { assert.Len(t, sut.Validate(), 5)
t.Errorf("5 validation errors expected but was: %v\n", len(sut.Validate()))
}
} }

View file

@ -173,7 +173,7 @@ func TestActivityValidationUndo(t *testing.T) {
"startTime":"2024-03-27T00:00:00Z", "startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":"https://codeberg.org/api/v1/activitypub/repository-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) t.Error(*err)
} }

View file

@ -9,6 +9,7 @@ import (
"forgejo.org/modules/validation" "forgejo.org/modules/validation"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
"github.com/stretchr/testify/assert"
) )
func Test_ForgeUserActivityValidation(t *testing.T) { func Test_ForgeUserActivityValidation(t *testing.T) {
@ -34,7 +35,6 @@ func Test_ForgeUserActivityValidation(t *testing.T) {
sut.Note = note sut.Note = note
if res, _ := validation.IsValid(sut); !res { valid, _ := validation.IsValid(sut)
t.Errorf("sut expected to be valid: %v\n", sut.Validate()) assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
}
} }

View file

@ -115,7 +115,7 @@ func TestPersonIdValidation(t *testing.T) {
result, err := validation.IsValid(sut) result, err := validation.IsValid(sut)
assert.False(t, result) 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 = PersonID{}
sut.ID = "1" sut.ID = "1"
@ -166,38 +166,28 @@ func TestWebfingerId(t *testing.T) {
} }
func TestShouldThrowErrorOnInvalidInput(t *testing.T) { func TestShouldThrowErrorOnInvalidInput(t *testing.T) {
var err any tests := []struct {
_, err = NewPersonID("", "forgejo") input string
if err == nil { username string
t.Errorf("empty input should be invalid.") 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 { for _, tt := range tests {
t.Errorf("localhost uris are not external") _, err := NewPersonID(tt.input, tt.username)
} if tt.expectErr {
_, err = NewPersonID("./api/v1/something", "forgejo") assert.Error(t, err, "Expected an error for input: %s", tt.input)
if err == nil { } else {
t.Errorf("relative uris are not allowed") assert.NoError(t, err, "Expected no error for input: %s, but got: %v", tt.input, err)
} }
_, 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)
} }
} }
@ -221,14 +211,11 @@ func Test_PersonUnmarshalJSON(t *testing.T) {
} }
sut := new(ForgePerson) sut := new(ForgePerson)
err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
if err != nil { require.NoError(t, err, "UnmarshalJSON() unexpected error: %q", err)
t.Errorf("UnmarshalJSON() unexpected error: %v", err)
}
x, _ := expected.MarshalJSON() x, _ := expected.MarshalJSON()
y, _ := sut.MarshalJSON() y, _ := sut.MarshalJSON()
if !reflect.DeepEqual(x, y) { assert.True(t, reflect.DeepEqual(x, y), "UnmarshalJSON()\n got: %q,\n want: %q", x, y)
t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y)
}
expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{ expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{
"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10", "id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
@ -244,9 +231,7 @@ func Test_PersonUnmarshalJSON(t *testing.T) {
"\n", ""), "\n", ""),
"\t", "") "\t", "")
err = sut.UnmarshalJSON([]byte(expectedStr)) err = sut.UnmarshalJSON([]byte(expectedStr))
if err != nil { require.NoError(t, err, "UnmarshalJSON() unexpected error: %q", err)
t.Errorf("UnmarshalJSON() unexpected error: %v", err)
}
result, _ := sut.MarshalJSON() result, _ := sut.MarshalJSON()
assert.JSONEq(t, expectedStr, string(result), "Expected string is not equal") 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) { func TestForgePersonValidation(t *testing.T) {
sut := new(ForgePerson) sut := new(ForgePerson)
sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
if res, _ := validation.IsValid(sut); !res { valid, _ := validation.IsValid(sut)
t.Errorf("sut expected to be valid: %v\n", sut.Validate()) assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
}
} }
func TestAsloginName(t *testing.T) { func TestAsloginName(t *testing.T) {

View file

@ -58,7 +58,7 @@ func TestActorIdValidation(t *testing.T) {
sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/" sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/"
result := sut.Validate() result := sut.Validate()
assert.Len(t, result, 1) 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 = ActorID{}
sut.ID = "1" sut.ID = "1"

View file

@ -9,6 +9,7 @@ import (
"forgejo.org/modules/validation" "forgejo.org/modules/validation"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
"github.com/stretchr/testify/assert"
) )
func Test_UserActivityNoteValidation(t *testing.T) { 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") sut.URL = ap.IRI("example.org/user-id/57")
if res, _ := validation.IsValid(sut); !res { valid, _ := validation.IsValid(sut)
t.Errorf("sut expected to be valid: %v\n", sut.Validate()) assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate())
}
} }

View file

@ -45,7 +45,7 @@ func IsValid(v Validateable) (bool, error) {
func ValidateIDExists(value ap.Item, name string) []string { func ValidateIDExists(value ap.Item, name string) []string {
if value == nil { 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) return ValidateNotEmpty(value.GetID().String(), name)
} }
@ -76,12 +76,12 @@ func ValidateNotEmpty(value any, name string) []string {
if isValid { if isValid {
return []string{} 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 { func ValidateMaxLen(value string, maxLen int, name string) []string {
if utf8.RuneCountInString(value) > maxLen { 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{} return []string{}
} }

View file

@ -6,9 +6,12 @@ package activitypub
import ( import (
"net/http" "net/http"
"forgejo.org/models/activities"
"forgejo.org/modules/activitypub" "forgejo.org/modules/activitypub"
"forgejo.org/modules/forgefed"
"forgejo.org/modules/log" "forgejo.org/modules/log"
"forgejo.org/modules/web" "forgejo.org/modules/web"
"forgejo.org/routers/api/v1/utils"
"forgejo.org/services/context" "forgejo.org/services/context"
"forgejo.org/services/convert" "forgejo.org/services/convert"
"forgejo.org/services/federation" "forgejo.org/services/federation"
@ -78,3 +81,162 @@ func PersonInbox(ctx *context.APIContext) {
} }
responseServiceResult(ctx, result) 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)
}
}

View file

@ -844,6 +844,11 @@ func Routes() *web.Route {
activitypub.ReqHTTPUserSignature(), activitypub.ReqHTTPUserSignature(),
bind(ap.Activity{}), bind(ap.Activity{}),
activitypub.PersonInbox) 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()) }, context.UserIDAssignmentAPI(), checkTokenPublicOnly())
m.Group("/actor", func() { m.Group("/actor", func() {
m.Get("", activitypub.Actor) m.Get("", activitypub.Actor)

View file

@ -13,3 +13,10 @@ type swaggerResponseActivityPub struct {
// in:body // in:body
Body api.ActivityPub `json:"body"` Body api.ActivityPub `json:"body"`
} }
// Personfeed
// swagger:response PersonFeed
type swaggerResponsePersonFeed struct {
// in:body
Body []api.APPersonFollowItem `json:"body"`
}

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

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

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

View file

@ -5,21 +5,56 @@ package federation
import ( import (
"context" "context"
"net/http"
"forgejo.org/models/user" "forgejo.org/models/user"
"forgejo.org/modules/forgefed"
"forgejo.org/modules/log" "forgejo.org/modules/log"
context_service "forgejo.org/services/context"
ap "github.com/go-ap/activitypub" ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
) )
func ProcessPersonInbox(ctx context.Context, user *user.User, activity *ap.Activity) (ServiceResult, error) { func ProcessPersonInbox(ctx context.Context, user *user.User, activity *ap.Activity) (ServiceResult, error) {
switch activity.Type { switch activity.Type {
case ap.CreateType:
return processPersonInboxCreate(ctx, user, activity)
case ap.FollowType: case ap.FollowType:
return processPersonFollow(ctx, user, activity) return processPersonFollow(ctx, user, activity)
case ap.UndoType: case ap.UndoType:
return processPersonInboxUndo(ctx, user, activity) return processPersonInboxUndo(ctx, user, activity)
case ap.AcceptType:
return processPersonInboxAccept(activity)
} }
log.Error("Unsupported PersonInbox activity: %v", activity.Type) log.Error("Unsupported PersonInbox activity: %v", activity.Type)
return ServiceResult{}, NewErrNotAcceptablef("unsupported 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,
})
}

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

View file

@ -19,6 +19,7 @@ import (
"forgejo.org/modules/repository" "forgejo.org/modules/repository"
"forgejo.org/modules/setting" "forgejo.org/modules/setting"
"forgejo.org/modules/util" "forgejo.org/modules/util"
federation_service "forgejo.org/services/federation"
notify_service "forgejo.org/services/notify" 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 { 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 { if err != nil {
return err 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 { 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 { if err != nil {
return err 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) { func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) {

View file

@ -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": { "/activitypub/user-id/{user-id}/inbox": {
"post": { "post": {
"produces": [ "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": { "/admin/cron": {
"get": { "get": {
"produces": [ "produces": [
@ -21121,6 +21216,28 @@
}, },
"x-go-package": "forgejo.org/services/context" "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": { "AccessToken": {
"type": "object", "type": "object",
"title": "AccessToken represents an API access token.", "title": "AccessToken represents an API access token.",
@ -29566,6 +29683,15 @@
} }
} }
}, },
"PersonFeed": {
"description": "Personfeed",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/APPersonFollowItem"
}
}
},
"PublicKey": { "PublicKey": {
"description": "PublicKey", "description": "PublicKey",
"schema": { "schema": {

View file

@ -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")
})
}

View file

@ -39,9 +39,6 @@ func TestActivityPubPerson(t *testing.T) {
localUserName := "user2" localUserName := "user2"
localUserURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", localUrl, localUserID) 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 // Unsigned request
t.Run("UnsignedRequest", func(t *testing.T) { t.Run("UnsignedRequest", func(t *testing.T) {
req := NewRequest(t, "GET", localUserURL) req := NewRequest(t, "GET", localUserURL)
@ -111,3 +108,30 @@ func TestActivityPubPersonInbox(t *testing.T) {
assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) 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)
})
}