mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-29 19:02:26 -05:00 
			
		
		
		
	updates our codeberg.org/gruf/go-kv log key-value formatting library to latest version, which comes with some maaaaaaajor speed boosts in the form of:
- very minimal reflect.Value{} usage
- caching prepared formatting functions per type
~~still a work-in-progress until i make a release tag on the go-kv repository, which itself is waiting on published benchmark results in the README and finishing writing some code comments~~
benchmarks so far show this to be ~3x faster than the "fmt" stdlib package on average, when run across a wide variety (106 different types) of test cases, while still creating more visually friendly log output and actually recursing down nested struct ptrs
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4341
Co-authored-by: kim <grufwub@gmail.com>
Co-committed-by: kim <grufwub@gmail.com>
		
	
			
		
			
				
	
	
		
			1087 lines
		
	
	
	
		
			30 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			1087 lines
		
	
	
	
		
			30 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // 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 (
 | |
| 	"bufio"
 | |
| 	"cmp"
 | |
| 	"context"
 | |
| 	"encoding/csv"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"io"
 | |
| 	"slices"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"codeberg.org/gruf/go-kv/v2"
 | |
| 
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/admin"
 | |
| 	apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/config"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/db"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/gtserror"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/id"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/log"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/transport"
 | |
| 	"code.superseriousbusiness.org/gotosocial/internal/util"
 | |
| )
 | |
| 
 | |
| // ScheduleJobs schedules domain permission subscription
 | |
| // fetching + updating using configured parameters.
 | |
| //
 | |
| // Returns an error if `MediaCleanupFrom`
 | |
| // is not a valid format (hh:mm:ss).
 | |
