Code: Select all
<?php
namespace Statbus\Controllers;
use Psr\Container\ContainerInterface;
use Statbus\Controllers\Controller as Controller;
use Statbus\Models\Ticket as Ticket;
use Statbus\Models\Player as Player;
class TicketController extends Controller {
public function __construct(ContainerInterface $container) {
parent::__construct($container);
$this->settings = $this->container->get('settings')['statbus'];
$this->tm = new Ticket($this->settings);
$this->pm = new Player($this->settings);
// $this->pages = ceil($this->DB->cell("SELECT count(tbl_messages.id) FROM tbl_messages WHERE tbl_messages.deleted = 0
// AND (tbl_messages.expire_timestamp > NOW() OR tbl_messages.expire_timestamp IS NULL)") / $this->per_page);
$this->url = $this->router->pathFor('ticket.index');
$this->path = 'ticket.single';
$this->permaLink = 'ticket.single';
}
public function getActiveTickets(){
$this->pages = ceil($this->DB->cell("SELECT
count(tbl_ticket.id)
FROM tbl_ticket
WHERE tbl_ticket.action = 'Ticket Opened';") / $this->per_page);
$tickets = $this->DB->run("
SELECT
t.server_ip,
t.server_port as port,
t.round_id as round,
t.ticket,
t.action,
t.message,
t.timestamp,
t.recipient as recipient_ckey,
t.sender as sender_ckey,
'player' as r_rank,
'player' as s_rank,
(SELECT `action` FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id ORDER BY id DESC LIMIT 1) as `status`,
(SELECT COUNT(id) FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id) as `replies`
FROM tbl_ticket t
WHERE t.action = 'Ticket Opened'
GROUP BY t.id
ORDER BY `timestamp` DESC
LIMIT ?, ?;", ($this->page * $this->per_page) - $this->per_page, $this->per_page);
foreach ($tickets as &$t){
$t->sender = new \stdclass;
$t->sender->ckey = $t->sender_ckey;
$t->sender->rank = $t->s_rank;
$t->sender = $this->pm->parsePlayer($t->sender);
$t->recipient = new \stdclass;
$t->recipient->ckey = $t->recipient_ckey;
$t->recipient->rank = $t->r_rank;
$t->recipient = $this->pm->parsePlayer($t->recipient);
$t = $this->tm->parseTicket($t);
}
return $tickets;
}
public function getTicketsForRound(int $round) {
$round = filter_var($round, FILTER_VALIDATE_INT);
$tickets = $this->DB->run("SELECT
t.id,
t.server_ip,
t.server_port as port,
t.round_id as round,
t.ticket,
t.action,
t.message,
t.timestamp,
t.recipient as recipient_ckey,
t.sender as sender_ckey,
r.rank as r_rank,
s.rank as s_rank,
(SELECT `action` FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id ORDER BY id DESC LIMIT 1) as `status`,
(SELECT COUNT(id) FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id) as `replies`
FROM tbl_ticket t
LEFT JOIN tbl_admin AS r ON r.ckey = t.recipient
LEFT JOIN tbl_admin AS s ON s.ckey = t.sender
WHERE t.round_id = ?
AND t.action = 'Ticket Opened'
ORDER BY `timestamp` ASC;", $round);
foreach ($tickets as &$t){
$t->sender = new \stdclass;
$t->sender->ckey = $t->sender_ckey;
$t->sender->rank = $t->s_rank;
$t->sender = $this->pm->parsePlayer($t->sender);
$t->recipient = new \stdclass;
$t->recipient->ckey = $t->recipient_ckey;
$t->recipient->rank = $t->r_rank;
$t->recipient = $this->pm->parsePlayer($t->recipient);
$t = $this->tm->parseTicket($t);
}
return $tickets;
}
public function getSingleTicket(int $round, int $ticket){
$round = filter_var($round, FILTER_VALIDATE_INT);
$ticket = filter_var($ticket, FILTER_VALIDATE_INT);
$tickets = $this->DB->run("SELECT
t.id,
t.server_ip,
t.server_port as port,
t.round_id as round,
t.ticket,
t.action,
t.message,
t.timestamp,
t.recipient as recipient_ckey,
t.sender as sender_ckey,
r.rank as r_rank,
s.rank as s_rank
FROM tbl_ticket t
LEFT JOIN tbl_admin AS r ON r.ckey = t.recipient
LEFT JOIN tbl_admin AS s ON s.ckey = t.sender
WHERE t.round_id = ?
AND t.ticket = ?
ORDER BY `timestamp` ASC;", $round, $ticket);
foreach ($tickets as &$t){
$t->sender = new \stdclass;
$t->sender->ckey = $t->sender_ckey;
$t->sender->rank = $t->s_rank;
$t->sender = $this->pm->parsePlayer($t->sender);
$t->recipient = new \stdclass;
$t->recipient->ckey = $t->recipient_ckey;
$t->recipient->rank = $t->r_rank;
$t->recipient = $this->pm->parsePlayer($t->recipient);
$t = $this->tm->parseTicket($t);
}
return $tickets;
}
public function getTicketsForCkey(string $ckey) {
$this->pages = ceil($this->DB->cell("SELECT
count(t.id)
FROM tbl_ticket t
WHERE t.action = 'Ticket Opened' AND (t.recipient = ? OR t.sender = ?);", $ckey, $ckey) / $this->per_page);
$tickets = $this->DB->run("SELECT
t.server_ip,
t.server_port as port,
t.round_id as round,
t.ticket,
t.action,
t.message,
t.timestamp,
t.recipient as recipient_ckey,
t.sender as sender_ckey,
'player' as r_rank,
'player' as s_rank,
(SELECT `action` FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id ORDER BY id DESC LIMIT 1) as `status`,
(SELECT COUNT(id) FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id) as `replies`
FROM tbl_ticket t
WHERE t.action = 'Ticket Opened'
AND (t.recipient = ? OR t.sender = ?)
GROUP BY t.id
ORDER BY `timestamp` DESC
LIMIT ?, ?;", $ckey, $ckey, ($this->page * $this->per_page) - $this->per_page, $this->page * $this->per_page);
// var_dump(($this->page * $this->per_page) - $this->per_page);
// var_dump($this->page * $this->per_page);
foreach ($tickets as &$t){
$t->sender = new \stdclass;
$t->sender->ckey = $t->sender_ckey;
$t->sender->rank = $t->s_rank;
$t->sender = $this->pm->parsePlayer($t->sender);
$t->recipient = new \stdclass;
$t->recipient->ckey = $t->recipient_ckey;
$t->recipient->rank = $t->r_rank;
$t->recipient = $this->pm->parsePlayer($t->recipient);
$t = $this->tm->parseTicket($t);
}
return $tickets;
}
public function index($request, $response, $args) {
if(isset($args['page'])) {
$this->page = filter_var($args['page'], FILTER_VALIDATE_INT);
}
return $this->view->render($this->response, 'tickets/index.tpl',[
'tickets' => $this->getActiveTickets(),
'ticket' => $this,
]);
}
public function roundTickets($request, $response, $args){
$this->path = 'ticket.round';
return $this->view->render($this->response, 'tickets/round.tpl',[
'tickets' => $this->getTicketsForRound($args['round']),
'round' => $args['round'],
'ticket' => $this
]);
}
public function single($request, $response, $args){
return $this->view->render($this->response, 'tickets/single.tpl',[
'tickets' => $this->getSingleTicket($args['round'],$args['ticket']),
]);
}
public function myTickets($request, $response, $args) {
if(isset($args['page'])) {
$this->page = filter_var($args['page'], FILTER_VALIDATE_INT);
}
$user = $this->container->get('user');
$this->path = "me.tickets";
$this->permaLink = "me.tickets.single";
return $this->view->render($this->response, 'tickets/me.tpl',[
'tickets' => $this->getTicketsForCkey($user->ckey),
'ticket' => $this,
]);
}
public function myTicket($request, $response, $args){
$this->user = $this->container->get('user');
$tickets = $this->getSingleTicket($args['round'], $args['ticket']);
if(!in_array($this->user->ckey, [$tickets[0]->sender_ckey, $tickets[0]->recipient_ckey])) {
return $this->view->render($this->response, 'base/error.tpl',[
'message' => 'You do not have permission to view this',
'code' => 403
]);
}
$canPublicize = false;
if(!$tickets[0]->recipient && $this->user->ckey === $tickets[0]->sender_ckey){
$canPublicize = TRUE; //Ahelps sent by anyone regardless of rank
}
if($this->user->ckey === $tickets[0]->recipient_ckey) {
$canPublicize = TRUE; //Ahelps sent from admin to player
}
if('POST' === $this->request->getMethod() && TRUE === $canPublicize){
$this->setTicketStatus($tickets[0]->id);
}
$status = $this->ticketPublicityStatus($tickets[0]->id);
@$status->canPublicize = $canPublicize;
return $this->view->render($this->response, 'tickets/single.me.tpl',[
'tickets' => $tickets,
'status' => $status
]);
}
public function publicTicket($request, $response, $args){
$this->alt_db = $this->container->get('ALT_DB');
$id = $this->getTicketIDFromIdentifier($args['identifier']);
$status = $this->ticketPublicityStatus($id);
if($status && 1 !== $status->status){
return $this->view->render($this->response, 'base/error.tpl',[
'message' => 'You do not have permission to view this',
'code' => 403
]);
}
$ticket = $this->getFullTicketFromID($id);
$tickets = $this->getSingleTicket($ticket->round_id, $ticket->ticket);
return $this->view->render($this->response, 'tickets/single.me.tpl',[
'tickets' => $tickets,
'status' => $status
]);
}
private function getFullTicketFromID($id){
return($this->DB->row("SELECT round_id, ticket FROM tbl_ticket WHERE id = ?", $id));
}
private function getTicketIDFromIdentifier($identifier) {
return $this->alt_db->cell("SELECT ticket FROM public_tickets WHERE identifier = ?", $identifier);
}
private function ticketPublicityStatus($id){
$this->alt_db = $this->container->get('ALT_DB');
$status = $this->alt_db->row("SELECT * FROM public_tickets WHERE ticket = ?", $id);
return $status;
}
private function setTicketStatus($id){
$status = $this->ticketPublicityStatus($id);
if(!$status){
$this->alt_db->insert("public_tickets", [
'ticket' => $id,
'status' => 1,
'identifier' => substr(hash('SHA512',base64_encode(random_bytes(32))),0,16)
]
);
} else if(1 === $status->status) {
$this->alt_db->run("UPDATE public_tickets SET `status` = 0 WHERE ticket = ?", $id);
} else {
$this->alt_db->run("UPDATE public_tickets SET `status` = 1 WHERE ticket = ?", $id);
}
}
}
This is the code that controls displaying tickets on Statbus. Let's dive in and do a code review!
Right off the bat, there are a number of structural issues. Specifically, this version of Statbus is built with "fat" controllers, where there's too much logic in one file. I've since learned to do a better job of separating my code out into smaller files. My forthcoming Banbus project will be an excellent demonstration of these principles.
Relatedly, I've also made the mistake of passing my dependency container into the controller. This is fine, but it's very inefficient and hard to determine what exactly the Ticket controller relies on. It's like building the entire house when you just need one specific room. At the time, this seemed like a good approach.
Code: Select all
public function __construct(ContainerInterface $container) {
parent::__construct($container);
$this->settings = $this->container->get('settings')['statbus'];
$this->tm = new Ticket($this->settings);
$this->pm = new Player($this->settings);
// $this->pages = ceil($this->DB->cell("SELECT count(tbl_messages.id) FROM tbl_messages WHERE tbl_messages.deleted = 0
// AND (tbl_messages.expire_timestamp > NOW() OR tbl_messages.expire_timestamp IS NULL)") / $this->per_page);
$this->url = $this->router->pathFor('ticket.index');
$this->path = 'ticket.single';
$this->permaLink = 'ticket.single';
}
This is the constructor magic method called every time the Ticket controller is invoked. As I said, the dependency container is passed in, and then sent to the parent classes constructor method. From there, we invoke a ticket and player model that we'll be reusing a few times elsewhere. Both of these models require an array of global Statbus settings in order to properly function. The player model is used to generate the Ticket's player and admin labels, to quickly show what rank someone is. Reviewing my code now, I don't think a player model needs to be invoked; the ticket model invokes its OWN player model. We've just got redundant classes being invoked here.
Now we've got some commented-out code here. This is a holdover from when I assumed incorrectly that I'd want to be paginating my results all the time, which means counting all the rows. I just need to remove those lines entirely.
Finally, we set some class properties that are universal and used by the template engine on the front end.
Code: Select all
public function getActiveTickets(){
$this->pages = ceil($this->DB->cell("SELECT
count(tbl_ticket.id)
FROM tbl_ticket
WHERE tbl_ticket.action = 'Ticket Opened';") / $this->per_page);
$tickets = $this->DB->run("
SELECT
t.server_ip,
t.server_port as port,
t.round_id as round,
t.ticket,
t.action,
t.message,
t.timestamp,
t.recipient as recipient_ckey,
t.sender as sender_ckey,
'player' as r_rank,
'player' as s_rank,
(SELECT `action` FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id ORDER BY id DESC LIMIT 1) as `status`,
(SELECT COUNT(id) FROM tbl_ticket WHERE t.ticket = ticket AND t.round_id = round_id) as `replies`
FROM tbl_ticket t
WHERE t.action = 'Ticket Opened'
GROUP BY t.id
ORDER BY `timestamp` DESC
LIMIT ?, ?;", ($this->page * $this->per_page) - $this->per_page, $this->per_page);
foreach ($tickets as &$t){
$t->sender = new \stdclass;
$t->sender->ckey = $t->sender_ckey;
$t->sender->rank = $t->s_rank;
$t->sender = $this->pm->parsePlayer($t->sender);
$t->recipient = new \stdclass;
$t->recipient->ckey = $t->recipient_ckey;
$t->recipient->rank = $t->r_rank;
$t->recipient = $this->pm->parsePlayer($t->recipient);
$t = $this->tm->parseTicket($t);
}
return $tickets;
}
Ultimately, most of these methods with a long list of columns to query can be moved into a private class property, since they don't change often from method to method. Even better, we can abstract these queries out into specific classes for talking to the database (i.e. a class like GetTicketsForRound, which would inherit the columns required for base functionality and extend them for the specific use case).
We also have to get some information to pass to the template's pagination. For this, we need to know:
1. How many pages of results there are
2. What page we're on
3. How many results we want per page
The latter two are handled in the controller parent class. For the first variable though, we need to count the number of rows, divided by the number of results we want per page. With MySQL though, we have to reverse the page number to the actual number of rows to offset the result by. The LIMIT clause of the query expects a row offset (Number of Results * current page number), and the number of rows to return from that offset ((number of results * current page) + number of results per page).
Now that we have the results, we need to format them for the template, and also massage a bunch of data that doesn't exist in the database into something that we can work with. The remainder of this method is dedicated to iterating over the query results and parsing them. The sender and recipient objects should be a class, instead of being built by hand in this manner. I honestly hate these sorts of code blocks, but I didn't know any better way to do this at the time.
getTicketsForRound, getSingleTicket and getTicketsForCkey are all basically the same as this first method, so I won't go over them again.
Now we're getting into the even less exciting parts of this class. index, roundTickets and single are all called by the application when you hit specific URLs. They're fairly straightforward, so I won't go into detail on them.
The myTickets method does some different things.
Code: Select all
public function myTickets($request, $response, $args) {
if(isset($args['page'])) {
$this->page = filter_var($args['page'], FILTER_VALIDATE_INT);
}
$user = $this->container->get('user');
$this->path = "me.tickets";
$this->permaLink = "me.tickets.single";
return $this->view->render($this->response, 'tickets/me.tpl',[
'tickets' => $this->getTicketsForCkey($user->ckey),
'ticket' => $this,
]);
}
First, we're getting the current page number, for reasons I don't exactly remember. This should be happening in the controller parent class. We're also getting the current active user from the container. Instead of putting the entire user object into a new variable though, we should just do something like
'tickets' => $this->getTicketsForCkey($this->container->get('user')->ckey)
Which brings us to myTicket! There's a LOT going on in this method.
Code: Select all
public function myTicket($request, $response, $args){
$this->user = $this->container->get('user');
$tickets = $this->getSingleTicket($args['round'], $args['ticket']);
if(!in_array($this->user->ckey, [$tickets[0]->sender_ckey, $tickets[0]->recipient_ckey])) {
return $this->view->render($this->response, 'base/error.tpl',[
'message' => 'You do not have permission to view this',
'code' => 403
]);
}
$canPublicize = false;
if(!$tickets[0]->recipient && $this->user->ckey === $tickets[0]->sender_ckey){
$canPublicize = TRUE; //Ahelps sent by anyone regardless of rank
}
if($this->user->ckey === $tickets[0]->recipient_ckey) {
$canPublicize = TRUE; //Ahelps sent from admin to player
}
if('POST' === $this->request->getMethod() && TRUE === $canPublicize){
$this->setTicketStatus($tickets[0]->id);
}
$status = $this->ticketPublicityStatus($tickets[0]->id);
@$status->canPublicize = $canPublicize;
ty
return $this->view->render($this->response, 'tickets/single.me.tpl',[
'tickets' => $tickets,
'status' => $status
]);
}
This method allows a user to view their own tickets. That is, a ticket they were involved in, either by being bwoinked by an admin, or pressing F1 and sending an ahelp. If you were paying attention to the previous method, this should start off pretty straightforward. Get the user from the container so we can get their ckey. Then, we get the single ticket. IF the current user's ckey is not the ticket's sender OR recipient, we deny the request. This prevents anyone who is not the current user from accessing the ticket. Note that this is for the user's tickets, and does not affect an admin being able to view a ticket through TGDB.
Next up, we're getting into whether or not a ticket is public, and what to do with it, depending on several states. As a failsafe, we're setting canPublicize to false by default. Now, we need to determine if the user is eligible to make this ticket public. As before, if this ticket was sent BY the user, or TO the user by an admin, they reserve the right to make that ticket public.
If the user clicks the Make Public button on their ticket, a POST request is made to the same URL, and if the user can change the publicity, we flip the status with the setTicketStatus method.
Code: Select all
private function setTicketStatus($id){
$status = $this->ticketPublicityStatus($id);
if(!$status){
$this->alt_db->insert("public_tickets", [
'ticket' => $id,
'status' => 1,
'identifier' => substr(hash('SHA512',base64_encode(random_bytes(32))),0,16)
]
);
} else if(1 === $status->status) {
$this->alt_db->run("UPDATE public_tickets SET `status` = 0 WHERE ticket = ?", $id);
} else {
$this->alt_db->run("UPDATE public_tickets SET `status` = 1 WHERE ticket = ?", $id);
}
}
This is a complex little bit of code that I'm particularly proud of. In order to prevent people from crawling the statbus for public tickets (by incrementing the round and ticket numbers), I generate a unique identifier for each public ticket, and store it in the alternative database for Statbus. If a ticket already exists in this database, I simply flip value the public column to the opposite of whatever it just was.
I hope this has been an enlightening post!