Transient model for federated unstar (#6740)

This is the first step to https://codeberg.org/forgejo-contrib/federation/src/branch/main/FederationRoadmap.md#federated-unstar-wip

Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
Co-authored-by: ansgarz <ansgar.zwick@meissa.de>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/6740
Reviewed-by: Otto <otto@codeberg.org>
Co-authored-by: Mirco <mirco.zachmann@meissa.de>
Co-committed-by: Mirco <mirco.zachmann@meissa.de>
This commit is contained in:
Mirco 2025-02-11 12:49:32 +00:00 committed by Otto
parent 0dcc72dcd9
commit 179f85cf49
7 changed files with 385 additions and 28 deletions

View file

@ -87,6 +87,9 @@ code.gitea.io/gitea/modules/eventsource
Event.String Event.String
code.gitea.io/gitea/modules/forgefed code.gitea.io/gitea/modules/forgefed
NewForgeUndoLike
ForgeUndoLike.UnmarshalJSON
ForgeUndoLike.Validate
GetItemByType GetItemByType
JSONUnmarshalerFn JSONUnmarshalerFn
NotEmpty NotEmpty

View file

@ -21,8 +21,8 @@ type ForgeLike struct {
func NewForgeLike(actorIRI, objectIRI string, startTime time.Time) (ForgeLike, error) { func NewForgeLike(actorIRI, objectIRI string, startTime time.Time) (ForgeLike, error) {
result := ForgeLike{} result := ForgeLike{}
result.Type = ap.LikeType result.Type = ap.LikeType
result.Actor = ap.IRI(actorIRI) // That's us, a User result.Actor = ap.IRI(actorIRI)
result.Object = ap.IRI(objectIRI) // That's them, a Repository result.Object = ap.IRI(objectIRI)
result.StartTime = startTime result.StartTime = startTime
if valid, err := validation.IsValid(result); !valid { if valid, err := validation.IsValid(result); !valid {
return ForgeLike{}, err return ForgeLike{}, err
@ -46,20 +46,23 @@ func (like ForgeLike) Validate() []string {
var result []string var result []string
result = append(result, validation.ValidateNotEmpty(string(like.Type), "type")...) result = append(result, validation.ValidateNotEmpty(string(like.Type), "type")...)
result = append(result, validation.ValidateOneOf(string(like.Type), []any{"Like"}, "type")...) result = append(result, validation.ValidateOneOf(string(like.Type), []any{"Like"}, "type")...)
if like.Actor == nil { if like.Actor == nil {
result = append(result, "Actor should not be nil.") result = append(result, "Actor should not be nil.")
} else { } else {
result = append(result, validation.ValidateNotEmpty(like.Actor.GetID().String(), "actor")...) result = append(result, validation.ValidateNotEmpty(like.Actor.GetID().String(), "actor")...)
} }
if like.Object == nil {
result = append(result, "Object should not be nil.")
} else {
result = append(result, validation.ValidateNotEmpty(like.Object.GetID().String(), "object")...)
}
result = append(result, validation.ValidateNotEmpty(like.StartTime.String(), "startTime")...) result = append(result, validation.ValidateNotEmpty(like.StartTime.String(), "startTime")...)
if like.StartTime.IsZero() { if like.StartTime.IsZero() {
result = append(result, "StartTime was invalid.") result = append(result, "StartTime was invalid.")
} }
if like.Object == nil {
result = append(result, "Object should not be nil.")
} else {
result = append(result, validation.ValidateNotEmpty(like.Object.GetID().String(), "object")...)
}
return result return result
} }

View file

@ -16,11 +16,11 @@ import (
) )
func Test_NewForgeLike(t *testing.T) { func Test_NewForgeLike(t *testing.T) {
want := []byte(`{"type":"Like","startTime":"2024-03-07T00:00:00Z","actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1","object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}`)
actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1" actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"
objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1" objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1"
want := []byte(`{"type":"Like","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"}`) startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-07")
startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27")
sut, err := NewForgeLike(actorIRI, objectIRI, startTime) sut, err := NewForgeLike(actorIRI, objectIRI, startTime)
if err != nil { if err != nil {
t.Errorf("unexpected error: %v\n", err) t.Errorf("unexpected error: %v\n", err)
@ -84,7 +84,6 @@ func Test_LikeUnmarshalJSON(t *testing.T) {
wantErr error wantErr error
} }
//revive:disable
tests := map[string]testPair{ tests := map[string]testPair{
"with ID": { "with ID": {
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"}`),
@ -100,10 +99,9 @@ func Test_LikeUnmarshalJSON(t *testing.T) {
"invalid": { "invalid": {
item: []byte(`{"type":"Invalid","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"`), item: []byte(`{"type":"Invalid","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"`),
want: &ForgeLike{}, want: &ForgeLike{},
wantErr: fmt.Errorf("cannot parse JSON:"), wantErr: fmt.Errorf("cannot parse JSON"),
}, },
} }
//revive:enable
for name, test := range tests { for name, test := range tests {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
@ -120,7 +118,9 @@ func Test_LikeUnmarshalJSON(t *testing.T) {
} }
} }
func TestActivityValidation(t *testing.T) { func Test_ForgeLikeValidation(t *testing.T) {
// 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",
@ -130,35 +130,37 @@ func TestActivityValidation(t *testing.T) {
t.Errorf("sut expected to be valid: %v\n", sut.Validate()) t.Errorf("sut expected to be valid: %v\n", sut.Validate())
} }
// 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 sut.Validate()[0] != "type should not be empty" { if err := validateAndCheckError(sut, "type should not be empty"); err != nil {
t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) t.Error(err)
} }
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 sut.Validate()[0] != "Value bad-type is not contained in allowed values [Like]" { if err := validateAndCheckError(sut, "Value bad-type is not contained in allowed values [Like]"); err != nil {
t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) t.Error(err)
} }
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 sut.Validate()[0] != "StartTime was invalid." { if err := validateAndCheckError(sut, "StartTime was invalid."); err != nil {
t.Errorf("validation error expected but was: %v\n", sut.Validate()) t.Error(err)
} }
sut.UnmarshalJSON([]byte(`{"type":"Wrong", sut.UnmarshalJSON([]byte(`{"type":"Wrong",
"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 sut.Validate()[0] != "Value Wrong is not contained in allowed values [Like]" { if err := validateAndCheckError(sut, "Value Wrong is not contained in allowed values [Like]"); err != nil {
t.Errorf("validation error expected but was: %v\n", sut.Validate()) t.Error(err)
} }
} }
@ -166,6 +168,6 @@ 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 { if len(sut.Validate()) != 5 {
t.Errorf("5 validateion errors expected but was: %v\n", len(sut.Validate())) t.Errorf("5 validation errors expected but was: %v\n", len(sut.Validate()))
} }
} }

View file

@ -0,0 +1,80 @@
// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgefed
import (
"time"
"code.gitea.io/gitea/modules/validation"
ap "github.com/go-ap/activitypub"
)
// ForgeLike activity data type
// swagger:model
type ForgeUndoLike struct {
// swagger:ignore
ap.Activity
}
func NewForgeUndoLike(actorIRI, objectIRI string, startTime time.Time) (ForgeUndoLike, error) {
result := ForgeUndoLike{}
result.Type = ap.UndoType
result.Actor = ap.IRI(actorIRI)
result.StartTime = startTime
like := ap.Activity{}
like.Type = ap.LikeType
like.Actor = ap.IRI(actorIRI)
like.Object = ap.IRI(objectIRI)
result.Object = &like
if valid, err := validation.IsValid(result); !valid {
return ForgeUndoLike{}, err
}
return result, nil
}
func (undo *ForgeUndoLike) UnmarshalJSON(data []byte) error {
return undo.Activity.UnmarshalJSON(data)
}
func (undo ForgeUndoLike) Validate() []string {
var result []string
result = append(result, validation.ValidateNotEmpty(string(undo.Type), "type")...)
result = append(result, validation.ValidateOneOf(string(undo.Type), []any{"Undo"}, "type")...)
if undo.Actor == nil {
result = append(result, "Actor should not be nil.")
} else {
result = append(result, validation.ValidateNotEmpty(undo.Actor.GetID().String(), "actor")...)
}
result = append(result, validation.ValidateNotEmpty(undo.StartTime.String(), "startTime")...)
if undo.StartTime.IsZero() {
result = append(result, "StartTime was invalid.")
}
if undo.Object == nil {
result = append(result, "object should not be empty.")
} else if activity, ok := undo.Object.(*ap.Activity); !ok {
result = append(result, "object is not of type Activity")
} else {
result = append(result, validation.ValidateNotEmpty(string(activity.Type), "type")...)
result = append(result, validation.ValidateOneOf(string(activity.Type), []any{"Like"}, "type")...)
if activity.Actor == nil {
result = append(result, "Object.Actor should not be nil.")
} else {
result = append(result, validation.ValidateNotEmpty(activity.Actor.GetID().String(), "actor")...)
}
if activity.Object == nil {
result = append(result, "Object.Object should not be nil.")
} else {
result = append(result, validation.ValidateNotEmpty(activity.Object.GetID().String(), "object")...)
}
}
return result
}

View file

@ -0,0 +1,246 @@
// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgefed
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
"code.gitea.io/gitea/modules/validation"
ap "github.com/go-ap/activitypub"
)
func Test_NewForgeUndoLike(t *testing.T) {
actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"
objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1"
want := []byte(`{"type":"Undo","startTime":"2024-03-27T00:00:00Z",` +
`"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` +
`"object":{` +
`"type":"Like",` +
`"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` +
`"object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}}`)
startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27")
sut, err := NewForgeUndoLike(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())
}
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)
}
}
func Test_UndoLikeMarshalJSON(t *testing.T) {
type testPair struct {
item ForgeUndoLike
want []byte
wantErr error
}
startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27")
like, _ := NewForgeLike("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", "https://codeberg.org/api/v1/activitypub/repository-id/1", startTime)
tests := map[string]testPair{
"empty": {
item: ForgeUndoLike{},
want: nil,
},
"valid": {
item: ForgeUndoLike{
Activity: ap.Activity{
StartTime: startTime,
Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"),
Type: "Undo",
Object: like,
},
},
want: []byte(`{"type":"Undo",` +
`"startTime":"2024-03-27T00:00:00Z",` +
`"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` +
`"object":{` +
`"type":"Like",` +
`"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"}}`),
},
}
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\nwant %q", got, tt.want)
}
})
}
}
func Test_UndoLikeUnmarshalJSON(t *testing.T) {
type testPair struct {
item []byte
want *ForgeUndoLike
wantErr error
}
startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27")
like, _ := NewForgeLike("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", "https://codeberg.org/api/v1/activitypub/repository-id/1", startTime)
tests := map[string]testPair{
"valid": {
item: []byte(`{"type":"Undo",` +
`"startTime":"2024-03-27T00:00:00Z",` +
`"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` +
`"object":{` +
`"type":"Like",` +
`"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"}}`),
want: &ForgeUndoLike{
Activity: ap.Activity{
StartTime: startTime,
Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"),
Type: "Undo",
Object: like,
},
},
wantErr: nil,
},
"invalid": {
item: []byte(`invalid JSON`),
want: nil,
wantErr: fmt.Errorf("cannot parse JSON"),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got := new(ForgeUndoLike)
err := got.UnmarshalJSON(test.item)
if test.wantErr != nil {
if err == nil {
t.Errorf("UnmarshalJSON() error = nil, wantErr \"%v\"", test.wantErr)
} else if !strings.Contains(err.Error(), test.wantErr.Error()) {
t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr)
}
return
}
remarshalledgot, _ := got.MarshalJSON()
remarshalledwant, _ := test.want.MarshalJSON()
if !reflect.DeepEqual(remarshalledgot, remarshalledwant) {
t.Errorf("UnmarshalJSON() got = %#v\nwant %#v", got, test.want)
}
})
}
}
func TestActivityValidationUndo(t *testing.T) {
sut := new(ForgeUndoLike)
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":{
"type":"Like",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}}`))
if res, _ := validation.IsValid(sut); !res {
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
}
_ = sut.UnmarshalJSON([]byte(`
{"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":{
"type":"Like",
"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 {
t.Error(*err)
}
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"object":{
"type":"Like",
"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, "Actor should not be nil."); err != nil {
t.Error(*err)
}
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"actor":"string",
"object":{
"type":"Like",
"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, "Actor should not be nil."); err != nil {
t.Error(*err)
}
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"
}`))
if err := validateAndCheckError(sut, "object should not be empty."); err != nil {
t.Error(*err)
}
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":{
"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, "object is not of type Activity"); err != nil {
t.Error(*err)
}
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":{
"type":"Like",
"object":""}}`))
if err := validateAndCheckError(sut, "Object.Actor should not be nil."); err != nil {
t.Error(*err)
}
_ = sut.UnmarshalJSON([]byte(`
{"type":"Undo",
"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",
"object":{
"type":"Like",
"startTime":"2024-03-27T00:00:00Z",
"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"}}`))
if err := validateAndCheckError(sut, "Object.Object should not be nil."); err != nil {
t.Error(*err)
}
}

View file

@ -0,0 +1,23 @@
// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package forgefed
import (
"fmt"
"code.gitea.io/gitea/modules/validation"
)
func validateAndCheckError(subject validation.Validateable, expectedError string) *string {
errors := subject.Validate()
err := errors[0]
if len(errors) < 1 {
val := "Validation error should have been returned, but was not."
return &val
} else if err != expectedError {
val := fmt.Sprintf("Validation error should be [%v] but was: %v\n", expectedError, err)
return &val
}
return nil
}

View file

@ -70,8 +70,8 @@ func RepositoryInbox(ctx *context.APIContext) {
repository := ctx.Repo.Repository repository := ctx.Repo.Repository
log.Info("RepositoryInbox: repo: %v", repository) log.Info("RepositoryInbox: repo: %v", repository)
form := web.GetForm(ctx) form := web.GetForm(ctx)
// TODO: Decide between like/undo{like} activity
httpStatus, title, err := federation.ProcessLikeActivity(ctx, form, repository.ID) httpStatus, title, err := federation.ProcessLikeActivity(ctx, form, repository.ID)
if err != nil { if err != nil {
ctx.Error(httpStatus, title, err) ctx.Error(httpStatus, title, err)