| func (s *Subscriptions) ScheduleJobs() error {
 | |
| 	const hourMinute = "15:04"
 | |
| 
 | |
| 	var (
 | |
| 		now            = time.Now()
 | |
| 		processEvery   = config.GetInstanceSubscriptionsProcessEvery()
 | |
| 		processFromStr = config.GetInstanceSubscriptionsProcessFrom()
 | |
| 	)
 | |
| 
 | |
| 	// Parse processFromStr as hh:mm.
 | |
| 	// Resulting time will be on 1 Jan year zero.
 | |
| 	processFrom, err := time.Parse(hourMinute, processFromStr)
 | |
| 	if err != nil {
 | |
| 		return gtserror.Newf(
 | |
| 			"error parsing '%s' in time format 'hh:mm': %w",
 | |
| 			processFromStr, err,
 | |
| 		)
 | |
| 	}
 | |
| 
 | |
| 	// Time travel from
 | |
| 	// year zero, groovy.
 | |
| 	firstProcessAt := time.Date(
 | |
| 		now.Year(),
 | |
| 		now.Month(),
 | |
| 		now.Day(),
 | |
| 		processFrom.Hour(),
 | |
| 		processFrom.Minute(),
 | |
| 		0,
 | |
| 		0,
 | |
| 		now.Location(),
 | |
| 	)
 | |
| 
 | |
| 	// Ensure first processing is in the future.
 | |
| 	for firstProcessAt.Before(now) {
 | |
| 		firstProcessAt = firstProcessAt.Add(processEvery)
 | |
| 	}
 | |
| 
 | |
| 	fn := func(ctx context.Context, start time.Time) {
 | |
| 		log.Info(ctx, "starting instance subscriptions processing")
 | |
| 
 | |
| 		// In blocklist (default) mode, process allows
 | |
| 		// first to provide immunity to block side effects.
 | |
| 		//
 | |
| 		// In allowlist mode, process blocks first to
 | |
| 		// ensure allowlist doesn't override blocks.
 | |
| 		var order [2]gtsmodel.DomainPermissionType
 | |
| 		if config.GetInstanceFederationMode() == config.InstanceFederationModeBlocklist {
 | |
| 			order = [2]gtsmodel.DomainPermissionType{
 | |
| 				gtsmodel.DomainPermissionAllow,
 | |
| 				gtsmodel.DomainPermissionBlock,
 | |
| 			}
 | |
| 		} else {
 | |
| 			order = [2]gtsmodel.DomainPermissionType{
 | |
| 				gtsmodel.DomainPermissionBlock,
 | |
| 				gtsmodel.DomainPermissionAllow,
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// Fetch + process subscribed perms in order.
 | |
| 		for _, permType := range order {
 | |
| 			s.ProcessDomainPermissionSubscriptions(ctx, permType)
 | |
| 		}
 | |
| 
 | |
| 		log.Infof(ctx, "finished instance subscriptions processing after %s", time.Since(start))
 | |
| 	}
 | |
| 
 | |
| 	log.Infof(nil,
 | |
| 		"scheduling instance subscriptions processing to run every %s, starting from %s; next processing will run at %s",
 | |
| 		processEvery, processFromStr, firstProcessAt,
 | |
| 	)
 | |
| 
 | |
| 	// Schedule processing to execute according to schedule.
 | |
| 	if !s.state.Workers.Scheduler.AddRecurring(
 | |
| 		"@subsprocessing",
 | |
| 		firstProcessAt,
 | |
| 		processEvery,
 | |
| 		fn,
 | |
| 	) {
 | |
| 		panic("failed to schedule @subsprocessing")
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // 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")
 | |
| 	defer log.Info(ctx, "finished")
 | |
| 
 | |
| 	// 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.Errorf(ctx, "db error getting domain perm subs by priority: %v", 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.Errorf(ctx, "error getting transport for instance account: %v", err)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	var skipCache bool
 | |
| 	for i, permSub := range permSubs {
 | |
| 		// Higher priority permission subs = everything
 | |
| 		// above this permission sub in the slice.
 | |
| 		higherPrios := permSubs[:i]
 | |
| 
 | |
| 		_, retracted, err := s.ProcessDomainPermissionSubscription(
 | |
| 			ctx,
 | |
| 			permSub,
 | |
| 			tsport,
 | |
| 			higherPrios,
 | |
| 			false,     // Not dry. Wet, if you will.
 | |
| 			skipCache, // Skip cache if necessary.
 | |
| 		)
 | |
| 		if err != nil {
 | |
| 			// Real db error.
 | |
| 			log.Errorf(ctx,
 | |
| 				"error processing domain permission subscription %s: %v",
 | |
| 				permSub.URI, err,
 | |
| 			)
 | |
| 			return
 | |
| 		}
 | |
| 
 | |
| 		// If any retractions have been done, skip caching
 | |
| 		// when doing subsequent fetches. This makes it so
 | |
| 		// that if an entry was present in a higher-priority
 | |
| 		// list and a lower-priority list, but was retracted
 | |
| 		// from the higher-priority list, it will be created
 | |
| 		// and managed by the lower-priority list instead.
 | |
| 		if retracted && !skipCache {
 | |
| 			skipCache = true
 | |
| 		}
 | |
| 
 | |
| 		// Update this perm sub.
 | |
| 		err = s.state.DB.UpdateDomainPermissionSubscription(ctx, permSub)
 | |
| 		if err != nil {
 | |
| 			// Real db error.
 | |
| 			log.Errorf(ctx, "db error updating domain perm sub: %v", err)
 | |
| 			return
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // 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,
 | |
| // including a boolean to indicate whether or not any retractions have been
 | |
| // performed since the list was last checked (if ever).
 | |
| //
 | |
| // 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.
 | |
| //
 | |
| // If dry == true, then the URI will still be called, and permissions
 | |
| // will be parsed, but they will not actually be created.
 | |
| //
 | |
| // If skipCache == true, then conditional HTTP request headers will not be
 | |
| // sent, and so cached values for the domain permission subscription list
 | |
| // will not be used (ie., the list will always be fetched "fresh").
 | |
| //
 | |
| // Note that while this function modifies fields on the given permSub,
 | |
| // it's up to the caller to update it in the database (if desired).
 | |
| func (s *Subscriptions) ProcessDomainPermissionSubscription(
 | |
| 	ctx context.Context,
 | |
| 	permSub *gtsmodel.DomainPermissionSubscription,
 | |
| 	tsport transport.Transport,
 | |
| 	higherPrios []*gtsmodel.DomainPermissionSubscription,
 | |
| 	dry bool,
 | |
| 	skipCache bool,
 | |
| ) ([]gtsmodel.DomainPermission, bool, 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, skipping conditional requests
 | |
| 	// (caching) if we've been told to do so.
 | |
| 	resp, err := tsport.DereferenceDomainPermissions(
 | |
| 		ctx, permSub, skipCache,
 | |
| 	)
 | |
| 	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, false, 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.ETag = resp.ETag
 | |
| 		permSub.LastModified = resp.LastModified
 | |
| 		permSub.SuccessfullyFetchedAt = permSub.FetchedAt
 | |
| 		return nil, false, 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 wantedPerms
 | |
| 	// that the subscription wants to create.
 | |
| 	var wantedPerms []gtsmodel.DomainPermission
 | |
| 
 | |
| 	switch permSub.ContentType {
 | |
| 
 | |
| 	// text/csv
 | |
| 	case gtsmodel.DomainPermSubContentTypeCSV:
 | |
| 		wantedPerms, err = permsFromCSV(l, permSub.PermissionType, resp.Body)
 | |
| 
 | |
| 	// application/json
 | |
| 	case gtsmodel.DomainPermSubContentTypeJSON:
 | |
| 		wantedPerms, err = permsFromJSON(l, permSub.PermissionType, resp.Body)
 | |
| 
 | |
| 	// text/plain
 | |
| 	case gtsmodel.DomainPermSubContentTypePlain:
 | |
| 		wantedPerms, err = permsFromPlain(l, permSub.PermissionType, resp.Body)
 | |
| 	}
 | |
| 
 | |
| 	if err != nil {
 | |
| 		// We retrieved the permissions from remote but
 | |
| 		// the connection died halfway through transfer,
 | |
| 		// or we couldn't parse the results, or something.
 | |
| 		// Just set error and return.
 | |
| 		errStr := err.Error()
 | |
| 		l.Warnf("couldn't parse results: %+v", err)
 | |
| 		permSub.Error = errStr
 | |
| 		return nil, false, nil
 | |
| 	}
 | |
| 
 | |
| 	if len(wantedPerms) == 0 {
 | |
| 		// Fetch was OK, and parsing was, on the surface at
 | |
| 		// least, OK, but we didn't get any perms. Consider
 | |
| 		// this an error as users will probably want to know.
 | |
| 		const errStr = "fetch successful but parsed zero usable results"
 | |
| 		l.Warn(errStr)
 | |
| 		permSub.Error = errStr
 | |
| 		return nil, false, nil
 | |
| 	}
 | |
| 
 | |
| 	// This can now be considered a successful fetch.
 | |
| 	permSub.SuccessfullyFetchedAt = permSub.FetchedAt
 | |
| 	permSub.ETag = resp.ETag
 | |
| 	permSub.LastModified = resp.LastModified
 | |
| 	permSub.Error = ""
 | |
| 
 | |
| 	// Keep track of which domain perms are
 | |
| 	// created (or would be, if dry == true).
 | |
| 	createdPerms := make([]gtsmodel.DomainPermission, 0, len(wantedPerms))
 | |
| 
 | |
| 	// Iterate through wantedPerms and
 | |
| 	// create (or dry create) each one.
 | |
| 	for _, wantedPerm := range wantedPerms {
 | |
| 		l := l.WithField("domain", wantedPerm.GetDomain())
 | |
| 		created, err := s.processDomainPermission(
 | |
| 			ctx, l,
 | |
| 			wantedPerm,
 | |
| 			permSub,
 | |
| 			higherPrios,
 | |
| 			dry,
 | |
| 		)
 | |
| 		if err != nil {
 | |
| 			// Proper db error.
 | |
| 			return nil, false, err
 | |
| 		}
 | |
| 
 | |
| 		if !created {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		createdPerms = append(createdPerms, wantedPerm)
 | |
| 	}
 | |
| 
 | |
| 	if dry {
 | |
| 		// Don't do any further
 | |
| 		// processing with a dry run.
 | |
| 		return createdPerms, false, nil
 | |
| 	}
 | |
| 
 | |
| 	// Process any retractions since
 | |
| 	// the last time list was checked.
 | |
| 	//
 | |
| 	// Being unable to do retractions
 | |
| 	// isn't the end of the world as it
 | |
| 	// can be tried again next time the
 | |
| 	// list is updated, so if there was
 | |
| 	// an error just warn it.
 | |
| 	retracted, err := s.processRetractions(
 | |
| 		ctx, l,
 | |
| 		permSub,
 | |
| 		wantedPerms,
 | |
| 	)
 | |
| 	if err != nil {
 | |
| 		l.Warnf("error doing retractions: %+v", err)
 | |
| 	}
 | |
| 
 | |
| 	return createdPerms, retracted, nil
 | |
| }
 | |
| 
 | |
| // processDomainPermission processes one wanted domain
 | |
| // permission discovered via a domain permission sub's URI.
 | |
| //
 | |
| // If dry == true, then the returned boolean indicates whether
 | |
| // the permission would actually be created. If dry == false,
 | |
| // the bool indicates whether the permission was created or adopted.
 | |
| //
 | |
| // 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,
 | |
| 	wantedPerm gtsmodel.DomainPermission,
 | |
| 	permSub *gtsmodel.DomainPermissionSubscription,
 | |
| 	higherPrios []*gtsmodel.DomainPermissionSubscription,
 | |
| 	dry bool,
 | |
| ) (bool, error) {
 | |
| 	// If domain is excluded from automatic
 | |
| 	// permission creation, don't process it.
 | |
| 	domain := wantedPerm.GetDomain()
 | |
| 	excluded, err := s.state.DB.IsDomainPermissionExcluded(ctx, domain)
 | |
| 	if err != nil {
 | |
| 		// Proper db error.
 | |
| 		return false, err
 | |
| 	}
 | |
| 
 | |
| 	if excluded {
 | |
| 		l.Debug("domain is excluded, skipping")
 | |
| 		return false, err
 | |
| 	}
 | |
| 
 | |
| 	// 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 false, err
 | |
| 	}
 | |
| 
 | |
| 	if covered {
 | |
| 		l.Debug("domain is covered by a higher-priority subscription, skipping")
 | |
| 		return false, err
 | |
| 	}
 | |
| 
 | |
| 	// True if a perm already exists.
 | |
| 	// Note: != nil doesn't work because
 | |
| 	// of Go interface idiosyncracies.
 | |
| 	existing := !util.IsNil(existingPerm)
 | |
| 
 | |
| 	if dry {
 | |
| 		// If this is a dry run, return
 | |
| 		// now without doing any DB changes.
 | |
| 		return !existing, nil
 | |
| 	}
 | |
| 
 | |
| 	// Handle perm creation differently depending
 | |
| 	// on whether or not a perm already existed.
 | |
| 	switch {
 | |
| 
 | |
| 	case !existing && *permSub.AsDraft:
 | |
| 		// No existing perm, create as draft.
 | |
| 		err = s.state.DB.PutDomainPermissionDraft(
 | |
| 			ctx,
 | |
| 			>smodel.DomainPermissionDraft{
 | |
| 				ID:                 id.NewULID(),
 | |
| 				PermissionType:     permSub.PermissionType,
 | |
| 				Domain:             domain,
 | |
| 				CreatedByAccountID: permSub.CreatedByAccount.ID,
 | |
| 				CreatedByAccount:   permSub.CreatedByAccount,
 | |
| 				PrivateComment:     permSub.URI,
 | |
| 				PublicComment:      wantedPerm.GetPublicComment(),
 | |
| 				Obfuscate:          wantedPerm.GetObfuscate(),
 | |
| 				SubscriptionID:     permSub.ID,
 | |
| 			},
 | |
| 		)
 | |
| 
 | |
| 	case !existing && !*permSub.AsDraft:
 | |
| 		// No existing perm, create a new one of the
 | |
| 		// appropriate type, and process side effects.
 | |
| 		var (
 | |
| 			insertF func() error
 | |
| 			action  *gtsmodel.AdminAction
 | |
| 			actionF admin.ActionF
 | |
| 		)
 | |
| 
 | |
| 		if permSub.PermissionType == gtsmodel.DomainPermissionBlock {
 | |
| 			// Prepare to insert + process a block.
 | |
| 			domainBlock := >smodel.DomainBlock{
 | |
| 				ID:                 id.NewULID(),
 | |
| 				Domain:             domain,
 | |
| 				CreatedByAccountID: permSub.CreatedByAccount.ID,
 | |
| 				CreatedByAccount:   permSub.CreatedByAccount,
 | |
| 				PrivateComment:     permSub.URI,
 | |
| 				PublicComment:      wantedPerm.GetPublicComment(),
 | |
| 				Obfuscate:          wantedPerm.GetObfuscate(),
 | |
| 				SubscriptionID:     permSub.ID,
 | |
| 			}
 | |
| 			insertF = func() error { return s.state.DB.PutDomainBlock(ctx, domainBlock) }
 | |
| 
 | |
| 			action = >smodel.AdminAction{
 | |
| 				ID:             id.NewULID(),
 | |
| 				TargetCategory: gtsmodel.AdminActionCategoryDomain,
 | |
| 				TargetID:       domain,
 | |
| 				Type:           gtsmodel.AdminActionSuspend,
 | |
| 				AccountID:      permSub.CreatedByAccountID,
 | |
| 			}
 | |
| 			actionF = s.state.AdminActions.DomainBlockF(action.ID, domainBlock)
 | |
| 
 | |
| 		} else {
 | |
| 			// Prepare to insert + process an allow.
 | |
| 			domainAllow := >smodel.DomainAllow{
 | |
| 				ID:                 id.NewULID(),
 | |
| 				Domain:             domain,
 | |
| 				CreatedByAccountID: permSub.CreatedByAccount.ID,
 | |
| 				CreatedByAccount:   permSub.CreatedByAccount,
 | |
| 				PrivateComment:     permSub.URI,
 | |
| 				PublicComment:      wantedPerm.GetPublicComment(),
 | |
| 				Obfuscate:          wantedPerm.GetObfuscate(),
 | |
| 				SubscriptionID:     permSub.ID,
 | |
| 			}
 | |
| 			insertF = func() error { return s.state.DB.PutDomainAllow(ctx, domainAllow) }
 | |
| 
 | |
| 			action = >smodel.AdminAction{
 | |
| 				ID:             id.NewULID(),
 | |
| 				TargetCategory: gtsmodel.AdminActionCategoryDomain,
 | |
| 				TargetID:       domain,
 | |
| 				Type:           gtsmodel.AdminActionUnsuspend,
 | |
| 				AccountID:      permSub.CreatedByAccountID,
 | |
| 			}
 | |
| 			actionF = s.state.AdminActions.DomainAllowF(action.ID, domainAllow)
 | |
| 		}
 | |
| 
 | |
| 		// Insert the new perm in the db.
 | |
| 		if err = insertF(); err != nil {
 | |
| 			// Couldn't insert wanted perm,
 | |
| 			// don't process side effects.
 | |
| 			break
 | |
| 		}
 | |
| 
 | |
| 		// Run admin action to process
 | |
| 		// side effects of permission.
 | |
| 		err = s.state.AdminActions.Run(ctx, action, actionF)
 | |
| 
 | |
| 	case existingPerm.IsOrphan():
 | |
| 		// Perm already exists, but it's not managed
 | |
| 		// by a subscription, ie., it's an orphan.
 | |
| 		if !*permSub.AdoptOrphans {
 | |
| 			l.Debug("permission exists as an orphan that we shouldn't adopt, skipping")
 | |
| 			return false, nil
 | |
| 		}
 | |
| 
 | |
| 		// Orphan is adoptable, so adopt
 | |
| 		// it by rewriting some fields.
 | |
| 		//
 | |
| 		// TODO: preserve previous private
 | |
| 		// + public comment in some way.
 | |
| 		l.Debug("adopting orphan permission")
 | |
| 		err = s.adoptPerm(
 | |
| 			ctx,
 | |
| 			existingPerm,
 | |
| 			permSub,
 | |
| 			wantedPerm.GetObfuscate(),
 | |
| 			permSub.URI,
 | |
| 			wantedPerm.GetPublicComment(),
 | |
| 		)
 | |
| 
 | |
| 	case existingPerm.GetSubscriptionID() != permSub.ID:
 | |
| 		// Perm already exists, and is managed
 | |
| 		// by a lower-priority subscription.
 | |
| 		// Take it for ourselves.
 | |
| 		//
 | |
| 		// TODO: preserve previous private
 | |
| 		// + public comment in some way.
 | |
| 		l.Debug("taking over permission from lower-priority subscription")
 | |
| 		err = s.adoptPerm(
 | |
| 			ctx,
 | |
| 			existingPerm,
 | |
| 			permSub,
 | |
| 			wantedPerm.GetObfuscate(),
 | |
| 			permSub.URI,
 | |
| 			wantedPerm.GetPublicComment(),
 | |
| 		)
 | |
| 
 | |
| 	default:
 | |
| 		// Perm exists and is managed by us.
 | |
| 		//
 | |
| 		// TODO: update public/private comment
 | |
| 		// from latest version if it's changed.
 | |
| 		l.Debug("permission already exists and is managed by this subscription, skipping")
 | |
| 		return false, nil
 | |
| 	}
 | |
| 
 | |
| 	if err != nil && !errors.Is(err, db.ErrAlreadyExists) {
 | |
| 		// Proper db error.
 | |
| 		return false, err
 | |
| 	}
 | |
| 
 | |
| 	return true, nil
 | |
| }
 | |
| 
 | |
| func permsFromCSV(
 | |
| 	l log.Entry,
 | |
| 	permType gtsmodel.DomainPermissionType,
 | |
| 	body io.ReadCloser,
 | |
| ) ([]gtsmodel.DomainPermission, error) {
 | |
| 	csvReader := csv.NewReader(body)
 | |
| 
 | |
| 	// Read and validate column headers.
 | |
| 	columnHeaders, err := csvReader.Read()
 | |
| 	if err != nil {
 | |
| 		body.Close()
 | |
| 		return nil, gtserror.NewfAt(3, "error decoding csv column headers: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	var (
 | |
| 		domainI        *int
 | |
| 		severityI      *int
 | |
| 		publicCommentI *int
 | |
| 		obfuscateI     *int
 | |
| 	)
 | |
| 
 | |
| 	for i, columnHeader := range columnHeaders {
 | |
| 		// Remove leading # if present.
 | |
| 		columnHeader = strings.TrimLeft(columnHeader, "#")
 | |
| 
 | |
| 		// Find index of each column header we
 | |
| 		// care about, ensuring no duplicates.
 | |
| 		switch {
 | |
| 
 | |
| 		case columnHeader == "domain":
 | |
| 			if domainI != nil {
 | |
| 				body.Close()
 | |
| 				err := gtserror.NewfAt(3, "duplicate domain column header in csv: %+v", columnHeaders)
 | |
| 				return nil, err
 | |
| 			}
 | |
| 			domainI = &i
 | |
| 
 | |
| 		case columnHeader == "severity":
 | |
| 			if severityI != nil {
 | |
| 				body.Close()
 | |
| 				err := gtserror.NewfAt(3, "duplicate severity column header in csv: %+v", columnHeaders)
 | |
| 				return nil, err
 | |
| 			}
 | |
| 			severityI = &i
 | |
| 
 | |
| 		case columnHeader == "public_comment" || columnHeader == "comment":
 | |
| 			if publicCommentI != nil {
 | |
| 				body.Close()
 | |
| 				err := gtserror.NewfAt(3, "duplicate public_comment or comment column header in csv: %+v", columnHeaders)
 | |
| 				return nil, err
 | |
| 			}
 | |
| 			publicCommentI = &i
 | |
| 
 | |
| 		case columnHeader == "obfuscate":
 | |
| 			if obfuscateI != nil {
 | |
| 				body.Close()
 | |
| 				err := gtserror.NewfAt(3, "duplicate obfuscate column header in csv: %+v", columnHeaders)
 | |
| 				return nil, err
 | |
| 			}
 | |
| 			obfuscateI = &i
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// Ensure we have at least a domain
 | |
| 	// index, as that's the bare minimum.
 | |
| 	if domainI == nil {
 | |
| 		body.Close()
 | |
| 		err := gtserror.NewfAt(3, "no domain column header in csv: %+v", columnHeaders)
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	// Read remaining CSV records.
 | |
| 	records, err := csvReader.ReadAll()
 | |
| 
 | |
| 	// Totally done
 | |
| 	// with body now.
 | |
| 	body.Close()
 | |
| 
 | |
| 	// Check for decode error.
 | |
| 	if err != nil {
 | |
| 		err := gtserror.NewfAt(3, "error decoding body into csv: %w", err)
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	// Make sure we actually
 | |
| 	// have some records.
 | |
| 	if len(records) == 0 {
 | |
| 		return nil, nil
 | |
| 	}
 | |
| 
 | |
| 	// Convert records to permissions slice.
 | |
| 	perms := make([]gtsmodel.DomainPermission, 0, len(records))
 | |
| 	for _, record := range records {
 | |
| 		if len(record) != 6 {
 | |
| 			l.Warnf("skipping invalid-length record: %+v", record)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Skip records that specify severity
 | |
| 		// that's not "suspend" (we don't support
 | |
| 		// "silence" or "limit" or whatever yet).
 | |
| 		if severityI != nil {
 | |
| 			severity := record[*severityI]
 | |
| 			if severity != "suspend" {
 | |
| 				l.Warnf("skipping non-suspend record: %+v", record)
 | |
| 				continue
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// Normalize + validate domain.
 | |
| 		domainRaw := record[*domainI]
 | |
| 		domain, err := util.PunifySafely(domainRaw)
 | |
| 		if err != nil {
 | |
| 			l.Warnf("skipping invalid domain %s: %+v", domainRaw, err)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Instantiate the permission
 | |
| 		// as either block or allow.
 | |
| 		var perm gtsmodel.DomainPermission
 | |
| 		switch permType {
 | |
| 		case gtsmodel.DomainPermissionBlock:
 | |
| 			perm = >smodel.DomainBlock{Domain: domain}
 | |
| 		case gtsmodel.DomainPermissionAllow:
 | |
| 			perm = >smodel.DomainAllow{Domain: domain}
 | |
| 		}
 | |
| 
 | |
| 		// Set remaining optional fields
 | |
| 		// if they're present in the CSV.
 | |
| 		if publicCommentI != nil {
 | |
| 			perm.SetPublicComment(record[*publicCommentI])
 | |
| 		}
 | |
| 
 | |
| 		var obfuscate bool
 | |
| 		if obfuscateI != nil {
 | |
| 			obfuscate, err = strconv.ParseBool(record[*obfuscateI])
 | |
| 			if err != nil {
 | |
| 				l.Warnf("couldn't parse obfuscate field of record: %+v", record)
 | |
| 				continue
 | |
| 			}
 | |
| 		}
 | |
| 		perm.SetObfuscate(&obfuscate)
 | |
| 
 | |
| 		// We're done.
 | |
| 		perms = append(perms, perm)
 | |
| 	}
 | |
| 
 | |
| 	return perms, nil
 | |
| }
 | |
| 
 | |
| func permsFromJSON(
 | |
| 	l log.Entry,
 | |
| 	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 {
 | |
| 
 | |
| 		// Normalize + validate domain.
 | |
| 		domainRaw := apiPerm.Domain.Domain
 | |
| 		domain, err := util.PunifySafely(domainRaw)
 | |
| 		if err != nil {
 | |
| 			l.Warnf("skipping invalid domain %s: %+v", domainRaw, err)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Instantiate the permission
 | |
| 		// as either block or allow.
 | |
| 		var perm gtsmodel.DomainPermission
 | |
| 		switch permType {
 | |
| 		case gtsmodel.DomainPermissionBlock:
 | |
| 			perm = >smodel.DomainBlock{Domain: domain}
 | |
| 		case gtsmodel.DomainPermissionAllow:
 | |
| 			perm = >smodel.DomainAllow{Domain: domain}
 | |
| 		}
 | |
| 
 | |
| 		// Set remaining fields.
 | |
| 		publicComment := cmp.Or(apiPerm.PublicComment, apiPerm.Comment)
 | |
| 		perm.SetPublicComment(util.PtrOrZero(publicComment))
 | |
| 		perm.SetObfuscate(util.Ptr(util.PtrOrZero(apiPerm.Obfuscate)))
 | |
| 
 | |
| 		// We're done.
 | |
| 		perms = append(perms, perm)
 | |
| 	}
 | |
| 
 | |
| 	return perms, nil
 | |
| }
 | |
| 
 | |
| func permsFromPlain(
 | |
| 	l log.Entry,
 | |
| 	permType gtsmodel.DomainPermissionType,
 | |
| 	body io.ReadCloser,
 | |
| ) ([]gtsmodel.DomainPermission, error) {
 | |
| 	// Scan + split by line.
 | |
| 	sc := bufio.NewScanner(body)
 | |
| 
 | |
| 	// Read into domains
 | |
| 	// line by line.
 | |
| 	var domains []string
 | |
| 	for sc.Scan() {
 | |
| 		domains = append(domains, sc.Text())
 | |
| 	}
 | |
| 
 | |
| 	// Whatever happened, we're
 | |
| 	// done with the body now.
 | |
| 	body.Close()
 | |
| 
 | |
| 	// Check if error reading body.
 | |
| 	if err := sc.Err(); err != nil {
 | |
| 		return nil, gtserror.NewfAt(3, "error decoding into plain: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Convert raw domains to permissions.
 | |
| 	perms := make([]gtsmodel.DomainPermission, 0, len(domains))
 | |
| 	for _, domainRaw := range domains {
 | |
| 
 | |
| 		// Normalize + validate domain as ASCII.
 | |
| 		domain, err := util.PunifySafely(domainRaw)
 | |
| 		if err != nil {
 | |
| 			l.Warnf("skipping invalid domain %s: %+v", domainRaw, err)
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Instantiate the permission
 | |
| 		// as either block or allow.
 | |
| 		var perm gtsmodel.DomainPermission
 | |
| 		switch permType {
 | |
| 		case gtsmodel.DomainPermissionBlock:
 | |
| 			perm = >smodel.DomainBlock{
 | |
| 				Domain:    domain,
 | |
| 				Obfuscate: util.Ptr(false),
 | |
| 			}
 | |
| 		case gtsmodel.DomainPermissionAllow:
 | |
| 			perm = >smodel.DomainAllow{
 | |
| 				Domain:    domain,
 | |
| 				Obfuscate: util.Ptr(false),
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// We're done.
 | |
| 		perms = append(perms, perm)
 | |
| 	}
 | |
| 
 | |
| 	return perms, 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 perm
 | |
| 	// of appropriate type.
 | |
| 	var dbErr error
 | |
| 	switch permType {
 | |
| 	case gtsmodel.DomainPermissionBlock:
 | |
| 		existingPerm, dbErr = s.state.DB.GetDomainBlock(ctx, domain)
 | |
| 	case gtsmodel.DomainPermissionAllow:
 | |
| 		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
 | |
| }
 | |
| 
 | |
| func (s *Subscriptions) adoptPerm(
 | |
| 	ctx context.Context,
 | |
| 	perm gtsmodel.DomainPermission,
 | |
| 	permSub *gtsmodel.DomainPermissionSubscription,
 | |
| 	obfuscate *bool,
 | |
| 	privateComment string,
 | |
| 	publicComment string,
 | |
| ) error {
 | |
| 	// Set to our sub ID + this subs's
 | |
| 	// account as we're managing it now.
 | |
| 	perm.SetSubscriptionID(permSub.ID)
 | |
| 	perm.SetCreatedByAccountID(permSub.CreatedByAccount.ID)
 | |
| 	perm.SetCreatedByAccount(permSub.CreatedByAccount)
 | |
| 
 | |
| 	// Set new metadata on the perm.
 | |
| 	perm.SetPrivateComment(privateComment)
 | |
| 	perm.SetPublicComment(publicComment)
 | |
| 
 | |
| 	// Avoid trying to blat nil into the db directly by
 | |
| 	// defaulting to false if not set on wanted perm.
 | |
| 	perm.SetObfuscate(cmp.Or(obfuscate, util.Ptr(false)))
 | |
| 
 | |
| 	// Update the perm in the db.
 | |
| 	var err error
 | |
| 	switch p := perm.(type) {
 | |
| 	case *gtsmodel.DomainBlock:
 | |
| 		err = s.state.DB.UpdateDomainBlock(ctx, p)
 | |
| 	case *gtsmodel.DomainAllow:
 | |
| 		err = s.state.DB.UpdateDomainAllow(ctx, p)
 | |
| 	}
 | |
| 
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func (s *Subscriptions) processRetractions(
 | |
| 	ctx context.Context,
 | |
| 	l log.Entry,
 | |
| 	permSub *gtsmodel.DomainPermissionSubscription,
 | |
| 	wantedPerms []gtsmodel.DomainPermission,
 | |
| ) (bool, error) {
 | |
| 	var (
 | |
| 		isBlocks        = permSub.PermissionType == gtsmodel.DomainPermissionBlock
 | |
| 		removeRetracted = *permSub.RemoveRetracted
 | |
| 
 | |
| 		// True if at least one
 | |
| 		// retraction has occurred.
 | |
| 		retracted bool
 | |
| 	)
 | |
| 
 | |
| 	// Gather existing perms into an interface type.
 | |
| 	existingPerms := []gtsmodel.DomainPermission{}
 | |
| 	if isBlocks {
 | |
| 		existingBlocks, err := s.state.DB.GetDomainBlocksBySubscriptionID(ctx, permSub.ID)
 | |
| 		if err != nil && !errors.Is(err, db.ErrNoEntries) {
 | |
| 			// Proper db error.
 | |
| 			err := gtserror.Newf("db error getting existing blocks owned by perm sub: %w", err)
 | |
| 			return retracted, err
 | |
| 		}
 | |
| 		for _, existingBlock := range existingBlocks {
 | |
| 			existingPerms = append(existingPerms, existingBlock)
 | |
| 		}
 | |
| 	} else {
 | |
| 		existingAllows, err := s.state.DB.GetDomainAllowsBySubscriptionID(ctx, permSub.ID)
 | |
| 		if err != nil && !errors.Is(err, db.ErrNoEntries) {
 | |
| 			// Proper db error.
 | |
| 			err := gtserror.Newf("db error getting existing allows owned by perm sub: %w", err)
 | |
| 			return retracted, err
 | |
| 		}
 | |
| 		for _, existingAllow := range existingAllows {
 | |
| 			existingPerms = append(existingPerms, existingAllow)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// For each existing permission, check if
 | |
| 	// it's included in the list of wanted perms
 | |
| 	// that's just been freshly fetched + created.
 | |
| 	//
 | |
| 	// If it's not, we should consider it retracted
 | |
| 	// and handle retraction effects appropriately.
 | |
| 	for _, existingPerm := range existingPerms {
 | |
| 		if slices.ContainsFunc(
 | |
| 			wantedPerms,
 | |
| 			func(wantedPerm gtsmodel.DomainPermission) bool {
 | |
| 				return existingPerm.GetDomain() == wantedPerm.GetDomain()
 | |
| 			},
 | |
| 		) {
 | |
| 			// This permission from the
 | |
| 			// database exists in wanted
 | |
| 			// perms, so it's not been
 | |
| 			// retracted, leave it alone.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// This perm exists in the database but
 | |
| 		// not in wanted perms, so it has been
 | |
| 		// retracted, check what we need to do.
 | |
| 		domain := existingPerm.GetDomain()
 | |
| 		l.WithField("domain", domain).Info("handling retraction")
 | |
| 
 | |
| 		var (
 | |
| 			dbF     func() error
 | |
| 			action  *gtsmodel.AdminAction
 | |
| 			actionF admin.ActionF
 | |
| 		)
 | |
| 
 | |
| 		switch {
 | |
| 
 | |
| 		// Remove this block.
 | |
| 		case isBlocks && removeRetracted:
 | |
| 			dbF = func() error { return s.state.DB.DeleteDomainBlock(ctx, domain) }
 | |
| 			action = >smodel.AdminAction{
 | |
| 				ID:             id.NewULID(),
 | |
| 				TargetCategory: gtsmodel.AdminActionCategoryDomain,
 | |
| 				TargetID:       domain,
 | |
| 				Type:           gtsmodel.AdminActionUnsuspend,
 | |
| 				AccountID:      permSub.CreatedByAccountID,
 | |
| 			}
 | |
| 			actionF = s.state.AdminActions.DomainUnblockF(
 | |
| 				action.ID,
 | |
| 				existingPerm.(*gtsmodel.DomainBlock),
 | |
| 			)
 | |
| 
 | |
| 		// Remove this allow.
 | |
| 		case !isBlocks && removeRetracted:
 | |
| 			dbF = func() error { return s.state.DB.DeleteDomainAllow(ctx, domain) }
 | |
| 			action = >smodel.AdminAction{
 | |
| 				ID:             id.NewULID(),
 | |
| 				TargetCategory: gtsmodel.AdminActionCategoryDomain,
 | |
| 				TargetID:       domain,
 | |
| 				Type:           gtsmodel.AdminActionUnallow,
 | |
| 				AccountID:      permSub.CreatedByAccountID,
 | |
| 			}
 | |
| 			actionF = s.state.AdminActions.DomainUnallowF(
 | |
| 				action.ID,
 | |
| 				existingPerm.(*gtsmodel.DomainAllow),
 | |
| 			)
 | |
| 
 | |
| 		// Orphan this block.
 | |
| 		case isBlocks:
 | |
| 			block := existingPerm.(*gtsmodel.DomainBlock)
 | |
| 			block.SubscriptionID = ""
 | |
| 			dbF = func() error { return s.state.DB.UpdateDomainBlock(ctx, block, "subscription_id") }
 | |
| 
 | |
| 		// Orphan this allow.
 | |
| 		case !isBlocks:
 | |
| 			allow := existingPerm.(*gtsmodel.DomainAllow)
 | |
| 			allow.SubscriptionID = ""
 | |
| 			dbF = func() error { return s.state.DB.UpdateDomainAllow(ctx, allow, "subscription_id") }
 | |
| 		}
 | |
| 
 | |
| 		// Run the retraction db
 | |
| 		// func to either delete
 | |
| 		// or update the perm.
 | |
| 		if err := dbF(); err != nil {
 | |
| 			return retracted, err
 | |
| 		}
 | |
| 
 | |
| 		// Mark that at least one
 | |
| 		// retraction has been done.
 | |
| 		if !retracted {
 | |
| 			retracted = true
 | |
| 		}
 | |
| 
 | |
| 		if action == nil {
 | |
| 			// No side effects;
 | |
| 			// nothing else to do.
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		// Run the side effects.
 | |
| 		if err := s.state.AdminActions.Run(ctx, action, actionF); err != nil {
 | |
| 			err := gtserror.Newf("error running side effects: %w", err)
 | |
| 			return retracted, err
 | |
| 		}
 | |
| 
 | |
| 		// TODO: Remove draft(s) as well?
 | |
| 	}
 | |
| 
 | |
| 	return retracted, nil
 | |
| }
 |