Add Person and Repository ActivityPub endpoints
parent
05a74e6e22
commit
d12fd434ba
2
go.mod
2
go.mod
|
@ -85,6 +85,7 @@ require (
|
|||
github.com/tstranex/u2f v1.0.0
|
||||
github.com/unrolled/render v1.4.1
|
||||
github.com/urfave/cli v1.22.9
|
||||
github.com/valyala/fastjson v1.6.3
|
||||
github.com/xanzy/go-gitlab v0.64.0
|
||||
github.com/yohcop/openid-go v1.0.0
|
||||
github.com/yuin/goldmark v1.4.12
|
||||
|
@ -256,7 +257,6 @@ require (
|
|||
github.com/toqueteos/webbrowser v1.2.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.10 // indirect
|
||||
github.com/unknwon/com v1.0.1 // indirect
|
||||
github.com/valyala/fastjson v1.6.3 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.1 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
|
|
|
@ -22,14 +22,15 @@ type Type int
|
|||
|
||||
// Note: new type must append to the end of list to maintain compatibility.
|
||||
const (
|
||||
NoType Type = iota
|
||||
Plain // 1
|
||||
LDAP // 2
|
||||
SMTP // 3
|
||||
PAM // 4
|
||||
DLDAP // 5
|
||||
OAuth2 // 6
|
||||
SSPI // 7
|
||||
NoType Type = iota
|
||||
Plain // 1
|
||||
LDAP // 2
|
||||
SMTP // 3
|
||||
PAM // 4
|
||||
DLDAP // 5
|
||||
OAuth2 // 6
|
||||
SSPI // 7
|
||||
Federated // 8
|
||||
)
|
||||
|
||||
// String returns the string name of the LoginType
|
||||
|
@ -178,6 +179,11 @@ func (source *Source) IsSSPI() bool {
|
|||
return source.Type == SSPI
|
||||
}
|
||||
|
||||
// IsFederated returns true of this source is of the Federated type.
|
||||
func (source *Source) IsFederated() bool {
|
||||
return source.Type == Federated
|
||||
}
|
||||
|
||||
// HasTLS returns true of this source supports TLS.
|
||||
func (source *Source) HasTLS() bool {
|
||||
hasTLSer, ok := source.Cfg.(HasTLSer)
|
||||
|
|
|
@ -17,7 +17,7 @@ var (
|
|||
ErrNameEmpty = errors.New("Name is empty")
|
||||
|
||||
// AlphaDashDotPattern characters prohibited in a user name (anything except A-Za-z0-9_.-)
|
||||
AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`)
|
||||
AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.@]`)
|
||||
)
|
||||
|
||||
// ErrNameReserved represents a "reserved name" error.
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package forgefed
|
||||
|
||||
import (
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
const (
|
||||
RepositoryType ap.ActivityVocabularyType = "Repository"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
ap.Actor
|
||||
// Team Collection of actors who have management/push access to the repository
|
||||
Team ap.Item `jsonld:"team,omitempty"`
|
||||
// Forks OrderedCollection of repositories that are forks of this repository
|
||||
Forks ap.Item `jsonld:"forks,omitempty"`
|
||||
}
|
||||
|
||||
// GetItemByType instantiates a new Repository object if the type matches
|
||||
// otherwise it defaults to existing activitypub package typer function.
|
||||
func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) {
|
||||
if typ == RepositoryType {
|
||||
return RepositoryNew(""), nil
|
||||
}
|
||||
return ap.GetItemByType(typ)
|
||||
}
|
||||
|
||||
// RepositoryNew initializes a Repository type actor
|
||||
func RepositoryNew(id ap.ID) *Repository {
|
||||
a := ap.ActorNew(id, RepositoryType)
|
||||
o := Repository{Actor: *a}
|
||||
o.Type = RepositoryType
|
||||
return &o
|
||||
}
|
||||
|
||||
func (r Repository) MarshalJSON() ([]byte, error) {
|
||||
b, err := r.Actor.MarshalJSON()
|
||||
if len(b) == 0 || err != nil {
|
||||
return make([]byte, 0), err
|
||||
}
|
||||
|
||||
b = b[:len(b)-1]
|
||||
if r.Team != nil {
|
||||
ap.WriteItemJSONProp(&b, "team", r.Team)
|
||||
}
|
||||
if r.Forks != nil {
|
||||
ap.WriteItemJSONProp(&b, "forks", r.Forks)
|
||||
}
|
||||
ap.Write(&b, '}')
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UnmarshalJSON(data []byte) error {
|
||||
p := fastjson.Parser{}
|
||||
val, err := p.ParseBytes(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.Team = ap.JSONGetItem(val, "team")
|
||||
r.Forks = ap.JSONGetItem(val, "forks")
|
||||
|
||||
return ap.OnActor(&r.Actor, func(a *ap.Actor) error {
|
||||
return ap.LoadActor(val, a)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
package forgefed
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func Test_GetItemByType(t *testing.T) {
|
||||
type testtt struct {
|
||||
typ ap.ActivityVocabularyType
|
||||
want ap.Item
|
||||
wantErr error
|
||||
}
|
||||
tests := map[string]testtt{
|
||||
"invalid type": {
|
||||
typ: ap.ActivityVocabularyType("invalidtype"),
|
||||
wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub
|
||||
},
|
||||
"Repository": {
|
||||
typ: RepositoryType,
|
||||
want: new(Repository),
|
||||
},
|
||||
"Person - fall back": {
|
||||
typ: ap.PersonType,
|
||||
want: new(ap.Person),
|
||||
},
|
||||
"Question - fall back": {
|
||||
typ: ap.QuestionType,
|
||||
want: new(ap.Question),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
maybeRepository, err := GetItemByType(tt.typ)
|
||||
if !reflect.DeepEqual(tt.wantErr, err) {
|
||||
t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ)
|
||||
}
|
||||
if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) {
|
||||
t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_RepositoryMarshalJSON(t *testing.T) {
|
||||
type testPair struct {
|
||||
item Repository
|
||||
want []byte
|
||||
wantErr error
|
||||
}
|
||||
|
||||
tests := map[string]testPair{
|
||||
"empty": {
|
||||
item: Repository{},
|
||||
want: nil,
|
||||
},
|
||||
"with ID": {
|
||||
item: Repository{
|
||||
Actor: ap.Actor{
|
||||
ID: "https://example.com/1",
|
||||
},
|
||||
Team: nil,
|
||||
},
|
||||
want: []byte(`{"id":"https://example.com/1"}`),
|
||||
},
|
||||
"with Team as IRI": {
|
||||
item: Repository{
|
||||
Team: ap.IRI("https://example.com/1"),
|
||||
},
|
||||
want: []byte(`{"team":"https://example.com/1"}`),
|
||||
},
|
||||
"with Team as IRIs": {
|
||||
item: Repository{
|
||||
Team: ap.ItemCollection{
|
||||
ap.IRI("https://example.com/1"),
|
||||
ap.IRI("https://example.com/2"),
|
||||
},
|
||||
},
|
||||
want: []byte(`{"team":["https://example.com/1","https://example.com/2"]}`),
|
||||
},
|
||||
"with Team as Object": {
|
||||
item: Repository{
|
||||
Team: ap.Object{ID: "https://example.com/1"},
|
||||
},
|
||||
want: []byte(`{"team":{"id":"https://example.com/1"}}`),
|
||||
},
|
||||
"with Team as slice of Objects": {
|
||||
item: Repository{
|
||||
Team: ap.ItemCollection{
|
||||
ap.Object{ID: "https://example.com/1"},
|
||||
ap.Object{ID: "https://example.com/2"},
|
||||
},
|
||||
},
|
||||
want: []byte(`{"team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`),
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_RepositoryUnmarshalJSON(t *testing.T) {
|
||||
type testPair struct {
|
||||
data []byte
|
||||
want *Repository
|
||||
wantErr error
|
||||
}
|
||||
|
||||
tests := map[string]testPair{
|
||||
"nil": {
|
||||
data: nil,
|
||||
wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
|
||||
},
|
||||
"empty": {
|
||||
data: []byte{},
|
||||
wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
|
||||
},
|
||||
"with Type": {
|
||||
data: []byte(`{"type":"Repository"}`),
|
||||
want: &Repository{
|
||||
Actor: ap.Actor{
|
||||
Type: RepositoryType,
|
||||
},
|
||||
},
|
||||
},
|
||||
"with Type and ID": {
|
||||
data: []byte(`{"id":"https://example.com/1","type":"Repository"}`),
|
||||
want: &Repository{
|
||||
Actor: ap.Actor{
|
||||
ID: "https://example.com/1",
|
||||
Type: RepositoryType,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := new(Repository)
|
||||
err := got.UnmarshalJSON(tt.data)
|
||||
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
|
||||
t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.want != nil && !reflect.DeepEqual(got, tt.want) {
|
||||
jGot, _ := json.Marshal(got)
|
||||
jWant, _ := json.Marshal(tt.want)
|
||||
t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func Follow(ctx context.Context, activity ap.Follow) {
|
||||
actorIRI := activity.Actor.GetID()
|
||||
objectIRI := activity.Object.GetID()
|
||||
actorIRISplit := strings.Split(actorIRI.String(), "/")
|
||||
objectIRISplit := strings.Split(objectIRI.String(), "/")
|
||||
actorName := actorIRISplit[len(actorIRISplit)-1] + "@" + actorIRISplit[2]
|
||||
objectName := objectIRISplit[len(objectIRISplit)-1]
|
||||
|
||||
err := FederatedUserNew(actorName, actorIRI)
|
||||
if err != nil {
|
||||
log.Warn("Couldn't create new user", err)
|
||||
}
|
||||
actorUser, err := user_model.GetUserByName(ctx, actorName)
|
||||
if err != nil {
|
||||
log.Warn("Couldn't find actor", err)
|
||||
}
|
||||
objectUser, err := user_model.GetUserByName(ctx, objectName)
|
||||
if err != nil {
|
||||
log.Warn("Couldn't find object", err)
|
||||
}
|
||||
|
||||
user_model.FollowUser(actorUser.ID, objectUser.ID)
|
||||
|
||||
accept := ap.AcceptNew(objectIRI, activity)
|
||||
accept.Actor = ap.Person{ID: objectIRI}
|
||||
accept.To = ap.ItemCollection{ap.IRI(actorIRI.String() + "/inbox")}
|
||||
accept.Object = activity
|
||||
|
||||
Send(objectUser, accept)
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/json"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func Fetch(iri *url.URL) (b []byte, err error) {
|
||||
req := httplib.NewRequest(iri.String(), http.MethodGet)
|
||||
req.Header("Accept", ActivityStreamsContentType)
|
||||
req.Header("User-Agent", "Gitea/"+setting.AppVer)
|
||||
resp, err := req.Response()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
|
||||
return
|
||||
}
|
||||
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
|
||||
return
|
||||
}
|
||||
|
||||
func Send(user *user_model.User, activity *ap.Activity) {
|
||||
body, err := activity.MarshalJSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var jsonmap map[string]interface{}
|
||||
err = json.Unmarshal(body, &jsonmap)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
jsonmap["@context"] = "https://www.w3.org/ns/activitystreams"
|
||||
body, _ = json.Marshal(jsonmap)
|
||||
|
||||
for _, to := range activity.To {
|
||||
client, _ := NewClient(user, setting.AppURL+"api/v1/activitypub/user/"+user.Name+"#main-key")
|
||||
resp, _ := client.Post(body, to.GetID().String())
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
|
||||
log.Debug(string(respBody))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func FederatedUserNew(name string, IRI ap.IRI) error {
|
||||
user := &user_model.User{
|
||||
Name: name,
|
||||
Email: name,
|
||||
LoginType: auth.Federated,
|
||||
Website: IRI.String(),
|
||||
}
|
||||
return user_model.CreateUser(user)
|
||||
}
|
|
@ -5,16 +5,21 @@
|
|||
package activitypub
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/forgefed"
|
||||
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/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/go-ap/jsonld"
|
||||
)
|
||||
|
||||
// Person function returns the Person actor for a user
|
||||
|
@ -23,7 +28,7 @@ func Person(ctx *context.APIContext) {
|
|||
// ---
|
||||
// summary: Returns the Person actor for a user
|
||||
// produces:
|
||||
// - application/json
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
|
@ -62,6 +67,11 @@ func Person(ctx *context.APIContext) {
|
|||
person.Inbox = ap.IRI(link + "/inbox")
|
||||
person.Outbox = ap.IRI(link + "/outbox")
|
||||
|
||||
person.Following = ap.IRI(link + "/following")
|
||||
person.Followers = ap.IRI(link + "/followers")
|
||||
|
||||
person.Liked = ap.IRI(link + "/liked")
|
||||
|
||||
person.PublicKey.ID = ap.IRI(link + "#main-key")
|
||||
person.PublicKey.Owner = ap.IRI(link)
|
||||
|
||||
|
@ -72,16 +82,7 @@ func Person(ctx *context.APIContext) {
|
|||
}
|
||||
person.PublicKey.PublicKeyPem = publicKeyPem
|
||||
|
||||
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
|
||||
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)
|
||||
}
|
||||
response(ctx, person)
|
||||
}
|
||||
|
||||
// PersonInbox function handles the incoming data for a user inbox
|
||||
|
@ -90,7 +91,7 @@ func PersonInbox(ctx *context.APIContext) {
|
|||
// ---
|
||||
// summary: Send to the inbox
|
||||
// produces:
|
||||
// - application/json
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
|
@ -98,9 +99,180 @@ func PersonInbox(ctx *context.APIContext) {
|
|||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.Federation.MaxSize))
|
||||
if err != nil {
|
||||
ctx.ServerError("Error reading request body", err)
|
||||
}
|
||||
|
||||
var activity ap.Activity
|
||||
activity.UnmarshalJSON(body)
|
||||
if activity.Type == ap.FollowType {
|
||||
activitypub.Follow(ctx, activity)
|
||||
} else {
|
||||
log.Warn("ActivityStreams type not supported", activity)
|
||||
ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PersonOutbox function returns the user's Outbox OrderedCollection
|
||||
func PersonOutbox(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox
|
||||
// ---
|
||||
// summary: Returns the Outbox OrderedCollection
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
|
||||
feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{
|
||||
RequestedUser: ctx.ContextUser,
|
||||
Actor: ctx.ContextUser,
|
||||
IncludePrivate: false,
|
||||
OnlyPerformedBy: true,
|
||||
IncludeDeleted: false,
|
||||
Date: ctx.FormString("date"),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("Couldn't fetch outbox", err)
|
||||
}
|
||||
|
||||
outbox := ap.OrderedCollectionNew(ap.IRI(link))
|
||||
for _, action := range feed {
|
||||
/*if action.OpType == ExampleType {
|
||||
activity := ap.ExampleNew()
|
||||
outbox.OrderedItems.Append(activity)
|
||||
}*/
|
||||
log.Debug(action.Content)
|
||||
}
|
||||
outbox.TotalItems = uint(len(outbox.OrderedItems))
|
||||
|
||||
response(ctx, outbox)
|
||||
}
|
||||
|
||||
// PersonFollowing function returns the user's Following Collection
|
||||
func PersonFollowing(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user/{username}/following activitypub activitypubPersonFollowing
|
||||
// ---
|
||||
// summary: Returns the Following Collection
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
|
||||
users, err := user_model.GetUserFollowing(ctx.ContextUser, utils.GetListOptions(ctx))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserFollowing", err)
|
||||
return
|
||||
}
|
||||
|
||||
following := ap.OrderedCollectionNew(ap.IRI(link))
|
||||
following.TotalItems = uint(len(users))
|
||||
|
||||
for _, user := range users {
|
||||
// TODO: handle non-Federated users
|
||||
person := ap.PersonNew(ap.IRI(user.Website))
|
||||
following.OrderedItems.Append(person)
|
||||
}
|
||||
|
||||
response(ctx, following)
|
||||
}
|
||||
|
||||
// PersonFollowers function returns the user's Followers Collection
|
||||
func PersonFollowers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonFollowers
|
||||
// ---
|
||||
// summary: Returns the Followers Collection
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
|
||||
users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserFollowers", err)
|
||||
return
|
||||
}
|
||||
|
||||
followers := ap.OrderedCollectionNew(ap.IRI(link))
|
||||
followers.TotalItems = uint(len(users))
|
||||
|
||||
for _, user := range users {
|
||||
person := ap.PersonNew(ap.IRI(user.Website))
|
||||
followers.OrderedItems.Append(person)
|
||||
}
|
||||
|
||||
response(ctx, followers)
|
||||
}
|
||||
|
||||
// PersonLiked function returns the user's Liked Collection
|
||||
func PersonLiked(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonLiked
|
||||
// ---
|
||||
// summary: Returns the Liked Collection
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
|
||||
repos, count, err := repo_model.SearchRepository(&repo_model.SearchRepoOptions{
|
||||
Actor: ctx.Doer,
|
||||
Private: ctx.IsSigned,
|
||||
StarredByID: ctx.ContextUser.ID,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserStarred", err)
|
||||
return
|
||||
}
|
||||
|
||||
liked := ap.OrderedCollectionNew(ap.IRI(link))
|
||||
liked.TotalItems = uint(count)
|
||||
|
||||
for _, repo := range repos {
|
||||
repo := forgefed.RepositoryNew(ap.IRI(strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/repo/" + repo.OwnerName + "/" + repo.Name))
|
||||
liked.OrderedItems.Append(repo)
|
||||
}
|
||||
|
||||
response(ctx, liked)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,198 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/models"
|
||||
"code.gitea.io/gitea/models/forgefed"
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/routers/api/v1/utils"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
// Repo function
|
||||
func Repo(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/repo/{username}/{reponame} activitypub activitypubRepo
|
||||
// ---
|
||||
// summary: Returns the repository
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: reponame
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
repo := forgefed.RepositoryNew(ap.IRI(link))
|
||||
|
||||
repo.Name = ap.NaturalLanguageValuesNew()
|
||||
err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name))
|
||||
if err != nil {
|
||||
ctx.ServerError("Set Name", err)
|
||||
return
|
||||
}
|
||||
|
||||
repo.AttributedTo = ap.IRI(strings.TrimSuffix(link, "/"+ctx.Repo.Repository.Name))
|
||||
|
||||
repo.Summary = ap.NaturalLanguageValuesNew()
|
||||
err = repo.Summary.Set("en", ap.Content(ctx.Repo.Repository.Description))
|
||||
if err != nil {
|
||||
ctx.ServerError("Set Description", err)
|
||||
return
|
||||
}
|
||||
|
||||
repo.Inbox = ap.IRI(link + "/inbox")
|
||||
repo.Outbox = ap.IRI(link + "/outbox")
|
||||
repo.Followers = ap.IRI(link + "/followers")
|
||||
repo.Team = ap.IRI(link + "/team")
|
||||
|
||||
response(ctx, repo)
|
||||
}
|
||||
|
||||
// RepoInbox function
|
||||
func RepoInbox(ctx *context.APIContext) {
|
||||
// swagger:operation POST /activitypub/repo/{username}/{reponame}/inbox activitypub activitypubRepoInbox
|
||||
// ---
|
||||
// summary: Send to the inbox
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: reponame
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
|
||||
body, err := io.ReadAll(ctx.Req.Body)
|
||||
if err != nil {
|
||||
ctx.ServerError("Error reading request body", err)
|
||||
}
|
||||
|
||||
var activity ap.Activity
|
||||
activity.UnmarshalJSON(body)
|
||||
if activity.Type == ap.FollowType {
|
||||
// activitypub.Follow(ctx, activity)
|
||||
} else {
|
||||
log.Warn("ActivityStreams type not supported", activity)
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RepoOutbox function
|
||||
func RepoOutbox(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/repo/{username}/outbox activitypub activitypubPersonOutbox
|
||||
// ---
|
||||
// summary: Returns the outbox
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: reponame
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
|
||||
feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{
|
||||
RequestedUser: ctx.ContextUser,
|
||||
Actor: ctx.ContextUser,
|
||||
IncludePrivate: false,
|
||||
OnlyPerformedBy: true,
|
||||
IncludeDeleted: false,
|
||||
Date: ctx.FormString("date"),
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("Couldn't fetch outbox", err)
|
||||
}
|
||||
|
||||
outbox := ap.OrderedCollectionNew(ap.IRI(link))
|
||||
for _, action := range feed {
|
||||
/*if action.OpType == ExampleType {
|
||||
activity := ap.ExampleNew()
|
||||
outbox.OrderedItems.Append(activity)
|
||||
}*/
|
||||
log.Debug(action.Content)
|
||||
}
|
||||
outbox.TotalItems = uint(len(outbox.OrderedItems))
|
||||
|
||||
response(ctx, outbox)
|
||||
}
|
||||
|
||||
// RepoFollowers function
|
||||
func RepoFollowers(ctx *context.APIContext) {
|
||||
// swagger:operation GET /activitypub/repo/{username}/{reponame}/followers activitypub activitypubRepoFollowers
|
||||
// ---
|
||||
// summary: Returns the followers collection
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
// parameters:
|
||||
// - name: username
|
||||
// in: path
|
||||
// description: username of the user
|
||||
// type: string
|
||||
// required: true
|
||||
// - name: reponame
|
||||
// in: path
|
||||
// description: name of the repository
|
||||
// type: string
|
||||
// required: true
|
||||
// responses:
|
||||
// "200":
|
||||
// "$ref": "#/responses/ActivityPub"
|
||||
|
||||
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
|
||||
|
||||
users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx))
|
||||
if err != nil {
|
||||
ctx.ServerError("GetUserFollowers", err)
|
||||
return
|
||||
}
|
||||
|
||||
followers := ap.OrderedCollectionNew(ap.IRI(link))
|
||||
followers.TotalItems = uint(len(users))
|
||||
|
||||
for _, user := range users {
|
||||
person := ap.PersonNew(ap.IRI(user.Website))
|
||||
followers.OrderedItems.Append(person)
|
||||
}
|
||||
|
||||
response(ctx, followers)
|
||||
}
|
|
@ -9,13 +9,11 @@ import (
|
|||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/modules/activitypub"
|
||||
gitea_context "code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
|
@ -44,24 +42,6 @@ func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err
|
|||
return
|
||||
}
|
||||
|
||||
func fetch(iri *url.URL) (b []byte, err error) {
|
||||
req := httplib.NewRequest(iri.String(), http.MethodGet)
|
||||
req.Header("Accept", activitypub.ActivityStreamsContentType)
|
||||
req.Header("User-Agent", "Gitea/"+setting.AppVer)
|
||||
resp, err := req.Response()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
|
||||
return
|
||||
}
|
||||
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
|
||||
return
|
||||
}
|
||||
|
||||
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
|
||||
r := ctx.Req
|
||||
|
||||
|
@ -76,7 +56,7 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er
|
|||
return
|
||||
}
|
||||
// 2. Fetch the public key of the other actor
|
||||
b, err := fetch(idIRI)
|
||||
b, err := activitypub.Fetch(idIRI)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/activitypub"
|
||||
"code.gitea.io/gitea/modules/context"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/go-ap/jsonld"
|
||||
)
|
||||
|
||||
func response(ctx *context.APIContext, v interface{}) {
|
||||
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(v)
|
||||
if err != nil {
|
||||
ctx.ServerError("Marshal", 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)
|
||||
}
|
||||
}
|
|
@ -648,7 +648,17 @@ func Routes() *web.Route {
|
|||
m.Group("/user/{username}", func() {
|
||||
m.Get("", activitypub.Person)
|
||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
||||
m.Get("/outbox", activitypub.PersonOutbox)
|
||||
m.Get("/following", activitypub.PersonFollowing)
|
||||
m.Get("/followers", activitypub.PersonFollowers)
|
||||
m.Get("/liked", activitypub.PersonLiked)
|
||||
}, context_service.UserAssignmentAPI())
|
||||
m.Group("/repo/{username}/{reponame}", func() {
|
||||
m.Get("", activitypub.Repo)
|
||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.RepoInbox)
|
||||
m.Get("/outbox", activitypub.RepoOutbox)
|
||||
m.Get("/followers", activitypub.RepoFollowers)
|
||||
}, repoAssignment())
|
||||
})
|
||||
}
|
||||
m.Get("/signing-key.gpg", misc.SigningKey)
|
||||
|
|
|
@ -36,6 +36,12 @@ func Profile(ctx *context.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if strings.Contains(ctx.ContextUser.Name, "@") {
|
||||
ctx.Resp.Header().Add("Location", ctx.ContextUser.Website)
|
||||
ctx.Resp.WriteHeader(http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.ContextUser.IsOrganization() {
|
||||
org.Home(ctx)
|
||||
return
|
||||
|
|
|
@ -29,6 +29,7 @@ type webfingerLink struct {
|
|||
Rel string `json:"rel,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Href string `json:"href,omitempty"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Titles map[string]string `json:"titles,omitempty"`
|
||||
Properties map[string]interface{} `json:"properties,omitempty"`
|
||||
}
|
||||
|
@ -107,6 +108,10 @@ func WebfingerQuery(ctx *context.Context) {
|
|||
Type: "application/activity+json",
|
||||
Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
|
||||
},
|
||||
{
|
||||
Rel: "http://ostatus.org/schema/1.0/subscribe",
|
||||
Template: appURL.String() + "api/v1/authorize_interaction?uri={uri}",
|
||||
},
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
|
|
|
@ -23,10 +23,142 @@
|
|||
},
|
||||
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
|
||||
"paths": {
|
||||
"/activitypub/repo/{username}/outbox": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Returns the outbox",
|
||||
"operationId": "activitypubPersonOutbox",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repository",
|
||||
"name": "reponame",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/repo/{username}/{reponame}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Returns the repository",
|
||||
"operationId": "activitypubRepo",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repository",
|
||||
"name": "reponame",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/repo/{username}/{reponame}/followers": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Returns the followers collection",
|
||||
"operationId": "activitypubRepoFollowers",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repository",
|
||||
"name": "reponame",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/repo/{username}/{reponame}/inbox": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Send to the inbox",
|
||||
"operationId": "activitypubRepoInbox",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "name of the repository",
|
||||
"name": "reponame",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"$ref": "#/responses/empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user/{username}": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
|
@ -49,10 +181,62 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user/{username}/followers": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Returns the Liked Collection",
|
||||
"operationId": "activitypubPersonLiked",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user/{username}/following": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Returns the Following Collection",
|
||||
"operationId": "activitypubPersonFollowing",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user/{username}/inbox": {
|
||||
"post": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
|
@ -75,6 +259,32 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/activitypub/user/{username}/outbox": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/activity+json"
|
||||
],
|
||||
"tags": [
|
||||
"activitypub"
|
||||
],
|
||||
"summary": "Returns the Outbox OrderedCollection",
|
||||
"operationId": "activitypubPersonOutbox",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "username of the user",
|
||||
"name": "username",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/ActivityPub"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/cron": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
Loading…
Reference in New Issue