deletes, unboosts, docs updates

This commit is contained in:
tsmethurst 2021-06-21 15:50:12 +02:00
parent efbd839181
commit ef3d38240b
15 changed files with 318 additions and 33 deletions

View File

@ -78,7 +78,7 @@
* [x] /api/v1/statuses/:id/favourite POST (Fave a status)
* [x] /api/v1/statuses/:id/unfavourite POST (Unfave a status)
* [x] /api/v1/statuses/:id/reblog POST (Reblog a status)
* [ ] /api/v1/statuses/:id/unreblog POST (Undo a reblog)
* [x] /api/v1/statuses/:id/unreblog POST (Undo a reblog)
* [ ] /api/v1/statuses/:id/bookmark POST (Bookmark a status)
* [ ] /api/v1/statuses/:id/unbookmark POST (Undo a bookmark)
* [ ] /api/v1/statuses/:id/mute POST (Mute notifications on a status)

View File

@ -8,31 +8,46 @@ Federated social media software.
GoToSocial is a Fediverse server project, written in Golang. It provides an alternative to existing projects such as [Mastodon](https://joinmastodon.org/), [Pleroma](https://pleroma.social/), [Friendica](https://friendica.net), [PixelFed](https://pixelfed.org/) etc.
One of the key differences between GoToSocial and those other projects is that GoToSocial doesn't include an integrated front-end (ie., a webapp). Instead, like the Matrix.org's [Synapse](https://github.com/matrix-org/synapse) project, it provides only a server implementation and a well-documented API. On this API, developers are free to build any front-end implementation or mobile application that they wish.
One of the key differences between GoToSocial and those other projects is that GoToSocial doesn't include an integrated client front-end (ie., a webapp). Instead, like the Matrix.org's [Synapse](https://github.com/matrix-org/synapse) project, it provides only a server implementation, some static web pages for profiles and posts, and a well-documented API. On this API, developers are free to build any front-end implementation or mobile application that they wish.
Because the server implementation is as generic and flexible/configurable as possible, GoToSocial provides the basis for many different types of social media experience, whether Tumblr-like, Facebook-like, or Twitter-like.
## Goals
## Features Wishlist
The first goal of the project is to implement a feature set comparable to Mastodon: server logic, federation logic, and a client API that's a superset of the Mastodon API described [here](https://docs.joinmastodon.org/).
A grab-bag of things that are already included or will be included in the project if time allows:
Once the client API is implemented, it should allow existing Mastodon apps like [Tusky](https://tusky.app/) and [Whalebird](https://whalebird.social/en/desktop/contents) to work with GoToSocial.
After that, custom features will be added that will necessitate expanding the API.
## Wishlist
Among other things:
* Reputation-based 'slow' federation.
* Granular post settings.
* Local-only posting.
* Easily-configurable character limit.
* Groups and group posting.
* Various federation modes, including reputation-based 'slow' federation, 'normal' federation, and zero federation.
* Local-only posting, and granular post settings including 'rebloggable/boostable', 'likeable', 'replyable'.
* Character limit for posts that's easy for admins to configure (no messing around in the source code).
* Groups and group posting!
* Built-in, automatic LetsEncrypt support (no messing around with Nginx or Certbot).
* Good performance on lower-powered machines like Raspberry Pi, old laptops, tiny VPSes (the test VPS has 1gb of ram and 1 cpu core).
## Implementation Status
For an up-to-date view on progress made towards a v1.0.0 release, see [here](./PROGRESS.md).
Things are moving on the project! As of June 2021 you can now:
* Build and deploy GoToSocial as a binary, with automatic LetsEncrypt certificate support built-in.
* Connect to the running instance via Tusky or Pinafore, using email address and password (stored encrypted).
* Post/delete posts.
* Reply/delete replies.
* Fave/unfave posts.
* Post images and gifs.
* Boost stuff/unboost stuff.
* Set your profile info (including header and avatar).
* Follow people/unfollow people.
* Accept follow requests from people.
* Post followers only/direct/public/unlocked.
* Customize posts with further flags: federated (y/n), replyable (y/n), likeable (y/n), boostable (y/n) -- not supported through Pinafore/Tusky yet.
* Get notifications for mentions/replies/likes/boosts.
* View local timeline.
* View and scroll home timeline (with ~10ms latency hell yeah).
* Stream new posts, notifications and deletes through a websockets connection via Pinafore.
* Federation support and interoperability with Mastodon and others.
In other words, a deployed GoToSocial instance is already pretty useable!
For a detailed view on progress made towards a v0.1.0 (beta) release, see [here](./PROGRESS.md).
## Contact
@ -44,7 +59,9 @@ Currently, this project is funded using Liberapay, to put bread on the table whi
### Sponsors
None yet! [Go For It](https://liberapay.com/dumpsterqueer/)
Only anonymous donations so far!
[Donate](https://liberapay.com/dumpsterqueer/)
### Image Attribution

View File

@ -98,6 +98,7 @@ func (m *Module) Route(r router.Router) error {
r.AttachHandler(http.MethodGet, FavouritedPath, m.StatusFavedByGETHandler)
r.AttachHandler(http.MethodPost, ReblogPath, m.StatusBoostPOSTHandler)
r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler)
r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler)
r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler)

View File

@ -0,0 +1,60 @@
/*
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 status
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
)
// StatusUnboostPOSTHandler handles unboost requests against a given status ID
func (m *Module) StatusUnboostPOSTHandler(c *gin.Context) {
l := m.log.WithFields(logrus.Fields{
"func": "StatusUnboostPOSTHandler",
"request_uri": c.Request.RequestURI,
"user_agent": c.Request.UserAgent(),
"origin_ip": c.ClientIP(),
})
l.Debugf("entering function")
authed, err := oauth.Authed(c, true, false, true, true) // we don't really need an app here but we want everything else
if err != nil {
l.Debug("not authed so can't unboost status")
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})
return
}
targetStatusID := c.Param(IDKey)
if targetStatusID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"})
return
}
mastoStatus, errWithCode := m.processor.StatusUnboost(authed, targetStatusID)
if errWithCode != nil {
l.Debugf("error processing status unboost: %s", errWithCode.Error())
c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()})
return
}
c.JSON(http.StatusOK, mastoStatus)
}

View File

@ -138,6 +138,18 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error
return errors.New("undo was not parseable as *gtsmodel.StatusFave")
}
return p.federateUnfave(fave, clientMsg.OriginAccount, clientMsg.TargetAccount)
case gtsmodel.ActivityStreamsAnnounce:
// UNDO ANNOUNCE/BOOST
boost, ok := clientMsg.GTSModel.(*gtsmodel.Status)
if !ok {
return errors.New("undo was not parseable as *gtsmodel.Status")
}
if err := p.deleteStatusFromTimelines(boost); err != nil {
return err
}
return p.federateUnannounce(boost, clientMsg.OriginAccount, clientMsg.TargetAccount)
}
case gtsmodel.ActivityStreamsDelete:
// DELETE
@ -313,6 +325,36 @@ func (p *processor) federateUnfave(fave *gtsmodel.StatusFave, originAccount *gts
return err
}
func (p *processor) federateUnannounce(boost *gtsmodel.Status, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error {
asAnnounce, err := p.tc.BoostToAS(boost, originAccount, targetAccount)
if err != nil {
return fmt.Errorf("federateUnannounce: error converting status to announce: %s", err)
}
// create an Undo and set the appropriate actor on it
undo := streams.NewActivityStreamsUndo()
undo.SetActivityStreamsActor(asAnnounce.GetActivityStreamsActor())
// Set the boost as the 'object' property.
undoObject := streams.NewActivityStreamsObjectProperty()
undoObject.AppendActivityStreamsAnnounce(asAnnounce)
undo.SetActivityStreamsObject(undoObject)
// set the to
undo.SetActivityStreamsTo(asAnnounce.GetActivityStreamsTo())
// set the cc
undo.SetActivityStreamsCc(asAnnounce.GetActivityStreamsCc())
outboxIRI, err := url.Parse(originAccount.OutboxURI)
if err != nil {
return fmt.Errorf("federateUnannounce: error parsing outboxURI %s: %s", originAccount.OutboxURI, err)
}
_, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, undo)
return err
}
func (p *processor) federateAcceptFollowRequest(follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error {
// if both accounts are local there's nothing to do here
if originAccount.Domain == "" && targetAccount.Domain == "" {

View File

@ -401,5 +401,9 @@ func (p *processor) timelineStatusForAccount(status *gtsmodel.Status, accountID
}
func (p *processor) deleteStatusFromTimelines(status *gtsmodel.Status) error {
return p.timelineManager.WipeStatusFromAllTimelines(status.ID)
if err := p.timelineManager.WipeStatusFromAllTimelines(status.ID); err != nil {
return err
}
return p.streamingProcessor.StreamDelete(status.ID)
}

View File

@ -117,6 +117,8 @@ type Processor interface {
StatusFave(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error)
// StatusBoost processes the boost/reblog of a given status, returning the newly-created boost if all is well.
StatusBoost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// StatusUnboost processes the unboost/unreblog of a given status, returning the status if all is well.
StatusUnboost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// StatusBoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings.
StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode)
// StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.

View File

@ -40,6 +40,10 @@ func (p *processor) StatusBoost(authed *oauth.Auth, targetStatusID string) (*api
return p.statusProcessor.Boost(authed.Account, authed.Application, targetStatusID)
}
func (p *processor) StatusUnboost(authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
return p.statusProcessor.Unboost(authed.Account, authed.Application, targetStatusID)
}
func (p *processor) StatusBoostedBy(authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) {
return p.statusProcessor.BoostedBy(authed.Account, targetStatusID)
}

View File

@ -21,6 +21,8 @@ type Processor interface {
Fave(account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// Boost processes the boost/reblog of a given status, returning the newly-created boost if all is well.
Boost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// Unboost processes the unboost/unreblog of a given status, returning the status if all is well.
Unboost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
// BoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings.
BoostedBy(account *gtsmodel.Account, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode)
// FavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings.

View File

@ -0,0 +1,95 @@
package status
import (
"errors"
"fmt"
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"
)
func (p *processor) Unboost(account *gtsmodel.Account, application *gtsmodel.Application, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
l := p.log.WithField("func", "Unboost")
l.Tracef("going to search for target status %s", targetStatusID)
targetStatus := &gtsmodel.Status{}
if err := p.db.GetByID(targetStatusID, targetStatus); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err))
}
l.Tracef("going to search for target account %s", targetStatus.AccountID)
targetAccount := &gtsmodel.Account{}
if err := p.db.GetByID(targetStatus.AccountID, targetAccount); err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching target account %s: %s", targetStatus.AccountID, err))
}
l.Trace("going to see if status is visible")
visible, err := p.filter.StatusVisible(targetStatus, account)
if err != nil {
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err))
}
if !visible {
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible"))
}
// check if we actually have a boost for this status
var toUnboost bool
gtsBoost := &gtsmodel.Status{}
where := []db.Where{
{
Key: "boost_of_id",
Value: targetStatusID,
},
{
Key: "account_id",
Value: account.ID,
},
}
err = p.db.GetWhere(where, gtsBoost)
if err == nil {
// we have a boost
toUnboost = true
}
if err != nil {
// something went wrong in the db finding the boost
if _, ok := err.(db.ErrNoEntries); !ok {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error fetching existing boost from database: %s", err))
}
// we just don't have a boost
toUnboost = false
}
if toUnboost {
// we had a boost, so take some action to get rid of it
if err := p.db.DeleteWhere(where, &gtsmodel.Status{}); err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unboosting status: %s", err))
}
// pin some stuff onto the boost while we have it out of the db
gtsBoost.GTSBoostedStatus = targetStatus
gtsBoost.GTSBoostedStatus.GTSAuthorAccount = targetAccount
gtsBoost.GTSBoostedAccount = targetAccount
gtsBoost.GTSAuthorAccount = account
// send it back to the processor for async processing
p.fromClientAPI <- gtsmodel.FromClientAPI{
APObjectType: gtsmodel.ActivityStreamsAnnounce,
APActivityType: gtsmodel.ActivityStreamsUndo,
GTSModel: gtsBoost,
OriginAccount: account,
TargetAccount: targetAccount,
}
}
mastoStatus, err := p.tc.StatusToMasto(targetStatus, account)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err))
}
return mastoStatus, nil
}

View File

@ -0,0 +1,51 @@
package streaming
import (
"fmt"
"strings"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
func (p *processor) StreamDelete(statusID string) error {
errs := []string{}
// we want to range through ALL streams for ALL accounts here to make sure it's very clear to everyone that the status has been deleted
p.streamMap.Range(func(k interface{}, v interface{}) bool {
// the key of this map should be an accountID (string)
accountID, ok := k.(string)
if !ok {
errs = append(errs, "key in streamMap was not a string!")
return false
}
// the value of the map should be a buncha streams
streamsForAccount, ok := v.(*gtsmodel.StreamsForAccount)
if !ok {
errs = append(errs, fmt.Sprintf("stream map error for account stream %s", accountID))
}
// lock the streams while we work on them
streamsForAccount.Lock()
defer streamsForAccount.Unlock()
for _, stream := range streamsForAccount.Streams {
// lock each individual stream as we work on it
stream.Lock()
defer stream.Unlock()
if stream.Connected {
stream.Messages <- &gtsmodel.Message{
Stream: []string{stream.Type},
Event: "delete",
Payload: statusID,
}
}
}
return true
})
if len(errs) != 0 {
return fmt.Errorf("one or more errors streaming status delete: %s", strings.Join(errs, ";"))
}
return nil
}

View File

@ -18,9 +18,14 @@ import (
type Processor interface {
// AuthorizeStreamingRequest returns an oauth2 token info in response to an access token query from the streaming API
AuthorizeStreamingRequest(accessToken string) (*gtsmodel.Account, error)
// OpenStreamForAccount returns a new Stream for the given account, which will contain a channel for passing messages back to the caller.
OpenStreamForAccount(account *gtsmodel.Account, streamType string) (*gtsmodel.Stream, gtserror.WithCode)
// StreamStatusToAccount streams the given status to any open, appropriate streams belonging to the given account.
StreamStatusToAccount(s *apimodel.Status, account *gtsmodel.Account) error
// StreamNotificationToAccount streams the given notification to any open, appropriate streams belonging to the given account.
StreamNotificationToAccount(n *apimodel.Notification, account *gtsmodel.Account) error
// StreamDelete streams the delete of the given statusID to *ALL* open streams.
StreamDelete(statusID string) error
}
type processor struct {

View File

@ -74,11 +74,9 @@ type Manager interface {
GetOldestIndexedID(timelineAccountID string) (string, error)
// PrepareXFromTop prepares limit n amount of posts, based on their indexed representations, from the top of the index.
PrepareXFromTop(timelineAccountID string, limit int) error
// WipeStatusFromTimeline completely removes a status and from the index and prepared posts of the given account ID
//
// The returned int indicates how many entries were removed.
WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error)
// WipeStatusFromAllTimelines removes the status from the index and prepared posts of all timelines
// Remove removes one status from the timeline of the given timelineAccountID
Remove(statusID string, timelineAccountID string) (int, error)
// WipeStatusFromAllTimelines removes one status from the index and prepared posts of all timelines
WipeStatusFromAllTimelines(statusID string) error
}
@ -177,12 +175,6 @@ func (m *manager) PrepareXFromTop(timelineAccountID string, limit int) error {
return t.PrepareFromTop(limit)
}
func (m *manager) WipeStatusFromTimeline(timelineAccountID string, statusID string) (int, error) {
t := m.getOrCreateTimeline(timelineAccountID)
return t.Remove(statusID)
}
func (m *manager) WipeStatusFromAllTimelines(statusID string) error {
errors := []string{}
m.accountTimelines.Range(func(k interface{}, i interface{}) bool {
@ -195,7 +187,7 @@ func (m *manager) WipeStatusFromAllTimelines(statusID string) error {
errors = append(errors, err.Error())
}
return false
return true
})
var err error

View File

@ -3,9 +3,16 @@ package timeline
import (
"container/list"
"errors"
"github.com/sirupsen/logrus"
)
func (t *timeline) Remove(statusID string) (int, error) {
l := t.log.WithFields(logrus.Fields{
"func": "Remove",
"accountTimeline": t.accountID,
"statusID": statusID,
})
t.Lock()
defer t.Unlock()
var removed int
@ -19,6 +26,7 @@ func (t *timeline) Remove(statusID string) (int, error) {
return removed, errors.New("Remove: could not parse e as a postIndexEntry")
}
if entry.statusID == statusID {
l.Debug("found status in postIndex")
removeIndexes = append(removeIndexes, e)
}
}
@ -37,6 +45,7 @@ func (t *timeline) Remove(statusID string) (int, error) {
return removed, errors.New("Remove: could not parse e as a preparedPostsEntry")
}
if entry.statusID == statusID {
l.Debug("found status in preparedPosts")
removePrepared = append(removePrepared, e)
}
}
@ -46,5 +55,6 @@ func (t *timeline) Remove(statusID string) (int, error) {
removed = removed + 1
}
l.Debugf("removed %d entries", removed)
return removed, nil
}

View File

@ -649,7 +649,7 @@ func (c *converter) BoostToAS(boostWrapperStatus *gtsmodel.Status, boostingAccou
if err := c.db.GetByID(boostWrapperStatus.BoostOfID, b); err != nil {
return nil, fmt.Errorf("BoostToAS: error getting status with ID %s from the db: %s", boostWrapperStatus.BoostOfID, err)
}
boostWrapperStatus = b
boostWrapperStatus.GTSBoostedStatus = b
}
// create the announce