Peter Sanchez: 1 Add additional changes to help filter out spam accounts. 13 files changed, 343 insertions(+), 23 deletions(-)
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 -3Learn more about email & git
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