added on local at 2026-07-01 12:34:35
| 1 | #!/usr/bin/perl | |
| 2 | ||
| 3 | ################################################################################################################################## | |
| 4 | # ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____ | |
| 5 | # / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \ | |
| 6 | # / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / / | |
| 7 | # / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ / | |
| 8 | # \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/ | |
| 9 | # | |
| 10 | # - Written by: Shawn Pickering | |
| 11 | # - shawn@shawnpickering.com | |
| 12 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | ############################################################ | |
| 24 | # Modules | |
| 25 | ############################################################ | |
| 26 | $CGI::POST_MAX = 4 * 1024 * 1024; | |
| 27 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 28 | use MODS::Login; my $login = new MODS::Login; | |
| 29 | use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls(); | |
| 30 | use MODS::Template; my $tfile = new MODS::Template; | |
| 31 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 32 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 33 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 34 | use MODS::PTMatrix::PM; | |
| 35 | use Digest::SHA qw(sha256_hex); | |
| 36 | use File::Path qw(make_path); | |
| 37 | ||
| 38 | ||
| 39 | ||
| 40 | ############################################################# | |
| 41 | ## Get form data | |
| 42 | ############################################################# | |
| 43 | my %rawform = %$form; | |
| 44 | ||
| 45 | foreach my $line(keys %$form){ | |
| 46 | $form->{$line} =~ s/[^!-~\s]//g; | |
| 47 | $form->{$line} = quotemeta("$form->{$line}"); | |
| 48 | } | |
| 49 | ||
| 50 | ||
| 51 | ||
| 52 | ############################################################ | |
| 53 | # Program Controls | |
| 54 | ############################################################ | |
| 55 | $|=1; | |
| 56 | my $userinfo = $login->login_verify(); | |
| 57 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~; | |
| 58 | if(!$userinfo){print qq~<script>window.location="$url->{login}";</script>~;} | |
| 59 | elsif(($ENV{REQUEST_METHOD}||'') eq 'POST'){&profile_save;} | |
| 60 | else{&profile_view;} | |
| 61 | ||
| 62 | ||
| 63 | ############################################################ | |
| 64 | # Subroutines | |
| 65 | ############################################################ | |
| 66 | #Load the target user row (self by default, ?id=N for a teammate) | |
| 67 | sub load_target{ | |
| 68 | my($dbh) = @_; | |
| 69 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 70 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 71 | my $target_id = $uid; | |
| 72 | if($form->{id}){ | |
| 73 | my $req = $form->{id}; $req =~ s/[^0-9]//g; | |
| 74 | $target_id = $req if length $req; | |
| 75 | } | |
| 76 | my $row = $db->db_readwrite($dbh, qq~SELECT * FROM `${database_name}`.users WHERE id='$target_id' AND company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 77 | return ($row, $target_id); | |
| 78 | } | |
| 79 | ||
| 80 | #Compute the actor's permission flags for this target | |
| 81 | sub perm_flags{ | |
| 82 | my($dbh, $target_id, $t_row) = @_; | |
| 83 | ||
| 84 | my $is_super = $userinfo->{is_super_admin} ? 1 : 0; | |
| 85 | my $is_company_admin = ($userinfo->{role} && ($userinfo->{role} eq 'company_admin' || $userinfo->{role} eq 'company_owner')) ? 1 : 0; | |
| 86 | ||
| 87 | #Detect optional HR-admin columns - pre-migration installs treat them as 0 | |
| 88 | my $col_check = $db->db_readwrite($dbh, qq~SELECT COUNT(IF(COLUMN_NAME='is_hr_admin',1,NULL)) AS has_hr, COUNT(IF(COLUMN_NAME='profile_locked',1,NULL)) AS has_lock FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${database_name}' AND TABLE_NAME='users'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 89 | my $has_hr_cols = ($col_check && $col_check->{has_hr}) ? 1 : 0; | |
| 90 | my $has_lock = ($col_check && $col_check->{has_lock}) ? 1 : 0; | |
| 91 | ||
| 92 | my $is_hr_admin = ($has_hr_cols && $userinfo->{is_hr_admin}) ? 1 : 0; | |
| 93 | my $target_locked = ($has_lock && $t_row->{profile_locked}) ? 1 : 0; | |
| 94 | my $target_hr = ($has_hr_cols && $t_row->{is_hr_admin}) ? 1 : 0; | |
| 95 | ||
| 96 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 97 | my $is_self = ($target_id == $uid) ? 1 : 0; | |
| 98 | ||
| 99 | my $can_edit_target = $is_super || $is_company_admin || $is_hr_admin || ($is_self && !$target_locked); | |
| 100 | my $can_override_lock = $is_super || $is_hr_admin; | |
| 101 | my $can_toggle_hr = $is_super; | |
| 102 | ||
| 103 | return { | |
| 104 | is_super => $is_super, | |
| 105 | is_company_admin => $is_company_admin, | |
| 106 | is_hr_admin => $is_hr_admin, | |
| 107 | has_hr_cols => $has_hr_cols, | |
| 108 | has_lock => $has_lock, | |
| 109 | target_locked => $target_locked, | |
| 110 | target_hr => $target_hr, | |
| 111 | is_self => $is_self, | |
| 112 | can_edit_target => $can_edit_target, | |
| 113 | can_override_lock=> $can_override_lock, | |
| 114 | can_toggle_hr => $can_toggle_hr, | |
| 115 | }; | |
| 116 | } | |
| 117 | ||
| 118 | sub profile_save{ | |
| 119 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 120 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 121 | ||
| 122 | my $dbh = $db->db_connect(); | |
| 123 | my ($t_row, $target_id) = &load_target($dbh); | |
| 124 | if(!$t_row || !$t_row->{id}){ | |
| 125 | $db->db_disconnect($dbh); | |
| 126 | print qq~<script>window.location="$url->{profile}";</script>~; | |
| 127 | return; | |
| 128 | } | |
| 129 | my $p = &perm_flags($dbh, $target_id, $t_row); | |
| 130 | ||
| 131 | my $action = $form->{act} || 'update'; | |
| 132 | my $flash = ''; | |
| 133 | ||
| 134 | if($action eq 'lock_profile' && $p->{can_override_lock} && $p->{has_lock}){ | |
| 135 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET profile_locked=1, profile_locked_at=NOW(), profile_locked_by_user_id='$uid' WHERE id='$target_id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 136 | $flash = 'Profile locked. The user can no longer edit their own profile.'; | |
| 137 | }elsif($action eq 'unlock_profile' && $p->{can_override_lock} && $p->{has_lock}){ | |
| 138 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET profile_locked=0, profile_locked_at=NULL, profile_locked_by_user_id=NULL WHERE id='$target_id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 139 | $flash = 'Profile unlocked. The user can edit their own profile again.'; | |
| 140 | }elsif($action eq 'toggle_hr_admin' && $p->{can_toggle_hr} && $p->{has_hr_cols}){ | |
| 141 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET is_hr_admin = 1 - IFNULL(is_hr_admin,0) WHERE id='$target_id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 142 | $flash = 'HR-admin role toggled.'; | |
| 143 | }elsif($action eq 'change_password' && $p->{is_self}){ | |
| 144 | my $cur = $rawform{current_password} || ''; | |
| 145 | my $new = $rawform{new_password} || ''; | |
| 146 | if(length($new) < 8){ | |
| 147 | $flash = 'New password must be at least 8 characters.'; | |
| 148 | }else{ | |
| 149 | my ($info) = $login->login_exec($userinfo->{email}, $cur); | |
| 150 | if($info){ | |
| 151 | $login->update_password($uid, $new); | |
| 152 | $flash = 'Password updated. You will be signed out of other devices.'; | |
| 153 | }else{ | |
| 154 | $flash = 'Current password is incorrect.'; | |
| 155 | } | |
| 156 | } | |
| 157 | }elsif($action eq 'update' && $p->{can_edit_target}){ | |
| 158 | my @sets; | |
| 159 | push @sets, "display_name='" . MODS::PTMatrix::PM::sql_quote($rawform{display_name}) . "'" if defined $rawform{display_name}; | |
| 160 | push @sets, "job_title='" . MODS::PTMatrix::PM::sql_quote($rawform{job_title}) . "'" if defined $rawform{job_title}; | |
| 161 | push @sets, "avatar_color='" . MODS::PTMatrix::PM::safe_color($form->{avatar_color}) . "'" if defined $form->{avatar_color}; | |
| 162 | if(defined $form->{timezone}){ | |
| 163 | my $tz = $form->{timezone}; $tz =~ s/[^A-Za-z0-9_+\-:\/]//g; | |
| 164 | push @sets, "timezone='$tz'"; | |
| 165 | } | |
| 166 | ||
| 167 | #Org-chart columns - only touched if migration ran | |
| 168 | my $has_orgcols = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='${database_name}' AND TABLE_NAME='users' AND COLUMN_NAME IN ('phone','bio_short','skills_csv','manager_user_id','photo_url')~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 169 | if($has_orgcols && $has_orgcols->{n}){ | |
| 170 | push @sets, "phone='" . MODS::PTMatrix::PM::sql_quote(substr($rawform{phone} || '', 0, 40)) . "'" if defined $rawform{phone}; | |
| 171 | push @sets, "bio_short='" . MODS::PTMatrix::PM::sql_quote(substr($rawform{bio_short} || '', 0, 255)) . "'" if defined $rawform{bio_short}; | |
| 172 | if(defined $rawform{skills_csv}){ | |
| 173 | my $sk = $rawform{skills_csv} || ''; | |
| 174 | $sk =~ s/^\s+|\s+$//g; | |
| 175 | $sk = substr($sk, 0, 500); | |
| 176 | push @sets, "skills_csv='" . MODS::PTMatrix::PM::sql_quote($sk) . "'"; | |
| 177 | } | |
| 178 | if(defined $form->{manager_user_id} && ($p->{is_super} || $p->{is_company_admin} || $p->{is_hr_admin} || $p->{is_self})){ | |
| 179 | my $mid = $form->{manager_user_id}; | |
| 180 | $mid =~ s/[^0-9]//g; | |
| 181 | $mid = '' if $mid eq "$target_id"; #Can't manage yourself | |
| 182 | push @sets, "manager_user_id=" . (length($mid) ? "'$mid'" : 'NULL'); | |
| 183 | } | |
| 184 | ||
| 185 | #Optional avatar upload | |
| 186 | my $fh = $query->upload('photo_file'); | |
| 187 | if($fh){ | |
| 188 | my $info = $query->uploadInfo($query->param('photo_file')) || {}; | |
| 189 | my $mime = $info->{'Content-Type'} || ''; | |
| 190 | my %allowed = ('image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'); | |
| 191 | if($allowed{$mime}){ | |
| 192 | my $root = '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/uploads/avatars'; | |
| 193 | make_path($root) unless -d $root; | |
| 194 | binmode $fh; | |
| 195 | my $blob = do { local $/; <$fh> }; | |
| 196 | if(length($blob) > 0 && length($blob) <= 4 * 1024 * 1024){ | |
| 197 | my $hash = substr(sha256_hex($blob), 0, 12); | |
| 198 | my $ext = $allowed{$mime}; | |
| 199 | my $fn = "${target_id}-${hash}.${ext}"; | |
| 200 | my $abs = "$root/$fn"; | |
| 201 | if(open my $out, '>', $abs){ | |
| 202 | binmode $out; | |
| 203 | print $out $blob; | |
| 204 | close $out; | |
| 205 | push @sets, "photo_url='" . MODS::PTMatrix::PM::sql_quote("/uploads/avatars/$fn") . "'"; | |
| 206 | }else{ | |
| 207 | $flash = "Could not save photo: $!"; | |
| 208 | } | |
| 209 | } | |
| 210 | }else{ | |
| 211 | $flash = 'Photo must be JPG, PNG, WebP, or GIF.'; | |
| 212 | } | |
| 213 | } | |
| 214 | push @sets, "photo_url=NULL" if $form->{remove_photo} && $form->{remove_photo} eq '1'; | |
| 215 | } | |
| 216 | ||
| 217 | if(@sets){ | |
| 218 | my $set = join(', ', @sets); | |
| 219 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.users SET $set WHERE id='$target_id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 220 | } | |
| 221 | $flash ||= 'Profile saved.'; | |
| 222 | } | |
| 223 | ||
| 224 | $db->db_disconnect($dbh); | |
| 225 | &profile_view($flash); | |
| 226 | } | |
| 227 | ||
| 228 | sub profile_view{ | |
| 229 | my($flash) = @_; | |
| 230 | $flash ||= ''; | |
| 231 | ||
| 232 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 233 | ||
| 234 | my $dbh = $db->db_connect(); | |
| 235 | my ($t_row, $target_id) = &load_target($dbh); | |
| 236 | if(!$t_row || !$t_row->{id}){ | |
| 237 | $db->db_disconnect($dbh); | |
| 238 | print qq~Status: 404 Not Found\nContent-Type: text/plain\n\nUser not found in this company.\n~; | |
| 239 | exit; | |
| 240 | } | |
| 241 | ||
| 242 | my $p = &perm_flags($dbh, $target_id, $t_row); | |
| 243 | ||
| 244 | #Manager dropdown - all OTHER active teammates | |
| 245 | my @mgr_choices = $db->db_readwrite_multiple($dbh, qq~SELECT id, display_name, email FROM `${database_name}`.users WHERE company_id='$cid' AND account_status='active' AND id<>'$target_id' ORDER BY display_name~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 246 | my @manager_opts; | |
| 247 | push @manager_opts, { id => '', name => '— no manager —', selected => (!$t_row->{manager_user_id}) ? 1 : 0 }; | |
| 248 | foreach my $m(@mgr_choices){ | |
| 249 | push @manager_opts, { | |
| 250 | id => $m->{id}, | |
| 251 | name => MODS::PTMatrix::PM::h($m->{display_name} || $m->{email}), | |
| 252 | selected => (($t_row->{manager_user_id} || '') eq $m->{id}) ? 1 : 0, | |
| 253 | }; | |
| 254 | } | |
| 255 | ||
| 256 | $db->db_disconnect($dbh); | |
| 257 | ||
| 258 | #Create all template variables | |
| 259 | my $tvars; | |
| 260 | $tvars->{flash} = $flash; | |
| 261 | $tvars->{has_flash} = length($flash) ? 1 : 0; | |
| 262 | $tvars->{target_id} = $target_id; | |
| 263 | $tvars->{is_self} = $p->{is_self}; | |
| 264 | $tvars->{is_super} = $p->{is_super}; | |
| 265 | $tvars->{is_company_admin} = $p->{is_company_admin}; | |
| 266 | $tvars->{is_hr_admin} = $p->{is_hr_admin}; | |
| 267 | $tvars->{target_is_hr} = $p->{target_hr}; | |
| 268 | $tvars->{target_locked} = $p->{target_locked}; | |
| 269 | $tvars->{can_edit} = $p->{can_edit_target} ? 1 : 0; | |
| 270 | $tvars->{can_lock} = $p->{can_override_lock} ? 1 : 0; | |
| 271 | $tvars->{can_toggle_hr} = $p->{can_toggle_hr} ? 1 : 0; | |
| 272 | $tvars->{has_hr_cols} = $p->{has_hr_cols}; | |
| 273 | $tvars->{has_lock} = $p->{has_lock}; | |
| 274 | ||
| 275 | $tvars->{display_name} = MODS::PTMatrix::PM::h($t_row->{display_name}); | |
| 276 | $tvars->{job_title} = MODS::PTMatrix::PM::h($t_row->{job_title} || ''); | |
| 277 | $tvars->{email} = MODS::PTMatrix::PM::h($t_row->{email}); | |
| 278 | $tvars->{avatar_color} = $t_row->{avatar_color} || '#5aa9ff'; | |
| 279 | $tvars->{timezone} = $t_row->{timezone} || 'UTC'; | |
| 280 | $tvars->{initials} = MODS::PTMatrix::PM::initials($t_row->{display_name}); | |
| 281 | $tvars->{company_name} = MODS::PTMatrix::PM::h($userinfo->{company_name} || ''); | |
| 282 | $tvars->{role_label} = ucfirst($t_row->{role} || 'member'); | |
| 283 | $tvars->{photo_url} = MODS::PTMatrix::PM::h($t_row->{photo_url} || ''); | |
| 284 | $tvars->{has_photo} = $t_row->{photo_url} ? 1 : 0; | |
| 285 | $tvars->{phone} = MODS::PTMatrix::PM::h($t_row->{phone} || ''); | |
| 286 | $tvars->{bio_short} = MODS::PTMatrix::PM::h($t_row->{bio_short} || ''); | |
| 287 | $tvars->{skills_csv} = MODS::PTMatrix::PM::h($t_row->{skills_csv} || ''); | |
| 288 | $tvars->{manager_opts} = \@manager_opts; | |
| 289 | ||
| 290 | my $body = join('', $tfile->template('tf_profile.html', $tvars, $userinfo)); | |
| 291 | ||
| 292 | $load->render({ | |
| 293 | userinfo => $userinfo, | |
| 294 | page_key => 'profile', | |
| 295 | title => $p->{is_self} ? 'My Profile' : "Profile: $tvars->{display_name}", | |
| 296 | body => $body, | |
| 297 | }); | |
| 298 | } |