Software y sistemas

Sistema acortador de enlaces con PHP similar a bitly

Hoy vengo a presentar un sistema que recién he terminado de programar. Se trata de un software open source escrito en PHP con MySQL y Bootstrap, mismo que es un acortador de enlaces similar a los acortadores como bit.ly.

En este caso este programa se encarga de acortar enlaces, redireccionar a los usuarios y registrar cuando se hace clic. Aunque su funcionamiento está inspirado en los acortadores de enlaces, no tiene todas las funciones idénticas.

Este software gratuito que he creado con PHP tiene las siguientes características:

  1. Login y gestión de usuarios
  2. Creación de enlaces
    1. Enlaces sin redireccionamiento instantáneo: en este caso, antes de redireccionar al usuario, se presenta una plantilla en donde se puede poner publicidad, avisos, redes sociales etcétera.
    2. Links con redireccionamiento instantáneo: simplemente redireccionan al usuario de manera transparente, registrando el clic o visita.
  3. Reporte general de clics en rango de fecha, con gráfica y descripción de los enlaces más populares

Como siempre te digo, este software se puede personalizar, tomar como base, etcétera. Por cierto, hace un tiempo hice un software parecido pero ese acorta enlaces para ganar dinero, usando a su vez acortadores como ouo, adfly, etcétera.

Base de datos

Comencemos viendo la estructura de la base de datos y la conexión a la misma. Utilicé la función para conectar usando PDO. Todo está en una clase, así se separan los conceptos:

<?php

namespace Parzibyte;

use PDO;

class Database
{
    static function get()
    {
        $password = Utils::getVarFromEnvironmentVariables("MYSQL_PASSWORD");
        $user = Utils::getVarFromEnvironmentVariables("MYSQL_USER");
        $dbName = Utils::getVarFromEnvironmentVariables("MYSQL_DATABASE_NAME");
        $database = new PDO('mysql:host=localhost;dbname=' . $dbName, $user, $password);
        $database->query("set names utf8;");
        $database->setAttribute(PDO::ATTR_EMULATE_PREPARES, FALSE);
        $database->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $database->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
        return $database;
    }
}

Las credenciales de acceso son tomadas de un archivo de entorno, en donde se debe configurar la contraseña, usuario y nombre de la base de datos. También es un buen momento para presentar la clase de Útiles:

<?php

namespace Parzibyte;

use Exception;

class Utils
{

    static function getVarFromEnvironmentVariables($key)
    {
        if (defined("_ENV_CACHE")) {
            $vars = _ENV_CACHE;
        } else {
            $file = "env.php";
            if (!file_exists($file)) {
                throw new Exception("The environment file ($file) does not exists. Please create it");
            }
            $vars = parse_ini_file($file);
            define("_ENV_CACHE", $vars);
        }
        if (isset($vars[$key])) {
            return $vars[$key];
        } else {
            throw new Exception("The specified key (" . $key . ") does not exist in the environment file");
        }
    }


}

Y finalmente las tablas del sistema. Básicamente son 3 tablas:

  1. Enlaces: aquí se gestionan todos los links que se acortan en el sistema, se guarda el enlace al que redirecciona, el título, si es o no redireccionado instantáneamente y un hash único y aleatorio
  2. Usuarios: los usuarios, solo se guarda el correo y la contraseña. Obviamente la contraseña está hasheada con bcrypt pero antes se hace que sea de longitud fija con sha256.
  3. Estadísticas de enlaces: fecha en la que se hizo clic en un enlace, y el id del mismo. Aquí también se podría guardar la IP del usuario, el navegador, el enlace del que vienen, etcétera; pero lo dejé así por simplicidad.

El esquema queda así:

CREATE TABLE IF NOT EXISTS users(
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email varchar(255) NOT NULL UNIQUE,
    password varchar(255) NOT NULL
);
CREATE TABLE IF NOT EXISTS links(
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    hash varchar(6) UNIQUE,
    title varchar(255) NOT NULL,
    real_link varchar(1024) NOT NULL,
    instant_redirect BOOLEAN DEFAULT 1
);

