added on local at 2026-07-01 12:34:01
| 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 | our $DBH; | |
| 21 | our $AUTH; | |
| 22 | ||
| 23 | ||
| 24 | ||
| 25 | ############################################################ | |
| 26 | # Modules | |
| 27 | ############################################################ | |
| 28 | $CGI::POST_MAX = 8 * 1024 * 1024; | |
| 29 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 30 | use MODS::Login; my $login = new MODS::Login; | |
| 31 | use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls(); | |
| 32 | use MODS::Template; my $tfile = new MODS::Template; | |
| 33 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 34 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 35 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 36 | use MODS::PTMatrix::PM; | |
| 37 | use MODS::PTMatrix::API; my $api = new MODS::PTMatrix::API; | |
| 38 | use Digest::SHA qw(sha256_hex); | |
| 39 | ||
| 40 | my %rawform = %$form; | |
| 41 | ||
| 42 | ||
| 43 | ||
| 44 | ############################################################ | |
| 45 | # Program Controls | |
| 46 | ############################################################ | |
| 47 | $|=1; | |
| 48 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'api'; | |
| 49 | ||
| 50 | if($entry eq 'api_keys'){ | |
| 51 | foreach my $line(keys %$form){ | |
| 52 | $form->{$line} =~ s/[^!-~\s]//g; | |
| 53 | $form->{$line} = quotemeta("$form->{$line}"); | |
| 54 | } | |
| 55 | my $userinfo = $login->login_verify(); | |
| 56 | if(!$userinfo){print qq~Content-type:text/html; Cache-Control:no-cache;\n\n<script>window.location="$url->{login}";</script>~; exit;} | |
| 57 | ||
| 58 | my $is_admin = ($userinfo->{role} && ($userinfo->{role} eq 'company_admin' || $userinfo->{role} eq 'company_owner')) || $userinfo->{is_super_admin}; | |
| 59 | if(!$is_admin){print qq~Status: 403 Forbidden\nContent-Type: text/plain\n\nAdmin only.\n~; exit;} | |
| 60 | ||
| 61 | if(($ENV{REQUEST_METHOD} || '') eq 'POST'){&api_action($userinfo);} | |
| 62 | else{&api_panel($userinfo);} | |
| 63 | exit; | |
| 64 | } | |
| 65 | ||
| 66 | &api_dispatch; | |
| 67 | ||
| 68 | ||
| 69 | ############################################################ | |
| 70 | # Subroutines | |
| 71 | ############################################################ | |
| 72 | #Tiny JSON parser (no external dep). Flat objects only - string/number/bool/null values. | |
| 73 | sub parse_json{ | |
| 74 | my($s) = @_; | |
| 75 | return {} unless defined $s && length $s; | |
| 76 | my %out; | |
| 77 | while($s =~ /"([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*"((?:[^"\\]|\\.)*)"/g){ | |
| 78 | my($k, $v) = ($1, $2); | |
| 79 | $v =~ s/\\"/"/g; $v =~ s/\\n/\n/g; $v =~ s/\\r/\r/g; $v =~ s/\\\\/\\/g; | |
| 80 | $out{$k} = $v; | |
| 81 | } | |
| 82 | while($s =~ /"([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*(-?\d+(?:\.\d+)?)/g){ | |
| 83 | $out{$1} = $2 unless exists $out{$1}; | |
| 84 | } | |
| 85 | while($s =~ /"([^"\\]*(?:\\.[^"\\]*)*)"\s*:\s*(true|false|null)/g){ | |
| 86 | my($k, $v) = ($1, $2); | |
| 87 | $out{$k} = ($v eq 'true' ? 1 : $v eq 'false' ? 0 : undef) unless exists $out{$k}; | |
| 88 | } | |
| 89 | return \%out; | |
| 90 | } | |
| 91 | ||
| 92 | sub api_dispatch{ | |
| 93 | #Rewritten to /api.cgi/v1/... by Apache. Parse PATH_INFO, dispatch to handler. | |
| 94 | ||
| 95 | $DBH = $db->db_connect(); | |
| 96 | if(!$DBH){ | |
| 97 | $api->send_response(500, { error => 'db connect failed' }); | |
| 98 | exit; | |
| 99 | } | |
| 100 | ||
| 101 | if(!$api->schema_ready($db, $DBH, $database_name)){ | |
| 102 | $api->send_response(503, { error => 'api not provisioned' }); | |
| 103 | exit; | |
| 104 | } | |
| 105 | ||
| 106 | $AUTH = $api->authenticate($db, $DBH, $database_name); | |
| 107 | if(!$AUTH){ | |
| 108 | $api->send_response(401, { error => 'invalid or missing API key', hint => 'Set Authorization: Bearer tfk_...' }); | |
| 109 | exit; | |
| 110 | } | |
| 111 | ||
| 112 | my $path = $ENV{PATH_INFO} || $query->url_param('path') || ''; | |
| 113 | my $method = uc($ENV{REQUEST_METHOD} || 'GET'); | |
| 114 | ||
| 115 | #Read JSON body for write methods | |
| 116 | my $body_json = ''; | |
| 117 | if($method =~ /^(POST|PATCH|PUT)$/){ | |
| 118 | my $len = $ENV{CONTENT_LENGTH} || 0; | |
| 119 | read(STDIN, $body_json, $len) if $len; | |
| 120 | } | |
| 121 | my $body = $body_json ? &parse_json($body_json) : {}; | |
| 122 | ||
| 123 | #Route dispatch | |
| 124 | if ($path =~ m~^/v1/me/?$~) {&do_me;} | |
| 125 | elsif($path =~ m~^/v1/projects/?$~) {&do_projects;} | |
| 126 | elsif($path =~ m~^/v1/tasks/?$~ && $method eq 'GET') {&do_tasks_list;} | |
| 127 | elsif($path =~ m~^/v1/tasks/?$~ && $method eq 'POST') {&do_tasks_create($body);} | |
| 128 | elsif($path =~ m~^/v1/tasks/(\d+)/?$~ && $method eq 'GET') {&do_tasks_get($1);} | |
| 129 | elsif($path =~ m~^/v1/tasks/(\d+)/?$~ && $method eq 'PATCH') {&do_tasks_update($1, $body);} | |
| 130 | elsif($path =~ m~^/v1/tasks/(\d+)/comments/?$~ && $method eq 'POST'){&do_tasks_comment($1, $body);} | |
| 131 | elsif($path =~ m~^/v1/users/?$~) {&do_users;} | |
| 132 | elsif($path =~ m~^/v1/departments/?$~) {&do_departments;} | |
| 133 | elsif($path =~ m~^/v1/task_types/?$~) {&do_task_types;} | |
| 134 | else{ | |
| 135 | $api->send_response(404, { error => 'no such endpoint', path => $path, method => $method }); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | sub do_me{ | |
| 140 | $api->send_response(200, { | |
| 141 | company_id => $AUTH->{company_id}, | |
| 142 | user_id => $AUTH->{user_id}, | |
| 143 | scopes => $AUTH->{scopes}, | |
| 144 | api_version => 'v1', | |
| 145 | }); | |
| 146 | } | |
| 147 | ||
| 148 | sub do_projects{ | |
| 149 | my $cid = $AUTH->{company_id}; | |
| 150 | my @r = $db->db_readwrite_multiple($DBH, qq~SELECT id, name, key_prefix, color FROM `${database_name}`.projects WHERE company_id='$cid' ORDER BY name LIMIT 200~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 151 | $api->send_response(200, { projects => \@r, count => scalar(@r) }); | |
| 152 | } | |
| 153 | ||
| 154 | sub do_tasks_list{ | |
| 155 | my $cid = $AUTH->{company_id}; | |
| 156 | my $f = $query->Vars; | |
| 157 | ||
| 158 | my @where = ("t.company_id='$cid'"); | |
| 159 | push @where, "t.status='$f->{status}'" if $f->{status} && $f->{status} =~ /^(open|in_progress|blocked|review|done|cancelled)$/; | |
| 160 | push @where, "t.priority='$f->{priority}'" if $f->{priority} && $f->{priority} =~ /^(low|medium|high|urgent|critical)$/; | |
| 161 | push @where, "t.project_id='$f->{project_id}'" if $f->{project_id} && $f->{project_id} =~ /^\d+$/; | |
| 162 | push @where, "t.work_item_type='$f->{work_item_type}'" if $f->{work_item_type} && $f->{work_item_type} =~ /^(task|ticket|bug|epic|story|request|incident)$/; | |
| 163 | push @where, "t.assigned_user_id='$f->{assigned_user_id}'" if $f->{assigned_user_id} && $f->{assigned_user_id} =~ /^\d+$/; | |
| 164 | ||
| 165 | my $limit = $f->{limit} || 50; $limit =~ s/[^0-9]//g; $limit ||= 50; $limit = 200 if $limit > 200; | |
| 166 | my $offset = $f->{offset} || 0; $offset =~ s/[^0-9]//g; $offset = 0 if $offset eq ''; | |
| 167 | my $w = join(' AND ', @where); | |
| 168 | ||
| 169 | my @rows = $db->db_readwrite_multiple($DBH, qq~SELECT t.id, t.title, t.status, t.priority, t.work_item_type, t.requester_type, t.customer_email, t.customer_name, t.ticket_number, t.project_id, t.task_type_id, t.assigned_user_id, t.assigned_department_id, t.reporter_user_id, t.due_date, t.created_at, t.updated_at, t.completed_at, p.key_prefix FROM `${database_name}`.tasks t JOIN `${database_name}`.projects p ON p.id = t.project_id WHERE $w ORDER BY t.updated_at DESC LIMIT $limit OFFSET $offset~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 170 | foreach my $r(@rows){ | |
| 171 | $r->{display_id} = ($r->{key_prefix} ? "$r->{key_prefix}-$r->{ticket_number}" : "TF-$r->{id}"); | |
| 172 | } | |
| 173 | ||
| 174 | $api->send_response(200, { tasks => \@rows, count => scalar(@rows), limit => $limit+0, offset => $offset+0 }); | |
| 175 | } | |
| 176 | ||
| 177 | sub do_tasks_get{ | |
| 178 | my($id) = @_; | |
| 179 | my $cid = $AUTH->{company_id}; | |
| 180 | my $r = $db->db_readwrite($DBH, qq~SELECT t.*, p.key_prefix FROM `${database_name}`.tasks t JOIN `${database_name}`.projects p ON p.id=t.project_id WHERE t.id='$id' AND t.company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 181 | if(!$r || !$r->{id}){$api->send_response(404, { error => 'task not found' }); return;} | |
| 182 | $r->{display_id} = ($r->{key_prefix} ? "$r->{key_prefix}-$r->{ticket_number}" : "TF-$r->{id}"); | |
| 183 | $api->send_response(200, { task => $r }); | |
| 184 | } | |
| 185 | ||
| 186 | sub do_tasks_create{ | |
| 187 | my($b) = @_; | |
| 188 | my $cid = $AUTH->{company_id}; | |
| 189 | ||
| 190 | my $pid = $b->{project_id}; | |
| 191 | $pid =~ s/[^0-9]//g if defined $pid; | |
| 192 | return $api->send_response(400, { error => 'project_id required' }) unless $pid; | |
| 193 | ||
| 194 | my $title = substr(($b->{title} || ''), 0, 280); | |
| 195 | return $api->send_response(400, { error => 'title required' }) unless length $title; | |
| 196 | ||
| 197 | my $proj = $db->db_readwrite($DBH, qq~SELECT id FROM `${database_name}`.projects WHERE id='$pid' AND company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 198 | return $api->send_response(404, { error => 'project not found' }) unless $proj && $proj->{id}; | |
| 199 | ||
| 200 | my $col = $db->db_readwrite($DBH, qq~SELECT id FROM `${database_name}`.project_kanban_columns WHERE project_id='$pid' ORDER BY position LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 201 | return $api->send_response(422, { error => 'project has no kanban columns' }) unless $col && $col->{id}; | |
| 202 | ||
| 203 | my $tn = $db->db_readwrite($DBH, qq~SELECT COALESCE(MAX(ticket_number),0)+1 AS n FROM `${database_name}`.tasks WHERE project_id='$pid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 204 | my $ticket_n = ($tn && $tn->{n}) ? $tn->{n} : 1; | |
| 205 | ||
| 206 | my $prio = ($b->{priority} && $b->{priority} =~ /^(low|medium|high|urgent|critical)$/) ? $b->{priority} : 'medium'; | |
| 207 | my $wit = ($b->{work_item_type} && $b->{work_item_type} =~ /^(task|ticket|bug|epic|story|request|incident)$/) ? $b->{work_item_type} : 'task'; | |
| 208 | my $reporter = $AUTH->{user_id} || 1; | |
| 209 | ||
| 210 | my $t_q = $title; $t_q =~ s/'/''/g; | |
| 211 | my $d_q = substr(($b->{description} || ''), 0, 64000); $d_q =~ s/'/''/g; | |
| 212 | my $ce = substr(($b->{customer_email} || ''), 0, 255); $ce =~ s/'/''/g; | |
| 213 | my $cn = substr(($b->{customer_name} || ''), 0, 160); $cn =~ s/'/''/g; | |
| 214 | my $req_type = (length $ce) ? 'external' : 'internal'; | |
| 215 | ||
| 216 | my $sql = qq~INSERT INTO `${database_name}`.tasks SET company_id='$cid', project_id='$pid', ticket_number='$ticket_n', title='$t_q', description='$d_q', column_id='$col->{id}', position_in_column=0, status='open', priority='$prio', work_item_type='$wit', requester_type='$req_type', source='api', customer_email='$ce', customer_name='$cn', reporter_user_id='$reporter'~; | |
| 217 | my $r = $db->db_readwrite($DBH, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 218 | my $new_id = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0; | |
| 219 | ||
| 220 | #Fire outbound webhooks + SLA setup for this event (best-effort) | |
| 221 | eval { | |
| 222 | require MODS::PTMatrix::WebhookRouter; | |
| 223 | MODS::PTMatrix::WebhookRouter->new->fire($db, $DBH, $database_name, $cid, 'task.created', { | |
| 224 | task_id => $new_id, title => $title, project_id => $pid, | |
| 225 | status => 'open', priority => $prio, work_item_type => $wit, | |
| 226 | customer_email => $ce, customer_name => $cn, source => 'api', | |
| 227 | }); | |
| 228 | }; | |
| 229 | eval { | |
| 230 | require MODS::PTMatrix::SLA; | |
| 231 | my $tk = $db->db_readwrite($DBH, qq~SELECT id, company_id, project_id, priority, work_item_type, assigned_department_id FROM `${database_name}`.tasks WHERE id='$new_id'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 232 | MODS::PTMatrix::SLA->new->apply_for_task($db, $DBH, $database_name, $tk) if $tk; | |
| 233 | }; | |
| 234 | ||
| 235 | $api->send_response(201, { task_id => $new_id, ticket_number => $ticket_n }); | |
| 236 | } | |
| 237 | ||
| 238 | sub do_tasks_update{ | |
| 239 | my($id, $b) = @_; | |
| 240 | my $cid = $AUTH->{company_id}; | |
| 241 | ||
| 242 | my $r = $db->db_readwrite($DBH, qq~SELECT id, status FROM `${database_name}`.tasks WHERE id='$id' AND company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 243 | return $api->send_response(404, { error => 'task not found' }) unless $r && $r->{id}; | |
| 244 | ||
| 245 | my @sets; | |
| 246 | if(defined $b->{title}){ | |
| 247 | my $v = substr($b->{title}, 0, 280); | |
| 248 | $v =~ s/'/''/g; | |
| 249 | push @sets, "title='$v'"; | |
| 250 | } | |
| 251 | if(defined $b->{status} && $b->{status} =~ /^(open|in_progress|blocked|review|done|cancelled)$/){ | |
| 252 | push @sets, "status='$b->{status}'"; | |
| 253 | push @sets, 'completed_at=NOW()' if $b->{status} eq 'done'; | |
| 254 | } | |
| 255 | if(defined $b->{priority} && $b->{priority} =~ /^(low|medium|high|urgent|critical)$/){ | |
| 256 | push @sets, "priority='$b->{priority}'"; | |
| 257 | } | |
| 258 | if(defined $b->{assigned_user_id}){ | |
| 259 | my $v = $b->{assigned_user_id}; $v =~ s/[^0-9]//g; | |
| 260 | push @sets, "assigned_user_id=" . (length $v ? "'$v'" : 'NULL'); | |
| 261 | } | |
| 262 | if(defined $b->{assigned_department_id}){ | |
| 263 | my $v = $b->{assigned_department_id}; $v =~ s/[^0-9]//g; | |
| 264 | push @sets, "assigned_department_id=" . (length $v ? "'$v', routed_at=NOW()" : 'NULL'); | |
| 265 | } | |
| 266 | ||
| 267 | return $api->send_response(400, { error => 'nothing to update' }) unless @sets; | |
| 268 | ||
| 269 | my $set = join(',', @sets); | |
| 270 | $db->db_readwrite($DBH, qq~UPDATE `${database_name}`.tasks SET $set WHERE id='$id'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 271 | ||
| 272 | #Fire webhooks + SLA transition | |
| 273 | eval { | |
| 274 | require MODS::PTMatrix::WebhookRouter; | |
| 275 | MODS::PTMatrix::WebhookRouter->new->fire($db, $DBH, $database_name, $cid, 'task.updated', { task_id => $id, %$b }); | |
| 276 | }; | |
| 277 | if($b->{status}){ | |
| 278 | eval { | |
| 279 | require MODS::PTMatrix::SLA; | |
| 280 | if($b->{status} =~ /^(done|cancelled|resolved|closed)$/){ | |
| 281 | MODS::PTMatrix::SLA->new->mark_resolved($db, $DBH, $database_name, $id); | |
| 282 | } | |
| 283 | }; | |
| 284 | } | |
| 285 | ||
| 286 | $api->send_response(200, { task_id => $id+0, updated => 1 }); | |
| 287 | } | |
| 288 | ||
| 289 | sub do_tasks_comment{ | |
| 290 | my($id, $b) = @_; | |
| 291 | my $cid = $AUTH->{company_id}; | |
| 292 | ||
| 293 | my $body = substr(($b->{body} || ''), 0, 64000); | |
| 294 | return $api->send_response(400, { error => 'body required' }) unless length $body; | |
| 295 | ||
| 296 | my $r = $db->db_readwrite($DBH, qq~SELECT id FROM `${database_name}`.tasks WHERE id='$id' AND company_id='$cid' LIMIT 1~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 297 | return $api->send_response(404, { error => 'task not found' }) unless $r && $r->{id}; | |
| 298 | ||
| 299 | my $b_q = $body; $b_q =~ s/'/''/g; | |
| 300 | my $internal = $b->{internal_only} ? 1 : 0; | |
| 301 | my $user_sql = $AUTH->{user_id} ? "'$AUTH->{user_id}'" : 'NULL'; | |
| 302 | ||
| 303 | my $insert = $db->db_readwrite($DBH, qq~INSERT INTO `${database_name}`.task_comments SET task_id='$id', user_id=$user_sql, body='$b_q', internal_only='$internal'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 304 | my $cmt_id = ($insert && $insert->{mysql_insertid}) ? $insert->{mysql_insertid} : 0; | |
| 305 | ||
| 306 | eval { | |
| 307 | require MODS::PTMatrix::WebhookRouter; | |
| 308 | MODS::PTMatrix::WebhookRouter->new->fire($db, $DBH, $database_name, $cid, 'task.commented', { | |
| 309 | task_id => $id, comment_id => $cmt_id, body_excerpt => substr($body, 0, 200), | |
| 310 | }); | |
| 311 | }; | |
| 312 | eval { | |
| 313 | require MODS::PTMatrix::SLA; | |
| 314 | MODS::PTMatrix::SLA->new->mark_response($db, $DBH, $database_name, $id) if !$internal; | |
| 315 | }; | |
| 316 | ||
| 317 | $api->send_response(201, { task_id => $id+0, comment_id => $cmt_id+0 }); | |
| 318 | } | |
| 319 | ||
| 320 | sub do_users{ | |
| 321 | my $cid = $AUTH->{company_id}; | |
| 322 | my @r = $db->db_readwrite_multiple($DBH, qq~SELECT u.id, u.display_name, u.email, u.role, u.job_title, GROUP_CONCAT(dm.department_id) AS department_ids FROM `${database_name}`.users u LEFT JOIN `${database_name}`.department_members dm ON dm.user_id = u.id WHERE u.company_id='$cid' AND u.account_status='active' GROUP BY u.id ORDER BY u.display_name LIMIT 500~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 323 | foreach my $row(@r){ | |
| 324 | if(defined $row->{department_ids} && length $row->{department_ids}){ | |
| 325 | $row->{department_ids} = [ map { $_ + 0 } split /,/, $row->{department_ids} ]; | |
| 326 | }else{ | |
| 327 | $row->{department_ids} = []; | |
| 328 | } | |
| 329 | } | |
| 330 | $api->send_response(200, { users => \@r, count => scalar(@r) }); | |
| 331 | } | |
| 332 | ||
| 333 | sub do_departments{ | |
| 334 | my $cid = $AUTH->{company_id}; | |
| 335 | my @r = $db->db_readwrite_multiple($DBH, qq~SELECT id, name, color FROM `${database_name}`.departments WHERE company_id='$cid' ORDER BY name~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 336 | $api->send_response(200, { departments => \@r, count => scalar(@r) }); | |
| 337 | } | |
| 338 | ||
| 339 | sub do_task_types{ | |
| 340 | my $cid = $AUTH->{company_id}; | |
| 341 | my @r = $db->db_readwrite_multiple($DBH, qq~SELECT id, name, color FROM `${database_name}`.task_types WHERE company_id='$cid' ORDER BY name~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 342 | $api->send_response(200, { task_types => \@r, count => scalar(@r) }); | |
| 343 | } | |
| 344 | ||
| 345 | #============================================================ | |
| 346 | # api_keys entry: admin UI for API keys + webhook subscriptions | |
| 347 | #============================================================ | |
| 348 | sub ak_enc{ | |
| 349 | my $s = shift; | |
| 350 | $s = '' unless defined $s; | |
| 351 | $s =~ s/'/''/g; | |
| 352 | return "'$s'"; | |
| 353 | } | |
| 354 | ||
| 355 | sub ak_int_safe{ | |
| 356 | my $s = shift // ''; | |
| 357 | $s =~ s/[^0-9]//g; | |
| 358 | return length($s) ? $s : 0; | |
| 359 | } | |
| 360 | ||
| 361 | sub api_action{ | |
| 362 | my($userinfo) = @_; | |
| 363 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 364 | my $uid = MODS::PTMatrix::PM::get_user_id($userinfo); | |
| 365 | ||
| 366 | my $dbh = $db->db_connect(); | |
| 367 | my $schema_ready = $api->schema_ready($db, $dbh, $database_name); | |
| 368 | if(!$schema_ready){ | |
| 369 | $db->db_disconnect($dbh); | |
| 370 | &api_panel($userinfo); | |
| 371 | return; | |
| 372 | } | |
| 373 | ||
| 374 | my $act = $form->{act} || ''; | |
| 375 | my ($flash_kind, $flash_msg, $new_key_plain) = ('', '', ''); | |
| 376 | ||
| 377 | if($act eq 'create_key'){ | |
| 378 | my $name = substr($rawform{name} || 'API key', 0, 120); | |
| 379 | my $scopes = $rawform{scopes} || 'read,write'; | |
| 380 | $scopes =~ s/[^a-z,_]//g; | |
| 381 | my ($plain) = $api->issue_key($db, $dbh, $database_name, | |
| 382 | company_id => $cid, | |
| 383 | created_by_user_id => $uid, | |
| 384 | name => $name, | |
| 385 | scopes => $scopes, | |
| 386 | ); | |
| 387 | $new_key_plain = $plain; | |
| 388 | $flash_kind = 'ok'; | |
| 389 | $flash_msg = 'API key created. <strong>This is the ONLY time you will see the full secret</strong> — copy it now.'; | |
| 390 | }elsif($act eq 'delete_key'){ | |
| 391 | my $id = &ak_int_safe($form->{id}); | |
| 392 | $db->db_readwrite($dbh, qq~DELETE FROM `${database_name}`.api_keys WHERE id='$id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 393 | $flash_kind = 'ok'; $flash_msg = 'Key revoked.'; | |
| 394 | }elsif($act eq 'toggle_key'){ | |
| 395 | my $id = &ak_int_safe($form->{id}); | |
| 396 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.api_keys SET is_active=1-is_active WHERE id='$id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 397 | $flash_kind = 'ok'; $flash_msg = 'Toggled.'; | |
| 398 | }elsif($act eq 'create_webhook'){ | |
| 399 | my $name = substr($rawform{name} || '', 0, 120); | |
| 400 | my $target = substr($rawform{target_url} || '', 0, 1024); | |
| 401 | my $events = substr($rawform{event_kinds} || '', 0, 500); | |
| 402 | $events =~ s/\s//g; | |
| 403 | ||
| 404 | if(length $name && $target =~ m~^https?://~i){ | |
| 405 | my $secret; | |
| 406 | if(open my $fh, '<', '/dev/urandom'){ | |
| 407 | my $buf; | |
| 408 | read($fh, $buf, 24); | |
| 409 | close $fh; | |
| 410 | $secret = unpack('H*', $buf); | |
| 411 | }else{ | |
| 412 | $secret = sprintf('%016x%016x%016x', int(rand(2**32)), int(rand(2**32)), int(rand(2**32))); | |
| 413 | } | |
| 414 | ||
| 415 | my $cid_q = &ak_enc($cid); | |
| 416 | my $name_q = &ak_enc($name); | |
| 417 | my $target_q = &ak_enc($target); | |
| 418 | my $events_q = length($events) ? &ak_enc($events) : 'NULL'; | |
| 419 | my $secret_q = &ak_enc($secret); | |
| 420 | my $uid_q = &ak_enc($uid); | |
| 421 | ||
| 422 | my $sql = qq~INSERT INTO `${database_name}`.webhook_subscriptions SET company_id=$cid_q, name=$name_q, target_url=$target_q, event_kinds=$events_q, signing_secret=$secret_q, created_by_user_id=$uid_q~; | |
| 423 | $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 424 | ||
| 425 | $flash_kind = 'ok'; $flash_msg = 'Webhook created. Signing secret will appear in the row.'; | |
| 426 | } | |
| 427 | }elsif($act eq 'delete_webhook'){ | |
| 428 | my $id = &ak_int_safe($form->{id}); | |
| 429 | $db->db_readwrite($dbh, qq~DELETE FROM `${database_name}`.webhook_subscriptions WHERE id='$id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 430 | $flash_kind = 'ok'; $flash_msg = 'Subscription removed.'; | |
| 431 | }elsif($act eq 'toggle_webhook'){ | |
| 432 | my $id = &ak_int_safe($form->{id}); | |
| 433 | $db->db_readwrite($dbh, qq~UPDATE `${database_name}`.webhook_subscriptions SET is_active=1-is_active WHERE id='$id' AND company_id='$cid'~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 434 | $flash_kind = 'ok'; $flash_msg = 'Toggled.'; | |
| 435 | } | |
| 436 | ||
| 437 | $db->db_disconnect($dbh); | |
| 438 | &api_panel($userinfo, $flash_kind, $flash_msg, $new_key_plain); | |
| 439 | } | |
| 440 | ||
| 441 | sub api_panel{ | |
| 442 | my($userinfo, $flash_kind, $flash_msg, $new_key_plain) = @_; | |
| 443 | $flash_kind ||= ''; | |
| 444 | $flash_msg ||= ''; | |
| 445 | $new_key_plain ||= ''; | |
| 446 | ||
| 447 | my $cid = MODS::PTMatrix::PM::get_company_id($userinfo); | |
| 448 | my $dbh = $db->db_connect(); | |
| 449 | my $schema_ready = $api->schema_ready($db, $dbh, $database_name); | |
| 450 | ||
| 451 | my @keys = $schema_ready ? $db->db_readwrite_multiple($dbh, qq~SELECT * FROM `${database_name}`.api_keys WHERE company_id='$cid' ORDER BY is_active DESC, created_at DESC~, $ENV{SCRIPT_NAME}, __LINE__) : (); | |
| 452 | my @hooks = $schema_ready ? $db->db_readwrite_multiple($dbh, qq~SELECT * FROM `${database_name}`.webhook_subscriptions WHERE company_id='$cid' ORDER BY is_active DESC, created_at DESC~, $ENV{SCRIPT_NAME}, __LINE__) : (); | |
| 453 | ||
| 454 | $db->db_disconnect($dbh); | |
| 455 | ||
| 456 | my @key_rows; | |
| 457 | foreach my $r(@keys){ | |
| 458 | push @key_rows, { | |
| 459 | id => $r->{id}, | |
| 460 | name => MODS::PTMatrix::PM::h($r->{name}), | |
| 461 | secret_prefix => MODS::PTMatrix::PM::h($r->{secret_prefix}), | |
| 462 | scopes => MODS::PTMatrix::PM::h($r->{scopes}), | |
| 463 | is_active => $r->{is_active} ? 1 : 0, | |
| 464 | active_label => $r->{is_active} ? 'Active' : 'Revoked', | |
| 465 | last_used_at => $r->{last_used_at} || 'never', | |
| 466 | use_count => $r->{use_count} || 0, | |
| 467 | }; | |
| 468 | } | |
| 469 | ||
| 470 | my @hook_rows; | |
| 471 | foreach my $r(@hooks){ | |
| 472 | push @hook_rows, { | |
| 473 | id => $r->{id}, | |
| 474 | name => MODS::PTMatrix::PM::h($r->{name}), | |
| 475 | target_url => MODS::PTMatrix::PM::h($r->{target_url}), | |
| 476 | event_kinds => MODS::PTMatrix::PM::h($r->{event_kinds} || 'all'), | |
| 477 | signing_secret => MODS::PTMatrix::PM::h($r->{signing_secret}), | |
| 478 | is_active => $r->{is_active} ? 1 : 0, | |
| 479 | active_label => $r->{is_active} ? 'Active' : 'Paused', | |
| 480 | delivered_count => $r->{delivered_count} || 0, | |
| 481 | failed_count => $r->{failed_count} || 0, | |
| 482 | last_delivered_at => $r->{last_delivered_at} || 'never', | |
| 483 | last_error => $r->{last_error} ? MODS::PTMatrix::PM::h(substr($r->{last_error}, 0, 200)) : '', | |
| 484 | has_error => $r->{last_error} ? 1 : 0, | |
| 485 | }; | |
| 486 | } | |
| 487 | ||
| 488 | print qq~Content-Type: text/html; charset=utf-8\nCache-Control: no-store\n\n~; | |
| 489 | ||
| 490 | my $tvars; | |
| 491 | $tvars->{keys} = \@key_rows; | |
| 492 | $tvars->{has_keys} = scalar(@key_rows) ? 1 : 0; | |
| 493 | $tvars->{hooks} = \@hook_rows; | |
| 494 | $tvars->{has_hooks} = scalar(@hook_rows) ? 1 : 0; | |
| 495 | $tvars->{new_key_plain} = MODS::PTMatrix::PM::h($new_key_plain); | |
| 496 | $tvars->{has_new_key} = $new_key_plain ? 1 : 0; | |
| 497 | $tvars->{schema_ready} = $schema_ready ? 1 : 0; | |
| 498 | $tvars->{schema_missing} = $schema_ready ? 0 : 1; | |
| 499 | $tvars->{flash_kind} = $flash_kind; | |
| 500 | $tvars->{flash_msg} = $flash_msg; | |
| 501 | $tvars->{has_flash} = $flash_msg ne '' ? 1 : 0; | |
| 502 | ||
| 503 | my $body = join('', $tfile->template('tf_api_keys.html', $tvars, $userinfo)); | |
| 504 | ||
| 505 | $load->render({ | |
| 506 | userinfo => $userinfo, | |
| 507 | page_key => 'api_keys', | |
| 508 | title => 'API & Webhooks', | |
| 509 | body => $body, | |
| 510 | }); | |
| 511 | } |