diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 18c22a980..7fb3efe76 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -2399,6 +2399,27 @@ definitions: type: object x-go-name: StatusReblogged x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + statusSource: + description: |- + StatusSource represents the source text of a + status as submitted to the API when it was created. + properties: + id: + description: ID of the status. + example: 01FBVD42CQ3ZEEVMW180SBX03B + type: string + x-go-name: ID + source: + description: Plain-text source of a status. + type: string + x-go-name: Text + spoiler_text: + description: Plain-text version of spoiler text. + type: string + x-go-name: SpoilerText + type: object + x-go-name: StatusSource + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model swaggerCollection: properties: '@context': @@ -7693,6 +7714,42 @@ paths: summary: View accounts that have reblogged/boosted the target status. tags: - statuses + /api/v1/statuses/{id}/source: + get: + operationId: statusSourceGet + parameters: + - description: Target status ID. + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: "" + schema: + items: + $ref: '#/definitions/statusSource' + 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 source text of status with the given ID. Requester must own the status. + tags: + - statuses /api/v1/statuses/{id}/unbookmark: post: operationId: statusUnbookmark diff --git a/internal/api/client/statuses/status.go b/internal/api/client/statuses/status.go index 266481b91..33af9c456 100644 --- a/internal/api/client/statuses/status.go +++ b/internal/api/client/statuses/status.go @@ -67,6 +67,9 @@ const ( // HistoryPath is used for fetching history of posts. HistoryPath = BasePathWithID + "/history" + + // SourcePath is used for fetching source of a post. + SourcePath = BasePathWithID + "/source" ) type Module struct { @@ -110,4 +113,5 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H // history/edit stuff attachHandler(http.MethodGet, HistoryPath, m.StatusHistoryGETHandler) + attachHandler(http.MethodGet, SourcePath, m.StatusSourceGETHandler) } diff --git a/internal/api/client/statuses/statussource.go b/internal/api/client/statuses/statussource.go new file mode 100644 index 000000000..c74d99bfc --- /dev/null +++ b/internal/api/client/statuses/statussource.go @@ -0,0 +1,95 @@ +// 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" +) + +// StatusSourceGETHandler swagger:operation GET /api/v1/statuses/{id}/source statusSourceGet +// +// View source text of status with the given ID. Requester must own 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/statusSource" +// '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) StatusSourceGETHandler(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().SourceGet(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/statussource_test.go b/internal/api/client/statuses/statussource_test.go new file mode 100644 index 000000000..edb2dad3c --- /dev/null +++ b/internal/api/client/statuses/statussource_test.go @@ -0,0 +1,101 @@ +// 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 StatusSourceTestSuite struct { + StatusStandardTestSuite +} + +func (suite *StatusSourceTestSuite) TestGetSource() { + 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.SourcePath, ":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.StatusSourceGETHandler(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(`{ + "id": "01F8MHAMCHF6Y650WCRSCP4WMY", + "source": "hello everyone!", + "spoiler_text": "introduction post" +}`, dst.String()) +} + +func TestStatusSourceTestSuite(t *testing.T) { + suite.Run(t, new(StatusSourceTestSuite)) +} diff --git a/internal/api/model/status.go b/internal/api/model/status.go index e8677ff6b..d7d3266ed 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -250,6 +250,20 @@ const ( StatusContentTypeDefault = StatusContentTypePlain ) +// StatusSource represents the source text of a +// status as submitted to the API when it was created. +// +// swagger:model statusSource +type StatusSource struct { + // ID of the status. + // example: 01FBVD42CQ3ZEEVMW180SBX03B + ID string `json:"id"` + // Plain-text source of a status. + Text string `json:"source"` + // Plain-text version of spoiler text. + SpoilerText string `json:"spoiler_text"` +} + // StatusEdit represents one historical revision of a status, containing // partial information about the state of the status at that revision. // diff --git a/internal/processing/status/get.go b/internal/processing/status/get.go index 8b0c21adf..57fd4005c 100644 --- a/internal/processing/status/get.go +++ b/internal/processing/status/get.go @@ -73,6 +73,44 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) } +// SourceGet returns the *apimodel.StatusSource version of the targetStatusID. +// Status must belong to the requester, and must not be a boost. +func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.StatusSource, gtserror.WithCode) { + targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, + requestingAccount, + targetStatusID, + nil, // default freshness + ) + if errWithCode != nil { + return nil, errWithCode + } + + // Redirect to wrapped status if boost. + targetStatus, errWithCode = p.c.UnwrapIfBoost( + ctx, + requestingAccount, + targetStatus, + ) + if errWithCode != nil { + return nil, errWithCode + } + + if targetStatus.AccountID != requestingAccount.ID { + err := gtserror.Newf( + "status %s does not belong to account %s", + targetStatusID, requestingAccount.ID, + ) + return nil, gtserror.NewErrorNotFound(err) + } + + statusSource, err := p.converter.StatusToAPIStatusSource(ctx, targetStatus) + if err != nil { + err = gtserror.Newf("error converting status: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + return statusSource, nil +} + // WebGet gets the given status for web use, taking account of privacy settings. func (p *Processor) WebGet(ctx context.Context, targetStatusID string) (*apimodel.Status, gtserror.WithCode) { targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx, diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index fa704a5bb..98e1a4611 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -792,6 +792,17 @@ func (c *Converter) StatusToWebStatus( return webStatus, nil } +// StatusToAPIStatusSource returns the *apimodel.StatusSource of the given status. +// Callers should check beforehand whether a requester has permission to view the +// source of the status, and ensure they're passing only a local status into this function. +func (c *Converter) StatusToAPIStatusSource(ctx context.Context, s *gtsmodel.Status) (*apimodel.StatusSource, error) { + return &apimodel.StatusSource{ + ID: s.ID, + Text: s.Text, + SpoilerText: s.ContentWarning, + }, nil +} + // statusToFrontend is a package internal function for // parsing a status into its initial frontend representation. //