CREATE TABLE IF NOT EXISTS statistics(
    link_id BIGINT UNSIGNED NOT NULL,
    date varchar(10) NOT NULL,
    FOREIGN KEY (link_id) REFERENCES links(id) ON DELETE CASCADE ON UPDATE CASCADE
);

# A default user. Password is 'hunter2'
INSERT INTO `users` (`email`, `password`) VALUES
('parzibyte@gmail.com', '$2y$10$DVMlG/zp8rB3KrW6oRvpvOgbIkoRRfOXu/9H5DgTfVQXwZP5m.tQy');


El script en este caso ya lleva un usuario por defecto para que pueda iniciar sesión, más adelante puedes entrar y cambiarle la contraseña (de este modo tendrás un usuario por defecto).

Login y gestión de usuarios

Para acceder a todas las funciones del sistema se usa la sesión y un login simple. El login se ve así:

Login – Acortador en PHP similar a bitly

Toda la gestión de usuarios se lleva a cabo dentro de la siguiente clase, tanto para crear un usuario, obtenerlos y autenticarlos:

<?php

namespace Parzibyte;

class UserController
{

    static function delete($id)
    {
        $db = Database::get();
        $statement = $db->prepare("DELETE FROM users WHERE id = ?");
        $statement->execute([$id]);
    }

    static function updatePassword($id, $password)
    {
        $hashedPassword = Security::hashPassword($password);
        $db = Database::get();
        $statement = $db->prepare("UPDATE users SET password = ? WHERE id = ?");
        $statement->execute([$hashedPassword, $id]);
    }

    static function getOneById($id)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT id, email, password FROM users WHERE id = ?");
        $statement->execute([$id]);
        return $statement->fetchObject();
    }
    static function getAll()
    {
        $db = Database::get();
        $statement = $db->query("SELECT id, email, password FROM users");
        return $statement->fetchAll();
    }
    static function create($email, $password)
    {
        $hashedPassword = Security::hashPassword($password);
        $db = Database::get();
        $statement = $db->prepare("INSERT INTO users(email, password) VALUES (?, ?)");
        $statement->execute([$email, $hashedPassword]);
    }

    static function auth($email, $password)
    {
        $user = self::getOneByEmail($email);
        if (!$user) {
            return false;
        }
        return Security::verifyPassword($password, $user->password);
    }

    static function authById($id, $password)
    {
        $user = self::getOneById($id);
        if (!$user) {
            return false;
        }
        return Security::verifyPassword($password, $user->password);
    }

    static function getOneByEmail($email)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT id, email, password FROM users WHERE email = ?");
        $statement->execute([$email]);
        return $statement->fetchObject();
    }
}

Esta clase es usada desde otros archivos, simplemente se invocan a los métodos necesarios.

Gestión de usuarios – Software acortador de enlaces

También veamos cómo se hashean las contraseñas:

<?php

namespace Parzibyte;

class Security
{
    static function hashPassword($password)
    {
        return password_hash(self::preparePlainPassword($password), PASSWORD_BCRYPT);
    }

    static function verifyPassword($password, $hash)
    {
        return password_verify(self::preparePlainPassword($password), $hash);
    }

    static function preparePlainPassword($password)
    {
        return hash("sha256", $password);
    }
}

Fíjate en que estamos usando SHA-256 pero no como método de encriptación, sino como método para acortar las contraseñas.

Es decir, así el usuario podría tener una contraseña sin límite de longitud, ya que al final se va a encriptar el hash de sha 256, sin representar ningún riesgo de seguridad pues al final se guarda lo que genera bcrypt.

Gestión de enlaces

Administrador de enlaces – Software open source para acortar y redireccionar enlaces

En los enlaces podemos buscar ya sea por título o por el enlace al que se redirecciona. También podemos ver las estadísticas por enlace, abrirlo en una nueva pestaña, copiar el enlace acortado, editarlo o eliminarlo.

Por cierto, para eliminar se muestra una alerta con Sweet Alert 2.

Código para administración de enlaces

Como lo dije, este sistema escrito en PHP tiene como principal objetivo generar un hash de un enlace y usarlo más tarde. Básicamente genera un enlace ya acortado que puedes compartir, y cuando el usuario hace clic en él, se redirecciona y se va registra el clic.

Veamos primero la clase que se encarga de todo ello:

<?php

namespace Parzibyte;

class LinkController
{
    static $HASH_LENGTH = 6;

    static function getOneByHash($hash)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT id, hash, title, real_link, instant_redirect FROM links WHERE hash = ?");
        $statement->execute([$hash]);
        return $statement->fetchObject();
    }
    static function delete($id)
    {
        $db = Database::get();
        $statement = $db->prepare("DELETE FROM links WHERE id = ?");
        return $statement->execute([$id]);
    }

    static function update($id, $title, $realLink, $instantRedirect)
    {
        $db = Database::get();
        $statement = $db->prepare("UPDATE links SET title = ?, real_link = ?, instant_redirect = ? WHERE id = ?");
        return $statement->execute([$title, $realLink, $instantRedirect, $id]);
    }

    static function getOne($id)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT id, hash, title, real_link, instant_redirect FROM links WHERE id = ?");
        $statement->execute([$id]);
        return $statement->fetchObject();
    }

    static function getAll()
    {
        $db = Database::get();
        $statement = $db->query("SELECT id, hash, title, real_link, instant_redirect FROM links");
        return $statement->fetchAll();
    }

    static function search($search)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT id, hash, title, real_link, instant_redirect FROM links WHERE title LIKE ? OR real_link LIKE ?");
        $statement->execute(["%$search%", "%$search%"]);
        return $statement->fetchAll();
    }
    static function add($title, $realLink, $instantRedirect)
    {
        $hash = self::getUniqueHash();
        $db = Database::get();
        $statement = $db->prepare("INSERT INTO links(hash, title, real_link, instant_redirect) VALUES (?, ?, ?, ?)");
        return $statement->execute([$hash, $title, $realLink, $instantRedirect]);
    }

    static function getUniqueHash()
    {
        do {
            $hash = self::getRandomString(self::$HASH_LENGTH);
        } while (self::hashExists($hash));
        return $hash;
    }

    static function hashExists($hash)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT id FROM links WHERE hash = ? LIMIT 1");
        $statement->execute([$hash]);
        return $statement->fetchObject() != null;
    }

    static function getRandomString($length)
    {
        $source = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
        return substr(str_shuffle($source), 0, $length);
    }
}

Se cuentan con varios métodos para agregar un enlace, actualizarlo, obtenerlos, etcétera. Algo interesante es la manera en la que se genera el hash, veamos la función getRandomString que regresa una cadena aleatoria conformada de números y letras minúsculas o mayúsculas.

También es importante la función getUniqueHash que obtiene un hash único haciendo un ciclo do while que verifica que el hash no exista en la base de datos, usando también a su vez la función hashExists que devuelve un booleano indicando si determinado hash ya existe.

La longitud del hash es de 6, si lo cambias, asegúrate de cambiar la expresión regular del RewriteRule en el archivo .htaccess.

Por cierto, para que los enlaces sean lo más cortos posibles he usando el archivo de configuración de Apache para redireccionar a link.php pasando el hash en caso de que se cumpla con la expresión regular:

RewriteEngine On
RewriteRule ^([a-z0-9A-Z]{6})$ link.php?hash=$1

Si no entiendes mucho de lo que hablo, no te preocupes, ya hice un post explicando RewriteRule justamente para este ejemplo.

Lado del cliente

Por cierto, en el lado del cliente he usado Vue.js para gestionar todos los enlaces. El código queda así:

<?php

