mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-01 06:50:00 +00:00
[feature] Support setting private notes on accounts (#1982)
* Support setting private notes on accounts * Reformat comment whitespace * Add missing license headers * Use apiutil.ParseID * Rename Note model and cache to AccountNote * Update golden cache config in test/envparsing.sh * Rename gtsmodel/note.go to gtsmodel/accountnote.go * Update AccountNote uniqueness constraint name Now has same prefix as other indexes on this table. --------- Co-authored-by: tobi <31960611+tsmethurst@users.noreply.github.com>
This commit is contained in:
parent
5f3e095717
commit
22ac4607a1
19 changed files with 597 additions and 2 deletions
|
@ -2944,6 +2944,45 @@ paths:
|
||||||
summary: See all lists of yours that contain requested account.
|
summary: See all lists of yours that contain requested account.
|
||||||
tags:
|
tags:
|
||||||
- accounts
|
- accounts
|
||||||
|
/api/v1/accounts/{id}/note:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- multipart/form-data
|
||||||
|
operationId: accountNote
|
||||||
|
parameters:
|
||||||
|
- description: The id of the account for which to set a note.
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- default: ""
|
||||||
|
description: The text of the note. Omit this parameter or send an empty string to clear the note.
|
||||||
|
in: formData
|
||||||
|
name: comment
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Your relationship to the account.
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/accountRelationship'
|
||||||
|
"400":
|
||||||
|
description: bad request
|
||||||
|
"401":
|
||||||
|
description: unauthorized
|
||||||
|
"404":
|
||||||
|
description: not found
|
||||||
|
"406":
|
||||||
|
description: not acceptable
|
||||||
|
"500":
|
||||||
|
description: internal server error
|
||||||
|
security:
|
||||||
|
- OAuth2 Bearer:
|
||||||
|
- write:accounts
|
||||||
|
summary: Set a private note for an account with the given id.
|
||||||
|
tags:
|
||||||
|
- accounts
|
||||||
/api/v1/accounts/{id}/statuses:
|
/api/v1/accounts/{id}/statuses:
|
||||||
get:
|
get:
|
||||||
description: The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
|
description: The statuses will be returned in descending chronological order (newest first), with sequential IDs (bigger = newer).
|
||||||
|
|
|
@ -45,6 +45,7 @@
|
||||||
FollowPath = BasePathWithID + "/follow"
|
FollowPath = BasePathWithID + "/follow"
|
||||||
ListsPath = BasePathWithID + "/lists"
|
ListsPath = BasePathWithID + "/lists"
|
||||||
LookupPath = BasePath + "/lookup"
|
LookupPath = BasePath + "/lookup"
|
||||||
|
NotePath = BasePathWithID + "/note"
|
||||||
RelationshipsPath = BasePath + "/relationships"
|
RelationshipsPath = BasePath + "/relationships"
|
||||||
SearchPath = BasePath + "/search"
|
SearchPath = BasePath + "/search"
|
||||||
StatusesPath = BasePathWithID + "/statuses"
|
StatusesPath = BasePathWithID + "/statuses"
|
||||||
|
@ -101,6 +102,9 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
||||||
// account lists
|
// account lists
|
||||||
attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler)
|
attachHandler(http.MethodGet, ListsPath, m.AccountListsGETHandler)
|
||||||
|
|
||||||
|
// account note
|
||||||
|
attachHandler(http.MethodPost, NotePath, m.AccountNotePOSTHandler)
|
||||||
|
|
||||||
// search for accounts
|
// search for accounts
|
||||||
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
|
attachHandler(http.MethodGet, SearchPath, m.AccountSearchGETHandler)
|
||||||
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
|
attachHandler(http.MethodGet, LookupPath, m.AccountLookupGETHandler)
|
||||||
|
|
108
internal/api/client/accounts/note.go
Normal file
108
internal/api/client/accounts/note.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package accounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccountNotePOSTHandler swagger:operation POST /api/v1/accounts/{id}/note accountNote
|
||||||
|
//
|
||||||
|
// Set a private note for an account with the given id.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// tags:
|
||||||
|
// - accounts
|
||||||
|
//
|
||||||
|
// consumes:
|
||||||
|
// - multipart/form-data
|
||||||
|
//
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// parameters:
|
||||||
|
// -
|
||||||
|
// name: id
|
||||||
|
// type: string
|
||||||
|
// description: The id of the account for which to set a note.
|
||||||
|
// in: path
|
||||||
|
// required: true
|
||||||
|
// -
|
||||||
|
// name: comment
|
||||||
|
// type: string
|
||||||
|
// description: The text of the note. Omit this parameter or send an empty string to clear the note.
|
||||||
|
// in: formData
|
||||||
|
// default: ""
|
||||||
|
//
|
||||||
|
// security:
|
||||||
|
// - OAuth2 Bearer:
|
||||||
|
// - write:accounts
|
||||||
|
//
|
||||||
|
// responses:
|
||||||
|
// '200':
|
||||||
|
// description: Your relationship to the account.
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/accountRelationship"
|
||||||
|
// '400':
|
||||||
|
// description: bad request
|
||||||
|
// '401':
|
||||||
|
// description: unauthorized
|
||||||
|
// '404':
|
||||||
|
// description: not found
|
||||||
|
// '406':
|
||||||
|
// description: not acceptable
|
||||||
|
// '500':
|
||||||
|
// description: internal server error
|
||||||
|
func (m *Module) AccountNotePOSTHandler(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
|
||||||
|
}
|
||||||
|
|
||||||
|
targetAcctID, errWithCode := apiutil.ParseID(c.Param(apiutil.IDKey))
|
||||||
|
if errWithCode != nil {
|
||||||
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := &apimodel.AccountNoteRequest{}
|
||||||
|
if err := c.ShouldBind(form); err != nil {
|
||||||
|
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relationship, errWithCode := m.processor.Account().PutNote(c.Request.Context(), authed.Account, targetAcctID, form.Comment)
|
||||||
|
if errWithCode != nil {
|
||||||
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, relationship)
|
||||||
|
}
|
|
@ -231,3 +231,11 @@ type AccountRole struct {
|
||||||
AccountRoleAdmin AccountRoleName = "admin" // Instance admin
|
AccountRoleAdmin AccountRoleName = "admin" // Instance admin
|
||||||
AccountRoleUnknown AccountRoleName = "" // We don't know / remote account
|
AccountRoleUnknown AccountRoleName = "" // We don't know / remote account
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AccountNoteRequest models a request to update the private note for an account.
|
||||||
|
//
|
||||||
|
// swagger:ignore
|
||||||
|
type AccountNoteRequest struct {
|
||||||
|
// Comment to use for the note text.
|
||||||
|
Comment string `form:"comment" json:"comment" xml:"comment"`
|
||||||
|
}
|
||||||
|
|
22
internal/cache/gts.go
vendored
22
internal/cache/gts.go
vendored
|
@ -27,6 +27,7 @@
|
||||||
|
|
||||||
type GTSCaches struct {
|
type GTSCaches struct {
|
||||||
account *result.Cache[*gtsmodel.Account]
|
account *result.Cache[*gtsmodel.Account]
|
||||||
|
accountNote *result.Cache[*gtsmodel.AccountNote]
|
||||||
block *result.Cache[*gtsmodel.Block]
|
block *result.Cache[*gtsmodel.Block]
|
||||||
// TODO: maybe should be moved out of here since it's
|
// TODO: maybe should be moved out of here since it's
|
||||||
// not actually doing anything with gtsmodel.DomainBlock.
|
// not actually doing anything with gtsmodel.DomainBlock.
|
||||||
|
@ -54,6 +55,7 @@ type GTSCaches struct {
|
||||||
// NOTE: the cache MUST NOT be in use anywhere, this is not thread-safe.
|
// NOTE: the cache MUST NOT be in use anywhere, this is not thread-safe.
|
||||||
func (c *GTSCaches) Init() {
|
func (c *GTSCaches) Init() {
|
||||||
c.initAccount()
|
c.initAccount()
|
||||||
|
c.initAccountNote()
|
||||||
c.initBlock()
|
c.initBlock()
|
||||||
c.initDomainBlock()
|
c.initDomainBlock()
|
||||||
c.initEmoji()
|
c.initEmoji()
|
||||||
|
@ -77,6 +79,7 @@ func (c *GTSCaches) Init() {
|
||||||
// Start will attempt to start all of the gtsmodel caches, or panic.
|
// Start will attempt to start all of the gtsmodel caches, or panic.
|
||||||
func (c *GTSCaches) Start() {
|
func (c *GTSCaches) Start() {
|
||||||
tryStart(c.account, config.GetCacheGTSAccountSweepFreq())
|
tryStart(c.account, config.GetCacheGTSAccountSweepFreq())
|
||||||
|
tryStart(c.accountNote, config.GetCacheGTSAccountNoteSweepFreq())
|
||||||
tryStart(c.block, config.GetCacheGTSBlockSweepFreq())
|
tryStart(c.block, config.GetCacheGTSBlockSweepFreq())
|
||||||
tryStart(c.emoji, config.GetCacheGTSEmojiSweepFreq())
|
tryStart(c.emoji, config.GetCacheGTSEmojiSweepFreq())
|
||||||
tryStart(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq())
|
tryStart(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq())
|
||||||
|
@ -104,6 +107,7 @@ func (c *GTSCaches) Start() {
|
||||||
// Stop will attempt to stop all of the gtsmodel caches, or panic.
|
// Stop will attempt to stop all of the gtsmodel caches, or panic.
|
||||||
func (c *GTSCaches) Stop() {
|
func (c *GTSCaches) Stop() {
|
||||||
tryStop(c.account, config.GetCacheGTSAccountSweepFreq())
|
tryStop(c.account, config.GetCacheGTSAccountSweepFreq())
|
||||||
|
tryStop(c.accountNote, config.GetCacheGTSAccountNoteSweepFreq())
|
||||||
tryStop(c.block, config.GetCacheGTSBlockSweepFreq())
|
tryStop(c.block, config.GetCacheGTSBlockSweepFreq())
|
||||||
tryStop(c.emoji, config.GetCacheGTSEmojiSweepFreq())
|
tryStop(c.emoji, config.GetCacheGTSEmojiSweepFreq())
|
||||||
tryStop(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq())
|
tryStop(c.emojiCategory, config.GetCacheGTSEmojiCategorySweepFreq())
|
||||||
|
@ -128,6 +132,11 @@ func (c *GTSCaches) Account() *result.Cache[*gtsmodel.Account] {
|
||||||
return c.account
|
return c.account
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccountNote provides access to the gtsmodel Note database cache.
|
||||||
|
func (c *GTSCaches) AccountNote() *result.Cache[*gtsmodel.AccountNote] {
|
||||||
|
return c.accountNote
|
||||||
|
}
|
||||||
|
|
||||||
// Block provides access to the gtsmodel Block (account) database cache.
|
// Block provides access to the gtsmodel Block (account) database cache.
|
||||||
func (c *GTSCaches) Block() *result.Cache[*gtsmodel.Block] {
|
func (c *GTSCaches) Block() *result.Cache[*gtsmodel.Block] {
|
||||||
return c.block
|
return c.block
|
||||||
|
@ -238,6 +247,19 @@ func (c *GTSCaches) initAccount() {
|
||||||
c.account.IgnoreErrors(ignoreErrors)
|
c.account.IgnoreErrors(ignoreErrors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *GTSCaches) initAccountNote() {
|
||||||
|
c.accountNote = result.New([]result.Lookup{
|
||||||
|
{Name: "ID"},
|
||||||
|
{Name: "AccountID.TargetAccountID"},
|
||||||
|
}, func(n1 *gtsmodel.AccountNote) *gtsmodel.AccountNote {
|
||||||
|
n2 := new(gtsmodel.AccountNote)
|
||||||
|
*n2 = *n1
|
||||||
|
return n2
|
||||||
|
}, config.GetCacheGTSAccountNoteMaxSize())
|
||||||
|
c.accountNote.SetTTL(config.GetCacheGTSAccountNoteTTL(), true)
|
||||||
|
c.accountNote.IgnoreErrors(ignoreErrors)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *GTSCaches) initBlock() {
|
func (c *GTSCaches) initBlock() {
|
||||||
c.block = result.New([]result.Lookup{
|
c.block = result.New([]result.Lookup{
|
||||||
{Name: "ID"},
|
{Name: "ID"},
|
||||||
|
|
|
@ -186,6 +186,10 @@ type GTSCacheConfiguration struct {
|
||||||
AccountTTL time.Duration `name:"account-ttl"`
|
AccountTTL time.Duration `name:"account-ttl"`
|
||||||
AccountSweepFreq time.Duration `name:"account-sweep-freq"`
|
AccountSweepFreq time.Duration `name:"account-sweep-freq"`
|
||||||
|
|
||||||
|
AccountNoteMaxSize int `name:"account-note-max-size"`
|
||||||
|
AccountNoteTTL time.Duration `name:"account-note-ttl"`
|
||||||
|
AccountNoteSweepFreq time.Duration `name:"account-note-sweep-freq"`
|
||||||
|
|
||||||
BlockMaxSize int `name:"block-max-size"`
|
BlockMaxSize int `name:"block-max-size"`
|
||||||
BlockTTL time.Duration `name:"block-ttl"`
|
BlockTTL time.Duration `name:"block-ttl"`
|
||||||
BlockSweepFreq time.Duration `name:"block-sweep-freq"`
|
BlockSweepFreq time.Duration `name:"block-sweep-freq"`
|
||||||
|
|
|
@ -131,6 +131,10 @@
|
||||||
AccountTTL: time.Minute * 30,
|
AccountTTL: time.Minute * 30,
|
||||||
AccountSweepFreq: time.Minute,
|
AccountSweepFreq: time.Minute,
|
||||||
|
|
||||||
|
AccountNoteMaxSize: 1000,
|
||||||
|
AccountNoteTTL: time.Minute * 30,
|
||||||
|
AccountNoteSweepFreq: time.Minute,
|
||||||
|
|
||||||
BlockMaxSize: 1000,
|
BlockMaxSize: 1000,
|
||||||
BlockTTL: time.Minute * 30,
|
BlockTTL: time.Minute * 30,
|
||||||
BlockSweepFreq: time.Minute,
|
BlockSweepFreq: time.Minute,
|
||||||
|
|
|
@ -2474,6 +2474,81 @@ func GetCacheGTSAccountSweepFreq() time.Duration { return global.GetCacheGTSAcco
|
||||||
// SetCacheGTSAccountSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountSweepFreq' field
|
// SetCacheGTSAccountSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountSweepFreq' field
|
||||||
func SetCacheGTSAccountSweepFreq(v time.Duration) { global.SetCacheGTSAccountSweepFreq(v) }
|
func SetCacheGTSAccountSweepFreq(v time.Duration) { global.SetCacheGTSAccountSweepFreq(v) }
|
||||||
|
|
||||||
|
// GetCacheGTSAccountNoteMaxSize safely fetches the Configuration value for state's 'Cache.GTS.AccountNoteMaxSize' field
|
||||||
|
func (st *ConfigState) GetCacheGTSAccountNoteMaxSize() (v int) {
|
||||||
|
st.mutex.RLock()
|
||||||
|
v = st.config.Cache.GTS.AccountNoteMaxSize
|
||||||
|
st.mutex.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCacheGTSAccountNoteMaxSize safely sets the Configuration value for state's 'Cache.GTS.AccountNoteMaxSize' field
|
||||||
|
func (st *ConfigState) SetCacheGTSAccountNoteMaxSize(v int) {
|
||||||
|
st.mutex.Lock()
|
||||||
|
defer st.mutex.Unlock()
|
||||||
|
st.config.Cache.GTS.AccountNoteMaxSize = v
|
||||||
|
st.reloadToViper()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheGTSAccountNoteMaxSizeFlag returns the flag name for the 'Cache.GTS.AccountNoteMaxSize' field
|
||||||
|
func CacheGTSAccountNoteMaxSizeFlag() string { return "cache-gts-account-note-max-size" }
|
||||||
|
|
||||||
|
// GetCacheGTSAccountNoteMaxSize safely fetches the value for global configuration 'Cache.GTS.AccountNoteMaxSize' field
|
||||||
|
func GetCacheGTSAccountNoteMaxSize() int { return global.GetCacheGTSAccountNoteMaxSize() }
|
||||||
|
|
||||||
|
// SetCacheGTSAccountNoteMaxSize safely sets the value for global configuration 'Cache.GTS.AccountNoteMaxSize' field
|
||||||
|
func SetCacheGTSAccountNoteMaxSize(v int) { global.SetCacheGTSAccountNoteMaxSize(v) }
|
||||||
|
|
||||||
|
// GetCacheGTSAccountNoteTTL safely fetches the Configuration value for state's 'Cache.GTS.AccountNoteTTL' field
|
||||||
|
func (st *ConfigState) GetCacheGTSAccountNoteTTL() (v time.Duration) {
|
||||||
|
st.mutex.RLock()
|
||||||
|
v = st.config.Cache.GTS.AccountNoteTTL
|
||||||
|
st.mutex.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCacheGTSAccountNoteTTL safely sets the Configuration value for state's 'Cache.GTS.AccountNoteTTL' field
|
||||||
|
func (st *ConfigState) SetCacheGTSAccountNoteTTL(v time.Duration) {
|
||||||
|
st.mutex.Lock()
|
||||||
|
defer st.mutex.Unlock()
|
||||||
|
st.config.Cache.GTS.AccountNoteTTL = v
|
||||||
|
st.reloadToViper()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheGTSAccountNoteTTLFlag returns the flag name for the 'Cache.GTS.AccountNoteTTL' field
|
||||||
|
func CacheGTSAccountNoteTTLFlag() string { return "cache-gts-account-note-ttl" }
|
||||||
|
|
||||||
|
// GetCacheGTSAccountNoteTTL safely fetches the value for global configuration 'Cache.GTS.AccountNoteTTL' field
|
||||||
|
func GetCacheGTSAccountNoteTTL() time.Duration { return global.GetCacheGTSAccountNoteTTL() }
|
||||||
|
|
||||||
|
// SetCacheGTSAccountNoteTTL safely sets the value for global configuration 'Cache.GTS.AccountNoteTTL' field
|
||||||
|
func SetCacheGTSAccountNoteTTL(v time.Duration) { global.SetCacheGTSAccountNoteTTL(v) }
|
||||||
|
|
||||||
|
// GetCacheGTSAccountNoteSweepFreq safely fetches the Configuration value for state's 'Cache.GTS.AccountNoteSweepFreq' field
|
||||||
|
func (st *ConfigState) GetCacheGTSAccountNoteSweepFreq() (v time.Duration) {
|
||||||
|
st.mutex.RLock()
|
||||||
|
v = st.config.Cache.GTS.AccountNoteSweepFreq
|
||||||
|
st.mutex.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCacheGTSAccountNoteSweepFreq safely sets the Configuration value for state's 'Cache.GTS.AccountNoteSweepFreq' field
|
||||||
|
func (st *ConfigState) SetCacheGTSAccountNoteSweepFreq(v time.Duration) {
|
||||||
|
st.mutex.Lock()
|
||||||
|
defer st.mutex.Unlock()
|
||||||
|
st.config.Cache.GTS.AccountNoteSweepFreq = v
|
||||||
|
st.reloadToViper()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheGTSAccountNoteSweepFreqFlag returns the flag name for the 'Cache.GTS.AccountNoteSweepFreq' field
|
||||||
|
func CacheGTSAccountNoteSweepFreqFlag() string { return "cache-gts-account-note-sweep-freq" }
|
||||||
|
|
||||||
|
// GetCacheGTSAccountNoteSweepFreq safely fetches the value for global configuration 'Cache.GTS.AccountNoteSweepFreq' field
|
||||||
|
func GetCacheGTSAccountNoteSweepFreq() time.Duration { return global.GetCacheGTSAccountNoteSweepFreq() }
|
||||||
|
|
||||||
|
// SetCacheGTSAccountNoteSweepFreq safely sets the value for global configuration 'Cache.GTS.AccountNoteSweepFreq' field
|
||||||
|
func SetCacheGTSAccountNoteSweepFreq(v time.Duration) { global.SetCacheGTSAccountNoteSweepFreq(v) }
|
||||||
|
|
||||||
// GetCacheGTSBlockMaxSize safely fetches the Configuration value for state's 'Cache.GTS.BlockMaxSize' field
|
// GetCacheGTSBlockMaxSize safely fetches the Configuration value for state's 'Cache.GTS.BlockMaxSize' field
|
||||||
func (st *ConfigState) GetCacheGTSBlockMaxSize() (v int) {
|
func (st *ConfigState) GetCacheGTSBlockMaxSize() (v int) {
|
||||||
st.mutex.RLock()
|
st.mutex.RLock()
|
||||||
|
|
|
@ -49,6 +49,7 @@ type BunDBStandardTestSuite struct {
|
||||||
testFaves map[string]*gtsmodel.StatusFave
|
testFaves map[string]*gtsmodel.StatusFave
|
||||||
testLists map[string]*gtsmodel.List
|
testLists map[string]*gtsmodel.List
|
||||||
testListEntries map[string]*gtsmodel.ListEntry
|
testListEntries map[string]*gtsmodel.ListEntry
|
||||||
|
testAccountNotes map[string]*gtsmodel.AccountNote
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *BunDBStandardTestSuite) SetupSuite() {
|
func (suite *BunDBStandardTestSuite) SetupSuite() {
|
||||||
|
@ -68,6 +69,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() {
|
||||||
suite.testFaves = testrig.NewTestFaves()
|
suite.testFaves = testrig.NewTestFaves()
|
||||||
suite.testLists = testrig.NewTestLists()
|
suite.testLists = testrig.NewTestLists()
|
||||||
suite.testListEntries = testrig.NewTestListEntries()
|
suite.testListEntries = testrig.NewTestListEntries()
|
||||||
|
suite.testAccountNotes = testrig.NewTestAccountNotes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *BunDBStandardTestSuite) SetupTest() {
|
func (suite *BunDBStandardTestSuite) SetupTest() {
|
||||||
|
|
62
internal/db/bundb/migrations/20230711214815_account_notes.go
Normal file
62
internal/db/bundb/migrations/20230711214815_account_notes.go
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
up := func(ctx context.Context, db *bun.DB) error {
|
||||||
|
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||||
|
// Account note table.
|
||||||
|
if _, err := tx.
|
||||||
|
NewCreateTable().
|
||||||
|
Model(>smodel.AccountNote{}).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add IDs index to the account note table.
|
||||||
|
if _, err := tx.
|
||||||
|
NewCreateIndex().
|
||||||
|
Model(>smodel.AccountNote{}).
|
||||||
|
Index("account_notes_account_id_target_account_id_idx").
|
||||||
|
Column("account_id", "target_account_id").
|
||||||
|
Exec(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
down := func(ctx context.Context, db *bun.DB) error {
|
||||||
|
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Migrations.Register(up, down); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -85,6 +85,19 @@ func (r *relationshipDB) GetRelationship(ctx context.Context, requestingAccount
|
||||||
return nil, fmt.Errorf("GetRelationship: error checking blockedBy: %w", err)
|
return nil, fmt.Errorf("GetRelationship: error checking blockedBy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retrieve a note by the requesting account on the target account, if there is one
|
||||||
|
note, err := r.GetNote(
|
||||||
|
gtscontext.SetBarebones(ctx),
|
||||||
|
requestingAccount,
|
||||||
|
targetAccount,
|
||||||
|
)
|
||||||
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
|
return nil, fmt.Errorf("GetRelationship: error fetching note: %w", err)
|
||||||
|
}
|
||||||
|
if note != nil {
|
||||||
|
rel.Note = note.Comment
|
||||||
|
}
|
||||||
|
|
||||||
return &rel, nil
|
return &rel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
99
internal/db/bundb/relationship_note.go
Normal file
99
internal/db/bundb/relationship_note.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package bundb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *relationshipDB) GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error) {
|
||||||
|
return r.getNote(
|
||||||
|
ctx,
|
||||||
|
"AccountID.TargetAccountID",
|
||||||
|
func(note *gtsmodel.AccountNote) error {
|
||||||
|
return r.conn.NewSelect().Model(note).
|
||||||
|
Where("? = ?", bun.Ident("account_id"), sourceAccountID).
|
||||||
|
Where("? = ?", bun.Ident("target_account_id"), targetAccountID).
|
||||||
|
Scan(ctx)
|
||||||
|
},
|
||||||
|
sourceAccountID,
|
||||||
|
targetAccountID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *relationshipDB) getNote(ctx context.Context, lookup string, dbQuery func(*gtsmodel.AccountNote) error, keyParts ...any) (*gtsmodel.AccountNote, error) {
|
||||||
|
// Fetch note from cache with loader callback
|
||||||
|
note, err := r.state.Caches.GTS.AccountNote().Load(lookup, func() (*gtsmodel.AccountNote, error) {
|
||||||
|
var note gtsmodel.AccountNote
|
||||||
|
|
||||||
|
// Not cached! Perform database query
|
||||||
|
if err := dbQuery(¬e); err != nil {
|
||||||
|
return nil, r.conn.ProcessError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ¬e, nil
|
||||||
|
}, keyParts...)
|
||||||
|
if err != nil {
|
||||||
|
// already processed
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if gtscontext.Barebones(ctx) {
|
||||||
|
// Only a barebones model was requested.
|
||||||
|
return note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the note source account
|
||||||
|
note.Account, err = r.state.DB.GetAccountByID(
|
||||||
|
gtscontext.SetBarebones(ctx),
|
||||||
|
note.AccountID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting note source account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the note target account
|
||||||
|
note.TargetAccount, err = r.state.DB.GetAccountByID(
|
||||||
|
gtscontext.SetBarebones(ctx),
|
||||||
|
note.TargetAccountID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting note target account: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *relationshipDB) PutNote(ctx context.Context, note *gtsmodel.AccountNote) error {
|
||||||
|
note.UpdatedAt = time.Now()
|
||||||
|
return r.state.Caches.GTS.AccountNote().Store(note, func() error {
|
||||||
|
_, err := r.conn.
|
||||||
|
NewInsert().
|
||||||
|
Model(note).
|
||||||
|
On("CONFLICT (?, ?) DO UPDATE", bun.Ident("account_id"), bun.Ident("target_account_id")).
|
||||||
|
Set("? = ?, ? = ?", bun.Ident("updated_at"), note.UpdatedAt, bun.Ident("comment"), note.Comment).
|
||||||
|
Exec(ctx)
|
||||||
|
return r.conn.ProcessError(err)
|
||||||
|
})
|
||||||
|
}
|
|
@ -912,6 +912,53 @@ func (suite *RelationshipTestSuite) TestUpdateFollow() {
|
||||||
suite.True(relationship.Notifying)
|
suite.True(relationship.Notifying)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *RelationshipTestSuite) TestGetNote() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Retrieve a fixture note
|
||||||
|
account1 := suite.testAccounts["local_account_1"].ID
|
||||||
|
account2 := suite.testAccounts["local_account_2"].ID
|
||||||
|
expectedNote := suite.testAccountNotes["local_account_2_note_on_1"]
|
||||||
|
note, err := suite.db.GetNote(ctx, account2, account1)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotNil(note)
|
||||||
|
suite.Equal(expectedNote.ID, note.ID)
|
||||||
|
suite.Equal(expectedNote.Comment, note.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *RelationshipTestSuite) TestPutNote() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// put a note in
|
||||||
|
account1 := suite.testAccounts["local_account_1"].ID
|
||||||
|
account2 := suite.testAccounts["local_account_2"].ID
|
||||||
|
err := suite.db.PutNote(ctx, >smodel.AccountNote{
|
||||||
|
ID: "01H539R2NA0M83JX15Y5RWKE97",
|
||||||
|
AccountID: account1,
|
||||||
|
TargetAccountID: account2,
|
||||||
|
Comment: "foo",
|
||||||
|
})
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// make sure the note is in the db
|
||||||
|
note, err := suite.db.GetNote(ctx, account1, account2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotNil(note)
|
||||||
|
suite.Equal("01H539R2NA0M83JX15Y5RWKE97", note.ID)
|
||||||
|
suite.Equal("foo", note.Comment)
|
||||||
|
|
||||||
|
// update the note
|
||||||
|
note.Comment = "bar"
|
||||||
|
err = suite.db.PutNote(ctx, note)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// make sure the comment changes
|
||||||
|
note, err = suite.db.GetNote(ctx, account1, account2)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotNil(note)
|
||||||
|
suite.Equal("bar", note.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRelationshipTestSuite(t *testing.T) {
|
func TestRelationshipTestSuite(t *testing.T) {
|
||||||
suite.Run(t, new(RelationshipTestSuite))
|
suite.Run(t, new(RelationshipTestSuite))
|
||||||
}
|
}
|
||||||
|
|
|
@ -165,4 +165,10 @@ type Relationship interface {
|
||||||
|
|
||||||
// CountAccountFollowerRequests returns number of follow requests originating from the given account.
|
// CountAccountFollowerRequests returns number of follow requests originating from the given account.
|
||||||
CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error)
|
CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error)
|
||||||
|
|
||||||
|
// GetNote gets a private note from a source account on a target account, if it exists.
|
||||||
|
GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error)
|
||||||
|
|
||||||
|
// PutNote creates or updates a private note.
|
||||||
|
PutNote(ctx context.Context, note *gtsmodel.AccountNote) error
|
||||||
}
|
}
|
||||||
|
|
32
internal/gtsmodel/accountnote.go
Normal file
32
internal/gtsmodel/accountnote.go
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package gtsmodel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AccountNote stores a private note from a local account related to any account.
|
||||||
|
type AccountNote struct {
|
||||||
|
ID string `validate:"required,ulid" bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||||
|
CreatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||||
|
UpdatedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||||
|
AccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:account_notes_account_id_target_account_id_uniq,notnull,nullzero"` // ID of the local account that created the note
|
||||||
|
Account *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to accountID
|
||||||
|
TargetAccountID string `validate:"required,ulid" bun:"type:CHAR(26),unique:account_notes_account_id_target_account_id_uniq,notnull,nullzero"` // Who is the target of this note?
|
||||||
|
TargetAccount *Account `validate:"-" bun:"rel:belongs-to"` // Account corresponding to targetAccountID
|
||||||
|
Comment string `validate:"-" bun:""` // The text of the note.
|
||||||
|
}
|
48
internal/processing/account/note.go
Normal file
48
internal/processing/account/note.go
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
// 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PutNote updates the requesting account's private note on the target account.
|
||||||
|
func (p *Processor) PutNote(ctx context.Context, requestingAccount *gtsmodel.Account, targetAccountID string, comment string) (*apimodel.Relationship, gtserror.WithCode) {
|
||||||
|
targetAccount, errWithCode := p.Get(ctx, requestingAccount, targetAccountID)
|
||||||
|
if errWithCode != nil {
|
||||||
|
return nil, errWithCode
|
||||||
|
}
|
||||||
|
|
||||||
|
note := >smodel.AccountNote{
|
||||||
|
ID: id.NewULID(),
|
||||||
|
AccountID: requestingAccount.ID,
|
||||||
|
TargetAccountID: targetAccount.ID,
|
||||||
|
Comment: comment,
|
||||||
|
}
|
||||||
|
err := p.state.DB.PutNote(ctx, note)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.RelationshipGet(ctx, requestingAccount, targetAccount.ID)
|
||||||
|
}
|
|
@ -20,6 +20,9 @@ EXPECT=$(cat <<"EOF"
|
||||||
"cache": {
|
"cache": {
|
||||||
"gts": {
|
"gts": {
|
||||||
"account-max-size": 99,
|
"account-max-size": 99,
|
||||||
|
"account-note-max-size": 1000,
|
||||||
|
"account-note-sweep-freq": 60000000000,
|
||||||
|
"account-note-ttl": 1800000000000,
|
||||||
"account-sweep-freq": 1000000000,
|
"account-sweep-freq": 1000000000,
|
||||||
"account-ttl": 10800000000000,
|
"account-ttl": 10800000000000,
|
||||||
"block-max-size": 1000,
|
"block-max-size": 1000,
|
||||||
|
|
|
@ -60,6 +60,7 @@
|
||||||
>smodel.EmojiCategory{},
|
>smodel.EmojiCategory{},
|
||||||
>smodel.Tombstone{},
|
>smodel.Tombstone{},
|
||||||
>smodel.Report{},
|
>smodel.Report{},
|
||||||
|
>smodel.AccountNote{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestDB returns a new initialized, empty database for testing.
|
// NewTestDB returns a new initialized, empty database for testing.
|
||||||
|
@ -280,6 +281,12 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, v := range NewTestAccountNotes() {
|
||||||
|
if err := db.Put(ctx, v); err != nil {
|
||||||
|
log.Panic(nil, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.CreateInstanceAccount(ctx); err != nil {
|
if err := db.CreateInstanceAccount(ctx); err != nil {
|
||||||
log.Panic(nil, err)
|
log.Panic(nil, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1893,6 +1893,18 @@ func NewTestFaves() map[string]*gtsmodel.StatusFave {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTestAccountNotes returns some account notes for use in testing.
|
||||||
|
func NewTestAccountNotes() map[string]*gtsmodel.AccountNote {
|
||||||
|
return map[string]*gtsmodel.AccountNote{
|
||||||
|
"local_account_2_note_on_1": {
|
||||||
|
ID: "01H53TM628GNC4ZDNRGQGPK8S0",
|
||||||
|
AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF",
|
||||||
|
TargetAccountID: "01F8MH1H7YV1Z7D2C8K2730QBF",
|
||||||
|
Comment: "extremely average poster",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewTestNotifications returns some notifications for use in testing.
|
// NewTestNotifications returns some notifications for use in testing.
|
||||||
func NewTestNotifications() map[string]*gtsmodel.Notification {
|
func NewTestNotifications() map[string]*gtsmodel.Notification {
|
||||||
return map[string]*gtsmodel.Notification{
|
return map[string]*gtsmodel.Notification{
|
||||||
|
|
Loading…
Reference in a new issue