diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index d636b7586..18c22a980 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2214,6 +2214,52 @@ definitions: type: object x-go-name: Context x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + statusEdit: + description: |- + StatusEdit represents one historical revision of a status, containing + partial information about the state of the status at that revision. + properties: + account: + $ref: '#/definitions/account' + content: + description: |- + The content of this status at this revision. + Should be HTML, but might also be plaintext in some cases. + example:

Hey this is a status!

+ type: string + x-go-name: Content + created_at: + description: The date when this revision was created (ISO 8601 Datetime). + example: "2021-07-30T09:20:25+00:00" + type: string + x-go-name: CreatedAt + emojis: + description: Custom emoji to be used when rendering status content. + items: + $ref: '#/definitions/emoji' + type: array + x-go-name: Emojis + media_attachments: + description: Media that is attached to this status. + items: + $ref: '#/definitions/attachment' + type: array + x-go-name: MediaAttachments + poll: + $ref: '#/definitions/poll' + sensitive: + description: Status marked sensitive at this revision. + example: false + type: boolean + x-go-name: Sensitive + spoiler_text: + description: Subject, summary, or content warning for the status at this revision. + example: warning nsfw + type: string + x-go-name: SpoilerText + type: object + x-go-name: StatusEdit + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model statusReblogged: properties: account: @@ -7464,6 +7510,43 @@ paths: summary: View accounts that have faved/starred/liked the target status. tags: - statuses + /api/v1/statuses/{id}/history: + get: + description: 'UNIMPLEMENTED: Currently this endpoint will always return an array of length 1, containing only the latest/current version of the status.' + operationId: statusHistoryGet + parameters: + - description: Target status ID. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: "" + schema: + items: + $ref: '#/definitions/statusEdit' + 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: + - read:statuses + summary: View edit history of status with the given ID. + tags: + - statuses /api/v1/statuses/{id}/mute: post: description: |- diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go index c93d69994..266481b91 100644 --- a/internal/api/client/statuses/status.go +++ b/internal/api/client/statuses/status.go @@ -64,6 +64,9 @@ // ContextPath is used for fetching context of posts ContextPath = BasePathWithID + "/context" + + // HistoryPath is used for fetching history of posts. + HistoryPath = BasePathWithID + "/history" ) type Module struct { @@ -104,4 +107,7 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H // context / status thread attachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler) + + // history/edit stuff + attachHandler(http.MethodGet, HistoryPath, m.StatusHistoryGETHandler) } diff --git a/internal/api/client/statuses/statushistory.go b/internal/api/client/statuses/statushistory.go new file mode 100644 index 000000000..ba1af58cf --- /dev/null +++ b/internal/api/client/statuses/statushistory.go @@ -0,0 +1,97 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 . + +package statuses + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// StatusHistoryGETHandler swagger:operation GET /api/v1/statuses/{id}/history statusHistoryGet +// +// View edit history of status with the given ID. +// +// UNIMPLEMENTED: Currently this endpoint will always return an array of length 1, containing only the latest/current version of the status. +// +// --- +// tags: +// - statuses +// +// produces: +// - application/json +// +// parameters: +// - +// name: id +// type: string +// description: Target status ID. +// in: path +// required: true +// +// security: +// - OAuth2 Bearer: +// - read:statuses +// +// responses: +// '200': +// schema: +// type: array +// items: +// "$ref": "#/definitions/statusEdit" +// '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) StatusHistoryGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + targetStatusID, errWithCode := apiutil.ParseID(c.Param(IDKey)) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Status().HistoryGet(c.Request.Context(), authed.Account, targetStatusID) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusOK, resp) +} diff --git a/internal/api/client/statuses/statushistory_test.go b/internal/api/client/statuses/statushistory_test.go new file mode 100644 index 000000000..e524e9239 --- /dev/null +++ b/internal/api/client/statuses/statushistory_test.go @@ -0,0 +1,133 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 . + +package statuses_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/client/statuses" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type StatusHistoryTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusHistoryTestSuite) TestGetHistory() { + var ( + testApplication = suite.testApplications["application_1"] + testAccount = suite.testAccounts["local_account_1"] + testUser = suite.testUsers["local_account_1"] + testToken = oauth.DBTokenToToken(suite.testTokens["local_account_1"]) + targetStatusID = suite.testStatuses["local_account_1_status_1"].ID + target = fmt.Sprintf("http://localhost:8080%s", strings.ReplaceAll(statuses.HistoryPath, ":id", targetStatusID)) + ) + + // Setup request. + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, target, nil) + request.Header.Set("accept", "application/json") + ctx, _ := testrig.CreateGinTestContext(recorder, request) + + // Set auth + path params. + ctx.Set(oauth.SessionAuthorizedApplication, testApplication) + ctx.Set(oauth.SessionAuthorizedToken, testToken) + ctx.Set(oauth.SessionAuthorizedUser, testUser) + ctx.Set(oauth.SessionAuthorizedAccount, testAccount) + ctx.Params = gin.Params{ + gin.Param{ + Key: statuses.IDKey, + Value: targetStatusID, + }, + } + + // Call the handler. + suite.statusModule.StatusHistoryGETHandler(ctx) + + // Check code. + if code := recorder.Code; code != http.StatusOK { + suite.FailNow("", "unexpected http code: %d", code) + } + + // Read body. + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + if err != nil { + suite.FailNow(err.Error()) + } + + // Indent nicely. + dst := new(bytes.Buffer) + if err := json.Indent(dst, b, "", " "); err != nil { + suite.FailNow(err.Error()) + } + + suite.Equal(`[ + { + "content": "hello everyone!", + "spoiler_text": "introduction post", + "sensitive": true, + "created_at": "2021-10-20T10:40:37.000Z", + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "followers_count": 2, + "following_count": 2, + "statuses_count": 7, + "last_status_at": "2023-12-10T09:24:00.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "user" + } + }, + "poll": null, + "media_attachments": [], + "emojis": [] + } +]`, dst.String()) +} + +func TestStatusHistoryTestSuite(t *testing.T) { + suite.Run(t, new(StatusHistoryTestSuite)) +} diff --git a/internal/api/model/status.go b/internal/api/model/status.go index fed2cdf37..e8677ff6b 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -249,3 +249,33 @@ type AdvancedVisibilityFlagsForm struct { StatusContentTypeMarkdown StatusContentType = "text/markdown" StatusContentTypeDefault = StatusContentTypePlain ) + +// StatusEdit represents one historical revision of a status, containing +// partial information about the state of the status at that revision. +// +// swagger:model statusEdit +type StatusEdit struct { + // The content of this status at this revision. + // Should be HTML, but might also be plaintext in some cases. + // example:

Hey this is a status!

+ Content string `json:"content"` + // Subject, summary, or content warning for the status at this revision. + // example: warning nsfw + SpoilerText string `json:"spoiler_text"` + // Status marked sensitive at this revision. + // example: false + Sensitive bool `json:"sensitive"` + // The date when this revision was created (ISO 8601 Datetime). + // example: 2021-07-30T09:20:25+00:00 + CreatedAt string `json:"created_at"` + // The account that authored this status. + Account *Account `json:"account"` + // The poll attached to the status at this revision. + // Note that edits changing the poll options will be collapsed together into one edit, since this action resets the poll. + // nullable: true + Poll *Poll `json:"poll"` + // Media that is attached to this status. + MediaAttachments []*Attachment `json:"media_attachments"` + // Custom emoji to be used when rendering status content. + Emojis []Emoji `json:"emojis"` +} diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 7256d2f82..8b0c21adf 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -25,8 +25,40 @@ 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/util" ) +// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc. +// TODO: currently this just returns the latest version of the status. +func (p *Processor) HistoryGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) { + targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, + requestingAccount, + targetStatusID, + nil, // default freshness + ) + if errWithCode != nil { + return nil, errWithCode + } + + apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) + if errWithCode != nil { + return nil, errWithCode + } + + return []*apimodel.StatusEdit{ + { + Content: apiStatus.Content, + SpoilerText: apiStatus.SpoilerText, + Sensitive: apiStatus.Sensitive, + CreatedAt: util.FormatISO8601(targetStatus.UpdatedAt), + Account: apiStatus.Account, + Poll: apiStatus.Poll, + MediaAttachments: apiStatus.MediaAttachments, + Emojis: apiStatus.Emojis, + }, + }, nil +} + // Get gets the given status, taking account of privacy settings and blocks etc. func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,