include_once "session_check.php";
include_once "header.php";
include_once "nav.php";
include_once "vendor/autoload.php";
?>
<div class="row" id="app">
    <div class="col-12">
        <h1>Link management</h1>
        <a href="add_link.php" class="btn btn-success mb-2"><i class="fa fa-plus"></i>&nbsp;Add link</a>
        <input v-model="search" @keyup="getLinks()" placeholder="Search link by title or link" type="text" class="form-control">
        <div class="table-responsive">
            <table class="table">
                <thead>
                    <tr>
                        <th>Title</th>
                        <th>Real link</th>
                        <th>Instant redirect</th>
                        <th>Operations</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="link in links">
                        <td>{{link.title}}</td>
                        <td>{{link.real_link}}</td>
                        <td>
                            <i v-if="link.instant_redirect" class="fa fa-check"></i>
                            <i v-else class="fa fa-times"></i>
                        </td>
                        <td>
                            <button title="Link statistics" @click="statistics(link)" class="btn btn-info btn-sm">
                                <i class="fa fa-chart-bar"></i>
                            </button>
                            <button title="Open in external tab" @click="open(link)" class="btn btn-primary btn-sm">
                                <i class="fa fa-external-link-alt"></i>
                            </button>
                            <button title="Copy" @click="copy(link)" class="btn btn-success btn-sm">
                                <i class="fa fa-clipboard"></i>
                            </button>
                            <button title="Edit" @click="edit(link)" class="btn btn-warning btn-sm">
                                <i class="fa fa-edit"></i>
                            </button>
                            <button title="Delete" @click="deleteLink(link)" class="btn btn-danger btn-sm">
                                <i class="fa fa-trash"></i>
                            </button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</div>
<script src="js/vue.min.js"></script>
<script src="js/vue-toasted.min.js"></script>
<script src="js/sweetalert2.min.js"></script>
<script>
    Vue.use(Toasted);
    new Vue({
        el: "#app",
        data: () => ({
            links: [],
            search: "",
        }),
        mounted() {
            this.getLinks();
        },
        methods: {
            statistics(link) {
                window.location.href = "./link_statistics.php?id=" + link.id;
            },
            open(link) {
                window.open(this.getLinkForSharing(link));
            },
            edit(link) {
                window.location.href = "./edit_link.php?id=" + link.id;
            },
            async deleteLink(link) {
                const result = await Swal.fire({
                    title: 'Delete',
                    text: "Are you sure you want to delete this link?",
                    icon: 'question',
                    showCancelButton: true,
                    confirmButtonColor: '#e51c23',
                    cancelButtonColor: '#4A42F3',
                    cancelButtonText: 'No',
                    confirmButtonText: 'Yes, delete it'
                });
                if (result.value) {
                    window.location.href = "./delete_link.php?id=" + link.id;
                }
            },
            async copy(link) {
                const fullUrl = this.getLinkForSharing(link);
                if (!navigator.clipboard) {
                    prompt("Please press CTRL + C", fullUrl);
                } else {
                    await navigator.clipboard.writeText(fullUrl);
                }
                this.$toasted.show("Copied", {
                    position: "top-right",
                    duration: 1000,
                });
            },
            getLinkForSharing(link) {
                const url = new URL(window.location);
                let path = url.pathname.split("/");
                path.pop(); // remove the last
                url.pathname = path.join("/");
                return url.href + `/${link.hash}`;
            },
            async getLinks() {
                const r = await fetch(`./get_links_ajax.php?search=${this.search}`);
                const links = await r.json();
                this.links = links;
            }
        }
    });
</script>
<?php include_once "footer.php"; ?>

Redireccionar

La parte más importante del sistema es la de redireccionar. Primero lo hice simple del lado del servidor, pero varios bots que generan una previsualización (como el de Telegram, Twitter, Facebook) marcaban un clic, cosa que es errónea.

Así que mejor lo hice del lado del cliente. Yo sé que el scrapper de Google incorpora JavaScript y espera a que la página esté renderizada, pero en este caso una simple previsualización no desencadena el registro del clic.

<?php

use Parzibyte\LinkController;

include_once "vendor/autoload.php";

if (!isset($_GET["hash"])) {
    exit("id is not present in URL");
}
$hash = $_GET["hash"];
$link = LinkController::getOneByHash($hash);
if (!$link) {
    exit("Link does not exist");
}
if ($link->instant_redirect) {
?>
    <script>
        (async () => {
            await fetch("./track_link.php", {
                method: "POST",
                body: JSON.stringify("<?php echo $link->hash ?>"),
            });
            window.location.href = "<?php echo $link->real_link ?>";
        })();
    </script>
<?php
    exit;
} else {
    include_once "redirect_template.php";
}

En caso de que el redireccionamiento sea instantáneo, simplemente se registra la visita en el archivo track_link.php usando fetch, y una vez que se ha hecho, se redirecciona cambiando window.location.href.

