diff --git a/models/forgefed/activity.go b/models/forgefed/activity.go index 4016b71b38..b0d17b9a9c 100644 --- a/models/forgefed/activity.go +++ b/models/forgefed/activity.go @@ -6,6 +6,7 @@ package forgefed import ( "time" + "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/validation" ap "github.com/go-ap/activitypub" @@ -18,6 +19,21 @@ type ForgeLike struct { ap.Activity } +func NewForgeLike(ctx *context.APIContext) (ForgeLike, error) { + result := ForgeLike{} + actorIRI := ctx.Repo.Owner.APAPIURL() + objectIRI := ctx.Repo.Repository.APAPIURL() + result.Type = ap.LikeType + // ToDo: Would validating the source by Actor.Type field make sense? + result.Actor = ap.ActorNew(ap.IRI(actorIRI), "ForgejoUser") // Thats us, a User + result.Object = ap.ObjectNew(ap.ActivityVocabularyType(objectIRI)) // Thats them, a Repository + result.StartTime = time.Now() + if valid, err := validation.IsValid(result); !valid { + return ForgeLike{}, err + } + return result, nil +} + func (like ForgeLike) MarshalJSON() ([]byte, error) { return like.Activity.MarshalJSON() } diff --git a/models/repo/repo.go b/models/repo/repo.go index af3488b8f6..2cd3b51b6e 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -346,6 +346,11 @@ func (repo *Repository) APIURL() string { return setting.AppURL + "api/v1/repos/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name) } +// APAPIURL returns the activitypub repository API URL +func (repo *Repository) APAPIURL() string { + return setting.AppURL + "api/v1/activitypub/repository-id/" + url.PathEscape(string(repo.ID)) +} + // GetCommitsCountCacheKey returns cache key used for commits count caching. func (repo *Repository) GetCommitsCountCacheKey(contextName string, isRef bool) string { var prefix string diff --git a/models/user/user.go b/models/user/user.go index e016ff5fbd..3e47bfccc8 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -301,6 +301,11 @@ func (u *User) HTMLURL() string { return setting.AppURL + url.PathEscape(u.Name) } +// APAPIURL returns the IRI to the api endpoint of the user +func (u *User) APAPIURL() string { + return setting.AppURL + url.PathEscape("api/v1/activitypub/user-id/") + url.PathEscape(string(u.ID)) +} + // OrganisationLink returns the organization sub page link. func (u *User) OrganisationLink() string { return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) diff --git a/modules/forgefed/federation_service.go b/modules/forgefed/federation_service.go index 8b5e0714bf..4c89a43c64 100644 --- a/modules/forgefed/federation_service.go +++ b/modules/forgefed/federation_service.go @@ -22,6 +22,14 @@ import ( "github.com/google/uuid" ) +// ToDo: May need to change the name to reflect workings of function better +// LikeActivity receives a ForgeLike activity and does the following: +// Validation of the activity +// Creation of a (remote) federationHost if not existing +// Creation of a forgefed Person if not existing +// Validation of incoming RepositoryID against Local RepositoryID +// Star the repo if it wasn't already stared +// Do some mitigation against out of order attacks func LikeActivity(ctx *context.APIContext, form any, repositoryID int64) (int, string, error) { activity := form.(*forgefed.ForgeLike) if res, err := validation.IsValid(activity); !res { @@ -37,7 +45,7 @@ func LikeActivity(ctx *context.APIContext, form any, repositoryID int64) (int, s } federationHost, err := forgefed.FindFederationHostByFqdn(ctx, rawActorID.Host) if err != nil { - return http.StatusInternalServerError, "Could not loading FederationHost", err + return http.StatusInternalServerError, "Could not load FederationHost", err } if federationHost == nil { result, err := CreateFederationHostFromAP(ctx, rawActorID) diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 677c2f3b9d..6069714410 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -157,6 +157,10 @@ func IsValidFederatedRepoURLList(urls string) bool { return true } +func IsOfValidLength(str string) bool { + return len(str) <= 2048 +} + var ( validUsernamePatternWithDots = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`) validUsernamePatternWithoutDots = regexp.MustCompile(`^[\da-zA-Z][-\w]*$`) diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go index 24b1ce6682..405450aa1c 100644 --- a/modules/validation/validatable.go +++ b/modules/validation/validatable.go @@ -46,7 +46,7 @@ func ValidateNotEmpty(value any, fieldName string) []string { if isValid { return []string{} } - return []string{fmt.Sprintf("Field %v may not be empty", fieldName)} + return []string{fmt.Sprintf("Field %v should not be empty", fieldName)} } func ValidateMaxLen(value string, maxLen int, fieldName string) []string { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 31b6b7c9f4..6d56bb36ec 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -898,7 +898,7 @@ func Routes() *web.Route { }, context_service.UserIDAssignmentAPI()) m.Group("/repository-id/{repository-id}", func() { m.Get("", activitypub.Repository) - m.Post("/inbox", // ToDo: Post or Put? + m.Post("/inbox", // TODO: bind ativities here bind(forgefed.ForgeLike{}), // TODO: activitypub.ReqHTTPSignature(), diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 2659789ddd..a07407ffc3 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -7,12 +7,16 @@ package user import ( std_context "context" "net/http" + "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/forgefed" access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/convert" @@ -160,6 +164,32 @@ func Star(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "StarRepo", err) return } + if setting.Federation.Enabled { + + likeActivity, err := forgefed.NewForgeLike(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + return + } + + json, err := likeActivity.MarshalJSON() + if err != nil { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + return + } + + apclient, err := activitypub.NewClient(ctx, ctx.Doer, ctx.Doer.APAPIURL()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + return + } + // ToDo: Change this to the standalone table of FederatedRepos + for _, target := range strings.Split(ctx.Repo.Repository.FederationRepos, ";") { + apclient.Post([]byte(json), target) + } + + // Send to list of federated repos + } ctx.Status(http.StatusNoContent) } diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index bcb4de1b9e..624ae89b9c 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -192,11 +192,17 @@ func SettingsPost(ctx *context.Context) { return } + // ToDo: Use Federated Repo Struct & Update Federated Repo Table switch { // Allow clearing the field case form.FederationRepos == "": repo.FederationRepos = "" // Validate + case !validation.IsOfValidLength(form.FederationRepos): // ToDo: Use for public testing only. In production we might need longer strings. + ctx.Data["ERR_FederationRepos"] = true + ctx.Flash.Error("The given string was larger than 2048 bytes") + ctx.Redirect(repo.Link() + "/settings") + return case validation.IsValidFederatedRepoURL(form.FederationRepos): repo.FederationRepos = form.FederationRepos default: