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:
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.
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:
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).
Para acceder a todas las funciones del sistema se usa la sesión y un login simple. El login se ve así:
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.
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.
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.
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.
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> 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"; ?>
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 <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 <i class="fab fa-facebook"></i></a>
<br>
<a href="https://twitter.com/parzibyte" target="_blank" class="seguir btn btn-info">Twitter <i class="fab fa-twitter"></i></a>
<a href="https://www.instagram.com/parzibyte/" target="_blank" class="seguir btn btn-warning">Instagram <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.
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.
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> <input @change="onRangeChange()" v-model="start" type="date" id="start" class="form-control mr-2">
<label for="end">End:</label> <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.
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.
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í.
Ya te enseñé cómo convertir una aplicación web de Vue 3 en una PWA. Al…
En este artículo voy a documentar la arquitectura que yo utilizo al trabajar con WebAssembly…
En un artículo anterior te enseñé a crear un PWA. Al final, cualquier aplicación que…
Al usar Comlink para trabajar con los workers usando JavaScript me han aparecido algunos errores…
En este artículo te voy a enseñar cómo usar un "top level await" esperando a…
Ayer estaba editando unos archivos que son servidos con el servidor Apache y al visitarlos…
Esta web usa cookies.
Ver comentarios
Exelente.. Justo lo que buscaba..
Hola, primero gracias por todo tu trabajo.
Queria saber si se puede utilizar sin instalar con el composer, en un servidor plesk
No respondo dudas en comentarios. Si tiene preguntas puede contactarme en https://parzibyte.me/#contacto