2023-03-12 15:00:57 +00:00
// 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/>.
2021-08-25 14:34:33 +01:00
2021-06-13 17:42:28 +01:00
package status
import (
2021-08-25 14:34:33 +01:00
"context"
2023-02-22 15:05:26 +00:00
"errors"
2021-06-13 17:42:28 +01:00
"fmt"
"time"
2021-08-31 14:59:12 +01:00
"github.com/superseriousbusiness/gotosocial/internal/ap"
2021-06-13 17:42:28 +01:00
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
2023-02-22 15:05:26 +00:00
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
2021-06-13 17:42:28 +01:00
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
2021-08-31 14:59:12 +01:00
"github.com/superseriousbusiness/gotosocial/internal/messages"
2021-07-26 19:25:54 +01:00
"github.com/superseriousbusiness/gotosocial/internal/text"
2023-02-22 15:05:26 +00:00
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
2021-12-20 14:19:53 +00:00
"github.com/superseriousbusiness/gotosocial/internal/uris"
2021-06-13 17:42:28 +01:00
)
2023-02-22 15:05:26 +00:00
// Create processes the given form to create a new status, returning the api model representation of that status if it's OK.
2023-08-07 09:25:54 +01:00
//
// Precondition: the form's fields should have already been validated and normalized by the caller.
2023-02-22 15:05:26 +00:00
func ( p * Processor ) Create ( ctx context . Context , account * gtsmodel . Account , application * gtsmodel . Application , form * apimodel . AdvancedStatusCreateForm ) ( * apimodel . Status , gtserror . WithCode ) {
2021-12-20 14:19:53 +00:00
accountURIs := uris . GenerateURIsForAccount ( account . Username )
2023-02-03 20:03:05 +00:00
thisStatusID := id . NewULID ( )
2022-08-15 11:35:05 +01:00
local := true
sensitive := form . Sensitive
2021-06-13 17:42:28 +01:00
newStatus := & gtsmodel . Status {
ID : thisStatusID ,
2022-05-07 16:55:27 +01:00
URI : accountURIs . StatusesURI + "/" + thisStatusID ,
URL : accountURIs . StatusesURL + "/" + thisStatusID ,
2021-06-13 17:42:28 +01:00
CreatedAt : time . Now ( ) ,
UpdatedAt : time . Now ( ) ,
2022-08-15 11:35:05 +01:00
Local : & local ,
2021-06-13 17:42:28 +01:00
AccountID : account . ID ,
2021-06-17 17:02:33 +01:00
AccountURI : account . URI ,
2022-05-26 10:37:13 +01:00
ContentWarning : text . SanitizePlaintext ( form . SpoilerText ) ,
2021-08-31 14:59:12 +01:00
ActivityStreamsType : ap . ObjectNote ,
2022-08-15 11:35:05 +01:00
Sensitive : & sensitive ,
2021-06-13 17:42:28 +01:00
CreatedWithApplicationID : application . ID ,
Text : form . Status ,
}
2023-03-01 18:26:53 +00:00
if errWithCode := processReplyToID ( ctx , p . state . DB , form , account . ID , newStatus ) ; errWithCode != nil {
2022-06-08 19:38:03 +01:00
return nil , errWithCode
2021-06-13 17:42:28 +01:00
}
2023-03-01 18:26:53 +00:00
if errWithCode := processMediaIDs ( ctx , p . state . DB , form , account . ID , newStatus ) ; errWithCode != nil {
2022-11-05 12:33:38 +00:00
return nil , errWithCode
2021-06-13 17:42:28 +01:00
}
2023-02-22 15:05:26 +00:00
if err := processVisibility ( ctx , form , account . Privacy , newStatus ) ; err != nil {
2021-06-13 17:42:28 +01:00
return nil , gtserror . NewErrorInternalError ( err )
}
2023-02-22 15:05:26 +00:00
if err := processLanguage ( ctx , form , account . Language , newStatus ) ; err != nil {
2021-06-13 17:42:28 +01:00
return nil , gtserror . NewErrorInternalError ( err )
}
2023-03-01 18:26:53 +00:00
if err := processContent ( ctx , p . state . DB , p . formatter , p . parseMention , form , account . ID , newStatus ) ; err != nil {
2021-06-13 17:42:28 +01:00
return nil , gtserror . NewErrorInternalError ( err )
}
2021-08-20 11:26:56 +01:00
// put the new status in the database
2023-03-01 18:26:53 +00:00
if err := p . state . DB . PutStatus ( ctx , newStatus ) ; err != nil {
2021-06-13 17:42:28 +01:00
return nil , gtserror . NewErrorInternalError ( err )
}
// send it back to the processor for async processing
2023-03-01 18:26:53 +00:00
p . state . Workers . EnqueueClientAPI ( ctx , messages . FromClientAPI {
2021-08-31 14:59:12 +01:00
APObjectType : ap . ObjectNote ,
APActivityType : ap . ActivityCreate ,
2021-06-13 17:42:28 +01:00
GTSModel : newStatus ,
OriginAccount : account ,
2022-04-28 13:23:11 +01:00
} )
2021-06-13 17:42:28 +01:00
2023-03-20 18:10:08 +00:00
return p . apiStatus ( ctx , newStatus , account )
2021-06-13 17:42:28 +01:00
}
2023-02-22 15:05:26 +00:00
func processReplyToID ( ctx context . Context , dbService db . DB , form * apimodel . AdvancedStatusCreateForm , thisAccountID string , status * gtsmodel . Status ) gtserror . WithCode {
if form . InReplyToID == "" {
return nil
}
// If this status is a reply to another status, we need to do a bit of work to establish whether or not this status can be posted:
//
// 1. Does the replied status exist in the database?
// 2. Is the replied status marked as replyable?
// 3. Does a block exist between either the current account or the account that posted the status it's replying to?
//
// If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
repliedStatus := & gtsmodel . Status { }
repliedAccount := & gtsmodel . Account { }
if err := dbService . GetByID ( ctx , form . InReplyToID , repliedStatus ) ; err != nil {
if err == db . ErrNoEntries {
err := fmt . Errorf ( "status with id %s not replyable because it doesn't exist" , form . InReplyToID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
}
err := fmt . Errorf ( "db error fetching status with id %s: %s" , form . InReplyToID , err )
return gtserror . NewErrorInternalError ( err )
}
if ! * repliedStatus . Replyable {
err := fmt . Errorf ( "status with id %s is marked as not replyable" , form . InReplyToID )
return gtserror . NewErrorForbidden ( err , err . Error ( ) )
}
if err := dbService . GetByID ( ctx , repliedStatus . AccountID , repliedAccount ) ; err != nil {
if err == db . ErrNoEntries {
err := fmt . Errorf ( "status with id %s not replyable because account id %s is not known" , form . InReplyToID , repliedStatus . AccountID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
}
err := fmt . Errorf ( "db error fetching account with id %s: %s" , repliedStatus . AccountID , err )
return gtserror . NewErrorInternalError ( err )
}
[performance] refactoring + add fave / follow / request / visibility caching (#1607)
* refactor visibility checking, add caching for visibility
* invalidate visibility cache items on account / status deletes
* fix requester ID passed to visibility cache nil ptr
* de-interface caches, fix home / public timeline caching + visibility
* finish adding code comments for visibility filter
* fix angry goconst linter warnings
* actually finish adding filter visibility code comments for timeline functions
* move home timeline status author check to after visibility
* remove now-unused code
* add more code comments
* add TODO code comment, update printed cache start names
* update printed cache names on stop
* start adding separate follow(request) delete db functions, add specific visibility cache tests
* add relationship type caching
* fix getting local account follows / followed-bys, other small codebase improvements
* simplify invalidation using cache hooks, add more GetAccountBy___() functions
* fix boosting to return 404 if not boostable but no error (to not leak status ID)
* remove dead code
* improved placement of cache invalidation
* update license headers
* add example follow, follow-request config entries
* add example visibility cache configuration to config file
* use specific PutFollowRequest() instead of just Put()
* add tests for all GetAccountBy()
* add GetBlockBy() tests
* update block to check primitive fields
* update and finish adding Get{Account,Block,Follow,FollowRequest}By() tests
* fix copy-pasted code
* update envparsing test
* whitespace
* fix bun struct tag
* add license header to gtscontext
* fix old license header
* improved error creation to not use fmt.Errorf() when not needed
* fix various rebase conflicts, fix account test
* remove commented-out code, fix-up mention caching
* fix mention select bun statement
* ensure mention target account populated, pass in context to customrenderer logging
* remove more uncommented code, fix typeutil test
* add statusfave database model caching
* add status fave cache configuration
* add status fave cache example config
* woops, catch missed error. nice catch linter!
* add back testrig panic on nil db
* update example configuration to match defaults, slight tweak to cache configuration defaults
* update envparsing test with new defaults
* fetch followingget to use the follow target account
* use accounnt.IsLocal() instead of empty domain check
* use constants for the cache visibility type check
* use bun.In() for notification type restriction in db query
* include replies when fetching PublicTimeline() (to account for single-author threads in Visibility{}.StatusPublicTimelineable())
* use bun query building for nested select statements to ensure working with postgres
* update public timeline future status checks to match visibility filter
* same as previous, for home timeline
* update public timeline tests to dynamically check for appropriate statuses
* migrate accounts to allow unique constraint on public_key
* provide minimal account with publicKey
---------
Signed-off-by: kim <grufwub@gmail.com>
Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
2023-03-28 14:03:14 +01:00
if blocked , err := dbService . IsEitherBlocked ( ctx , thisAccountID , repliedAccount . ID ) ; err != nil {
2023-02-22 15:05:26 +00:00
err := fmt . Errorf ( "db error checking block: %s" , err )
return gtserror . NewErrorInternalError ( err )
} else if blocked {
err := fmt . Errorf ( "status with id %s not replyable" , form . InReplyToID )
return gtserror . NewErrorNotFound ( err )
}
status . InReplyToID = repliedStatus . ID
status . InReplyToURI = repliedStatus . URI
status . InReplyToAccountID = repliedAccount . ID
return nil
}
func processMediaIDs ( ctx context . Context , dbService db . DB , form * apimodel . AdvancedStatusCreateForm , thisAccountID string , status * gtsmodel . Status ) gtserror . WithCode {
if form . MediaIDs == nil {
return nil
}
attachments := [ ] * gtsmodel . MediaAttachment { }
attachmentIDs := [ ] string { }
for _ , mediaID := range form . MediaIDs {
attachment , err := dbService . GetAttachmentByID ( ctx , mediaID )
if err != nil {
if errors . Is ( err , db . ErrNoEntries ) {
err = fmt . Errorf ( "ProcessMediaIDs: media not found for media id %s" , mediaID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
}
err = fmt . Errorf ( "ProcessMediaIDs: db error for media id %s" , mediaID )
return gtserror . NewErrorInternalError ( err )
}
if attachment . AccountID != thisAccountID {
err = fmt . Errorf ( "ProcessMediaIDs: media with id %s does not belong to account %s" , mediaID , thisAccountID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
}
if attachment . StatusID != "" || attachment . ScheduledStatusID != "" {
err = fmt . Errorf ( "ProcessMediaIDs: media with id %s is already attached to a status" , mediaID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
}
minDescriptionChars := config . GetMediaDescriptionMinChars ( )
if descriptionLength := len ( [ ] rune ( attachment . Description ) ) ; descriptionLength < minDescriptionChars {
err = fmt . Errorf ( "ProcessMediaIDs: description too short! media description of at least %d chararacters is required but %d was provided for media with id %s" , minDescriptionChars , descriptionLength , mediaID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
}
attachments = append ( attachments , attachment )
attachmentIDs = append ( attachmentIDs , attachment . ID )
}
status . Attachments = attachments
status . AttachmentIDs = attachmentIDs
return nil
}
func processVisibility ( ctx context . Context , form * apimodel . AdvancedStatusCreateForm , accountDefaultVis gtsmodel . Visibility , status * gtsmodel . Status ) error {
// by default all flags are set to true
federated := true
boostable := true
replyable := true
likeable := true
// If visibility isn't set on the form, then just take the account default.
// If that's also not set, take the default for the whole instance.
var vis gtsmodel . Visibility
switch {
case form . Visibility != "" :
vis = typeutils . APIVisToVis ( form . Visibility )
case accountDefaultVis != "" :
vis = accountDefaultVis
default :
vis = gtsmodel . VisibilityDefault
}
switch vis {
case gtsmodel . VisibilityPublic :
// for public, there's no need to change any of the advanced flags from true regardless of what the user filled out
break
case gtsmodel . VisibilityUnlocked :
// for unlocked the user can set any combination of flags they like so look at them all to see if they're set and then apply them
if form . Federated != nil {
federated = * form . Federated
}
if form . Boostable != nil {
boostable = * form . Boostable
}
if form . Replyable != nil {
replyable = * form . Replyable
}
if form . Likeable != nil {
likeable = * form . Likeable
}
case gtsmodel . VisibilityFollowersOnly , gtsmodel . VisibilityMutualsOnly :
// for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them
boostable = false
if form . Federated != nil {
federated = * form . Federated
}
if form . Replyable != nil {
replyable = * form . Replyable
}
if form . Likeable != nil {
likeable = * form . Likeable
}
case gtsmodel . VisibilityDirect :
// direct is pretty easy: there's only one possible setting so return it
federated = true
boostable = false
replyable = true
likeable = true
}
status . Visibility = vis
status . Federated = & federated
status . Boostable = & boostable
status . Replyable = & replyable
status . Likeable = & likeable
return nil
}
func processLanguage ( ctx context . Context , form * apimodel . AdvancedStatusCreateForm , accountDefaultLanguage string , status * gtsmodel . Status ) error {
if form . Language != "" {
status . Language = form . Language
} else {
status . Language = accountDefaultLanguage
}
if status . Language == "" {
return errors . New ( "no language given either in status create form or account default" )
}
return nil
}
func processContent ( ctx context . Context , dbService db . DB , formatter text . Formatter , parseMention gtsmodel . ParseMentionFunc , form * apimodel . AdvancedStatusCreateForm , accountID string , status * gtsmodel . Status ) error {
// if there's nothing in the status at all we can just return early
if form . Status == "" {
status . Content = ""
return nil
}
2023-03-02 11:06:40 +00:00
// if content type wasn't specified we should try to figure out what content type this user prefers
if form . ContentType == "" {
2023-02-22 15:05:26 +00:00
acct , err := dbService . GetAccountByID ( ctx , accountID )
if err != nil {
return fmt . Errorf ( "error processing new content: couldn't retrieve account from db to check post format: %s" , err )
}
2023-03-02 11:06:40 +00:00
switch acct . StatusContentType {
case "text/plain" :
form . ContentType = apimodel . StatusContentTypePlain
case "text/markdown" :
form . ContentType = apimodel . StatusContentTypeMarkdown
2023-02-22 15:05:26 +00:00
default :
2023-03-02 11:06:40 +00:00
form . ContentType = apimodel . StatusContentTypeDefault
2023-02-22 15:05:26 +00:00
}
}
2023-03-02 11:06:40 +00:00
// parse content out of the status depending on what content type has been submitted
2023-02-22 15:05:26 +00:00
var f text . FormatFunc
2023-03-02 11:06:40 +00:00
switch form . ContentType {
case apimodel . StatusContentTypePlain :
2023-02-22 15:05:26 +00:00
f = formatter . FromPlain
2023-03-02 11:06:40 +00:00
case apimodel . StatusContentTypeMarkdown :
2023-02-22 15:05:26 +00:00
f = formatter . FromMarkdown
default :
2023-03-02 11:06:40 +00:00
return fmt . Errorf ( "format %s not recognised as a valid status format" , form . ContentType )
2023-02-22 15:05:26 +00:00
}
formatted := f ( ctx , parseMention , accountID , status . ID , form . Status )
// add full populated gts {mentions, tags, emojis} to the status for passing them around conveniently
// add just their ids to the status for putting in the db
status . Mentions = formatted . Mentions
status . MentionIDs = make ( [ ] string , 0 , len ( formatted . Mentions ) )
for _ , gtsmention := range formatted . Mentions {
status . MentionIDs = append ( status . MentionIDs , gtsmention . ID )
}
status . Tags = formatted . Tags
status . TagIDs = make ( [ ] string , 0 , len ( formatted . Tags ) )
for _ , gtstag := range formatted . Tags {
status . TagIDs = append ( status . TagIDs , gtstag . ID )
}
status . Emojis = formatted . Emojis
status . EmojiIDs = make ( [ ] string , 0 , len ( formatted . Emojis ) )
for _ , gtsemoji := range formatted . Emojis {
status . EmojiIDs = append ( status . EmojiIDs , gtsemoji . ID )
}
spoilerformatted := formatter . FromPlainEmojiOnly ( ctx , parseMention , accountID , status . ID , form . SpoilerText )
for _ , gtsemoji := range spoilerformatted . Emojis {
status . Emojis = append ( status . Emojis , gtsemoji )
status . EmojiIDs = append ( status . EmojiIDs , gtsemoji . ID )
}
status . Content = formatted . HTML
return nil
}