summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-07-17 16:46:52 +0200
committerLibravatar GitHub <noreply@github.com>2024-07-17 16:46:52 +0200
commit0aadc2db2a42fc99538fbbb096b84b209b9ccd68 (patch)
tree38c58d163004d43da80d33477a6e9f22547bdb15 /internal
parentgive read-only access to /dev for ffmpeg to access /dev/urandom (#3109) (diff)
downloadgotosocial-0aadc2db2a42fc99538fbbb096b84b209b9ccd68.tar.xz
[feature] Allow users to set default interaction policies per status visibility (#3108)
* [feature] Allow users to set default interaction policies * use vars for default policies * avoid some code repetition * unfuck form binding * avoid bonkers loop * beep boop * put policyValsToAPIPolicyVals in separate function * don't bother with slices.Grow * oops
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client.go108
-rw-r--r--internal/api/client/admin/reportsget_test.go66
-rw-r--r--internal/api/client/interactionpolicies/getdefaults.go77
-rw-r--r--internal/api/client/interactionpolicies/policies.go45
-rw-r--r--internal/api/client/interactionpolicies/updatedefaults.go334
-rw-r--r--internal/api/client/statuses/statusmute_test.go44
-rw-r--r--internal/api/model/interactionpolicy.go111
-rw-r--r--internal/api/model/status.go2
-rw-r--r--internal/gtsmodel/interactionpolicy.go190
-rw-r--r--internal/processing/account/interactionpolicies.go208
-rw-r--r--internal/processing/status/create.go85
-rw-r--r--internal/processing/stream/statusupdate_test.go22
-rw-r--r--internal/typeutils/frontendtointernal.go172
-rw-r--r--internal/typeutils/internaltofrontend.go123
-rw-r--r--internal/typeutils/internaltofrontend_test.go224
15 files changed, 1627 insertions, 184 deletions
diff --git a/internal/api/client.go b/internal/api/client.go
index 07fc82aaa..b8b226804 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -34,6 +34,7 @@ import (
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
+ "github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
"github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
"github.com/superseriousbusiness/gotosocial/internal/api/client/markers"
"github.com/superseriousbusiness/gotosocial/internal/api/client/media"
@@ -58,32 +59,33 @@ type Client struct {
processor *processing.Processor
db db.DB
- accounts *accounts.Module // api/v1/accounts
- admin *admin.Module // api/v1/admin
- apps *apps.Module // api/v1/apps
- blocks *blocks.Module // api/v1/blocks
- bookmarks *bookmarks.Module // api/v1/bookmarks
- conversations *conversations.Module // api/v1/conversations
- customEmojis *customemojis.Module // api/v1/custom_emojis
- favourites *favourites.Module // api/v1/favourites
- featuredTags *featuredtags.Module // api/v1/featured_tags
- filtersV1 *filtersV1.Module // api/v1/filters
- filtersV2 *filtersV2.Module // api/v2/filters
- followRequests *followrequests.Module // api/v1/follow_requests
- instance *instance.Module // api/v1/instance
- lists *lists.Module // api/v1/lists
- markers *markers.Module // api/v1/markers
- media *media.Module // api/v1/media, api/v2/media
- mutes *mutes.Module // api/v1/mutes
- notifications *notifications.Module // api/v1/notifications
- polls *polls.Module // api/v1/polls
- preferences *preferences.Module // api/v1/preferences
- reports *reports.Module // api/v1/reports
- search *search.Module // api/v1/search, api/v2/search
- statuses *statuses.Module // api/v1/statuses
- streaming *streaming.Module // api/v1/streaming
- timelines *timelines.Module // api/v1/timelines
- user *user.Module // api/v1/user
+ accounts *accounts.Module // api/v1/accounts
+ admin *admin.Module // api/v1/admin
+ apps *apps.Module // api/v1/apps
+ blocks *blocks.Module // api/v1/blocks
+ bookmarks *bookmarks.Module // api/v1/bookmarks
+ conversations *conversations.Module // api/v1/conversations
+ customEmojis *customemojis.Module // api/v1/custom_emojis
+ favourites *favourites.Module // api/v1/favourites
+ featuredTags *featuredtags.Module // api/v1/featured_tags
+ filtersV1 *filtersV1.Module // api/v1/filters
+ filtersV2 *filtersV2.Module // api/v2/filters
+ followRequests *followrequests.Module // api/v1/follow_requests
+ instance *instance.Module // api/v1/instance
+ interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
+ lists *lists.Module // api/v1/lists
+ markers *markers.Module // api/v1/markers
+ media *media.Module // api/v1/media, api/v2/media
+ mutes *mutes.Module // api/v1/mutes
+ notifications *notifications.Module // api/v1/notifications
+ polls *polls.Module // api/v1/polls
+ preferences *preferences.Module // api/v1/preferences
+ reports *reports.Module // api/v1/reports
+ search *search.Module // api/v1/search, api/v2/search
+ statuses *statuses.Module // api/v1/statuses
+ streaming *streaming.Module // api/v1/streaming
+ timelines *timelines.Module // api/v1/timelines
+ user *user.Module // api/v1/user
}
func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
@@ -116,6 +118,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.filtersV2.Route(h)
c.followRequests.Route(h)
c.instance.Route(h)
+ c.interactionPolicies.Route(h)
c.lists.Route(h)
c.markers.Route(h)
c.media.Route(h)
@@ -136,31 +139,32 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
processor: p,
db: state.DB,
- accounts: accounts.New(p),
- admin: admin.New(state, p),
- apps: apps.New(p),
- blocks: blocks.New(p),
- bookmarks: bookmarks.New(p),
- conversations: conversations.New(p),
- customEmojis: customemojis.New(p),
- favourites: favourites.New(p),
- featuredTags: featuredtags.New(p),
- filtersV1: filtersV1.New(p),
- filtersV2: filtersV2.New(p),
- followRequests: followrequests.New(p),
- instance: instance.New(p),
- lists: lists.New(p),
- markers: markers.New(p),
- media: media.New(p),
- mutes: mutes.New(p),
- notifications: notifications.New(p),
- polls: polls.New(p),
- preferences: preferences.New(p),
- reports: reports.New(p),
- search: search.New(p),
- statuses: statuses.New(p),
- streaming: streaming.New(p, time.Second*30, 4096),
- timelines: timelines.New(p),
- user: user.New(p),
+ accounts: accounts.New(p),
+ admin: admin.New(state, p),
+ apps: apps.New(p),
+ blocks: blocks.New(p),
+ bookmarks: bookmarks.New(p),
+ conversations: conversations.New(p),
+ customEmojis: customemojis.New(p),
+ favourites: favourites.New(p),
+ featuredTags: featuredtags.New(p),
+ filtersV1: filtersV1.New(p),
+ filtersV2: filtersV2.New(p),
+ followRequests: followrequests.New(p),
+ instance: instance.New(p),
+ interactionPolicies: interactionpolicies.New(p),
+ lists: lists.New(p),
+ markers: markers.New(p),
+ media: media.New(p),
+ mutes: mutes.New(p),
+ notifications: notifications.New(p),
+ polls: polls.New(p),
+ preferences: preferences.New(p),
+ reports: reports.New(p),
+ search: search.New(p),
+ statuses: statuses.New(p),
+ streaming: streaming.New(p, time.Second*30, 4096),
+ timelines: timelines.New(p),
+ user: user.New(p),
}
}
diff --git a/internal/api/client/admin/reportsget_test.go b/internal/api/client/admin/reportsget_test.go
index 28efabc00..265920409 100644
--- a/internal/api/client/admin/reportsget_test.go
+++ b/internal/api/client/admin/reportsget_test.go
@@ -528,7 +528,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}
],
"rules": [
@@ -750,7 +770,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}
],
"rules": [
@@ -972,7 +1012,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}
],
"rules": [
diff --git a/internal/api/client/interactionpolicies/getdefaults.go b/internal/api/client/interactionpolicies/getdefaults.go
new file mode 100644
index 000000000..4ad0071f4
--- /dev/null
+++ b/internal/api/client/interactionpolicies/getdefaults.go
@@ -0,0 +1,77 @@
+// 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 interactionpolicies
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PoliciesDefaultsGETHandler swagger:operation GET /api/v1/interaction_policies/defaults policiesDefaultsGet
+//
+// Get default interaction policies for new statuses created by you.
+//
+// ---
+// tags:
+// - interaction_policies
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - read:accounts
+//
+// responses:
+// '200':
+// description: A default policies object containing a policy for each status visibility.
+// schema:
+// "$ref": "#/definitions/defaultPolicies"
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) PoliciesDefaultsGETHandler(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
+ }
+
+ resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesGet(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, resp)
+}
diff --git a/internal/api/client/interactionpolicies/policies.go b/internal/api/client/interactionpolicies/policies.go
new file mode 100644
index 000000000..9b34a8c80
--- /dev/null
+++ b/internal/api/client/interactionpolicies/policies.go
@@ -0,0 +1,45 @@
+// 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 interactionpolicies
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ BasePath = "/v1/interaction_policies"
+ DefaultsPath = BasePath + "/defaults"
+)
+
+type Module struct {
+ processor *processing.Processor
+}
+
+func New(processor *processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ attachHandler(http.MethodGet, DefaultsPath, m.PoliciesDefaultsGETHandler)
+ attachHandler(http.MethodPatch, DefaultsPath, m.PoliciesDefaultsPATCHHandler)
+}
diff --git a/internal/api/client/interactionpolicies/updatedefaults.go b/internal/api/client/interactionpolicies/updatedefaults.go
new file mode 100644
index 000000000..e11a3bd19
--- /dev/null
+++ b/internal/api/client/interactionpolicies/updatedefaults.go
@@ -0,0 +1,334 @@
+// 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 interactionpolicies
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/gin-gonic/gin/binding"
+ "github.com/go-playground/form/v4"
+ 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"
+)
+
+// PoliciesDefaultsPATCHHandler swagger:operation PATCH /api/v1/interaction_policies/defaults policiesDefaultsUpdate
+//
+// Update default interaction policies per visibility level for new statuses created by you.
+//
+// If submitting using form data, use the following pattern:
+//
+// `VISIBILITY[INTERACTION_TYPE][CONDITION][INDEX]=Value`
+//
+// For example: `public[can_reply][always][0]=author`
+//
+// Using `curl` this might look something like:
+//
+// `curl -F 'public[can_reply][always][0]=author' -F 'public[can_reply][always][1]=followers'`
+//
+// The JSON equivalent would be:
+//
+// `curl -H 'Content-Type: application/json' -d '{"public":{"can_reply":{"always":["author","followers"]}}}'`
+//
+// Any visibility level left unspecified in the request body will be returned to the default.
+//
+// Ie., in the example above, "public" would be updated, but "unlisted", "private", and "direct" would be reset to defaults.
+//
+// The server will perform some normalization on submitted policies so that you can't submit totally invalid policies.
+//
+// ---
+// tags:
+// - interaction_policies
+//
+// consumes:
+// - multipart/form-data
+// - application/x-www-form-urlencoded
+// - application/json
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: public[can_favourite][always][0]
+// in: formData
+// description: Nth entry for public.can_favourite.always.
+// type: string
+// -
+// name: public[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for public.can_favourite.with_approval.
+// type: string
+// -
+// name: public[can_reply][always][0]
+// in: formData
+// description: Nth entry for public.can_reply.always.
+// type: string
+// -
+// name: public[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for public.can_reply.with_approval.
+// type: string
+// -
+// name: public[can_reblog][always][0]
+// in: formData
+// description: Nth entry for public.can_reblog.always.
+// type: string
+// -
+// name: public[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for public.can_reblog.with_approval.
+// type: string
+//
+// -
+// name: unlisted[can_favourite][always][0]
+// in: formData
+// description: Nth entry for unlisted.can_favourite.always.
+// type: string
+// -
+// name: unlisted[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for unlisted.can_favourite.with_approval.
+// type: string
+// -
+// name: unlisted[can_reply][always][0]
+// in: formData
+// description: Nth entry for unlisted.can_reply.always.
+// type: string
+// -
+// name: unlisted[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for unlisted.can_reply.with_approval.
+// type: string
+// -
+// name: unlisted[can_reblog][always][0]
+// in: formData
+// description: Nth entry for unlisted.can_reblog.always.
+// type: string
+// -
+// name: unlisted[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for unlisted.can_reblog.with_approval.
+// type: string
+//
+// -
+// name: private[can_favourite][always][0]
+// in: formData
+// description: Nth entry for private.can_favourite.always.
+// type: string
+// -
+// name: private[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for private.can_favourite.with_approval.
+// type: string
+// -
+// name: private[can_reply][always][0]
+// in: formData
+// description: Nth entry for private.can_reply.always.
+// type: string
+// -
+// name: private[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for private.can_reply.with_approval.
+// type: string
+// -
+// name: private[can_reblog][always][0]
+// in: formData
+// description: Nth entry for private.can_reblog.always.
+// type: string
+// -
+// name: private[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for private.can_reblog.with_approval.
+// type: string
+//
+// -
+// name: direct[can_favourite][always][0]
+// in: formData
+// description: Nth entry for direct.can_favourite.always.
+// type: string
+// -
+// name: direct[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for direct.can_favourite.with_approval.
+// type: string
+// -
+// name: direct[can_reply][always][0]
+// in: formData
+// description: Nth entry for direct.can_reply.always.
+// type: string
+// -
+// name: direct[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for direct.can_reply.with_approval.
+// type: string
+// -
+// name: direct[can_reblog][always][0]
+// in: formData
+// description: Nth entry for direct.can_reblog.always.
+// type: string
+// -
+// name: direct[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for direct.can_reblog.with_approval.
+// type: string
+//
+// security:
+// - OAuth2 Bearer:
+// - write:accounts
+//
+// responses:
+// '200':
+// description: Updated default policies object containing a policy for each status visibility.
+// schema:
+// "$ref": "#/definitions/defaultPolicies"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '422':
+// description: unprocessable
+// '500':
+// description: internal server error
+func (m *Module) PoliciesDefaultsPATCHHandler(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
+ }
+
+ form, err := parseUpdateAccountForm(c)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesUpdate(
+ c.Request.Context(),
+ authed.Account,
+ form,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, resp)
+}
+
+// intPolicyFormBinding satisfies gin's binding.Binding interface.
+// Should only be used specifically for multipart/form-data MIME type.
+type intPolicyFormBinding struct {
+ visibility string
+}
+
+func (i intPolicyFormBinding) Name() string {
+ return i.visibility
+}
+
+func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
+ if err := req.ParseForm(); err != nil {
+ return err
+ }
+
+ // Change default namespace prefix and suffix to
+ // allow correct parsing of the field attributes.
+ decoder := form.NewDecoder()
+ decoder.SetNamespacePrefix("[")
+ decoder.SetNamespaceSuffix("]")
+
+ return decoder.Decode(obj, req.Form)
+}
+
+// customBind does custom form binding for
+// each visibility in the form data.
+func customBind(
+ c *gin.Context,
+ form *apimodel.UpdateInteractionPoliciesRequest,
+) error {
+ for _, vis := range []string{
+ "Direct",
+ "Private",
+ "Unlisted",
+ "Public",
+ } {
+ if err := c.ShouldBindWith(
+ form,
+ intPolicyFormBinding{
+ visibility: vis,
+ },
+ ); err != nil {
+ return fmt.Errorf("custom form binding failed: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) {
+ form := new(apimodel.UpdateInteractionPoliciesRequest)
+
+ switch ct := c.ContentType(); ct {
+ case binding.MIMEJSON:
+ // Just bind with default json binding.
+ if err := c.ShouldBindWith(form, binding.JSON); err != nil {
+ return nil, err
+ }
+
+ case binding.MIMEPOSTForm:
+ // Bind with default form binding first.
+ if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
+ return nil, err
+ }
+
+ // Now do custom binding.
+ if err := customBind(c, form); err != nil {
+ return nil, err
+ }
+
+ case binding.MIMEMultipartPOSTForm:
+ // Bind with default form binding first.
+ if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
+ return nil, err
+ }
+
+ // Now do custom binding.
+ if err := customBind(c, form); err != nil {
+ return nil, err
+ }
+
+ default:
+ err := fmt.Errorf(
+ "content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
+ ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
+ )
+ return nil, err
+ }
+
+ return form, nil
+}
diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go
index 01bea4e5c..9e517b36d 100644
--- a/internal/api/client/statuses/statusmute_test.go
+++ b/internal/api/client/statuses/statusmute_test.go
@@ -147,7 +147,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"emojis": [],
"card": null,
"poll": null,
- "text": "hello everyone!"
+ "text": "hello everyone!",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, muted)
// Unmute the status, ensure `muted` is `false`.
@@ -212,7 +232,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"emojis": [],
"card": null,
"poll": null,
- "text": "hello everyone!"
+ "text": "hello everyone!",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, unmuted)
}
diff --git a/internal/api/model/interactionpolicy.go b/internal/api/model/interactionpolicy.go
new file mode 100644
index 000000000..7c5df09e8
--- /dev/null
+++ b/internal/api/model/interactionpolicy.go
@@ -0,0 +1,111 @@
+// 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 model
+
+// One interaction policy entry for a status.
+//
+// It can be EITHER one of the internal keywords listed below, OR a full-fledged ActivityPub URI of an Actor, like "https://example.org/users/some_user".
+//
+// Internal keywords:
+//
+// - public - Public, aka anyone who can see the status according to its visibility level.
+// - followers - Followers of the status author.
+// - following - People followed by the status author.
+// - mutuals - Mutual follows of the status author (reserved, unused).
+// - mentioned - Accounts mentioned in, or replied-to by, the status.
+// - author - The status author themself.
+// - me - If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
+//
+// swagger:model interactionPolicyValue
+type PolicyValue string
+
+const (
+ PolicyValuePublic PolicyValue = "public" // Public, aka anyone who can see the status according to its visibility level.
+ PolicyValueFollowers PolicyValue = "followers" // Followers of the status author.
+ PolicyValueFollowing PolicyValue = "following" // People followed by the status author.
+ PolicyValueMutuals PolicyValue = "mutuals" // Mutual follows of the status author (reserved, unused).
+ PolicyValueMentioned PolicyValue = "mentioned" // Accounts mentioned in, or replied-to by, the status.
+ PolicyValueAuthor PolicyValue = "author" // The status author themself.
+ PolicyValueMe PolicyValue = "me" // If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
+)
+
+// Rules for one interaction type.
+//
+// swagger:model interactionPolicyRules
+type PolicyRules struct {
+ // Policy entries for accounts that can always do this type of interaction.
+ Always []PolicyValue `form:"always" json:"always"`
+ // Policy entries for accounts that require approval to do this type of interaction.
+ WithApproval []PolicyValue `form:"with_approval" json:"with_approval"`
+}
+
+// Interaction policy of a status.
+//
+// swagger:model interactionPolicy
+type InteractionPolicy struct {
+ // Rules for who can favourite this status.
+ CanFavourite PolicyRules `form:"can_favourite" json:"can_favourite"`
+ // Rules for who can reply to this status.
+ CanReply PolicyRules `form:"can_reply" json:"can_reply"`
+ // Rules for who can reblog this status.
+ CanReblog PolicyRules `form:"can_reblog" json:"can_reblog"`
+}
+
+// Default interaction policies to use for new statuses by requesting account.
+//
+// swagger:model defaultPolicies
+type DefaultPolicies struct {
+ // TODO: Add mutuals only default.
+
+ // Default policy for new direct visibility statuses.
+ Direct InteractionPolicy `json:"direct"`
+ // Default policy for new private/followers-only visibility statuses.
+ Private InteractionPolicy `json:"private"`
+ // Default policy for new unlisted/unlocked visibility statuses.
+ Unlisted InteractionPolicy `json:"unlisted"`
+ // Default policy for new public visibility statuses.
+ Public InteractionPolicy `json:"public"`
+}
+
+// swagger:ignore
+type UpdateInteractionPoliciesRequest struct {
+ // Default policy for new direct visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Direct *InteractionPolicy `form:"direct" json:"direct"`
+ // Default policy for new private/followers-only visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Private *InteractionPolicy `form:"private" json:"private"`
+ // Default policy for new unlisted/unlocked visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Unlisted *InteractionPolicy `form:"unlisted" json:"unlisted"`
+ // Default policy for new public visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Public *InteractionPolicy `form:"public" json:"public"`
+}
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index e469835bd..7358916ab 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -102,6 +102,8 @@ type Status struct {
Text string `json:"text,omitempty"`
// A list of filters that matched this status and why they matched, if there are any such filters.
Filtered []FilterResult `json:"filtered,omitempty"`
+ // The interaction policy for this status, as set by the status author.
+ InteractionPolicy InteractionPolicy `json:"interaction_policy"`
}
// WebStatus is like *model.Status, but contains
diff --git a/internal/gtsmodel/interactionpolicy.go b/internal/gtsmodel/interactionpolicy.go
index ecb525b47..993763dc3 100644
--- a/internal/gtsmodel/interactionpolicy.go
+++ b/internal/gtsmodel/interactionpolicy.go
@@ -180,135 +180,109 @@ func DefaultInteractionPolicyFor(v Visibility) *InteractionPolicy {
}
}
-// Returns the default interaction policy
-// for a post with visibility of public.
-func DefaultInteractionPolicyPublic() *InteractionPolicy {
- // Anyone can like.
- canLikeAlways := make(PolicyValues, 1)
- canLikeAlways[0] = PolicyValuePublic
-
- // Unused, set empty.
- canLikeWithApproval := make(PolicyValues, 0)
-
- // Anyone can reply.
- canReplyAlways := make(PolicyValues, 1)
- canReplyAlways[0] = PolicyValuePublic
-
- // Unused, set empty.
- canReplyWithApproval := make(PolicyValues, 0)
-
- // Anyone can announce.
- canAnnounceAlways := make(PolicyValues, 1)
- canAnnounceAlways[0] = PolicyValuePublic
-
- // Unused, set empty.
- canAnnounceWithApproval := make(PolicyValues, 0)
-
- return &InteractionPolicy{
- CanLike: PolicyRules{
- Always: canLikeAlways,
- WithApproval: canLikeWithApproval,
+var defaultPolicyPublic = &InteractionPolicy{
+ CanLike: PolicyRules{
+ // Anyone can like.
+ Always: PolicyValues{
+ PolicyValuePublic,
},
- CanReply: PolicyRules{
- Always: canReplyAlways,
- WithApproval: canReplyWithApproval,
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanReply: PolicyRules{
+ // Anyone can reply.
+ Always: PolicyValues{
+ PolicyValuePublic,
},
- CanAnnounce: PolicyRules{
- Always: canAnnounceAlways,
- WithApproval: canAnnounceWithApproval,
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanAnnounce: PolicyRules{
+ // Anyone can announce.
+ Always: PolicyValues{
+ PolicyValuePublic,
},
- }
+ WithApproval: make(PolicyValues, 0),
+ },
+}
+
+// Returns the default interaction policy
+// for a post with visibility of public.
+func DefaultInteractionPolicyPublic() *InteractionPolicy {
+ return defaultPolicyPublic
}
// Returns the default interaction policy
// for a post with visibility of unlocked.
func DefaultInteractionPolicyUnlocked() *InteractionPolicy {
// Same as public (for now).
- return DefaultInteractionPolicyPublic()
+ return defaultPolicyPublic
+}
+
+var defaultPolicyFollowersOnly = &InteractionPolicy{
+ CanLike: PolicyRules{
+ // Self, followers and
+ // mentioned can like.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueFollowers,
+ PolicyValueMentioned,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanReply: PolicyRules{
+ // Self, followers and
+ // mentioned can reply.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueFollowers,
+ PolicyValueMentioned,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanAnnounce: PolicyRules{
+ // Only self can announce.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
}
// Returns the default interaction policy for
// a post with visibility of followers only.
func DefaultInteractionPolicyFollowersOnly() *InteractionPolicy {
- // Self, followers and mentioned can like.
- canLikeAlways := make(PolicyValues, 3)
- canLikeAlways[0] = PolicyValueAuthor
- canLikeAlways[1] = PolicyValueFollowers
- canLikeAlways[2] = PolicyValueMentioned
-
- // Unused, set empty.
- canLikeWithApproval := make(PolicyValues, 0)
-
- // Self, followers and mentioned can reply.
- canReplyAlways := make(PolicyValues, 3)
- canReplyAlways[0] = PolicyValueAuthor
- canReplyAlways[1] = PolicyValueFollowers
- canReplyAlways[2] = PolicyValueMentioned
-
- // Unused, set empty.
- canReplyWithApproval := make(PolicyValues, 0)
-
- // Only self can announce.
- canAnnounceAlways := make(PolicyValues, 1)
- canAnnounceAlways[0] = PolicyValueAuthor
-
- // Unused, set empty.
- canAnnounceWithApproval := make(PolicyValues, 0)
+ return defaultPolicyFollowersOnly
+}
- return &InteractionPolicy{
- CanLike: PolicyRules{
- Always: canLikeAlways,
- WithApproval: canLikeWithApproval,
+var defaultPolicyDirect = &InteractionPolicy{
+ CanLike: PolicyRules{
+ // Mentioned and self
+ // can always like.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueMentioned,
},
- CanReply: PolicyRules{
- Always: canReplyAlways,
- WithApproval: canReplyWithApproval,
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanReply: PolicyRules{
+ // Mentioned and self
+ // can always reply.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueMentioned,
},
- CanAnnounce: PolicyRules{
- Always: canAnnounceAlways,
- WithApproval: canAnnounceWithApproval,
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanAnnounce: PolicyRules{
+ // Only self can announce.
+ Always: PolicyValues{
+ PolicyValueAuthor,
},
- }
+ WithApproval: make(PolicyValues, 0),
+ },
}
// Returns the default interaction policy
// for a post with visibility of direct.
func DefaultInteractionPolicyDirect() *InteractionPolicy {
- // Mentioned and self can always like.
- canLikeAlways := make(PolicyValues, 2)
- canLikeAlways[0] = PolicyValueAuthor
- canLikeAlways[1] = PolicyValueMentioned
-
- // Unused, set empty.
- canLikeWithApproval := make(PolicyValues, 0)
-
- // Mentioned and self can always reply.
- canReplyAlways := make(PolicyValues, 2)
- canReplyAlways[0] = PolicyValueAuthor
- canReplyAlways[1] = PolicyValueMentioned
-
- // Unused, set empty.
- canReplyWithApproval := make(PolicyValues, 0)
-
- // Only self can announce.
- canAnnounceAlways := make(PolicyValues, 1)
- canAnnounceAlways[0] = PolicyValueAuthor
-
- // Unused, set empty.
- canAnnounceWithApproval := make(PolicyValues, 0)
-
- return &InteractionPolicy{
- CanLike: PolicyRules{
- Always: canLikeAlways,
- WithApproval: canLikeWithApproval,
- },
- CanReply: PolicyRules{
- Always: canReplyAlways,
- WithApproval: canReplyWithApproval,
- },
- CanAnnounce: PolicyRules{
- Always: canAnnounceAlways,
- WithApproval: canAnnounceWithApproval,
- },
- }
+ return defaultPolicyDirect
}
diff --git a/internal/processing/account/interactionpolicies.go b/internal/processing/account/interactionpolicies.go
new file mode 100644
index 000000000..e02b43e9e
--- /dev/null
+++ b/internal/processing/account/interactionpolicies.go
@@ -0,0 +1,208 @@
+// 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 (
+ "cmp"
+ "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/typeutils"
+)
+
+func (p *Processor) DefaultInteractionPoliciesGet(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) (*apimodel.DefaultPolicies, gtserror.WithCode) {
+ // Ensure account settings populated.
+ if err := p.populateAccountSettings(ctx, requester); err != nil {
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "direct" policy
+ // or global default.
+ direct := cmp.Or(
+ requester.Settings.InteractionPolicyDirect,
+ gtsmodel.DefaultInteractionPolicyDirect(),
+ )
+
+ directAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, direct, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy direct: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "private" policy
+ // or global default.
+ private := cmp.Or(
+ requester.Settings.InteractionPolicyFollowersOnly,
+ gtsmodel.DefaultInteractionPolicyFollowersOnly(),
+ )
+
+ privateAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, private, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy private: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "unlisted" policy
+ // or global default.
+ unlisted := cmp.Or(
+ requester.Settings.InteractionPolicyUnlocked,
+ gtsmodel.DefaultInteractionPolicyUnlocked(),
+ )
+
+ unlistedAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, unlisted, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy unlisted: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "public" policy
+ // or global default.
+ public := cmp.Or(
+ requester.Settings.InteractionPolicyPublic,
+ gtsmodel.DefaultInteractionPolicyPublic(),
+ )
+
+ publicAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, public, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy public: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return &apimodel.DefaultPolicies{
+ Direct: *directAPI,
+ Private: *privateAPI,
+ Unlisted: *unlistedAPI,
+ Public: *publicAPI,
+ }, nil
+}
+
+func (p *Processor) DefaultInteractionPoliciesUpdate(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ form *apimodel.UpdateInteractionPoliciesRequest,
+) (*apimodel.DefaultPolicies, gtserror.WithCode) {
+ // Lock on this account as we're modifying its Settings.
+ unlock := p.state.ProcessingLocks.Lock(requester.URI)
+ defer unlock()
+
+ // Ensure account settings populated.
+ if err := p.populateAccountSettings(ctx, requester); err != nil {
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if form.Direct == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyDirect = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Direct,
+ apimodel.VisibilityDirect,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyDirect = policy
+ }
+
+ if form.Private == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyFollowersOnly = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Private,
+ apimodel.VisibilityPrivate,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyFollowersOnly = policy
+ }
+
+ if form.Unlisted == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyUnlocked = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Unlisted,
+ apimodel.VisibilityUnlisted,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyUnlocked = policy
+ }
+
+ if form.Public == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyPublic = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Public,
+ apimodel.VisibilityPublic,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyPublic = policy
+ }
+
+ if err := p.state.DB.UpdateAccountSettings(ctx, requester.Settings); err != nil {
+ err := gtserror.Newf("db error updating setttings: %w", err)
+ return nil, gtserror.NewErrorInternalError(err, err.Error())
+ }
+
+ return p.DefaultInteractionPoliciesGet(ctx, requester)
+}
+
+// populateAccountSettings just ensures that
+// Settings is populated on the given account.
+func (p *Processor) populateAccountSettings(
+ ctx context.Context,
+ acct *gtsmodel.Account,
+) error {
+ if acct.Settings != nil {
+ // Already populated.
+ return nil
+ }
+
+ // Not populated,
+ // get from db.
+ var err error
+ acct.Settings, err = p.state.DB.GetAccountSettings(ctx, acct.ID)
+ if err != nil {
+ return gtserror.Newf(
+ "db error getting settings for account %s: %w",
+ acct.ID, err,
+ )
+ }
+
+ return nil
+}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 8898181ae..a5978a999 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -121,6 +121,12 @@ func (p *Processor) Create(
return nil, gtserror.NewErrorInternalError(err)
}
+ // Process policy AFTER visibility as it
+ // relies on status.Visibility being set.
+ if err := processInteractionPolicy(form, requester.Settings, status); err != nil {
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
if err := processLanguage(form, requester.Settings.Language, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@@ -281,26 +287,79 @@ func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.Advanced
return nil
}
-func processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
- // by default all flags are set to true
- federated := true
-
- // If visibility isn't set on the form, then just take the account default.
- // If that's also not set, take the default for the whole instance.
- var vis gtsmodel.Visibility
+func processVisibility(
+ form *apimodel.AdvancedStatusCreateForm,
+ accountDefaultVis gtsmodel.Visibility,
+ status *gtsmodel.Status,
+) error {
switch {
+ // Visibility set on form, use that.
case form.Visibility != "":
- vis = typeutils.APIVisToVis(form.Visibility)
+ status.Visibility = typeutils.APIVisToVis(form.Visibility)
+
+ // Fall back to account default.
case accountDefaultVis != "":
- vis = accountDefaultVis
+ status.Visibility = accountDefaultVis
+
+ // What? Fall back to global default.
default:
- vis = gtsmodel.VisibilityDefault
+ status.Visibility = gtsmodel.VisibilityDefault
}
- // Todo: sort out likeable/replyable/boostable in next PR.
-
- status.Visibility = vis
+ // Set federated flag to form value
+ // if provided, or default to true.
+ federated := util.PtrValueOr(form.Federated, true)
status.Federated = &federated
+
+ return nil
+}
+
+func processInteractionPolicy(
+ _ *apimodel.AdvancedStatusCreateForm,
+ settings *gtsmodel.AccountSettings,
+ status *gtsmodel.Status,
+) error {
+ // TODO: parse policy for this
+ // status from form and prefer this.
+
+ // TODO: prevent scope widening by
+ // limiting interaction policy if
+ // inReplyTo status has a stricter
+ // interaction policy than this one.
+
+ switch status.Visibility {
+
+ case gtsmodel.VisibilityPublic:
+ // Take account's default "public" policy if set.
+ if p := settings.InteractionPolicyPublic; p != nil {
+ status.InteractionPolicy = p
+ }
+
+ case gtsmodel.VisibilityUnlocked:
+ // Take account's default "unlisted" policy if set.
+ if p := settings.InteractionPolicyUnlocked; p != nil {
+ status.InteractionPolicy = p
+ }
+
+ case gtsmodel.VisibilityFollowersOnly,
+ gtsmodel.VisibilityMutualsOnly:
+ // Take account's default followers-only policy if set.
+ // TODO: separate policy for mutuals-only vis.
+ if p := settings.InteractionPolicyFollowersOnly; p != nil {
+ status.InteractionPolicy = p
+ }
+
+ case gtsmodel.VisibilityDirect:
+ // Take account's default direct policy if set.
+ if p := settings.InteractionPolicyDirect; p != nil {
+ status.InteractionPolicy = p
+ }
+ }
+
+ // If no policy set by now, status interaction
+ // policy will be stored as nil, which just means
+ // "fall back to global default policy". We avoid
+ // setting it explicitly to save space.
return nil
}
diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go
index 359212ee6..38be9ea5e 100644
--- a/internal/processing/stream/statusupdate_test.go
+++ b/internal/processing/stream/statusupdate_test.go
@@ -129,7 +129,27 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, dst.String())
suite.Equal(msg.Event, "status.update")
}
diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go
index f194770df..8ced14d58 100644
--- a/internal/typeutils/frontendtointernal.go
+++ b/internal/typeutils/frontendtointernal.go
@@ -18,6 +18,10 @@
package typeutils
import (
+ "fmt"
+ "net/url"
+ "slices"
+
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
@@ -57,3 +61,171 @@ func APIFilterActionToFilterAction(m apimodel.FilterAction) gtsmodel.FilterActio
}
return gtsmodel.FilterActionNone
}
+
+func APIPolicyValueToPolicyValue(u apimodel.PolicyValue) (gtsmodel.PolicyValue, error) {
+ switch u {
+ case apimodel.PolicyValuePublic:
+ return gtsmodel.PolicyValuePublic, nil
+
+ case apimodel.PolicyValueFollowers:
+ return gtsmodel.PolicyValueFollowers, nil
+
+ case apimodel.PolicyValueFollowing:
+ return gtsmodel.PolicyValueFollowing, nil
+
+ case apimodel.PolicyValueMutuals:
+ return gtsmodel.PolicyValueMutuals, nil
+
+ case apimodel.PolicyValueMentioned:
+ return gtsmodel.PolicyValueMentioned, nil
+
+ case apimodel.PolicyValueAuthor:
+ return gtsmodel.PolicyValueAuthor, nil
+
+ case apimodel.PolicyValueMe:
+ err := fmt.Errorf("policyURI %s has no corresponding internal model", apimodel.PolicyValueMe)
+ return "", err
+
+ default:
+ // Parse URI to ensure it's a
+ // url with a valid protocol.
+ url, err := url.Parse(string(u))
+ if err != nil {
+ err := fmt.Errorf("could not parse non-predefined policy value as uri: %w", err)
+ return "", err
+ }
+
+ if url.Host != "http" && url.Host != "https" {
+ err := fmt.Errorf("non-predefined policy values must have protocol 'http' or 'https' (%s)", u)
+ return "", err
+ }
+
+ return gtsmodel.PolicyValue(u), nil
+ }
+}
+
+func APIInteractionPolicyToInteractionPolicy(
+ p *apimodel.InteractionPolicy,
+ v apimodel.Visibility,
+) (*gtsmodel.InteractionPolicy, error) {
+ visibility := APIVisToVis(v)
+
+ convertURIs := func(apiURIs []apimodel.PolicyValue) (gtsmodel.PolicyValues, error) {
+ policyURIs := gtsmodel.PolicyValues{}
+ for _, apiURI := range apiURIs {
+ uri, err := APIPolicyValueToPolicyValue(apiURI)
+ if err != nil {
+ return nil, err
+ }
+
+ if !uri.FeasibleForVisibility(visibility) {
+ err := fmt.Errorf("policyURI %s is not feasible for visibility %s", apiURI, v)
+ return nil, err
+ }
+
+ policyURIs = append(policyURIs, uri)
+ }
+ return policyURIs, nil
+ }
+
+ canLikeAlways, err := convertURIs(p.CanFavourite.Always)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_favourite.always: %w", v, err)
+ return nil, err
+ }
+
+ canLikeWithApproval, err := convertURIs(p.CanFavourite.WithApproval)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_favourite.with_approval: %w", v, err)
+ return nil, err
+ }
+
+ canReplyAlways, err := convertURIs(p.CanReply.Always)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reply.always: %w", v, err)
+ return nil, err
+ }
+
+ canReplyWithApproval, err := convertURIs(p.CanReply.WithApproval)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reply.with_approval: %w", v, err)
+ return nil, err
+ }
+
+ canAnnounceAlways, err := convertURIs(p.CanReblog.Always)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reblog.always: %w", v, err)
+ return nil, err
+ }
+
+ canAnnounceWithApproval, err := convertURIs(p.CanReblog.WithApproval)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reblog.with_approval: %w", v, err)
+ return nil, err
+ }
+
+ // Normalize URIs.
+ //
+ // 1. Ensure canLikeAlways, canReplyAlways,
+ // and canAnnounceAlways include self
+ // (either explicitly or within public).
+
+ // ensureIncludesSelf adds the "author" PolicyValue
+ // to given slice of PolicyValues, if not already
+ // explicitly or implicitly included.
+ ensureIncludesSelf := func(vals gtsmodel.PolicyValues) gtsmodel.PolicyValues {
+ includesSelf := slices.ContainsFunc(
+ vals,
+ func(uri gtsmodel.PolicyValue) bool {
+ return uri == gtsmodel.PolicyValuePublic ||
+ uri == gtsmodel.PolicyValueAuthor
+ },
+ )
+
+ if includesSelf {
+ // This slice of policy values
+ // already includes self explicitly
+ // or implicitly, nothing to change.
+ return vals
+ }
+
+ // Need to add self/author to
+ // this slice of policy values.
+ vals = append(vals, gtsmodel.PolicyValueAuthor)
+ return vals
+ }
+
+ canLikeAlways = ensureIncludesSelf(canLikeAlways)
+ canReplyAlways = ensureIncludesSelf(canReplyAlways)
+ canAnnounceAlways = ensureIncludesSelf(canAnnounceAlways)
+
+ // 2. Ensure canReplyAlways includes mentioned
+ // accounts (either explicitly or within public).
+ if !slices.ContainsFunc(
+ canReplyAlways,
+ func(uri gtsmodel.PolicyValue) bool {
+ return uri == gtsmodel.PolicyValuePublic ||
+ uri == gtsmodel.PolicyValueMentioned
+ },
+ ) {
+ canReplyAlways = append(
+ canReplyAlways,
+ gtsmodel.PolicyValueMentioned,
+ )
+ }
+
+ return &gtsmodel.InteractionPolicy{
+ CanLike: gtsmodel.PolicyRules{
+ Always: canLikeAlways,
+ WithApproval: canLikeWithApproval,
+ },
+ CanReply: gtsmodel.PolicyRules{
+ Always: canReplyAlways,
+ WithApproval: canReplyWithApproval,
+ },
+ CanAnnounce: gtsmodel.PolicyRules{
+ Always: canAnnounceAlways,
+ WithApproval: canAnnounceWithApproval,
+ },
+ }, nil
+}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d24ae3ea5..6350f3269 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -1234,6 +1234,20 @@ func (c *Converter) baseStatusToFrontend(
log.Errorf(ctx, "error converting status emojis: %v", err)
}
+ // Take status's interaction policy, or
+ // fall back to default for its visibility.
+ var p *gtsmodel.InteractionPolicy
+ if s.InteractionPolicy != nil {
+ p = s.InteractionPolicy
+ } else {
+ p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility)
+ }
+
+ apiInteractionPolicy, err := c.InteractionPolicyToAPIInteractionPolicy(ctx, p, s, requestingAccount)
+ if err != nil {
+ return nil, gtserror.Newf("error converting interaction policy: %w", err)
+ }
+
apiStatus := &apimodel.Status{
ID: s.ID,
CreatedAt: util.FormatISO8601(s.CreatedAt),
@@ -1258,6 +1272,7 @@ func (c *Converter) baseStatusToFrontend(
Emojis: apiEmojis,
Card: nil, // TODO: implement cards
Text: s.Text,
+ InteractionPolicy: *apiInteractionPolicy,
}
// Nullable fields.
@@ -2256,3 +2271,111 @@ func (c *Converter) ThemesToAPIThemes(themes []*gtsmodel.Theme) []apimodel.Theme
}
return apiThemes
}
+
+// Convert the given gtsmodel policy
+// into an apimodel interaction policy.
+//
+// Provided status can be nil to convert a
+// policy without a particular status in mind.
+//
+// RequestingAccount can also be nil for
+// unauthorized requests (web, public api etc).
+func (c *Converter) InteractionPolicyToAPIInteractionPolicy(
+ ctx context.Context,
+ policy *gtsmodel.InteractionPolicy,
+ _ *gtsmodel.Status, // Used in upcoming PR.
+ _ *gtsmodel.Account, // Used in upcoming PR.
+) (*apimodel.InteractionPolicy, error) {
+ apiPolicy := &apimodel.InteractionPolicy{
+ CanFavourite: apimodel.PolicyRules{
+ Always: policyValsToAPIPolicyVals(policy.CanLike.Always),
+ WithApproval: policyValsToAPIPolicyVals(policy.CanLike.WithApproval),
+ },
+ CanReply: apimodel.PolicyRules{
+ Always: policyValsToAPIPolicyVals(policy.CanReply.Always),
+ WithApproval: policyValsToAPIPolicyVals(policy.CanReply.WithApproval),
+ },
+ CanReblog: apimodel.PolicyRules{
+ Always: policyValsToAPIPolicyVals(policy.CanAnnounce.Always),
+ WithApproval: policyValsToAPIPolicyVals(policy.CanAnnounce.WithApproval),
+ },
+ }
+
+ return apiPolicy, nil
+}
+
+func policyValsToAPIPolicyVals(vals gtsmodel.PolicyValues) []apimodel.PolicyValue {
+
+ var (
+ valsLen = len(vals)
+
+ // Use a map to deduplicate added vals as we go.
+ addedVals = make(map[apimodel.PolicyValue]struct{}, valsLen)
+
+ // Vals we'll be returning.
+ apiVals = make([]apimodel.PolicyValue, 0, valsLen)
+ )
+
+ for _, policyVal := range vals {
+ switch policyVal {
+
+ case gtsmodel.PolicyValueAuthor:
+ // Author can do this.
+ newVal := apimodel.PolicyValueAuthor
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueMentioned:
+ // Mentioned can do this.
+ newVal := apimodel.PolicyValueMentioned
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueMutuals:
+ // Mutuals can do this.
+ newVal := apimodel.PolicyValueMutuals
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueFollowing:
+ // Following can do this.
+ newVal := apimodel.PolicyValueFollowing
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueFollowers:
+ // Followers can do this.
+ newVal := apimodel.PolicyValueFollowers
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValuePublic:
+ // Public can do this.
+ newVal := apimodel.PolicyValuePublic
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ default:
+ // Specific URI of ActivityPub Actor.
+ newVal := apimodel.PolicyValue(policyVal)
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+ }
+ }
+
+ return apiVals
+}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index c4da0d57c..9fd4cea46 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -546,7 +546,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
],
"card": null,
"poll": null,
- "text": "hello world! #welcome ! first post on the instance :rainbow: !"
+ "text": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -701,7 +721,27 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
],
"status_matches": []
}
- ]
+ ],
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -877,7 +917,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -955,6 +1015,26 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
"emojis": [],
"card": null,
"poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ },
"media_attachments": [
{
"id": "01HE7Y3C432WRSNS10EZM86SA5",
@@ -1137,7 +1217,121 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
],
"card": null,
"poll": null,
- "text": "hello world! #welcome ! first post on the instance :rainbow: !"
+ "text": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
+}`, string(b))
+}
+
+func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteractions() {
+ testStatus := &gtsmodel.Status{}
+ *testStatus = *suite.testStatuses["local_account_1_status_3"]
+ testStatus.Language = ""
+ requestingAccount := suite.testAccounts["admin_account"]
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
+ suite.NoError(err)
+
+ b, err := json.MarshalIndent(apiStatus, "", " ")
+ suite.NoError(err)
+
+ suite.Equal(`{
+ "id": "01F8MHBBN8120SYH7D5S050MGK",
+ "created_at": "2021-10-20T10:40:37.000Z",
+ "in_reply_to_id": null,
+ "in_reply_to_account_id": null,
+ "sensitive": false,
+ "spoiler_text": "test: you shouldn't be able to interact with this post in any way",
+ "visibility": "private",
+ "language": null,
+ "uri": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK",
+ "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK",
+ "replies_count": 0,
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "favourited": false,
+ "reblogged": false,
+ "muted": false,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
+ "reblog": null,
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "account": {
+ "id": "01F8MH1H7YV1Z7D2C8K2730QBF",
+ "username": "the_mighty_zork",
+ "acct": "the_mighty_zork",
+ "display_name": "original zork (he/they)",
+ "locked": false,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2022-05-20T11:09:18.000Z",
+ "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e",
+ "url": "http://localhost:8080/@the_mighty_zork",
+ "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "avatar_description": "a green goblin looking nasty",
+ "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "header_description": "A very old-school screenshot of the original team fortress mod for quake",
+ "followers_count": 2,
+ "following_count": 2,
+ "statuses_count": 8,
+ "last_status_at": "2024-01-10T09:24:00.000Z",
+ "emojis": [],
+ "fields": [],
+ "enable_rss": true,
+ "role": {
+ "name": "user"
+ }
+ },
+ "media_attachments": [],
+ "mentions": [],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null,
+ "text": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "author"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "author"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "author"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -2014,7 +2208,27 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}
],
"rules": [