En caso de que no, entonces se incluye la plantilla de redireccionamiento en donde es responsabilidad del programador implementar el trackeo del clic y el redireccionamiento. En mi caso se ve así:

<!DOCTYPE html>
<html lang="es">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><?php echo $link->title ?></title>
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/all.min.css">
</head>

<body>
    <main class="container-fluid">
        <div class="row">
            <div class="col-12 text-center">
                <h2>Redireccionando a <?php echo $link->title ?>...</h2>
                <div class="alert alert-primary">
                    <h4>Para continuar, sígueme en una de mis redes sociales: (el enlace se muestra al hacer clic)</h4>
                </div>
                <a href="https://www.youtube.com/channel/UCroP4BTWjfM0CkGB6AFUoBg?sub_confirmation=1" target="_blank" class="seguir btn btn-danger mb-2">Youtube&nbsp;<i class="fab fa-youtube"></i></a>
                <a href="https://www.facebook.com/parzibyte.fanpage/" target="_blank" class="seguir btn btn-primary mb-2">Facebook&nbsp;<i class="fab fa-facebook"></i></a>
                <br>
                <a href="https://twitter.com/parzibyte" target="_blank" class="seguir btn btn-info">Twitter&nbsp;<i class="fab fa-twitter"></i></a>
                <a href="https://www.instagram.com/parzibyte/" target="_blank" class="seguir btn btn-warning">Instagram&nbsp;<i class="fab fa-instagram"></i></a>
            </div>
            <div class="col-12 mt-2 text-center" id="link"> </div>
        </div>
        <script>
            let mostrado = false;
            document.addEventListener("DOMContentLoaded", () => {
                const $link = document.querySelector("#link");
                const $buttons = document.querySelectorAll(".seguir");
                $buttons.forEach($button => {
                    $button.addEventListener("click", () => {
                        if (mostrado) return;
                        mostrado = true;
                        $link.innerHTML = `<strong class="h5">¡Gracias por seguirme, me motivas a seguir con mi trabajo!</strong><br>Redireccionando...`;
                        setTimeout(async () => {
                            (async () => {
                                await fetch("./track_link.php", {
                                    method: "POST",
                                    body: JSON.stringify("<?php echo $link->hash ?>"),
                                });
                                window.location.href = "<?php echo $link->real_link ?>";
                            })();
                        }, 1000)
                    })
                });
            });
        </script>

        <?php include_once "footer.php"; ?>

Puedes usar la plantilla que te presento o usar tus propios métodos. También puedes marcar siempre el link como instantáneo, de este modo no tienes que modificar la plantilla.

Estadísticas generales

En las estadísticas generales vemos los clics generados por fecha, en una gráfica de línea. Además, podemos cambiar el rango de las fechas para ver la gráfica a lo largo de ese período.

Por defecto se muestra la fecha de inicio y fin del mes actual, pero puede cambiarse manualmente. También vemos los enlaces con más clics en el período actual, y enlaces con más clics en todo el tiempo.

Estadísticas de enlaces para software acortador en PHP y MySQL

Ahora veamos el código que hace posible esto. Primero desde el lado del servidor, mismo que se encarga de registrar la visita o clic, así como de regresar las estadísticas:

<?php

namespace Parzibyte;

class StatisticsController
{

    static function getClickCountByDateAndLink($linkId, $start, $end)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT date, count(*) as clicks FROM statistics
        WHERE date >= ? AND date <= ?
        AND link_id = ?
        GROUP BY date");
        $statement->execute([$start, $end, $linkId]);
        return $statement->fetchAll();
    }

    static function getMostClickedLinksOfAllTime()
    {
        $db = Database::get();
        $statement = $db->query("SELECT links.title, count(*) AS clicks
        FROM statistics INNER JOIN links ON links.id = link_id
        GROUP BY link_id, title 
        ORDER BY clicks DESC LIMIT 10");
        return $statement->fetchAll();
    }

    static function getMostClickedLinksByDate($start, $end)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT links.title, count(*) AS clicks
        FROM statistics INNER JOIN links ON links.id = link_id
        WHERE date >= ? AND date <= ?
        GROUP BY link_id, title 
        ORDER BY clicks DESC LIMIT 10");
        $statement->execute([$start, $end]);
        return $statement->fetchAll();
    }

    static function getClickCountByDate($start, $end)
    {
        $db = Database::get();
        $statement = $db->prepare("SELECT date, count(*) as clicks FROM statistics
        WHERE date >= ? AND date <= ?
        GROUP BY date");
        $statement->execute([$start, $end]);
        return $statement->fetchAll();
    }

    static function registerClick($linkId)
    {
        $db = Database::get();
        $statement = $db->prepare("INSERT INTO statistics(link_id, date) VALUES (?, ?)");
        $statement->execute([$linkId, date("Y-m-d")]);
    }
}

Básicamente toda la magia la hacen las consultas SQL, los WHERE, GROUP BY, etcétera. Y ya del lado del cliente usamos Vue.js así como chart.js para la gráfica, combinando ambas tecnologías para refrescar tan pronto se seleccione otra fecha, usando AJAX:

<?php
include_once "session_check.php";
include_once "header.php";
include_once "nav.php";
?>
<div class="row" id="app">
    <div class="col-12">
        <h1>Statistics</h1>
    </div>
    <div class="col-12">
        <div class="form-inline">
            <label for="start">Start:</label>&nbsp;<input @change="onRangeChange()" v-model="start" type="date" id="start" class="form-control mr-2">
            <label for="end">End:</label>&nbsp;<input @change="onRangeChange()" v-model="end" type="date" id="end" class="form-control mr-2">
        </div>
    </div>
    <div class="col-12">
        <canvas id="chart" width="400" height="100"></canvas>
    </div>
    <div class="col-12 col-md-6">
        <h2 class="text-center">Most clicked links in range</h2>
        <ul class="list-group">
            <li v-for="link in mostClickedLinksInRange" class="list-group-item d-flex justify-content-between align-items-center">
                {{link.title}}
                <h3><span class="badge badge-primary badge-pill">{{link.clicks}}</span></h3>
            </li>
        </ul>
    </div>
    <div class="col-12 col-md-6">
        <h2 class="text-center">Most clicked links of all time</h2>
        <ul class="list-group">
            <li v-for="link in mostClickedLinksOfAllTime" class="list-group-item d-flex justify-content-between align-items-center">
                {{link.title}}
                <h3><span class="badge badge-primary badge-pill">{{link.clicks}}</span></h3>
            </li>
        </ul>
    </div>
</div>
<script src="js/vue.min.js"></script>
<script src="js/Chart.bundle.min.js"></script>
<script>
    new Vue({
        el: "#app",
        data: () => ({
            start: "",
            end: "",
            chart: null,
            mostClickedLinksInRange: [],
            mostClickedLinksOfAllTime: [],
        }),
        mounted() {
            this.start = this.getStartMonthDate();
            this.end = this.getEndMonthDate();
            this.onRangeChange();
        },
        methods: {
            async onRangeChange() {
                await this.getStatisticsBySelectedRange();
                await this.getMostClickedLinksInRange();
                await this.getMostClickedLinksOfAllTime();
            },
            async getMostClickedLinksInRange() {
                const r = await fetch(`./get_most_clicked_links_by_date.php?start=${this.start}&end=${this.end}`);
                this.mostClickedLinksInRange = await r.json();
            },
            async getMostClickedLinksOfAllTime() {
                const r = await fetch(`./get_most_clicked_links_of_all_time.php`);
                this.mostClickedLinksOfAllTime = await r.json();
            },
            async getStatisticsBySelectedRange() {
                const r = await fetch(`./get_statistics_by_date.php?start=${this.start}&end=${this.end}`);
                const statisticsData = await r.json();
                const labels = statisticsData.map(dateAndClicks => dateAndClicks.date);
                const data = statisticsData.map(dateAndClicks => dateAndClicks.clicks);
                this.refreshChart(labels, data);
            },
            refreshChart(labels, data) {
                if (this.chart) {
                    this.chart.destroy();
                }
                this.chart = new Chart(document.querySelector("#chart"), {
                    type: 'line',
                    data: {
                        labels: labels,
                        datasets: [{
                            label: 'Clicks',
                            data: data,
                            backgroundColor: [
                                'rgba(54, 162, 235, 0.2)',
                                'rgba(255, 206, 86, 0.2)',
                                'rgba(75, 192, 192, 0.2)',
                                'rgba(153, 102, 255, 0.2)',
                                'rgba(255, 159, 64, 0.2)'
                            ],
                            borderColor: [
                                'rgba(54, 162, 235, 1)',
                                'rgba(255, 206, 86, 1)',
                                'rgba(75, 192, 192, 1)',
                                'rgba(153, 102, 255, 1)',
                                'rgba(255, 159, 64, 1)'
                            ],
                            borderWidth: 1,
                        }]
                    },
                    options: {
                        scales: {
                            yAxes: [{
                                ticks: {
                                    beginAtZero: true
                                }
                            }],
                        },
                    }
                });
            },
            getStartMonthDate() {
                const d = new Date();
                return this.formatDate(new Date(d.getFullYear(), d.getMonth(), 1));
            },
            getEndMonthDate() {
                const d = new Date();
                return this.formatDate(new Date(d.getFullYear(), d.getMonth() + 1, 0));
            },
            formatDate(date) {
                const month = date.getMonth() + 1;
                const day = date.getDate();
                return `${date.getFullYear()}-${(month < 10 ? '0' : '').concat(month)}-${(day < 10 ? '0' : '').concat(day)}`;
            }

        }
    });
