From f9cb086c53f06b6fd33f3c378ae1288751944eff Mon Sep 17 00:00:00 2001 From: tobi Date: Mon, 15 Sep 2025 13:26:50 +0200 Subject: [PATCH] [bugfix] Parse `scheduled_at` as ISO8601 with offset if RFC3339 parse fails (#4431) # Description > If this is a code change, please include a summary of what you've coded, and link to the issue(s) it closes/implements. > > If this is a documentation change, please briefly describe what you've changed and why. Closes https://codeberg.org/superseriousbusiness/gotosocial/issues/4409 by reattempting `scheduled_at` parsing using ISO8601 offset. ## Checklist Please put an x inside each checkbox to indicate that you've read and followed it: `[ ]` -> `[x]` If this is a documentation change, only the first checkbox must be filled (you can delete the others if you want). - [x] I/we have read the [GoToSocial contribution guidelines](https://codeberg.org/superseriousbusiness/gotosocial/src/branch/main/CONTRIBUTING.md). - [x] I/we have discussed the proposed changes already, either in an issue on the repository, or in the Matrix chat. - [x] I/we have not leveraged AI to create the proposed changes. - [x] I/we have performed a self-review of added code. - [x] I/we have written code that is legible and maintainable by others. - [x] I/we have commented the added code, particularly in hard-to-understand areas. - [x] I/we have made any necessary changes to documentation. - [x] I/we have added tests that cover new code. - [x] I/we have run tests and they pass locally with the changes. - [x] I/we have run `go fmt ./...` and `golangci-lint run`. Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4431 Co-authored-by: tobi Co-committed-by: tobi --- docs/api/swagger.yaml | 2 - internal/api/client/statuses/statuscreate.go | 28 +++++++++- .../api/client/statuses/statuscreate_test.go | 52 ++++++++++++++++++- internal/api/model/status.go | 12 +++-- internal/util/time.go | 7 +-- 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index b84a3e21b..ae8888af4 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -11291,8 +11291,6 @@ paths: description: unprocessable content "500": description: internal server error - "501": - description: scheduled_at was set, but this feature is not yet implemented security: - OAuth2 Bearer: - write:statuses diff --git a/internal/api/client/statuses/statuscreate.go b/internal/api/client/statuses/statuscreate.go index 167b59b23..de06d2d8b 100644 --- a/internal/api/client/statuses/statuscreate.go +++ b/internal/api/client/statuses/statuscreate.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "net/http" + "time" apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model" apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" @@ -259,8 +260,6 @@ import ( // description: unprocessable content // '500': // description: internal server error -// '501': -// description: scheduled_at was set, but this feature is not yet implemented func (m *Module) StatusCreatePOSTHandler(c *gin.Context) { authed, errWithCode := apiutil.TokenAuth(c, true, true, true, true, @@ -414,5 +413,30 @@ func parseStatusCreateForm(c *gin.Context) (*apimodel.StatusCreateRequest, gtser form.Poll.ExpiresIn = util.PtrOrZero(expiresIn) } + // Parse scheduled_at if given. + if form.ScheduledAtRaw != "" { + // Try RFC3339 initially, which + // is a stricter UTC subset of ISO8601. + scheduledAt, err := time.Parse( + time.RFC3339, form.ScheduledAtRaw, + ) + if err != nil { + // Try ISO8601 with offset + // (many clients use this). + scheduledAt, err = time.Parse( + util.ISO8601Offset, form.ScheduledAtRaw, + ) + } + + // If we still have an error + // we can't use this time. + if err != nil { + text := "could not parse scheduled_at value " + form.ScheduledAtRaw + " as ISO8601 time" + return nil, gtserror.NewErrorBadRequest(errors.New(text), text) + } + + form.ScheduledAt = &scheduledAt + } + return form, nil } diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go index 84a6622a2..ca82502c2 100644 --- a/internal/api/client/statuses/statuscreate_test.go +++ b/internal/api/client/statuses/statuscreate_test.go @@ -467,7 +467,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMessedUpIntPolicy() { }`, out) } -func (suite *StatusCreateTestSuite) TestPostNewScheduledStatus() { +func (suite *StatusCreateTestSuite) TestPostNewScheduledStatusRFC3339() { out, recorder := suite.postStatus(map[string][]string{ "status": {"this is a brand new status! #helloworld"}, "spoiler_text": {"hello hello"}, @@ -497,6 +497,56 @@ func (suite *StatusCreateTestSuite) TestPostNewScheduledStatus() { }`, out) } +func (suite *StatusCreateTestSuite) TestPostNewScheduledStatusISO8601Offset() { + out, recorder := suite.postStatus(map[string][]string{ + "status": {"this is a brand new status! #helloworld"}, + "spoiler_text": {"hello hello"}, + "sensitive": {"true"}, + "visibility": {string(apimodel.VisibilityMutualsOnly)}, + "scheduled_at": {"2080-09-05T19:38:00+0400"}, + }, "") + + // We should have OK from + // our call to the function. + suite.Equal(http.StatusOK, recorder.Code) + + // A scheduled status with scheduled_at and status params should be returned. + suite.Equal(`{ + "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "media_attachments": [], + "params": { + "application_id": "01F8MGY43H3N2C8EWPR2FPYEXG", + "language": "", + "scheduled_at": null, + "sensitive": true, + "spoiler_text": "hello hello", + "text": "this is a brand new status! #helloworld", + "visibility": "private" + }, + "scheduled_at": "2080-09-05T15:38:00.000Z" +}`, out) +} + +func (suite *StatusCreateTestSuite) TestPostNewScheduledStatusNonsenseTime() { + out, recorder := suite.postStatus(map[string][]string{ + "status": {"this is a brand new status! #helloworld"}, + "spoiler_text": {"hello hello"}, + "sensitive": {"true"}, + "visibility": {string(apimodel.VisibilityMutualsOnly)}, + "scheduled_at": {"pee pee poo poo"}, + }, "") + + // We should have 400 from + // our call to the function. + suite.Equal(http.StatusBadRequest, recorder.Code) + + // We should have a helpful error + // message telling us how we screwed up. + suite.Equal(`{ + "error": "Bad Request: could not parse scheduled_at value pee pee poo poo as ISO8601 time" +}`, out) +} + func (suite *StatusCreateTestSuite) TestPostNewBackfilledStatus() { // A time in the past. scheduledAtStr := "2020-10-04T15:32:02.018Z" diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 3edb4be5f..3ce5913bd 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -248,14 +248,18 @@ type StatusCreateRequest struct { // Deprecated: Only used if LocalOnly is not set. Federated *bool `form:"federated" json:"federated"` - // ISO 8601 Datetime at which to schedule a status. + // ISO8601 datetime string at which to schedule a status. // - // Providing this parameter with a *future* time will cause ScheduledStatus to be returned instead of Status. - // Must be at least 5 minutes in the future. + // Providing this parameter with a *future* time will cause ScheduledStatus + // to be returned instead of Status (must be at least 5 minutes in the future). // // Providing this parameter with a *past* time will cause the status to be backdated, // and will not push it to the user's followers. This is intended for importing old statuses. - ScheduledAt *time.Time `form:"scheduled_at" json:"scheduled_at"` + ScheduledAtRaw string `form:"scheduled_at" json:"scheduled_at"` + + // ScheduledAtParsed is the parsed version + // of scheduled_at, if scheduled_at was set. + ScheduledAt *time.Time `form:"-" json:"-"` // ISO 639 language code for this status. Language string `form:"language" json:"language"` diff --git a/internal/util/time.go b/internal/util/time.go index 9ea8d0c93..7f75920e8 100644 --- a/internal/util/time.go +++ b/internal/util/time.go @@ -20,9 +20,10 @@ package util import "time" const ( - ISO8601 = "2006-01-02T15:04:05.000Z" - ISO8601Date = "2006-01-02" - RFC2822 = "Mon, 02 Jan 2006 15:04:05 -0700" + ISO8601 = "2006-01-02T15:04:05.000Z" + ISO8601Offset = "2006-01-02T15:04:05-0700" + ISO8601Date = "2006-01-02" + RFC2822 = "Mon, 02 Jan 2006 15:04:05 -0700" ) // FormatISO8601 converts the given time to UTC and then formats it