Subject: [PATCH links] Support for short link monthly limitations versus the temporary delayed redirect page.

From: Peter Sanchez <[email-masked]>
Date: Wed, 12 Feb 2025 07:28:41 -0600

Changelog-changed: If short limit is configured no longer use the
  delayed redirect page for free accounts.
Signed-off-by: Peter Sanchez <[email-masked]>
---
The redirect page always annoyed us and we feel like it's a bad stick to
use to encourage upgrades of those using the hosted service. This will
simply restrict free users to 10 free shortened links per month, which
is generally more than enough.

 api/graph/schema.resolvers.go          | 36 ++++++++++++++++-
 cmd/migrations.go                      |  7 ++++
 config.example.ini                     |  3 ++
 go.mod                                 |  1 +
 go.sum                                 |  8 +---
 migrations/0002_add_audit_log.down.sql |  1 +
 migrations/0002_add_audit_log.up.sql   | 12 ++++++
 models/link_short.go                   |  2 +-
 models/models.go                       |  5 +++
 models/organization.go                 | 56 ++++++++++++++++++--------
 models/qr_codes.go                     |  2 +-
 models/schema.sql                      | 13 ++++++
 models/utils.go                        | 16 ++++++--
 short/routes.go                        | 22 +++++-----
 templates/pricing_list.html            |  6 +--
 15 files changed, 145 insertions(+), 45 deletions(-)
 create mode 100644 migrations/0002_add_audit_log.down.sql
 create mode 100644 migrations/0002_add_audit_log.up.sql

diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 8e7f7d0..d82d48b 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -39,6 +39,7 @@ 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"
 	gaccounts "netlandish.com/x/gobwebs/accounts"
 	gcore "netlandish.com/x/gobwebs/core"
@@ -1980,7 +1981,8 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
 		return nil, valid.ErrAuthorization
 	}
 	user := tokenUser.User.(*models.User)
-	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
+	c := server.EchoForContext(ctx)
+	lang := links.GetLangFromRequest(c.Request(), user)
 	lt := localizer.GetLocalizer(lang)
 
 	validator := valid.New(ctx)
@@ -2061,8 +2063,26 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
 		return nil, nil
 	}
 
-	// Validate domains
 	srv := server.ForContext(ctx)
+
+	// Validate limit within free accounts, if configured
+	if links.BillingEnabled(srv.Config) && org.IsRestricted([]string{models.BillingStatusFree}) {
+		if shortLimit, ok := srv.Config.File.Get("links", "short-limit-month"); ok {
+			if sv, err := strconv.Atoi(shortLimit); err == nil {
+				atLimit, err := org.ReachedMonthlyShortLimit(ctx, sv)
+				if err == nil && atLimit {
+					validator.Error(
+						"%s", lt.Translate(
+							"You have reached your monthly short link limit. Please upgrade to "+
+								"remove this limitation.")).
+						WithCode(valid.ErrRestrictedCode)
+					return nil, nil
+				}
+			}
+		}
+	}
+
+	// Validate domains
 	defaultDomainLookup, ok := srv.Config.File.Get("links", "short-service-domain")
 	if !ok {
 		return nil, fmt.Errorf("%s", lt.Translate("short-service-domain is not configured"))
@@ -2150,6 +2170,18 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
 		}
 	}
 
+	alog := auditlog.New(
+		int(user.ID),
+		c.RealIP(),
+		models.LOG_SHORT_ADDED,
+		fmt.Sprintf("Added short link '%s'", linkShort.ShortCode),
+	)
+	alog.AddMetadata("org_id", org.ID)
+	err = alog.Store(ctx)
+	if err != nil {
+		return nil, err
+	}
+
 	return linkShort, nil
 }
 
diff --git a/cmd/migrations.go b/cmd/migrations.go
index 02a1476..ce7353f 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -31,5 +31,12 @@ func GetMigrations() []migrate.Migration {
 			0,
 			links.MigrateFS,
 		),
