~netlandish/links-dev

links: Add additional changes to help filter out spam accounts. v1 APPLIED

Peter Sanchez: 1
 Add additional changes to help filter out spam accounts.

 13 files changed, 343 insertions(+), 23 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/175/mbox | git am -3
Learn more about email & git

[PATCH links] Add additional changes to help filter out spam accounts. Export this patch

Changelog-updated: More changes to combat link spam farms.
---
 admin/routes.go                               |   2 +
 api/api_test.go                               | 196 ++++++++++++++++++
 api/graph/generated.go                        |  79 ++++++-
 api/graph/model/models_gen.go                 |   3 +-
 api/graph/schema.graphqls                     |   1 +
 api/graph/schema.resolvers.go                 |  39 +++-
 billing/processors.go                         |   4 +
 cmd/migrations.go                             |   7 +
 ...008_change_org_default_visibility.down.sql |   2 +
 .../0008_change_org_default_visibility.up.sql |   8 +
 models/organization.go                        |  21 +-
 models/schema.sql                             |   2 +-
 templates/admin_org_detail.html               |   2 +-
 13 files changed, 343 insertions(+), 23 deletions(-)
 create mode 100644 migrations/0008_change_org_default_visibility.down.sql
 create mode 100644 migrations/0008_change_org_default_visibility.up.sql

