mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-28 03:11:01 +00:00
Serve outbox
for Actor (#289)
* add statusesvisible convenience function * add minID + onlyPublic to account statuses get * move swagger collection stuff to common * start working on Outbox GETting * move functions into federationProcessor * outboxToASCollection * add statusesvisible convenience function * add minID + onlyPublic to account statuses get * move swagger collection stuff to common * start working on Outbox GETting * move functions into federationProcessor * outboxToASCollection * bit more work on outbox paging * wrapNoteInCreate function * test + hook up the processor functions * don't do prev + next links on empty reply * test get outbox through api * don't fail on no status entries * add outbox implementation doc * typo
This commit is contained in:
parent
26a95ad27d
commit
4b1d9d3780
38 changed files with 1851 additions and 470 deletions
47
docs/federation/behaviors/outbox.md
Normal file
47
docs/federation/behaviors/outbox.md
Normal file
|
@ -0,0 +1,47 @@
|
|||
# ActivityPub Outbox
|
||||
|
||||
GoToSocial implements Outboxes for Actors (ie., instance accounts) following the ActivityPub specification [here](https://www.w3.org/TR/activitypub/#outbox).
|
||||
|
||||
To get an [OrderedCollection](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) of Activities that an Actor has published recently, remote servers can do a `GET` request to a user's outbox. The address of this will be something like `https://example.org/users/whatever/outbox`.
|
||||
|
||||
The server will return an OrderedCollection of the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://example.org/users/whatever/outbox",
|
||||
"type": "OrderedCollection",
|
||||
"first": "https://example.org/users/whatever/outbox?page=true"
|
||||
}
|
||||
```
|
||||
|
||||
Note that the `OrderedCollection` itself contains no items. Callers must dereference the `first` page to start getting items. For example, a `GET` to `https://example.org/users/whatever/outbox?page=true` will produce something like the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "https://example.org/users/whatever/outbox?page=true",
|
||||
"type": "OrderedCollectionPage",
|
||||
"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||
"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||
"partOf": "https://example.org/users/whatever/outbox",
|
||||
"orderedItems": [
|
||||
"id": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7/activity",
|
||||
"type": "Create",
|
||||
"actor": "https://example.org/users/whatever",
|
||||
"published": "2021-10-18T20:06:18Z",
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"cc": [
|
||||
"https://example.org/users/whatever/followers"
|
||||
],
|
||||
"object": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `orderedItems` array will contain up to 30 entries. To get more entries beyond that, the caller can use the `next` link provided in the response.
|
||||
|
||||
Note that in the returned `orderedItems`, all activity types will be `Create`. On each activity, the `object` field will be the AP URI of an original public status created by the Actor who owns the Outbox (ie., a `Note` with `https://www.w3.org/ns/activitystreams#Public` in the `to` field, which is not a reply to another status). Callers can use the returned AP URIs to dereference the content of the notes.
|
||||
|
||||
Contrary to the ActivityPub spec, GoToSocial will deny requests that are not HTTP signed--that is, unauthenticated requests. This is consistent with GoToSocial's authentication policies for other federation API endpoints. This is to ensure that GoToSocial can deny requests from domains or users that have been blocked either by the GoToSocial instance itself (domain block), or by the individual owner of the Outbox.
|
|
@ -39,8 +39,12 @@ const (
|
|||
PinnedKey = "pinned"
|
||||
// MaxIDKey is for specifying the maximum ID of the status to retrieve.
|
||||
MaxIDKey = "max_id"
|
||||
// MediaOnlyKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
|
||||
MediaOnlyKey = "only_media"
|
||||
// MinIDKey is for specifying the minimum ID of the status to retrieve.
|
||||
MinIDKey = "min_id"
|
||||
// OnlyMediaKey is for specifying that only statuses with media should be returned in a list of returned statuses by an account.
|
||||
OnlyMediaKey = "only_media"
|
||||
// OnlyPublicKey is for specifying that only statuses with visibility public should be returned in a list of returned statuses by account.
|
||||
OnlyPublicKey = "only_public"
|
||||
|
||||
// IDKey is the key to use for retrieving account ID in requests
|
||||
IDKey = "id"
|
||||
|
|
|
@ -64,6 +64,12 @@ import (
|
|||
// Return only statuses *OLDER* than the given max status ID.
|
||||
// The status with the specified ID will not be included in the response.
|
||||
// in: query
|
||||
// - name: min_id
|
||||
// type: string
|
||||
// description: |-
|
||||
// Return only statuses *NEWER* than the given min status ID.
|
||||
// The status with the specified ID will not be included in the response.
|
||||
// in: query
|
||||
// required: false
|
||||
// - name: pinned_only
|
||||
// type: boolean
|
||||
|
@ -71,12 +77,18 @@ import (
|
|||
// default: false
|
||||
// in: query
|
||||
// required: false
|
||||
// - name: media_only
|
||||
// - name: only_media
|
||||
// type: boolean
|
||||
// description: Show only statuses with media attachments.
|
||||
// default: false
|
||||
// in: query
|
||||
// required: false
|
||||
// - name: only_public
|
||||
// type: boolean
|
||||
// description: Show only statuses with a privacy setting of 'public'.
|
||||
// default: false
|
||||
// in: query
|
||||
// required: false
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
|
@ -143,6 +155,12 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
|
|||
maxID = maxIDString
|
||||
}
|
||||
|
||||
minID := ""
|
||||
minIDString := c.Query(MinIDKey)
|
||||
if minIDString != "" {
|
||||
minID = minIDString
|
||||
}
|
||||
|
||||
pinnedOnly := false
|
||||
pinnedString := c.Query(PinnedKey)
|
||||
if pinnedString != "" {
|
||||
|
@ -156,7 +174,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
|
|||
}
|
||||
|
||||
mediaOnly := false
|
||||
mediaOnlyString := c.Query(MediaOnlyKey)
|
||||
mediaOnlyString := c.Query(OnlyMediaKey)
|
||||
if mediaOnlyString != "" {
|
||||
i, err := strconv.ParseBool(mediaOnlyString)
|
||||
if err != nil {
|
||||
|
@ -167,7 +185,19 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) {
|
|||
mediaOnly = i
|
||||
}
|
||||
|
||||
statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly)
|
||||
publicOnly := false
|
||||
publicOnlyString := c.Query(OnlyPublicKey)
|
||||
if mediaOnlyString != "" {
|
||||
i, err := strconv.ParseBool(publicOnlyString)
|
||||
if err != nil {
|
||||
l.Debugf("error parsing public only string: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse public only query param"})
|
||||
return
|
||||
}
|
||||
mediaOnly = i
|
||||
}
|
||||
|
||||
statuses, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
|
||||
if errWithCode != nil {
|
||||
l.Debugf("error from processor account statuses get: %s", errWithCode)
|
||||
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
|
||||
|
|
|
@ -57,3 +57,41 @@ func negotiateFormat(c *gin.Context) (string, error) {
|
|||
}
|
||||
return format, nil
|
||||
}
|
||||
|
||||
// SwaggerCollection represents an activitypub collection.
|
||||
// swagger:model swaggerCollection
|
||||
type SwaggerCollection struct {
|
||||
// ActivityStreams context.
|
||||
// example: https://www.w3.org/ns/activitystreams
|
||||
Context string `json:"@context"`
|
||||
// ActivityStreams ID.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
|
||||
ID string `json:"id"`
|
||||
// ActivityStreams type.
|
||||
// example: Collection
|
||||
Type string `json:"type"`
|
||||
// ActivityStreams first property.
|
||||
First SwaggerCollectionPage `json:"first"`
|
||||
// ActivityStreams last property.
|
||||
Last SwaggerCollectionPage `json:"last,omitempty"`
|
||||
}
|
||||
|
||||
// SwaggerCollectionPage represents one page of a collection.
|
||||
// swagger:model swaggerCollectionPage
|
||||
type SwaggerCollectionPage struct {
|
||||
// ActivityStreams ID.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?page=true
|
||||
ID string `json:"id"`
|
||||
// ActivityStreams type.
|
||||
// example: CollectionPage
|
||||
Type string `json:"type"`
|
||||
// Link to the next page.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?only_other_accounts=true&page=true
|
||||
Next string `json:"next"`
|
||||
// Collection this page belongs to.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
|
||||
PartOf string `json:"partOf"`
|
||||
// Items on this page.
|
||||
// example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"]
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
|
|
@ -436,7 +436,7 @@ func (suite *InboxPostTestSuite) TestPostDelete() {
|
|||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
|
||||
// no statuses from foss satan should be left in the database
|
||||
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", false, false)
|
||||
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", "", false, false, false)
|
||||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
suite.Empty(dbStatuses)
|
||||
|
||||
|
|
142
internal/api/s2s/user/outboxget.go
Normal file
142
internal/api/s2s/user/outboxget.go
Normal file
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OutboxGETHandler swagger:operation GET /users/{username}/outbox s2sOutboxGet
|
||||
//
|
||||
// Get the public outbox collection for an actor.
|
||||
//
|
||||
// Note that the response will be a Collection with a page as `first`, as shown below, if `page` is `false`.
|
||||
//
|
||||
// If `page` is `true`, then the response will be a single `CollectionPage` without the wrapping `Collection`.
|
||||
//
|
||||
// HTTP signature is required on the request.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - s2s/federation
|
||||
//
|
||||
// produces:
|
||||
// - application/activity+json
|
||||
//
|
||||
// parameters:
|
||||
// - name: username
|
||||
// type: string
|
||||
// description: Username of the account.
|
||||
// in: path
|
||||
// required: true
|
||||
// - name: page
|
||||
// type: boolean
|
||||
// description: Return response as a CollectionPage.
|
||||
// in: query
|
||||
// default: false
|
||||
// - name: min_id
|
||||
// type: string
|
||||
// description: Minimum ID of the next status, used for paging.
|
||||
// in: query
|
||||
// - name: max_id
|
||||
// type: string
|
||||
// description: Maximum ID of the next status, used for paging.
|
||||
// in: query
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/swaggerCollection"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '404':
|
||||
// description: not found
|
||||
func (m *Module) OutboxGETHandler(c *gin.Context) {
|
||||
l := logrus.WithFields(logrus.Fields{
|
||||
"func": "OutboxGETHandler",
|
||||
"url": c.Request.RequestURI,
|
||||
})
|
||||
|
||||
requestedUsername := c.Param(UsernameKey)
|
||||
if requestedUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"})
|
||||
return
|
||||
}
|
||||
|
||||
page := false
|
||||
pageString := c.Query(PageKey)
|
||||
if pageString != "" {
|
||||
i, err := strconv.ParseBool(pageString)
|
||||
if err != nil {
|
||||
l.Debugf("error parsing page string: %s", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse page query param"})
|
||||
return
|
||||
}
|
||||
page = i
|
||||
}
|
||||
|
||||
minID := ""
|
||||
minIDString := c.Query(MinIDKey)
|
||||
if minIDString != "" {
|
||||
minID = minIDString
|
||||
}
|
||||
|
||||
maxID := ""
|
||||
maxIDString := c.Query(MaxIDKey)
|
||||
if maxIDString != "" {
|
||||
maxID = maxIDString
|
||||
}
|
||||
|
||||
format, err := negotiateFormat(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotAcceptable, gin.H{"error": fmt.Sprintf("could not negotiate format with given Accept header(s): %s", err)})
|
||||
return
|
||||
}
|
||||
l.Tracef("negotiated format: %s", format)
|
||||
|
||||
ctx := transferContext(c)
|
||||
|
||||
outbox, errWithCode := m.processor.GetFediOutbox(ctx, requestedUsername, page, maxID, minID, c.Request.URL)
|
||||
if errWithCode != nil {
|
||||
l.Info(errWithCode.Error())
|
||||
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
|
||||
return
|
||||
}
|
||||
|
||||
b, mErr := json.Marshal(outbox)
|
||||
if mErr != nil {
|
||||
err := fmt.Errorf("could not marshal json: %s", mErr)
|
||||
l.Error(err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, format, b)
|
||||
}
|
206
internal/api/s2s/user/outboxget_test.go
Normal file
206
internal/api/s2s/user/outboxget_test.go
Normal file
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/s2s/user"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type OutboxGetTestSuite struct {
|
||||
UserStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *OutboxGetTestSuite) TestGetOutbox() {
|
||||
// the dereference we're gonna use
|
||||
derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts)
|
||||
signedRequest := derefRequests["foss_satan_dereference_zork_outbox"]
|
||||
targetAccount := suite.testAccounts["local_account_1"]
|
||||
|
||||
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
|
||||
federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
|
||||
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
|
||||
userModule := user.New(suite.config, processor).(*user.Module)
|
||||
|
||||
// setup request
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI, nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
|
||||
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
|
||||
|
||||
// we need to pass the context through signature check first to set appropriate values on it
|
||||
suite.securityModule.SignatureCheck(ctx)
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: user.UsernameKey,
|
||||
Value: targetAccount.Username,
|
||||
},
|
||||
}
|
||||
|
||||
// trigger the function being tested
|
||||
userModule.OutboxGETHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","first":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","id":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollection"}`, string(b))
|
||||
|
||||
m := make(map[string]interface{})
|
||||
err = json.Unmarshal(b, &m)
|
||||
suite.NoError(err)
|
||||
|
||||
t, err := streams.ToType(context.Background(), m)
|
||||
suite.NoError(err)
|
||||
|
||||
_, ok := t.(vocab.ActivityStreamsOrderedCollection)
|
||||
suite.True(ok)
|
||||
}
|
||||
|
||||
func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() {
|
||||
// the dereference we're gonna use
|
||||
derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts)
|
||||
signedRequest := derefRequests["foss_satan_dereference_zork_outbox_first"]
|
||||
targetAccount := suite.testAccounts["local_account_1"]
|
||||
|
||||
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
|
||||
federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
|
||||
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
|
||||
userModule := user.New(suite.config, processor).(*user.Module)
|
||||
|
||||
// setup request
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true", nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
|
||||
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
|
||||
|
||||
// we need to pass the context through signature check first to set appropriate values on it
|
||||
suite.securityModule.SignatureCheck(ctx)
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: user.UsernameKey,
|
||||
Value: targetAccount.Username,
|
||||
},
|
||||
}
|
||||
|
||||
// trigger the function being tested
|
||||
userModule.OutboxGETHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true","next":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026max_id=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":{"actor":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity","object":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T10:40:37Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"},"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","prev":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026min_id=01F8MHAMCHF6Y650WCRSCP4WMY","type":"OrderedCollectionPage"}`, string(b))
|
||||
|
||||
m := make(map[string]interface{})
|
||||
err = json.Unmarshal(b, &m)
|
||||
suite.NoError(err)
|
||||
|
||||
t, err := streams.ToType(context.Background(), m)
|
||||
suite.NoError(err)
|
||||
|
||||
_, ok := t.(vocab.ActivityStreamsOrderedCollectionPage)
|
||||
suite.True(ok)
|
||||
}
|
||||
|
||||
func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() {
|
||||
// the dereference we're gonna use
|
||||
derefRequests := testrig.NewTestDereferenceRequests(suite.testAccounts)
|
||||
signedRequest := derefRequests["foss_satan_dereference_zork_outbox_next"]
|
||||
targetAccount := suite.testAccounts["local_account_1"]
|
||||
|
||||
tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db)
|
||||
federator := testrig.NewTestFederator(suite.db, tc, suite.storage)
|
||||
processor := testrig.NewTestProcessor(suite.db, suite.storage, federator)
|
||||
userModule := user.New(suite.config, processor).(*user.Module)
|
||||
|
||||
// setup request
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.OutboxURI+"?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader)
|
||||
ctx.Request.Header.Set("Date", signedRequest.DateHeader)
|
||||
|
||||
// we need to pass the context through signature check first to set appropriate values on it
|
||||
suite.securityModule.SignatureCheck(ctx)
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: user.UsernameKey,
|
||||
Value: targetAccount.Username,
|
||||
},
|
||||
gin.Param{
|
||||
Key: user.MaxIDKey,
|
||||
Value: "01F8MHAMCHF6Y650WCRSCP4WMY",
|
||||
},
|
||||
}
|
||||
|
||||
// trigger the function being tested
|
||||
userModule.OutboxGETHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/the_mighty_zork/outbox?page=true\u0026maxID=01F8MHAMCHF6Y650WCRSCP4WMY","orderedItems":[],"partOf":"http://localhost:8080/users/the_mighty_zork/outbox","type":"OrderedCollectionPage"}`, string(b))
|
||||
|
||||
m := make(map[string]interface{})
|
||||
err = json.Unmarshal(b, &m)
|
||||
suite.NoError(err)
|
||||
|
||||
t, err := streams.ToType(context.Background(), m)
|
||||
suite.NoError(err)
|
||||
|
||||
_, ok := t.(vocab.ActivityStreamsOrderedCollectionPage)
|
||||
suite.True(ok)
|
||||
}
|
||||
|
||||
func TestOutboxGetTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(OutboxGetTestSuite))
|
||||
}
|
|
@ -75,7 +75,7 @@ import (
|
|||
// '200':
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/swaggerStatusRepliesCollection"
|
||||
// "$ref": "#/definitions/swaggerCollection"
|
||||
// '400':
|
||||
// description: bad request
|
||||
// '401':
|
||||
|
@ -158,39 +158,3 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) {
|
|||
|
||||
c.Data(http.StatusOK, format, b)
|
||||
}
|
||||
|
||||
// SwaggerStatusRepliesCollection represents a response to GET /users/{username}/statuses/{status}/replies.
|
||||
// swagger:model swaggerStatusRepliesCollection
|
||||
type SwaggerStatusRepliesCollection struct {
|
||||
// ActivityStreams context.
|
||||
// example: https://www.w3.org/ns/activitystreams
|
||||
Context string `json:"@context"`
|
||||
// ActivityStreams ID.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
|
||||
ID string `json:"id"`
|
||||
// ActivityStreams type.
|
||||
// example: Collection
|
||||
Type string `json:"type"`
|
||||
// ActivityStreams first property.
|
||||
First SwaggerStatusRepliesCollectionPage `json:"first"`
|
||||
}
|
||||
|
||||
// SwaggerStatusRepliesCollectionPage represents one page of a collection.
|
||||
// swagger:model swaggerStatusRepliesCollectionPage
|
||||
type SwaggerStatusRepliesCollectionPage struct {
|
||||
// ActivityStreams ID.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?page=true
|
||||
ID string `json:"id"`
|
||||
// ActivityStreams type.
|
||||
// example: CollectionPage
|
||||
Type string `json:"type"`
|
||||
// Link to the next page.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies?only_other_accounts=true&page=true
|
||||
Next string `json:"next"`
|
||||
// Collection this page belongs to.
|
||||
// example: https://example.org/users/some_user/statuses/106717595988259568/replies
|
||||
PartOf string `json:"partOf"`
|
||||
// Items on this page.
|
||||
// example: ["https://example.org/users/some_other_user/statuses/086417595981111564", "https://another.example.com/users/another_user/statuses/01FCN8XDV3YG7B4R42QA6YQZ9R"]
|
||||
Items []string `json:"items"`
|
||||
}
|
||||
|
|
|
@ -37,6 +37,8 @@ const (
|
|||
OnlyOtherAccountsKey = "only_other_accounts"
|
||||
// MinIDKey is for filtering status responses.
|
||||
MinIDKey = "min_id"
|
||||
// MaxIDKey is for filtering status responses.
|
||||
MaxIDKey = "max_id"
|
||||
// PageKey is for filtering status responses.
|
||||
PageKey = "page"
|
||||
|
||||
|
@ -50,6 +52,8 @@ const (
|
|||
UsersPublicKeyPath = UsersBasePathWithUsername + "/" + util.PublicKeyPath
|
||||
// UsersInboxPath is for serving POST requests to a user's inbox with the given username key.
|
||||
UsersInboxPath = UsersBasePathWithUsername + "/" + util.InboxPath
|
||||
// UsersOutboxPath is for serving GET requests to a user's outbox with the given username key.
|
||||
UsersOutboxPath = UsersBasePathWithUsername + "/" + util.OutboxPath
|
||||
// UsersFollowersPath is for serving GET request's to a user's followers list, with the given username key.
|
||||
UsersFollowersPath = UsersBasePathWithUsername + "/" + util.FollowersPath
|
||||
// UsersFollowingPath is for serving GET request's to a user's following list, with the given username key.
|
||||
|
@ -83,5 +87,6 @@ func (m *Module) Route(s router.Router) error {
|
|||
s.AttachHandler(http.MethodGet, UsersStatusPath, m.StatusGETHandler)
|
||||
s.AttachHandler(http.MethodGet, UsersPublicKeyPath, m.PublicKeyGETHandler)
|
||||
s.AttachHandler(http.MethodGet, UsersStatusRepliesPath, m.StatusRepliesGETHandler)
|
||||
s.AttachHandler(http.MethodGet, UsersOutboxPath, m.OutboxGETHandler)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ type Account interface {
|
|||
// then all statuses will be returned. If limit is set to 0, the size of the returned slice will not be limited. This can
|
||||
// be very memory intensive so you probably shouldn't do this!
|
||||
// In case of no entries, a 'no entries' error will be returned
|
||||
GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, Error)
|
||||
GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, Error)
|
||||
|
||||
GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)
|
||||
|
||||
|
|
|
@ -231,7 +231,7 @@ func (a *accountDB) CountAccountStatuses(ctx context.Context, accountID string)
|
|||
Count(ctx)
|
||||
}
|
||||
|
||||
func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, db.Error) {
|
||||
func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]*gtsmodel.Status, db.Error) {
|
||||
statuses := []*gtsmodel.Status{}
|
||||
|
||||
q := a.conn.
|
||||
|
@ -247,14 +247,22 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
|
|||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if pinnedOnly {
|
||||
q = q.Where("pinned = ?", true)
|
||||
if excludeReplies {
|
||||
q = q.WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id"))
|
||||
}
|
||||
|
||||
if maxID != "" {
|
||||
q = q.Where("id < ?", maxID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
q = q.Where("id > ?", minID)
|
||||
}
|
||||
|
||||
if pinnedOnly {
|
||||
q = q.Where("pinned = ?", true)
|
||||
}
|
||||
|
||||
if mediaOnly {
|
||||
q = q.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.
|
||||
|
@ -263,8 +271,8 @@ func (a *accountDB) GetAccountStatuses(ctx context.Context, accountID string, li
|
|||
})
|
||||
}
|
||||
|
||||
if excludeReplies {
|
||||
q = q.WhereGroup(" AND ", whereEmptyOrNull("in_reply_to_id"))
|
||||
if publicOnly {
|
||||
q = q.Where("visibility = ?", gtsmodel.VisibilityPublic)
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
|
|
|
@ -135,6 +135,19 @@ func (f *federatingDB) NewID(ctx context.Context, t vocab.Type) (idURL *url.URL,
|
|||
return idProp.GetIRI(), nil
|
||||
}
|
||||
}
|
||||
case ap.ActivityCreate:
|
||||
// CREATE
|
||||
// ID might already be set on a Create, so check it here and return it if it is
|
||||
create, ok := t.(vocab.ActivityStreamsCreate)
|
||||
if !ok {
|
||||
return nil, errors.New("newid: create couldn't be parsed into vocab.ActivityStreamsCreate")
|
||||
}
|
||||
idProp := create.GetJSONLDId()
|
||||
if idProp != nil {
|
||||
if idProp.IsIRI() {
|
||||
return idProp.GetIRI(), nil
|
||||
}
|
||||
}
|
||||
case ap.ActivityAnnounce:
|
||||
// ANNOUNCE aka BOOST
|
||||
// ID might already be set on an announce we've created, so check it here and return it if it is
|
||||
|
|
|
@ -38,8 +38,8 @@ func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form
|
|||
return p.accountProcessor.Update(ctx, authed.Account, form)
|
||||
}
|
||||
|
||||
func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) {
|
||||
return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly)
|
||||
func (p *processor) AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) {
|
||||
return p.accountProcessor.StatusesGet(ctx, authed.Account, targetAccountID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
|
||||
}
|
||||
|
||||
func (p *processor) AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode) {
|
||||
|
|
|
@ -50,7 +50,7 @@ type Processor interface {
|
|||
Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
|
||||
// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
|
||||
// the account given in authed.
|
||||
StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode)
|
||||
StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode)
|
||||
// FollowersGet fetches a list of the target account's followers.
|
||||
FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
|
||||
// FollowingGet fetches a list of the accounts that target account is following.
|
||||
|
|
|
@ -139,7 +139,7 @@ func (p *processor) Delete(ctx context.Context, account *gtsmodel.Account, origi
|
|||
var maxID string
|
||||
selectStatusesLoop:
|
||||
for {
|
||||
statuses, err := p.db.GetAccountStatuses(ctx, account.ID, 20, false, maxID, false, false)
|
||||
statuses, err := p.db.GetAccountStatuses(ctx, account.ID, 20, false, maxID, "", false, false, false)
|
||||
if err != nil {
|
||||
if err == db.ErrNoEntries {
|
||||
// no statuses left for this instance so we're done
|
||||
|
|
|
@ -28,7 +28,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode) {
|
||||
func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinnedOnly bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode) {
|
||||
if blocked, err := p.db.IsBlocked(ctx, requestingAccount.ID, targetAccountID, true); err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
} else if blocked {
|
||||
|
@ -37,7 +37,7 @@ func (p *processor) StatusesGet(ctx context.Context, requestingAccount *gtsmodel
|
|||
|
||||
apiStatuses := []apimodel.Status{}
|
||||
|
||||
statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, maxID, pinnedOnly, mediaOnly)
|
||||
statuses, err := p.db.GetAccountStatuses(ctx, targetAccountID, limit, excludeReplies, maxID, minID, pinnedOnly, mediaOnly, publicOnly)
|
||||
if err != nil {
|
||||
if err == db.ErrNoEntries {
|
||||
return apiStatuses, nil
|
||||
|
|
|
@ -20,418 +20,49 @@ package processing
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func (p *processor) GetFediUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
var requestedPerson vocab.ActivityStreamsPerson
|
||||
if util.IsPublicKeyPath(requestURL) {
|
||||
// if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key
|
||||
requestedPerson, err = p.tc.AccountToASMinimal(ctx, requestedAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else if util.IsUserPath(requestURL) {
|
||||
// if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
// if we're not already handshaking/dereferencing a remote account, dereference it now
|
||||
if !p.federator.Handshaking(ctx, requestedUsername, requestingAccountURI) {
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
}
|
||||
|
||||
requestedPerson, err = p.tc.AccountToAS(ctx, requestedAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else {
|
||||
return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path"))
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(requestedPerson)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return p.federationProcessor.GetUser(ctx, requestedUsername, requestURL)
|
||||
}
|
||||
|
||||
func (p *processor) GetFediFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
requestedAccountURI, err := url.Parse(requestedAccount.URI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
|
||||
}
|
||||
|
||||
requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err))
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(requestedFollowers)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return p.federationProcessor.GetFollowers(ctx, requestedUsername, requestURL)
|
||||
}
|
||||
|
||||
func (p *processor) GetFediFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
requestedAccountURI, err := url.Parse(requestedAccount.URI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
|
||||
}
|
||||
|
||||
requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err))
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(requestedFollowing)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return p.federationProcessor.GetFollowing(ctx, requestedUsername, requestURL)
|
||||
}
|
||||
|
||||
func (p *processor) GetFediStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
// authorize the request:
|
||||
// 1. check if a block exists between the requester and the requestee
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
// get the status out of the database here
|
||||
s := >smodel.Status{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{
|
||||
{Key: "id", Value: requestedStatusID},
|
||||
{Key: "account_id", Value: requestedAccount.ID},
|
||||
}, s); err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
|
||||
}
|
||||
|
||||
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
// requester is authorized to view the status, so convert it to AP representation and serialize it
|
||||
asStatus, err := p.tc.StatusToAS(ctx, s)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(asStatus)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return p.federationProcessor.GetStatus(ctx, requestedUsername, requestedStatusID, requestURL)
|
||||
}
|
||||
|
||||
func (p *processor) GetFediStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
return p.federationProcessor.GetStatusReplies(ctx, requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, requestURL)
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
// authorize the request:
|
||||
// 1. check if a block exists between the requester and the requestee
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
// get the status out of the database here
|
||||
s := >smodel.Status{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{
|
||||
{Key: "id", Value: requestedStatusID},
|
||||
{Key: "account_id", Value: requestedAccount.ID},
|
||||
}, s); err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
|
||||
}
|
||||
|
||||
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// now there are three scenarios:
|
||||
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
|
||||
// 2. we're asked for a page but only_other_accounts has not been set in the query -- so we should just return the first page of the collection, with no items.
|
||||
// 3. we're asked for a page, and only_other_accounts has been set, and min_id has optionally been set -- so we need to return some actual items!
|
||||
|
||||
if !page {
|
||||
// scenario 1
|
||||
|
||||
// get the collection
|
||||
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
data, err = streams.Serialize(collection)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else if page && requestURL.Query().Get("only_other_accounts") == "" {
|
||||
// scenario 2
|
||||
|
||||
// get the collection
|
||||
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
// but only return the first page
|
||||
data, err = streams.Serialize(collection.GetActivityStreamsFirst().GetActivityStreamsCollectionPage())
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else {
|
||||
// scenario 3
|
||||
// get immediate children
|
||||
replies, err := p.db.GetStatusChildren(ctx, s, true, minID)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// filter children and extract URIs
|
||||
replyURIs := map[string]*url.URL{}
|
||||
for _, r := range replies {
|
||||
// only show public or unlocked statuses as replies
|
||||
if r.Visibility != gtsmodel.VisibilityPublic && r.Visibility != gtsmodel.VisibilityUnlocked {
|
||||
continue
|
||||
}
|
||||
|
||||
// respect onlyOtherAccounts parameter
|
||||
if onlyOtherAccounts && r.AccountID == requestedAccount.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
// only show replies that the status owner can see
|
||||
visibleToStatusOwner, err := p.filter.StatusVisible(ctx, r, requestedAccount)
|
||||
if err != nil || !visibleToStatusOwner {
|
||||
continue
|
||||
}
|
||||
|
||||
// only show replies that the requester can see
|
||||
visibleToRequester, err := p.filter.StatusVisible(ctx, r, requestingAccount)
|
||||
if err != nil || !visibleToRequester {
|
||||
continue
|
||||
}
|
||||
|
||||
rURI, err := url.Parse(r.URI)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
replyURIs[r.ID] = rURI
|
||||
}
|
||||
|
||||
repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, s, onlyOtherAccounts, minID, replyURIs)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
data, err = streams.Serialize(repliesPage)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
func (p *processor) GetFediOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
return p.federationProcessor.GetOutbox(ctx, requestedUsername, page, maxID, minID, requestURL)
|
||||
}
|
||||
|
||||
func (p *processor) GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// return the webfinger representation
|
||||
return &apimodel.WellKnownResponse{
|
||||
Subject: fmt.Sprintf("acct:%s@%s", requestedAccount.Username, p.config.AccountDomain),
|
||||
Aliases: []string{
|
||||
requestedAccount.URI,
|
||||
requestedAccount.URL,
|
||||
},
|
||||
Links: []apimodel.Link{
|
||||
{
|
||||
Rel: "http://webfinger.net/rel/profile-page",
|
||||
Type: "text/html",
|
||||
Href: requestedAccount.URL,
|
||||
},
|
||||
{
|
||||
Rel: "self",
|
||||
Type: "application/activity+json",
|
||||
Href: requestedAccount.URI,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return p.federationProcessor.GetWebfingerAccount(ctx, requestedUsername)
|
||||
}
|
||||
|
||||
func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) {
|
||||
return &apimodel.WellKnownResponse{
|
||||
Links: []apimodel.Link{
|
||||
{
|
||||
Rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
Href: fmt.Sprintf("%s://%s/nodeinfo/2.0", p.config.Protocol, p.config.Host),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return p.federationProcessor.GetNodeInfoRel(ctx, request)
|
||||
}
|
||||
|
||||
func (p *processor) GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) {
|
||||
return &apimodel.Nodeinfo{
|
||||
Version: "2.0",
|
||||
Software: apimodel.NodeInfoSoftware{
|
||||
Name: "gotosocial",
|
||||
Version: p.config.SoftwareVersion,
|
||||
},
|
||||
Protocols: []string{"activitypub"},
|
||||
Services: apimodel.NodeInfoServices{
|
||||
Inbound: []string{},
|
||||
Outbound: []string{},
|
||||
},
|
||||
OpenRegistrations: p.config.AccountsConfig.OpenRegistration,
|
||||
Usage: apimodel.NodeInfoUsage{
|
||||
Users: apimodel.NodeInfoUsers{},
|
||||
},
|
||||
Metadata: make(map[string]interface{}),
|
||||
}, nil
|
||||
return p.federationProcessor.GetNodeInfo(ctx, request)
|
||||
}
|
||||
|
||||
func (p *processor) InboxPost(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
contextWithChannel := context.WithValue(ctx, util.APFromFederatorChanKey, p.fromFederator)
|
||||
return p.federator.FederatingActor().PostInbox(contextWithChannel, w, r)
|
||||
return p.federationProcessor.PostInbox(ctx, w, r)
|
||||
}
|
||||
|
|
103
internal/processing/federation/federation.go
Normal file
103
internal/processing/federation/federation.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/visibility"
|
||||
)
|
||||
|
||||
// Processor wraps functions for processing federation API requests.
|
||||
type Processor interface {
|
||||
// GetUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
|
||||
// before returning a JSON serializable interface to the caller.
|
||||
GetUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFollowers handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFollowing handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups.
|
||||
GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode)
|
||||
|
||||
// GetNodeInfoRel returns a well known response giving the path to node info.
|
||||
GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode)
|
||||
|
||||
// GetNodeInfo returns a node info struct in response to a node info request.
|
||||
GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode)
|
||||
|
||||
// GetOutbox returns the activitypub representation of a local user's outbox.
|
||||
// This contains links to PUBLIC posts made by this user.
|
||||
GetOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// PostInbox handles POST requests to a user's inbox for new activitypub messages.
|
||||
//
|
||||
// PostInbox returns true if the request was handled as an ActivityPub POST to an actor's inbox.
|
||||
// If false, the request was not an ActivityPub request and may still be handled by the caller in another way, such as serving a web page.
|
||||
//
|
||||
// If the error is nil, then the ResponseWriter's headers and response has already been written. If a non-nil error is returned, then no response has been written.
|
||||
//
|
||||
// If the Actor was constructed with the Federated Protocol enabled, side effects will occur.
|
||||
//
|
||||
// If the Federated Protocol is not enabled, writes the http.StatusMethodNotAllowed status code in the response. No side effects occur.
|
||||
PostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error)
|
||||
}
|
||||
|
||||
type processor struct {
|
||||
db db.DB
|
||||
config *config.Config
|
||||
federator federation.Federator
|
||||
tc typeutils.TypeConverter
|
||||
filter visibility.Filter
|
||||
fromFederator chan messages.FromFederator
|
||||
}
|
||||
|
||||
// New returns a new federation processor.
|
||||
func New(db db.DB, tc typeutils.TypeConverter, config *config.Config, federator federation.Federator, fromFederator chan messages.FromFederator) Processor {
|
||||
return &processor{
|
||||
db: db,
|
||||
config: config,
|
||||
federator: federator,
|
||||
tc: tc,
|
||||
filter: visibility.NewFilter(db),
|
||||
fromFederator: fromFederator,
|
||||
}
|
||||
}
|
74
internal/processing/federation/getfollowers.go
Normal file
74
internal/processing/federation/getfollowers.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
func (p *processor) GetFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
requestedAccountURI, err := url.Parse(requestedAccount.URI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
|
||||
}
|
||||
|
||||
requestedFollowers, err := p.federator.FederatingDB().Followers(ctx, requestedAccountURI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching followers for uri %s: %s", requestedAccountURI.String(), err))
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(requestedFollowers)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
74
internal/processing/federation/getfollowing.go
Normal file
74
internal/processing/federation/getfollowing.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
func (p *processor) GetFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
requestedAccountURI, err := url.Parse(requestedAccount.URI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error parsing url %s: %s", requestedAccount.URI, err))
|
||||
}
|
||||
|
||||
requestedFollowing, err := p.federator.FederatingDB().Following(ctx, requestedAccountURI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching following for uri %s: %s", requestedAccountURI.String(), err))
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(requestedFollowing)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
69
internal/processing/federation/getnodeinfo.go
Normal file
69
internal/processing/federation/getnodeinfo.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
const (
|
||||
nodeInfoVersion = "2.0"
|
||||
nodeInfoSoftwareName = "gotosocial"
|
||||
)
|
||||
|
||||
var (
|
||||
nodeInfoRel = fmt.Sprintf("http://nodeinfo.diaspora.software/ns/schema/%s", nodeInfoVersion)
|
||||
nodeInfoProtocols = []string{"activitypub"}
|
||||
)
|
||||
|
||||
func (p *processor) GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode) {
|
||||
return &apimodel.WellKnownResponse{
|
||||
Links: []apimodel.Link{
|
||||
{
|
||||
Rel: nodeInfoRel,
|
||||
Href: fmt.Sprintf("%s://%s/nodeinfo/%s", p.config.Protocol, p.config.Host, nodeInfoVersion),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *processor) GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode) {
|
||||
return &apimodel.Nodeinfo{
|
||||
Version: nodeInfoVersion,
|
||||
Software: apimodel.NodeInfoSoftware{
|
||||
Name: nodeInfoSoftwareName,
|
||||
Version: p.config.SoftwareVersion,
|
||||
},
|
||||
Protocols: nodeInfoProtocols,
|
||||
Services: apimodel.NodeInfoServices{
|
||||
Inbound: []string{},
|
||||
Outbound: []string{},
|
||||
},
|
||||
OpenRegistrations: p.config.AccountsConfig.OpenRegistration,
|
||||
Usage: apimodel.NodeInfoUsage{
|
||||
Users: apimodel.NodeInfoUsers{},
|
||||
},
|
||||
Metadata: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
107
internal/processing/federation/getoutbox.go
Normal file
107
internal/processing/federation/getoutbox.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
func (p *processor) GetOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
// authorize the request:
|
||||
// 1. check if a block exists between the requester and the requestee
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
// now there are two scenarios:
|
||||
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
|
||||
// 2. we're asked for a specific page; this can be either the first page or any other page
|
||||
|
||||
if !page {
|
||||
/*
|
||||
scenario 1: return the collection with no items
|
||||
we want something that looks like this:
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://example.org/users/whatever/outbox",
|
||||
"type": "OrderedCollection",
|
||||
"first": "https://example.org/users/whatever/outbox?page=true",
|
||||
"last": "https://example.org/users/whatever/outbox?min_id=0&page=true"
|
||||
}
|
||||
*/
|
||||
collection, err := p.tc.OutboxToASCollection(ctx, requestedAccount.OutboxURI)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
data, err = streams.Serialize(collection)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// scenario 2 -- get the requested page
|
||||
// limit pages to 30 entries per page
|
||||
publicStatuses, err := p.db.GetAccountStatuses(ctx, requestedAccount.ID, 30, true, maxID, minID, false, false, true)
|
||||
if err != nil && err != db.ErrNoEntries {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
outboxPage, err := p.tc.StatusesToASOutboxPage(ctx, requestedAccount.OutboxURI, maxID, minID, publicStatuses)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
data, err = streams.Serialize(outboxPage)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
91
internal/processing/federation/getstatus.go
Normal file
91
internal/processing/federation/getstatus.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
func (p *processor) GetStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
// authorize the request:
|
||||
// 1. check if a block exists between the requester and the requestee
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
// get the status out of the database here
|
||||
s := >smodel.Status{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{
|
||||
{Key: "id", Value: requestedStatusID},
|
||||
{Key: "account_id", Value: requestedAccount.ID},
|
||||
}, s); err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
|
||||
}
|
||||
|
||||
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
// requester is authorized to view the status, so convert it to AP representation and serialize it
|
||||
asStatus, err := p.tc.StatusToAS(ctx, s)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(asStatus)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
164
internal/processing/federation/getstatusreplies.go
Normal file
164
internal/processing/federation/getstatusreplies.go
Normal file
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
func (p *processor) GetStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// authenticate the request
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
// authorize the request:
|
||||
// 1. check if a block exists between the requester and the requestee
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
// get the status out of the database here
|
||||
s := >smodel.Status{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{
|
||||
{Key: "id", Value: requestedStatusID},
|
||||
{Key: "account_id", Value: requestedAccount.ID},
|
||||
}, s); err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting status with id %s and account id %s: %s", requestedStatusID, requestedAccount.ID, err))
|
||||
}
|
||||
|
||||
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("status with id %s not visible to user with id %s", s.ID, requestingAccount.ID))
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// now there are three scenarios:
|
||||
// 1. we're asked for the whole collection and not a page -- we can just return the collection, with no items, but a link to 'first' page.
|
||||
// 2. we're asked for a page but only_other_accounts has not been set in the query -- so we should just return the first page of the collection, with no items.
|
||||
// 3. we're asked for a page, and only_other_accounts has been set, and min_id has optionally been set -- so we need to return some actual items!
|
||||
|
||||
if !page {
|
||||
// scenario 1
|
||||
|
||||
// get the collection
|
||||
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
data, err = streams.Serialize(collection)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else if page && requestURL.Query().Get("only_other_accounts") == "" {
|
||||
// scenario 2
|
||||
|
||||
// get the collection
|
||||
collection, err := p.tc.StatusToASRepliesCollection(ctx, s, onlyOtherAccounts)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
// but only return the first page
|
||||
data, err = streams.Serialize(collection.GetActivityStreamsFirst().GetActivityStreamsCollectionPage())
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else {
|
||||
// scenario 3
|
||||
// get immediate children
|
||||
replies, err := p.db.GetStatusChildren(ctx, s, true, minID)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// filter children and extract URIs
|
||||
replyURIs := map[string]*url.URL{}
|
||||
for _, r := range replies {
|
||||
// only show public or unlocked statuses as replies
|
||||
if r.Visibility != gtsmodel.VisibilityPublic && r.Visibility != gtsmodel.VisibilityUnlocked {
|
||||
continue
|
||||
}
|
||||
|
||||
// respect onlyOtherAccounts parameter
|
||||
if onlyOtherAccounts && r.AccountID == requestedAccount.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
// only show replies that the status owner can see
|
||||
visibleToStatusOwner, err := p.filter.StatusVisible(ctx, r, requestedAccount)
|
||||
if err != nil || !visibleToStatusOwner {
|
||||
continue
|
||||
}
|
||||
|
||||
// only show replies that the requester can see
|
||||
visibleToRequester, err := p.filter.StatusVisible(ctx, r, requestingAccount)
|
||||
if err != nil || !visibleToRequester {
|
||||
continue
|
||||
}
|
||||
|
||||
rURI, err := url.Parse(r.URI)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
replyURIs[r.ID] = rURI
|
||||
}
|
||||
|
||||
repliesPage, err := p.tc.StatusURIsToASRepliesPage(ctx, s, onlyOtherAccounts, minID, replyURIs)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
data, err = streams.Serialize(repliesPage)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
85
internal/processing/federation/getuser.go
Normal file
85
internal/processing/federation/getuser.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func (p *processor) GetUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
var requestedPerson vocab.ActivityStreamsPerson
|
||||
if util.IsPublicKeyPath(requestURL) {
|
||||
// if it's a public key path, we don't need to authenticate but we'll only serve the bare minimum user profile needed for the public key
|
||||
requestedPerson, err = p.tc.AccountToASMinimal(ctx, requestedAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else if util.IsUserPath(requestURL) {
|
||||
// if it's a user path, we want to fully authenticate the request before we serve any data, and then we can serve a more complete profile
|
||||
requestingAccountURI, authenticated, err := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
|
||||
if err != nil || !authenticated {
|
||||
return nil, gtserror.NewErrorNotAuthorized(errors.New("not authorized"), "not authorized")
|
||||
}
|
||||
|
||||
// if we're not already handshaking/dereferencing a remote account, dereference it now
|
||||
if !p.federator.Handshaking(ctx, requestedUsername, requestingAccountURI) {
|
||||
requestingAccount, _, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotAuthorized(err)
|
||||
}
|
||||
|
||||
blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
if blocked {
|
||||
return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID))
|
||||
}
|
||||
}
|
||||
|
||||
requestedPerson, err = p.tc.AccountToAS(ctx, requestedAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
} else {
|
||||
return nil, gtserror.NewErrorBadRequest(fmt.Errorf("path was not public key path or user path"))
|
||||
}
|
||||
|
||||
data, err := streams.Serialize(requestedPerson)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
64
internal/processing/federation/getwebfinger.go
Normal file
64
internal/processing/federation/getwebfinger.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
const (
|
||||
webfingerProfilePage = "http://webfinger.net/rel/profile-page"
|
||||
webFingerProfilePageContentType = "text/html"
|
||||
webfingerSelf = "self"
|
||||
webFingerSelfContentType = "application/activity+json"
|
||||
webfingerAccount = "acct"
|
||||
)
|
||||
|
||||
func (p *processor) GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) {
|
||||
// get the account the request is referring to
|
||||
requestedAccount, err := p.db.GetLocalAccountByUsername(ctx, requestedUsername)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("database error getting account with username %s: %s", requestedUsername, err))
|
||||
}
|
||||
|
||||
// return the webfinger representation
|
||||
return &apimodel.WellKnownResponse{
|
||||
Subject: fmt.Sprintf("%s:%s@%s", webfingerAccount, requestedAccount.Username, p.config.AccountDomain),
|
||||
Aliases: []string{
|
||||
requestedAccount.URI,
|
||||
requestedAccount.URL,
|
||||
},
|
||||
Links: []apimodel.Link{
|
||||
{
|
||||
Rel: webfingerProfilePage,
|
||||
Type: webFingerProfilePageContentType,
|
||||
Href: requestedAccount.URL,
|
||||
},
|
||||
{
|
||||
Rel: webfingerSelf,
|
||||
Type: webFingerSelfContentType,
|
||||
Href: requestedAccount.URI,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
32
internal/processing/federation/postinbox.go
Normal file
32
internal/processing/federation/postinbox.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package federation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func (p *processor) PostInbox(ctx context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
|
||||
// pass the fromFederator channel through to postInbox, since it'll be needed later
|
||||
contextWithChannel := context.WithValue(ctx, util.APFromFederatorChanKey, p.fromFederator)
|
||||
return p.federator.FederatingActor().PostInbox(contextWithChannel, w, r)
|
||||
}
|
|
@ -269,12 +269,17 @@ func (p *processor) federateStatus(ctx context.Context, status *gtsmodel.Status)
|
|||
return fmt.Errorf("federateStatus: error converting status to as format: %s", err)
|
||||
}
|
||||
|
||||
create, err := p.tc.WrapNoteInCreate(asStatus, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("federateStatus: error wrapping status in create: %s", err)
|
||||
}
|
||||
|
||||
outboxIRI, err := url.Parse(status.Account.OutboxURI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("federateStatus: error parsing outboxURI %s: %s", status.Account.OutboxURI, err)
|
||||
}
|
||||
|
||||
_, err = p.federator.FederatingActor().Send(ctx, outboxIRI, asStatus)
|
||||
_, err = p.federator.FederatingActor().Send(ctx, outboxIRI, create)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -339,7 +339,7 @@ func (suite *FromFederatorTestSuite) TestProcessAccountDelete() {
|
|||
suite.False(zorkFollowsSatan)
|
||||
|
||||
// no statuses from foss satan should be left in the database
|
||||
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", false, false)
|
||||
dbStatuses, err := suite.db.GetAccountStatuses(ctx, deletedAccount.ID, 0, false, "", "", false, false, false)
|
||||
suite.ErrorIs(err, db.ErrNoEntries)
|
||||
suite.Empty(dbStatuses)
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/account"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/admin"
|
||||
federationProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/federation"
|
||||
mediaProcessor "github.com/superseriousbusiness/gotosocial/internal/processing/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing/streaming"
|
||||
|
@ -78,7 +79,7 @@ type Processor interface {
|
|||
AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error)
|
||||
// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
|
||||
// the account given in authed.
|
||||
AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, pinned bool, mediaOnly bool) ([]apimodel.Status, gtserror.WithCode)
|
||||
AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) ([]apimodel.Status, gtserror.WithCode)
|
||||
// AccountFollowersGet fetches a list of the target account's followers.
|
||||
AccountFollowersGet(ctx context.Context, authed *oauth.Auth, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
|
||||
// AccountFollowingGet fetches a list of the accounts that target account is following.
|
||||
|
@ -190,32 +191,26 @@ type Processor interface {
|
|||
// GetFediUser handles the getting of a fedi/activitypub representation of a user/account, performing appropriate authentication
|
||||
// before returning a JSON serializable interface to the caller.
|
||||
GetFediUser(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFediFollowers handles the getting of a fedi/activitypub representation of a user/account's followers, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetFediFollowers(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFediFollowing handles the getting of a fedi/activitypub representation of a user/account's following, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetFediFollowing(ctx context.Context, requestedUsername string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFediStatus handles the getting of a fedi/activitypub representation of a particular status, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetFediStatus(ctx context.Context, requestedUsername string, requestedStatusID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFediStatus handles the getting of a fedi/activitypub representation of replies to a status, performing appropriate
|
||||
// authentication before returning a JSON serializable interface to the caller.
|
||||
GetFediStatusReplies(ctx context.Context, requestedUsername string, requestedStatusID string, page bool, onlyOtherAccounts bool, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
|
||||
// GetFediOutbox returns the public outbox of the requested user, with the given parameters.
|
||||
GetFediOutbox(ctx context.Context, requestedUsername string, page bool, maxID string, minID string, requestURL *url.URL) (interface{}, gtserror.WithCode)
|
||||
// GetWebfingerAccount handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups.
|
||||
GetWebfingerAccount(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode)
|
||||
|
||||
// GetNodeInfoRel returns a well known response giving the path to node info.
|
||||
GetNodeInfoRel(ctx context.Context, request *http.Request) (*apimodel.WellKnownResponse, gtserror.WithCode)
|
||||
|
||||
// GetNodeInfo returns a node info struct in response to a node info request.
|
||||
GetNodeInfo(ctx context.Context, request *http.Request) (*apimodel.Nodeinfo, gtserror.WithCode)
|
||||
|
||||
// InboxPost handles POST requests to a user's inbox for new activitypub messages.
|
||||
//
|
||||
// InboxPost returns true if the request was handled as an ActivityPub POST to an actor's inbox.
|
||||
|
@ -254,6 +249,7 @@ type processor struct {
|
|||
streamingProcessor streaming.Processor
|
||||
mediaProcessor mediaProcessor.Processor
|
||||
userProcessor user.Processor
|
||||
federationProcessor federationProcessor.Processor
|
||||
}
|
||||
|
||||
// NewProcessor returns a new Processor that uses the given federator
|
||||
|
@ -267,6 +263,7 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f
|
|||
adminProcessor := admin.New(db, tc, mediaHandler, fromClientAPI, config)
|
||||
mediaProcessor := mediaProcessor.New(db, tc, mediaHandler, storage, config)
|
||||
userProcessor := user.New(db, config)
|
||||
federationProcessor := federationProcessor.New(db, tc, config, federator, fromFederator)
|
||||
|
||||
return &processor{
|
||||
fromClientAPI: fromClientAPI,
|
||||
|
@ -288,6 +285,7 @@ func NewProcessor(config *config.Config, tc typeutils.TypeConverter, federator f
|
|||
streamingProcessor: streamingProcessor,
|
||||
mediaProcessor: mediaProcessor,
|
||||
userProcessor: userProcessor,
|
||||
federationProcessor: federationProcessor,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -155,6 +155,19 @@ type TypeConverter interface {
|
|||
StatusToASRepliesCollection(ctx context.Context, status *gtsmodel.Status, onlyOtherAccounts bool) (vocab.ActivityStreamsCollection, error)
|
||||
// StatusURIsToASRepliesPage returns a collection page with appropriate next/part of pagination.
|
||||
StatusURIsToASRepliesPage(ctx context.Context, status *gtsmodel.Status, onlyOtherAccounts bool, minID string, replies map[string]*url.URL) (vocab.ActivityStreamsCollectionPage, error)
|
||||
// OutboxToASCollection returns an ordered collection with appropriate id, next, and last fields.
|
||||
// The returned collection won't have any actual entries; just links to where entries can be obtained.
|
||||
OutboxToASCollection(ctx context.Context, outboxID string) (vocab.ActivityStreamsOrderedCollection, error)
|
||||
// StatusesToASOutboxPage returns an ordered collection page using the given statuses and parameters as contents.
|
||||
//
|
||||
// The maxID and minID should be the parameters that were passed to the database to obtain the given statuses.
|
||||
// These will be used to create the 'id' field of the collection.
|
||||
//
|
||||
// OutboxID is used to create the 'partOf' field in the collection.
|
||||
//
|
||||
// Appropriate 'next' and 'prev' fields will be created based on the highest and lowest IDs present in the statuses slice.
|
||||
StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error)
|
||||
|
||||
/*
|
||||
INTERNAL (gts) MODEL TO INTERNAL MODEL
|
||||
*/
|
||||
|
@ -170,6 +183,12 @@ type TypeConverter interface {
|
|||
|
||||
// WrapPersonInUpdate
|
||||
WrapPersonInUpdate(person vocab.ActivityStreamsPerson, originAccount *gtsmodel.Account) (vocab.ActivityStreamsUpdate, error)
|
||||
// WrapNoteInCreate wraps a Note with a Create activity.
|
||||
//
|
||||
// If objectIRIOnly is set to true, then the function won't put the *entire* note in the Object field of the Create,
|
||||
// but just the AP URI of the note. This is useful in cases where you want to give a remote server something to dereference,
|
||||
// and still have control over whether or not they're allowed to actually see the contents.
|
||||
WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOnly bool) (vocab.ActivityStreamsCreate, error)
|
||||
}
|
||||
|
||||
type converter struct {
|
||||
|
|
|
@ -32,6 +32,13 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
)
|
||||
|
||||
// const (
|
||||
// // highestID is the highest possible ULID
|
||||
// highestID = "ZZZZZZZZZZZZZZZZZZZZZZZZZZ"
|
||||
// // lowestID is the lowest possible ULID
|
||||
// lowestID = "00000000000000000000000000"
|
||||
// )
|
||||
|
||||
// Converts a gts model account into an Activity Streams person type.
|
||||
func (c *converter) AccountToAS(ctx context.Context, a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) {
|
||||
person := streams.NewActivityStreamsPerson()
|
||||
|
@ -1013,3 +1020,140 @@ func (c *converter) StatusURIsToASRepliesPage(ctx context.Context, status *gtsmo
|
|||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
/*
|
||||
the goal is to end up with something like this:
|
||||
{
|
||||
"id": "https://example.org/users/whatever/outbox?page=true",
|
||||
"type": "OrderedCollectionPage",
|
||||
"next": "https://example.org/users/whatever/outbox?max_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||
"prev": "https://example.org/users/whatever/outbox?min_id=01FJC1Q0E3SSQR59TD2M1KP4V8&page=true",
|
||||
"partOf": "https://example.org/users/whatever/outbox",
|
||||
"orderedItems": [
|
||||
"id": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7/activity",
|
||||
"type": "Create",
|
||||
"actor": "https://example.org/users/whatever",
|
||||
"published": "2021-10-18T20:06:18Z",
|
||||
"to": [
|
||||
"https://www.w3.org/ns/activitystreams#Public"
|
||||
],
|
||||
"cc": [
|
||||
"https://example.org/users/whatever/followers"
|
||||
],
|
||||
"object": "https://example.org/users/whatever/statuses/01FJC1MKPVX2VMWP2ST93Q90K7"
|
||||
]
|
||||
}
|
||||
*/
|
||||
func (c *converter) StatusesToASOutboxPage(ctx context.Context, outboxID string, maxID string, minID string, statuses []*gtsmodel.Status) (vocab.ActivityStreamsOrderedCollectionPage, error) {
|
||||
page := streams.NewActivityStreamsOrderedCollectionPage()
|
||||
|
||||
// .id
|
||||
pageIDProp := streams.NewJSONLDIdProperty()
|
||||
pageID := fmt.Sprintf("%s?page=true", outboxID)
|
||||
if minID != "" {
|
||||
pageID = fmt.Sprintf("%s&minID=%s", pageID, minID)
|
||||
}
|
||||
if maxID != "" {
|
||||
pageID = fmt.Sprintf("%s&maxID=%s", pageID, maxID)
|
||||
}
|
||||
pageIDURI, err := url.Parse(pageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageIDProp.SetIRI(pageIDURI)
|
||||
page.SetJSONLDId(pageIDProp)
|
||||
|
||||
// .partOf
|
||||
collectionIDURI, err := url.Parse(outboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
partOfProp := streams.NewActivityStreamsPartOfProperty()
|
||||
partOfProp.SetIRI(collectionIDURI)
|
||||
page.SetActivityStreamsPartOf(partOfProp)
|
||||
|
||||
// .orderedItems
|
||||
itemsProp := streams.NewActivityStreamsOrderedItemsProperty()
|
||||
var highest string
|
||||
var lowest string
|
||||
for _, s := range statuses {
|
||||
note, err := c.StatusToAS(ctx, s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
create, err := c.WrapNoteInCreate(note, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemsProp.AppendActivityStreamsCreate(create)
|
||||
|
||||
if highest == "" || s.ID > highest {
|
||||
highest = s.ID
|
||||
}
|
||||
if lowest == "" || s.ID < lowest {
|
||||
lowest = s.ID
|
||||
}
|
||||
}
|
||||
page.SetActivityStreamsOrderedItems(itemsProp)
|
||||
|
||||
// .next
|
||||
if lowest != "" {
|
||||
nextProp := streams.NewActivityStreamsNextProperty()
|
||||
nextPropIDString := fmt.Sprintf("%s?page=true&max_id=%s", outboxID, lowest)
|
||||
nextPropIDURI, err := url.Parse(nextPropIDString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nextProp.SetIRI(nextPropIDURI)
|
||||
page.SetActivityStreamsNext(nextProp)
|
||||
}
|
||||
|
||||
// .prev
|
||||
if highest != "" {
|
||||
prevProp := streams.NewActivityStreamsPrevProperty()
|
||||
prevPropIDString := fmt.Sprintf("%s?page=true&min_id=%s", outboxID, highest)
|
||||
prevPropIDURI, err := url.Parse(prevPropIDString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prevProp.SetIRI(prevPropIDURI)
|
||||
page.SetActivityStreamsPrev(prevProp)
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
/*
|
||||
we want something that looks like this:
|
||||
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "https://example.org/users/whatever/outbox",
|
||||
"type": "OrderedCollection",
|
||||
"first": "https://example.org/users/whatever/outbox?page=true"
|
||||
}
|
||||
*/
|
||||
func (c *converter) OutboxToASCollection(ctx context.Context, outboxID string) (vocab.ActivityStreamsOrderedCollection, error) {
|
||||
collection := streams.NewActivityStreamsOrderedCollection()
|
||||
|
||||
collectionIDProp := streams.NewJSONLDIdProperty()
|
||||
outboxIDURI, err := url.Parse(outboxID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing url %s", outboxID)
|
||||
}
|
||||
collectionIDProp.SetIRI(outboxIDURI)
|
||||
collection.SetJSONLDId(collectionIDProp)
|
||||
|
||||
collectionFirstProp := streams.NewActivityStreamsFirstProperty()
|
||||
collectionFirstPropID := fmt.Sprintf("%s?page=true", outboxID)
|
||||
collectionFirstPropIDURI, err := url.Parse(collectionFirstPropID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing url %s", collectionFirstPropID)
|
||||
}
|
||||
collectionFirstProp.SetIRI(collectionFirstPropIDURI)
|
||||
collection.SetActivityStreamsFirst(collectionFirstProp)
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
|
|
@ -37,18 +37,98 @@ func (suite *InternalToASTestSuite) TestAccountToAS() {
|
|||
testAccount := suite.testAccounts["local_account_1"] // take zork for this test
|
||||
|
||||
asPerson, err := suite.typeconverter.AccountToAS(context.Background(), testAccount)
|
||||
assert.NoError(suite.T(), err)
|
||||
suite.NoError(err)
|
||||
|
||||
ser, err := streams.Serialize(asPerson)
|
||||
assert.NoError(suite.T(), err)
|
||||
suite.NoError(err)
|
||||
|
||||
bytes, err := json.Marshal(ser)
|
||||
assert.NoError(suite.T(), err)
|
||||
suite.NoError(err)
|
||||
|
||||
fmt.Println(string(bytes))
|
||||
// TODO: write assertions here, rn we're just eyeballing the output
|
||||
}
|
||||
|
||||
func (suite *InternalToASTestSuite) TestOutboxToASCollection() {
|
||||
testAccount := suite.testAccounts["admin_account"]
|
||||
ctx := context.Background()
|
||||
|
||||
collection, err := suite.typeconverter.OutboxToASCollection(ctx, testAccount.OutboxURI)
|
||||
suite.NoError(err)
|
||||
|
||||
ser, err := streams.Serialize(collection)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
bytes, err := json.Marshal(ser)
|
||||
suite.NoError(err)
|
||||
|
||||
/*
|
||||
we want this:
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"first": "http://localhost:8080/users/admin/outbox?page=true",
|
||||
"id": "http://localhost:8080/users/admin/outbox",
|
||||
"type": "OrderedCollection"
|
||||
}
|
||||
*/
|
||||
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","first":"http://localhost:8080/users/admin/outbox?page=true","id":"http://localhost:8080/users/admin/outbox","type":"OrderedCollection"}`, string(bytes))
|
||||
}
|
||||
|
||||
func (suite *InternalToASTestSuite) TestStatusesToASOutboxPage() {
|
||||
testAccount := suite.testAccounts["admin_account"]
|
||||
ctx := context.Background()
|
||||
|
||||
// get public statuses from testaccount
|
||||
statuses, err := suite.db.GetAccountStatuses(ctx, testAccount.ID, 30, true, "", "", false, false, true)
|
||||
suite.NoError(err)
|
||||
|
||||
page, err := suite.typeconverter.StatusesToASOutboxPage(ctx, testAccount.OutboxURI, "", "", statuses)
|
||||
suite.NoError(err)
|
||||
|
||||
ser, err := streams.Serialize(page)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
bytes, err := json.Marshal(ser)
|
||||
suite.NoError(err)
|
||||
|
||||
/*
|
||||
|
||||
we want this:
|
||||
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"id": "http://localhost:8080/users/admin/outbox?page=true",
|
||||
"next": "http://localhost:8080/users/admin/outbox?page=true&max_id=01F8MH75CBF9JFX4ZAD54N0W0R",
|
||||
"orderedItems": [
|
||||
{
|
||||
"actor": "http://localhost:8080/users/admin",
|
||||
"cc": "http://localhost:8080/users/admin/followers",
|
||||
"id": "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37/activity",
|
||||
"object": "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
|
||||
"published": "2021-10-20T12:36:45Z",
|
||||
"to": "https://www.w3.org/ns/activitystreams#Public",
|
||||
"type": "Create"
|
||||
},
|
||||
{
|
||||
"actor": "http://localhost:8080/users/admin",
|
||||
"cc": "http://localhost:8080/users/admin/followers",
|
||||
"id": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/activity",
|
||||
"object": "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R",
|
||||
"published": "2021-10-20T11:36:45Z",
|
||||
"to": "https://www.w3.org/ns/activitystreams#Public",
|
||||
"type": "Create"
|
||||
}
|
||||
],
|
||||
"partOf": "http://localhost:8080/users/admin/outbox",
|
||||
"prev": "http://localhost:8080/users/admin/outbox?page=true&min_id=01F8MHAAY43M6RJ473VQFCVH37",
|
||||
"type": "OrderedCollectionPage"
|
||||
}
|
||||
*/
|
||||
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","id":"http://localhost:8080/users/admin/outbox?page=true","next":"http://localhost:8080/users/admin/outbox?page=true\u0026max_id=01F8MH75CBF9JFX4ZAD54N0W0R","orderedItems":[{"actor":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","id":"http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37/activity","object":"http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37","published":"2021-10-20T12:36:45Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"},{"actor":"http://localhost:8080/users/admin","cc":"http://localhost:8080/users/admin/followers","id":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R/activity","object":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","published":"2021-10-20T11:36:45Z","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"}],"partOf":"http://localhost:8080/users/admin/outbox","prev":"http://localhost:8080/users/admin/outbox?page=true\u0026min_id=01F8MHAAY43M6RJ473VQFCVH37","type":"OrderedCollectionPage"}`, string(bytes))
|
||||
}
|
||||
|
||||
func TestInternalToASTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(InternalToASTestSuite))
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/go-fed/activity/pub"
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
|
@ -66,3 +67,66 @@ func (c *converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi
|
|||
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func (c *converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOnly bool) (vocab.ActivityStreamsCreate, error) {
|
||||
create := streams.NewActivityStreamsCreate()
|
||||
|
||||
// Object property
|
||||
objectProp := streams.NewActivityStreamsObjectProperty()
|
||||
if objectIRIOnly {
|
||||
objectProp.AppendIRI(note.GetJSONLDId().GetIRI())
|
||||
} else {
|
||||
objectProp.AppendActivityStreamsNote(note)
|
||||
}
|
||||
create.SetActivityStreamsObject(objectProp)
|
||||
|
||||
// ID property
|
||||
idProp := streams.NewJSONLDIdProperty()
|
||||
createID := fmt.Sprintf("%s/activity", note.GetJSONLDId().GetIRI().String())
|
||||
createIDIRI, err := url.Parse(createID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idProp.SetIRI(createIDIRI)
|
||||
create.SetJSONLDId(idProp)
|
||||
|
||||
// Actor Property
|
||||
actorProp := streams.NewActivityStreamsActorProperty()
|
||||
actorIRI, err := ap.ExtractAttributedTo(note)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("WrapNoteInCreate: couldn't extract AttributedTo: %s", err)
|
||||
}
|
||||
actorProp.AppendIRI(actorIRI)
|
||||
create.SetActivityStreamsActor(actorProp)
|
||||
|
||||
// Published Property
|
||||
publishedProp := streams.NewActivityStreamsPublishedProperty()
|
||||
published, err := ap.ExtractPublished(note)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("WrapNoteInCreate: couldn't extract Published: %s", err)
|
||||
}
|
||||
publishedProp.Set(published)
|
||||
create.SetActivityStreamsPublished(publishedProp)
|
||||
|
||||
// To Property
|
||||
toProp := streams.NewActivityStreamsToProperty()
|
||||
tos, err := ap.ExtractTos(note)
|
||||
if err == nil {
|
||||
for _, to := range tos {
|
||||
toProp.AppendIRI(to)
|
||||
}
|
||||
create.SetActivityStreamsTo(toProp)
|
||||
}
|
||||
|
||||
// Cc Property
|
||||
ccProp := streams.NewActivityStreamsCcProperty()
|
||||
ccs, err := ap.ExtractCCs(note)
|
||||
if err == nil {
|
||||
for _, cc := range ccs {
|
||||
ccProp.AppendIRI(cc)
|
||||
}
|
||||
create.SetActivityStreamsCc(ccProp)
|
||||
}
|
||||
|
||||
return create, nil
|
||||
}
|
||||
|
|
74
internal/typeutils/wrap_test.go
Normal file
74
internal/typeutils/wrap_test.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package typeutils_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type WrapTestSuite struct {
|
||||
TypeUtilsTestSuite
|
||||
}
|
||||
|
||||
func (suite *WrapTestSuite) TestWrapNoteInCreateIRIOnly() {
|
||||
testStatus := suite.testStatuses["local_account_1_status_1"]
|
||||
|
||||
note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus)
|
||||
suite.NoError(err)
|
||||
|
||||
create, err := suite.typeconverter.WrapNoteInCreate(note, true)
|
||||
suite.NoError(err)
|
||||
suite.NotNil(create)
|
||||
|
||||
createI, err := streams.Serialize(create)
|
||||
suite.NoError(err)
|
||||
|
||||
bytes, err := json.Marshal(createI)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","actor":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity","object":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T12:40:37+02:00","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"}`, string(bytes))
|
||||
}
|
||||
|
||||
func (suite *WrapTestSuite) TestWrapNoteInCreate() {
|
||||
testStatus := suite.testStatuses["local_account_1_status_1"]
|
||||
|
||||
note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus)
|
||||
suite.NoError(err)
|
||||
|
||||
create, err := suite.typeconverter.WrapNoteInCreate(note, false)
|
||||
suite.NoError(err)
|
||||
suite.NotNil(create)
|
||||
|
||||
createI, err := streams.Serialize(create)
|
||||
suite.NoError(err)
|
||||
|
||||
bytes, err := json.Marshal(createI)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(`{"@context":"https://www.w3.org/ns/activitystreams","actor":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/activity","object":{"attachment":[],"attributedTo":"http://localhost:8080/users/the_mighty_zork","cc":"http://localhost:8080/users/the_mighty_zork/followers","content":"hello everyone!","id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY","published":"2021-10-20T12:40:37+02:00","replies":{"first":{"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?page=true","next":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies?only_other_accounts=false\u0026page=true","partOf":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"CollectionPage"},"id":"http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY/replies","type":"Collection"},"summary":"introduction post","tag":[],"to":"https://www.w3.org/ns/activitystreams#Public","type":"Note","url":"http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY"},"published":"2021-10-20T12:40:37+02:00","to":"https://www.w3.org/ns/activitystreams#Public","type":"Create"}`, string(bytes))
|
||||
}
|
||||
|
||||
func TestWrapTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(WrapTestSuite))
|
||||
}
|
|
@ -32,6 +32,10 @@ type Filter interface {
|
|||
// or account domains, and other relevant accounts mentioned in or replied to by the status.
|
||||
StatusVisible(ctx context.Context, targetStatus *gtsmodel.Status, requestingAccount *gtsmodel.Account) (bool, error)
|
||||
|
||||
// StatusesVisible calls StatusVisible for each status in the statuses slice, and returns a slice of only
|
||||
// statuses which are visible to the requestingAccount.
|
||||
StatusesVisible(ctx context.Context, statuses []*gtsmodel.Status, requestingAccount *gtsmodel.Account) ([]*gtsmodel.Status, error)
|
||||
|
||||
// StatusHometimelineable returns true if targetStatus should be in the home timeline of the requesting account.
|
||||
//
|
||||
// This function will call StatusVisible internally, so it's not necessary to call it beforehand.
|
||||
|
|
|
@ -239,3 +239,17 @@ func (f *filter) StatusVisible(ctx context.Context, targetStatus *gtsmodel.Statu
|
|||
// If we reached here, all is okay
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *filter) StatusesVisible(ctx context.Context, statuses []*gtsmodel.Status, requestingAccount *gtsmodel.Account) ([]*gtsmodel.Status, error) {
|
||||
filtered := []*gtsmodel.Status{}
|
||||
for _, s := range statuses {
|
||||
visible, err := f.StatusVisible(ctx, s, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if visible {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
|
|
@ -524,6 +524,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account {
|
|||
}
|
||||
return accounts
|
||||
}
|
||||
|
||||
// NewTestAttachments returns a map of attachments keyed according to which account
|
||||
// and status they belong to, and which attachment number of that status they are.
|
||||
func NewTestAttachments() map[string]*gtsmodel.MediaAttachment {
|
||||
|
@ -863,8 +864,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
|
|||
TagIDs: []string{"01F8MHA1A2NF9MJ3WCCQ3K8BSZ"},
|
||||
MentionIDs: []string{},
|
||||
EmojiIDs: []string{"01F8MH9H8E4VG3KDYJR9EGPXCQ"},
|
||||
CreatedAt: time.Now().Add(-71 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-71 * time.Hour),
|
||||
CreatedAt: TimeMustParse("2021-10-20T11:36:45Z"),
|
||||
UpdatedAt: TimeMustParse("2021-10-20T11:36:45Z"),
|
||||
Local: true,
|
||||
AccountURI: "http://localhost:8080/users/admin",
|
||||
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||
|
@ -886,8 +887,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
|
|||
URI: "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
|
||||
URL: "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37",
|
||||
Content: "🐕🐕🐕🐕🐕",
|
||||
CreatedAt: time.Now().Add(-70 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-70 * time.Hour),
|
||||
CreatedAt: TimeMustParse("2021-10-20T12:36:45Z"),
|
||||
UpdatedAt: TimeMustParse("2021-10-20T12:36:45Z"),
|
||||
Local: true,
|
||||
AccountURI: "http://localhost:8080/users/admin",
|
||||
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF",
|
||||
|
@ -934,8 +935,8 @@ func NewTestStatuses() map[string]*gtsmodel.Status {
|
|||
URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
|
||||
URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY",
|
||||
Content: "hello everyone!",
|
||||
CreatedAt: time.Now().Add(-47 * time.Hour),
|
||||
UpdatedAt: time.Now().Add(-47 * time.Hour),
|
||||
CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
|
||||
UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"),
|
||||
Local: true,
|
||||
AccountURI: "http://localhost:8080/users/the_mighty_zork",
|
||||
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
|
||||
|
@ -1536,11 +1537,38 @@ func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[strin
|
|||
DateHeader: date,
|
||||
}
|
||||
|
||||
target = URLMustParse(accounts["local_account_1"].OutboxURI)
|
||||
sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target)
|
||||
fossSatanDereferenceZorkOutbox := ActivityWithSignature{
|
||||
SignatureHeader: sig,
|
||||
DigestHeader: digest,
|
||||
DateHeader: date,
|
||||
}
|
||||
|
||||
target = URLMustParse(accounts["local_account_1"].OutboxURI + "?page=true")
|
||||
sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target)
|
||||
fossSatanDereferenceZorkOutboxFirst := ActivityWithSignature{
|
||||
SignatureHeader: sig,
|
||||
DigestHeader: digest,
|
||||
DateHeader: date,
|
||||
}
|
||||
|
||||
target = URLMustParse(accounts["local_account_1"].OutboxURI + "?page=true&max_id=01F8MHAMCHF6Y650WCRSCP4WMY")
|
||||
sig, digest, date = GetSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, target)
|
||||
fossSatanDereferenceZorkOutboxNext := ActivityWithSignature{
|
||||
SignatureHeader: sig,
|
||||
DigestHeader: digest,
|
||||
DateHeader: date,
|
||||
}
|
||||
|
||||
return map[string]ActivityWithSignature{
|
||||
"foss_satan_dereference_zork": fossSatanDereferenceZork,
|
||||
"foss_satan_dereference_local_account_1_status_1_replies": fossSatanDereferenceLocalAccount1Status1Replies,
|
||||
"foss_satan_dereference_local_account_1_status_1_replies_next": fossSatanDereferenceLocalAccount1Status1RepliesNext,
|
||||
"foss_satan_dereference_local_account_1_status_1_replies_last": fossSatanDereferenceLocalAccount1Status1RepliesLast,
|
||||
"foss_satan_dereference_zork_outbox": fossSatanDereferenceZorkOutbox,
|
||||
"foss_satan_dereference_zork_outbox_first": fossSatanDereferenceZorkOutboxFirst,
|
||||
"foss_satan_dereference_zork_outbox_next": fossSatanDereferenceZorkOutboxNext,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue