Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/SEO.pm
Diff
/var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/SEO.pm
added on local at 2026-07-01 15:02:55
Added
+0
lines
Removed
-0
lines
Context
233
unchanged
Blobs
from abb238726b56
to abb238726b56
to abb238726b56
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::ContactForge::SEO; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # ContactForge - SEO + Open Graph + Twitter Card helper. |
| 4 | 4 | # |
| 5 | 5 | # Two scopes: |
| 6 | 6 | # - dashboard SEO: per-row columns on the dashboards table |
| 7 | 7 | # (seo_title, seo_description, seo_keywords, ...) |
| 8 | 8 | # - platform SEO: rows in platform_settings keyed by setting_key |
| 9 | 9 | # ('seo.title', 'seo.description', ...) |
| 10 | 10 | # |
| 11 | 11 | # render_tags() builds the <meta> block (title + description + OG + |
| 12 | 12 | # twitter) the wrapper template injects into <head>. Sensible fallbacks |
| 13 | 13 | # at every layer so a brand-new dashboard still emits good defaults. |
| 14 | 14 | # |
| 15 | 15 | # Every query is guarded with information_schema checks because the |
| 16 | 16 | # platform_settings table and the new dashboards.seo_* columns may |
| 17 | 17 | # not be present yet on early-adopter accounts -- DBConnect's error() |
| 18 | 18 | # calls exit() so we ALWAYS check schema before reading. |
| 19 | 19 | #====================================================================== |
| 20 | 20 | use strict; |
| 21 | 21 | use warnings; |
| 22 | 22 | |
| 23 | 23 | sub new { |
| 24 | 24 | my ($class, %args) = @_; |
| 25 | 25 | return bless({}, $class); |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | # ---- Schema readiness ------------------------------------------------ |
| 29 | 29 | sub platform_settings_ready { |
| 30 | 30 | my ($self, $db, $dbh, $DB) = @_; |
| 31 | 31 | my $r = $db->db_readwrite($dbh, qq~ |
| 32 | 32 | SELECT COUNT(*) AS n FROM information_schema.tables |
| 33 | 33 | WHERE table_schema='$DB' AND table_name='platform_settings' |
| 34 | 34 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 35 | 35 | return ($r && $r->{n}) ? 1 : 0; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | sub dashboard_seo_ready { |
| 39 | 39 | my ($self, $db, $dbh, $DB) = @_; |
| 40 | 40 | my $r = $db->db_readwrite($dbh, qq~ |
| 41 | 41 | SELECT COUNT(*) AS n FROM information_schema.columns |
| 42 | 42 | WHERE table_schema='$DB' AND table_name='dashboards' |
| 43 | 43 | AND column_name='seo_title' |
| 44 | 44 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 45 | 45 | return ($r && $r->{n}) ? 1 : 0; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 48 | # ---- Platform-wide SEO ---------------------------------------------- |
| 49 | 49 | # Returns a hash of every seo.* setting. Missing keys come back as |
| 50 | 50 | # undef so callers can apply per-key fallbacks. |
| 51 | 51 | sub get_platform_settings { |
| 52 | 52 | my ($self, $db, $dbh, $DB) = @_; |
| 53 | 53 | return {} unless $self->platform_settings_ready($db, $dbh, $DB); |
| 54 | 54 | my @rows = $db->db_readwrite_multiple($dbh, qq~ |
| 55 | 55 | SELECT setting_key, setting_value |
| 56 | 56 | FROM `${DB}`.platform_settings |
| 57 | 57 | WHERE setting_key LIKE 'seo.%' |
| 58 | 58 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 59 | 59 | my %out; |
| 60 | 60 | foreach my $r (@rows) { |
| 61 | 61 | $out{ $r->{setting_key} } = $r->{setting_value}; |
| 62 | 62 | } |
| 63 | 63 | return \%out; |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | 66 | sub set_platform_setting { |
| 67 | 67 | my ($self, $db, $dbh, $DB, $key, $value, $actor_uid) = @_; |
| 68 | 68 | return 0 unless $key; |
| 69 | 69 | return 0 unless $self->platform_settings_ready($db, $dbh, $DB); |
| 70 | 70 | |
| 71 | 71 | # Sanitize the key - dotted lowercase + alphanumeric/underscore only. |
| 72 | 72 | $key =~ s/[^a-z0-9._]//g; |
| 73 | 73 | $key = substr($key, 0, 120); |
| 74 | 74 | return 0 unless length $key; |
| 75 | 75 | |
| 76 | 76 | # SQL-escape the value as a single-quoted string. |
| 77 | 77 | my $sv = defined $value ? $value : ''; |
| 78 | 78 | $sv =~ s/'/''/g; |
| 79 | 79 | |
| 80 | 80 | my $actor = defined $actor_uid ? $actor_uid : ''; |
| 81 | 81 | $actor =~ s/[^0-9]//g; |
| 82 | 82 | my $actor_sql = $actor eq '' ? 'NULL' : "'$actor'"; |
| 83 | 83 | |
| 84 | 84 | # UPSERT: insert, or update on duplicate key. |
| 85 | 85 | $db->db_readwrite($dbh, qq~ |
| 86 | 86 | INSERT INTO `${DB}`.platform_settings |
| 87 | 87 | SET setting_key='$key', |
| 88 | 88 | setting_value='$sv', |
| 89 | 89 | updated_by_user_id=$actor_sql |
| 90 | 90 | ON DUPLICATE KEY UPDATE |
| 91 | 91 | setting_value='$sv', |
| 92 | 92 | updated_by_user_id=$actor_sql |
| 93 | 93 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 94 | 94 | return 1; |
| 95 | 95 | } |
| 96 | 96 | |
| 97 | 97 | # ---- Per-dashboard SEO --------------------------------------------- |
| 98 | 98 | sub get_dashboard_seo { |
| 99 | 99 | my ($self, $db, $dbh, $DB, $dashboard_id) = @_; |
| 100 | 100 | $dashboard_id =~ s/[^0-9]//g; |
| 101 | 101 | return {} unless $dashboard_id; |
| 102 | 102 | return {} unless $self->dashboard_seo_ready($db, $dbh, $DB); |
| 103 | 103 | |
| 104 | 104 | my $r = $db->db_readwrite($dbh, qq~ |
| 105 | 105 | SELECT id, name, tagline, subdomain, hero_image_1, |
| 106 | 106 | seo_title, seo_description, seo_keywords, |
| 107 | 107 | seo_canonical_url, seo_og_image, |
| 108 | 108 | seo_robots_index, seo_twitter_card, seo_twitter_site |
| 109 | 109 | FROM `${DB}`.dashboards |
| 110 | 110 | WHERE id='$dashboard_id' |
| 111 | 111 | LIMIT 1 |
| 112 | 112 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 113 | 113 | return ($r && $r->{id}) ? $r : {}; |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | 116 | sub update_dashboard_seo { |
| 117 | 117 | my ($self, $db, $dbh, $DB, $dashboard_id, %a) = @_; |
| 118 | 118 | $dashboard_id =~ s/[^0-9]//g; |
| 119 | 119 | return 0 unless $dashboard_id; |
| 120 | 120 | return 0 unless $self->dashboard_seo_ready($db, $dbh, $DB); |
| 121 | 121 | |
| 122 | 122 | my $title = _trim($a{seo_title}, 160); |
| 123 | 123 | my $desc = _trim($a{seo_description}, 320); |
| 124 | 124 | my $kw = _trim($a{seo_keywords}, 255); |
| 125 | 125 | my $canon = _trim($a{seo_canonical_url}, 255); |
| 126 | 126 | my $og = _trim($a{seo_og_image}, 255); |
| 127 | 127 | my $tsite = _trim($a{seo_twitter_site}, 64); |
| 128 | 128 | my $robots = ($a{seo_robots_index} && $a{seo_robots_index} ne '0') ? 1 : 0; |
| 129 | 129 | |
| 130 | 130 | my $card = $a{seo_twitter_card} || 'summary_large_image'; |
| 131 | 131 | my %card_ok = (summary => 1, summary_large_image => 1); |
| 132 | 132 | $card = 'summary_large_image' unless $card_ok{$card}; |
| 133 | 133 | |
| 134 | 134 | my $q_title = _qstr($title); |
| 135 | 135 | my $q_desc = _qstr($desc); |
| 136 | 136 | my $q_kw = _qstr($kw); |
| 137 | 137 | my $q_canon = _qstr($canon); |
| 138 | 138 | my $q_og = _qstr($og); |
| 139 | 139 | my $q_tsite = _qstr($tsite); |
| 140 | 140 | |
| 141 | 141 | $db->db_readwrite($dbh, qq~ |
| 142 | 142 | UPDATE `${DB}`.dashboards |
| 143 | 143 | SET seo_title=$q_title, |
| 144 | 144 | seo_description=$q_desc, |
| 145 | 145 | seo_keywords=$q_kw, |
| 146 | 146 | seo_canonical_url=$q_canon, |
| 147 | 147 | seo_og_image=$q_og, |
| 148 | 148 | seo_robots_index='$robots', |
| 149 | 149 | seo_twitter_card='$card', |
| 150 | 150 | seo_twitter_site=$q_tsite |
| 151 | 151 | WHERE id='$dashboard_id' |
| 152 | 152 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 153 | 153 | return 1; |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | 156 | # ---- Tag rendering --------------------------------------------------- |
| 157 | 157 | # Builds the head <meta> block for either: |
| 158 | 158 | # - a dashboard page (pass dashboard row + page title) |
| 159 | 159 | # - the platform dashboard / marketing pages (pass undef for store, |
| 160 | 160 | # and we draw entirely from platform_settings + fallback title) |
| 161 | 161 | # |
| 162 | 162 | # Returns an HTML string ready to drop into <head>. |
| 163 | 163 | sub render_tags { |
| 164 | 164 | my ($self, %a) = @_; |
| 165 | 165 | my $store = $a{dashboard} || {}; # dashboard row from get_dashboard_seo() |
| 166 | 166 | my $platform = $a{platform} || {}; # hash from get_platform_settings() |
| 167 | 167 | my $page_title = $a{page_title} || ''; |
| 168 | 168 | my $request_url = $a{request_url} || ''; |
| 169 | 169 | |
| 170 | 170 | # Compute effective values with this priority: |
| 171 | 171 | # dashboard override > dashboard defaults > platform > page_title |
| 172 | 172 | my $title = $store->{seo_title} || $store->{name} || $page_title || $platform->{'seo.title'} || 'ContactForge'; |
| 173 | 173 | my $description = $store->{seo_description} || $store->{tagline} || $platform->{'seo.description'} || ''; |
| 174 | 174 | my $keywords = $store->{seo_keywords} || $platform->{'seo.keywords'} || ''; |
| 175 | 175 | my $canonical = $store->{seo_canonical_url} || $request_url || ''; |
| 176 | 176 | my $og_image = $store->{seo_og_image} || $store->{hero_image_1} || $platform->{'seo.og_image'} || ''; |
| 177 | 177 | my $og_site = $platform->{'seo.og_site_name'} || 'ContactForge'; |
| 178 | 178 | my $twit_card = $store->{seo_twitter_card}|| $platform->{'seo.twitter_card'} || 'summary_large_image'; |
| 179 | 179 | my $twit_site = $store->{seo_twitter_site}|| $platform->{'seo.twitter_site'} || ''; |
| 180 | 180 | |
| 181 | 181 | # robots_index: 1 = index,follow. 0 = noindex,nofollow. |
| 182 | 182 | # If dashboard override is explicit 0, honor it; otherwise platform default. |
| 183 | 183 | my $robots_on = 1; |
| 184 | 184 | if (defined $store->{seo_robots_index}) { |
| 185 | 185 | $robots_on = $store->{seo_robots_index} ? 1 : 0; |
| 186 | 186 | } elsif (defined $platform->{'seo.robots_index'} && $platform->{'seo.robots_index'} eq '0') { |
| 187 | 187 | $robots_on = 0; |
| 188 | 188 | } |
| 189 | 189 | my $robots_str = $robots_on ? 'index,follow' : 'noindex,nofollow'; |
| 190 | 190 | |
| 191 | 191 | # HTML-escape every field that lands inside an attribute. |
| 192 | 192 | foreach ($title, $description, $keywords, $canonical, $og_image, $og_site, $twit_card, $twit_site) { |
| 193 | 193 | $_ = '' unless defined $_; |
| 194 | 194 | s/&/&/g; s/</</g; s/>/>/g; s/"/"/g; |
| 195 | 195 | } |
| 196 | 196 | |
| 197 | 197 | my $h = ''; |
| 198 | 198 | $h .= qq~ <meta name="description" content="$description">\n~ if length $description; |
| 199 | 199 | $h .= qq~ <meta name="keywords" content="$keywords">\n~ if length $keywords; |
| 200 | 200 | $h .= qq~ <meta name="robots" content="$robots_str">\n~; |
| 201 | 201 | $h .= qq~ <link rel="canonical" href="$canonical">\n~ if length $canonical; |
| 202 | 202 | $h .= qq~ <meta property="og:title" content="$title">\n~; |
| 203 | 203 | $h .= qq~ <meta property="og:description" content="$description">\n~ if length $description; |
| 204 | 204 | $h .= qq~ <meta property="og:type" content="website">\n~; |
| 205 | 205 | $h .= qq~ <meta property="og:site_name" content="$og_site">\n~ if length $og_site; |
| 206 | 206 | $h .= qq~ <meta property="og:image" content="$og_image">\n~ if length $og_image; |
| 207 | 207 | $h .= qq~ <meta property="og:url" content="$canonical">\n~ if length $canonical; |
| 208 | 208 | $h .= qq~ <meta name="twitter:card" content="$twit_card">\n~; |
| 209 | 209 | $h .= qq~ <meta name="twitter:title" content="$title">\n~; |
| 210 | 210 | $h .= qq~ <meta name="twitter:description" content="$description">\n~ if length $description; |
| 211 | 211 | $h .= qq~ <meta name="twitter:image" content="$og_image">\n~ if length $og_image; |
| 212 | 212 | $h .= qq~ <meta name="twitter:site" content="$twit_site">\n~ if length $twit_site; |
| 213 | 213 | return $h; |
| 214 | 214 | } |
| 215 | 215 | |
| 216 | 216 | # ---- Internal -------------------------------------------------------- |
| 217 | 217 | sub _trim { |
| 218 | 218 | my ($s, $max) = @_; |
| 219 | 219 | return '' unless defined $s; |
| 220 | 220 | $s =~ s/^\s+|\s+$//g; |
| 221 | 221 | $s =~ s/[\r\n]+/ /g; |
| 222 | 222 | $max ||= 255; |
| 223 | 223 | return substr($s, 0, $max); |
| 224 | 224 | } |
| 225 | 225 | |
| 226 | 226 | sub _qstr { |
| 227 | 227 | my $s = shift; |
| 228 | 228 | return 'NULL' unless defined $s && length $s; |
| 229 | 229 | $s =~ s/'/''/g; |
| 230 | 230 | return "'$s'"; |
| 231 | 231 | } |
| 232 | 232 | |
| 233 | 233 | 1; |