mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 08:22:27 -05:00 
			
		
		
		
	* start working on notifs for new posts * tidy up a bit * update swagger * carry over show reblogs + notify from follow req * test notify on status post * update column slice * dedupe update logic + add tests * fix own boosts not being timelined * avoid type check, passing unnecessary accounts * remove unnecessary 'inReplyToID' check * add a couple todo's for future db functions
		
			
				
	
	
		
			503 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			503 lines
		
	
	
	
		
			16 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // GoToSocial
 | |
| // Copyright (C) GoToSocial Authors admin@gotosocial.org
 | |
| // SPDX-License-Identifier: AGPL-3.0-or-later
 | |
| //
 | |
| // 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 processing
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/config"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/db"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/email"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/id"
 | |
| 	"github.com/superseriousbusiness/gotosocial/internal/stream"
 | |
| )
 | |
| 
 | |
| // timelineAndNotifyStatus processes the given new status and inserts it into
 | |
| // the HOME timelines of accounts that follow the status author. It will also
 | |
| // handle notifications for any mentions attached to the account, and also
 | |
| // notifications for any local accounts that want a notif when this account posts.
 | |
| func (p *Processor) timelineAndNotifyStatus(ctx context.Context, status *gtsmodel.Status) error {
 | |
| 	// Ensure status fully populated; including account, mentions, etc.
 | |
| 	if err := p.state.DB.PopulateStatus(ctx, status); err != nil {
 | |
| 		return fmt.Errorf("timelineAndNotifyStatus: error populating status with id %s: %w", status.ID, err)
 | |
| 	}
 | |
| 
 | |
| 	// Get local followers of the account that posted the status.
 | |
| 	follows, err := p.state.DB.GetAccountLocalFollowers(ctx, status.AccountID)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("timelineAndNotifyStatus: error getting local followers for account id %s: %w", status.AccountID, err)
 | |
| 	}
 | |
| 
 | |
| 	// If the poster is also local, add a fake entry for them
 | |
| 	// so they can see their own status in their timeline.
 | |
| 	if status.Account.IsLocal() {
 | |
| 		follows = append(follows, >smodel.Follow{
 | |
| 			AccountID:   status.AccountID,
 | |
| 			Account:     status.Account,
 | |
| 			Notify:      func() *bool { b := false; return &b }(), // Account shouldn't notify itself.
 | |
| 			ShowReblogs: func() *bool { b := true; return &b }(),  // Account should show own reblogs.
 | |
| 		})
 | |
| 	}
 | |
| 
 | |
| 	// Timeline the status for each local follower of this account.
 | |
| 	// This will also handle notifying any followers with notify
 | |
| 	// set to true on their follow.
 | |
| 	if err := p.timelineAndNotifyStatusForFollowers(ctx, status, follows); err != nil {
 | |
| 		return fmt.Errorf("timelineAndNotifyStatus: error timelining status %s for followers: %w", status.ID, err)
 | |
| 	}
 | |
| 
 | |
| 	// Notify each local account that's mentioned by this status.
 | |
