This commit is contained in:
tobi 2024-12-29 16:26:36 +01:00
parent 378822e480
commit 20aa6aa9e6
6 changed files with 743 additions and 0 deletions

View File

@ -149,6 +149,7 @@ func (m *Module) DomainPermissionDraftsPOSTHandler(c *gin.Context) {
form.Obfuscate,
form.PublicComment,
form.PrivateComment,
"", // No subscription ID.
)
if errWithCode != nil {
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)

View File

@ -125,6 +125,7 @@ func (p *Processor) DomainPermissionDraftCreate(
obfuscate bool,
publicComment string,
privateComment string,
subscriptionID string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft := &gtsmodel.DomainPermissionDraft{
ID: id.NewULID(),
@ -135,6 +136,7 @@ func (p *Processor) DomainPermissionDraftCreate(
PrivateComment: privateComment,
PublicComment: publicComment,
Obfuscate: &obfuscate,
SubscriptionID: subscriptionID,
}
if err := p.state.DB.PutDomainPermissionDraft(ctx, permDraft); err != nil {

View File

@ -0,0 +1,511 @@
// 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 subscriptions
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"io"
"slices"
"strings"
"time"
"codeberg.org/gruf/go-kv"
"github.com/miekg/dns"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/processing/admin"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
type Subscriptions struct {
state *state.State
transportController transport.Controller
tc *typeutils.Converter
admin *admin.Processor
}
// ProcessDomainPermissionSubscriptions processes all domain permission
// subscriptions of the given permission type by, in turn, calling the
// URI of each subscription, parsing the result into a list of domain
// permissions, and creating (or skipping) each permission as appropriate.
func (s *Subscriptions) ProcessDomainPermissionSubscriptions(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
) {
log.Info(ctx, "start")
// Get permission subscriptions in priority order (highest -> lowest).
permSubs, err := s.state.DB.GetDomainPermissionSubscriptionsByPriority(ctx, permType)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Real db error.
log.Error(ctx, err)
return
}
if len(permSubs) == 0 {
// No subscriptions of this
// type, so nothing to do.
return
}
// Get a transport using the instance account,
// we can reuse this for each HTTP call.
tsport, err := s.transportController.NewTransportForUsername(ctx, "")
if err != nil {
log.Error(ctx, err)
return
}
for i, permSub := range permSubs {
// Higher priority permission subs = everything
// above this permission sub in the slice.
getHigherPrios := func() ([]*gtsmodel.DomainPermissionSubscription, error) {
return permSubs[:i], nil
}
perms, err := s.ProcessDomainPermissionSubscription(
ctx,
permSub,
tsport,
getHigherPrios,
)
if err != nil {
// Real db error.
log.Error(ctx, err)
return
}
// Update count if necessary.
if count := len(perms); count != 0 {
permSub.Count = uint64(count)
}
// Update this perm sub.
err = s.state.DB.UpdateDomainPermissionSubscription(ctx, permSub)
if err != nil {
// Real db error.
log.Error(ctx, err)
return
}
}
log.Info(ctx, "finished")
}
// ProcessDomainPermissionSubscription processes one domain permission
// subscription by dereferencing the URI, parsing the response into a list
// of permissions, and for each discovered permission either creating an
// entry in the database, or ignoring it if it's excluded or already
// covered by a higher-priority subscription.
//
// On success, the slice of discovered DomainPermissions will be returned.
// In case of parsing error, or error on the remote side, permSub.Error
// will be updated with the calling/parsing error, and `nil, nil` will be
// returned. In case of an actual db error, `nil, err` will be returned and
// the caller should handle it.
//
// getHigherPrios should be a function for returning a slice of domain
// permission subscriptions with a higher priority than the given permSub.
func (s *Subscriptions) ProcessDomainPermissionSubscription(
ctx context.Context,
permSub *gtsmodel.DomainPermissionSubscription,
tsport transport.Transport,
getHigherPrios func() ([]*gtsmodel.DomainPermissionSubscription, error),
) ([]gtsmodel.DomainPermission, error) {
l := log.
WithContext(ctx).
WithFields(kv.Fields{
{"permType", permSub.PermissionType.String()},
{"permSubURI", permSub.URI},
}...)
// Set FetchedAt as we're
// going to attempt this now.
permSub.FetchedAt = time.Now()
// Call the URI but don't force.
resp, err := tsport.DereferenceDomainPermissions(
ctx, permSub, false,
)
if err != nil {
// Couldn't get this one,
// set error + return.
errStr := err.Error()
l.Warnf("couldn't dereference permSubURI: %+v", err)
permSub.Error = errStr
return nil, nil
}
// If the permissions at URI weren't modified
// since last time, just update some metadata
// to indicate a successful fetch, and return.
if resp.Unmodified {
l.Debug("received 304 Not Modified from remote")
permSub.SuccessfullyFetchedAt = time.Now()
if permSub.ETag == "" && resp.ETag != "" {
// We didn't have an ETag before but
// we have one now: probably the remote
// added ETag support in the meantime.
permSub.ETag = resp.ETag
}
return nil, nil
}
// At this point we know we got a 200 OK
// from the URI, so we've got a live body!
// Try to parse the body as a list of perms.
var perms []gtsmodel.DomainPermission
switch permSub.ContentType {
// text/csv
case gtsmodel.DomainPermSubContentTypeCSV:
perms, err = s.permsFromCSV(ctx, permSub.PermissionType, resp.Body)
// application/json
case gtsmodel.DomainPermSubContentTypeJSON:
perms, err = s.permsFromJSON(ctx, permSub.PermissionType, resp.Body)
// text/plain
case gtsmodel.DomainPermSubContentTypePlain:
perms, err = s.permsFromPlain(ctx, permSub.PermissionType, resp.Body)
}
if err != nil {
// We retrieved the permissions from remote
// but we couldn't parse them into anything
// workable, or the connection died halfway
// through transfer, or some other annoyance.
// Just set error and return.
errStr := err.Error()
l.Warnf("couldn't parse results: %+v", err)
permSub.Error = errStr
return nil, nil
}
higherPrios, err := getHigherPrios()
if err != nil {
// Proper db error.
return nil, err
}
// Process each domain permission.
for _, perm := range perms {
l = l.WithField("domain", perm.GetDomain())
if err := s.processDomainPermission(
ctx,
l,
perm,
permSub,
higherPrios,
); err != nil {
// Proper db error.
return nil, err
}
}
return perms, nil
}
// processDomainPermission processes one domain permission
// discovered via a domain permission subscription's URI.
//
// Error will only be returned in case of an actual database
// error, else the error will be logged and nil returned.
func (s *Subscriptions) processDomainPermission(
ctx context.Context,
l log.Entry,
perm gtsmodel.DomainPermission,
permSub *gtsmodel.DomainPermissionSubscription,
higherPrios []*gtsmodel.DomainPermissionSubscription,
) error {
// If domain contains wildcard/obfuscation
// characters, we can't do anything with it.
domain := perm.GetDomain()
if strings.Contains(domain, "*") {
l.Warn("'*' char(s) in domain, skipping")
return nil
}
// Basic validation.
if _, ok := dns.IsDomainName(domain); !ok {
l.Warn("invalid domain, skipping")
return nil
}
// Convert to punycode.
domain, err := util.Punify(domain)
if err != nil {
l.Warnf("could not punify domain (%+v), skipping", err)
return nil
}
// Domain looks good. Check if it's excluded.
excluded, err := s.state.DB.IsDomainPermissionExcluded(ctx, domain)
if err != nil {
// Proper db error.
return err
}
if excluded {
l.Debug("domain is excluded, skipping")
return nil
}
// Check if a permission already exists for
// this domain, and if it's covered already
// by a higher-priority subscription.
existingPerm, covered, err := s.existingCovered(
ctx, permSub.PermissionType, domain, higherPrios,
)
if err != nil {
// Proper db error.
return err
}
if covered {
l.Debug("domain is covered by a higher-priority subscription, skipping")
return nil
}
existing := !util.IsNil(existingPerm)
switch {
// No existing perm, create draft.
case !existing && *permSub.AsDraft:
_, err = s.admin.DomainPermissionDraftCreate(
ctx,
perm.GetCreatedByAccount(),
domain,
permSub.PermissionType,
util.PtrOrZero(perm.GetObfuscate()),
perm.GetPublicComment(),
permSub.URI,
permSub.ID,
)
// No existing perm, create straight up.
case !existing && !*permSub.AsDraft:
_, _, err = s.admin.DomainPermissionCreate(
ctx,
permSub.PermissionType,
perm.GetCreatedByAccount(),
domain,
util.PtrOrZero(perm.GetObfuscate()),
perm.GetPublicComment(),
permSub.URI,
permSub.ID,
)
// Exists but we should adopt/take it.
case existingPerm.GetSubscriptionID() != "" || *permSub.AdoptOrphans:
existingPerm.SetCreatedByAccountID(perm.GetCreatedByAccountID())
existingPerm.SetCreatedByAccount(perm.GetCreatedByAccount())
existingPerm.SetSubscriptionID(permSub.ID)
existingPerm.SetObfuscate(perm.GetObfuscate())
existingPerm.SetPrivateComment(perm.GetPrivateComment())
existingPerm.SetPublicComment(perm.GetPublicComment())
switch p := existingPerm.(type) {
case *gtsmodel.DomainBlock:
err = s.state.DB.UpdateDomainBlock(ctx, p)
case *gtsmodel.DomainAllow:
err = s.state.DB.UpdateDomainAllow(ctx, p)
}
// Exists but we should leave it alone.
default:
l.Debug("domain is covered by a higher-priority subscription, skipping")
}
if err != nil && !errors.Is(err, db.ErrAlreadyExists) {
// Proper db error.
return err
}
return nil
}
func (s *Subscriptions) permsFromCSV(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
body io.ReadCloser,
) ([]gtsmodel.DomainPermission, error) {
// Read body into memory as slice of CSV records.
records, err := csv.NewReader(body).ReadAll()
// Whatever happened, we're
// done with the body now.
body.Close()
// Check if error reading body.
if err != nil {
return nil, gtserror.NewfAt(3, "error decoding into csv: %w", err)
}
// Convert CSV records to perms.
return s.tc.CSVToDomainPerms(ctx, records, permType)
}
func (s *Subscriptions) permsFromJSON(
_ context.Context,
permType gtsmodel.DomainPermissionType,
body io.ReadCloser,
) ([]gtsmodel.DomainPermission, error) {
var (
dec = json.NewDecoder(body)
apiPerms = make([]*apimodel.DomainPermission, 0)
)
// Read body into memory as
// slice of domain permissions.
if err := dec.Decode(&apiPerms); err != nil {
_ = body.Close() // ensure closed.
return nil, gtserror.NewfAt(3, "error decoding into json: %w", err)
}
// Perform a secondary decode just to ensure we drained the
// entirety of the data source. Error indicates either extra
// trailing garbage, or multiple JSON values (invalid data).
if err := dec.Decode(&struct{}{}); err != io.EOF {
_ = body.Close() // ensure closed.
return nil, gtserror.NewfAt(3, "data remaining after json")
}
// Done with body.
_ = body.Close()
// Convert apimodel perms to barebones internal perms.
perms := make([]gtsmodel.DomainPermission, 0, len(apiPerms))
for _, apiPerm := range apiPerms {
// Instantiate the permission
// as either block or allow.
var perm gtsmodel.DomainPermission
if permType == gtsmodel.DomainPermissionBlock {
perm = &gtsmodel.DomainBlock{Domain: apiPerm.Domain.Domain}
} else {
perm = &gtsmodel.DomainAllow{Domain: apiPerm.Domain.Domain}
}
// Set remaining fields.
perm.SetPublicComment(apiPerm.PublicComment)
perm.SetObfuscate(&apiPerm.Obfuscate)
// We're done.
perms = append(perms, perm)
}
return perms, nil
}
func (s *Subscriptions) permsFromPlain(
_ context.Context,
permType gtsmodel.DomainPermissionType,
body io.ReadCloser,
) ([]gtsmodel.DomainPermission, error) {
// Read body into memory as bytes.
b, err := io.ReadAll(body)
// Whatever happened, we're
// done with the body now.
body.Close()
// Check if error reading body.
if err != nil {
return nil, gtserror.NewfAt(3, "error decoding into plain: %w", err)
}
// Coerce to newline-separated list of domains.
domains := strings.Split(string(b), "\n")
// Convert raw domains to permissions.
perms := make([]gtsmodel.DomainPermission, 0, len(domains))
for _, domain := range domains {
// Instantiate the permission
// as either block or allow.
var perm gtsmodel.DomainPermission
if permType == gtsmodel.DomainPermissionBlock {
perm = &gtsmodel.DomainBlock{Domain: domain}
} else {
perm = &gtsmodel.DomainAllow{Domain: domain}
}
// We're done.
perms = append(perms, perm)
}
return nil, nil
}
func (s *Subscriptions) existingCovered(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
domain string,
higherPrios []*gtsmodel.DomainPermissionSubscription,
) (
existingPerm gtsmodel.DomainPermission,
covered bool,
err error,
) {
// Check for existing permission of appropriate type.
var dbErr error
if permType == gtsmodel.DomainPermissionBlock {
existingPerm, dbErr = s.state.DB.GetDomainBlock(ctx, domain)
} else {
existingPerm, dbErr = s.state.DB.GetDomainAllow(ctx, domain)
}
if dbErr != nil && !errors.Is(dbErr, db.ErrNoEntries) {
// Real db error.
err = dbErr
return
}
if util.IsNil(existingPerm) {
// Can't be covered if
// no existing perm.
return
}
subscriptionID := existingPerm.GetSubscriptionID()
if subscriptionID == "" {
// Can't be covered if
// no subscription ID.
return
}
// Covered if subscription ID is in the slice
// of higher-priority permission subscriptions.
covered = slices.ContainsFunc(
higherPrios,
func(permSub *gtsmodel.DomainPermissionSubscription) bool {
return permSub.ID == subscriptionID
},
)
return
}

View File

@ -0,0 +1,123 @@
// 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 transport
import (
"context"
"io"
"net/http"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
type DereferenceDomainPermissionsResp struct {
// Set only if response was 200 OK.
// It's up to the caller to close
// this when they're done with it.
Body io.ReadCloser
// True if response
// was 304 Not Modified.
Unmodified bool
// May be set
// if 200 or 304.
ETag string
}
func (t *transport) DereferenceDomainPermissions(
ctx context.Context,
permSub *gtsmodel.DomainPermissionSubscription,
force bool,
) (*DereferenceDomainPermissionsResp, error) {
// Prepare new HTTP request to endpoint
req, err := http.NewRequestWithContext(ctx, "GET", permSub.URI, nil)
if err != nil {
return nil, err
}
// Set basic auth header if necessary.
if permSub.FetchUsername != "" || permSub.FetchPassword != "" {
req.SetBasicAuth(permSub.FetchUsername, permSub.FetchPassword)
}
// Set relevant Accept headers.
// Allow fallback in case target doesn't
// negotiate content type correctly.
req.Header.Add("Accept-Charset", "utf-8")
req.Header.Add("Accept", permSub.ContentType.String()+","+"*/*")
// If force is true, we want to skip setting Cache
// headers so that we definitely don't get a 304 back.
if !force {
// If we've successfully fetched this list
// before, set If-Modified-Since to last
// success to make the request conditional.
//
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
if !permSub.SuccessfullyFetchedAt.IsZero() {
timeStr := permSub.SuccessfullyFetchedAt.Format(http.TimeFormat)
req.Header.Add("If-Modified-Since", timeStr)
}
// If we've got an ETag stored for this list, set
// If-None-Match to make the request conditional.
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#caching_of_unchanged_resources.
if len(permSub.ETag) != 0 {
req.Header.Add("If-None-Match", permSub.ETag)
}
}
// Perform the HTTP request
rsp, err := t.GET(req)
if err != nil {
return nil, err
}
// If we have an unexpected / error response,
// wrap + return as error. This will also drain
// and close the response body for us.
if rsp.StatusCode != http.StatusOK &&
rsp.StatusCode != http.StatusNotModified {
err := gtserror.NewFromResponse(rsp)
return nil, err
}
// Check already if we were given an ETag
// we can use, as ETag is often returned
// even on 304 Not Modified responses.
eTag := rsp.Header.Get("ETag")
if rsp.StatusCode == http.StatusNotModified {
// Nothing has changed on the remote side
// since we last fetched, so there's nothing
// to do and we don't need to read the body.
rsp.Body.Close()
return &DereferenceDomainPermissionsResp{
Unmodified: true,
ETag: eTag,
}, nil
}
// Return the body + ETag to the caller.
return &DereferenceDomainPermissionsResp{
Body: rsp.Body,
ETag: eTag,
}, nil
}

View File

@ -78,6 +78,20 @@ type Transport interface {
// DereferenceInstance dereferences remote instance information, first by checking /api/v1/instance, and then by checking /.well-known/nodeinfo.
DereferenceInstance(ctx context.Context, iri *url.URL) (*gtsmodel.Instance, error)
// DereferenceDomainPermissions dereferences the
// permissions list present at the given permSub's URI.
//
// If "force", then If-Modified-Since and If-None-Match
// headers will *NOT* be sent with the outgoing request.
//
// If err == nil and Unmodified == false, then it's up
// to the caller to close the returned io.ReadCloser.
DereferenceDomainPermissions(
ctx context.Context,
permSub *gtsmodel.DomainPermissionSubscription,
force bool,
) (*DereferenceDomainPermissionsResp, error)
// Finger performs a webfinger request with the given username and domain, and returns the bytes from the response body.
Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error)
}

View File

@ -27,6 +27,7 @@
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -553,3 +554,94 @@ func (c *Converter) CSVToBlocks(
return blocks, nil
}
// CSVToDomainPerms converts a slice of Mastodon-style
// CSV records to barebones gtsmodel.DomainPermission's,
// ready for further processing.
//
// Expected column names are:
//
// #domain
// #severity
// #reject_media
// #reject_reports
// #public_comment
// #obfuscate
func (c *Converter) CSVToDomainPerms(
ctx context.Context,
records [][]string,
permType gtsmodel.DomainPermissionType,
) ([]gtsmodel.DomainPermission, error) {
// Make sure we actually
// have some records.
if len(records) == 0 {
return nil, nil
}
// Validate column headers.
columnHeaders := records[0]
if !slices.Equal(
columnHeaders,
[]string{
"#domain",
"#severity",
"#reject_media",
"#reject_reports",
"#public_comment",
"#obfuscate",
},
) {
return nil, gtserror.Newf(
"unexpected column headers in csv: %+v",
columnHeaders,
)
}
// Trim off column headers
// now they're validated.
records = records[1:]
// Convert records to permissions slice.
perms := make([]gtsmodel.DomainPermission, 0, len(records))
for _, record := range records {
if len(record) != 6 {
log.Warnf(ctx, "skipping invalid-length record: %+v", record)
continue
}
var (
domain = record[0]
severity = record[1]
publicComment = record[4]
obfuscate, err = strconv.ParseBool(record[5])
)
if severity != "suspend" {
log.Warnf(ctx, "skipping non-suspend record: %+v", record)
continue
}
if err != nil {
log.Warnf(ctx, "couldn't parse obfuscate field of record: %+v", record)
continue
}
// Instantiate the permission
// as either block or allow.
var perm gtsmodel.DomainPermission
if permType == gtsmodel.DomainPermissionBlock {
perm = &gtsmodel.DomainBlock{Domain: domain}
} else {
perm = &gtsmodel.DomainAllow{Domain: domain}
}
// Set remaining fields.
perm.SetPublicComment(publicComment)
perm.SetObfuscate(&obfuscate)
// We're done.
perms = append(perms, perm)
}
return perms, nil
}