create.go (12787B)
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 "github.com/superseriousbusiness/gotosocial/internal/ap" 27 apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 28 "github.com/superseriousbusiness/gotosocial/internal/config" 29 "github.com/superseriousbusiness/gotosocial/internal/db" 30 "github.com/superseriousbusiness/gotosocial/internal/gtserror" 31 "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" 32 "github.com/superseriousbusiness/gotosocial/internal/id" 33 "github.com/superseriousbusiness/gotosocial/internal/messages" 34 "github.com/superseriousbusiness/gotosocial/internal/text" 35 "github.com/superseriousbusiness/gotosocial/internal/typeutils" 36 "github.com/superseriousbusiness/gotosocial/internal/uris" 37 ) 38 39 // Create processes the given form to create a new status, returning the api model representation of that status if it's OK. 40 func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) { 41 accountURIs := uris.GenerateURIsForAccount(account.Username) 42 thisStatusID := id.NewULID() 43 local := true 44 sensitive := form.Sensitive 45 46 newStatus := >smodel.Status{ 47 ID: thisStatusID, 48 URI: accountURIs.StatusesURI + "/" + thisStatusID, 49 URL: accountURIs.StatusesURL + "/" + thisStatusID, 50 CreatedAt: time.Now(), 51 UpdatedAt: time.Now(), 52 Local: &local, 53 AccountID: account.ID, 54 AccountURI: account.URI, 55 ContentWarning: text.SanitizePlaintext(form.SpoilerText), 56 ActivityStreamsType: ap.ObjectNote, 57 Sensitive: &sensitive, 58 Language: form.Language, 59 CreatedWithApplicationID: application.ID, 60 Text: form.Status, 61 } 62 63 if errWithCode := processReplyToID(ctx, p.state.DB, form, account.ID, newStatus); errWithCode != nil { 64 return nil, errWithCode 65 } 66 67 if errWithCode := processMediaIDs(ctx, p.state.DB, form, account.ID, newStatus); errWithCode != nil { 68 return nil, errWithCode 69 } 70 71 if err := processVisibility(ctx, form, account.Privacy, newStatus); err != nil { 72 return nil, gtserror.NewErrorInternalError(err) 73 } 74 75 if err := processLanguage(ctx, form, account.Language, newStatus); err != nil { 76 return nil, gtserror.NewErrorInternalError(err) 77 } 78 79 if err := processContent(ctx, p.state.DB, p.formatter, p.parseMention, form, account.ID, newStatus); err != nil { 80 return nil, gtserror.NewErrorInternalError(err) 81 } 82 83 // put the new status in the database 84 if err := p.state.DB.PutStatus(ctx, newStatus); err != nil { 85 return nil, gtserror.NewErrorInternalError(err) 86 } 87 88 // send it back to the processor for async processing 89 p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ 90 APObjectType: ap.ObjectNote, 91 APActivityType: ap.ActivityCreate, 92 GTSModel: newStatus, 93 OriginAccount: account, 94 }) 95 96 return p.apiStatus(ctx, newStatus, account) 97 } 98 99 func processReplyToID(ctx context.Context, dbService db.DB, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { 100 if form.InReplyToID == "" { 101 return nil 102 } 103 104 // 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: 105 // 106 // 1. Does the replied status exist in the database? 107 // 2. Is the replied status marked as replyable? 108 // 3. Does a block exist between either the current account or the account that posted the status it's replying to? 109 // 110 // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. 111 repliedStatus := >smodel.Status{} 112 repliedAccount := >smodel.Account{} 113 114 if err := dbService.GetByID(ctx, form.InReplyToID, repliedStatus); err != nil { 115 if err == db.ErrNoEntries { 116 err := fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) 117 return gtserror.NewErrorBadRequest(err, err.Error()) 118 } 119 err := fmt.Errorf("db error fetching status with id %s: %s", form.InReplyToID, err) 120 return gtserror.NewErrorInternalError(err) 121 } 122 if !*repliedStatus.Replyable { 123 err := fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) 124 return gtserror.NewErrorForbidden(err, err.Error()) 125 } 126 127 if err := dbService.GetByID(ctx, repliedStatus.AccountID, repliedAccount); err != nil { 128 if err == db.ErrNoEntries { 129 err := fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) 130 return gtserror.NewErrorBadRequest(err, err.Error()) 131 } 132 err := fmt.Errorf("db error fetching account with id %s: %s", repliedStatus.AccountID, err) 133 return gtserror.NewErrorInternalError(err) 134 } 135 136 if blocked, err := dbService.IsEitherBlocked(ctx, thisAccountID, repliedAccount.ID); err != nil { 137 err := fmt.Errorf("db error checking block: %s", err) 138 return gtserror.NewErrorInternalError(err) 139 } else if blocked { 140 err := fmt.Errorf("status with id %s not replyable", form.InReplyToID) 141 return gtserror.NewErrorNotFound(err) 142 } 143 144 status.InReplyToID = repliedStatus.ID 145 status.InReplyToURI = repliedStatus.URI 146 status.InReplyToAccountID = repliedAccount.ID 147 148 return nil 149 } 150 151 func processMediaIDs(ctx context.Context, dbService db.DB, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { 152 if form.MediaIDs == nil { 153 return nil 154 } 155 156 attachments := []*gtsmodel.MediaAttachment{} 157 attachmentIDs := []string{} 158 for _, mediaID := range form.MediaIDs { 159 attachment, err := dbService.GetAttachmentByID(ctx, mediaID) 160 if err != nil { 161 if errors.Is(err, db.ErrNoEntries) { 162 err = fmt.Errorf("ProcessMediaIDs: media not found for media id %s", mediaID) 163 return gtserror.NewErrorBadRequest(err, err.Error()) 164 } 165 err = fmt.Errorf("ProcessMediaIDs: db error for media id %s", mediaID) 166 return gtserror.NewErrorInternalError(err) 167 } 168 169 if attachment.AccountID != thisAccountID { 170 err = fmt.Errorf("ProcessMediaIDs: media with id %s does not belong to account %s", mediaID, thisAccountID) 171 return gtserror.NewErrorBadRequest(err, err.Error()) 172 } 173 174 if attachment.StatusID != "" || attachment.ScheduledStatusID != "" { 175 err = fmt.Errorf("ProcessMediaIDs: media with id %s is already attached to a status", mediaID) 176 return gtserror.NewErrorBadRequest(err, err.Error()) 177 } 178 179 minDescriptionChars := config.GetMediaDescriptionMinChars() 180 if descriptionLength := len([]rune(attachment.Description)); descriptionLength < minDescriptionChars { 181 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) 182 return gtserror.NewErrorBadRequest(err, err.Error()) 183 } 184 185 attachments = append(attachments, attachment) 186 attachmentIDs = append(attachmentIDs, attachment.ID) 187 } 188 189 status.Attachments = attachments 190 status.AttachmentIDs = attachmentIDs 191 return nil 192 } 193 194 func processVisibility(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { 195 // by default all flags are set to true 196 federated := true 197 boostable := true 198 replyable := true 199 likeable := true 200 201 // If visibility isn't set on the form, then just take the account default. 202 // If that's also not set, take the default for the whole instance. 203 var vis gtsmodel.Visibility 204 switch { 205 case form.Visibility != "": 206 vis = typeutils.APIVisToVis(form.Visibility) 207 case accountDefaultVis != "": 208 vis = accountDefaultVis 209 default: 210 vis = gtsmodel.VisibilityDefault 211 } 212 213 switch vis { 214 case gtsmodel.VisibilityPublic: 215 // for public, there's no need to change any of the advanced flags from true regardless of what the user filled out 216 break 217 case gtsmodel.VisibilityUnlocked: 218 // 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 219 if form.Federated != nil { 220 federated = *form.Federated 221 } 222 223 if form.Boostable != nil { 224 boostable = *form.Boostable 225 } 226 227 if form.Replyable != nil { 228 replyable = *form.Replyable 229 } 230 231 if form.Likeable != nil { 232 likeable = *form.Likeable 233 } 234 235 case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly: 236 // for followers or mutuals only, boostable will *always* be false, but the other fields can be set so check and apply them 237 boostable = false 238 239 if form.Federated != nil { 240 federated = *form.Federated 241 } 242 243 if form.Replyable != nil { 244 replyable = *form.Replyable 245 } 246 247 if form.Likeable != nil { 248 likeable = *form.Likeable 249 } 250 251 case gtsmodel.VisibilityDirect: 252 // direct is pretty easy: there's only one possible setting so return it 253 federated = true 254 boostable = false 255 replyable = true 256 likeable = true 257 } 258 259 status.Visibility = vis 260 status.Federated = &federated 261 status.Boostable = &boostable 262 status.Replyable = &replyable 263 status.Likeable = &likeable 264 return nil 265 } 266 267 func processLanguage(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { 268 if form.Language != "" { 269 status.Language = form.Language 270 } else { 271 status.Language = accountDefaultLanguage 272 } 273 if status.Language == "" { 274 return errors.New("no language given either in status create form or account default") 275 } 276 return nil 277 } 278 279 func processContent(ctx context.Context, dbService db.DB, formatter text.Formatter, parseMention gtsmodel.ParseMentionFunc, form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { 280 // if there's nothing in the status at all we can just return early 281 if form.Status == "" { 282 status.Content = "" 283 return nil 284 } 285 286 // if content type wasn't specified we should try to figure out what content type this user prefers 287 if form.ContentType == "" { 288 acct, err := dbService.GetAccountByID(ctx, accountID) 289 if err != nil { 290 return fmt.Errorf("error processing new content: couldn't retrieve account from db to check post format: %s", err) 291 } 292 293 switch acct.StatusContentType { 294 case "text/plain": 295 form.ContentType = apimodel.StatusContentTypePlain 296 case "text/markdown": 297 form.ContentType = apimodel.StatusContentTypeMarkdown 298 default: 299 form.ContentType = apimodel.StatusContentTypeDefault 300 } 301 } 302 303 // parse content out of the status depending on what content type has been submitted 304 var f text.FormatFunc 305 switch form.ContentType { 306 case apimodel.StatusContentTypePlain: 307 f = formatter.FromPlain 308 case apimodel.StatusContentTypeMarkdown: 309 f = formatter.FromMarkdown 310 default: 311 return fmt.Errorf("format %s not recognised as a valid status format", form.ContentType) 312 } 313 formatted := f(ctx, parseMention, accountID, status.ID, form.Status) 314 315 // add full populated gts {mentions, tags, emojis} to the status for passing them around conveniently 316 // add just their ids to the status for putting in the db 317 status.Mentions = formatted.Mentions 318 status.MentionIDs = make([]string, 0, len(formatted.Mentions)) 319 for _, gtsmention := range formatted.Mentions { 320 status.MentionIDs = append(status.MentionIDs, gtsmention.ID) 321 } 322 323 status.Tags = formatted.Tags 324 status.TagIDs = make([]string, 0, len(formatted.Tags)) 325 for _, gtstag := range formatted.Tags { 326 status.TagIDs = append(status.TagIDs, gtstag.ID) 327 } 328 329 status.Emojis = formatted.Emojis 330 status.EmojiIDs = make([]string, 0, len(formatted.Emojis)) 331 for _, gtsemoji := range formatted.Emojis { 332 status.EmojiIDs = append(status.EmojiIDs, gtsemoji.ID) 333 } 334 335 spoilerformatted := formatter.FromPlainEmojiOnly(ctx, parseMention, accountID, status.ID, form.SpoilerText) 336 for _, gtsemoji := range spoilerformatted.Emojis { 337 status.Emojis = append(status.Emojis, gtsemoji) 338 status.EmojiIDs = append(status.EmojiIDs, gtsemoji.ID) 339 } 340 341 status.Content = formatted.HTML 342 return nil 343 }