accountupdate.go (8449B)
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 accounts 19 20 import ( 21 "errors" 22 "fmt" 23 "net/http" 24 "strconv" 25 26 "github.com/gin-gonic/gin" 27 "github.com/gin-gonic/gin/binding" 28 "github.com/go-playground/form/v4" 29 apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" 30 apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" 31 "github.com/superseriousbusiness/gotosocial/internal/gtserror" 32 "github.com/superseriousbusiness/gotosocial/internal/oauth" 33 "golang.org/x/exp/slices" 34 ) 35 36 // AccountUpdateCredentialsPATCHHandler swagger:operation PATCH /api/v1/accounts/update_credentials accountUpdate 37 // 38 // Update your account. 39 // 40 // --- 41 // tags: 42 // - accounts 43 // 44 // consumes: 45 // - multipart/form-data 46 // - application/x-www-form-urlencoded 47 // - application/json 48 // 49 // produces: 50 // - application/json 51 // 52 // parameters: 53 // - 54 // name: discoverable 55 // in: formData 56 // description: Account should be made discoverable and shown in the profile directory (if enabled). 57 // type: boolean 58 // - 59 // name: bot 60 // in: formData 61 // description: Account is flagged as a bot. 62 // type: boolean 63 // - 64 // name: display_name 65 // in: formData 66 // description: The display name to use for the account. 67 // type: string 68 // allowEmptyValue: true 69 // - 70 // name: note 71 // in: formData 72 // description: Bio/description of this account. 73 // type: string 74 // allowEmptyValue: true 75 // - 76 // name: avatar 77 // in: formData 78 // description: Avatar of the user. 79 // type: file 80 // - 81 // name: header 82 // in: formData 83 // description: Header of the user. 84 // type: file 85 // - 86 // name: locked 87 // in: formData 88 // description: Require manual approval of follow requests. 89 // type: boolean 90 // - 91 // name: source[privacy] 92 // in: formData 93 // description: Default post privacy for authored statuses. 94 // type: string 95 // - 96 // name: source[sensitive] 97 // in: formData 98 // description: Mark authored statuses as sensitive by default. 99 // type: boolean 100 // - 101 // name: source[language] 102 // in: formData 103 // description: Default language to use for authored statuses (ISO 6391). 104 // type: string 105 // - 106 // name: source[status_content_type] 107 // in: formData 108 // description: Default content type to use for authored statuses (text/plain or text/markdown). 109 // type: string 110 // - 111 // name: custom_css 112 // in: formData 113 // description: >- 114 // Custom CSS to use when rendering this account's profile or statuses. 115 // String must be no more than 5,000 characters (~5kb). 116 // type: string 117 // - 118 // name: enable_rss 119 // in: formData 120 // description: Enable RSS feed for this account's Public posts at `/[username]/feed.rss` 121 // type: boolean 122 // - 123 // name: fields_attributes 124 // in: formData 125 // description: Profile fields to be added to this account's profile 126 // type: array 127 // items: 128 // type: object 129 // 130 // security: 131 // - OAuth2 Bearer: 132 // - write:accounts 133 // 134 // responses: 135 // '200': 136 // description: "The newly updated account." 137 // schema: 138 // "$ref": "#/definitions/account" 139 // '400': 140 // description: bad request 141 // '401': 142 // description: unauthorized 143 // '404': 144 // description: not found 145 // '406': 146 // description: not acceptable 147 // '500': 148 // description: internal server error 149 func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { 150 authed, err := oauth.Authed(c, true, true, true, true) 151 if err != nil { 152 apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) 153 return 154 } 155 156 if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { 157 apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) 158 return 159 } 160 161 form, err := parseUpdateAccountForm(c) 162 if err != nil { 163 apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) 164 return 165 } 166 167 acctSensitive, errWithCode := m.processor.Account().Update(c.Request.Context(), authed.Account, form) 168 if errWithCode != nil { 169 apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) 170 return 171 } 172 173 c.JSON(http.StatusOK, acctSensitive) 174 } 175 176 // fieldsAttributesFormBinding satisfies gin's binding.Binding interface. 177 // Should only be used specifically for multipart/form-data MIME type. 178 type fieldsAttributesFormBinding struct{} 179 180 func (fieldsAttributesFormBinding) Name() string { 181 return "FieldsAttributes" 182 } 183 184 func (fieldsAttributesFormBinding) Bind(req *http.Request, obj any) error { 185 if err := req.ParseForm(); err != nil { 186 return err 187 } 188 189 // Change default namespace prefix and suffix to 190 // allow correct parsing of the field attributes. 191 decoder := form.NewDecoder() 192 decoder.SetNamespacePrefix("[") 193 decoder.SetNamespaceSuffix("]") 194 195 return decoder.Decode(obj, req.Form) 196 } 197 198 func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateCredentialsRequest, error) { 199 form := &apimodel.UpdateCredentialsRequest{ 200 Source: &apimodel.UpdateSource{}, 201 } 202 203 switch ct := c.ContentType(); ct { 204 case binding.MIMEJSON: 205 // Bind with default json binding first. 206 if err := c.ShouldBindWith(form, binding.JSON); err != nil { 207 return nil, err 208 } 209 210 // Now use custom form binding for 211 // field attributes in the json data. 212 var err error 213 form.FieldsAttributes, err = parseFieldsAttributesFromJSON(form.JSONFieldsAttributes) 214 if err != nil { 215 return nil, fmt.Errorf("custom json binding failed: %w", err) 216 } 217 case binding.MIMEPOSTForm: 218 // Bind with default form binding first. 219 if err := c.ShouldBindWith(form, binding.FormPost); err != nil { 220 return nil, err 221 } 222 223 // Now use custom form binding for 224 // field attributes in the form data. 225 if err := c.ShouldBindWith(form, fieldsAttributesFormBinding{}); err != nil { 226 return nil, fmt.Errorf("custom form binding failed: %w", err) 227 } 228 case binding.MIMEMultipartPOSTForm: 229 // Bind with default form binding first. 230 if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil { 231 return nil, err 232 } 233 234 // Now use custom form binding for 235 // field attributes in the form data. 236 if err := c.ShouldBindWith(form, fieldsAttributesFormBinding{}); err != nil { 237 return nil, fmt.Errorf("custom form binding failed: %w", err) 238 } 239 default: 240 err := fmt.Errorf("content-type %s not supported for this endpoint; supported content-types are %s, %s, %s", ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm) 241 return nil, err 242 } 243 244 if form == nil || 245 (form.Discoverable == nil && 246 form.Bot == nil && 247 form.DisplayName == nil && 248 form.Note == nil && 249 form.Avatar == nil && 250 form.Header == nil && 251 form.Locked == nil && 252 form.Source.Privacy == nil && 253 form.Source.Sensitive == nil && 254 form.Source.Language == nil && 255 form.Source.StatusContentType == nil && 256 form.FieldsAttributes == nil && 257 form.CustomCSS == nil && 258 form.EnableRSS == nil) { 259 return nil, errors.New("empty form submitted") 260 } 261 262 return form, nil 263 } 264 265 func parseFieldsAttributesFromJSON(jsonFieldsAttributes *map[string]apimodel.UpdateField) (*[]apimodel.UpdateField, error) { 266 if jsonFieldsAttributes == nil { 267 // Nothing set, nothing to do. 268 return nil, nil 269 } 270 271 fieldsAttributes := make([]apimodel.UpdateField, 0, len(*jsonFieldsAttributes)) 272 for keyStr, updateField := range *jsonFieldsAttributes { 273 key, err := strconv.Atoi(keyStr) 274 if err != nil { 275 return nil, fmt.Errorf("couldn't parse fieldAttributes key %s to int: %w", keyStr, err) 276 } 277 278 fieldsAttributes = append(fieldsAttributes, apimodel.UpdateField{ 279 Key: key, 280 Name: updateField.Name, 281 Value: updateField.Value, 282 }) 283 } 284 285 // Sort slice by the key each field was submitted with. 286 slices.SortFunc(fieldsAttributes, func(a, b apimodel.UpdateField) bool { 287 return a.Key < b.Key 288 }) 289 290 return &fieldsAttributes, nil 291 }