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