commit 1ede54ddf6dfd2d4ba039eb7e23b74bcac65b643 parent 91c0ed863a7d514b65db79ae4eb85822f0f0f508 Author: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 8 Jun 2022 20:38:03 +0200 [feature] More consistent API error handling (#637) * update templates * start reworking api error handling * update template * return AP status at web endpoint if negotiated * start making api error handling much more consistent * update account endpoints to new error handling * use new api error handling in admin endpoints * go fmt ./... * use api error logic in app * use generic error handling in auth * don't export generic error handler * don't defer clearing session * user nicer error handling on oidc callback handler * tidy up the sign in handler * tidy up the token handler * use nicer error handling in blocksget * auth emojis endpoint * fix up remaining api endpoints * fix whoopsie during login flow * regenerate swagger docs * change http error logging to debug Diffstat:
131 files changed, 2140 insertions(+), 1659 deletions(-)
diff --git a/cmd/gotosocial/action/server/server.go b/cmd/gotosocial/action/server/server.go @@ -165,7 +165,7 @@ var Start action.GTSAction = func(ctx context.Context) error { } // build client api modules - authModule := auth.New(dbService, oauthServer, idp) + authModule := auth.New(dbService, oauthServer, idp, processor) accountModule := account.New(processor) instanceModule := instance.New(processor) appsModule := app.New(processor) diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go @@ -108,7 +108,7 @@ var Start action.GTSAction = func(ctx context.Context) error { } // build client api modules - authModule := auth.New(dbService, oauthServer, idp) + authModule := auth.New(dbService, oauthServer, idp, processor) accountModule := account.New(processor) instanceModule := instance.New(processor) appsModule := app.New(processor) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml @@ -692,7 +692,7 @@ definitions: text_url: description: |- A shorter URL for the attachment. - Not currently used. + In our case, we just give the URL again since we don't create smaller URLs. type: string x-go-name: TextURL type: @@ -1894,8 +1894,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable "500": - description: internal error + description: internal server error security: - OAuth2 Application: - write:accounts @@ -1924,6 +1926,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -1952,6 +1958,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:blocks @@ -1999,6 +2009,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:follows @@ -2029,6 +2043,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -2059,6 +2077,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -2134,6 +2156,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -2162,6 +2188,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:blocks @@ -2190,6 +2220,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:follows @@ -2215,6 +2249,12 @@ paths: description: bad request "401": description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:accounts @@ -2247,6 +2287,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -2313,6 +2357,12 @@ paths: description: bad request "401": description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:accounts @@ -2335,6 +2385,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -2372,6 +2426,12 @@ paths: description: unauthorized "403": description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2406,10 +2466,18 @@ paths: $ref: '#/definitions/emoji' "400": description: bad request + "401": + description: unauthorized "403": description: forbidden + "404": + description: not found + "406": + description: not acceptable "409": description: conflict -- domain/shortcode combo for emoji already exists + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2439,10 +2507,16 @@ paths: type: array "400": description: bad request + "401": + description: unauthorized "403": description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2511,8 +2585,16 @@ paths: $ref: '#/definitions/domainBlock' "400": description: bad request + "401": + description: unauthorized "403": description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2537,10 +2619,16 @@ paths: $ref: '#/definitions/domainBlock' "400": description: bad request + "401": + description: unauthorized "403": description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2564,10 +2652,16 @@ paths: $ref: '#/definitions/domainBlock' "400": description: bad request + "401": + description: unauthorized "403": description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2599,8 +2693,16 @@ paths: asynchronously after the request completes. "400": description: bad request + "401": + description: unauthorized "403": description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2660,10 +2762,14 @@ paths: description: bad request "401": description: unauthorized - "422": - description: unprocessable + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable "500": - description: internal error + description: internal server error summary: Register a new application on this instance. tags: - apps @@ -2714,6 +2820,10 @@ paths: description: unauthorized "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:blocks @@ -2753,10 +2863,12 @@ paths: description: bad request "401": description: unauthorized - "403": - description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:follows @@ -2785,10 +2897,10 @@ paths: description: bad request "401": description: unauthorized - "403": - description: forbidden "404": description: not found + "406": + description: not acceptable "500": description: internal server error security: @@ -2817,10 +2929,10 @@ paths: description: bad request "401": description: unauthorized - "403": - description: forbidden "404": description: not found + "406": + description: not acceptable "500": description: internal server error security: @@ -2843,6 +2955,8 @@ paths: description: Instance information. schema: $ref: '#/definitions/instance' + "406": + description: not acceptable "500": description: internal error summary: View instance information. @@ -2909,6 +3023,14 @@ paths: description: bad request "401": description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - admin @@ -2952,10 +3074,10 @@ paths: description: bad request "401": description: unauthorized - "403": - description: forbidden "422": description: unprocessable + "500": + description: internal server error security: - OAuth2 Bearer: - write:media @@ -2982,10 +3104,12 @@ paths: description: bad request "401": description: unauthorized - "403": - description: forbidden - "422": - description: unprocessable + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:media @@ -3036,10 +3160,12 @@ paths: description: bad request "401": description: unauthorized - "403": - description: forbidden - "422": - description: unprocessable + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:media @@ -3141,6 +3267,12 @@ paths: description: bad request "401": description: unauthorized + "404": + description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:search @@ -3226,10 +3358,14 @@ paths: description: bad request "401": description: unauthorized + "403": + description: forbidden "404": description: not found + "406": + description: not acceptable "500": - description: internal error + description: internal server error security: - OAuth2 Bearer: - write:statuses @@ -3263,6 +3399,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:statuses @@ -3288,10 +3428,14 @@ paths: description: bad request "401": description: unauthorized + "403": + description: forbidden "404": description: not found + "406": + description: not acceptable "500": - description: internal error + description: internal server error security: - OAuth2 Bearer: - read:statuses @@ -3324,6 +3468,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:statuses @@ -3354,6 +3502,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:statuses @@ -3386,6 +3538,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - read:accounts @@ -3419,6 +3575,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:statuses @@ -3481,6 +3641,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:statuses @@ -3511,6 +3675,10 @@ paths: description: forbidden "404": description: not found + "406": + description: not acceptable + "500": + description: internal server error security: - OAuth2 Bearer: - write:statuses @@ -3778,6 +3946,8 @@ paths: description: unauthorized "403": description: forbidden + "406": + description: not acceptable "500": description: internal error security: diff --git a/internal/api/client/account/accountcreate.go b/internal/api/client/account/accountcreate.go @@ -23,12 +23,11 @@ import ( "net" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/validate" ) @@ -61,58 +60,51 @@ import ( // description: "An OAuth2 access token for the newly-created account." // schema: // "$ref": "#/definitions/oauthToken" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable // '500': -// description: internal error +// description: internal server error func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "accountCreatePOSTHandler") authed, err := oauth.Authed(c, true, true, false, false) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - l.Trace("parsing request form") form := &model.AccountCreateRequest{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + if err := c.ShouldBind(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("validating form %+v", form) if err := validateCreateAccount(form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } clientIP := c.ClientIP() - l.Tracef("attempting to parse client ip address %s", clientIP) signUpIP := net.ParseIP(clientIP) if signUpIP == nil { - l.Debugf("error validating sign up ip address %s", clientIP) - c.JSON(http.StatusBadRequest, gin.H{"error": "ip address could not be parsed from request"}) + err := errors.New("ip address could not be parsed from request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - form.IP = signUpIP - ti, err := m.processor.AccountCreate(c.Request.Context(), authed, form) - if err != nil { - l.Errorf("internal server error while creating new account: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + ti, errWithCode := m.processor.AccountCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -122,6 +114,10 @@ func (m *Module) AccountCreatePOSTHandler(c *gin.Context) { // validateCreateAccount checks through all the necessary prerequisites for creating a new account, // according to the provided account create request. If the account isn't eligible, an error will be returned. func validateCreateAccount(form *model.AccountCreateRequest) error { + if form == nil { + return errors.New("form was nil") + } + if !config.GetAccountsRegistrationOpen() { return errors.New("registration is not open for this server") } diff --git a/internal/api/client/account/accountdelete.go b/internal/api/client/account/accountdelete.go @@ -19,12 +19,13 @@ package account import ( + "errors" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -57,32 +58,35 @@ import ( // description: bad request // '401': // description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountDeletePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "AccountDeletePOSTHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("retrieved account %+v", authed.Account.ID) form := &model.AccountDeleteRequest{} if err := c.ShouldBind(&form); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if form.Password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no password provided in account delete request"}) + err = errors.New("no password provided in account delete request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } form.DeleteOriginID = authed.Account.ID if errWithCode := m.processor.AccountDeleteLocal(c.Request.Context(), authed, form); errWithCode != nil { - l.Debugf("could not delete account: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/accountget.go b/internal/api/client/account/accountget.go @@ -19,11 +19,12 @@ package account import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -53,34 +54,38 @@ import ( // '200': // schema: // "$ref": "#/definitions/account" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } acctInfo, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, targetAcctID) - if err != nil { - logrus.Debug(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go @@ -19,15 +19,15 @@ package account import ( + "errors" "fmt" "net/http" "strconv" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -98,68 +98,67 @@ import ( // description: "The newly updated account." // schema: // "$ref": "#/definitions/account" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountUpdateCredentialsPATCHHandler(c *gin.Context) { - l := logrus.WithField("func", "accountUpdateCredentialsPATCHHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("retrieved account %+v", authed.Account.ID) if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } form, err := parseUpdateAccountForm(c) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - // if everything on the form is nil, then nothing has been set and we shouldn't continue - if form.Discoverable == nil && - form.Bot == nil && - form.DisplayName == nil && - form.Note == nil && - form.Avatar == nil && - form.Header == nil && - form.Locked == nil && - form.Source.Privacy == nil && - form.Source.Sensitive == nil && - form.Source.Language == nil && - form.FieldsAttributes == nil { - l.Debugf("could not parse form from request") - c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - acctSensitive, err := m.processor.AccountUpdate(c.Request.Context(), authed, form) - if err != nil { - l.Debugf("could not update account: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + acctSensitive, errWithCode := m.processor.AccountUpdate(c.Request.Context(), authed, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - l.Tracef("conversion successful, returning OK and apisensitive account %+v", acctSensitive) c.JSON(http.StatusOK, acctSensitive) } func parseUpdateAccountForm(c *gin.Context) (*model.UpdateCredentialsRequest, error) { - // parse main fields from request form := &model.UpdateCredentialsRequest{ Source: &model.UpdateSource{}, } - if err := c.ShouldBind(&form); err != nil || form == nil { + + if err := c.ShouldBind(&form); err != nil { return nil, fmt.Errorf("could not parse form from request: %s", err) } + if form == nil || + (form.Discoverable == nil && + form.Bot == nil && + form.DisplayName == nil && + form.Note == nil && + form.Avatar == nil && + form.Header == nil && + form.Locked == nil && + form.Source.Privacy == nil && + form.Source.Sensitive == nil && + form.Source.Language == nil && + form.FieldsAttributes == nil) { + return nil, errors.New("empty form submitted") + } + // parse source field-by-field sourceMap := c.PostFormMap("source") diff --git a/internal/api/client/account/accountupdate_test.go b/internal/api/client/account/accountupdate_test.go @@ -26,7 +26,6 @@ import ( "net/http/httptest" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/account" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" @@ -65,7 +64,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandler() // check the response b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) + suite.NoError(err) // unmarshal the returned account apimodelAccount := &apimodel.Account{} @@ -104,7 +103,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUnl // check the response b1, err := ioutil.ReadAll(result1.Body) - assert.NoError(suite.T(), err) + suite.NoError(err) // unmarshal the returned account apimodelAccount1 := &apimodel.Account{} @@ -185,7 +184,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerGet // check the response b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) + suite.NoError(err) // unmarshal the returned account apimodelAccount := &apimodel.Account{} @@ -227,7 +226,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerTwo // check the response b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) + suite.NoError(err) // unmarshal the returned account apimodelAccount := &apimodel.Account{} @@ -271,7 +270,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerWit // check the response b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) + suite.NoError(err) // unmarshal the returned account apimodelAccount := &apimodel.Account{} @@ -313,8 +312,8 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerEmp // check the response b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - suite.Equal(`{"error":"empty form submitted"}`, string(b)) + suite.NoError(err) + suite.Equal(`{"error":"Bad Request: empty form submitted"}`, string(b)) } func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpdateSource() { @@ -348,7 +347,7 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateCredentialsPATCHHandlerUpd // check the response b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) + suite.NoError(err) // unmarshal the returned account apimodelAccount := &apimodel.Account{} diff --git a/internal/api/client/account/accountverify.go b/internal/api/client/account/accountverify.go @@ -21,10 +21,9 @@ package account import ( "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -47,30 +46,31 @@ import ( // '200': // schema: // "$ref": "#/definitions/account" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountVerifyGETHandler(c *gin.Context) { - l := logrus.WithField("func", "accountVerifyGETHandler") - authed, err := oauth.Authed(c, true, false, false, true) + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - acctSensitive, err := m.processor.AccountGet(c.Request.Context(), authed, authed.Account.ID) - if err != nil { - l.Debugf("error getting account from processor: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + acctSensitive, errWithCode := m.processor.AccountGet(c.Request.Context(), authed, authed.Account.ID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/block.go b/internal/api/client/account/block.go @@ -19,10 +19,12 @@ package account import ( + "errors" "net/http" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -54,33 +56,38 @@ import ( // description: Your relationship to this account. // schema: // "$ref": "#/definitions/accountRelationship" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountBlockPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } relationship, errWithCode := m.processor.AccountBlockCreate(c.Request.Context(), authed, targetAcctID) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/follow.go b/internal/api/client/account/follow.go @@ -19,11 +19,13 @@ package account import ( + "errors" "net/http" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -75,39 +77,45 @@ import ( // description: Your relationship to this account. // schema: // "$ref": "#/definitions/accountRelationship" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountFollowPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } + form := &model.AccountFollowRequest{} if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } form.ID = targetAcctID relationship, errWithCode := m.processor.AccountFollowCreate(c.Request.Context(), authed, form) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/followers.go b/internal/api/client/account/followers.go @@ -19,10 +19,12 @@ package account import ( + "errors" "net/http" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -56,33 +58,38 @@ import ( // type: array // items: // "$ref": "#/definitions/account" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountFollowersGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } followers, errWithCode := m.processor.AccountFollowersGet(c.Request.Context(), authed, targetAcctID) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/following.go b/internal/api/client/account/following.go @@ -19,10 +19,12 @@ package account import ( + "errors" "net/http" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -56,33 +58,38 @@ import ( // type: array // items: // "$ref": "#/definitions/account" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountFollowingGETHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } following, errWithCode := m.processor.AccountFollowingGet(c.Request.Context(), authed, targetAcctID) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/relationships.go b/internal/api/client/account/relationships.go @@ -1,13 +1,13 @@ package account import ( + "errors" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -43,24 +43,25 @@ import ( // type: array // items: // "$ref": "#/definitions/accountRelationship" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { - l := logrus.WithField("func", "AccountRelationshipsGETHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -69,8 +70,8 @@ func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { // check fallback -- let's be generous and see if maybe it's just set as 'id'? id := c.Query("id") if id == "" { - l.Debug("no account id specified in query") - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err = errors.New("no account id(s) specified in query") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } targetAccountIDs = append(targetAccountIDs, id) @@ -80,8 +81,8 @@ func (m *Module) AccountRelationshipsGETHandler(c *gin.Context) { for _, targetAccountID := range targetAccountIDs { r, errWithCode := m.processor.AccountRelationshipGet(c.Request.Context(), authed, targetAccountID) - if err != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } relationships = append(relationships, *r) diff --git a/internal/api/client/account/statuses.go b/internal/api/client/account/statuses.go @@ -19,13 +19,14 @@ package account import ( + "errors" + "fmt" "net/http" "strconv" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -110,31 +111,32 @@ import ( // type: array // items: // "$ref": "#/definitions/status" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountStatusesGETHandler(c *gin.Context) { - l := logrus.WithField("func", "AccountStatusesGETHandler") - authed, err := oauth.Authed(c, false, false, false, false) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - l.Debug("no account id specified in query") - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -143,8 +145,8 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -155,8 +157,8 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if excludeRepliesString != "" { i, err := strconv.ParseBool(excludeRepliesString) if err != nil { - l.Debugf("error parsing exclude replies string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse exclude replies query param"}) + err := fmt.Errorf("error parsing %s: %s", ExcludeRepliesKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } excludeReplies = i @@ -167,8 +169,8 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if excludeReblogsString != "" { i, err := strconv.ParseBool(excludeReblogsString) if err != nil { - l.Debugf("error parsing exclude reblogs string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse exclude reblogs query param"}) + err := fmt.Errorf("error parsing %s: %s", ExcludeReblogsKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } excludeReblogs = i @@ -191,8 +193,8 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if pinnedString != "" { i, err := strconv.ParseBool(pinnedString) if err != nil { - l.Debugf("error parsing pinned string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse pinned query param"}) + err := fmt.Errorf("error parsing %s: %s", PinnedKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } pinnedOnly = i @@ -203,8 +205,8 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if mediaOnlyString != "" { i, err := strconv.ParseBool(mediaOnlyString) if err != nil { - l.Debugf("error parsing media only string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse media only query param"}) + err := fmt.Errorf("error parsing %s: %s", OnlyMediaKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } mediaOnly = i @@ -215,8 +217,8 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { if publicOnlyString != "" { i, err := strconv.ParseBool(publicOnlyString) if err != nil { - l.Debugf("error parsing public only string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse public only query param"}) + err := fmt.Errorf("error parsing %s: %s", OnlyPublicKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } publicOnly = i @@ -224,8 +226,7 @@ func (m *Module) AccountStatusesGETHandler(c *gin.Context) { resp, errWithCode := m.processor.AccountStatusesGet(c.Request.Context(), authed, targetAcctID, limit, excludeReplies, excludeReblogs, maxID, minID, pinnedOnly, mediaOnly, publicOnly) if errWithCode != nil { - l.Debugf("error from processor account statuses get: %s", errWithCode) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/unblock.go b/internal/api/client/account/unblock.go @@ -19,10 +19,12 @@ package account import ( + "errors" "net/http" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -54,33 +56,38 @@ import ( // description: Your relationship to this account. // schema: // "$ref": "#/definitions/accountRelationship" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountUnblockPOSTHandler(c *gin.Context) { authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } relationship, errWithCode := m.processor.AccountBlockRemove(c.Request.Context(), authed, targetAcctID) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/account/unfollow.go b/internal/api/client/account/unfollow.go @@ -19,12 +19,12 @@ package account import ( + "errors" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -56,37 +56,38 @@ import ( // description: Your relationship to this account. // schema: // "$ref": "#/definitions/accountRelationship" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountUnfollowPOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "AccountUnfollowPOSTHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debug(err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - l.Debug(err) - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } relationship, errWithCode := m.processor.AccountFollowRemove(c.Request.Context(), authed, targetAcctID) if errWithCode != nil { - l.Debug(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/accountaction.go b/internal/api/client/admin/accountaction.go @@ -19,12 +19,14 @@ package admin import ( + "errors" "fmt" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -72,53 +74,47 @@ import ( // description: unauthorized // '403': // description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) AccountActionPOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "AccountActionPOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed... authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - // with an admin account if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - // extract the form from the request context - l.Tracef("parsing request form: %+v", c.Request.Form) form := &model.AdminAccountActionRequest{} if err := c.ShouldBind(form); err != nil { - l.Debugf("error parsing form %+v: %s", c.Request.Form, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if form.Type == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no type specified"}) + err := errors.New("no type specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } targetAcctID := c.Param(IDKey) if targetAcctID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } form.TargetAccountID = targetAcctID if errWithCode := m.processor.AdminAccountAction(c.Request.Context(), authed, form); errWithCode != nil { - l.Debugf("error performing account action: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/domainblockcreate.go b/internal/api/client/admin/domainblockcreate.go @@ -7,9 +7,9 @@ import ( "strconv" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -86,33 +86,33 @@ import ( // Note that if a list has been imported, then an `array` of newly created domain blocks will be returned instead. // schema: // "$ref": "#/definitions/domainBlock" -// '403': -// description: forbidden // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "DomainBlocksPOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } + if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -121,49 +121,43 @@ func (m *Module) DomainBlocksPOSTHandler(c *gin.Context) { if importString != "" { i, err := strconv.ParseBool(importString) if err != nil { - l.Debugf("error parsing import string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse import query param"}) + err := fmt.Errorf("error parsing %s: %s", ImportQueryKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } imp = i } - // extract the media create form from the request context - l.Tracef("parsing request form: %+v", c.Request.Form) form := &model.DomainBlockCreateRequest{} if err := c.ShouldBind(form); err != nil { - l.Debugf("error parsing form %+v: %s", c.Request.Form, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) if err := validateCreateDomainBlock(form, imp); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + err := fmt.Errorf("error validating form: %s", err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if imp { // we're importing multiple blocks - domainBlocks, err := m.processor.AdminDomainBlocksImport(c.Request.Context(), authed, form) - if err != nil { - l.Debugf("error importing domain blocks: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + domainBlocks, errWithCode := m.processor.AdminDomainBlocksImport(c.Request.Context(), authed, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } c.JSON(http.StatusOK, domainBlocks) - } else { - // we're just creating one block - domainBlock, err := m.processor.AdminDomainBlockCreate(c.Request.Context(), authed, form) - if err != nil { - l.Debugf("error creating domain block: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, domainBlock) + return + } + + // we're just creating one block + domainBlock, errWithCode := m.processor.AdminDomainBlockCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return } + c.JSON(http.StatusOK, domainBlock) } func validateCreateDomainBlock(form *model.DomainBlockCreateRequest, imp bool) error { diff --git a/internal/api/client/admin/domainblockdelete.go b/internal/api/client/admin/domainblockdelete.go @@ -1,11 +1,13 @@ package admin import ( + "errors" + "fmt" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -36,48 +38,46 @@ import ( // description: The domain block that was just deleted. // schema: // "$ref": "#/definitions/domainBlock" -// '403': -// description: forbidden // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) DomainBlockDELETEHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "DomainBlockDELETEHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } + if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } domainBlockID := c.Param(IDKey) if domainBlockID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no domain block id provided"}) + err := errors.New("no domain block id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } domainBlock, errWithCode := m.processor.AdminDomainBlockDelete(c.Request.Context(), authed, domainBlockID) if errWithCode != nil { - l.Debugf("error deleting domain block: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/domainblockget.go b/internal/api/client/admin/domainblockget.go @@ -1,12 +1,14 @@ package admin import ( + "errors" + "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -37,41 +39,40 @@ import ( // description: The requested domain block. // schema: // "$ref": "#/definitions/domainBlock" -// '403': -// description: forbidden // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) DomainBlockGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "DomainBlockGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } + if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } domainBlockID := c.Param(IDKey) if domainBlockID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no domain block id provided"}) + err := errors.New("no domain block id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -80,17 +81,16 @@ func (m *Module) DomainBlockGETHandler(c *gin.Context) { if exportString != "" { i, err := strconv.ParseBool(exportString) if err != nil { - l.Debugf("error parsing export string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse export query param"}) + err := fmt.Errorf("error parsing %s: %s", ExportQueryKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } export = i } - domainBlock, err := m.processor.AdminDomainBlockGet(c.Request.Context(), authed, domainBlockID, export) - if err != nil { - l.Debugf("error getting domain block: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + domainBlock, errWithCode := m.processor.AdminDomainBlockGet(c.Request.Context(), authed, domainBlockID, export) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/domainblocksget.go b/internal/api/client/admin/domainblocksget.go @@ -1,12 +1,14 @@ package admin import ( + "errors" + "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -43,35 +45,40 @@ import ( // type: array // items: // "$ref": "#/definitions/domainBlock" -// '403': -// description: forbidden // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) DomainBlocksGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "DomainBlocksGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } + if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + domainBlockID := c.Param(IDKey) + if domainBlockID == "" { + err := errors.New("no domain block id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -80,17 +87,16 @@ func (m *Module) DomainBlocksGETHandler(c *gin.Context) { if exportString != "" { i, err := strconv.ParseBool(exportString) if err != nil { - l.Debugf("error parsing export string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse export query param"}) + err := fmt.Errorf("error parsing %s: %s", ExportQueryKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } export = i } - domainBlocks, err := m.processor.AdminDomainBlocksGet(c.Request.Context(), authed, export) - if err != nil { - l.Debugf("error getting domain blocks: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + domainBlocks, errWithCode := m.processor.AdminDomainBlocksGet(c.Request.Context(), authed, export) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/admin/emojicreate.go b/internal/api/client/admin/emojicreate.go @@ -24,9 +24,9 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/validate" ) @@ -69,59 +69,52 @@ import ( // description: The newly-created emoji. // schema: // "$ref": "#/definitions/emoji" -// '403': -// description: forbidden // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable // '409': // description: conflict -- domain/shortcode combo for emoji already exists +// '500': +// description: internal server error func (m *Module) EmojiCreatePOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "emojiCreatePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed with an admin account - authed, err := oauth.Authed(c, true, true, true, true) // posting a status is serious business so we want *everything* + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } + if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - // extract the media create form from the request context - l.Tracef("parsing request form: %+v", c.Request.Form) form := &model.EmojiCreateRequest{} if err := c.ShouldBind(form); err != nil { - l.Debugf("error parsing form %+v: %s", c.Request.Form, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) if err := validateCreateEmoji(form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } apiEmoji, errWithCode := m.processor.AdminEmojiCreate(c.Request.Context(), authed, form) if errWithCode != nil { - l.Debugf("error creating emoji: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -129,7 +122,6 @@ func (m *Module) EmojiCreatePOSTHandler(c *gin.Context) { } func validateCreateEmoji(form *model.EmojiCreateRequest) error { - // check there actually is an image attached and it's not size 0 if form.Image == nil || form.Image.Size == 0 { return errors.New("no emoji given") } diff --git a/internal/api/client/admin/emojicreate_test.go b/internal/api/client/admin/emojicreate_test.go @@ -120,7 +120,7 @@ func (suite *EmojiCreateTestSuite) TestEmojiCreateAlreadyExists() { suite.NoError(err) suite.NotEmpty(b) - suite.Equal(`{"error":"conflict: emoji with shortcode rainbow already exists"}`, string(b)) + suite.Equal(`{"error":"Conflict: emoji with shortcode rainbow already exists"}`, string(b)) } func TestEmojiCreateTestSuite(t *testing.T) { diff --git a/internal/api/client/admin/mediacleanup.go b/internal/api/client/admin/mediacleanup.go @@ -23,9 +23,10 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -54,39 +55,34 @@ import ( // '200': // description: |- // Echos the number of days requested. The cleanup is performed asynchronously after the request completes. -// '403': -// description: forbidden // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) MediaCleanupPOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "MediaCleanupPOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - - // make sure we're authed... authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - // with an admin account if !authed.User.Admin { - l.Debugf("user %s not an admin", authed.User.ID) - c.JSON(http.StatusForbidden, gin.H{"error": "not an admin"}) + err := fmt.Errorf("user %s not an admin", authed.User.ID) + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - // extract the form from the request context - l.Tracef("parsing request form: %+v", c.Request.Form) form := &model.MediaCleanupRequest{} if err := c.ShouldBind(form); err != nil { - l.Debugf("error parsing form %+v: %s", c.Request.Form, err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("could not parse form: %s", err)}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -101,8 +97,7 @@ func (m *Module) MediaCleanupPOSTHandler(c *gin.Context) { } if errWithCode := m.processor.AdminMediaPrune(c.Request.Context(), remoteCacheDays); errWithCode != nil { - l.Debugf("error starting prune of remote media: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/app/appcreate.go b/internal/api/client/app/appcreate.go @@ -22,18 +22,16 @@ import ( "fmt" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) +// these consts are used to ensure users can't spam huge entries into our database const ( - // permitted length for most fields - formFieldLen = 64 - // redirect can be a bit bigger because we probably need to encode data in the redirect uri + formFieldLen = 64 formRedirectLen = 512 ) @@ -64,56 +62,63 @@ const ( // description: "The newly-created application." // schema: // "$ref": "#/definitions/application" -// '401': -// description: unauthorized // '400': // description: bad request -// '422': -// description: unprocessable +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable // '500': -// description: internal error +// description: internal server error func (m *Module) AppsPOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "AppsPOSTHandler") - l.Trace("entering AppsPOSTHandler") - authed, err := oauth.Authed(c, false, false, false, false) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } form := &model.ApplicationCreateRequest{} if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // check lengths of fields before proceeding so the user can't spam huge entries into the database if len(form.ClientName) > formFieldLen { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", formFieldLen)}) - return - } - if len(form.Website) > formFieldLen { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", formFieldLen)}) + err := fmt.Errorf("client_name must be less than %d bytes", formFieldLen) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } + if len(form.RedirectURIs) > formRedirectLen { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", formRedirectLen)}) + err := fmt.Errorf("redirect_uris must be less than %d bytes", formRedirectLen) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } + if len(form.Scopes) > formFieldLen { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", formFieldLen)}) + err := fmt.Errorf("scopes must be less than %d bytes", formFieldLen) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - apiApp, err := m.processor.AppCreate(c.Request.Context(), authed, form) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if len(form.Website) > formFieldLen { + err := fmt.Errorf("website must be less than %d bytes", formFieldLen) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + apiApp, errWithCode := m.processor.AppCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/auth/auth.go b/internal/api/client/auth/auth.go @@ -25,6 +25,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/router" ) @@ -66,17 +67,19 @@ const ( // Module implements the ClientAPIModule interface for type Module struct { - db db.DB - server oauth.Server - idp oidc.IDP + db db.DB + server oauth.Server + idp oidc.IDP + processor processing.Processor } // New returns a new auth module -func New(db db.DB, server oauth.Server, idp oidc.IDP) api.ClientModule { +func New(db db.DB, server oauth.Server, idp oidc.IDP, processor processing.Processor) api.ClientModule { return &Module{ - db: db, - server: server, - idp: idp, + db: db, + server: server, + idp: idp, + processor: processor, } } diff --git a/internal/api/client/auth/auth_test.go b/internal/api/client/auth/auth_test.go @@ -23,25 +23,37 @@ import ( "fmt" "net/http/httptest" + "codeberg.org/gruf/go-store/kv" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/memstore" "github.com/gin-gonic/gin" "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/concurrency" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/email" + "github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" + "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/oidc" + "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/testrig" ) type AuthStandardTestSuite struct { suite.Suite - db db.DB - idp oidc.IDP - oauthServer oauth.Server + db db.DB + storage *kv.KVStore + mediaManager media.Manager + federator federation.Federator + processor processing.Processor + emailSender email.Sender + idp oidc.IDP + oauthServer oauth.Server // standard suite models testTokens map[string]*gtsmodel.Token @@ -69,17 +81,26 @@ func (suite *AuthStandardTestSuite) SetupSuite() { func (suite *AuthStandardTestSuite) SetupTest() { testrig.InitTestConfig() - suite.db = testrig.NewTestDB() testrig.InitTestLog() + fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) + clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) + + suite.db = testrig.NewTestDB() + suite.storage = testrig.NewTestStorage() + suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage) + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil), suite.db, fedWorker), suite.storage, suite.mediaManager, fedWorker) + suite.emailSender = testrig.NewEmailSender("../../../../web/template/", nil) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator, suite.emailSender, suite.mediaManager, clientWorker, fedWorker) + suite.oauthServer = testrig.NewTestOauthServer(suite.db) var err error suite.idp, err = oidc.NewIDP(context.Background()) if err != nil { panic(err) } - suite.authModule = auth.New(suite.db, suite.oauthServer, suite.idp).(*auth.Module) - testrig.StandardDBSetup(suite.db, nil) + suite.authModule = auth.New(suite.db, suite.oauthServer, suite.idp, suite.processor).(*auth.Module) + testrig.StandardDBSetup(suite.db, suite.testAccounts) } func (suite *AuthStandardTestSuite) TearDownTest() { @@ -92,7 +113,7 @@ func (suite *AuthStandardTestSuite) newContext(requestMethod string, requestPath ctx, engine := gin.CreateTestContext(recorder) // load templates into the engine - testrig.ConfigureTemplatesWithGin(engine) + testrig.ConfigureTemplatesWithGin(engine, "../../../../web/template") // create the request protocol := config.GetProtocol() diff --git a/internal/api/client/auth/authorize.go b/internal/api/client/auth/authorize.go @@ -23,9 +23,6 @@ import ( "fmt" "net/http" "net/url" - "strings" - - "github.com/sirupsen/logrus" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -33,18 +30,22 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) +// helpfulAdvice is a handy hint to users; +// particularly important during the login flow +var helpfulAdvice = "If you arrived at this error during a login/oauth flow, please try clearing your session cookies and logging in again; if problems persist, make sure you're using the correct credentials" + // AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize // The idea here is to present an oauth authorize page to the user, with a button // that they have to click to accept. func (m *Module) AuthorizeGETHandler(c *gin.Context) { - l := logrus.WithField("func", "AuthorizeGETHandler") s := sessions.Default(c) if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - c.HTML(http.StatusNotAcceptable, "error.tmpl", gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -52,56 +53,75 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { // If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. userID, ok := s.Get(sessionUserID).(string) if !ok || userID == "" { - l.Trace("userid was empty, parsing form then redirecting to sign in page") form := &model.OAuthAuthorize{} - if err := c.Bind(form); err != nil { - l.Debugf("invalid auth form: %s", err) + if err := c.ShouldBind(form); err != nil { m.clearSession(s) - c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) return } - l.Debugf("parsed auth form: %+v", form) - if err := extractAuthForm(s, form); err != nil { - l.Debugf(fmt.Sprintf("error parsing form at /oauth/authorize: %s", err)) + if errWithCode := saveAuthFormToSession(s, form); errWithCode != nil { m.clearSession(s) - c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": err.Error()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } + c.Redirect(http.StatusSeeOther, AuthSignInPath) return } - // We can use the client_id on the session to retrieve info about the app associated with the client_id + // use session information to validate app, user, and account for this request clientID, ok := s.Get(sessionClientID).(string) if !ok || clientID == "" { - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": "no client_id found in session"}) + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionClientID) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) return } + app := >smodel.Application{} if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{ - "error": fmt.Sprintf("no application found for client id %s", clientID), - }) + safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, helpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, helpfulAdvice) + } + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - // redirect the user if they have not confirmed their email yet, thier account has not been approved yet, - // or thier account has been disabled. user := >smodel.User{} if err := m.db.GetByID(c.Request.Context(), userID, user); err != nil { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + safe := fmt.Sprintf("user with id %s could not be retrieved", userID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, helpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, helpfulAdvice) + } + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } + acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) if err != nil { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, helpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, helpfulAdvice) + } + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - if !ensureUserIsAuthorizedOrRedirect(c, user, acct) { + + if ensureUserIsAuthorizedOrRedirect(c, user, acct) { return } @@ -109,25 +129,27 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { redirect, ok := s.Get(sessionRedirectURI).(string) if !ok || redirect == "" { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": "no redirect_uri found in session"}) + err := fmt.Errorf("key %s was not found in session", sessionRedirectURI) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) return } + scope, ok := s.Get(sessionScope).(string) if !ok || scope == "" { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": "no scope found in session"}) + err := fmt.Errorf("key %s was not found in session", sessionScope) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) return } // the authorize template will display a form to the user where they can get some information // about the app that's trying to authorize, and the scope of the request. // They can then approve it if it looks OK to them, which will POST to the AuthorizePOSTHandler - l.Trace("serving authorize html") c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ "appname": app.Name, "appwebsite": app.Website, "redirect": redirect, - sessionScope: scope, + "scope": scope, "user": acct.Username, }) } @@ -136,13 +158,10 @@ func (m *Module) AuthorizeGETHandler(c *gin.Context) { // At this point we assume that the user has A) logged in and B) accepted that the app should act for them, // so we should proceed with the authentication flow and generate an oauth token for them if we can. func (m *Module) AuthorizePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "AuthorizePOSTHandler") s := sessions.Default(c) // We need to retrieve the original form submitted to the authorizeGEThandler, and // recreate it on the request so that it can be used further by the oauth2 library. - // So first fetch all the values from the session. - errs := []string{} forceLogin, ok := s.Get(sessionForceLogin).(string) @@ -152,77 +171,107 @@ func (m *Module) AuthorizePOSTHandler(c *gin.Context) { responseType, ok := s.Get(sessionResponseType).(string) if !ok || responseType == "" { - errs = append(errs, "session missing response_type") + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionResponseType)) } clientID, ok := s.Get(sessionClientID).(string) if !ok || clientID == "" { - errs = append(errs, "session missing client_id") + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionClientID)) } redirectURI, ok := s.Get(sessionRedirectURI).(string) if !ok || redirectURI == "" { - errs = append(errs, "session missing redirect_uri") + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionRedirectURI)) } scope, ok := s.Get(sessionScope).(string) if !ok { - errs = append(errs, "session missing scope") + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope)) } userID, ok := s.Get(sessionUserID).(string) if !ok { - errs = append(errs, "session missing userid") + errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID)) + } + + if len(errs) != 0 { + errs = append(errs, helpfulAdvice) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during AuthorizePOSTHandler"), errs...), m.processor.InstanceGet) + return } - // redirect the user if they have not confirmed their email yet, thier account has not been approved yet, - // or thier account has been disabled. user := >smodel.User{} if err := m.db.GetByID(c.Request.Context(), userID, user); err != nil { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + safe := fmt.Sprintf("user with id %s could not be retrieved", userID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, helpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, helpfulAdvice) + } + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } + acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID) if err != nil { m.clearSession(s) - c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{"error": err.Error()}) + safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, helpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, helpfulAdvice) + } + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - if !ensureUserIsAuthorizedOrRedirect(c, user, acct) { + + if ensureUserIsAuthorizedOrRedirect(c, user, acct) { return } + // we're done with the session now, so just clear it out m.clearSession(s) - if len(errs) != 0 { - c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": strings.Join(errs, ": ")}) - return + // we have to set the values on the request form + // so that they're picked up by the oauth server + c.Request.Form = url.Values{ + sessionForceLogin: {forceLogin}, + sessionResponseType: {responseType}, + sessionClientID: {clientID}, + sessionRedirectURI: {redirectURI}, + sessionScope: {scope}, + sessionUserID: {userID}, } - // now set the values on the request - values := url.Values{} - values.Set(sessionForceLogin, forceLogin) - values.Set(sessionResponseType, responseType) - values.Set(sessionClientID, clientID) - values.Set(sessionRedirectURI, redirectURI) - values.Set(sessionScope, scope) - values.Set(sessionUserID, userID) - c.Request.Form = values - l.Tracef("values on request set to %+v", c.Request.Form) - - // and proceed with authorization using the oauth2 library if err := m.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { - c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), helpfulAdvice), m.processor.InstanceGet) } } -// extractAuthForm checks the given OAuthAuthorize form, and stores -// the values in the form into the session. -func extractAuthForm(s sessions.Session, form *model.OAuthAuthorize) error { - // these fields are *required* so check 'em - if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { - return errors.New("missing one of: response_type, client_id or redirect_uri") +// saveAuthFormToSession checks the given OAuthAuthorize form, +// and stores the values in the form into the session. +func saveAuthFormToSession(s sessions.Session, form *model.OAuthAuthorize) gtserror.WithCode { + if form == nil { + err := errors.New("OAuthAuthorize form was nil") + return gtserror.NewErrorBadRequest(err, err.Error(), helpfulAdvice) + } + + if form.ResponseType == "" { + err := errors.New("field response_type was not set on OAuthAuthorize form") + return gtserror.NewErrorBadRequest(err, err.Error(), helpfulAdvice) + } + + if form.ClientID == "" { + err := errors.New("field client_id was not set on OAuthAuthorize form") + return gtserror.NewErrorBadRequest(err, err.Error(), helpfulAdvice) + } + + if form.RedirectURI == "" { + err := errors.New("field redirect_uri was not set on OAuthAuthorize form") + return gtserror.NewErrorBadRequest(err, err.Error(), helpfulAdvice) } // set default scope to read @@ -237,29 +286,33 @@ func extractAuthForm(s sessions.Session, form *model.OAuthAuthorize) error { s.Set(sessionRedirectURI, form.RedirectURI) s.Set(sessionScope, form.Scope) s.Set(sessionState, uuid.NewString()) - return s.Save() + + if err := s.Save(); err != nil { + err := fmt.Errorf("error saving form values onto session: %s", err) + return gtserror.NewErrorInternalError(err, helpfulAdvice) + } + + return nil } -func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) bool { +func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) (redirected bool) { if user.ConfirmedAt.IsZero() { ctx.Redirect(http.StatusSeeOther, CheckYourEmailPath) - return false + redirected = true + return } if !user.Approved { ctx.Redirect(http.StatusSeeOther, WaitForApprovalPath) - return false - } - - if user.Disabled { - ctx.Redirect(http.StatusSeeOther, AccountDisabledPath) - return false + redirected = true + return } - if !account.SuspendedAt.IsZero() { + if user.Disabled || !account.SuspendedAt.IsZero() { ctx.Redirect(http.StatusSeeOther, AccountDisabledPath) - return false + redirected = true + return } - return true + return } diff --git a/internal/api/client/auth/callback.go b/internal/api/client/auth/callback.go @@ -30,7 +30,9 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/oidc" "github.com/superseriousbusiness/gotosocial/internal/validate" @@ -40,11 +42,14 @@ import ( func (m *Module) CallbackGETHandler(c *gin.Context) { s := sessions.Default(c) - // first make sure the state set in the cookie is the same as the state returned from the external provider + // check the query vs session state parameter to mitigate csrf + // https://auth0.com/docs/secure/attack-protection/state-parameters + state := c.Query(callbackStateParam) if state == "" { m.clearSession(s) - c.JSON(http.StatusForbidden, gin.H{"error": "state query not found on callback"}) + err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -52,84 +57,104 @@ func (m *Module) CallbackGETHandler(c *gin.Context) { savedState, ok := savedStateI.(string) if !ok { m.clearSession(s) - c.JSON(http.StatusForbidden, gin.H{"error": "state not found in session"}) + err := fmt.Errorf("key %s was not found in session", sessionState) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if state != savedState { m.clearSession(s) - c.JSON(http.StatusForbidden, gin.H{"error": "state mismatch"}) + err := errors.New("mismatch between query state and session state") + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } + // retrieve stored claims using code code := c.Query(callbackCodeParam) + if code == "" { + m.clearSession(s) + err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } - claims, err := m.idp.HandleCallback(c.Request.Context(), code) - if err != nil { + claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code) + if errWithCode != nil { m.clearSession(s) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - // We can use the client_id on the session to retrieve info about the app associated with the client_id + // We can use the client_id on the session to retrieve + // info about the app associated with the client_id clientID, ok := s.Get(sessionClientID).(string) if !ok || clientID == "" { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": "no client_id found in session during callback"}) + err := fmt.Errorf("key %s was not found in session", sessionClientID) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) return } + app := >smodel.Application{} if err := m.db.GetWhere(c.Request.Context(), []db.Where{{Key: sessionClientID, Value: clientID}}, app); err != nil { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("no application found for client id %s", clientID)}) + safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID) + var errWithCode gtserror.WithCode + if err == db.ErrNoEntries { + errWithCode = gtserror.NewErrorBadRequest(err, safe, helpfulAdvice) + } else { + errWithCode = gtserror.NewErrorInternalError(err, safe, helpfulAdvice) + } + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - user, err := m.parseUserFromClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) - if err != nil { + user, errWithCode := m.parseUserFromClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID) + if errWithCode != nil { m.clearSession(s) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } s.Set(sessionUserID, user.ID) if err := s.Save(); err != nil { m.clearSession(s) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } c.Redirect(http.StatusFound, OauthAuthorizePath) } -func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, error) { +func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) { if claims.Email == "" { - return nil, errors.New("no email returned in claims") + err := errors.New("no email returned in claims") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } // see if we already have a user for this email address + // if so, we don't need to continue + create one user := >smodel.User{} err := m.db.GetWhere(ctx, []db.Where{{Key: "email", Value: claims.Email}}, user) if err == nil { - // we do! so we can just return it return user, nil } if err != db.ErrNoEntries { - // we have an actual error in the database - return nil, fmt.Errorf("error checking database for email %s: %s", claims.Email, err) + err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) + return nil, gtserror.NewErrorInternalError(err) } // maybe we have an unconfirmed user err = m.db.GetWhere(ctx, []db.Where{{Key: "unconfirmed_email", Value: claims.Email}}, user) if err == nil { - // user is unconfirmed so return an error - return nil, fmt.Errorf("user with email address %s is unconfirmed", claims.Email) + err := fmt.Errorf("user with email address %s is unconfirmed", claims.Email) + return nil, gtserror.NewErrorForbidden(err, err.Error()) } if err != db.ErrNoEntries { - // we have an actual error in the database - return nil, fmt.Errorf("error checking database for email %s: %s", claims.Email, err) + err := fmt.Errorf("error checking database for email %s: %s", claims.Email, err) + return nil, gtserror.NewErrorInternalError(err) } // we don't have a confirmed or unconfirmed user with the claimed email address @@ -138,10 +163,10 @@ func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, i // check if the email address is available for use; if it's not there's nothing we can so emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email) if err != nil { - return nil, fmt.Errorf("email %s not available: %s", claims.Email, err) + return nil, gtserror.NewErrorBadRequest(err) } if !emailAvailable { - return nil, fmt.Errorf("email %s in use", claims.Email) + return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", claims.Email)) } // now we need a username @@ -149,12 +174,12 @@ func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, i // make sure claims.Name is defined since we'll be using that for the username if claims.Name == "" { - return nil, errors.New("no name returned in claims") + err := errors.New("no name returned in claims") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } // check if we can just use claims.Name as-is - err = validate.Username(claims.Name) - if err == nil { + if err = validate.Username(claims.Name); err == nil { // the name we have on the claims is already a valid username username = claims.Name } else { @@ -166,12 +191,12 @@ func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, i // lowercase the whole thing lower := strings.ToLower(underscored) // see if this is valid.... - if err := validate.Username(lower); err == nil { - // we managed to get a valid username - username = lower - } else { - return nil, fmt.Errorf("couldn't parse a valid username from claims.Name value of %s", claims.Name) + if err := validate.Username(lower); err != nil { + err := fmt.Errorf("couldn't parse a valid username from claims.Name value of %s: %s", claims.Name, err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } + // we managed to get a valid username + username = lower } var iString string @@ -185,7 +210,7 @@ func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, i for i := 1; !found; i++ { usernameAvailable, err := m.db.IsUsernameAvailable(ctx, username+iString) if err != nil { - return nil, err + return nil, gtserror.NewErrorInternalError(err) } if usernameAvailable { // no error so we've found a username that works @@ -223,7 +248,7 @@ func (m *Module) parseUserFromClaims(ctx context.Context, claims *oidc.Claims, i // create the user! this will also create an account and store it in the database so we don't need to do that here user, err = m.db.NewSignup(ctx, username, "", requireApproval, claims.Email, password, ip, "", appID, emailVerified, admin) if err != nil { - return nil, fmt.Errorf("error creating user: %s", err) + return nil, gtserror.NewErrorInternalError(err) } return user, nil diff --git a/internal/api/client/auth/signin.go b/internal/api/client/auth/signin.go @@ -21,14 +21,14 @@ package auth import ( "context" "errors" + "fmt" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "golang.org/x/crypto/bcrypt" ) @@ -41,64 +41,62 @@ type login struct { // SignInGETHandler should be served at https://example.org/auth/sign_in. // The idea is to present a sign in page to the user, where they can enter their username and password. -// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler +// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler. +// If an idp provider is set, then the user will be redirected to that to do their sign in. func (m *Module) SignInGETHandler(c *gin.Context) { - l := logrus.WithField("func", "SignInGETHandler") - l.Trace("entering sign in handler") - if _, err := api.NegotiateAccept(c, api.HTMLAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - if m.idp != nil { - s := sessions.Default(c) + if m.idp == nil { + // no idp provider, use our own funky little sign in page + c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) + return + } - stateI := s.Get(sessionState) - state, ok := stateI.(string) - if !ok { - m.clearSession(s) - c.JSON(http.StatusForbidden, gin.H{"error": "state not found in session"}) - return - } + // idp provider is in use, so redirect to it + s := sessions.Default(c) - redirect := m.idp.AuthCodeURL(state) - l.Debugf("redirecting to external idp at %s", redirect) - c.Redirect(http.StatusSeeOther, redirect) + stateI := s.Get(sessionState) + state, ok := stateI.(string) + if !ok { + m.clearSession(s) + err := fmt.Errorf("key %s was not found in session", sessionState) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) + + c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(state)) } // SignInPOSTHandler should be served at https://example.org/auth/sign_in. // The idea is to present a sign in page to the user, where they can enter their username and password. // The handler will then redirect to the auth handler served at /auth func (m *Module) SignInPOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "SignInPOSTHandler") s := sessions.Default(c) + form := &login{} if err := c.ShouldBind(form); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) m.clearSession(s) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) return } - l.Tracef("parsed form: %+v", form) - userid, err := m.ValidatePassword(c.Request.Context(), form.Email, form.Password) - if err != nil { - c.String(http.StatusForbidden, err.Error()) - m.clearSession(s) + userid, errWithCode := m.ValidatePassword(c.Request.Context(), form.Email, form.Password) + if errWithCode != nil { + // don't clear session here, so the user can just press back and try again + // if they accidentally gave the wrong password or something + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } s.Set(sessionUserID, userid) if err := s.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - m.clearSession(s) - return + err := fmt.Errorf("error saving user id onto session: %s", err) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err, helpfulAdvice), m.processor.InstanceGet) } - l.Trace("redirecting to auth page") c.Redirect(http.StatusFound, OauthAuthorizePath) } @@ -106,42 +104,34 @@ func (m *Module) SignInPOSTHandler(c *gin.Context) { // The goal is to authenticate the password against the one for that email // address stored in the database. If OK, we return the userid (a ulid) for that user, // so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. -func (m *Module) ValidatePassword(ctx context.Context, email string, password string) (userid string, err error) { - l := logrus.WithField("func", "ValidatePassword") - - // make sure an email/password was provided and bail if not +func (m *Module) ValidatePassword(ctx context.Context, email string, password string) (string, gtserror.WithCode) { if email == "" || password == "" { - l.Debug("email or password was not provided") - return incorrectPassword() + err := errors.New("email or password was not provided") + return incorrectPassword(err) } - // first we select the user from the database based on email address, bail if no user found for that email - gtsUser := >smodel.User{} - - if err := m.db.GetWhere(ctx, []db.Where{{Key: "email", Value: email}}, gtsUser); err != nil { - l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) - return incorrectPassword() + user := >smodel.User{} + if err := m.db.GetWhere(ctx, []db.Where{{Key: "email", Value: email}}, user); err != nil { + err := fmt.Errorf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) + return incorrectPassword(err) } - // make sure a password is actually set and bail if not - if gtsUser.EncryptedPassword == "" { - l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) - return incorrectPassword() + if user.EncryptedPassword == "" { + err := fmt.Errorf("encrypted password for user %s was empty for some reason", user.Email) + return incorrectPassword(err) } - // compare the provided password with the encrypted one from the db, bail if they don't match - if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { - l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) - return incorrectPassword() + if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil { + err := fmt.Errorf("password hash didn't match for user %s during login attempt: %s", user.Email, err) + return incorrectPassword(err) } - // If we've made it this far the email/password is correct, so we can just return the id of the user. - userid = gtsUser.ID - l.Tracef("returning (%s, %s)", userid, err) - return + return user.ID, nil } -// incorrectPassword is just a little helper function to use in the ValidatePassword function -func incorrectPassword() (string, error) { - return "", errors.New("password/email combination was incorrect") +// incorrectPassword wraps the given error in a gtserror.WithCode, and returns +// only a generic 'safe' error message to the user, to not give any info away. +func incorrectPassword(err error) (string, gtserror.WithCode) { + safeErr := fmt.Errorf("password/email combination was incorrect") + return "", gtserror.NewErrorUnauthorized(err, safeErr.Error(), helpfulAdvice) } diff --git a/internal/api/client/auth/token.go b/internal/api/client/auth/token.go @@ -19,11 +19,10 @@ package auth import ( - "net/http" "net/url" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/gin-gonic/gin" ) @@ -40,38 +39,40 @@ type tokenBody struct { // TokenPOSTHandler should be served as a POST at https://example.org/oauth/token // The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. func (m *Module) TokenPOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "TokenPOSTHandler") - l.Trace("entered TokenPOSTHandler") - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } form := &tokenBody{} - if err := c.ShouldBind(form); err == nil { - c.Request.Form = url.Values{} - if form.ClientID != nil { - c.Request.Form.Set("client_id", *form.ClientID) - } - if form.ClientSecret != nil { - c.Request.Form.Set("client_secret", *form.ClientSecret) - } - if form.Code != nil { - c.Request.Form.Set("code", *form.Code) - } - if form.GrantType != nil { - c.Request.Form.Set("grant_type", *form.GrantType) - } - if form.RedirectURI != nil { - c.Request.Form.Set("redirect_uri", *form.RedirectURI) - } - if form.Scope != nil { - c.Request.Form.Set("scope", *form.Scope) - } + if err := c.ShouldBind(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, helpfulAdvice), m.processor.InstanceGet) + return + } + + c.Request.Form = url.Values{} + if form.ClientID != nil { + c.Request.Form.Set("client_id", *form.ClientID) + } + if form.ClientSecret != nil { + c.Request.Form.Set("client_secret", *form.ClientSecret) + } + if form.Code != nil { + c.Request.Form.Set("code", *form.Code) + } + if form.GrantType != nil { + c.Request.Form.Set("grant_type", *form.GrantType) + } + if form.RedirectURI != nil { + c.Request.Form.Set("redirect_uri", *form.RedirectURI) + } + if form.Scope != nil { + c.Request.Form.Set("scope", *form.Scope) } + // pass the writer and request into the oauth server handler, which will + // take care of writing the oauth token into the response etc if err := m.server.HandleTokenRequest(c.Writer, c.Request); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err, helpfulAdvice), m.processor.InstanceGet) } } diff --git a/internal/api/client/blocks/blocksget.go b/internal/api/client/blocks/blocksget.go @@ -19,13 +19,13 @@ package blocks import ( + "fmt" "net/http" "strconv" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -80,24 +80,25 @@ import ( // type: array // items: // "$ref": "#/definitions/account" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) BlocksGETHandler(c *gin.Context) { - l := logrus.WithField("func", "PublicTimelineGETHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -118,8 +119,8 @@ func (m *Module) BlocksGETHandler(c *gin.Context) { if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -127,8 +128,7 @@ func (m *Module) BlocksGETHandler(c *gin.Context) { resp, errWithCode := m.processor.BlocksGet(c.Request.Context(), authed, maxID, sinceID, limit) if errWithCode != nil { - l.Debugf("error from processor BlocksGet: %s", errWithCode) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/emoji/emojisget.go b/internal/api/client/emoji/emojisget.go @@ -5,18 +5,25 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" ) // EmojisGETHandler returns a list of custom emojis enabled on the instance func (m *Module) EmojisGETHandler(c *gin.Context) { + if _, err := oauth.Authed(c, true, true, true, true); err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } emojis, errWithCode := m.processor.CustomEmojisGet(c) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/favourites/favouritesget.go b/internal/api/client/favourites/favouritesget.go @@ -1,29 +1,26 @@ package favourites import ( + "fmt" "net/http" "strconv" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) // FavouritesGETHandler handles GETting favourites. func (m *Module) FavouritesGETHandler(c *gin.Context) { - l := logrus.WithField("func", "PublicTimelineGETHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -44,8 +41,8 @@ func (m *Module) FavouritesGETHandler(c *gin.Context) { if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -53,8 +50,7 @@ func (m *Module) FavouritesGETHandler(c *gin.Context) { resp, errWithCode := m.processor.FavedTimelineGet(c.Request.Context(), authed, maxID, minID, limit) if errWithCode != nil { - l.Debugf("error from processor FavedTimelineGet: %s", errWithCode) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/fileserver/servefile.go b/internal/api/client/fileserver/servefile.go @@ -19,6 +19,7 @@ package fileserver import ( + "fmt" "io" "net/http" @@ -26,6 +27,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -34,17 +36,9 @@ import ( // Note: to mitigate scraping attempts, no information should be given out on a bad request except "404 page not found". // Don't give away account ids or media ids or anything like that; callers shouldn't be able to infer anything. func (m *FileServer) ServeFile(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "ServeFile", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Trace("received request") - authed, err := oauth.Authed(c, false, false, false, false) if err != nil { - c.String(http.StatusNotFound, "404 page not found") + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) return } @@ -53,29 +47,29 @@ func (m *FileServer) ServeFile(c *gin.Context) { // "FILE_NAME" consists of two parts, the attachment's database id, a period, and the file extension. accountID := c.Param(AccountIDKey) if accountID == "" { - l.Debug("missing accountID from request") - c.String(http.StatusNotFound, "404 page not found") + err := fmt.Errorf("missing %s from request", AccountIDKey) + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) return } mediaType := c.Param(MediaTypeKey) if mediaType == "" { - l.Debug("missing mediaType from request") - c.String(http.StatusNotFound, "404 page not found") + err := fmt.Errorf("missing %s from request", MediaTypeKey) + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) return } mediaSize := c.Param(MediaSizeKey) if mediaSize == "" { - l.Debug("missing mediaSize from request") - c.String(http.StatusNotFound, "404 page not found") + err := fmt.Errorf("missing %s from request", MediaSizeKey) + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) return } fileName := c.Param(FileNameKey) if fileName == "" { - l.Debug("missing fileName from request") - c.String(http.StatusNotFound, "404 page not found") + err := fmt.Errorf("missing %s from request", FileNameKey) + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), m.processor.InstanceGet) return } @@ -86,8 +80,7 @@ func (m *FileServer) ServeFile(c *gin.Context) { FileName: fileName, }) if errWithCode != nil { - l.Errorf(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -95,7 +88,7 @@ func (m *FileServer) ServeFile(c *gin.Context) { // if the content is a ReadCloser, close it when we're done if closer, ok := content.Content.(io.ReadCloser); ok { if err := closer.Close(); err != nil { - l.Errorf("error closing readcloser: %s", err) + logrus.Errorf("ServeFile: error closing readcloser: %s", err) } } }() @@ -103,9 +96,9 @@ func (m *FileServer) ServeFile(c *gin.Context) { // TODO: if the requester only accepts text/html we should try to serve them *something*. // This is mostly needed because when sharing a link to a gts-hosted file on something like mastodon, the masto servers will // attempt to look up the content to provide a preview of the link, and they ask for text/html. - format, err := api.NegotiateAccept(c, api.Offer(content.ContentType)) - if errWithCode != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + format, err := api.NegotiateAccept(c, api.MIME(content.ContentType)) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } diff --git a/internal/api/client/filter/filtersget.go b/internal/api/client/filter/filtersget.go @@ -5,12 +5,19 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" ) // FiltersGETHandler returns a list of filters set by/for the authed account func (m *Module) FiltersGETHandler(c *gin.Context) { + if _, err := oauth.Authed(c, true, true, true, true); err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } diff --git a/internal/api/client/followrequest/authorize.go b/internal/api/client/followrequest/authorize.go @@ -19,12 +19,12 @@ package followrequest import ( + "errors" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -62,43 +62,34 @@ import ( // description: bad request // '401': // description: unauthorized -// '403': -// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable // '500': // description: internal server error func (m *Module) FollowRequestAuthorizePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "FollowRequestAuthorizePOSTHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) - return - } - - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } originAccountID := c.Param(IDKey) if originAccountID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no follow request origin account id provided"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } relationship, errWithCode := m.processor.FollowRequestAccept(c.Request.Context(), authed, originAccountID) if errWithCode != nil { - l.Debug(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/followrequest/authorize_test.go b/internal/api/client/followrequest/authorize_test.go @@ -82,6 +82,34 @@ func (suite *AuthorizeTestSuite) TestAuthorize() { suite.Equal(`{"id":"01FHMQX3GAABWSM0S2VZEC2SWC","following":false,"showing_reblogs":false,"notifying":false,"followed_by":true,"blocking":false,"blocked_by":false,"muting":false,"muting_notifications":false,"requested":false,"domain_blocking":false,"endorsed":false,"note":""}`, string(b)) } +func (suite *AuthorizeTestSuite) TestAuthorizeNoFR() { + requestingAccount := suite.testAccounts["remote_account_2"] + + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPost, []byte{}, fmt.Sprintf("/api/v1/follow_requests/%s/authorize", requestingAccount.ID), "") + + ctx.Params = gin.Params{ + gin.Param{ + Key: followrequest.IDKey, + Value: requestingAccount.ID, + }, + } + + // call the handler + suite.followRequestModule.FollowRequestAuthorizePOSTHandler(ctx) + + suite.Equal(http.StatusNotFound, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + // check the response + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + suite.Equal(`{"error":"Not Found"}`, string(b)) +} + func TestAuthorizeTestSuite(t *testing.T) { suite.Run(t, &AuthorizeTestSuite{}) } diff --git a/internal/api/client/followrequest/get.go b/internal/api/client/followrequest/get.go @@ -21,10 +21,9 @@ package followrequest import ( "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -71,34 +70,27 @@ import ( // description: bad request // '401': // description: unauthorized -// '403': -// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) FollowRequestGETHandler(c *gin.Context) { - l := logrus.WithField("func", "FollowRequestGETHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) - return - } - - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } accts, errWithCode := m.processor.FollowRequestsGet(c.Request.Context(), authed) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/followrequest/reject.go b/internal/api/client/followrequest/reject.go @@ -19,11 +19,12 @@ package followrequest import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -59,43 +60,34 @@ import ( // description: bad request // '401': // description: unauthorized -// '403': -// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable // '500': // description: internal server error func (m *Module) FollowRequestRejectPOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "FollowRequestRejectPOSTHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) - return - } - - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } originAccountID := c.Param(IDKey) if originAccountID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no follow request origin account id provided"}) + err := errors.New("no account id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } relationship, errWithCode := m.processor.FollowRequestReject(c.Request.Context(), authed, originAccountID) if errWithCode != nil { - l.Debug(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/instance/instanceget.go b/internal/api/client/instance/instanceget.go @@ -3,9 +3,9 @@ package instance import ( "net/http" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/gin-gonic/gin" ) @@ -30,22 +30,19 @@ import ( // description: "Instance information." // schema: // "$ref": "#/definitions/instance" +// '406': +// description: not acceptable // '500': // description: internal error func (m *Module) InstanceInformationGETHandler(c *gin.Context) { - l := logrus.WithField("func", "InstanceInformationGETHandler") - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - host := config.GetHost() - - instance, err := m.processor.InstanceGet(c.Request.Context(), host) - if err != nil { - l.Debugf("error getting instance from processor: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + instance, errWithCode := m.processor.InstanceGet(c.Request.Context(), config.GetHost()) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/instance/instancepatch.go b/internal/api/client/instance/instancepatch.go @@ -1,13 +1,13 @@ package instance import ( + "errors" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -82,52 +82,51 @@ import ( // description: "The newly updated instance." // schema: // "$ref": "#/definitions/instance" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) InstanceUpdatePATCHHandler(c *gin.Context) { - l := logrus.WithField("func", "InstanceUpdatePATCHHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - // only admins can update instance settings if !authed.User.Admin { - l.Debug("user is not an admin so cannot update instance settings") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not an admin"}) + err := errors.New("user is not an admin so cannot update instance settings") + api.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGet) return } - l.Debug("parsing request form") form := &model.InstanceSettingsUpdateRequest{} - if err := c.ShouldBind(&form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if err := c.ShouldBind(&form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - l.Debugf("parsed form: %+v", form) - - // if everything on the form is nil, then nothing has been set and we shouldn't continue if form.Title == nil && form.ContactUsername == nil && form.ContactEmail == nil && form.ShortDescription == nil && form.Description == nil && form.Terms == nil && form.Avatar == nil && form.Header == nil { - l.Debugf("could not parse form from request") - c.JSON(http.StatusBadRequest, gin.H{"error": "empty form submitted"}) + err := errors.New("empty form submitted") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } i, errWithCode := m.processor.InstancePatch(c.Request.Context(), form) if errWithCode != nil { - l.Debugf("error with instance patch request: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" + "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/testrig" ) @@ -125,6 +126,67 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() { suite.Equal(`{"uri":"http://localhost:8080","title":"localhost:8080","description":"","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"","version":"","registrations":true,"approval_required":true,"invites_enabled":false,"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":0,"status_count":16,"user_count":4},"thumbnail":"","max_toot_chars":5000}`, string(b)) } +func (suite *InstancePatchTestSuite) TestInstancePatch4() { + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{}) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + + // set up the request + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, instance.InstanceInformationPath, w.FormDataContentType()) + + // call the handler + suite.instanceModule.InstanceUpdatePATCHHandler(ctx) + + suite.Equal(http.StatusBadRequest, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + suite.NoError(err) + + suite.Equal(`{"error":"Bad Request: empty form submitted"}`, string(b)) +} + +func (suite *InstancePatchTestSuite) TestInstancePatch5() { + requestBody, w, err := testrig.CreateMultipartFormData( + "", "", + map[string]string{ + "short_description": "<p>This is some html, which is <em>allowed</em> in short descriptions.</p>", + }) + if err != nil { + panic(err) + } + bodyBytes := requestBody.Bytes() + + // set up the request + recorder := httptest.NewRecorder() + ctx := suite.newContext(recorder, http.MethodPatch, bodyBytes, instance.InstanceInformationPath, w.FormDataContentType()) + + ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) + ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(suite.testTokens["local_account_1"])) + ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"]) + ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) + + // call the handler + suite.instanceModule.InstanceUpdatePATCHHandler(ctx) + + suite.Equal(http.StatusForbidden, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + suite.NoError(err) + + suite.Equal(`{"error":"Forbidden: user is not an admin so cannot update instance settings"}`, string(b)) +} + func TestInstancePatchTestSuite(t *testing.T) { suite.Run(t, &InstancePatchTestSuite{}) } diff --git a/internal/api/client/list/listsgets.go b/internal/api/client/list/listsgets.go @@ -5,12 +5,19 @@ import ( "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" ) // ListsGETHandler returns a list of lists created by/for the authed account func (m *Module) ListsGETHandler(c *gin.Context) { + if _, err := oauth.Authed(c, true, true, true, true); err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } diff --git a/internal/api/client/media/mediacreate.go b/internal/api/client/media/mediacreate.go @@ -23,12 +23,11 @@ import ( "fmt" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -80,46 +79,36 @@ import ( // description: bad request // '401': // description: unauthorized -// '403': -// description: forbidden // '422': // description: unprocessable +// '500': +// description: internal server error func (m *Module) MediaCreatePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "statusCreatePOSTHandler") - authed, err := oauth.Authed(c, true, true, true, true) // posting new media is serious business so we want *everything* + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - // extract the media create form from the request context - l.Tracef("parsing request form: %s", c.Request.Form) form := &model.AttachmentRequest{} if err := c.ShouldBind(&form); err != nil { - l.Debugf("error parsing form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Errorf("could not parse form: %s", err)}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) if err := validateCreateMedia(form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - l.Debug("calling processor media create func") - apiAttachment, err := m.processor.MediaCreate(c.Request.Context(), authed, form) + apiAttachment, errWithCode := m.processor.MediaCreate(c.Request.Context(), authed, form) if err != nil { - l.Debugf("error creating attachment: %s", err) - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -143,6 +132,7 @@ func validateCreateMedia(form *model.AttachmentRequest) error { if maxImageSize > maxSize { maxSize = maxImageSize } + if form.File.Size > int64(maxSize) { return fmt.Errorf("file size limit exceeded: limit is %d bytes but attachment was %d bytes", maxSize, form.File.Size) } diff --git a/internal/api/client/media/mediacreate_test.go b/internal/api/client/media/mediacreate_test.go @@ -247,15 +247,14 @@ func (suite *MediaCreateTestSuite) TestMediaCreateLongDescription() { suite.mediaModule.MediaCreatePOSTHandler(ctx) // check response - suite.EqualValues(http.StatusUnprocessableEntity, recorder.Code) + suite.EqualValues(http.StatusBadRequest, recorder.Code) result := recorder.Result() defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - expectedErr := fmt.Sprintf(`{"error":"image description length must be between 0 and 500 characters (inclusive), but provided image description was %d chars"}`, len(description)) - suite.Equal(expectedErr, string(b)) + suite.Equal(`{"error":"Bad Request: image description length must be between 0 and 500 characters (inclusive), but provided image description was 6667 chars"}`, string(b)) } func (suite *MediaCreateTestSuite) TestMediaCreateTooShortDescription() { diff --git a/internal/api/client/media/mediaget.go b/internal/api/client/media/mediaget.go @@ -19,12 +19,12 @@ package media import ( + "errors" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -59,33 +59,34 @@ import ( // description: bad request // '401': // description: unauthorized -// '403': -// description: forbidden -// '422': -// description: unprocessable +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) MediaGETHandler(c *gin.Context) { - l := logrus.WithField("func", "MediaGETHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } attachmentID := c.Param(IDKey) if attachmentID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no attachment ID given in request"}) + err := errors.New("no attachment id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } attachment, errWithCode := m.processor.MediaGet(c.Request.Context(), authed, attachmentID) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/media/mediaupdate.go b/internal/api/client/media/mediaupdate.go @@ -23,12 +23,11 @@ import ( "fmt" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -89,50 +88,45 @@ import ( // description: bad request // '401': // description: unauthorized -// '403': -// description: forbidden -// '422': -// description: unprocessable +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) MediaPUTHandler(c *gin.Context) { - l := logrus.WithField("func", "MediaGETHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } attachmentID := c.Param(IDKey) if attachmentID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no attachment ID given in request"}) + err := errors.New("no attachment id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // extract the media update form from the request context - l.Tracef("parsing request form: %s", c.Request.Form) - var form model.AttachmentUpdateRequest - if err := c.ShouldBind(&form); err != nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + form := &model.AttachmentUpdateRequest{} + if err := c.ShouldBind(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) - if err := validateUpdateMedia(&form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if err := validateUpdateMedia(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - attachment, errWithCode := m.processor.MediaUpdate(c.Request.Context(), authed, attachmentID, &form) + attachment, errWithCode := m.processor.MediaUpdate(c.Request.Context(), authed, attachmentID, form) if errWithCode != nil { - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/media/mediaupdate_test.go b/internal/api/client/media/mediaupdate_test.go @@ -232,7 +232,7 @@ func (suite *MediaUpdateTestSuite) TestUpdateImageShortDescription() { suite.NoError(err) // reply should be an error message - suite.Equal(`{"error":"image description length must be between 50 and 500 characters (inclusive), but provided image description was 16 chars"}`, string(b)) + suite.Equal(`{"error":"Bad Request: image description length must be between 50 and 500 characters (inclusive), but provided image description was 16 chars"}`, string(b)) } func TestMediaUpdateTestSuite(t *testing.T) { diff --git a/internal/api/client/notification/notificationsget.go b/internal/api/client/notification/notificationsget.go @@ -19,34 +19,26 @@ package notification import ( + "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) // NotificationsGETHandler serves a list of notifications to the caller, with the desired query parameters func (m *Module) NotificationsGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "NotificationsGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.Authed(c, true, true, true, true) // we don't really need an app here but we want everything else + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Errorf("error authing status faved by request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -55,8 +47,8 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) { if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -76,8 +68,7 @@ func (m *Module) NotificationsGETHandler(c *gin.Context) { resp, errWithCode := m.processor.NotificationsGet(c.Request.Context(), authed, limit, maxID, sinceID) if errWithCode != nil { - l.Debugf("error processing notifications get: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/search/searchget.go b/internal/api/client/search/searchget.go @@ -19,14 +19,15 @@ package search import ( + "errors" "fmt" "net/http" "strconv" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -52,50 +53,44 @@ import ( // type: array // items: // "$ref": "#/definitions/searchResult" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized +// '404': +// description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) SearchGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "SearchGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.Authed(c, true, true, true, true) // we don't really need an app here but we want everything else + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Errorf("error authing search request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - accountID := c.Query(AccountIDKey) - maxID := c.Query(MaxIDKey) - minID := c.Query(MinIDKey) - searchType := c.Query(TypeKey) - excludeUnreviewed := false excludeUnreviewedString := c.Query(ExcludeUnreviewedKey) if excludeUnreviewedString != "" { var err error excludeUnreviewed, err = strconv.ParseBool(excludeUnreviewedString) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("couldn't parse param %s: %s", excludeUnreviewedString, err)}) + err := fmt.Errorf("error parsing %s: %s", ExcludeUnreviewedKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } query := c.Query(QueryKey) if query == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter q was empty"}) + err := errors.New("query parameter q was empty") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } @@ -105,18 +100,19 @@ func (m *Module) SearchGETHandler(c *gin.Context) { var err error resolve, err = strconv.ParseBool(resolveString) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("couldn't parse param %s: %s", resolveString, err)}) + err := fmt.Errorf("error parsing %s: %s", ResolveKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } - limit := 20 + limit := 2 limitString := c.Query(LimitKey) if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -133,18 +129,12 @@ func (m *Module) SearchGETHandler(c *gin.Context) { if offsetString != "" { i, err := strconv.ParseInt(offsetString, 10, 64) if err != nil { - l.Debugf("error parsing offset string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse offset query param"}) + err := fmt.Errorf("error parsing %s: %s", OffsetKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } offset = int(i) } - if limit > 40 { - limit = 40 - } - if limit < 1 { - limit = 1 - } following := false followingString := c.Query(FollowingKey) @@ -152,16 +142,17 @@ func (m *Module) SearchGETHandler(c *gin.Context) { var err error following, err = strconv.ParseBool(followingString) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("couldn't parse param %s: %s", followingString, err)}) + err := fmt.Errorf("error parsing %s: %s", FollowingKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } } searchQuery := &model.SearchQuery{ - AccountID: accountID, - MaxID: maxID, - MinID: minID, - Type: searchType, + AccountID: c.Query(AccountIDKey), + MaxID: c.Query(MaxIDKey), + MinID: c.Query(MinIDKey), + Type: c.Query(TypeKey), ExcludeUnreviewed: excludeUnreviewed, Query: query, Resolve: resolve, @@ -172,8 +163,7 @@ func (m *Module) SearchGETHandler(c *gin.Context) { results, errWithCode := m.processor.SearchGet(c.Request.Context(), authed, searchQuery) if errWithCode != nil { - l.Debugf("error searching: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusboost.go b/internal/api/client/status/statusboost.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -66,37 +67,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusBoostPOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusBoostPOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debug("not authed so can't boost status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } apiStatus, errWithCode := m.processor.StatusBoost(c.Request.Context(), authed, targetStatusID) if errWithCode != nil { - l.Debugf("error processing status boost: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusboost_test.go b/internal/api/client/status/statusboost_test.go @@ -134,13 +134,13 @@ func (suite *StatusBoostTestSuite) TestPostUnboostable() { suite.statusModule.StatusBoostPOSTHandler(ctx) // check response - suite.EqualValues(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses + suite.Equal(http.StatusForbidden, recorder.Code) // we 403 unboostable statuses result := recorder.Result() defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"forbidden"}`, string(b)) + assert.Equal(suite.T(), `{"error":"Forbidden"}`, string(b)) } // try to boost a status that's not visible to the user @@ -177,13 +177,7 @@ func (suite *StatusBoostTestSuite) TestPostNotVisible() { suite.statusModule.StatusBoostPOSTHandler(ctx) // check response - suite.EqualValues(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible - - result := recorder.Result() - defer result.Body.Close() - b, err := ioutil.ReadAll(result.Body) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"404 not found"}`, string(b)) + suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible } func TestStatusBoostTestSuite(t *testing.T) { diff --git a/internal/api/client/status/statusboostedby.go b/internal/api/client/status/statusboostedby.go @@ -23,6 +23,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -84,10 +85,9 @@ func (m *Module) StatusBoostedByGETHandler(c *gin.Context) { return } - apiAccounts, err := m.processor.StatusBoostedBy(c.Request.Context(), authed, targetStatusID) - if err != nil { - l.Debugf("error processing status boosted by request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + apiAccounts, errWithCode := m.processor.StatusBoostedBy(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statuscontext.go b/internal/api/client/status/statuscontext.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -65,37 +66,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusContextGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusContextGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Errorf("error authing status context request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } statusContext, errWithCode := m.processor.StatusGetContext(c.Request.Context(), authed, targetStatusID) if errWithCode != nil { - l.Debugf("error getting status context: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statuscreate.go b/internal/api/client/status/statuscreate.go @@ -23,12 +23,11 @@ import ( "fmt" "net/http" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" "github.com/superseriousbusiness/gotosocial/internal/validate" ) @@ -61,58 +60,44 @@ import ( // description: "The newly created status." // schema: // "$ref": "#/definitions/status" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable // '500': -// description: internal error +// description: internal server error func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "statusCreatePOSTHandler") authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - // First check this user/account is permitted to post new statuses. - // There's no point continuing otherwise. - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) - return - } - - // extract the status create form from the request context - l.Debugf("parsing request form: %s", c.Request.Form) form := &model.AdvancedStatusCreateForm{} - if err := c.ShouldBind(form); err != nil || form == nil { - l.Debugf("could not parse form from request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + if err := c.ShouldBind(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - l.Debugf("handling status request form: %+v", form) - // Give the fields on the request form a first pass to make sure the request is superficially valid. - l.Tracef("validating form %+v", form) if err := validateCreateStatus(form); err != nil { - l.Debugf("error validating form: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - apiStatus, err := m.processor.StatusCreate(c.Request.Context(), authed, form) - if err != nil { - l.Debugf("error processing status create: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + apiStatus, errWithCode := m.processor.StatusCreate(c.Request.Context(), authed, form) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } @@ -120,7 +105,6 @@ func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { } func validateCreateStatus(form *model.AdvancedStatusCreateForm) error { - // validate that, structurally, we have a valid status/post if form.Status == "" && form.MediaIDs == nil && form.Poll == nil { return errors.New("no status, media, or poll provided") } @@ -135,19 +119,16 @@ func validateCreateStatus(form *model.AdvancedStatusCreateForm) error { maxPollChars := config.GetStatusesPollOptionMaxChars() maxCwChars := config.GetStatusesCWMaxChars() - // validate status if form.Status != "" { if len(form.Status) > maxChars { return fmt.Errorf("status too long, %d characters provided but limit is %d", len(form.Status), maxChars) } } - // validate media attachments if len(form.MediaIDs) > maxMediaFiles { return fmt.Errorf("too many media files attached to status, %d attached but limit is %d", len(form.MediaIDs), maxMediaFiles) } - // validate poll if form.Poll != nil { if form.Poll.Options == nil { return errors.New("poll with no options") @@ -162,14 +143,12 @@ func validateCreateStatus(form *model.AdvancedStatusCreateForm) error { } } - // validate spoiler text/cw if form.SpoilerText != "" { if len(form.SpoilerText) > maxCwChars { return fmt.Errorf("content-warning/spoilertext too long, %d characters provided but limit is %d", len(form.SpoilerText), maxCwChars) } } - // validate post language if form.Language != "" { if err := validate.Language(form.Language); err != nil { return err diff --git a/internal/api/client/status/statuscreate_test.go b/internal/api/client/status/statuscreate_test.go @@ -256,7 +256,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"bad request"}`, string(b)) + suite.Equal(`{"error":"Bad Request: status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) } // Post a reply to the status of a local user that allows replies. diff --git a/internal/api/client/status/statusdelete.go b/internal/api/client/status/statusdelete.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -65,43 +66,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusDELETEHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusDELETEHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debug("not authed so can't delete status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) - return - } - - apiStatus, err := m.processor.StatusDelete(c.Request.Context(), authed, targetStatusID) - if err != nil { - l.Debugf("error processing status delete: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - // the status was already gone/never existed - if apiStatus == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "Record not found"}) + apiStatus, errWithCode := m.processor.StatusDelete(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusfave.go b/internal/api/client/status/statusfave.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -62,37 +63,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusFavePOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusFavePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debug("not authed so can't fave status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - apiStatus, err := m.processor.StatusFave(c.Request.Context(), authed, targetStatusID) - if err != nil { - l.Debugf("error processing status fave: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + apiStatus, errWithCode := m.processor.StatusFave(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusfave_test.go b/internal/api/client/status/statusfave_test.go @@ -118,13 +118,13 @@ func (suite *StatusFaveTestSuite) TestPostUnfaveable() { suite.statusModule.StatusFavePOSTHandler(ctx) // check response - suite.EqualValues(http.StatusBadRequest, recorder.Code) + suite.EqualValues(http.StatusForbidden, recorder.Code) result := recorder.Result() defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) assert.NoError(suite.T(), err) - assert.Equal(suite.T(), `{"error":"bad request"}`, string(b)) + assert.Equal(suite.T(), `{"error":"Forbidden"}`, string(b)) } func TestStatusFaveTestSuite(t *testing.T) { diff --git a/internal/api/client/status/statusfavedby.go b/internal/api/client/status/statusfavedby.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -63,37 +64,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusFavedByGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "statusGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.Authed(c, true, true, true, true) // we don't really need an app here but we want everything else + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Errorf("error authing status faved by request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - apiAccounts, err := m.processor.StatusFavedBy(c.Request.Context(), authed, targetStatusID) - if err != nil { - l.Debugf("error processing status faved by request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + apiAccounts, errWithCode := m.processor.StatusFavedBy(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusget.go b/internal/api/client/status/statusget.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -54,45 +55,40 @@ import ( // description: "The requested created status." // schema: // "$ref": "#/definitions/status" -// '401': -// description: unauthorized // '400': // description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden // '404': // description: not found +// '406': +// description: not acceptable // '500': -// description: internal error +// description: internal server error func (m *Module) StatusGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "statusGETHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - - authed, err := oauth.Authed(c, false, false, false, false) + authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Errorf("error authing status faved by request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - apiStatus, err := m.processor.StatusGet(c.Request.Context(), authed, targetStatusID) - if err != nil { - l.Debugf("error processing status get: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + apiStatus, errWithCode := m.processor.StatusGet(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusunboost.go b/internal/api/client/status/statusunboost.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -63,37 +64,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusUnboostPOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusUnboostPOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debug("not authed so can't unboost status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } apiStatus, errWithCode := m.processor.StatusUnboost(c.Request.Context(), authed, targetStatusID) if errWithCode != nil { - l.Debugf("error processing status unboost: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/status/statusunfave.go b/internal/api/client/status/statusunfave.go @@ -19,11 +19,12 @@ package status import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -62,37 +63,32 @@ import ( // description: forbidden // '404': // description: not found +// '406': +// description: not acceptable +// '500': +// description: internal server error func (m *Module) StatusUnfavePOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusUnfavePOSTHandler", - "request_uri": c.Request.RequestURI, - "user_agent": c.Request.UserAgent(), - "origin_ip": c.ClientIP(), - }) - l.Debugf("entering function") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debug("not authed so can't unfave status") - c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } targetStatusID := c.Param(IDKey) if targetStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id provided"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - apiStatus, err := m.processor.StatusUnfave(c.Request.Context(), authed, targetStatusID) - if err != nil { - l.Debugf("error processing status unfave: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + apiStatus, errWithCode := m.processor.StatusUnfave(c.Request.Context(), authed, targetStatusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/streaming/stream.go b/internal/api/client/streaming/stream.go @@ -2,14 +2,24 @@ package streaming import ( "fmt" - "github.com/sirupsen/logrus" "net/http" "time" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // we expect cors requests (via eg., pinafore.social) so be lenient + CheckOrigin: func(r *http.Request) bool { return true }, +} + // StreamGETHandler swagger:operation GET /api/v1/streaming streamGet // // Initiate a websocket connection for live streaming of statuses and notifications. @@ -108,79 +118,78 @@ import ( // '400': // description: bad request func (m *Module) StreamGETHandler(c *gin.Context) { - l := logrus.WithField("func", "StreamGETHandler") - streamType := c.Query(StreamQueryKey) if streamType == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("no stream type provided under query key %s", StreamQueryKey)}) + err := fmt.Errorf("no stream type provided under query key %s", StreamQueryKey) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } accessToken := c.Query(AccessTokenQueryKey) if accessToken == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": fmt.Sprintf("no access token provided under query key %s", AccessTokenQueryKey)}) + err := fmt.Errorf("no access token provided under query key %s", AccessTokenQueryKey) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } - // make sure a valid token has been provided and obtain the associated account - account, err := m.processor.AuthorizeStreamingRequest(c.Request.Context(), accessToken) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "could not authorize with given token"}) + account, errWithCode := m.processor.AuthorizeStreamingRequest(c.Request.Context(), accessToken) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - // prepare to upgrade the connection to a websocket connection - upgrader := websocket.Upgrader{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - CheckOrigin: func(r *http.Request) bool { - // we fully expect cors requests (via something like pinafore.social) so we should be lenient here - return true - }, + stream, errWithCode := m.processor.OpenStreamForAccount(c.Request.Context(), account, streamType) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return } - // do the actual upgrade here - conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + l := logrus.WithFields(logrus.Fields{ + "account": account.Username, + "path": BasePath, + "streamID": stream.ID, + "streamType": streamType, + }) + + wsConn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - l.Infof("error upgrading websocket connection: %s", err) + // If the upgrade fails, then Upgrade replies to the client with an HTTP error response. + // Because websocket issues are a pretty common source of headaches, we should also log + // this at Error to make this plenty visible and help admins out a bit. + l.Errorf("error upgrading websocket connection: %s", err) + close(stream.Hangup) return } - defer conn.Close() // whatever happens, when we leave this function we want to close the websocket connection - // inform the processor that we have a new connection and want a s for it - s, errWithCode := m.processor.OpenStreamForAccount(c.Request.Context(), account, streamType) - if errWithCode != nil { - c.JSON(errWithCode.Code(), errWithCode.Safe()) - return - } - defer close(s.Hangup) // closing stream.Hangup indicates that we've finished with the connection (the client has gone), so we want to do this on exiting this handler + defer func() { + // cleanup + wsConn.Close() + close(stream.Hangup) + }() - // spawn a new ticker for pinging the connection periodically - t := time.NewTicker(30 * time.Second) + streamTicker := time.NewTicker(30 * time.Second) - // we want to stay in the sendloop as long as possible while the client is connected -- the only thing that should break the loop is if the client leaves or something else goes wrong -sendLoop: + // We want to stay in the loop as long as possible while the client is connected. + // The only thing that should break the loop is if the client leaves or the connection becomes unhealthy. + // + // If the loop does break, we expect the client to reattempt connection, so it's cheap to leave + try again +wsLoop: for { select { - case m := <-s.Messages: - // we've got a streaming message!! + case m := <-stream.Messages: l.Trace("received message from stream") - if err := conn.WriteJSON(m); err != nil { - l.Debugf("error writing json to websocket connection: %s", err) - // if something is wrong we want to bail and drop the connection -- the client will create a new one - break sendLoop + if err := wsConn.WriteJSON(m); err != nil { + l.Debugf("error writing json to websocket connection; breaking off: %s", err) + break wsLoop } l.Trace("wrote message into websocket connection") - case <-t.C: + case <-streamTicker.C: l.Trace("received TICK from ticker") - if err := conn.WriteMessage(websocket.PingMessage, []byte(": ping")); err != nil { - l.Debugf("error writing ping to websocket connection: %s", err) - // if something is wrong we want to bail and drop the connection -- the client will create a new one - break sendLoop + if err := wsConn.WriteMessage(websocket.PingMessage, []byte(": ping")); err != nil { + l.Debugf("error writing ping to websocket connection; breaking off: %s", err) + break wsLoop } l.Trace("wrote ping message into websocket connection") } } - - l.Trace("leaving StreamGETHandler") } diff --git a/internal/api/client/timeline/home.go b/internal/api/client/timeline/home.go @@ -19,13 +19,13 @@ package timeline import ( + "fmt" "net/http" "strconv" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -105,17 +105,14 @@ import ( // '400': // description: bad request func (m *Module) HomeTimelineGETHandler(c *gin.Context) { - l := logrus.WithField("func", "HomeTimelineGETHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -142,8 +139,8 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -154,8 +151,8 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { if localString != "" { i, err := strconv.ParseBool(localString) if err != nil { - l.Debugf("error parsing local string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse local query param"}) + err := fmt.Errorf("error parsing %s: %s", LocalKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } local = i @@ -163,8 +160,7 @@ func (m *Module) HomeTimelineGETHandler(c *gin.Context) { resp, errWithCode := m.processor.HomeTimelineGet(c.Request.Context(), authed, maxID, sinceID, minID, limit, local) if errWithCode != nil { - l.Debugf("error from processor HomeTimelineGet: %s", errWithCode) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/timeline/public.go b/internal/api/client/timeline/public.go @@ -19,13 +19,13 @@ package timeline import ( + "fmt" "net/http" "strconv" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -105,17 +105,14 @@ import ( // '400': // description: bad request func (m *Module) PublicTimelineGETHandler(c *gin.Context) { - l := logrus.WithField("func", "PublicTimelineGETHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } @@ -142,8 +139,8 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) { if limitString != "" { i, err := strconv.ParseInt(limitString, 10, 64) if err != nil { - l.Debugf("error parsing limit string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + err := fmt.Errorf("error parsing %s: %s", LimitKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } limit = int(i) @@ -154,8 +151,8 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) { if localString != "" { i, err := strconv.ParseBool(localString) if err != nil { - l.Debugf("error parsing local string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse local query param"}) + err := fmt.Errorf("error parsing %s: %s", LocalKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } local = i @@ -163,8 +160,7 @@ func (m *Module) PublicTimelineGETHandler(c *gin.Context) { resp, errWithCode := m.processor.PublicTimelineGet(c.Request.Context(), authed, maxID, sinceID, minID, limit, local) if errWithCode != nil { - l.Debugf("error from processor PublicTimelineGet: %s", errWithCode) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/user/passwordchange.go b/internal/api/client/user/passwordchange.go @@ -19,12 +19,13 @@ package user import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) @@ -54,48 +55,48 @@ import ( // responses: // '200': // description: Change successful +// '400': +// description: bad request // '401': // description: unauthorized // '403': // description: forbidden -// '400': -// description: bad request +// '406': +// description: not acceptable // '500': -// description: "internal error" +// description: internal error func (m *Module) PasswordChangePOSTHandler(c *gin.Context) { - l := logrus.WithField("func", "PasswordChangePOSTHandler") - authed, err := oauth.Authed(c, true, true, true, true) if err != nil { - l.Debugf("error authing: %s", err) - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) return } if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - // First check this user/account is active. - if authed.User.Disabled || !authed.User.Approved || !authed.Account.SuspendedAt.IsZero() { - l.Debugf("couldn't auth: %s", err) - c.JSON(http.StatusForbidden, gin.H{"error": "account is disabled, not yet approved, or suspended"}) + form := &model.PasswordChangeRequest{} + if err := c.ShouldBind(form); err != nil { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - form := &model.PasswordChangeRequest{} - if err := c.ShouldBind(form); err != nil || form == nil || form.NewPassword == "" || form.OldPassword == "" { - if err != nil { - l.Debugf("could not parse form from request: %s", err) - } - c.JSON(http.StatusBadRequest, gin.H{"error": "missing one or more required form values"}) + if form.OldPassword == "" { + err := errors.New("password change request missing field old_password") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) + return + } + + if form.NewPassword == "" { + err := errors.New("password change request missing field new_password") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } if errWithCode := m.processor.UserChangePassword(c.Request.Context(), authed, form); errWithCode != nil { - l.Debugf("error changing user password: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/client/user/passwordchange_test.go b/internal/api/client/user/passwordchange_test.go @@ -49,7 +49,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() { ctx.Set(oauth.SessionAuthorizedToken, oauthToken) ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) ctx.Request.Header.Set("accept", "application/json") ctx.Request.Form = url.Values{ "old_password": {"password"}, @@ -83,7 +83,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordMissingOldPassword() { ctx.Set(oauth.SessionAuthorizedToken, oauthToken) ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) ctx.Request.Header.Set("accept", "application/json") ctx.Request.Form = url.Values{ "new_password": {"peepeepoopoopassword"}, @@ -97,7 +97,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordMissingOldPassword() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"missing one or more required form values"}`, string(b)) + suite.Equal(`{"error":"Bad Request: password change request missing field old_password"}`, string(b)) } func (suite *PasswordChangeTestSuite) TestPasswordIncorrectOldPassword() { @@ -110,7 +110,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordIncorrectOldPassword() { ctx.Set(oauth.SessionAuthorizedToken, oauthToken) ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) ctx.Request.Header.Set("accept", "application/json") ctx.Request.Form = url.Values{ "old_password": {"notright"}, @@ -125,7 +125,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordIncorrectOldPassword() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"bad request: old password did not match"}`, string(b)) + suite.Equal(`{"error":"Bad Request: old password did not match"}`, string(b)) } func (suite *PasswordChangeTestSuite) TestPasswordWeakNewPassword() { @@ -138,7 +138,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordWeakNewPassword() { ctx.Set(oauth.SessionAuthorizedToken, oauthToken) ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"]) ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"]) - ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080/%s", user.PasswordChangePath), nil) + ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", user.PasswordChangePath), nil) ctx.Request.Header.Set("accept", "application/json") ctx.Request.Form = url.Values{ "old_password": {"password"}, @@ -153,7 +153,7 @@ func (suite *PasswordChangeTestSuite) TestPasswordWeakNewPassword() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"bad request: password is 94% strength, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) + suite.Equal(`{"error":"Bad Request: password is 94% strength, try including more special characters, using uppercase letters, using numbers or using a longer password"}`, string(b)) } func TestPasswordChangeTestSuite(t *testing.T) { diff --git a/internal/api/client/user/user_test.go b/internal/api/client/user/user_test.go @@ -56,8 +56,8 @@ type UserStandardTestSuite struct { } func (suite *UserStandardTestSuite) SetupTest() { - testrig.InitTestLog() testrig.InitTestConfig() + testrig.InitTestLog() fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1) clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1) suite.testTokens = testrig.NewTestTokens() diff --git a/internal/api/errorhandling.go b/internal/api/errorhandling.go @@ -0,0 +1,127 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +package api + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// TODO: add more templated html pages here for different error types + +// NotFoundHandler serves a 404 html page through the provided gin context, +// if accept is 'text/html', or just returns a json error if 'accept' is empty +// or application/json. +// +// When serving html, NotFoundHandler calls the provided InstanceGet function +// to fetch the apimodel representation of the instance, for serving in the +// 404 header and footer. +// +// If an error is returned by InstanceGet, the function will panic. +func NotFoundHandler(c *gin.Context, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode), accept string) { + switch accept { + case string(TextHTML): + host := config.GetHost() + instance, err := instanceGet(c.Request.Context(), host) + if err != nil { + panic(err) + } + + c.HTML(http.StatusNotFound, "404.tmpl", gin.H{ + "instance": instance, + }) + default: + c.JSON(http.StatusNotFound, gin.H{"error": http.StatusText(http.StatusNotFound)}) + } +} + +// genericErrorHandler is a more general version of the NotFoundHandler, which can +// be used for serving either generic error pages with some rendered help text, +// or just some error json if the caller prefers (or has no preference). +func genericErrorHandler(c *gin.Context, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode), accept string, errWithCode gtserror.WithCode) { + switch accept { + case string(TextHTML): + host := config.GetHost() + instance, err := instanceGet(c.Request.Context(), host) + if err != nil { + panic(err) + } + + c.HTML(errWithCode.Code(), "error.tmpl", gin.H{ + "instance": instance, + "code": errWithCode.Code(), + "error": errWithCode.Safe(), + }) + default: + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + } +} + +// ErrorHandler takes the provided gin context and errWithCode and tries to serve +// a helpful error to the caller. It will do content negotiation to figure out if +// the caller prefers to see an html page with the error rendered there. If not, or +// if something goes wrong during the function, it will recover and just try to serve +// an appropriate application/json content-type error. +func ErrorHandler(c *gin.Context, errWithCode gtserror.WithCode, instanceGet func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode)) { + path := c.Request.URL.Path + if raw := c.Request.URL.RawQuery; raw != "" { + path = path + "?" + raw + } + + l := logrus.WithFields(logrus.Fields{ + "path": path, + "error": errWithCode.Error(), + }) + + statusCode := errWithCode.Code() + + if statusCode == http.StatusInternalServerError { + l.Error("Internal Server Error") + } else { + l.Debug("handling error") + } + + // if we panic for any reason during error handling, + // we should still try to return a basic code + defer func() { + if p := recover(); p != nil { + l.Warnf("recovered from panic: %s", p) + c.JSON(statusCode, gin.H{"error": errWithCode.Safe()}) + } + }() + + // discover if we're allowed to serve a nice html error page, + // or if we should just use a json. Normally we would want to + // check for a returned error, but if an error occurs here we + // can just fall back to default behavior (serve json error). + accept, _ := NegotiateAccept(c, HTMLOrJSONAcceptHeaders...) + + if statusCode == http.StatusNotFound { + // use our special not found handler with useful status text + NotFoundHandler(c, instanceGet, accept) + } else { + genericErrorHandler(c, instanceGet, accept, errWithCode) + } +} diff --git a/internal/api/mime.go b/internal/api/mime.go @@ -0,0 +1,34 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + +package api + +// MIME represents a mime-type. +type MIME string + +// MIME type +const ( + AppJSON MIME = `application/json` + AppXML MIME = `application/xml` + AppActivityJSON MIME = `application/activity+json` + AppActivityLDJSON MIME = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` + AppForm MIME = `application/x-www-form-urlencoded` + MultipartForm MIME = `multipart/form-data` + TextXML MIME = `text/xml` + TextHTML MIME = `text/html` +) diff --git a/internal/api/negotiate.go b/internal/api/negotiate.go @@ -25,33 +25,40 @@ import ( "github.com/gin-gonic/gin" ) -// Offer represents an offered mime-type. -type Offer string - -const ( - AppJSON Offer = `application/json` // AppJSON is the mime type for 'application/json'. - AppActivityJSON Offer = `application/activity+json` // AppActivityJSON is the mime type for 'application/activity+json'. - AppActivityLDJSON Offer = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` // AppActivityLDJSON is the mime type for 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' - TextHTML Offer = `text/html` // TextHTML is the mime type for 'text/html'. -) - // ActivityPubAcceptHeaders represents the Accept headers mentioned here: -// https://www.w3.org/TR/activitypub/#retrieving-objects -var ActivityPubAcceptHeaders = []Offer{ +// +var ActivityPubAcceptHeaders = []MIME{ AppActivityJSON, AppActivityLDJSON, } // JSONAcceptHeaders is a slice of offers that just contains application/json types. -var JSONAcceptHeaders = []Offer{ +var JSONAcceptHeaders = []MIME{ + AppJSON, +} + +// HTMLOrJSONAcceptHeaders is a slice of offers that prefers TextHTML and will +// fall back to JSON if necessary. This is useful for error handling, since it can +// be used to serve a nice HTML page if the caller accepts that, or just JSON if not. +var HTMLOrJSONAcceptHeaders = []MIME{ + TextHTML, AppJSON, } // HTMLAcceptHeaders is a slice of offers that just contains text/html types. -var HTMLAcceptHeaders = []Offer{ +var HTMLAcceptHeaders = []MIME{ TextHTML, } +// HTMLOrActivityPubHeaders matches text/html first, then activitypub types. +// This is useful for user URLs that a user might go to in their browser. +// https://www.w3.org/TR/activitypub/#retrieving-objects +var HTMLOrActivityPubHeaders = []MIME{ + TextHTML, + AppActivityJSON, + AppActivityLDJSON, +} + // NegotiateAccept takes the *gin.Context from an incoming request, and a // slice of Offers, and performs content negotiation for the given request // with the given content-type offers. It will return a string representation @@ -73,7 +80,7 @@ var HTMLAcceptHeaders = []Offer{ // often-used Accept types. // // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation#server-driven_content_negotiation -func NegotiateAccept(c *gin.Context, offers ...Offer) (string, error) { +func NegotiateAccept(c *gin.Context, offers ...MIME) (string, error) { if len(offers) == 0 { return "", errors.New("no format offered") } diff --git a/internal/api/s2s/nodeinfo/nodeinfoget.go b/internal/api/s2s/nodeinfo/nodeinfoget.go @@ -23,8 +23,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // NodeInfoGETHandler swagger:operation GET /nodeinfo/2.0 nodeInfoGet @@ -45,26 +45,21 @@ import ( // schema: // "$ref": "#/definitions/nodeinfo" func (m *Module) NodeInfoGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "NodeInfoGETHandler", - "user-agent": c.Request.UserAgent(), - }) - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - ni, err := m.processor.GetNodeInfo(c.Request.Context(), c.Request) - if err != nil { - l.Debugf("error with get node info request: %s", err) - c.JSON(err.Code(), err.Safe()) + ni, errWithCode := m.processor.GetNodeInfo(c.Request.Context(), c.Request) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, jsonErr := json.Marshal(ni) - if jsonErr != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": jsonErr.Error()}) + b, err := json.Marshal(ni) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return } c.Data(http.StatusOK, `application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"`, b) diff --git a/internal/api/s2s/nodeinfo/wellknownget.go b/internal/api/s2s/nodeinfo/wellknownget.go @@ -22,8 +22,8 @@ import ( "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // NodeInfoWellKnownGETHandler swagger:operation GET /.well-known/nodeinfo nodeInfoWellKnownGet @@ -45,19 +45,14 @@ import ( // schema: // "$ref": "#/definitions/wellKnownResponse" func (m *Module) NodeInfoWellKnownGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "NodeInfoWellKnownGETHandler", - }) - if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - niRel, err := m.processor.GetNodeInfoRel(c.Request.Context(), c.Request) - if err != nil { - l.Debugf("error with get node info rel request: %s", err) - c.JSON(err.Code(), err.Safe()) + niRel, errWithCode := m.processor.GetNodeInfoRel(c.Request.Context(), c.Request) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/followers.go b/internal/api/s2s/user/followers.go @@ -20,48 +20,45 @@ package user import ( "encoding/json" - "fmt" + "errors" "net/http" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // FollowersGETHandler returns a collection of URIs for followers of the target user, formatted so that other AP servers can understand it. func (m *Module) FollowersGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "FollowersGETHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("negotiated format: %s", format) - ctx := transferContext(c) + if format == string(api.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + } - followers, errWithCode := m.processor.GetFediFollowers(ctx, requestedUsername, c.Request.URL) + resp, errWithCode := m.processor.GetFediFollowers(transferContext(c), requestedUsername, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(followers) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/following.go b/internal/api/s2s/user/following.go @@ -20,48 +20,45 @@ package user import ( "encoding/json" - "fmt" + "errors" "net/http" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // FollowingGETHandler returns a collection of URIs for accounts that the target user follows, formatted so that other AP servers can understand it. func (m *Module) FollowingGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "FollowingGETHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("negotiated format: %s", format) - ctx := transferContext(c) + if format == string(api.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + } - following, errWithCode := m.processor.GetFediFollowing(ctx, requestedUsername, c.Request.URL) + resp, errWithCode := m.processor.GetFediFollowing(transferContext(c), requestedUsername, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(following) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/inboxpost.go b/internal/api/s2s/user/inboxpost.go @@ -19,43 +19,33 @@ package user import ( - "net/http" + "errors" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/gtserror" //nolint:typecheck ) // InboxPOSTHandler deals with incoming POST requests to an actor's inbox. // Eg., POST to https://example.org/users/whatever/inbox. func (m *Module) InboxPOSTHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "InboxPOSTHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - ctx := transferContext(c) - - posted, err := m.processor.InboxPost(ctx, c.Writer, c.Request) - if err != nil { + if posted, err := m.processor.InboxPost(transferContext(c), c.Writer, c.Request); err != nil { if withCode, ok := err.(gtserror.WithCode); ok { - l.Debugf("InboxPOSTHandler: %s", withCode.Error()) - c.JSON(withCode.Code(), withCode.Safe()) - return + api.ErrorHandler(c, withCode, m.processor.InstanceGet) + } else { + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) } - l.Debugf("InboxPOSTHandler: error processing request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"}) - return - } - - if !posted { - l.Debugf("InboxPOSTHandler: request could not be handled as an AP request; headers were: %+v", c.Request.Header) - c.JSON(http.StatusBadRequest, gin.H{"error": "unable to process request"}) + } else if !posted { + err := errors.New("unable to process request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) } } diff --git a/internal/api/s2s/user/outboxget.go b/internal/api/s2s/user/outboxget.go @@ -20,13 +20,15 @@ package user import ( "encoding/json" + "errors" "fmt" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // OutboxGETHandler swagger:operation GET /users/{username}/outbox s2sOutboxGet @@ -80,23 +82,31 @@ import ( // '404': // description: not found func (m *Module) OutboxGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "OutboxGETHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(api.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + } + var page bool if pageString := c.Query(PageKey); pageString != "" { i, err := strconv.ParseBool(pageString) if err != nil { - l.Debugf("error parsing page string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse page query param"}) + err := fmt.Errorf("error parsing %s: %s", PageKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } page = i @@ -114,27 +124,15 @@ func (m *Module) OutboxGETHandler(c *gin.Context) { maxID = maxIDString } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) - if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) - return - } - l.Tracef("negotiated format: %s", format) - - ctx := transferContext(c) - - outbox, errWithCode := m.processor.GetFediOutbox(ctx, requestedUsername, page, maxID, minID, c.Request.URL) + resp, errWithCode := m.processor.GetFediOutbox(transferContext(c), requestedUsername, page, maxID, minID, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(outbox) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/publickeyget.go b/internal/api/s2s/user/publickeyget.go @@ -20,12 +20,13 @@ package user import ( "encoding/json" - "fmt" + "errors" "net/http" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // PublicKeyGETHandler should be served at eg https://example.org/users/:username/main-key. @@ -34,38 +35,34 @@ import ( // in the form of a vocab.ActivityStreamsPerson. The account will only contain the id, // public key, username, and type of the account. func (m *Module) PublicKeyGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "PublicKeyGETHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("negotiated format: %s", format) - ctx := transferContext(c) + if format == string(api.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + } - user, errWithCode := m.processor.GetFediUser(ctx, requestedUsername, c.Request.URL) + resp, errWithCode := m.processor.GetFediUser(transferContext(c), requestedUsername, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(user) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/repliesget.go b/internal/api/s2s/user/repliesget.go @@ -20,13 +20,15 @@ package user import ( "encoding/json" + "errors" "fmt" "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // StatusRepliesGETHandler swagger:operation GET /users/{username}/statuses/{status}/replies s2sRepliesGet @@ -86,29 +88,39 @@ import ( // '404': // description: not found func (m *Module) StatusRepliesGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusRepliesGETHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - requestedStatusID := c.Param(StatusIDKey) + // status IDs on our instance are always uppercase + requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) if requestedStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id specified in request"}) + err := errors.New("no status id specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) + return + } + + if format == string(api.TextHTML) { + // redirect to the status + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) + } + var page bool if pageString := c.Query(PageKey); pageString != "" { i, err := strconv.ParseBool(pageString) if err != nil { - l.Debugf("error parsing page string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse page query param"}) + err := fmt.Errorf("error parsing %s: %s", PageKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } page = i @@ -119,8 +131,8 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) { if onlyOtherAccountsString != "" { i, err := strconv.ParseBool(onlyOtherAccountsString) if err != nil { - l.Debugf("error parsing only_other_accounts string: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse only_other_accounts query param"}) + err := fmt.Errorf("error parsing %s: %s", OnlyOtherAccountsKey, err) + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } onlyOtherAccounts = i @@ -132,27 +144,15 @@ func (m *Module) StatusRepliesGETHandler(c *gin.Context) { minID = minIDString } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) - if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) - return - } - l.Tracef("negotiated format: %s", format) - - ctx := transferContext(c) - - replies, errWithCode := m.processor.GetFediStatusReplies(ctx, requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL) + resp, errWithCode := m.processor.GetFediStatusReplies(transferContext(c), requestedUsername, requestedStatusID, page, onlyOtherAccounts, minID, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(replies) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/statusget.go b/internal/api/s2s/user/statusget.go @@ -20,57 +20,53 @@ package user import ( "encoding/json" - "fmt" + "errors" "net/http" "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // StatusGETHandler serves the target status as an activitystreams NOTE so that other AP servers can parse it. func (m *Module) StatusGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "StatusGETHandler", - "url": c.Request.RequestURI, - }) - // usernames on our instance are always lowercase requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } // status IDs on our instance are always uppercase requestedStatusID := strings.ToUpper(c.Param(StatusIDKey)) if requestedStatusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id specified in request"}) + err := errors.New("no status id specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("negotiated format: %s", format) - ctx := transferContext(c) + if format == string(api.TextHTML) { + // redirect to the status + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername+"/statuses/"+requestedStatusID) + } - status, errWithCode := m.processor.GetFediStatus(ctx, requestedUsername, requestedStatusID, c.Request.URL) + resp, errWithCode := m.processor.GetFediStatus(transferContext(c), requestedUsername, requestedStatusID, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(status) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/user/userget.go b/internal/api/s2s/user/userget.go @@ -20,12 +20,13 @@ package user import ( "encoding/json" - "fmt" + "errors" "net/http" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) // UsersGETHandler should be served at https://example.org/users/:username. @@ -38,38 +39,34 @@ import ( // And of course, the request should be refused if the account or server making the // request is blocked. func (m *Module) UsersGETHandler(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "UsersGETHandler", - "url": c.Request.RequestURI, - }) - - requestedUsername := c.Param(UsernameKey) + // usernames on our instance are always lowercase + requestedUsername := strings.ToLower(c.Param(UsernameKey)) if requestedUsername == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no username specified in request"}) + err := errors.New("no username specified in request") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - format, err := api.NegotiateAccept(c, api.ActivityPubAcceptHeaders...) + format, err := api.NegotiateAccept(c, api.HTMLOrActivityPubHeaders...) if err != nil { - c.JSON(http.StatusNotAcceptable, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet) return } - l.Tracef("negotiated format: %s", format) - ctx := transferContext(c) + if format == string(api.TextHTML) { + // redirect to the user's profile + c.Redirect(http.StatusSeeOther, "/@"+requestedUsername) + } - user, errWithCode := m.processor.GetFediUser(ctx, requestedUsername, c.Request.URL) // GetFediUser handles auth as well + resp, errWithCode := m.processor.GetFediUser(transferContext(c), requestedUsername, c.Request.URL) if errWithCode != nil { - l.Info(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } - b, mErr := json.Marshal(user) - if mErr != nil { - err := fmt.Errorf("could not marshal json: %s", mErr) - l.Error(err) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + b, err := json.Marshal(resp) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/api/s2s/webfinger/webfingerget.go b/internal/api/s2s/webfinger/webfingerget.go @@ -27,6 +27,7 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" ) @@ -105,10 +106,9 @@ func (m *Module) WebfingerGETRequest(c *gin.Context) { ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) } - resp, err := m.processor.GetWebfingerAccount(ctx, username) - if err != nil { - l.Debugf("aborting request with an error: %s", err.Error()) - c.JSON(err.Code(), gin.H{"error": err.Safe()}) + resp, errWithCode := m.processor.GetWebfingerAccount(ctx, username) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } diff --git a/internal/api/security/extraheaders.go b/internal/api/security/extraheaders.go @@ -1,3 +1,21 @@ +/* + GoToSocial + Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org + + 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/>. +*/ + package security import "github.com/gin-gonic/gin" diff --git a/internal/api/security/useragentblock.go b/internal/api/security/useragentblock.go @@ -19,21 +19,17 @@ package security import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" ) -// UserAgentBlock blocks requests with undesired, empty, or invalid user-agent strings. +// UserAgentBlock aborts requests with empty user agent strings. func (m *Module) UserAgentBlock(c *gin.Context) { - l := logrus.WithFields(logrus.Fields{ - "func": "UserAgentBlock", - }) - if ua := c.Request.UserAgent(); ua == "" { - l.Debug("aborting request because there's no user-agent set") - c.AbortWithStatus(http.StatusTeapot) - return + code := http.StatusTeapot + err := errors.New(http.StatusText(code) + ": no user-agent sent with request") + c.AbortWithStatusJSON(code, gin.H{"error": err.Error()}) } } diff --git a/internal/federation/authenticate.go b/internal/federation/authenticate.go @@ -126,7 +126,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU vi := ctx.Value(ap.ContextRequestingPublicKeyVerifier) if vi == nil { err := errors.New("http request wasn't signed or http signature was invalid") - errWithCode := gtserror.NewErrorNotAuthorized(err, err.Error()) + errWithCode := gtserror.NewErrorUnauthorized(err, err.Error()) l.Debug(errWithCode) return nil, errWithCode } @@ -134,7 +134,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU verifier, ok := vi.(httpsig.Verifier) if !ok { err := errors.New("http request wasn't signed or http signature was invalid") - errWithCode := gtserror.NewErrorNotAuthorized(err, err.Error()) + errWithCode := gtserror.NewErrorUnauthorized(err, err.Error()) l.Debug(errWithCode) return nil, errWithCode } @@ -143,7 +143,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU si := ctx.Value(ap.ContextRequestingPublicKeySignature) if si == nil { err := errors.New("http request wasn't signed or http signature was invalid") - errWithCode := gtserror.NewErrorNotAuthorized(err, err.Error()) + errWithCode := gtserror.NewErrorUnauthorized(err, err.Error()) l.Debug(errWithCode) return nil, errWithCode } @@ -151,7 +151,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU signature, ok := si.(string) if !ok { err := errors.New("http request wasn't signed or http signature was invalid") - errWithCode := gtserror.NewErrorNotAuthorized(err, err.Error()) + errWithCode := gtserror.NewErrorUnauthorized(err, err.Error()) l.Debug(errWithCode) return nil, errWithCode } @@ -209,7 +209,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU // The actual http call to the remote server is made right here in the Dereference function. b, err := transport.Dereference(ctx, requestingPublicKeyID) if err != nil { - errWithCode := gtserror.NewErrorNotAuthorized(fmt.Errorf("error dereferencing public key %s: %s", requestingPublicKeyID, err)) + errWithCode := gtserror.NewErrorUnauthorized(fmt.Errorf("error dereferencing public key %s: %s", requestingPublicKeyID, err)) l.Debug(errWithCode) return nil, errWithCode } @@ -217,7 +217,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU // if the key isn't in the response, we can't authenticate the request requestingPublicKey, err := getPublicKeyFromResponse(ctx, b, requestingPublicKeyID) if err != nil { - errWithCode := gtserror.NewErrorNotAuthorized(fmt.Errorf("error parsing public key %s: %s", requestingPublicKeyID, err)) + errWithCode := gtserror.NewErrorUnauthorized(fmt.Errorf("error parsing public key %s: %s", requestingPublicKeyID, err)) l.Debug(errWithCode) return nil, errWithCode } @@ -225,7 +225,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU // we should be able to get the actual key embedded in the vocab.W3IDSecurityV1PublicKey pkPemProp := requestingPublicKey.GetW3IDSecurityV1PublicKeyPem() if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() { - errWithCode := gtserror.NewErrorNotAuthorized(errors.New("publicKeyPem property is not provided or it is not embedded as a value")) + errWithCode := gtserror.NewErrorUnauthorized(errors.New("publicKeyPem property is not provided or it is not embedded as a value")) l.Debug(errWithCode) return nil, errWithCode } @@ -234,14 +234,14 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU pubKeyPem := pkPemProp.Get() block, _ := pem.Decode([]byte(pubKeyPem)) if block == nil || block.Type != "PUBLIC KEY" { - errWithCode := gtserror.NewErrorNotAuthorized(errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")) + errWithCode := gtserror.NewErrorUnauthorized(errors.New("could not decode publicKeyPem to PUBLIC KEY pem block type")) l.Debug(errWithCode) return nil, errWithCode } publicKey, err = x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - errWithCode := gtserror.NewErrorNotAuthorized(fmt.Errorf("could not parse public key %s from block bytes: %s", requestingPublicKeyID, err)) + errWithCode := gtserror.NewErrorUnauthorized(fmt.Errorf("could not parse public key %s from block bytes: %s", requestingPublicKeyID, err)) l.Debug(errWithCode) return nil, errWithCode } @@ -249,7 +249,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU // all good! we just need the URI of the key owner to return pkOwnerProp := requestingPublicKey.GetW3IDSecurityV1Owner() if pkOwnerProp == nil || !pkOwnerProp.IsIRI() { - errWithCode := gtserror.NewErrorNotAuthorized(errors.New("publicKeyOwner property is not provided or it is not embedded as a value")) + errWithCode := gtserror.NewErrorUnauthorized(errors.New("publicKeyOwner property is not provided or it is not embedded as a value")) l.Debug(errWithCode) return nil, errWithCode } @@ -280,7 +280,7 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU l.Tracef("authentication for %s NOT PASSED with algorithm %s: %s", pkOwnerURI, algo, err) } - errWithCode := gtserror.NewErrorNotAuthorized(fmt.Errorf("authentication not passed for public key owner %s; signature value was '%s'", pkOwnerURI, signature)) + errWithCode := gtserror.NewErrorUnauthorized(fmt.Errorf("authentication not passed for public key owner %s; signature value was '%s'", pkOwnerURI, signature)) l.Debug(errWithCode) return nil, errWithCode } diff --git a/internal/gtserror/unauthorized.go b/internal/gtserror/unauthorized.go @@ -1,19 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org - - 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/>. -*/ - -package gtserror diff --git a/internal/gtserror/withcode.go b/internal/gtserror/withcode.go @@ -60,7 +60,7 @@ func (e withCode) Code() int { // NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text. func NewErrorBadRequest(original error, helpText ...string) WithCode { - safe := "bad request" + safe := http.StatusText(http.StatusBadRequest) if helpText != nil { safe = safe + ": " + strings.Join(helpText, ": ") } @@ -71,9 +71,9 @@ func NewErrorBadRequest(original error, helpText ...string) WithCode { } } -// NewErrorNotAuthorized returns an ErrorWithCode 401 with the given original error and optional help text. -func NewErrorNotAuthorized(original error, helpText ...string) WithCode { - safe := "not authorized" +// NewErrorUnauthorized returns an ErrorWithCode 401 with the given original error and optional help text. +func NewErrorUnauthorized(original error, helpText ...string) WithCode { + safe := http.StatusText(http.StatusUnauthorized) if helpText != nil { safe = safe + ": " + strings.Join(helpText, ": ") } @@ -86,7 +86,7 @@ func NewErrorNotAuthorized(original error, helpText ...string) WithCode { // NewErrorForbidden returns an ErrorWithCode 403 with the given original error and optional help text. func NewErrorForbidden(original error, helpText ...string) WithCode { - safe := "forbidden" + safe := http.StatusText(http.StatusForbidden) if helpText != nil { safe = safe + ": " + strings.Join(helpText, ": ") } @@ -99,7 +99,7 @@ func NewErrorForbidden(original error, helpText ...string) WithCode { // NewErrorNotFound returns an ErrorWithCode 404 with the given original error and optional help text. func NewErrorNotFound(original error, helpText ...string) WithCode { - safe := "404 not found" + safe := http.StatusText(http.StatusNotFound) if helpText != nil { safe = safe + ": " + strings.Join(helpText, ": ") } @@ -112,7 +112,7 @@ func NewErrorNotFound(original error, helpText ...string) WithCode { // NewErrorInternalError returns an ErrorWithCode 500 with the given original error and optional help text. func NewErrorInternalError(original error, helpText ...string) WithCode { - safe := "internal server error" + safe := http.StatusText(http.StatusInternalServerError) if helpText != nil { safe = safe + ": " + strings.Join(helpText, ": ") } @@ -125,7 +125,7 @@ func NewErrorInternalError(original error, helpText ...string) WithCode { // NewErrorConflict returns an ErrorWithCode 409 with the given original error and optional help text. func NewErrorConflict(original error, helpText ...string) WithCode { - safe := "conflict" + safe := http.StatusText(http.StatusConflict) if helpText != nil { safe = safe + ": " + strings.Join(helpText, ": ") } @@ -135,3 +135,29 @@ func NewErrorConflict(original error, helpText ...string) WithCode { code: http.StatusConflict, } } + +// NewErrorNotAcceptable returns an ErrorWithCode 406 with the given original error and optional help text. +func NewErrorNotAcceptable(original error, helpText ...string) WithCode { + safe := http.StatusText(http.StatusNotAcceptable) + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusNotAcceptable, + } +} + +// NewErrorUnprocessableEntity returns an ErrorWithCode 422 with the given original error and optional help text. +func NewErrorUnprocessableEntity(original error, helpText ...string) WithCode { + safe := http.StatusText(http.StatusUnprocessableEntity) + if helpText != nil { + safe = safe + ": " + strings.Join(helpText, ": ") + } + return withCode{ + original: original, + safe: errors.New(safe), + code: http.StatusUnprocessableEntity, + } +} diff --git a/internal/oidc/handlecallback.go b/internal/oidc/handlecallback.go @@ -24,24 +24,28 @@ import ( "fmt" "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) -func (i *idp) HandleCallback(ctx context.Context, code string) (*Claims, error) { +func (i *idp) HandleCallback(ctx context.Context, code string) (*Claims, gtserror.WithCode) { l := logrus.WithField("func", "HandleCallback") if code == "" { - return nil, errors.New("code was empty string") + err := errors.New("code was empty string") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } l.Debug("exchanging code for oauth2token") oauth2Token, err := i.oauth2Config.Exchange(ctx, code) if err != nil { - return nil, fmt.Errorf("error exchanging code for oauth2token: %s", err) + err := fmt.Errorf("error exchanging code for oauth2token: %s", err) + return nil, gtserror.NewErrorInternalError(err) } l.Debug("extracting id_token") rawIDToken, ok := oauth2Token.Extra("id_token").(string) if !ok { - return nil, errors.New("no id_token in oauth2token") + err := errors.New("no id_token in oauth2token") + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } l.Debugf("raw id token: %s", rawIDToken) @@ -50,13 +54,15 @@ func (i *idp) HandleCallback(ctx context.Context, code string) (*Claims, error) idTokenVerifier := i.provider.Verifier(i.oidcConf) idToken, err := idTokenVerifier.Verify(ctx, rawIDToken) if err != nil { - return nil, fmt.Errorf("could not verify id token: %s", err) + err = fmt.Errorf("could not verify id token: %s", err) + return nil, gtserror.NewErrorUnauthorized(err, err.Error()) } l.Debug("extracting claims from id_token") claims := &Claims{} if err := idToken.Claims(claims); err != nil { - return nil, fmt.Errorf("could not parse claims from idToken: %s", err) + err := fmt.Errorf("could not parse claims from idToken: %s", err) + return nil, gtserror.NewErrorInternalError(err, err.Error()) } return claims, nil diff --git a/internal/oidc/idp.go b/internal/oidc/idp.go @@ -24,6 +24,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "golang.org/x/oauth2" ) @@ -39,7 +40,7 @@ type IDP interface { // with a set of claims. // // Note that this function *does not* verify state. That should be handled by the caller *before* this function is called. - HandleCallback(ctx context.Context, code string) (*Claims, error) + HandleCallback(ctx context.Context, code string) (*Claims, gtserror.WithCode) // AuthCodeURL returns the proper redirect URL for this IDP, for redirecting requesters to the correct OIDC endpoint. AuthCodeURL(state string) string } diff --git a/internal/processing/account.go b/internal/processing/account.go @@ -26,7 +26,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) AccountCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) { +func (p *processor) AccountCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, gtserror.WithCode) { return p.accountProcessor.Create(ctx, authed.Token, authed.Application, form) } @@ -42,7 +42,7 @@ func (p *processor) AccountGetLocalByUsername(ctx context.Context, authed *oauth return p.accountProcessor.GetLocalByUsername(ctx, authed.Account, username) } -func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) { +func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) { return p.accountProcessor.Update(ctx, authed.Account, form) } diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go @@ -40,7 +40,7 @@ import ( // Processor wraps a bunch of functions for processing account actions. type Processor interface { // Create processes the given form for creating a new account, returning an oauth token for that account if successful. - Create(ctx context.Context, applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) + Create(ctx context.Context, applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, gtserror.WithCode) // Delete deletes an account, and all of that account's statuses, media, follows, notifications, etc etc etc. // The origin passed here should be either the ID of the account doing the delete (can be itself), or the ID of a domain block. Delete(ctx context.Context, account *gtsmodel.Account, origin string) gtserror.WithCode @@ -52,7 +52,7 @@ type Processor interface { // GetLocalByUsername processes the given request for account information targeting a local account by username. GetLocalByUsername(ctx context.Context, requestingAccount *gtsmodel.Account, username string) (*apimodel.Account, gtserror.WithCode) // Update processes the update of an account with the given form - Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) + Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) // StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. StatusesGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) diff --git a/internal/processing/account/create.go b/internal/processing/account/create.go @@ -27,29 +27,30 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/messages" "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/oauth2/v4" ) -func (p *processor) Create(ctx context.Context, applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) { +func (p *processor) Create(ctx context.Context, applicationToken oauth2.TokenInfo, application *gtsmodel.Application, form *apimodel.AccountCreateRequest) (*apimodel.Token, gtserror.WithCode) { l := logrus.WithField("func", "accountCreate") emailAvailable, err := p.db.IsEmailAvailable(ctx, form.Email) if err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } if !emailAvailable { - return nil, fmt.Errorf("email address %s in use", form.Email) + return nil, gtserror.NewErrorConflict(fmt.Errorf("email address %s is not available", form.Email)) } usernameAvailable, err := p.db.IsUsernameAvailable(ctx, form.Username) if err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } if !usernameAvailable { - return nil, fmt.Errorf("username %s in use", form.Username) + return nil, gtserror.NewErrorConflict(fmt.Errorf("username %s in use", form.Username)) } reasonRequired := config.GetAccountsReasonRequired() @@ -64,19 +65,19 @@ func (p *processor) Create(ctx context.Context, applicationToken oauth2.TokenInf l.Trace("creating new username and account") user, err := p.db.NewSignup(ctx, form.Username, text.SanitizePlaintext(reason), approvalRequired, form.Email, form.Password, form.IP, form.Locale, application.ID, false, false) if err != nil { - return nil, fmt.Errorf("error creating new signup in the database: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating new signup in the database: %s", err)) } l.Tracef("generating a token for user %s with account %s and application %s", user.ID, user.AccountID, application.ID) accessToken, err := p.oauthServer.GenerateUserAccessToken(ctx, applicationToken, application.ClientSecret, user.ID) if err != nil { - return nil, fmt.Errorf("error creating new access token for user %s: %s", user.ID, err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error creating new access token for user %s: %s", user.ID, err)) } if user.Account == nil { a, err := p.db.GetAccountByID(ctx, user.AccountID) if err != nil { - return nil, fmt.Errorf("error getting new account from the database: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("error getting new account from the database: %s", err)) } user.Account = a } diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go @@ -94,5 +94,6 @@ func (p *processor) getAccountFor(ctx context.Context, requestingAccount *gtsmod if err != nil { return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting account: %s", err)) } + return apiAccount, nil } diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/messages" @@ -37,7 +38,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/validate" ) -func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) { +func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) { l := logrus.WithField("func", "AccountUpdate") if form.Discoverable != nil { @@ -50,14 +51,14 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form if form.DisplayName != nil { if err := validate.DisplayName(*form.DisplayName); err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } account.DisplayName = text.SanitizePlaintext(*form.DisplayName) } if form.Note != nil { if err := validate.Note(*form.Note); err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } // Set the raw note before processing @@ -66,7 +67,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form // Process note to generate a valid HTML representation note, err := p.processNote(ctx, *form.Note, account.ID) if err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } // Set updated HTML-ified note @@ -76,7 +77,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form if form.Avatar != nil && form.Avatar.Size != 0 { avatarInfo, err := p.UpdateAvatar(ctx, form.Avatar, account.ID) if err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } account.AvatarMediaAttachmentID = avatarInfo.ID account.AvatarMediaAttachment = avatarInfo @@ -86,7 +87,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form if form.Header != nil && form.Header.Size != 0 { headerInfo, err := p.UpdateHeader(ctx, form.Header, account.ID) if err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } account.HeaderMediaAttachmentID = headerInfo.ID account.HeaderMediaAttachment = headerInfo @@ -100,7 +101,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form if form.Source != nil { if form.Source.Language != nil { if err := validate.Language(*form.Source.Language); err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } account.Language = *form.Source.Language } @@ -111,7 +112,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form if form.Source.Privacy != nil { if err := validate.Privacy(*form.Source.Privacy); err != nil { - return nil, err + return nil, gtserror.NewErrorBadRequest(err) } privacy := p.tc.APIVisToVis(apimodel.Visibility(*form.Source.Privacy)) account.Privacy = privacy @@ -120,7 +121,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form updatedAccount, err := p.db.UpdateAccount(ctx, account) if err != nil { - return nil, fmt.Errorf("could not update account %s: %s", account.ID, err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account %s: %s", account.ID, err)) } p.clientWorker.Queue(messages.FromClientAPI{ @@ -132,7 +133,7 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form acctSensitive, err := p.tc.AccountToAPIAccountSensitive(ctx, updatedAccount) if err != nil { - return nil, fmt.Errorf("could not convert account into apisensitive account: %s", err) + return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not convert account into apisensitive account: %s", err)) } return acctSensitive, nil } diff --git a/internal/processing/account/update_test.go b/internal/processing/account/update_test.go @@ -45,8 +45,8 @@ func (suite *AccountUpdateTestSuite) TestAccountUpdateSimple() { } // should get no error from the update function, and an api model account returned - apiAccount, err := suite.accountProcessor.Update(context.Background(), testAccount, form) - suite.NoError(err) + apiAccount, errWithCode := suite.accountProcessor.Update(context.Background(), testAccount, form) + suite.NoError(errWithCode) suite.NotNil(apiAccount) // fields on the profile should be updated @@ -88,8 +88,8 @@ go check out @1happyturtle, they have a cool account! } // should get no error from the update function, and an api model account returned - apiAccount, err := suite.accountProcessor.Update(context.Background(), testAccount, form) - suite.NoError(err) + apiAccount, errWithCode := suite.accountProcessor.Update(context.Background(), testAccount, form) + suite.NoError(errWithCode) suite.NotNil(apiAccount) // fields on the profile should be updated diff --git a/internal/processing/admin/emoji.go b/internal/processing/admin/emoji.go @@ -34,7 +34,7 @@ import ( func (p *processor) EmojiCreate(ctx context.Context, account *gtsmodel.Account, user *gtsmodel.User, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, gtserror.WithCode) { if !user.Admin { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin") + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("user %s not an admin", user.ID), "user is not an admin") } data := func(innerCtx context.Context) (io.Reader, int, error) { diff --git a/internal/processing/app.go b/internal/processing/app.go @@ -23,12 +23,13 @@ import ( "github.com/google/uuid" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) { +func (p *processor) AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, gtserror.WithCode) { // set default 'read' for scopes if it's not set var scopes string if form.Scopes == "" { @@ -40,13 +41,13 @@ func (p *processor) AppCreate(ctx context.Context, authed *oauth.Auth, form *api // generate new IDs for this application and its associated client clientID, err := id.NewRandomULID() if err != nil { - return nil, err + return nil, gtserror.NewErrorInternalError(err) } clientSecret := uuid.NewString() appID, err := id.NewRandomULID() if err != nil { - return nil, err + return nil, gtserror.NewErrorInternalError(err) } // generate the application to put in the database @@ -62,7 +63,7 @@ func (p *processor) AppCreate(ctx context.Context, authed *oauth.Auth, form *api // chuck it in the db if err := p.db.Put(ctx, app); err != nil { - return nil, err + return nil, gtserror.NewErrorInternalError(err) } // now we need to model an oauth client from the application that the oauth library can use @@ -70,17 +71,18 @@ func (p *processor) AppCreate(ctx context.Context, authed *oauth.Auth, form *api ID: clientID, Secret: clientSecret, Domain: form.RedirectURIs, - UserID: "", // This client isn't yet associated with a specific user, it's just an app client right now + // This client isn't yet associated with a specific user, it's just an app client right now + UserID: "", } // chuck it in the db if err := p.db.Put(ctx, oc); err != nil { - return nil, err + return nil, gtserror.NewErrorInternalError(err) } apiApp, err := p.tc.AppToAPIAppSensitive(ctx, app) if err != nil { - return nil, err + return nil, gtserror.NewErrorInternalError(err) } return apiApp, nil diff --git a/internal/processing/federation/getfollowers.go b/internal/processing/federation/getfollowers.go @@ -42,7 +42,7 @@ func (p *processor) GetFollowers(ctx context.Context, requestedUsername string, requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false) if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorUnauthorized(err) } blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) @@ -51,7 +51,7 @@ func (p *processor) GetFollowers(ctx context.Context, requestedUsername string, } if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } requestedAccountURI, err := url.Parse(requestedAccount.URI) diff --git a/internal/processing/federation/getfollowing.go b/internal/processing/federation/getfollowing.go @@ -42,7 +42,7 @@ func (p *processor) GetFollowing(ctx context.Context, requestedUsername string, requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false) if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorUnauthorized(err) } blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) @@ -51,7 +51,7 @@ func (p *processor) GetFollowing(ctx context.Context, requestedUsername string, } if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } requestedAccountURI, err := url.Parse(requestedAccount.URI) diff --git a/internal/processing/federation/getoutbox.go b/internal/processing/federation/getoutbox.go @@ -43,7 +43,7 @@ func (p *processor) GetOutbox(ctx context.Context, requestedUsername string, pag requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false) if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorUnauthorized(err) } // authorize the request: @@ -53,7 +53,7 @@ func (p *processor) GetOutbox(ctx context.Context, requestedUsername string, pag return nil, gtserror.NewErrorInternalError(err) } if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } var data map[string]interface{} diff --git a/internal/processing/federation/getstatus.go b/internal/processing/federation/getstatus.go @@ -42,7 +42,7 @@ func (p *processor) GetStatus(ctx context.Context, requestedUsername string, req requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false) if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorUnauthorized(err) } // authorize the request: @@ -53,7 +53,7 @@ func (p *processor) GetStatus(ctx context.Context, requestedUsername string, req } if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } // get the status out of the database here diff --git a/internal/processing/federation/getstatusreplies.go b/internal/processing/federation/getstatusreplies.go @@ -44,7 +44,7 @@ func (p *processor) GetStatusReplies(ctx context.Context, requestedUsername stri requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false) if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorUnauthorized(err) } // authorize the request: @@ -55,7 +55,7 @@ func (p *processor) GetStatusReplies(ctx context.Context, requestedUsername stri } if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } // get the status out of the database here diff --git a/internal/processing/federation/getuser.go b/internal/processing/federation/getuser.go @@ -54,7 +54,7 @@ func (p *processor) GetUser(ctx context.Context, requestedUsername string, reque if !p.federator.Handshaking(ctx, requestedUsername, requestingAccountURI) { requestingAccount, err := p.federator.GetRemoteAccount(ctx, requestedUsername, requestingAccountURI, false, false) if err != nil { - return nil, gtserror.NewErrorNotAuthorized(err) + return nil, gtserror.NewErrorUnauthorized(err) } blocked, err := p.db.IsBlocked(ctx, requestedAccount.ID, requestingAccount.ID, true) @@ -63,7 +63,7 @@ func (p *processor) GetUser(ctx context.Context, requestedUsername string, reque } if blocked { - return nil, gtserror.NewErrorNotAuthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) + return nil, gtserror.NewErrorUnauthorized(fmt.Errorf("block exists between accounts %s and %s", requestedAccount.ID, requestingAccount.ID)) } } diff --git a/internal/processing/media.go b/internal/processing/media.go @@ -26,7 +26,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) MediaCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { +func (p *processor) MediaCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) { return p.mediaProcessor.Create(ctx, authed.Account, form) } diff --git a/internal/processing/media/create.go b/internal/processing/media/create.go @@ -24,11 +24,12 @@ import ( "io" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" ) -func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) { +func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) { data := func(innerCtx context.Context) (io.Reader, int, error) { f, err := form.File.Open() return f, int(form.File.Size), err @@ -36,7 +37,8 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form focusX, focusY, err := parseFocus(form.Focus) if err != nil { - return nil, fmt.Errorf("could not parse focus value %s: %s", form.Focus, err) + err := fmt.Errorf("could not parse focus value %s: %s", form.Focus, err) + return nil, gtserror.NewErrorBadRequest(err, err.Error()) } // process the media attachment and load it immediately @@ -46,19 +48,18 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, form FocusY: &focusY, }) if err != nil { - return nil, err + return nil, gtserror.NewErrorUnprocessableEntity(err) } attachment, err := media.LoadAttachment(ctx) if err != nil { - return nil, err + return nil, gtserror.NewErrorUnprocessableEntity(err) } - // prepare the frontend representation now -- if there are any errors here at least we can bail without - // having already put something in the database and then having to clean it up again (eugh) apiAttachment, err := p.tc.AttachmentToAPIAttachment(ctx, attachment) if err != nil { - return nil, fmt.Errorf("error parsing media attachment to frontend type: %s", err) + err := fmt.Errorf("error parsing media attachment to frontend type: %s", err) + return nil, gtserror.NewErrorInternalError(err) } return &apiAttachment, nil diff --git a/internal/processing/media/media.go b/internal/processing/media/media.go @@ -34,7 +34,7 @@ import ( // Processor wraps a bunch of functions for processing media actions. type Processor interface { // Create creates a new media attachment belonging to the given account, using the request form. - Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) + Create(ctx context.Context, account *gtsmodel.Account, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) // Delete deletes the media attachment with the given ID, including all files pertaining to that attachment. Delete(ctx context.Context, mediaAttachmentID string) gtserror.WithCode // GetFile retrieves a file from storage and streams it back to the caller via an io.reader embedded in *apimodel.Content. diff --git a/internal/processing/processor.go b/internal/processing/processor.go @@ -72,7 +72,7 @@ type Processor interface { */ // AccountCreate processes the given form for creating a new account, returning an oauth token for that account if successful. - AccountCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, error) + AccountCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AccountCreateRequest) (*apimodel.Token, gtserror.WithCode) // AccountDeleteLocal processes the delete of a LOCAL account using the given form. AccountDeleteLocal(ctx context.Context, authed *oauth.Auth, form *apimodel.AccountDeleteRequest) gtserror.WithCode // AccountGet processes the given request for account information. @@ -80,7 +80,7 @@ type Processor interface { // AccountGet processes the given request for account information. AccountGetLocalByUsername(ctx context.Context, authed *oauth.Auth, username string) (*apimodel.Account, gtserror.WithCode) // AccountUpdate processes the update of an account with the given form - AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, error) + AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) // AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for // the account given in authed. AccountStatusesGet(ctx context.Context, authed *oauth.Auth, targetAccountID string, limit int, excludeReplies bool, excludeReblogs bool, maxID string, minID string, pinned bool, mediaOnly bool, publicOnly bool) (*apimodel.TimelineResponse, gtserror.WithCode) @@ -117,7 +117,7 @@ type Processor interface { AdminMediaPrune(ctx context.Context, mediaRemoteCacheDays int) gtserror.WithCode // AppCreate processes the creation of a new API application - AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) + AppCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, gtserror.WithCode) // BlocksGet returns a list of accounts blocked by the requesting account. BlocksGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, limit int) (*apimodel.BlocksResponse, gtserror.WithCode) @@ -143,7 +143,7 @@ type Processor interface { InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.Instance, gtserror.WithCode) // MediaCreate handles the creation of a media attachment, using the given form. - MediaCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, error) + MediaCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AttachmentRequest) (*apimodel.Attachment, gtserror.WithCode) // MediaGet handles the GET of a media attachment with the given ID MediaGet(ctx context.Context, authed *oauth.Auth, attachmentID string) (*apimodel.Attachment, gtserror.WithCode) // MediaUpdate handles the PUT of a media attachment with the given ID and form @@ -156,11 +156,11 @@ type Processor interface { SearchGet(ctx context.Context, authed *oauth.Auth, searchQuery *apimodel.SearchQuery) (*apimodel.SearchResult, gtserror.WithCode) // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. - StatusCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) + StatusCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) // StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through. - StatusDelete(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + StatusDelete(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusFave processes the faving of a given status, returning the updated status if the fave goes through. - StatusFave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + StatusFave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusBoost processes the boost/reblog of a given status, returning the newly-created boost if all is well. StatusBoost(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusUnboost processes the unboost/unreblog of a given status, returning the status if all is well. @@ -168,11 +168,11 @@ type Processor interface { // StatusBoostedBy returns a slice of accounts that have boosted the given status, filtered according to privacy settings. StatusBoostedBy(ctx context.Context, authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) // StatusFavedBy returns a slice of accounts that have liked the given status, filtered according to privacy settings. - StatusFavedBy(ctx context.Context, authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) + StatusFavedBy(ctx context.Context, authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) // StatusGet gets the given status, taking account of privacy settings and blocks etc. - StatusGet(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + StatusGet(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusUnfave processes the unfaving of a given status, returning the updated status if the fave goes through. - StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) + StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) // StatusGetContext returns the context (previous and following posts) from the given status ID StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) @@ -184,7 +184,7 @@ type Processor interface { FavedTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.TimelineResponse, gtserror.WithCode) // AuthorizeStreamingRequest returns a gotosocial account in exchange for an access token, or an error if the given token is not valid. - AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, error) + AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, gtserror.WithCode) // OpenStreamForAccount opens a new stream for the given account, with the given stream type. OpenStreamForAccount(ctx context.Context, account *gtsmodel.Account, streamType string) (*stream.Stream, gtserror.WithCode) diff --git a/internal/processing/status.go b/internal/processing/status.go @@ -26,15 +26,15 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (p *processor) StatusCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) { +func (p *processor) StatusCreate(ctx context.Context, authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) { return p.statusProcessor.Create(ctx, authed.Account, authed.Application, form) } -func (p *processor) StatusDelete(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +func (p *processor) StatusDelete(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { return p.statusProcessor.Delete(ctx, authed.Account, targetStatusID) } -func (p *processor) StatusFave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +func (p *processor) StatusFave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { return p.statusProcessor.Fave(ctx, authed.Account, targetStatusID) } @@ -50,15 +50,15 @@ func (p *processor) StatusBoostedBy(ctx context.Context, authed *oauth.Auth, tar return p.statusProcessor.BoostedBy(ctx, authed.Account, targetStatusID) } -func (p *processor) StatusFavedBy(ctx context.Context, authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, error) { +func (p *processor) StatusFavedBy(ctx context.Context, authed *oauth.Auth, targetStatusID string) ([]*apimodel.Account, gtserror.WithCode) { return p.statusProcessor.FavedBy(ctx, authed.Account, targetStatusID) } -func (p *processor) StatusGet(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +func (p *processor) StatusGet(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { return p.statusProcessor.Get(ctx, authed.Account, targetStatusID) } -func (p *processor) StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, error) { +func (p *processor) StatusUnfave(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { return p.statusProcessor.Unfave(ctx, authed.Account, targetStatusID) } diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go @@ -57,8 +57,8 @@ func (p *processor) Create(ctx context.Context, account *gtsmodel.Account, appli Text: form.Status, } - if err := p.ProcessReplyToID(ctx, form, account.ID, newStatus); err != nil { - return nil, gtserror.NewErrorInternalError(err) + if errWithCode := p.ProcessReplyToID(ctx, form, account.ID, newStatus); errWithCode != nil { + return nil, errWithCode } if err := p.ProcessMediaIDs(ctx, form, account.ID, newStatus); err != nil { diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go @@ -60,7 +60,7 @@ type Processor interface { */ ProcessVisibility(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error - ProcessReplyToID(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error + ProcessReplyToID(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode ProcessMediaIDs(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error ProcessLanguage(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error ProcessMentions(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go @@ -26,6 +26,7 @@ import ( "github.com/sirupsen/logrus" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/util" ) @@ -103,7 +104,7 @@ func (p *processor) ProcessVisibility(ctx context.Context, form *apimodel.Advanc return nil } -func (p *processor) ProcessReplyToID(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) error { +func (p *processor) ProcessReplyToID(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { if form.InReplyToID == "" { return nil } @@ -117,32 +118,37 @@ func (p *processor) ProcessReplyToID(ctx context.Context, form *apimodel.Advance // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. repliedStatus := >smodel.Status{} repliedAccount := >smodel.Account{} - // check replied status exists + is replyable + if err := p.db.GetByID(ctx, form.InReplyToID, repliedStatus); err != nil { if err == db.ErrNoEntries { - return fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) + err := fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) + return gtserror.NewErrorBadRequest(err, err.Error()) } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + err := fmt.Errorf("db error fetching status with id %s: %s", form.InReplyToID, err) + return gtserror.NewErrorInternalError(err) } if !repliedStatus.Replyable { - return fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) + err := fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) + return gtserror.NewErrorForbidden(err, err.Error()) } - // check replied account is known to us if err := p.db.GetByID(ctx, repliedStatus.AccountID, repliedAccount); err != nil { if err == db.ErrNoEntries { - return fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) + 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()) } - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) + err := fmt.Errorf("db error fetching account with id %s: %s", repliedStatus.AccountID, err) + return gtserror.NewErrorInternalError(err) } - // check if a block exists + if blocked, err := p.db.IsBlocked(ctx, thisAccountID, repliedAccount.ID, true); err != nil { - if err != db.ErrNoEntries { - return fmt.Errorf("status with id %s not replyable: %s", form.InReplyToID, err) - } + err := fmt.Errorf("db error checking block: %s", err) + return gtserror.NewErrorInternalError(err) } else if blocked { - return fmt.Errorf("status with id %s not replyable", form.InReplyToID) + err := fmt.Errorf("status with id %s not replyable", form.InReplyToID) + return gtserror.NewErrorNotFound(err) } + status.InReplyToID = repliedStatus.ID status.InReplyToAccountID = repliedAccount.ID diff --git a/internal/processing/streaming.go b/internal/processing/streaming.go @@ -26,7 +26,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/stream" ) -func (p *processor) AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, error) { +func (p *processor) AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, gtserror.WithCode) { return p.streamingProcessor.AuthorizeStreamingRequest(ctx, accessToken) } diff --git a/internal/processing/streaming/authorize.go b/internal/processing/streaming/authorize.go @@ -22,29 +22,40 @@ import ( "context" "fmt" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) -func (p *processor) AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, error) { +func (p *processor) AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, gtserror.WithCode) { ti, err := p.oauthServer.LoadAccessToken(ctx, accessToken) if err != nil { - return nil, fmt.Errorf("AuthorizeStreamingRequest: error loading access token: %s", err) + err := fmt.Errorf("could not load access token: %s", err) + return nil, gtserror.NewErrorUnauthorized(err) } uid := ti.GetUserID() if uid == "" { - return nil, fmt.Errorf("AuthorizeStreamingRequest: no userid in token") + err := fmt.Errorf("no userid in token") + return nil, gtserror.NewErrorUnauthorized(err) } - // fetch user's and account for this user id user := >smodel.User{} - if err := p.db.GetByID(ctx, uid, user); err != nil || user == nil { - return nil, fmt.Errorf("AuthorizeStreamingRequest: no user found for validated uid %s", uid) + if err := p.db.GetByID(ctx, uid, user); err != nil { + if err == db.ErrNoEntries { + err := fmt.Errorf("no user found for validated uid %s", uid) + return nil, gtserror.NewErrorUnauthorized(err) + } + return nil, gtserror.NewErrorInternalError(err) } acct, err := p.db.GetAccountByID(ctx, user.AccountID) - if err != nil || acct == nil { - return nil, fmt.Errorf("AuthorizeStreamingRequest: no account retrieved for user with id %s", uid) + if err != nil { + if err == db.ErrNoEntries { + err := fmt.Errorf("no account found for validated uid %s", uid) + return nil, gtserror.NewErrorUnauthorized(err) + } + return nil, gtserror.NewErrorInternalError(err) } return acct, nil diff --git a/internal/processing/streaming/authorize_test.go b/internal/processing/streaming/authorize_test.go @@ -39,7 +39,7 @@ func (suite *AuthorizeTestSuite) TestAuthorize() { suite.Equal(suite.testAccounts["local_account_2"].ID, account2.ID) noAccount, err := suite.streamingProcessor.AuthorizeStreamingRequest(context.Background(), "aaaaaaaaaaaaaaaaaaaaa!!") - suite.EqualError(err, "AuthorizeStreamingRequest: error loading access token: no entries") + suite.EqualError(err, "could not load access token: no entries") suite.Nil(noAccount) } diff --git a/internal/processing/streaming/streaming.go b/internal/processing/streaming/streaming.go @@ -33,7 +33,7 @@ import ( // Processor wraps a bunch of functions for processing streaming. type Processor interface { // AuthorizeStreamingRequest returns an oauth2 token info in response to an access token query from the streaming API - AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, error) + AuthorizeStreamingRequest(ctx context.Context, accessToken string) (*gtsmodel.Account, gtserror.WithCode) // OpenStreamForAccount returns a new Stream for the given account, which will contain a channel for passing messages back to the caller. OpenStreamForAccount(ctx context.Context, account *gtsmodel.Account, timeline string) (*stream.Stream, gtserror.WithCode) // StreamUpdateToAccount streams the given update to any open, appropriate streams belonging to the given account. diff --git a/internal/processing/user/changepassword_test.go b/internal/processing/user/changepassword_test.go @@ -57,7 +57,7 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() { errWithCode := suite.user.ChangePassword(context.Background(), user, "ooooopsydoooopsy", "verygoodnewpassword") suite.EqualError(errWithCode, "crypto/bcrypt: hashedPassword is not the hash of the given password") suite.Equal(http.StatusBadRequest, errWithCode.Code()) - suite.Equal("bad request: old password did not match", errWithCode.Safe()) + suite.Equal("Bad Request: old password did not match", errWithCode.Safe()) } func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() { @@ -66,7 +66,7 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() { errWithCode := suite.user.ChangePassword(context.Background(), user, "password", "1234") suite.EqualError(errWithCode, "password is 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password") suite.Equal(http.StatusBadRequest, errWithCode.Code()) - suite.Equal("bad request: password is 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password", errWithCode.Safe()) + suite.Equal("Bad Request: password is 11% strength, try including more special characters, using lowercase letters, using uppercase letters or using a longer password", errWithCode.Safe()) } func TestChangePasswordTestSuite(t *testing.T) { diff --git a/internal/transport/deliver.go b/internal/transport/deliver.go @@ -27,6 +27,7 @@ import ( "strings" "sync" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" ) @@ -79,7 +80,7 @@ func (t *transport) Deliver(ctx context.Context, b []byte, to *url.URL) error { return err } - req.Header.Add("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"") + req.Header.Add("Content-Type", string(api.AppActivityLDJSON)) req.Header.Add("Accept-Charset", "utf-8") req.Header.Add("User-Agent", t.controller.userAgent) req.Header.Set("Host", to.Host) diff --git a/internal/transport/dereference.go b/internal/transport/dereference.go @@ -25,6 +25,7 @@ import ( "net/http" "net/url" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/uris" ) @@ -52,7 +53,8 @@ func (t *transport) Dereference(ctx context.Context, iri *url.URL) ([]byte, erro if err != nil { return nil, err } - req.Header.Add("Accept", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\",application/activity+json") + + req.Header.Add("Accept", string(api.AppActivityLDJSON)+","+string(api.AppActivityJSON)) req.Header.Add("Accept-Charset", "utf-8") req.Header.Add("User-Agent", t.controller.userAgent) req.Header.Set("Host", iri.Host) diff --git a/internal/transport/derefinstance.go b/internal/transport/derefinstance.go @@ -30,6 +30,7 @@ import ( "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/id" @@ -94,7 +95,7 @@ func dereferenceByAPIV1Instance(ctx context.Context, t *transport, iri *url.URL) return nil, err } - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", string(api.AppJSON)) req.Header.Add("User-Agent", t.controller.userAgent) req.Header.Set("Host", cleanIRI.Host) @@ -245,7 +246,7 @@ func callNodeInfoWellKnown(ctx context.Context, t *transport, iri *url.URL) (*ur if err != nil { return nil, err } - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", string(api.AppJSON)) req.Header.Add("User-Agent", t.controller.userAgent) req.Header.Set("Host", cleanIRI.Host) @@ -297,7 +298,7 @@ func callNodeInfo(ctx context.Context, t *transport, iri *url.URL) (*apimodel.No if err != nil { return nil, err } - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", string(api.AppJSON)) req.Header.Add("User-Agent", t.controller.userAgent) req.Header.Set("Host", iri.Host) diff --git a/internal/transport/finger.go b/internal/transport/finger.go @@ -23,6 +23,8 @@ import ( "fmt" "io/ioutil" "net/http" + + "github.com/superseriousbusiness/gotosocial/internal/api" ) func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) { @@ -37,7 +39,7 @@ func (t *transport) Finger(ctx context.Context, targetUsername string, targetDom if err != nil { return nil, err } - req.Header.Add("Accept", "application/json") + req.Header.Add("Accept", string(api.AppJSON)) req.Header.Add("Accept", "application/jrd+json") req.Header.Add("User-Agent", t.controller.userAgent) req.Header.Set("Host", req.URL.Host) diff --git a/internal/web/base.go b/internal/web/base.go @@ -19,6 +19,7 @@ package web import ( + "errors" "fmt" "io/ioutil" "net/http" @@ -29,6 +30,7 @@ import ( "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/processing" "github.com/superseriousbusiness/gotosocial/internal/router" "github.com/superseriousbusiness/gotosocial/internal/uris" @@ -118,24 +120,6 @@ func (m *Module) baseHandler(c *gin.Context) { }) } -// NotFoundHandler serves a 404 html page instead of a blank 404 error. -func (m *Module) NotFoundHandler(c *gin.Context) { - l := logrus.WithField("func", "404") - l.Trace("serving 404 html") - - host := config.GetHost() - instance, err := m.processor.InstanceGet(c.Request.Context(), host) - if err != nil { - l.Debugf("error getting instance from processor: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) - return - } - - c.HTML(404, "404.tmpl", gin.H{ - "instance": instance, - }) -} - // Route satisfies the RESTAPIModule interface func (m *Module) Route(s router.Router) error { // serve static files from assets dir at /assets @@ -152,16 +136,18 @@ func (m *Module) Route(s router.Router) error { s.AttachHandler(http.MethodGet, "/", m.baseHandler) // serve profile pages at /@username - s.AttachHandler(http.MethodGet, profilePath, m.profileTemplateHandler) + s.AttachHandler(http.MethodGet, profilePath, m.profileGETHandler) // serve statuses - s.AttachHandler(http.MethodGet, statusPath, m.threadTemplateHandler) + s.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler) // serve email confirmation page at /confirm_email?token=whatever s.AttachHandler(http.MethodGet, confirmEmailPath, m.confirmEmailGETHandler) // 404 handler - s.AttachNoRouteHandler(m.NotFoundHandler) + s.AttachNoRouteHandler(func(c *gin.Context) { + api.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) + }) return nil } diff --git a/internal/web/confirmemail.go b/internal/web/confirmemail.go @@ -19,35 +19,35 @@ package web import ( + "errors" "net/http" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" ) func (m *Module) confirmEmailGETHandler(c *gin.Context) { + ctx := c.Request.Context() + // if there's no token in the query, just serve the 404 web handler token := c.Query(tokenParam) if token == "" { - m.NotFoundHandler(c) + api.ErrorHandler(c, gtserror.NewErrorNotFound(errors.New(http.StatusText(http.StatusNotFound))), m.processor.InstanceGet) return } - ctx := c.Request.Context() - user, errWithCode := m.processor.UserConfirmEmail(ctx, token) if errWithCode != nil { - logrus.Debugf("error confirming email: %s", errWithCode.Error()) - // if something goes wrong, just log it and direct to the 404 handler to not give anything away - m.NotFoundHandler(c) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } host := config.GetHost() instance, err := m.processor.InstanceGet(ctx, host) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/web/profile.go b/internal/web/profile.go @@ -21,59 +21,60 @@ package web import ( "context" "encoding/json" + "errors" "fmt" "math/rand" "net/http" + "strings" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/api" apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (m *Module) profileTemplateHandler(c *gin.Context) { - l := logrus.WithField("func", "profileTemplateHandler") - l.Trace("rendering profile template") +func (m *Module) profileGETHandler(c *gin.Context) { ctx := c.Request.Context() - username := c.Param(usernameKey) + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + + // usernames on our instance will always be lowercase + username := strings.ToLower(c.Param(usernameKey)) if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account username specified"}) + err := errors.New("no account username specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - authed, err := oauth.Authed(c, false, false, false, false) + host := config.GetHost() + instance, err := m.processor.InstanceGet(ctx, host) if err != nil { - l.Errorf("error authing profile GET request: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } - instance, errWithCode := m.processor.InstanceGet(ctx, config.GetHost()) - if errWithCode != nil { - l.Debugf("error getting instance from processor: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) - return + instanceGet := func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode) { + return instance, nil } account, errWithCode := m.processor.AccountGetLocalByUsername(ctx, authed, username) if errWithCode != nil { - l.Debugf("error getting account from processor: %s", errWithCode.Error()) - if errWithCode.Code() == http.StatusNotFound { - m.NotFoundHandler(c) - return - } - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, instanceGet) return } - // if we're getting an AP request on this endpoint we should render the account's AP representation instead + // if we're getting an AP request on this endpoint we + // should render the account's AP representation instead accept := c.NegotiateFormat(string(api.TextHTML), string(api.AppActivityJSON), string(api.AppActivityLDJSON)) if accept == string(api.AppActivityJSON) || accept == string(api.AppActivityLDJSON) { - m.returnAPRepresentation(ctx, c, username, accept) + m.returnAPProfile(ctx, c, username, accept) return } @@ -82,8 +83,7 @@ func (m *Module) profileTemplateHandler(c *gin.Context) { // with or without media statusResp, errWithCode := m.processor.AccountStatusesGet(ctx, authed, account.ID, 10, true, true, "", "", false, false, true) if errWithCode != nil { - l.Debugf("error getting statuses from processor: %s", errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, instanceGet) return } @@ -114,7 +114,7 @@ func (m *Module) profileTemplateHandler(c *gin.Context) { }) } -func (m *Module) returnAPRepresentation(ctx context.Context, c *gin.Context, username string, accept string) { +func (m *Module) returnAPProfile(ctx context.Context, c *gin.Context, username string, accept string) { verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)) if signed { ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) @@ -125,17 +125,16 @@ func (m *Module) returnAPRepresentation(ctx context.Context, c *gin.Context, use ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) } - user, errWithCode := m.processor.GetFediUser(ctx, username, c.Request.URL) // GetFediUser handles auth as well + user, errWithCode := m.processor.GetFediUser(ctx, username, c.Request.URL) if errWithCode != nil { - logrus.Infof(errWithCode.Error()) - c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) return } b, mErr := json.Marshal(user) if mErr != nil { err := fmt.Errorf("could not marshal json: %s", mErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } diff --git a/internal/web/thread.go b/internal/web/thread.go @@ -19,65 +19,88 @@ package web import ( + "context" + "encoding/json" + "errors" + "fmt" "net/http" "strings" - "github.com/sirupsen/logrus" - "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/ap" + "github.com/superseriousbusiness/gotosocial/internal/api" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/oauth" ) -func (m *Module) threadTemplateHandler(c *gin.Context) { - l := logrus.WithField("func", "threadTemplateGET") - l.Trace("rendering thread template") - +func (m *Module) threadGETHandler(c *gin.Context) { ctx := c.Request.Context() + authed, err := oauth.Authed(c, false, false, false, false) + if err != nil { + api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet) + return + } + // usernames on our instance will always be lowercase username := strings.ToLower(c.Param(usernameKey)) if username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no account username specified"}) + err := errors.New("no account username specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } // status ids will always be uppercase statusID := strings.ToUpper(c.Param(statusIDKey)) if statusID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "no status id specified"}) + err := errors.New("no status id specified") + api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet) return } - authed, err := oauth.Authed(c, false, false, false, false) + host := config.GetHost() + instance, err := m.processor.InstanceGet(ctx, host) if err != nil { - l.Errorf("error authing status GET request: %s", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) return } - host := config.GetHost() - instance, err := m.processor.InstanceGet(ctx, host) - if err != nil { - l.Debugf("error getting instance from processor: %s", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + instanceGet := func(ctx context.Context, domain string) (*apimodel.Instance, gtserror.WithCode) { + return instance, nil + } + + // do this check to make sure the status is actually from a local account, + // we shouldn't render threads from statuses that don't belong to us! + if _, errWithCode := m.processor.AccountGetLocalByUsername(ctx, authed, username); errWithCode != nil { + api.ErrorHandler(c, errWithCode, instanceGet) return } - status, err := m.processor.StatusGet(ctx, authed, statusID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) + status, errWithCode := m.processor.StatusGet(ctx, authed, statusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, instanceGet) return } if !strings.EqualFold(username, status.Account.Username) { - c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) + err := gtserror.NewErrorNotFound(errors.New("path username not equal to status author username")) + api.ErrorHandler(c, gtserror.NewErrorNotFound(err), instanceGet) return } - context, err := m.processor.StatusGetContext(ctx, authed, statusID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "status not found"}) + // if we're getting an AP request on this endpoint we + // should render the status's AP representation instead + accept := c.NegotiateFormat(string(api.TextHTML), string(api.AppActivityJSON), string(api.AppActivityLDJSON)) + if accept == string(api.AppActivityJSON) || accept == string(api.AppActivityLDJSON) { + m.returnAPStatus(ctx, c, username, statusID, accept) + return + } + + context, errWithCode := m.processor.StatusGetContext(ctx, authed, statusID) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, instanceGet) return } @@ -88,3 +111,30 @@ func (m *Module) threadTemplateHandler(c *gin.Context) { "stylesheets": []string{"/assets/Fork-Awesome/css/fork-awesome.min.css", "/assets/status.css"}, }) } + +func (m *Module) returnAPStatus(ctx context.Context, c *gin.Context, username string, statusID string, accept string) { + verifier, signed := c.Get(string(ap.ContextRequestingPublicKeyVerifier)) + if signed { + ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeyVerifier, verifier) + } + + signature, signed := c.Get(string(ap.ContextRequestingPublicKeySignature)) + if signed { + ctx = context.WithValue(ctx, ap.ContextRequestingPublicKeySignature, signature) + } + + status, errWithCode := m.processor.GetFediStatus(ctx, username, statusID, c.Request.URL) + if errWithCode != nil { + api.ErrorHandler(c, errWithCode, m.processor.InstanceGet) + return + } + + b, mErr := json.Marshal(status) + if mErr != nil { + err := fmt.Errorf("could not marshal json: %s", mErr) + api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet) + return + } + + c.Data(http.StatusOK, accept, b) +} diff --git a/testrig/router.go b/testrig/router.go @@ -20,10 +20,8 @@ package testrig import ( "context" - "fmt" "os" "path/filepath" - "runtime" "github.com/gin-gonic/gin" "github.com/superseriousbusiness/gotosocial/internal/config" @@ -54,25 +52,7 @@ func NewTestRouter(db db.DB) router.Router { } // ConfigureTemplatesWithGin will panic on any errors related to template loading during tests -func ConfigureTemplatesWithGin(engine *gin.Engine) { +func ConfigureTemplatesWithGin(engine *gin.Engine, templatePath string) { router.LoadTemplateFunctions(engine) - - templateBaseDir := config.GetWebTemplateBaseDir() - - if !filepath.IsAbs(templateBaseDir) { - // https://stackoverflow.com/questions/31873396/is-it-possible-to-get-the-current-root-of-package-structure-as-a-string-in-golan - _, runtimeCallerLocation, _, _ := runtime.Caller(0) - projectRoot, err := filepath.Abs(filepath.Join(filepath.Dir(runtimeCallerLocation), "../")) - if err != nil { - panic(err) - } - - templateBaseDir = filepath.Join(projectRoot, templateBaseDir) - } - - if _, err := os.Stat(filepath.Join(templateBaseDir, "index.tmpl")); err != nil { - panic(fmt.Errorf("%s doesn't seem to contain the templates; index.tmpl is missing: %w", templateBaseDir, err)) - } - - engine.LoadHTMLGlob(filepath.Join(templateBaseDir, "*")) + engine.LoadHTMLGlob(filepath.Join(templatePath, "*")) } diff --git a/web/template/404.tmpl b/web/template/404.tmpl @@ -2,7 +2,17 @@ <main> <section> <h1>404: Page Not Found</h1> - If you believe this was an error, you can <a href="{{.instance.ContactAccount.URL}}">contact an admin</a> + <p> + GoToSocial only serves Public statuses via the web. + If you reached this page by clicking on a status link, + it's possible that the status is not Public, has been + deleted by the author, you don't have permission to see + it, or it just doesn't exist at all. + </p> + <p> + If you believe this 404 was an error, you can contact + the instance admin. + </p> </section> </main> diff --git a/web/template/error.tmpl b/web/template/error.tmpl @@ -3,6 +3,5 @@ <section class="error"> <span>❌</span> <pre>{{.error}}</pre> </section> - </main> {{ template "footer.tmpl" .}} \ No newline at end of file diff --git a/web/template/footer.tmpl b/web/template/footer.tmpl @@ -5,10 +5,14 @@ <a href="https://github.com/superseriousbusiness/gotosocial">Source Code</a> </div> <div id="contact"> + {{ if .instance.ContactAccount }} Contact: <a href="{{.instance.ContactAccount.URL}}" class="nounderline">{{.instance.ContactAccount.Username}}</a><br> + {{ end }} </div> <div id="email"> + {{ if .instance.Email }} Email: <a href="mailto:{{.instance.Email}}" class="nounderline">{{.instance.Email}}</a><br> + {{ end }} </div> </footer> </body>