diff --git a/admin/routes.go b/admin/routes.go
index 8d42781..cb2e926 100644
--- a/admin/routes.go
+++ b/admin/routes.go
@@ -394,6 +394,7 @@ func (s *Service) OrgDetail(c echo.Context) error {
	pd.Data["no_domains"] = lt.Translate("No Domains")
	pd.Data["update_type"] = lt.Translate("Update Type")
	pd.Data["visibility"] = lt.Translate("Visibility")
	pd.Data["forced"] = lt.Translate("FORCED")

	type GraphQLResponse struct {
		Org *models.Organization `json:"getOrganization"`
@@ -412,6 +413,7 @@ func (s *Service) OrgDetail(c echo.Context) error {
					billing {
						status
					}
					visibilityOverride
				}
		}
	}`)
diff --git a/api/api_test.go b/api/api_test.go
index 4e41f83..ee0bf90 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -2,6 +2,8 @@ package main_test

import (
	"context"
	"database/sql"
	"fmt"
	"links"
	"links/cmd"
	"links/cmd/test"
@@ -2358,6 +2360,200 @@ func TestAPI(t *testing.T) {
		c.Equal("gqlclient: server failure: Link Short Not Found", err.Error())
	})

	t.Run("recent links visibility filter", func(t *testing.T) {
		user := test.NewTestUser(1, false, false, true, true)
		authCtx := auth.Context(ctx, user)

		var orgHidden, orgForced, orgPublic, orgPersonal models.Organization
		err := database.WithTx(dbCtx, nil, func(tx *sql.Tx) error {
			// Hidden org that should auto-promote after 4 links
			orgHidden = models.Organization{
				OwnerID:    int(user.ID),
				OrgType:    models.OrgTypeUser,
				Name:       "Hidden Org",
				Slug:       "hidden-org",
				IsActive:   true,
				Visibility: models.VisibilityHidden,
				Settings: models.OrganizationSettings{
					Billing: models.BillingSettings{
						Status: models.BillingStatusFree,
					},
				},
			}
			if err := orgHidden.Store(dbCtx); err != nil {
				return err
			}

			// Forced hidden org that should stay hidden even with 5+ links
			orgForced = models.Organization{
				OwnerID:    int(user.ID),
				OrgType:    models.OrgTypeUser,
				Name:       "Forced Hidden Org",
				Slug:       "forced-hidden-org",
				IsActive:   true,
				Visibility: models.VisibilityHidden,
				Settings: models.OrganizationSettings{
					VisibilityOverride: "FORCED",
					Billing: models.BillingSettings{
						Status: models.BillingStatusFree,
					},
				},
			}
			if err := orgForced.Store(dbCtx); err != nil {
				return err
			}

			orgPublic = models.Organization{
				OwnerID:    int(user.ID),
				OrgType:    models.OrgTypeUser,
				Name:       "Public Free Org",
				Slug:       "public-free-org",
				IsActive:   true,
				Visibility: models.VisibilityPublic,
				Settings: models.OrganizationSettings{
					Billing: models.BillingSettings{
						Status: models.BillingStatusFree,
					},
				},
			}
			if err := orgPublic.Store(dbCtx); err != nil {
				return err
			}

			orgPersonal = models.Organization{
				OwnerID:    int(user.ID),
				OrgType:    models.OrgTypeUser,
				Name:       "Personal Org Recent",
				Slug:       "personal-org-recent",
				IsActive:   true,
				Visibility: models.VisibilityPublic,
				Settings: models.OrganizationSettings{
					Billing: models.BillingSettings{
						Status: models.BillingStatusPersonal,
					},
				},
			}
			if err := orgPersonal.Store(dbCtx); err != nil {
				return err
			}

			for i := 0; i < 2; i++ {
				baseURL := models.BaseURL{
					URL:        fmt.Sprintf("https://hidden.com/%d", i),
					Visibility: models.VisibilityPublic,
				}
				if err := baseURL.Store(dbCtx); err != nil {
					return fmt.Errorf("failed to store hidden base URL %d: %w", i, err)
				}
				
				link := models.OrgLink{
					Title:      fmt.Sprintf("Hidden Link %d", i),
					URL:        fmt.Sprintf("https://hidden.com/%d", i),
					BaseURLID:  baseURL.ID,
					OrgID:      orgHidden.ID,
					UserID:     int(user.ID),
					Visibility: models.OrgLinkVisibilityPublic,
				}
				if err := link.Store(dbCtx); err != nil {
					return fmt.Errorf("failed to store hidden link %d: %w", i, err)
				}
			}

			// Add 5 links to forced hidden org - it should still stay hidden
			for i := 0; i < 5; i++ {
				baseURL := models.BaseURL{
					URL:        fmt.Sprintf("https://forced.com/%d", i),
					Visibility: models.VisibilityPublic,
				}
				if err := baseURL.Store(dbCtx); err != nil {
					return fmt.Errorf("failed to store forced base URL %d: %w", i, err)
				}
				
				link := models.OrgLink{
					Title:      fmt.Sprintf("Forced Link %d", i),
					URL:        fmt.Sprintf("https://forced.com/%d", i),
					BaseURLID:  baseURL.ID,
					OrgID:      orgForced.ID,
					UserID:     int(user.ID),
					Visibility: models.OrgLinkVisibilityPublic,
				}
				if err := link.Store(dbCtx); err != nil {
					return fmt.Errorf("failed to store forced link %d: %w", i, err)
				}
			}

			for i := 0; i < 5; i++ {
				baseURL := models.BaseURL{
					URL:        fmt.Sprintf("https://public.com/%d", i),
					Visibility: models.VisibilityPublic,
				}
				if err := baseURL.Store(dbCtx); err != nil {
					return fmt.Errorf("failed to store public base URL %d: %w", i, err)
				}
				
				link := models.OrgLink{
					Title:      fmt.Sprintf("Public Link %d", i),
					URL:        fmt.Sprintf("https://public.com/%d", i),
					BaseURLID:  baseURL.ID,
					OrgID:      orgPublic.ID,
					UserID:     int(user.ID),
					Visibility: models.OrgLinkVisibilityPublic,
				}
				if err := link.Store(dbCtx); err != nil {
					return fmt.Errorf("failed to store public link %d: %w", i, err)
				}
			}

			baseURL := models.BaseURL{
				URL:        "https://personal.com",
				Visibility: models.VisibilityPublic,
			}
			if err := baseURL.Store(dbCtx); err != nil {
				return fmt.Errorf("failed to store personal base URL: %w", err)
			}
			
			link := models.OrgLink{
				Title:      "Personal Link",
				URL:        "https://personal.com",
				BaseURLID:  baseURL.ID,
				OrgID:      orgPersonal.ID,
				UserID:     int(user.ID),
				Visibility: models.OrgLinkVisibilityPublic,
			}
			return link.Store(dbCtx)
		})
		c.NoError(err)

		type GraphQLResponse struct {
			OrgLinks struct {
				Result []models.OrgLink `json:"result"`
			} `json:"getOrgLinks"`
		}
		var result GraphQLResponse
		q := `query GetRecentLinks {
			getOrgLinks(input: {}) {
				result {
					id
					title
					orgSlug
				}
			}
		}`
		op := gqlclient.NewOperation(q)
		err = links.Execute(authCtx, op, &result)
		c.NoError(err)

		orgSlugs := make(map[string]bool)
		for _, link := range result.OrgLinks.Result {
			orgSlugs[link.OrgSlug] = true
		}

		c.False(orgSlugs["hidden-org"], "HIDDEN org should be filtered")
		c.False(orgSlugs["forced-hidden-org"], "FORCED HIDDEN org should be filtered even with 5+ links")
		c.True(orgSlugs["public-free-org"], "PUBLIC org should appear")
		c.True(orgSlugs["personal-org-recent"], "Non-FREE PUBLIC org should always appear")
	})

	t.Run("list listings denied", func(t *testing.T) {
		ctx := server.ServerContext(context.Background(), srv)
		ctx = auth.Context(ctx, test.NewTestUser(2, false, false, true, true))
diff --git a/api/graph/generated.go b/api/graph/generated.go
index 5acb2f3..94c875b 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -337,8 +337,9 @@ type ComplexityRoot struct {
	}

	OrganizationSettings struct {
		Billing     func(childComplexity int) int
		DefaultPerm func(childComplexity int) int
		Billing            func(childComplexity int) int
		DefaultPerm        func(childComplexity int) int
		VisibilityOverride func(childComplexity int) int
	}

	OrganizationStats struct {
@@ -2104,6 +2105,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin

		return e.complexity.OrganizationSettings.DefaultPerm(childComplexity), true

	case "OrganizationSettings.visibilityOverride":
		if e.complexity.OrganizationSettings.VisibilityOverride == nil {
			break
		}

		return e.complexity.OrganizationSettings.VisibilityOverride(childComplexity), true

	case "OrganizationStats.links":
		if e.complexity.OrganizationStats.Links == nil {
			break
@@ -15292,6 +15300,8 @@ func (ec *executionContext) fieldContext_Organization_settings(_ context.Context
				return ec.fieldContext_OrganizationSettings_defaultPerm(ctx, field)
			case "billing":
				return ec.fieldContext_OrganizationSettings_billing(ctx, field)
			case "visibilityOverride":
				return ec.fieldContext_OrganizationSettings_visibilityOverride(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type OrganizationSettings", field.Name)
		},
@@ -15793,6 +15803,69 @@ func (ec *executionContext) fieldContext_OrganizationSettings_billing(_ context.
	return fc, nil
}

func (ec *executionContext) _OrganizationSettings_visibilityOverride(ctx context.Context, field graphql.CollectedField, obj *models.OrganizationSettings) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrganizationSettings_visibilityOverride(ctx, field)
	if err != nil {
		return graphql.Null
	}
	ctx = graphql.WithFieldContext(ctx, fc)
	defer func() {
		if r := recover(); r != nil {
			ec.Error(ctx, ec.Recover(ctx, r))
			ret = graphql.Null
		}
	}()
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
		directive0 := func(rctx context.Context) (any, error) {
			ctx = rctx // use context from middleware stack in children
			return obj.VisibilityOverride, nil
		}

		directive1 := func(ctx context.Context) (any, error) {
			if ec.directives.Admin == nil {
				var zeroVal string
				return zeroVal, errors.New("directive admin is not implemented")
			}
			return ec.directives.Admin(ctx, obj, directive0)
		}

		tmp, err := directive1(rctx)
		if err != nil {
			return nil, graphql.ErrorOnPath(ctx, err)
		}
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(string); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be string`, tmp)
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		return graphql.Null
	}
	res := resTmp.(string)
	fc.Result = res
	return ec.marshalOString2string(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_OrganizationSettings_visibilityOverride(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "OrganizationSettings",
		Field:      field,
		IsMethod:   false,
		IsResolver: false,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			return nil, errors.New("field of type String does not have child fields")
		},
	}
	return fc, nil
}

