added on local at 2026-07-01 15:02:45
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ContactForge -- /forms.cgi | |
| 4 | # | |
| 5 | # Manage public lead-capture forms. List view + builder side panel. | |
| 6 | # Public URLs land at /form_render.cgi?t=<token> and submit to | |
| 7 | # /form_submit.cgi. | |
| 8 | # | |
| 9 | # Consolidates the former /form_action.cgi (POST handler) via | |
| 10 | # SCRIPT_NAME dispatch. The _action.cgi file is a wrapper that | |
| 11 | # `do`s this file. | |
| 12 | #====================================================================== | |
| 13 | use strict; | |
| 14 | use warnings; | |
| 15 | ||
| 16 | use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com'; | |
| 17 | use CGI; | |
| 18 | use JSON::PP; | |
| 19 | use MODS::Template; | |
| 20 | use MODS::DBConnect; | |
| 21 | use MODS::Login; | |
| 22 | use MODS::ContactForge::Config; | |
| 23 | use MODS::ContactForge::Wrapper; | |
| 24 | ||
| 25 | my $q = CGI->new; | |
| 26 | my $auth = MODS::Login->new; | |
| 27 | my $wrap = MODS::ContactForge::Wrapper->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $cfg = MODS::ContactForge::Config->new; | |
| 30 | my $DB = $cfg->settings('database_name'); | |
| 31 | ||
| 32 | $| = 1; | |
| 33 | my $userinfo = $auth->login_verify(); | |
| 34 | if (!$userinfo) { | |
| 35 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 36 | exit; | |
| 37 | } | |
| 38 | my $uid = $userinfo->{user_id}; | |
| 39 | $uid =~ s/[^0-9]//g; | |
| 40 | ||
| 41 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'forms'; | |
| 42 | ||
| 43 | if ($entry eq 'form_action') { | |
| 44 | _handle_action(); | |
| 45 | exit; | |
| 46 | } | |
| 47 | ||
| 48 | my $dbh = $db->db_connect(); | |
| 49 | ||
| 50 | # Pull lead_source list for the auto_source dropdown. | |
| 51 | my @sources = $db->db_readwrite_multiple($dbh, qq~ | |
| 52 | SELECT slug, name FROM `${DB}`.lead_sources ORDER BY name ASC | |
| 53 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 54 | ||
| 55 | # Pull existing forms. | |
| 56 | my @forms = $db->db_readwrite_multiple($dbh, qq~ | |
| 57 | SELECT id, token, name, description, success_url, success_message, | |
| 58 | auto_tag, auto_source_slug, submissions_count, is_active, | |
| 59 | DATE_FORMAT(created_at, '%b %e, %Y') AS created_pretty, | |
| 60 | (SELECT DATE_FORMAT(MAX(created_at),'%b %e, %Y %l:%i %p') | |
| 61 | FROM `${DB}`.lead_form_submissions s | |
| 62 | WHERE s.form_id=lead_forms.id) AS last_sub_pretty | |
| 63 | FROM `${DB}`.lead_forms | |
| 64 | WHERE owner_user_id='$uid' | |
| 65 | ORDER BY id DESC | |
| 66 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 67 | ||
| 68 | $db->db_disconnect($dbh); | |
| 69 | ||
| 70 | sub _h { | |
| 71 | my $s = shift; | |
| 72 | $s = '' unless defined $s; | |
| 73 | $s =~ s/&/&/g; | |
| 74 | $s =~ s/</</g; | |
| 75 | $s =~ s/>/>/g; | |
| 76 | $s =~ s/"/"/g; | |
| 77 | return $s; | |
| 78 | } | |
| 79 | ||
| 80 | my $edit_id = $q->param('edit') || ''; | |
| 81 | $edit_id =~ s/[^0-9]//g; | |
| 82 | my $is_new = $q->param('new') ? 1 : 0; | |
| 83 | ||
| 84 | # Build the form list HTML. | |
| 85 | my $list_html = ''; | |
| 86 | if (@forms) { | |
| 87 | $list_html .= qq~ | |
| 88 | <div class="table-wrap"> | |
| 89 | <table class="table"> | |
| 90 | <thead><tr> | |
| 91 | <th>Name</th><th>Public URL</th><th>Submissions</th><th>Last submission</th> | |
| 92 | <th>Active</th><th>Actions</th> | |
| 93 | </tr></thead> | |
| 94 | <tbody> | |
| 95 | ~; | |
| 96 | foreach my $f (@forms) { | |
| 97 | my $url = "/form_render.cgi?t=" . _h($f->{token}); | |
| 98 | my $active_label = $f->{is_active} ? 'On' : 'Off'; | |
| 99 | my $active_color = $f->{is_active} ? '#0d9488' : '#666'; | |
| 100 | $list_html .= qq~<tr> | |
| 101 | <td><strong>~ . _h($f->{name}) . qq~</strong><div class="text-xs text-dim">~ . _h($f->{description}) . qq~</div></td> | |
| 102 | <td><a href="$url" target="_blank" class="font-mono text-xs">$url</a></td> | |
| 103 | <td>$f->{submissions_count}</td> | |
| 104 | <td class="text-xs text-dim">~ . _h($f->{last_sub_pretty} || '-') . qq~</td> | |
| 105 | <td><span class="pill" style="color:$active_color">$active_label</span></td> | |
| 106 | <td> | |
| 107 | <a href="/forms.cgi?edit=$f->{id}" class="btn btn-secondary btn-sm">Edit</a> | |
| 108 | <form method="POST" action="/form_action.cgi" style="display:inline"> | |
| 109 | <input type="hidden" name="op" value="toggle"> | |
| 110 | <input type="hidden" name="id" value="$f->{id}"> | |
| 111 | <button class="btn btn-secondary btn-sm" type="submit">~ . ($f->{is_active} ? 'Disable' : 'Enable') . qq~</button> | |
| 112 | </form> | |
| 113 | <form method="POST" action="/form_action.cgi" style="display:inline" | |
| 114 | onsubmit="return confirm('Delete this form? Past submissions stay in the database.')"> | |
| 115 | <input type="hidden" name="op" value="delete"> | |
| 116 | <input type="hidden" name="id" value="$f->{id}"> | |
| 117 | <button class="btn btn-danger btn-sm" type="submit">Delete</button> | |
| 118 | </form> | |
| 119 | </td> | |
| 120 | </tr>~; | |
| 121 | } | |
| 122 | $list_html .= qq~</tbody></table></div>~; | |
| 123 | } | |
| 124 | else { | |
| 125 | $list_html = qq~<div class="text-dim text-sm">No lead forms yet. Build one and share the public URL on your website, in your email signature, or anywhere else.</div>~; | |
| 126 | } | |
| 127 | ||
| 128 | # Build the editor panel if ?new=1 or ?edit=N. | |
| 129 | my $editor_html = ''; | |
| 130 | my $editor_form; | |
| 131 | if ($is_new) { | |
| 132 | $editor_form = { | |
| 133 | id => '', | |
| 134 | name => '', | |
| 135 | description => '', | |
| 136 | success_url => '', | |
| 137 | success_message => 'Thanks - we have your details and will be in touch.', | |
| 138 | auto_tag => '', | |
| 139 | auto_source_slug=> 'website', | |
| 140 | fields_json => '[{"name":"email","label":"Email","type":"email","required":1},' | |
| 141 | . '{"name":"first_name","label":"First name","type":"text","required":0},' | |
| 142 | . '{"name":"last_name","label":"Last name","type":"text","required":0}]', | |
| 143 | }; | |
| 144 | } | |
| 145 | elsif ($edit_id) { | |
| 146 | $dbh = $db->db_connect(); | |
| 147 | my $r = $db->db_readwrite($dbh, qq~ | |
| 148 | SELECT id, name, description, success_url, success_message, | |
| 149 | auto_tag, auto_source_slug, fields_json | |
| 150 | FROM `${DB}`.lead_forms | |
| 151 | WHERE id='$edit_id' AND owner_user_id='$uid' LIMIT 1 | |
| 152 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 153 | $db->db_disconnect($dbh); | |
| 154 | $editor_form = $r if $r && $r->{id}; | |
| 155 | } | |
| 156 | ||
| 157 | if ($editor_form) { | |
| 158 | my $src_opts = ''; | |
| 159 | foreach my $s (@sources) { | |
| 160 | my $slug = _h($s->{slug}); | |
| 161 | my $name = _h($s->{name}); | |
| 162 | my $sel = ($s->{slug} eq ($editor_form->{auto_source_slug} || '')) ? ' selected' : ''; | |
| 163 | $src_opts .= qq~<option value="$slug"$sel>$name</option>~; | |
| 164 | } | |
| 165 | my $title = $editor_form->{id} ? 'Edit form' : 'New form'; | |
| 166 | $editor_html = qq~ | |
| 167 | <div class="card" style="margin-top:24px"> | |
| 168 | <h3 style="margin-top:0">$title</h3> | |
| 169 | <form method="POST" action="/form_action.cgi" style="display:grid;grid-template-columns:repeat(2,1fr);gap:14px"> | |
| 170 | <input type="hidden" name="op" value="save"> | |
| 171 | <input type="hidden" name="id" value="~ . _h($editor_form->{id}) . qq~"> | |
| 172 | <div> | |
| 173 | <label class="text-xs text-dim">Form name (internal)</label> | |
| 174 | <input type="text" name="name" class="input" required value="~ . _h($editor_form->{name}) . qq~" placeholder="e.g. \"Homepage demo request\""> | |
| 175 | </div> | |
| 176 | <div> | |
| 177 | <label class="text-xs text-dim">Lead source</label> | |
| 178 | <select name="auto_source_slug" class="input">$src_opts</select> | |
| 179 | </div> | |
| 180 | <div style="grid-column:1/-1"> | |
| 181 | <label class="text-xs text-dim">Description (shown above the form to visitors)</label> | |
| 182 | <input type="text" name="description" class="input" value="~ . _h($editor_form->{description}) . qq~"> | |
| 183 | </div> | |
| 184 | <div> | |
| 185 | <label class="text-xs text-dim">Auto-tag every lead with</label> | |
| 186 | <input type="text" name="auto_tag" class="input" value="~ . _h($editor_form->{auto_tag}) . qq~" placeholder="e.g. \"website lead\""> | |
| 187 | </div> | |
| 188 | <div> | |
| 189 | <label class="text-xs text-dim">Success URL (blank = generic thank-you page)</label> | |
| 190 | <input type="text" name="success_url" class="input" value="~ . _h($editor_form->{success_url}) . qq~" placeholder="https://yoursite.com/thanks"> | |
| 191 | </div> | |
| 192 | <div style="grid-column:1/-1"> | |
| 193 | <label class="text-xs text-dim">Success message</label> | |
| 194 | <input type="text" name="success_message" class="input" value="~ . _h($editor_form->{success_message}) . qq~"> | |
| 195 | </div> | |
| 196 | <div style="grid-column:1/-1"> | |
| 197 | <label class="text-xs text-dim">Fields (JSON array of {name,label,type,required})</label> | |
| 198 | <textarea name="fields_json" rows="6" class="input" style="font-family:monospace">~ . _h($editor_form->{fields_json}) . qq~</textarea> | |
| 199 | <div class="text-xs text-dim" style="margin-top:6px">type can be text / email / tel / textarea / select. For select, also pass an "options" array.</div> | |
| 200 | </div> | |
| 201 | <div style="grid-column:1/-1;display:flex;gap:10px"> | |
| 202 | <button class="btn btn-primary" type="submit">Save form</button> | |
| 203 | <a href="/forms.cgi" class="btn btn-secondary">Cancel</a> | |
| 204 | </div> | |
| 205 | </form> | |
| 206 | </div> | |
| 207 | ~; | |
| 208 | } | |
| 209 | ||
| 210 | my $body = qq~ | |
| 211 | <div class="page-shell" style="max-width:1100px"> | |
| 212 | <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px"> | |
| 213 | <h1 style="margin:0">Lead forms</h1> | |
| 214 | <a href="/forms.cgi?new=1" class="btn btn-primary">+ New form</a> | |
| 215 | </div> | |
| 216 | <p class="text-dim">Build a hosted form and embed the link anywhere. Submissions land in your Contacts list, dedupe by email, and (optionally) get a tag for tracking.</p> | |
| 217 | <div class="card"> | |
| 218 | $list_html | |
| 219 | </div> | |
| 220 | $editor_html | |
| 221 | </div> | |
| 222 | ~; | |
| 223 | ||
| 224 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 225 | ||
| 226 | $wrap->render({ | |
| 227 | userinfo => $userinfo, | |
| 228 | page_key => 'forms', | |
| 229 | title => 'Lead forms', | |
| 230 | body => $body, | |
| 231 | }); | |
| 232 | ||
| 233 | #====================================================================== | |
| 234 | # form_action entry: POST handler for create/update/delete/toggle on lead_forms. | |
| 235 | #====================================================================== | |
| 236 | sub _act_esc { | |
| 237 | my $s = shift; | |
| 238 | $s = '' unless defined $s; | |
| 239 | $s =~ s/\\/\\\\/g; | |
| 240 | $s =~ s/'/''/g; | |
| 241 | return $s; | |
| 242 | } | |
| 243 | ||
| 244 | sub _act_make_token { | |
| 245 | # 32-hex pseudo-UUID. | |
| 246 | my @hex = ('0'..'9','a'..'f'); | |
| 247 | my $t = ''; | |
| 248 | $t .= $hex[int(rand(16))] for (1..32); | |
| 249 | return $t; | |
| 250 | } | |
| 251 | ||
| 252 | sub _handle_action { | |
| 253 | my $op = lc($q->param('op') || ''); | |
| 254 | my $id = $q->param('id') || ''; | |
| 255 | $id =~ s/[^0-9]//g; | |
| 256 | ||
| 257 | my $dbh = $db->db_connect(); | |
| 258 | ||
| 259 | if ($op eq 'save') { | |
| 260 | my $name = $q->param('name') // ''; | |
| 261 | my $desc = $q->param('description') // ''; | |
| 262 | my $surl = $q->param('success_url') // ''; | |
| 263 | my $smsg = $q->param('success_message') // ''; | |
| 264 | my $tag = $q->param('auto_tag') // ''; | |
| 265 | my $src = $q->param('auto_source_slug') // ''; | |
| 266 | my $fields = $q->param('fields_json') // '[]'; | |
| 267 | ||
| 268 | # Cheap validation -- try to round-trip the JSON. If it fails, fall | |
| 269 | # back to an empty array so we never write garbage. | |
| 270 | my $ok = eval { JSON::PP::decode_json($fields); 1; }; | |
| 271 | $fields = '[]' unless $ok; | |
| 272 | ||
| 273 | for ($name, $desc, $surl, $smsg, $tag, $src) { | |
| 274 | s/^\s+|\s+$//g if defined; | |
| 275 | } | |
| 276 | ||
| 277 | if ($name eq '') { | |
| 278 | $db->db_disconnect($dbh); | |
| 279 | print "Status: 302 Found\nLocation: /forms.cgi?edit=$id&flash=missing\n\n"; | |
| 280 | return; | |
| 281 | } | |
| 282 | ||
| 283 | if ($id) { | |
| 284 | $db->db_readwrite($dbh, qq~ | |
| 285 | UPDATE `${DB}`.lead_forms | |
| 286 | SET name='~ . _act_esc($name) . qq~', | |
| 287 | description='~ . _act_esc($desc) . qq~', | |
| 288 | success_url='~ . _act_esc($surl) . qq~', | |
| 289 | success_message='~ . _act_esc($smsg) . qq~', | |
| 290 | auto_tag='~ . _act_esc($tag) . qq~', | |
| 291 | auto_source_slug='~. _act_esc($src) . qq~', | |
| 292 | fields_json='~ . _act_esc($fields) . qq~', | |
| 293 | updated_at=NOW() | |
| 294 | WHERE id='$id' AND owner_user_id='$uid' | |
| 295 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 296 | } else { | |
| 297 | my $token = _act_make_token(); | |
| 298 | my $ins = $db->db_readwrite($dbh, qq~ | |
| 299 | INSERT INTO `${DB}`.lead_forms | |
| 300 | SET owner_user_id='$uid', | |
| 301 | token='~ . _act_esc($token) . qq~', | |
| 302 | name='~ . _act_esc($name) . qq~', | |
| 303 | description='~ . _act_esc($desc) . qq~', | |
| 304 | success_url='~ . _act_esc($surl) . qq~', | |
| 305 | success_message='~ . _act_esc($smsg) . qq~', | |
| 306 | auto_tag='~ . _act_esc($tag) . qq~', | |
| 307 | auto_source_slug='~ . _act_esc($src) . qq~', | |
| 308 | fields_json='~ . _act_esc($fields) . qq~', | |
| 309 | is_active='1' | |
| 310 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 311 | $id = $ins->{mysql_insertid} || 0; | |
| 312 | } | |
| 313 | $db->db_disconnect($dbh); | |
| 314 | print "Status: 302 Found\nLocation: /forms.cgi?flash=saved\n\n"; | |
| 315 | return; | |
| 316 | } | |
| 317 | ||
| 318 | if ($op eq 'toggle' && $id) { | |
| 319 | $db->db_readwrite($dbh, qq~ | |
| 320 | UPDATE `${DB}`.lead_forms | |
| 321 | SET is_active = 1 - is_active, | |
| 322 | updated_at=NOW() | |
| 323 | WHERE id='$id' AND owner_user_id='$uid' | |
| 324 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 325 | $db->db_disconnect($dbh); | |
| 326 | print "Status: 302 Found\nLocation: /forms.cgi\n\n"; | |
| 327 | return; | |
| 328 | } | |
| 329 | ||
| 330 | if ($op eq 'delete' && $id) { | |
| 331 | $db->db_readwrite($dbh, qq~ | |
| 332 | DELETE FROM `${DB}`.lead_forms | |
| 333 | WHERE id='$id' AND owner_user_id='$uid' | |
| 334 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 335 | $db->db_disconnect($dbh); | |
| 336 | print "Status: 302 Found\nLocation: /forms.cgi?flash=deleted\n\n"; | |
| 337 | return; | |
| 338 | } | |
| 339 | ||
| 340 | $db->db_disconnect($dbh); | |
| 341 | print "Status: 302 Found\nLocation: /forms.cgi\n\n"; | |
| 342 | return; | |
| 343 | } |