mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-12-22 10:12:11 +00:00
[feature] Allow users to create + delete bookbarks, and view bookmarked statuses (#1168)
* Implement Bookmarks * Update based on review comments * Update swagger doc * Fix argument passing to status.Bookmark * Update changed test * Updates based on latest PR review
This commit is contained in:
parent
e58d2d8122
commit
477ae50933
|
@ -152,8 +152,8 @@ Crossed out - will not be implemented / will be stubbed only.
|
|||
- [ ] /api/v1/statuses/:id/unmute POST (Unmute notifications on a status)
|
||||
- [ ] /api/v1/statuses/:id/pin POST (Pin a status to profile)
|
||||
- [ ] /api/v1/statuses/:id/unpin POST (Unpin a status from profile)
|
||||
- ~~/api/v1/statuses/:id/bookmark POST (Bookmark a status)~~
|
||||
- ~~/api/v1/statuses/:id/unbookmark POST (Undo a bookmark)~~
|
||||
- [x] /api/v1/statuses/:id/bookmark POST (Bookmark a status)
|
||||
- [x] /api/v1/statuses/:id/unbookmark POST (Undo a bookmark)
|
||||
- [x] Media
|
||||
- [x] /api/v1/media POST (Upload a media attachment)
|
||||
- [x] /api/v1/media/:id GET (Get a media attachment)
|
||||
|
@ -242,8 +242,8 @@ Crossed out - will not be implemented / will be stubbed only.
|
|||
- ~~Suggestions~~
|
||||
- ~~/api/v1/suggestions GET (Get suggested accounts to follow)~~
|
||||
- ~~/api/v1/suggestions/:account_id DELETE (Delete a suggestion)~~
|
||||
- ~~Bookmarks~~
|
||||
- ~~/api/v1/bookmarks GET (See bookmarked statuses)~~
|
||||
- [x] Bookmarks
|
||||
- [x] /api/v1/bookmarks GET (See bookmarked statuses)
|
||||
|
||||
### Non-API tasks
|
||||
|
||||
|
|
|
@ -3326,6 +3326,34 @@ paths:
|
|||
summary: Get an array of accounts that requesting account has blocked.
|
||||
tags:
|
||||
- blocks
|
||||
/api/v1/bookmarks:
|
||||
get:
|
||||
description: Get an array of statuses bookmarked in the instance
|
||||
operationId: bookmarksGet
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Array of bookmarked statuses
|
||||
headers:
|
||||
Link:
|
||||
description: Links to the next and previous queries.
|
||||
type: string
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/status'
|
||||
type: array
|
||||
"401":
|
||||
description: unauthorized
|
||||
"406":
|
||||
description: not acceptable
|
||||
"500":
|
||||
description: internal server error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:bookmarks
|
||||
tags:
|
||||
- bookmarks
|
||||
/api/v1/custom_emojis:
|
||||
get:
|
||||
operationId: customEmojisGet
|
||||
|
@ -4111,6 +4139,40 @@ paths:
|
|||
summary: View status with the given ID.
|
||||
tags:
|
||||
- statuses
|
||||
/api/v1/statuses/{id}/bookmark:
|
||||
post:
|
||||
operationId: statusBookmark
|
||||
parameters:
|
||||
- description: Target status ID.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The status.
|
||||
schema:
|
||||
$ref: '#/definitions/status'
|
||||
"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:
|
||||
- write:statuses
|
||||
summary: Bookmark status with the given ID.
|
||||
tags:
|
||||
- statuses
|
||||
/api/v1/statuses/{id}/context:
|
||||
get:
|
||||
description: The returned statuses will be ordered in a thread structure, so they are suitable to be displayed in the order in which they were returned.
|
||||
|
@ -4285,6 +4347,40 @@ paths:
|
|||
summary: View accounts that have reblogged/boosted the target status.
|
||||
tags:
|
||||
- statuses
|
||||
/api/v1/statuses/{id}/unbookmark:
|
||||
post:
|
||||
operationId: statusUnbookmark
|
||||
parameters:
|
||||
- description: Target status ID.
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The status.
|
||||
schema:
|
||||
$ref: '#/definitions/status'
|
||||
"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:
|
||||
- write:statuses
|
||||
summary: Unbookmark status with the given ID.
|
||||
tags:
|
||||
- statuses
|
||||
/api/v1/statuses/{id}/unfavourite:
|
||||
post:
|
||||
operationId: statusUnfave
|
||||
|
|
50
internal/api/client/bookmarks/bookmarks.go
Normal file
50
internal/api/client/bookmarks/bookmarks.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
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 bookmarks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
)
|
||||
|
||||
const (
|
||||
// BasePath is the base path for serving the bookmarks API
|
||||
BasePath = "/api/v1/bookmarks"
|
||||
)
|
||||
|
||||
// Module implements the ClientAPIModule interface for everything related to bookmarks
|
||||
type Module struct {
|
||||
processor processing.Processor
|
||||
}
|
||||
|
||||
// New returns a new emoji module
|
||||
func New(processor processing.Processor) api.ClientModule {
|
||||
return &Module{
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
||||
// Route attaches all routes from this module to the given router
|
||||
func (m *Module) Route(r router.Router) error {
|
||||
r.AttachHandler(http.MethodGet, BasePath, m.BookmarksGETHandler)
|
||||
return nil
|
||||
}
|
148
internal/api/client/bookmarks/bookmarks_test.go
Normal file
148
internal/api/client/bookmarks/bookmarks_test.go
Normal file
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
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 bookmarks_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/bookmarks"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/concurrency"
|
||||
"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/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type BookmarkTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
db db.DB
|
||||
tc typeutils.TypeConverter
|
||||
mediaManager media.Manager
|
||||
federator federation.Federator
|
||||
emailSender email.Sender
|
||||
processor processing.Processor
|
||||
storage *storage.Driver
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*gtsmodel.Token
|
||||
testClients map[string]*gtsmodel.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
testAttachments map[string]*gtsmodel.MediaAttachment
|
||||
testStatuses map[string]*gtsmodel.Status
|
||||
testFollows map[string]*gtsmodel.Follow
|
||||
|
||||
// module being tested
|
||||
statusModule *status.Module
|
||||
bookmarkModule *bookmarks.Module
|
||||
}
|
||||
|
||||
func (suite *BookmarkTestSuite) SetupSuite() {
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
suite.testAttachments = testrig.NewTestAttachments()
|
||||
suite.testStatuses = testrig.NewTestStatuses()
|
||||
suite.testFollows = testrig.NewTestFollows()
|
||||
}
|
||||
|
||||
func (suite *BookmarkTestSuite) SetupTest() {
|
||||
testrig.InitTestConfig()
|
||||
testrig.InitTestLog()
|
||||
|
||||
suite.db = testrig.NewTestDB()
|
||||
suite.tc = testrig.NewTestTypeConverter(suite.db)
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media")
|
||||
|
||||
fedWorker := concurrency.NewWorkerPool[messages.FromFederator](-1, -1)
|
||||
clientWorker := concurrency.NewWorkerPool[messages.FromClientAPI](-1, -1)
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(suite.db, suite.storage)
|
||||
suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil, "../../../../testrig/media"), 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.statusModule = status.New(suite.processor).(*status.Module)
|
||||
suite.bookmarkModule = bookmarks.New(suite.processor).(*bookmarks.Module)
|
||||
|
||||
suite.NoError(suite.processor.Start())
|
||||
}
|
||||
|
||||
func (suite *BookmarkTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
}
|
||||
|
||||
func (suite *BookmarkTestSuite) TestGetBookmark() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
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", strings.Replace(status.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
suite.bookmarkModule.BookmarksGETHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
var statuses []model.Status
|
||||
err = json.Unmarshal(b, &statuses)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(1, len(statuses))
|
||||
}
|
||||
|
||||
func TestBookmarkTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(BookmarkTestSuite))
|
||||
}
|
107
internal/api/client/bookmarks/bookmarksget.go
Normal file
107
internal/api/client/bookmarks/bookmarksget.go
Normal file
|
@ -0,0 +1,107 @@
|
|||
package bookmarks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
const (
|
||||
// LimitKey is for setting the return amount limit for eg., requesting an account's statuses
|
||||
LimitKey = "limit"
|
||||
|
||||
// MaxIDKey is for specifying the maximum ID of the bookmark to retrieve.
|
||||
MaxIDKey = "max_id"
|
||||
// MinIDKey is for specifying the minimum ID of the bookmark to retrieve.
|
||||
MinIDKey = "min_id"
|
||||
)
|
||||
|
||||
// BookmarksGETHandler swagger:operation GET /api/v1/bookmarks bookmarksGet
|
||||
//
|
||||
// Get an array of statuses bookmarked in the instance
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - bookmarks
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:bookmarks
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: Array of bookmarked statuses
|
||||
// schema:
|
||||
// type: array
|
||||
// items:
|
||||
// "$ref": "#/definitions/status"
|
||||
// headers:
|
||||
// Link:
|
||||
// type: string
|
||||
// description: Links to the next and previous queries.
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '500':
|
||||
// description: internal server error
|
||||
func (m *Module) BookmarksGETHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 30
|
||||
limitString := c.Query(LimitKey)
|
||||
if limitString != "" {
|
||||
i, err := strconv.ParseInt(limitString, 10, 64)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error parsing %s: %s", LimitKey, err)
|
||||
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
limit = int(i)
|
||||
}
|
||||
|
||||
maxID := ""
|
||||
maxIDString := c.Query(MaxIDKey)
|
||||
if maxIDString != "" {
|
||||
maxID = maxIDString
|
||||
}
|
||||
|
||||
minID := ""
|
||||
minIDString := c.Query(MinIDKey)
|
||||
if minIDString != "" {
|
||||
minID = minIDString
|
||||
}
|
||||
|
||||
resp, errWithCode := m.processor.BookmarksGet(c.Request.Context(), authed, maxID, minID, limit)
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.LinkHeader != "" {
|
||||
c.Header("Link", resp.LinkHeader)
|
||||
}
|
||||
c.JSON(http.StatusOK, resp.Items)
|
||||
}
|
|
@ -96,6 +96,9 @@ func (m *Module) Route(r router.Router) error {
|
|||
r.AttachHandler(http.MethodPost, UnreblogPath, m.StatusUnboostPOSTHandler)
|
||||
r.AttachHandler(http.MethodGet, RebloggedPath, m.StatusBoostedByGETHandler)
|
||||
|
||||
r.AttachHandler(http.MethodPost, BookmarkPath, m.StatusBookmarkPOSTHandler)
|
||||
r.AttachHandler(http.MethodPost, UnbookmarkPath, m.StatusUnbookmarkPOSTHandler)
|
||||
|
||||
r.AttachHandler(http.MethodGet, ContextPath, m.StatusContextGETHandler)
|
||||
|
||||
r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler)
|
||||
|
|
98
internal/api/client/status/statusbookmark.go
Normal file
98
internal/api/client/status/statusbookmark.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
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 status
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// StatusBookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/bookmark statusBookmark
|
||||
//
|
||||
// Bookmark status with the given ID.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - statuses
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// type: string
|
||||
// description: Target status ID.
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:statuses
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// name: status
|
||||
// description: The status.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/status"
|
||||
// '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) StatusBookmarkPOSTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
err := errors.New("no status id specified")
|
||||
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
apiStatus, errWithCode := m.processor.StatusBookmark(c.Request.Context(), authed, targetStatusID)
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiStatus)
|
||||
}
|
83
internal/api/client/status/statusbookmark_test.go
Normal file
83
internal/api/client/status/statusbookmark_test.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
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 status_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusBookmarkTestSuite struct {
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *StatusBookmarkTestSuite) TestPostBookmark() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
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", strings.Replace(status.BookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
// normally the router would populate these params from the path values,
|
||||
// but because we're calling the function directly, we need to set them manually.
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: status.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusBookmarkPOSTHandler(ctx)
|
||||
|
||||
// check response
|
||||
suite.EqualValues(http.StatusOK, recorder.Code)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
statusReply := &model.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.True(statusReply.Bookmarked)
|
||||
}
|
||||
|
||||
func TestStatusBookmarkTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(StatusBookmarkTestSuite))
|
||||
}
|
98
internal/api/client/status/statusunbookmark.go
Normal file
98
internal/api/client/status/statusunbookmark.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
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 status
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// StatusUnbookmarkPOSTHandler swagger:operation POST /api/v1/statuses/{id}/unbookmark statusUnbookmark
|
||||
//
|
||||
// Unbookmark status with the given ID.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - statuses
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: id
|
||||
// type: string
|
||||
// description: Target status ID.
|
||||
// in: path
|
||||
// required: true
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:statuses
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// name: status
|
||||
// description: The status.
|
||||
// schema:
|
||||
// "$ref": "#/definitions/status"
|
||||
// '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) StatusUnbookmarkPOSTHandler(c *gin.Context) {
|
||||
authed, err := oauth.Authed(c, true, true, true, true)
|
||||
if err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := api.NegotiateAccept(c, api.JSONAcceptHeaders...); err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
targetStatusID := c.Param(IDKey)
|
||||
if targetStatusID == "" {
|
||||
err := errors.New("no status id specified")
|
||||
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
apiStatus, errWithCode := m.processor.StatusUnbookmark(c.Request.Context(), authed, targetStatusID)
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiStatus)
|
||||
}
|
78
internal/api/client/status/statusunbookmark_test.go
Normal file
78
internal/api/client/status/statusunbookmark_test.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
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 status_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/status"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type StatusUnbookmarkTestSuite struct {
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *StatusUnbookmarkTestSuite) TestPostUnbookmark() {
|
||||
t := suite.testTokens["local_account_1"]
|
||||
oauthToken := oauth.DBTokenToToken(t)
|
||||
|
||||
targetStatus := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
// setup
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
|
||||
ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
|
||||
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", strings.Replace(status.UnbookmarkPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
|
||||
ctx.Request.Header.Set("accept", "application/json")
|
||||
|
||||
ctx.Params = gin.Params{
|
||||
gin.Param{
|
||||
Key: status.IDKey,
|
||||
Value: targetStatus.ID,
|
||||
},
|
||||
}
|
||||
|
||||
suite.statusModule.StatusUnbookmarkPOSTHandler(ctx)
|
||||
|
||||
result := recorder.Result()
|
||||
defer result.Body.Close()
|
||||
b, err := ioutil.ReadAll(result.Body)
|
||||
suite.NoError(err)
|
||||
|
||||
statusReply := &model.Status{}
|
||||
err = json.Unmarshal(b, statusReply)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.False(statusReply.Bookmarked)
|
||||
}
|
||||
|
||||
func TestStatusUnbookmarkTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(StatusUnbookmarkTestSuite))
|
||||
}
|
|
@ -73,6 +73,8 @@ type Account interface {
|
|||
// or replies.
|
||||
GetAccountWebStatuses(ctx context.Context, accountID string, limit int, maxID string) ([]*gtsmodel.Status, Error)
|
||||
|
||||
GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, Error)
|
||||
|
||||
GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, Error)
|
||||
|
||||
// GetAccountLastPosted simply gets the timestamp of the most recent post by the account.
|
||||
|
|
|
@ -442,6 +442,38 @@ func (a *accountDB) GetAccountWebStatuses(ctx context.Context, accountID string,
|
|||
return a.statusesFromIDs(ctx, statusIDs)
|
||||
}
|
||||
|
||||
func (a *accountDB) GetBookmarks(ctx context.Context, accountID string, limit int, maxID string, minID string) ([]*gtsmodel.StatusBookmark, db.Error) {
|
||||
bookmarks := []*gtsmodel.StatusBookmark{}
|
||||
|
||||
q := a.conn.
|
||||
NewSelect().
|
||||
TableExpr("? AS ?", bun.Ident("status_bookmarks"), bun.Ident("status_bookmark")).
|
||||
Order("status_bookmark.id DESC").
|
||||
Where("? = ?", bun.Ident("status_bookmark.account_id"), accountID)
|
||||
|
||||
if accountID == "" {
|
||||
return nil, errors.New("must provide an account")
|
||||
}
|
||||
|
||||
if limit != 0 {
|
||||
q = q.Limit(limit)
|
||||
}
|
||||
|
||||
if maxID != "" {
|
||||
q = q.Where("? < ?", bun.Ident("status_bookmark.id"), maxID)
|
||||
}
|
||||
|
||||
if minID != "" {
|
||||
q = q.Where("? > ?", bun.Ident("status_bookmark.id"), minID)
|
||||
}
|
||||
|
||||
if err := q.Scan(ctx, &bookmarks); err != nil {
|
||||
return nil, a.conn.ProcessError(err)
|
||||
}
|
||||
|
||||
return bookmarks, nil
|
||||
}
|
||||
|
||||
func (a *accountDB) GetAccountBlocks(ctx context.Context, accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, db.Error) {
|
||||
blocks := []*gtsmodel.Block{}
|
||||
|
||||
|
|
|
@ -208,6 +208,12 @@ func (suite *AccountTestSuite) TestInsertAccountWithDefaults() {
|
|||
suite.False(*newAccount.HideCollections)
|
||||
}
|
||||
|
||||
func (suite *AccountTestSuite) TestGettingBookmarksWithNoAccount() {
|
||||
statuses, err := suite.db.GetBookmarks(context.Background(), "", 10, "", "")
|
||||
suite.Error(err)
|
||||
suite.Nil(statuses)
|
||||
}
|
||||
|
||||
func TestAccountTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AccountTestSuite))
|
||||
}
|
||||
|
|
|
@ -64,6 +64,9 @@ type Processor interface {
|
|||
// WebStatusesGet fetches a number of statuses (in descending order) from the given account. It selects only
|
||||
// statuses which are suitable for showing on the public web profile of an account.
|
||||
WebStatusesGet(ctx context.Context, targetAccountID string, maxID string) (*apimodel.PageableResponse, 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.
|
||||
BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode)
|
||||
// FollowersGet fetches a list of the target account's followers.
|
||||
FollowersGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string) ([]apimodel.Account, gtserror.WithCode)
|
||||
// FollowingGet fetches a list of the accounts that target account is following.
|
||||
|
|
88
internal/processing/account/getbookmarks.go
Normal file
88
internal/processing/account/getbookmarks.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
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 account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (p *processor) BookmarksGet(ctx context.Context, requestingAccount *gtsmodel.Account, limit int, maxID string, minID string) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
if requestingAccount == nil {
|
||||
return nil, gtserror.NewErrorForbidden(fmt.Errorf("cannot retrieve bookmarks without a requesting account"))
|
||||
}
|
||||
|
||||
bookmarks, err := p.db.GetBookmarks(ctx, requestingAccount.ID, limit, maxID, minID)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
count := len(bookmarks)
|
||||
filtered := make([]*gtsmodel.Status, 0, len(bookmarks))
|
||||
nextMaxIDValue := ""
|
||||
prevMinIDValue := ""
|
||||
for i, b := range bookmarks {
|
||||
s, err := p.db.GetStatusByID(ctx, b.StatusID)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
visible, err := p.filter.StatusVisible(ctx, s, requestingAccount)
|
||||
if err == nil && visible {
|
||||
if i == count-1 {
|
||||
nextMaxIDValue = b.ID
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
prevMinIDValue = b.ID
|
||||
}
|
||||
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
|
||||
count = len(filtered)
|
||||
|
||||
if count == 0 {
|
||||
return util.EmptyPageableResponse(), nil
|
||||
}
|
||||
|
||||
items := []interface{}{}
|
||||
for _, s := range filtered {
|
||||
item, err := p.tc.StatusToAPIStatus(ctx, s, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status to api: %s", err))
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
||||
Items: items,
|
||||
Path: "/api/v1/bookmarks",
|
||||
NextMaxIDValue: nextMaxIDValue,
|
||||
PrevMinIDValue: prevMinIDValue,
|
||||
Limit: limit,
|
||||
ExtraQueryParams: []string{},
|
||||
})
|
||||
}
|
31
internal/processing/bookmark.go
Normal file
31
internal/processing/bookmark.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
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 processing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
func (p *processor) BookmarksGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||
return p.accountProcessor.BookmarksGet(ctx, authed.Account, limit, maxID, minID)
|
||||
}
|
|
@ -146,6 +146,9 @@ type Processor interface {
|
|||
// CustomEmojisGet returns an array of info about the custom emojis on this server
|
||||
CustomEmojisGet(ctx context.Context) ([]*apimodel.Emoji, gtserror.WithCode)
|
||||
|
||||
// BookmarksGet returns a pageable response of statuses that have been bookmarked
|
||||
BookmarksGet(ctx context.Context, authed *oauth.Auth, maxID string, minID string, limit int) (*apimodel.PageableResponse, gtserror.WithCode)
|
||||
|
||||
// FileGet handles the fetching of a media attachment file via the fileserver.
|
||||
FileGet(ctx context.Context, authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, gtserror.WithCode)
|
||||
|
||||
|
@ -202,6 +205,10 @@ type Processor interface {
|
|||
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)
|
||||
// StatusBookmark process a bookmark for a status
|
||||
StatusBookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
|
||||
// StatusUnbookmark removes a bookmark for a status
|
||||
StatusUnbookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
|
||||
|
||||
// HomeTimelineGet returns statuses from the home timeline, with the given filters/parameters.
|
||||
HomeTimelineGet(ctx context.Context, authed *oauth.Auth, maxID string, sinceID string, minID string, limit int, local bool) (*apimodel.PageableResponse, gtserror.WithCode)
|
||||
|
|
|
@ -65,3 +65,11 @@ func (p *processor) StatusUnfave(ctx context.Context, authed *oauth.Auth, target
|
|||
func (p *processor) StatusGetContext(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Context, gtserror.WithCode) {
|
||||
return p.statusProcessor.Context(ctx, authed.Account, targetStatusID)
|
||||
}
|
||||
|
||||
func (p *processor) StatusBookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
|
||||
return p.statusProcessor.Bookmark(ctx, authed.Account, targetStatusID)
|
||||
}
|
||||
|
||||
func (p *processor) StatusUnbookmark(ctx context.Context, authed *oauth.Auth, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
|
||||
return p.statusProcessor.Unbookmark(ctx, authed.Account, targetStatusID)
|
||||
}
|
||||
|
|
86
internal/processing/status/bookmark.go
Normal file
86
internal/processing/status/bookmark.go
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
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 status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
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/id"
|
||||
)
|
||||
|
||||
func (p *processor) Bookmark(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
|
||||
targetStatus, err := p.db.GetStatusByID(ctx, targetStatusID)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err))
|
||||
}
|
||||
if targetStatus.Account == nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID))
|
||||
}
|
||||
visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err))
|
||||
}
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible"))
|
||||
}
|
||||
|
||||
// first check if the status is already bookmarked, if so we don't need to do anything
|
||||
newBookmark := true
|
||||
gtsBookmark := >smodel.StatusBookmark{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil {
|
||||
// we already have a bookmark for this status
|
||||
newBookmark = false
|
||||
}
|
||||
|
||||
if newBookmark {
|
||||
thisBookmarkID, err := id.NewULID()
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// we need to create a new bookmark in the database
|
||||
gtsBookmark := >smodel.StatusBookmark{
|
||||
ID: thisBookmarkID,
|
||||
AccountID: requestingAccount.ID,
|
||||
Account: requestingAccount,
|
||||
TargetAccountID: targetStatus.AccountID,
|
||||
TargetAccount: targetStatus.Account,
|
||||
StatusID: targetStatus.ID,
|
||||
Status: targetStatus,
|
||||
}
|
||||
|
||||
if err := p.db.Put(ctx, gtsBookmark); err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error putting bookmark in database: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
// return the apidon representation of the target status
|
||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err))
|
||||
}
|
||||
|
||||
return apiStatus, nil
|
||||
}
|
48
internal/processing/status/bookmark_test.go
Normal file
48
internal/processing/status/bookmark_test.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
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 status_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type StatusBookmarkTestSuite struct {
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *StatusBookmarkTestSuite) TestBookmark() {
|
||||
ctx := context.Background()
|
||||
|
||||
// bookmark a status
|
||||
bookmarkingAccount1 := suite.testAccounts["local_account_1"]
|
||||
targetStatus1 := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
bookmark1, err := suite.status.Bookmark(ctx, bookmarkingAccount1, targetStatus1.ID)
|
||||
suite.NoError(err)
|
||||
suite.NotNil(bookmark1)
|
||||
suite.True(bookmark1.Bookmarked)
|
||||
suite.Equal(targetStatus1.ID, bookmark1.ID)
|
||||
}
|
||||
|
||||
func TestStatusBookmarkTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(StatusBookmarkTestSuite))
|
||||
}
|
|
@ -54,6 +54,10 @@ type Processor interface {
|
|||
Unfave(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
|
||||
// Context returns the context (previous and following posts) from the given status ID
|
||||
Context(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Context, gtserror.WithCode)
|
||||
// Bookmarks a status
|
||||
Bookmark(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
|
||||
// Removes a bookmark for a status
|
||||
Unbookmark(ctx context.Context, account *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode)
|
||||
|
||||
/*
|
||||
PROCESSING UTILS
|
||||
|
|
69
internal/processing/status/unbookmark.go
Normal file
69
internal/processing/status/unbookmark.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
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 status
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func (p *processor) Unbookmark(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
|
||||
targetStatus, err := p.db.GetStatusByID(ctx, targetStatusID)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error fetching status %s: %s", targetStatusID, err))
|
||||
}
|
||||
if targetStatus.Account == nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("no status owner for status %s", targetStatusID))
|
||||
}
|
||||
visible, err := p.filter.StatusVisible(ctx, targetStatus, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorNotFound(fmt.Errorf("error seeing if status %s is visible: %s", targetStatus.ID, err))
|
||||
}
|
||||
if !visible {
|
||||
return nil, gtserror.NewErrorNotFound(errors.New("status is not visible"))
|
||||
}
|
||||
|
||||
// first check if the status is already bookmarked
|
||||
toUnbookmark := false
|
||||
gtsBookmark := >smodel.StatusBookmark{}
|
||||
if err := p.db.GetWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err == nil {
|
||||
// we already have a bookmark for this status
|
||||
toUnbookmark = true
|
||||
}
|
||||
|
||||
if toUnbookmark {
|
||||
if err := p.db.DeleteWhere(ctx, []db.Where{{Key: "status_id", Value: targetStatus.ID}, {Key: "account_id", Value: requestingAccount.ID}}, gtsBookmark); err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error unfaveing status: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
// return the apidon representation of the target status
|
||||
apiStatus, err := p.tc.StatusToAPIStatus(ctx, targetStatus, requestingAccount)
|
||||
if err != nil {
|
||||
return nil, gtserror.NewErrorInternalError(fmt.Errorf("error converting status %s to frontend representation: %s", targetStatus.ID, err))
|
||||
}
|
||||
|
||||
return apiStatus, nil
|
||||
}
|
54
internal/processing/status/unbookmark_test.go
Normal file
54
internal/processing/status/unbookmark_test.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
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 status_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type StatusUnbookmarkTestSuite struct {
|
||||
StatusStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *StatusUnbookmarkTestSuite) TestUnbookmark() {
|
||||
ctx := context.Background()
|
||||
|
||||
// bookmark a status
|
||||
bookmarkingAccount1 := suite.testAccounts["local_account_1"]
|
||||
targetStatus1 := suite.testStatuses["admin_account_status_1"]
|
||||
|
||||
bookmark1, err := suite.status.Bookmark(ctx, bookmarkingAccount1, targetStatus1.ID)
|
||||
suite.NoError(err)
|
||||
suite.NotNil(bookmark1)
|
||||
suite.True(bookmark1.Bookmarked)
|
||||
suite.Equal(targetStatus1.ID, bookmark1.ID)
|
||||
|
||||
bookmark2, err := suite.status.Unbookmark(ctx, bookmarkingAccount1, targetStatus1.ID)
|
||||
suite.NoError(err)
|
||||
suite.NotNil(bookmark2)
|
||||
suite.False(bookmark2.Bookmarked)
|
||||
suite.Equal(targetStatus1.ID, bookmark1.ID)
|
||||
}
|
||||
|
||||
func TestStatusUnbookmarkTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(StatusUnbookmarkTestSuite))
|
||||
}
|
|
@ -93,7 +93,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
|
|||
b, err := json.Marshal(apiStatus)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b))
|
||||
suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":true,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true,"role":"admin"},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true,"category":"reactions"}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b))
|
||||
}
|
||||
|
||||
func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() {
|
||||
|
|
|
@ -261,6 +261,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
|
|||
}
|
||||
}
|
||||
|
||||
for _, v := range NewTestBookmarks() {
|
||||
if err := db.Put(ctx, v); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.CreateInstanceAccount(ctx); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
|
|
@ -2251,6 +2251,26 @@ func NewTestFediStatuses() map[string]vocab.ActivityStreamsNote {
|
|||
}
|
||||
}
|
||||
|
||||
// NewTestBookmarks returns a map of gts model bookmarks, keyed in the format [bookmarking_account]_[target_status]
|
||||
func NewTestBookmarks() map[string]*gtsmodel.StatusBookmark {
|
||||
return map[string]*gtsmodel.StatusBookmark{
|
||||
"local_account_1_admin_account_status_1": {
|
||||
ID: "01F8MHD2QCZSZ6WQS2ATVPEYJ9",
|
||||
CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"),
|
||||
AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1
|
||||
TargetAccountID: "01F8MH17FWEB39HZJ76B6VXSKF", // admin account
|
||||
StatusID: "01F8MH75CBF9JFX4ZAD54N0W0R", // admin account status 1
|
||||
},
|
||||
"admin_account_local_account_1_status_1": {
|
||||
ID: "01F8Q0486ANTDWKG02A7DS1Q24",
|
||||
CreatedAt: TimeMustParse("2022-05-14T13:21:09+02:00"),
|
||||
AccountID: "01F8MH17FWEB39HZJ76B6VXSKF", // admin account
|
||||
TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", // local account 1
|
||||
StatusID: "01F8MHAMCHF6Y650WCRSCP4WMY", // local account status 1
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestDereferenceRequests returns a map of incoming dereference requests, with their signatures.
|
||||
func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature {
|
||||
var sig, digest, date string
|
||||
|
|
Loading…
Reference in a new issue