func (ec *executionContext) _OrganizationStats_links(ctx context.Context, field graphql.CollectedField, obj *model.OrganizationStats) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrganizationStats_links(ctx, field)
	if err != nil {
@@ -28728,6 +28801,8 @@ func (ec *executionContext) _OrganizationSettings(ctx context.Context, sel ast.S
			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
		case "billing":
			out.Values[i] = ec._OrganizationSettings_billing(ctx, field, obj)
		case "visibilityOverride":
			out.Values[i] = ec._OrganizationSettings_visibilityOverride(ctx, field, obj)
		default:
			panic("unknown field " + strconv.Quote(field.Name))
		}
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index ac45cf5..afc1aff 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -435,7 +435,8 @@ type TagInput struct {
	OrgSlug string        `json:"orgSlug"`
	Service DomainService `json:"service"`
	Tag     string        `json:"tag"`
	NewTag  *string       `json:"newTag,omitempty"`
	// newTag is required for `renameTag` mutation
	NewTag *string `json:"newTag,omitempty"`
}

type UpdateAdminDomainInput struct {
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 4799141..273d2aa 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -171,6 +171,7 @@ type BillingSettings {
type OrganizationSettings {
    defaultPerm: LinkVisibility!
    billing: BillingSettings
    visibilityOverride: String @admin
}

type Organization {
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 220803d..e05241f 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -40,8 +40,8 @@ import (
	"golang.org/x/image/draw"
	"golang.org/x/net/idna"
	"netlandish.com/x/gobwebs"
	auditlog "netlandish.com/x/gobwebs-auditlog"
	oauth2 "netlandish.com/x/gobwebs-oauth2"
	"netlandish.com/x/gobwebs-auditlog"
	"netlandish.com/x/gobwebs-oauth2"
	gaccounts "netlandish.com/x/gobwebs/accounts"
	gcore "netlandish.com/x/gobwebs/core"
	"netlandish.com/x/gobwebs/crypto"
@@ -548,6 +548,22 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
		return nil, err
	}

	if org.Visibility == models.VisibilityHidden &&
		org.Settings.VisibilityOverride != "FORCED" &&
		org.Settings.Billing.Status == models.BillingStatusFree {
		opts := &database.FilterOptions{
			Filter: sq.Eq{"org_id": org.ID},
		}
		linkCount, err := models.GetOrgLinksCount(ctx, opts)
		if err == nil && linkCount >= 4 {
			org.Visibility = models.VisibilityPublic
			err = org.Store(ctx)
			if err != nil {
				srv.Logger().Printf("Failed to auto-promote org to PUBLIC: %v (org_id: %d)", err, org.ID)
			}
		}
	}

	if input.Archive {
		srv.QueueTask("general", core.ArchiveURLSnapshotTask(srv, OrgLink))
	}
@@ -1590,11 +1606,12 @@ func (r *mutationResolver) Register(ctx context.Context, input *model.RegisterIn
	}

	org := &models.Organization{
		OwnerID:  int(user.ID),
		OrgType:  models.OrgTypeUser,
		Name:     user.Name,
		Slug:     slug,
		IsActive: true,
		OwnerID:    int(user.ID),
		OrgType:    models.OrgTypeUser,
		Name:       user.Name,
		Slug:       slug,
		IsActive:   true,
		Visibility: models.VisibilityHidden,
		Settings: models.OrganizationSettings{
			DefaultPerm: models.OrgLinkVisibilityPublic,
			Billing: models.BillingSettings{
@@ -4422,6 +4439,12 @@ func (r *mutationResolver) UpdateAdminOrg(ctx context.Context, input model.Admin
			return nil, nil
		}
		org.Visibility = vis

		if vis == models.VisibilityHidden {
			org.Settings.VisibilityOverride = "FORCED"
		} else {
			org.Settings.VisibilityOverride = ""
		}
		hasChanges = true
	}

@@ -4984,7 +5007,7 @@ func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
	return &model.Version{
		Major:           0,
		Minor:           7,
		Patch:           0,
		Patch:           1,
		DeprecationDate: nil,
	}, nil
}
diff --git a/billing/processors.go b/billing/processors.go
index c75448c..d73a599 100644
--- a/billing/processors.go
@@ -92,6 +92,10 @@ func ProcessCheckoutSessionCompletedTask(ctx context.Context, conf *config.Confi
		org.IsActive = true
	}

	if org.Visibility == models.VisibilityHidden && org.Settings.VisibilityOverride != "FORCED" {
		org.Visibility = models.VisibilityPublic
	}

	err = org.Store(ctx)
	if err != nil {
		return err
diff --git a/cmd/migrations.go b/cmd/migrations.go
index 892605c..77ed113 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -73,5 +73,12 @@ func GetMigrations() []migrate.Migration {
			0,
			links.MigrateFS,
		),
		migrate.FSFileMigration(
			"0008_change_org_default_visibility",
			"migrations/0008_change_org_default_visibility.up.sql",
			"migrations/0008_change_org_default_visibility.down.sql",
			0,
			links.MigrateFS,
		),
	}
}
diff --git a/migrations/0008_change_org_default_visibility.down.sql b/migrations/0008_change_org_default_visibility.down.sql
new file mode 100644
index 0000000..3480d66
--- /dev/null
+++ b/migrations/0008_change_org_default_visibility.down.sql
@@ -0,0 +1,2 @@
ALTER TABLE organizations ALTER COLUMN visibility SET DEFAULT 'PUBLIC';

diff --git a/migrations/0008_change_org_default_visibility.up.sql b/migrations/0008_change_org_default_visibility.up.sql
new file mode 100644
index 0000000..e2ae9c5
--- /dev/null
+++ b/migrations/0008_change_org_default_visibility.up.sql
@@ -0,0 +1,8 @@
-- Set visibilityOverride for existing HIDDEN organizations (using camelCase)
UPDATE organizations 
SET settings = jsonb_set(settings, '{visibilityOverride}', '"FORCED"'::jsonb)
WHERE visibility = 'HIDDEN';

-- Change default visibility for new organizations
ALTER TABLE organizations ALTER COLUMN visibility SET DEFAULT 'HIDDEN';

diff --git a/models/organization.go b/models/organization.go
index 551d308..61cf9eb 100644
--- a/models/organization.go
+++ b/models/organization.go
@@ -50,8 +50,9 @@ type BillingSettings struct {

// OrganizationSettings ...
type OrganizationSettings struct {
	DefaultPerm string          `json:"default_perm"` // default: OrgLinkVisibilityPublic
	Billing     BillingSettings `json:"billing"`
	DefaultPerm        string          `json:"default_perm"` // default: OrgLinkVisibilityPublic
	Billing            BillingSettings `json:"billing"`
	VisibilityOverride string          `json:"visibilityOverride,omitempty"` // "FORCED" when manually set to HIDDEN
}

// Value ...
@@ -173,9 +174,9 @@ func (o *Organization) Store(ctx context.Context) error {
			err = sq.
				Insert("organizations").
				Columns("owner_id", "org_type", "name", "slug", "image", "timezone",
					"settings", "is_active").
					"settings", "is_active", "visibility").
				Values(o.OwnerID, o.OrgType, o.Name, o.Slug, o.Image, o.Timezone,
					settings, o.IsActive).
					settings, o.IsActive, o.Visibility).
				Suffix(`RETURNING id, created_on, updated_on`).
				PlaceholderFormat(database.GetPlaceholderFormat()).
				RunWith(tx).
@@ -359,7 +360,7 @@ func (o *Organization) TagCloud(ctx context.Context, order TagCloudOrdering) ([]
// DeleteTagForService expects service value to be verified and tag to be provided.
func (o *Organization) DeleteTagForService(ctx context.Context, service string, tag *Tag) error {
	var junctionTable, parentTable, foreignKey string
	

	switch service {
	case DomainServiceLinks:
		junctionTable = "tag_links"
@@ -376,7 +377,7 @@ func (o *Organization) DeleteTagForService(ctx context.Context, service string,
	default:
		return fmt.Errorf("Invalid service type")
	}
	

	whereClause := sq.And{
		sq.Eq{junctionTable + ".tag_id": tag.ID},
		sq.Expr(fmt.Sprintf(
@@ -384,7 +385,7 @@ func (o *Organization) DeleteTagForService(ctx context.Context, service string,
			parentTable, parentTable, junctionTable, foreignKey, parentTable,
		), o.ID),
	}
	

	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		_, err := sq.Delete(junctionTable).
			Where(whereClause).
@@ -399,7 +400,7 @@ func (o *Organization) DeleteTagForService(ctx context.Context, service string,
func (o *Organization) RenameTagForService(ctx context.Context,
	service string, oTag, nTag *Tag) error {
	var junctionTable, parentTable, foreignKey string
	

	switch service {
	case DomainServiceLinks:
		junctionTable = "tag_links"
@@ -416,7 +417,7 @@ func (o *Organization) RenameTagForService(ctx context.Context,
	default:
		return fmt.Errorf("Invalid service type")
	}
	

	whereClause := sq.And{
		sq.Eq{junctionTable + ".tag_id": oTag.ID},
		sq.Expr(fmt.Sprintf(
@@ -424,7 +425,7 @@ func (o *Organization) RenameTagForService(ctx context.Context,
			parentTable, parentTable, junctionTable, foreignKey, parentTable,
		), o.ID),
	}
	

	return database.WithTx(ctx, nil, func(tx *sql.Tx) error {
		_, err := sq.Update(junctionTable).
			Set("tag_id", nTag.ID).
diff --git a/models/schema.sql b/models/schema.sql
index c01f32f..31b2c92 100644
--- a/models/schema.sql
+++ b/models/schema.sql
@@ -106,7 +106,7 @@ CREATE TABLE organizations (
  image VARCHAR(1024) DEFAULT '',
  settings JSONB DEFAULT '{}',
  timezone VARCHAR(20) NOT NULL DEFAULT 'UTC',
  visibility public_visibility DEFAULT 'PUBLIC',
  visibility public_visibility DEFAULT 'HIDDEN',
  is_active BOOLEAN DEFAULT TRUE,
  created_on TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_on TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
diff --git a/templates/admin_org_detail.html b/templates/admin_org_detail.html
index 6997b9f..686f60a 100644
--- a/templates/admin_org_detail.html
+++ b/templates/admin_org_detail.html
@@ -33,7 +33,7 @@
      </tr>
      <tr>
        <th>{{.pd.Data.visibility}}</th>
        <td>{{.org.Visibility}}</td>
        <td>{{.org.Visibility}}{{ if eq .org.Settings.VisibilityOverride "FORCED" }} - <strong>({{ .pd.Data.forced }})</strong>{{ end }}</td>
      </tr>
      <tr>
        <th>{{.pd.Data.is_active}}</th>
-- 
2.49.1
Applied.

To git@git.code.netlandish.com:~netlandish/links
   8db5562..47cf073  master -> master