| 	if err := p.notifyStatusMentions(ctx, status); err != nil {
 | |
| 		return fmt.Errorf("timelineAndNotifyStatus: error notifying status mentions for status %s: %w", status.ID, err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (p *Processor) timelineAndNotifyStatusForFollowers(ctx context.Context, status *gtsmodel.Status, follows []*gtsmodel.Follow) error {
 | |
| 	var (
 | |
| 		errs  = make(gtserror.MultiError, 0, len(follows))
 | |
| 		boost = status.BoostOfID != ""
 | |
| 		reply = status.InReplyToURI != ""
 | |
| 	)
 | |
| 
 | |
| 	for _, follow := range follows {
 | |
| 		if sr := follow.ShowReblogs; boost && (sr == nil || !*sr) {
 | |
| 			// This is a boost, but this follower
 | |
| 			// doesn't want to see those from this
 | |
| 			// account, so just skip everything.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Add status to home timeline for this
 | |
| 		// follower, and stream it if applicable.
 | |
| 		if timelined, err := p.timelineStatusForAccount(ctx, follow.Account, status); err != nil {
 | |
| 			errs.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error timelining status: %w", err))
 | |
| 			continue
 | |
| 		} else if !timelined {
 | |
| 			// Status wasn't added to home tomeline,
 | |
| 			// so we shouldn't notify it either.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		if n := follow.Notify; n == nil || !*n {
 | |
| 			// This follower doesn't have notifications
 | |
| 			// set for this account's new posts, so bail.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		if boost || reply {
 | |
| 			// Don't notify for boosts or replies.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// If we reach here, we know:
 | |
| 		//
 | |
| 		//   - This follower wants to be notified when this account posts.
 | |
| 		//   - This is a top-level post (not a reply).
 | |
| 		//   - This is not a boost of another post.
 | |
| 		//   - The post is visible in this follower's home timeline.
 | |
| 		//
 | |
| 		// That means we can officially notify this one.
 | |
| 		if err := p.notify(
 | |
| 			ctx,
 | |
| 			gtsmodel.NotificationStatus,
 | |
| 			follow.AccountID,
 | |
| 			status.AccountID,
 | |
| 			status.ID,
 | |
| 		); err != nil {
 | |
| 			errs.Append(fmt.Errorf("timelineAndNotifyStatusForFollowers: error notifying account %s about new status: %w", follow.AccountID, err))
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return errs.Combine()
 | |
| }
 | |
| 
 | |
| // timelineStatusForAccount puts the given status in the HOME timeline
 | |
| // of the account with given accountID, if it's HomeTimelineable.
 | |
| //
 | |
| // If the status was inserted into the home timeline of the given account,
 | |
| // true will be returned + it will also be streamed via websockets to the user.
 | |
| func (p *Processor) timelineStatusForAccount(ctx context.Context, account *gtsmodel.Account, status *gtsmodel.Status) (bool, error) {
 | |
| 	// Make sure the status is timelineable.
 | |
| 	if timelineable, err := p.filter.StatusHomeTimelineable(ctx, account, status); err != nil {
 | |
| 		err = fmt.Errorf("timelineStatusForAccount: error getting timelineability for status for timeline with id %s: %w", account.ID, err)
 | |
| 		return false, err
 | |
| 	} else if !timelineable {
 | |
| 		// Nothing to do.
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	// Insert status in the home timeline of account.
 | |
| 	if inserted, err := p.statusTimelines.IngestOne(ctx, account.ID, status); err != nil {
 | |
| 		err = fmt.Errorf("timelineStatusForAccount: error ingesting status %s: %w", status.ID, err)
 | |
| 		return false, err
 | |
| 	} else if !inserted {
 | |
| 		// Nothing more to do.
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	// The status was inserted so stream it to the user.
 | |
| 	apiStatus, err := p.tc.StatusToAPIStatus(ctx, status, account)
 | |
| 	if err != nil {
 | |
| 		err = fmt.Errorf("timelineStatusForAccount: error converting status %s to frontend representation: %w", status.ID, err)
 | |
| 		return true, err
 | |
| 	}
 | |
| 
 | |
| 	if err := p.stream.Update(apiStatus, account, stream.TimelineHome); err != nil {
 | |
| 		err = fmt.Errorf("timelineStatusForAccount: error streaming update for status %s: %w", status.ID, err)
 | |
| 		return true, err
 | |
| 	}
 | |
| 
 | |
| 	return true, nil
 | |
| }
 | |
| 
 | |
| func (p *Processor) notifyStatusMentions(ctx context.Context, status *gtsmodel.Status) error {
 | |
| 	errs := make(gtserror.MultiError, 0, len(status.Mentions))
 | |
| 
 | |
| 	for _, m := range status.Mentions {
 | |
| 		if err := p.notify(
 | |
| 			ctx,
 | |
| 			gtsmodel.NotificationMention,
 | |
| 			m.TargetAccountID,
 | |
| 			m.OriginAccountID,
 | |
| 			m.StatusID,
 | |
| 		); err != nil {
 | |
| 			errs.Append(err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return errs.Combine()
 | |
| }
 | |
| 
 | |
| func (p *Processor) notifyFollowRequest(ctx context.Context, followRequest *gtsmodel.FollowRequest) error {
 | |
| 	return p.notify(
 | |
| 		ctx,
 | |
| 		gtsmodel.NotificationFollowRequest,
 | |
| 		followRequest.TargetAccountID,
 | |
| 		followRequest.AccountID,
 | |
| 		"",
 | |
| 	)
 | |
| }
 | |
| 
 | |
| func (p *Processor) notifyFollow(ctx context.Context, follow *gtsmodel.Follow, targetAccount *gtsmodel.Account) error {
 | |
| 	// Remove previous follow request notification, if it exists.
 | |
| 	prevNotif, err := p.state.DB.GetNotification(
 | |
| 		gtscontext.SetBarebones(ctx),
 | |
| 		gtsmodel.NotificationFollowRequest,
 | |
| 		targetAccount.ID,
 | |
| 		follow.AccountID,
 | |
| 		"",
 | |
| 	)
 | |
| 	if err != nil && !errors.Is(err, db.ErrNoEntries) {
 | |
| 		// Proper error while checking.
 | |
| 		return fmt.Errorf("notifyFollow: db error checking for previous follow request notification: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if prevNotif != nil {
 | |
| 		// Previous notification existed, delete.
 | |
| 		if err := p.state.DB.DeleteNotificationByID(ctx, prevNotif.ID); err != nil {
 | |
| 			return fmt.Errorf("notifyFollow: db error removing previous follow request notification %s: %w", prevNotif.ID, err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Now notify the follow itself.
 | |
| 	return p.notify(
 | |
| 		ctx,
 | |
| 		gtsmodel.NotificationFollow,
 | |
| 		targetAccount.ID,
 | |
| 		follow.AccountID,
 | |
| 		"",
 | |
| 	)
 | |
| }
 | |
| 
 | |
| func (p *Processor) notifyFave(ctx context.Context, fave *gtsmodel.StatusFave) error {
 | |
| 	if fave.TargetAccountID == fave.AccountID {
 | |
| 		// Self-fave, nothing to do.
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	return p.notify(
 | |
| 		ctx,
 | |
| 		gtsmodel.NotificationFave,
 | |
| 		fave.TargetAccountID,
 | |
| 		fave.AccountID,
 | |
| 		fave.StatusID,
 | |
| 	)
 | |
| }
 | |
| 
 | |
| func (p *Processor) notifyAnnounce(ctx context.Context, status *gtsmodel.Status) error {
 | |
| 	if status.BoostOfID == "" {
 | |
| 		// Not a boost, nothing to do.
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	if status.BoostOfAccountID == status.AccountID {
 | |
| 		// Self-boost, nothing to do.
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	return p.notify(
 | |
| 		ctx,
 | |
| 		gtsmodel.NotificationReblog,
 | |
| 		status.BoostOfAccountID,
 | |
| 		status.AccountID,
 | |
| 		status.ID,
 | |
| 	)
 | |
| }
 | |
| 
 | |
| func (p *Processor) notify(
 | |
| 	ctx context.Context,
 | |
| 	notificationType gtsmodel.NotificationType,
 | |
| 	targetAccountID string,
 | |
| 	originAccountID string,
 | |
| 	statusID string,
 | |
| ) error {
 | |
| 	targetAccount, err := p.state.DB.GetAccountByID(ctx, targetAccountID)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("notify: error getting target account %s: %w", targetAccountID, err)
 | |
| 	}
 | |
| 
 | |
| 	if !targetAccount.IsLocal() {
 | |
| 		// Nothing to do.
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	// Make sure a notification doesn't
 | |
| 	// already exist with these params.
 | |
| 	if _, err := p.state.DB.GetNotification(
 | |
| 		ctx,
 | |
| 		notificationType,
 | |
| 		targetAccountID,
 | |
| 		originAccountID,
 | |
| 		statusID,
 | |
| 	); err == nil {
 | |
| 		// Notification exists, nothing to do.
 | |
| 		return nil
 | |
| 	} else if !errors.Is(err, db.ErrNoEntries) {
 | |
| 		// Real error.
 | |
| 		return fmt.Errorf("notify: error checking existence of notification: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Notification doesn't yet exist, so
 | |
| 	// we need to create + store one.
 | |
| 	notif := >smodel.Notification{
 | |
| 		ID:               id.NewULID(),
 | |
| 		NotificationType: notificationType,
 | |
| 		TargetAccountID:  targetAccountID,
 | |
| 		OriginAccountID:  originAccountID,
 | |
| 		StatusID:         statusID,
 | |
| 	}
 | |
| 
 | |
| 	if err := p.state.DB.PutNotification(ctx, notif); err != nil {
 | |
| 		return fmt.Errorf("notify: error putting notification in database: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Stream notification to the user.
 | |
| 	apiNotif, err := p.tc.NotificationToAPINotification(ctx, notif)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("notify: error converting notification to api representation: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if err := p.stream.Notify(apiNotif, targetAccount); err != nil {
 | |
| 		return fmt.Errorf("notify: error streaming notification to account: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // wipeStatus contains common logic used to totally delete a status
 | |
| // + all its attachments, notifications, boosts, and timeline entries.
 | |
| func (p *Processor) wipeStatus(ctx context.Context, statusToDelete *gtsmodel.Status, deleteAttachments bool) error {
 | |
| 	// either delete all attachments for this status, or simply
 | |
| 	// unattach all attachments for this status, so they'll be
 | |
| 	// cleaned later by a separate process; reason to unattach rather
 | |
| 	// than delete is that the poster might want to reattach them
 | |
| 	// to another status immediately (in case of delete + redraft)
 | |
| 	if deleteAttachments {
 | |
| 		// todo: p.state.DB.DeleteAttachmentsForStatus
 | |
| 		for _, a := range statusToDelete.AttachmentIDs {
 | |
| 			if err := p.media.Delete(ctx, a); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 	} else {
 | |
| 		// todo: p.state.DB.UnattachAttachmentsForStatus
 | |
| 		for _, a := range statusToDelete.AttachmentIDs {
 | |
| 			if _, err := p.media.Unattach(ctx, statusToDelete.Account, a); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// delete all mention entries generated by this status
 | |
| 	// todo: p.state.DB.DeleteMentionsForStatus
 | |
| 	for _, id := range statusToDelete.MentionIDs {
 | |
| 		if err := p.state.DB.DeleteMentionByID(ctx, id); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// delete all notification entries generated by this status
 | |
| 	if err := p.state.DB.DeleteNotificationsForStatus(ctx, statusToDelete.ID); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// delete all bookmarks that point to this status
 | |
| 	if err := p.state.DB.DeleteStatusBookmarksForStatus(ctx, statusToDelete.ID); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// delete all faves of this status
 | |
| 	if err := p.state.DB.DeleteStatusFavesForStatus(ctx, statusToDelete.ID); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// delete all boosts for this status + remove them from timelines
 | |
| 	if boosts, err := p.state.DB.GetStatusReblogs(ctx, statusToDelete); err == nil {
 | |
| 		for _, b := range boosts {
 | |
| 			if err := p.deleteStatusFromTimelines(ctx, b); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 			if err := p.state.DB.DeleteStatusByID(ctx, b.ID); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// delete this status from any and all timelines
 | |
| 	if err := p.deleteStatusFromTimelines(ctx, statusToDelete); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	// delete the status itself
 | |
| 	if err := p.state.DB.DeleteStatusByID(ctx, statusToDelete.ID); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // deleteStatusFromTimelines completely removes the given status from all timelines.
 | |
| // It will also stream deletion of the status to all open streams.
 | |
| func (p *Processor) deleteStatusFromTimelines(ctx context.Context, status *gtsmodel.Status) error {
 | |
| 	if err := p.statusTimelines.WipeItemFromAllTimelines(ctx, status.ID); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return p.stream.Delete(status.ID)
 | |
| }
 | |
| 
 | |
| /*
 | |
| 	EMAIL FUNCTIONS
 | |
| */
 | |
| 
 | |
| func (p *Processor) emailReport(ctx context.Context, report *gtsmodel.Report) error {
 | |
| 	instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("emailReport: error getting instance: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	toAddresses, err := p.state.DB.GetInstanceModeratorAddresses(ctx)
 | |
| 	if err != nil {
 | |
| 		if errors.Is(err, db.ErrNoEntries) {
 | |
| 			// No registered moderator addresses.
 | |
| 			return nil
 | |
| 		}
 | |
| 		return fmt.Errorf("emailReport: error getting instance moderator addresses: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if report.Account == nil {
 | |
| 		report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("emailReport: error getting report account: %w", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if report.TargetAccount == nil {
 | |
| 		report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("emailReport: error getting report target account: %w", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	reportData := email.NewReportData{
 | |
| 		InstanceURL:        instance.URI,
 | |
| 		InstanceName:       instance.Title,
 | |
| 		ReportURL:          instance.URI + "/settings/admin/reports/" + report.ID,
 | |
| 		ReportDomain:       report.Account.Domain,
 | |
| 		ReportTargetDomain: report.TargetAccount.Domain,
 | |
| 	}
 | |
| 
 | |
| 	if err := p.emailSender.SendNewReportEmail(toAddresses, reportData); err != nil {
 | |
| 		return fmt.Errorf("emailReport: error emailing instance moderators: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (p *Processor) emailReportClosed(ctx context.Context, report *gtsmodel.Report) error {
 | |
| 	user, err := p.state.DB.GetUserByAccountID(ctx, report.Account.ID)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("emailReportClosed: db error getting user: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if user.ConfirmedAt.IsZero() || !*user.Approved || *user.Disabled || user.Email == "" {
 | |
| 		// Only email users who:
 | |
| 		// - are confirmed
 | |
| 		// - are approved
 | |
| 		// - are not disabled
 | |
| 		// - have an email address
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	instance, err := p.state.DB.GetInstance(ctx, config.GetHost())
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("emailReportClosed: db error getting instance: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if report.Account == nil {
 | |
| 		report.Account, err = p.state.DB.GetAccountByID(ctx, report.AccountID)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("emailReportClosed: error getting report account: %w", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if report.TargetAccount == nil {
 | |
| 		report.TargetAccount, err = p.state.DB.GetAccountByID(ctx, report.TargetAccountID)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("emailReportClosed: error getting report target account: %w", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	reportClosedData := email.ReportClosedData{
 | |
| 		Username:             report.Account.Username,
 | |
| 		InstanceURL:          instance.URI,
 | |
| 		InstanceName:         instance.Title,
 | |
| 		ReportTargetUsername: report.TargetAccount.Username,
 | |
| 		ReportTargetDomain:   report.TargetAccount.Domain,
 | |
| 		ActionTakenComment:   report.ActionTaken,
 | |
| 	}
 | |
| 
 | |
| 	return p.emailSender.SendReportClosedEmail(user.Email, reportClosedData)
 | |
| }
 |