</script>
<?php
include_once "footer.php"; ?>

Presta atención a la función getStatisticsBySelectedRange que consulta las estadísticas y luego usa map para generar tanto los labels como la data que más tarde pasa a refreshChart para refrescar la gráfica.

También existen las estadísticas por enlace, pero eso ya no lo explicaré aquí porque el post se haría muy largo y el funcionamiento es similar al de las estadísticas generales.

Instalación del sistema

Primero se debe clonar o descargar el código, mismo que está en un repositorio desde este enlace. Aunque el sistema no tiene dependencias externas, es necesario generar el autoload y vendor usando composer:

composer install

Más tarde se debe proceder a crear el usuario y base de datos; darle permisos, etcétera. Luego importar schema.sql, ya sea desde phpmyadmin o con:

mysql -u usuario -p nombre_base_de_datos < schema.sql

Finalmente hay que crear el archivo env.php basado en el archivo env.example.php y configurar las credenciales de acceso a la base de datos.

Poniendo todo junto

No he explicado todo el código, pero sí lo más básico. Recuerda que eres libre de descargarlo y explorarlo en GitHub.

Básicamente estoy usando el redireccionador para redireccionar al código fuente de sí mismo. Aquí dejo un vídeo de YT por si algo no te quedó claro:

Te invito a leer más sobre PHP o a ver más software open source creado por mí.

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.
parzibyte

Programador freelancer listo para trabajar contigo. Aplicaciones web, móviles y de escritorio. PHP, Java, Go, Python, JavaScript, Kotlin y más :) https://parzibyte.me/blog/software-creado-por-parzibyte/

Ver comentarios

Entradas recientes

Desplegar PWA creada con Vue 3, Vite y SQLite3 en Apache

Ya te enseñé cómo convertir una aplicación web de Vue 3 en una PWA. Al…

3 días hace

Arquitectura para wasm con Go, Vue 3, Pinia y Vite

En este artículo voy a documentar la arquitectura que yo utilizo al trabajar con WebAssembly…

3 días hace

Vue 3 y Vite: crear PWA (Progressive Web App)

En un artículo anterior te enseñé a crear un PWA. Al final, cualquier aplicación que…

3 días hace

Errores de Comlink y algunas soluciones

Al usar Comlink para trabajar con los workers usando JavaScript me han aparecido algunos errores…

3 días hace

Esperar promesa para inicializar Store de Pinia con Vue 3

En este artículo te voy a enseñar cómo usar un "top level await" esperando a…

3 días hace

Solución: Apache – Server unable to read htaccess file

Ayer estaba editando unos archivos que son servidos con el servidor Apache y al visitarlos…

4 días hace

Esta web usa cookies.