pin.go (5582B)
1 // GoToSocial 2 // Copyright (C) GoToSocial Authors admin@gotosocial.org 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful, 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package status 19 20 import ( 21 "context" 22 "errors" 23 "fmt" 24 "time" 25 26 apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 27 "github.com/superseriousbusiness/gotosocial/internal/gtserror" 28 "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" 29 ) 30 31 const allowedPinnedCount = 10 32 33 // getPinnableStatus fetches targetStatusID status and ensures that requestingAccountID 34 // can pin or unpin it. 35 // 36 // It checks: 37 // - Status is visible to requesting account. 38 // - Status belongs to requesting account. 39 // - Status is public, unlisted, or followers-only. 40 // - Status is not a boost. 41 func (p *Processor) getPinnableStatus(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*gtsmodel.Status, gtserror.WithCode) { 42 targetStatus, errWithCode := p.getVisibleStatus(ctx, requestingAccount, targetStatusID) 43 if errWithCode != nil { 44 return nil, errWithCode 45 } 46 47 if targetStatus.AccountID != requestingAccount.ID { 48 err := fmt.Errorf("status %s does not belong to account %s", targetStatusID, requestingAccount.ID) 49 return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) 50 } 51 52 if targetStatus.Visibility == gtsmodel.VisibilityDirect { 53 err := errors.New("cannot pin direct messages") 54 return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) 55 } 56 57 if targetStatus.BoostOfID != "" { 58 err := errors.New("cannot pin boosts") 59 return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) 60 } 61 62 return targetStatus, nil 63 } 64 65 // PinCreate pins the target status to the top of requestingAccount's profile, if possible. 66 // 67 // Conditions for a pin to work: 68 // - Status belongs to requesting account. 69 // - Status is public, unlisted, or followers-only. 70 // - Status is not a boost. 71 // - Status is not already pinnd. 72 // - Limit of pinned statuses not yet met or exceeded. 73 // 74 // If the conditions can't be met, then code 422 Unprocessable Entity will be returned. 75 func (p *Processor) PinCreate(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { 76 targetStatus, errWithCode := p.getPinnableStatus(ctx, requestingAccount, targetStatusID) 77 if errWithCode != nil { 78 return nil, errWithCode 79 } 80 81 if !targetStatus.PinnedAt.IsZero() { 82 err := errors.New("status already pinned") 83 return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) 84 } 85 86 pinnedCount, err := p.state.DB.CountAccountPinned(ctx, requestingAccount.ID) 87 if err != nil { 88 return nil, gtserror.NewErrorInternalError(fmt.Errorf("error checking number of pinned statuses: %w", err)) 89 } 90 91 if pinnedCount >= allowedPinnedCount { 92 err = fmt.Errorf("status pin limit exceeded, you've already pinned %d status(es) out of %d", pinnedCount, allowedPinnedCount) 93 return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) 94 } 95 96 targetStatus.PinnedAt = time.Now() 97 if err := p.state.DB.UpdateStatus(ctx, targetStatus, "pinned_at"); err != nil { 98 err = gtserror.Newf("db error pinning status: %w", err) 99 return nil, gtserror.NewErrorInternalError(err) 100 } 101 102 if err := p.invalidateStatus(ctx, requestingAccount.ID, targetStatusID); err != nil { 103 err = gtserror.Newf("error invalidating status from timelines: %w", err) 104 return nil, gtserror.NewErrorInternalError(err) 105 } 106 107 return p.apiStatus(ctx, targetStatus, requestingAccount) 108 } 109 110 // PinRemove unpins the target status from the top of requestingAccount's profile, if possible. 111 // 112 // Conditions for an unpin to work: 113 // - Status belongs to requesting account. 114 // - Status is public, unlisted, or followers-only. 115 // - Status is not a boost. 116 // 117 // If the conditions can't be met, then code 422 Unprocessable Entity will be returned. 118 // 119 // Unlike with PinCreate, statuses that are already unpinned will not return 422, but just do 120 // nothing and return the api model representation of the status, to conform to the masto API. 121 func (p *Processor) PinRemove(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { 122 targetStatus, errWithCode := p.getPinnableStatus(ctx, requestingAccount, targetStatusID) 123 if errWithCode != nil { 124 return nil, errWithCode 125 } 126 127 if targetStatus.PinnedAt.IsZero() { 128 return p.apiStatus(ctx, targetStatus, requestingAccount) 129 } 130 131 targetStatus.PinnedAt = time.Time{} 132 if err := p.state.DB.UpdateStatus(ctx, targetStatus, "pinned_at"); err != nil { 133 err = gtserror.Newf("db error unpinning status: %w", err) 134 return nil, gtserror.NewErrorInternalError(err) 135 } 136 137 if err := p.invalidateStatus(ctx, requestingAccount.ID, targetStatusID); err != nil { 138 err = gtserror.Newf("error invalidating status from timelines: %w", err) 139 return nil, gtserror.NewErrorInternalError(err) 140 } 141 142 return p.apiStatus(ctx, targetStatus, requestingAccount) 143 }