@ -34,70 +34,75 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/uris"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// Create processes the given form to create a new status, returning the api model representation of that status if it's OK.
//
// Precondition: the form's fields should have already been validated and normalized by the caller.
func ( p * Processor ) Create ( ctx context . Context , account * gtsmodel . Account , application * gtsmodel . Application , form * apimodel . AdvancedStatusCreateForm ) ( * apimodel . Status , gtserror . WithCode ) {
accountURIs := uris . GenerateURIsForAccount ( account . Username )
thisStatusID := id . NewULID ( )
local := true
sensitive := form . Sensitive
newStatus := & gtsmodel . Status {
ID : thisStatusID ,
URI : accountURIs . StatusesURI + "/" + thisStatusID ,
URL : accountURIs . StatusesURL + "/" + thisStatusID ,
CreatedAt : time . Now ( ) ,
UpdatedAt : time . Now ( ) ,
Local : & local ,
AccountID : account . ID ,
AccountURI : account . URI ,
ContentWarning : text . SanitizeToPlaintext ( form . SpoilerText ) ,
func ( p * Processor ) Create ( ctx context . Context , requestingAccount * gtsmodel . Account , application * gtsmodel . Application , form * apimodel . AdvancedStatusCreateForm ) ( * apimodel . Status , gtserror . WithCode ) {
// Generate new ID for status.
statusID := id . NewULID ( )
// Generate necessary URIs for username, to build status URIs.
accountURIs := uris . GenerateURIsForAccount ( requestingAccount . Username )
// Get current time.
now := time . Now ( )
status := & gtsmodel . Status {
ID : statusID ,
URI : accountURIs . StatusesURI + "/" + statusID ,
URL : accountURIs . StatusesURL + "/" + statusID ,
CreatedAt : now ,
UpdatedAt : now ,
Local : util . Ptr ( true ) ,
Account : requestingAccount ,
AccountID : requestingAccount . ID ,
AccountURI : requestingAccount . URI ,
ActivityStreamsType : ap . ObjectNote ,
Sensitive : & sensitive ,
Sensitive : & form . S ensitive,
CreatedWithApplicationID : application . ID ,
Text : form . Status ,
}
if errWithCode := processReplyToID ( ctx , p . state . DB , form , a ccount. ID , newS tatus) ; errWithCode != nil {
if errWithCode := p . processReplyToID ( ctx , form , requestingA ccount. ID , s tatus) ; errWithCode != nil {
return nil , errWithCode
}
if errWithCode := processMediaIDs ( ctx , p . state . DB , form , a ccount. ID , newS tatus) ; errWithCode != nil {
if errWithCode := p . processMediaIDs ( ctx , form , requestingA ccount. ID , s tatus) ; errWithCode != nil {
return nil , errWithCode
}
if err := processVisibility ( ctx , form , a ccount. Privacy , newS tatus) ; err != nil {
if err := processVisibility ( form , requestingA ccount. Privacy , s tatus) ; err != nil {
return nil , gtserror . NewErrorInternalError ( err )
}
if err := processLanguage ( ctx , form , a ccount. Language , newS tatus) ; err != nil {
if err := processLanguage ( form , requestingA ccount. Language , s tatus) ; err != nil {
return nil , gtserror . NewErrorInternalError ( err )
}
if err := processContent ( ctx , p . state . DB , p . formatter , p . parseMention , form , account . ID , newS tatus) ; err != nil {
if err := p . processContent ( ctx , p . parseMention , form , s tatus) ; err != nil {
return nil , gtserror . NewErrorInternalError ( err )
}
// put the new status in the database
if err := p . state . DB . PutStatus ( ctx , newS tatus) ; err != nil {
// Insert this new status in the database.
if err := p . state . DB . PutStatus ( ctx , s tatus) ; err != nil {
return nil , gtserror . NewErrorInternalError ( err )
}
// send it back to the processor for async processing
// send it back to the client API worker for async side-effects.
p . state . Workers . EnqueueClientAPI ( ctx , messages . FromClientAPI {
APObjectType : ap . ObjectNote ,
APActivityType : ap . ActivityCreate ,
GTSModel : newS tatus,
OriginAccount : a ccount,
GTSModel : s tatus,
OriginAccount : requestingA ccount,
} )
return p . apiStatus ( ctx , newStatus , a ccount)
return p . apiStatus ( ctx , status , requestingA ccount)
}
func processReplyToID ( ctx context . Context , dbService db . DB , form * apimodel . AdvancedStatusCreateForm , thisAccountID string , status * gtsmodel . Status ) gtserror . WithCode {
func ( p * Processor ) processReplyToID ( ctx context . Context , form * apimodel . AdvancedStatusCreateForm , thisAccountID string , status * gtsmodel . Status ) gtserror . WithCode {
if form . InReplyToID == "" {
return nil
}
@ -109,78 +114,74 @@ func processReplyToID(ctx context.Context, dbService db.DB, form *apimodel.Advan
// 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 )
inReplyTo , err := p . state . DB . GetStatusByID ( ctx , form . InReplyToID )
if err != nil && ! errors . Is ( err , db . ErrNoEntries ) {
err := gtserror . Newf ( "error fetching status %s from db: %w" , 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 inReplyTo == nil {
const text = "cannot reply to status that does not exist"
return gtserror . NewErrorBadRequest ( errors . New ( text ) , text )
}
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 )
if ! * inReplyTo . Replyable {
text := fmt . Sprintf ( "status %s is marked as not replyable" , form . InReplyToID )
return gtserror . NewErrorForbidden ( errors . New ( text ) , text )
}
if blocked , err := dbService . IsEitherBlocked ( ctx , thisAccountID , repliedAccount . ID ) ; err != nil {
err := fmt . Errorf ( "db error checking block: %s ", err )
if blocked , err := p . state . DB . IsEitherBlocked ( ctx , thisAccountID , inReplyTo . AccountID ) ; err != nil {
err := gtserror . Newf ( "error checking block in db: %w" , err )
return gtserror . NewErrorInternalError ( err )
} else if blocked {
err := fmt . Errorf ( "status with id % s not replyable", form . InReplyToID )
return gtserror . NewErrorNotFound ( err )
text := fmt . Sprintf ( "status %s i s not replyable", form . InReplyToID )
return gtserror . NewErrorNotFound ( errors . New ( text ) , text )
}
status . InReplyToID = repliedStatus . ID
status . InReplyToURI = repliedStatus . URI
status . InReplyToAccountID = repliedAccount . ID
// Set status fields from inReplyTo.
status . InReplyToID = inReplyTo . ID
status . InReplyToURI = inReplyTo . URI
status . InReplyToAccountID = inReplyTo . AccountID
return nil
}
func processMediaIDs ( ctx context . Context , dbService db . DB , form * apimodel . AdvancedStatusCreateForm , thisAccountID string , status * gtsmodel . Status ) gtserror . WithCode {
func ( p * Processor ) processMediaIDs ( ctx context . Context , form * apimodel . AdvancedStatusCreateForm , thisAccountID string , status * gtsmodel . Status ) gtserror . WithCode {
if form . MediaIDs == nil {
return nil
}
// Get minimum allowed char descriptions.
minChars := config . GetMediaDescriptionMinChars ( )
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 )
attachment , err := p . state . DB . GetAttachmentByID ( ctx , mediaID )
if err != nil && ! errors . Is ( err , db . ErrNoEntries ) {
err := gtserror . Newf ( "error fetching media from db: %w" , err )
return gtserror . NewErrorInternalError ( err )
}
if attachment == nil {
text := fmt . Sprintf ( "media %s not found" , mediaID )
return gtserror . NewErrorBadRequest ( errors . New ( text ) , text )
}
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 ( ) )
text := fmt . Sprint f( "media %s does not belong to account" , mediaID )
return gtserror . NewErrorBadRequest ( errors . New ( text ) , text )
}
if attachment . StatusID != "" || attachment . ScheduledStatusID != "" {
err = fmt . Error f( "ProcessMediaIDs: media with id %s i s already attached to a status" , mediaID )
return gtserror . NewErrorBadRequest ( err , err . Error ( ) )
text := fmt . Sprint f( "media %s already attached to status" , mediaID )
return gtserror . NewErrorBadRequest ( errors . New ( text ) , text )
}
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 ( ) )
if length := len ( [ ] rune ( attachment . Description ) ) ; length < minChars {
text := fmt . Sprintf ( "media %s description too short, at least %d required" , mediaID , minChars )
return gtserror . NewErrorBadRequest ( errors . New ( text ) , text )
}
attachments = append ( attachments , attachment )
@ -192,7 +193,7 @@ func processMediaIDs(ctx context.Context, dbService db.DB, form *apimodel.Advanc
return nil
}
func processVisibility ( ctx context . Context , form * apimodel . AdvancedStatusCreateForm , accountDefaultVis gtsmodel . Visibility , status * gtsmodel . Status ) error {
func processVisibility ( form * apimodel . AdvancedStatusCreateForm , accountDefaultVis gtsmodel . Visibility , status * gtsmodel . Status ) error {
// by default all flags are set to true
federated := true
boostable := true
@ -265,7 +266,7 @@ func processVisibility(ctx context.Context, form *apimodel.AdvancedStatusCreateF
return nil
}
func processLanguage ( ctx context . Context , form * apimodel . AdvancedStatusCreateForm , accountDefaultLanguage string , status * gtsmodel . Status ) error {
func processLanguage ( form * apimodel . AdvancedStatusCreateForm , accountDefaultLanguage string , status * gtsmodel . Status ) error {
if form . Language != "" {
status . Language = form . Language
} else {
@ -277,68 +278,80 @@ func processLanguage(ctx context.Context, form *apimodel.AdvancedStatusCreateFor
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
func ( p * Processor ) processContent ( ctx context . Context , parseMention gtsmodel . ParseMentionFunc , form * apimodel . AdvancedStatusCreateForm , status * gtsmodel . Status ) error {
if form . ContentType == "" {
// If content type wasn't specified, use the author's preferred content-type.
contentType := apimodel . StatusContentType ( status . Account . StatusContentType )
form . ContentType = contentType
}
// if content type wasn't specified we should try to figure out what content type this user prefers
if form . ContentType == "" {
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 )
}
// format is the currently set text formatting
// function, according to the provided content-type.
var format text . FormatFunc
switch acct . StatusContentType {
case "text/plain" :
form . ContentType = apimodel . StatusContentTypePlain
case "text/markdown" :
form . ContentType = apimodel . StatusContentTypeMarkdown
default :
form . ContentType = apimodel . StatusContentTypeDefault
}
// formatInput is a shorthand function to format the given input string with the
// currently set 'formatFunc', passing in all required args and returning result.
formatInput := func ( formatFunc text . FormatFunc , input string ) * text . FormatResult {
return formatFunc ( ctx , parseMention , status . AccountID , status . ID , input )
}
// parse content out of the status depending on what content type has been submitted
var f text . FormatFunc
switch form . ContentType {
// None given / set,
// use default (plain).
case "" :
fallthrough
// Format status according to text/plain.
case apimodel . StatusContentTypePlain :
f = formatter . FromPlain
format = p . formatter . FromPlain
// Format status according to text/markdown.
case apimodel . StatusContentTypeMarkdown :
f = formatter . FromMarkdown
format = p . formatter . FromMarkdown
// Unknown.
default :
return fmt . Errorf ( "format %s not recognised as a valid status format" , form . ContentType )
}
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 )
return fmt . Errorf ( "invalid status format: %q" , form . ContentType )
}
status . Tags = formatted . Tags
status . TagIDs = make ( [ ] string , 0 , len ( formatted . Tags ) )
for _ , gtstag := range formatted . Tags {
status . TagIDs = append ( status . TagIDs , gtstag . ID )
}
// Sanitize status text and format.
contentRes := formatInput ( format , form . Status )
status . Emojis = formatted . Emojis
status . EmojiIDs = make ( [ ] string , 0 , len ( formatted . Emojis ) )
for _ , gtsemoji := range formatted . Emojis {
status . EmojiID s = append ( status . EmojiID s , gtsemoji . ID )
}
// Collect formatted results.
status . Content = contentRes . HTML
status . Mentions = append ( status . Mentions , contentRes . Mentions ... )
status . Emojis = append ( status . Emojis , contentRes . Emojis ... )
status . Tags = append ( status . Tags , contentRes . Tags ... )
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 )
}
// From here-on-out just use emoji-only
// plain-text formatting as the FormatFunc.
format = p . formatter . FromPlainEmojiOnly
// Sanitize content warning and format.
spoiler := text . SanitizeToPlaintext ( form . SpoilerText )
warningRes := formatInput ( format , spoiler )
// Collect formatted results.
status . ContentWarning = warningRes . HTML
status . Emojis = append ( status . Emojis , warningRes . Emojis ... )
// Gather all the database IDs from each of the gathered status mentions, tags, and emojis.
status . MentionIDs = gatherIDs ( status . Mentions , func ( mention * gtsmodel . Mention ) string { return mention . ID } )
status . TagIDs = gatherIDs ( status . Tags , func ( tag * gtsmodel . Tag ) string { return tag . ID } )
status . EmojiIDs = gatherIDs ( status . Emojis , func ( emoji * gtsmodel . Emoji ) string { return emoji . ID } )
status . Content = formatted . HTML
return nil
}
// gatherIDs is a small utility function to gather IDs from a slice of type T.
func gatherIDs [ T any ] ( in [ ] T , getID func ( T ) string ) [ ] string {
if getID == nil {
// move nil check out loop.
panic ( "nil getID function" )
}
ids := make ( [ ] string , len ( in ) )
for i , t := range in {
ids [ i ] = getID ( t )
}
return ids
}