+		migrate.FSFileMigration(
+			"0002_add_link_shorts_is_active",
+			"migrations/0002_add_audit_log.up.sql",
+			"migrations/0002_add_audit_log.down.sql",
+			0,
+			links.MigrateFS,
+		),
 	}
 }
diff --git a/config.example.ini b/config.example.ini
index 7d56af8..7ed3145 100644
--- a/config.example.ini
+++ b/config.example.ini
@@ -188,6 +188,9 @@ email-queue-size = 512
 # How many general queue workers. Defaults to 512
 general-queue-size = 512
 
+# How many short links can free accounts create per month
+short-limit-month = 10
+
 [stripe]
 secret-key=
 public-key=
diff --git a/go.mod b/go.mod
index a865294..f176675 100644
--- a/go.mod
+++ b/go.mod
@@ -29,6 +29,7 @@ require (
 	golang.org/x/time v0.5.0
 	hg.code.netlandish.com/~netlandish/sendygo v0.0.0-20230124192435-bbf347776232
 	netlandish.com/x/gobwebs v0.0.0-20250210133053-d6d2609ea06b
+	netlandish.com/x/gobwebs-auditlog v0.0.0-20250212125140-4bc7672b127a
 	netlandish.com/x/gobwebs-formguard v0.0.0-20241220204736-317383081170
 	netlandish.com/x/gobwebs-graphql v0.0.0-20250210133219-e8b6c75f26cf
 	netlandish.com/x/gobwebs-oauth2 v0.0.0-20250210133144-ca8ea95073d6
diff --git a/go.sum b/go.sum
index 601366c..ae8a35b 100644
--- a/go.sum
+++ b/go.sum
@@ -2584,18 +2584,14 @@ modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfp
 modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
 modernc.org/z v1.2.20/go.mod h1:zU9FiF4PbHdOTUxw+IF8j7ArBMRPsHgq10uVPt6xTzo=
 modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4=
-netlandish.com/x/gobwebs v0.0.0-20250114215141-abe19454ea49 h1:4H+Y6yocVcNs/w5FZILLFOn21VtBG5xWpiELvjaDpDY=
-netlandish.com/x/gobwebs v0.0.0-20250114215141-abe19454ea49/go.mod h1:MoHaX1u3Piz7GJaZ5YfG6NjXmlHbjc+7oDuLRtOrOMY=
 netlandish.com/x/gobwebs v0.0.0-20250210133053-d6d2609ea06b h1:gK7poU7Tpgdqh2nGaNvGFVEIu9GnRadXJTUrlUYbMbU=
 netlandish.com/x/gobwebs v0.0.0-20250210133053-d6d2609ea06b/go.mod h1:fKDMjTQdLJtWInVSH5T98i3x3yZ4PWq4hWLybbiIJfM=
+netlandish.com/x/gobwebs-auditlog v0.0.0-20250212125140-4bc7672b127a h1:AaS7ozdAms4S/IK8/6AP4oVXIX8k55QKhTHSplv4j6s=
+netlandish.com/x/gobwebs-auditlog v0.0.0-20250212125140-4bc7672b127a/go.mod h1:X+mCD/4Fatv2+nKKx3PbwnDVXI7iuv1mjWfKrCyp7CY=
 netlandish.com/x/gobwebs-formguard v0.0.0-20241220204736-317383081170 h1:iv47dRbi7yc1h2NhXVpescloOI+Bae/hqBNb1uw8jW4=
 netlandish.com/x/gobwebs-formguard v0.0.0-20241220204736-317383081170/go.mod h1:t89MBCex25e8eHB4WlyXac/ss+8qJ2oZN8rCapYGBXQ=
-netlandish.com/x/gobwebs-graphql v0.0.0-20241126234432-2cc59b2f7ebd h1:QJzQj/+2XAEHM0mCxqYleV0sVM2tNZBS6zP6uuOB+Tk=
-netlandish.com/x/gobwebs-graphql v0.0.0-20241126234432-2cc59b2f7ebd/go.mod h1:HLqiAsZAamUcGy6bF8Vyt0eGXX2UhSl8oO1Z/vUoDlM=
 netlandish.com/x/gobwebs-graphql v0.0.0-20250210133219-e8b6c75f26cf h1:0Qk9UortbYiFcgRy6OkdLOzqlYBnOnEcqpw99YM1Pj4=
 netlandish.com/x/gobwebs-graphql v0.0.0-20250210133219-e8b6c75f26cf/go.mod h1:iHcbg94iotWseyRP+SjvN14qIHT5brlwvhLJt8F2JTA=
-netlandish.com/x/gobwebs-oauth2 v0.0.0-20241220204404-89f1f20efe41 h1:SNhpXQS5kVQUMi4E3VdwJLq0uAdW/Q8VsU/F3WrkL7w=
-netlandish.com/x/gobwebs-oauth2 v0.0.0-20241220204404-89f1f20efe41/go.mod h1:+AvlXsp4pIwzB92VWGMdtjUi5T5m7DMjthXOtp6obbY=
 netlandish.com/x/gobwebs-oauth2 v0.0.0-20250210133144-ca8ea95073d6 h1:hIbRrbzMUkKCCP6c9GH917+OIkYfxKjIGAeyHhJn6Fc=
 netlandish.com/x/gobwebs-oauth2 v0.0.0-20250210133144-ca8ea95073d6/go.mod h1:A0sapIRq3S2c8prC6AK7iAHcrLFSy7PmdeKrBsmYkYw=
 netlandish.com/x/gobwebs-ses-feedback v0.0.0-20241220204650-1fb58398640c h1:H/w1cGcFPOG0IgrdPbs19VfRnPM02JMgPEqQbeDfNh0=
diff --git a/migrations/0002_add_audit_log.down.sql b/migrations/0002_add_audit_log.down.sql
new file mode 100644
index 0000000..c58ea10
--- /dev/null
+++ b/migrations/0002_add_audit_log.down.sql
@@ -0,0 +1 @@
+DROP TABLE audit_log;
diff --git a/migrations/0002_add_audit_log.up.sql b/migrations/0002_add_audit_log.up.sql
new file mode 100644
index 0000000..71bd047
--- /dev/null
+++ b/migrations/0002_add_audit_log.up.sql
@@ -0,0 +1,12 @@
+CREATE TABLE audit_log (
+  id serial PRIMARY KEY,
+  user_id INTEGER REFERENCES users (id) ON DELETE CASCADE,
+  ip_address character varying(50) NOT NULL,
+  event_type character varying(256) NOT NULL,
+  details character varying(512),
+  metadata JSONB DEFAULT '{}',
+  created_on TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX audit_log_id_idx ON audit_log (id);
+CREATE INDEX audit_log_created_on_idx ON audit_log (created_on);
diff --git a/models/link_short.go b/models/link_short.go
index 81de09a..932ff03 100644
--- a/models/link_short.go
+++ b/models/link_short.go
@@ -110,7 +110,7 @@ func (l *LinkShort) Store(ctx context.Context) error {
 	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
 		var err error
 		if l.ShortCode == "" {
-			code, err := getShortCode(ctx, tx, "link_shorts", "short_code")
+			code, err := getShortCode(ctx, tx, "link_shorts", "short_code", sq.Eq{"domain_id": l.DomainID})
 			if err != nil {
 				return err
 			}
diff --git a/models/models.go b/models/models.go
index 6728306..5dff73d 100644
--- a/models/models.go
+++ b/models/models.go
@@ -19,6 +19,11 @@ const (
 	PRIVATERSSFEEDCONF         = 300
 )
 
+// Event types for audit log entries
+const (
+	LOG_SHORT_ADDED = "short_added"
+)
+
 type AnalyticsData map[string]int
 
 // User ...
diff --git a/models/organization.go b/models/organization.go
index 5d94e12..8c9c5ff 100644
--- a/models/organization.go
+++ b/models/organization.go
@@ -279,22 +279,6 @@ func (o *Organization) DisplayBillingStatus(c echo.Context) string {
 	return status[o.Settings.Billing.Status]
 }
 
-// This function ennable or disable based on the boolean flag a batch of organization
-func ToggleOrganizaiongBatch(ctx context.Context, opts *database.FilterOptions, flag bool) error {
-	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
-		var err error
-		_, err = sq.
-			Update("organizations").
-			Set("is_active", flag).
-			Where(opts.Filter).
-			PlaceholderFormat(sq.Dollar).
-			RunWith(tx).
-			ExecContext(ctx)
-		return err
-	})
-	return err
-}
-
 func (o *Organization) GetRestrictedLinkCount(ctx context.Context) (int, error) {
 	if o.ID == 0 {
 		return 0, fmt.Errorf("Organization is not populated")
@@ -312,3 +296,43 @@ func (o *Organization) GetRestrictedLinkCount(ctx context.Context) (int, error)
 	})
 	return count, err
 }
+
+func (o *Organization) ReachedMonthlyShortLimit(ctx context.Context, limit int) (bool, error) {
+	if o.ID == 0 {
+		return false, fmt.Errorf("Organization is not populated")
+	}
+
+	var reached bool
+	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
+		query := `
+			SELECT COALESCE((
+				SELECT TRUE
+  				FROM audit_log
+				WHERE (metadata->>'org_id') = $1
+				AND event_type = $2
+  				AND created_on >= date_trunc('month', timezone('UTC', CURRENT_TIMESTAMP))
+				AND created_on < date_trunc('month', timezone('UTC', CURRENT_TIMESTAMP)) + INTERVAL '1 month'
+				HAVING COUNT(*) >= $3 
+			), FALSE) AS has_minimum_10_rows`
+		row := tx.QueryRowContext(ctx, query, o.ID, LOG_SHORT_ADDED, limit)
+		return row.Scan(&reached)
+	})
+	return reached, err
+
+}
+
+// This function ennable or disable based on the boolean flag a batch of organization
+func ToggleOrganizaiongBatch(ctx context.Context, opts *database.FilterOptions, flag bool) error {
+	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
+		var err error
+		_, err = sq.
+			Update("organizations").
+			Set("is_active", flag).
+			Where(opts.Filter).
+			PlaceholderFormat(sq.Dollar).
+			RunWith(tx).
+			ExecContext(ctx)
+		return err
+	})
+	return err
+}
diff --git a/models/qr_codes.go b/models/qr_codes.go
index 3576aef..2ba63a3 100644
--- a/models/qr_codes.go
+++ b/models/qr_codes.go
@@ -105,7 +105,7 @@ func (q *QRCode) Store(ctx context.Context) error {
 		var err error
 		if q.ID == 0 {
 			// When creating a new qr_code, generate an unique hash_id
-			code, err := getShortCode(ctx, tx, "qr_codes", "hash_id")
+			code, err := getShortCode(ctx, tx, "qr_codes", "hash_id", nil)
 			if err != nil {
 				return err
 			}
diff --git a/models/schema.sql b/models/schema.sql
index 1080fd1..93d5d7a 100644
--- a/models/schema.sql
+++ b/models/schema.sql
@@ -565,3 +565,16 @@ CREATE TABLE followers (
 CREATE INDEX followers_id_idx ON followers (id);
 CREATE INDEX followers_user_id_idx ON followers (user_id);
 CREATE INDEX followers_org_id_idx ON followers (org_id);
+
+CREATE TABLE audit_log (
+  id serial PRIMARY KEY,
+  user_id INTEGER REFERENCES users (id) ON DELETE CASCADE,
+  ip_address character varying(50) NOT NULL,
+  event_type character varying(256) NOT NULL,
+  details character varying(512),
+  metadata JSONB DEFAULT '{}',
+  created_on TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX audit_log_id_idx ON audit_log (id);
+CREATE INDEX audit_log_created_on_idx ON audit_log (created_on);
diff --git a/models/utils.go b/models/utils.go
index b7b3a5f..be59bc5 100644
--- a/models/utils.go
+++ b/models/utils.go
@@ -10,7 +10,7 @@ import (
 	sq "github.com/Masterminds/squirrel"
 )
 
-func getShortCode(ctx context.Context, tx *sql.Tx, table, field string) (string, error) {
+func getShortCode(ctx context.Context, tx *sql.Tx, table, field string, filter sq.Sqlizer) (string, error) {
 	chars := "abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ0123456789"
 	r := mrand.New(mrand.NewSource(time.Now().UnixNano()))
 
@@ -23,7 +23,15 @@ func getShortCode(ctx context.Context, tx *sql.Tx, table, field string) (string,
 			code[i] = chars[r.Intn(len(chars))]
 		}
 
-		exists, err := checkCode(ctx, tx, code, table, field)
+		var ft sq.Sqlizer
+		ft = sq.Eq{field: code}
+		if filter != nil {
+			ft = sq.And{
+				sq.Eq{field: code},
+				filter,
+			}
+		}
+		exists, err := checkCode(ctx, tx, table, ft)
 		if err != nil {
 			return "", err
 		}
@@ -44,11 +52,11 @@ func getShortCode(ctx context.Context, tx *sql.Tx, table, field string) (string,
 }
 
 // Verify that there's not an existing code already stored in the db
-func checkCode(ctx context.Context, tx *sql.Tx, code []byte, table, field string) (bool, error) {
+func checkCode(ctx context.Context, tx *sql.Tx, table string, filter sq.Sqlizer) (bool, error) {
 	err := sq.
 		Select("id").
 		From(table).
-		Where(sq.Eq{field: string(code)}).
+		Where(filter).
 		PlaceholderFormat(sq.Dollar).
 		RunWith(tx).
 		ScanContext(ctx)
diff --git a/short/routes.go b/short/routes.go
index 042bf02..5fce7cd 100644
--- a/short/routes.go
+++ b/short/routes.go
@@ -882,17 +882,19 @@ func (r *RedirectService) LinkShort(c echo.Context) error {
 	}
 
 	org := orgs[0]
-	if links.BillingEnabled(gctx.Server.Config) && org.IsRestricted(
-		[]string{models.BillingStatusFree, models.BillingStatusOpenSource}) {
-		lt := localizer.GetSessionLocalizer(c)
-		pd := localizer.NewPageData(lt.Translate("URL Shortening powered by Link Taco!"))
-		pd.Data["redirected"] = lt.Translate("You will be redirected in 10 seconds.")
-		gmap := gobwebs.Map{
-			"pd":      pd,
-			"url":     recURL,
-			"hideNav": true,
+	if links.BillingEnabled(gctx.Server.Config) && org.IsRestricted([]string{models.BillingStatusFree}) {
+		if _, ok := srv.Config.File.Get("links", "short-limit-month"); !ok {
+			// Only show delayed redirect page if short limits are not configured
+			lt := localizer.GetSessionLocalizer(c)
+			pd := localizer.NewPageData(lt.Translate("URL Shortening powered by Link Taco!"))
+			pd.Data["redirected"] = lt.Translate("You will be redirected in 10 seconds.")
+			gmap := gobwebs.Map{
+				"pd":      pd,
+				"url":     recURL,
+				"hideNav": true,
+			}
+			return r.Render(c, http.StatusOK, "restriction_redirect.html", gmap)
 		}
-		return r.Render(c, http.StatusOK, "restriction_redirect.html", gmap)
 	}
 
 	return c.Redirect(http.StatusMovedPermanently, recURL)
diff --git a/templates/pricing_list.html b/templates/pricing_list.html
index ad79564..393662d 100644
--- a/templates/pricing_list.html
+++ b/templates/pricing_list.html
@@ -243,11 +243,7 @@
             </tr>
             <tr>
                 <td class="text-center">{{.pd.Data.feature_link_short_1}}</td>
-		<td class="text-center">
-                    <svg style="width:20px" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-primary">
-                        <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
-                    </svg>
-                </td>
+		<td class="text-center">10 {{ .pd.Data.per_month }}</td>
                 <td class="text-center">
                     <svg style="width:20px" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-primary">
                         <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
-- 
2.47.2