279 lines
9.2 KiB
PHP
279 lines
9.2 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
|
|
const VISIT_COUNTER_LIVE_WINDOW_MINUTES = 5;
|
|
const VISIT_COUNTER_RETENTION_MONTHS = 13;
|
|
const VISIT_COUNTER_TOTAL_KEY = 'lifetime_total';
|
|
|
|
function visit_counter_timezone(): DateTimeZone
|
|
{
|
|
static $timezone = null;
|
|
if ($timezone instanceof DateTimeZone) {
|
|
return $timezone;
|
|
}
|
|
|
|
$timezone = new DateTimeZone('Europe/Paris');
|
|
return $timezone;
|
|
}
|
|
|
|
function visit_counter_now(): DateTimeImmutable
|
|
{
|
|
return new DateTimeImmutable('now', visit_counter_timezone());
|
|
}
|
|
|
|
function visit_counter_normalize_token(mixed $token): ?string
|
|
{
|
|
if (!is_string($token)) {
|
|
return null;
|
|
}
|
|
|
|
$normalized = strtolower(trim($token));
|
|
if ($normalized === '') {
|
|
return null;
|
|
}
|
|
|
|
return preg_match('/\A[a-f0-9]{48}\z/', $normalized) === 1 ? $normalized : null;
|
|
}
|
|
|
|
function visit_counter_generate_token(): string
|
|
{
|
|
return bin2hex(random_bytes(24));
|
|
}
|
|
|
|
function visit_counter_ensure_schema(): void
|
|
{
|
|
static $initialized = false;
|
|
if ($initialized) {
|
|
return;
|
|
}
|
|
|
|
$pdo = db();
|
|
$pdo->exec(
|
|
'CREATE TABLE IF NOT EXISTS visit_counter_sessions ('
|
|
. ' visit_token CHAR(48) NOT NULL,'
|
|
. ' first_seen_at DATETIME NOT NULL,'
|
|
. ' last_seen_at DATETIME NOT NULL,'
|
|
. ' created_at DATETIME NOT NULL,'
|
|
. ' updated_at DATETIME NOT NULL,'
|
|
. ' PRIMARY KEY (visit_token),'
|
|
. ' KEY idx_last_seen_at (last_seen_at)'
|
|
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
|
);
|
|
|
|
$pdo->exec(
|
|
'CREATE TABLE IF NOT EXISTS visit_counter_daily ('
|
|
. ' visit_token CHAR(48) NOT NULL,'
|
|
. ' visit_date DATE NOT NULL,'
|
|
. ' created_at DATETIME NOT NULL,'
|
|
. ' PRIMARY KEY (visit_token, visit_date),'
|
|
. ' KEY idx_visit_date (visit_date)'
|
|
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
|
);
|
|
|
|
$pdo->exec(
|
|
'CREATE TABLE IF NOT EXISTS visit_counter_meta ('
|
|
. ' meta_key VARCHAR(64) NOT NULL,'
|
|
. ' meta_value BIGINT UNSIGNED NOT NULL DEFAULT 0,'
|
|
. ' updated_at DATETIME NOT NULL,'
|
|
. ' PRIMARY KEY (meta_key)'
|
|
. ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
|
);
|
|
|
|
$initialized = true;
|
|
}
|
|
|
|
function visit_counter_prune_stale_data(?DateTimeImmutable $now = null): void
|
|
{
|
|
static $pruned = false;
|
|
if ($pruned) {
|
|
return;
|
|
}
|
|
|
|
visit_counter_ensure_schema();
|
|
$pdo = db();
|
|
$now = $now ?? visit_counter_now();
|
|
|
|
$sessionCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d H:i:s');
|
|
$dailyCutoff = $now->modify('-' . VISIT_COUNTER_RETENTION_MONTHS . ' months')->format('Y-m-d');
|
|
|
|
$deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_date < :cutoff');
|
|
$deleteDaily->bindValue(':cutoff', $dailyCutoff);
|
|
$deleteDaily->execute();
|
|
|
|
$deleteSessions = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE last_seen_at < :cutoff');
|
|
$deleteSessions->bindValue(':cutoff', $sessionCutoff);
|
|
$deleteSessions->execute();
|
|
|
|
$pruned = true;
|
|
}
|
|
|
|
function visit_counter_snapshot(?DateTimeImmutable $now = null): array
|
|
{
|
|
visit_counter_ensure_schema();
|
|
visit_counter_prune_stale_data($now);
|
|
|
|
$pdo = db();
|
|
$now = $now ?? visit_counter_now();
|
|
$threshold = $now->modify('-' . VISIT_COUNTER_LIVE_WINDOW_MINUTES . ' minutes')->format('Y-m-d H:i:s');
|
|
$today = $now->format('Y-m-d');
|
|
|
|
$liveStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_sessions WHERE last_seen_at >= :threshold');
|
|
$liveStmt->bindValue(':threshold', $threshold);
|
|
$liveStmt->execute();
|
|
$live = (int) $liveStmt->fetchColumn();
|
|
|
|
$dailyStmt = $pdo->prepare('SELECT COUNT(*) FROM visit_counter_daily WHERE visit_date = :visit_date');
|
|
$dailyStmt->bindValue(':visit_date', $today);
|
|
$dailyStmt->execute();
|
|
$daily = (int) $dailyStmt->fetchColumn();
|
|
|
|
$totalStmt = $pdo->prepare('SELECT meta_value FROM visit_counter_meta WHERE meta_key = :meta_key');
|
|
$totalStmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY);
|
|
$totalStmt->execute();
|
|
$totalValue = $totalStmt->fetchColumn();
|
|
$total = $totalValue === false ? 0 : (int) $totalValue;
|
|
|
|
if ($total === 0) {
|
|
$fallbackStmt = $pdo->query('SELECT COUNT(*) FROM visit_counter_sessions');
|
|
$total = (int) $fallbackStmt->fetchColumn();
|
|
}
|
|
|
|
return [
|
|
'live' => $live,
|
|
'daily' => $daily,
|
|
'total' => $total,
|
|
'live_window_minutes' => VISIT_COUNTER_LIVE_WINDOW_MINUTES,
|
|
'updated_at' => $now->format(DATE_ATOM),
|
|
'updated_label' => $now->format('H:i:s'),
|
|
];
|
|
}
|
|
|
|
function visit_counter_increment_total(string $timestamp): void
|
|
{
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) '
|
|
. 'ON DUPLICATE KEY UPDATE meta_value = meta_value + 1, updated_at = :updated_at_refresh'
|
|
);
|
|
$stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY);
|
|
$stmt->bindValue(':meta_value', 1, PDO::PARAM_INT);
|
|
$stmt->bindValue(':updated_at', $timestamp);
|
|
$stmt->bindValue(':updated_at_refresh', $timestamp);
|
|
$stmt->execute();
|
|
}
|
|
|
|
function visit_counter_decrement_total(string $timestamp): void
|
|
{
|
|
$stmt = db()->prepare(
|
|
'INSERT INTO visit_counter_meta (meta_key, meta_value, updated_at) VALUES (:meta_key, :meta_value, :updated_at) '
|
|
. 'ON DUPLICATE KEY UPDATE meta_value = CASE WHEN meta_value > 0 THEN meta_value - 1 ELSE 0 END, updated_at = :updated_at_refresh'
|
|
);
|
|
$stmt->bindValue(':meta_key', VISIT_COUNTER_TOTAL_KEY);
|
|
$stmt->bindValue(':meta_value', 0, PDO::PARAM_INT);
|
|
$stmt->bindValue(':updated_at', $timestamp);
|
|
$stmt->bindValue(':updated_at_refresh', $timestamp);
|
|
$stmt->execute();
|
|
}
|
|
|
|
function visit_counter_track(?string $token = null): array
|
|
{
|
|
visit_counter_ensure_schema();
|
|
$pdo = db();
|
|
$now = visit_counter_now();
|
|
visit_counter_prune_stale_data($now);
|
|
|
|
$visitToken = visit_counter_normalize_token($token) ?? visit_counter_generate_token();
|
|
$timestamp = $now->format('Y-m-d H:i:s');
|
|
$visitDate = $now->format('Y-m-d');
|
|
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$insertSession = $pdo->prepare(
|
|
'INSERT IGNORE INTO visit_counter_sessions (visit_token, first_seen_at, last_seen_at, created_at, updated_at) '
|
|
. 'VALUES (:visit_token, :first_seen_at, :last_seen_at, :created_at, :updated_at)'
|
|
);
|
|
$insertSession->bindValue(':visit_token', $visitToken);
|
|
$insertSession->bindValue(':first_seen_at', $timestamp);
|
|
$insertSession->bindValue(':last_seen_at', $timestamp);
|
|
$insertSession->bindValue(':created_at', $timestamp);
|
|
$insertSession->bindValue(':updated_at', $timestamp);
|
|
$insertSession->execute();
|
|
|
|
$isNewVisitor = $insertSession->rowCount() === 1;
|
|
|
|
if (!$isNewVisitor) {
|
|
$updateSession = $pdo->prepare(
|
|
'UPDATE visit_counter_sessions SET last_seen_at = :last_seen_at, updated_at = :updated_at WHERE visit_token = :visit_token'
|
|
);
|
|
$updateSession->bindValue(':last_seen_at', $timestamp);
|
|
$updateSession->bindValue(':updated_at', $timestamp);
|
|
$updateSession->bindValue(':visit_token', $visitToken);
|
|
$updateSession->execute();
|
|
}
|
|
|
|
$insertDaily = $pdo->prepare(
|
|
'INSERT IGNORE INTO visit_counter_daily (visit_token, visit_date, created_at) VALUES (:visit_token, :visit_date, :created_at)'
|
|
);
|
|
$insertDaily->bindValue(':visit_token', $visitToken);
|
|
$insertDaily->bindValue(':visit_date', $visitDate);
|
|
$insertDaily->bindValue(':created_at', $timestamp);
|
|
$insertDaily->execute();
|
|
|
|
if ($isNewVisitor) {
|
|
visit_counter_increment_total($timestamp);
|
|
}
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $exception) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $exception;
|
|
}
|
|
|
|
return [
|
|
'token' => $visitToken,
|
|
'counts' => visit_counter_snapshot($now),
|
|
];
|
|
}
|
|
|
|
function visit_counter_forget(?string $token = null): array
|
|
{
|
|
visit_counter_ensure_schema();
|
|
$pdo = db();
|
|
$now = visit_counter_now();
|
|
visit_counter_prune_stale_data($now);
|
|
|
|
$visitToken = visit_counter_normalize_token($token);
|
|
if ($visitToken === null) {
|
|
return visit_counter_snapshot($now);
|
|
}
|
|
|
|
$timestamp = $now->format('Y-m-d H:i:s');
|
|
|
|
$pdo->beginTransaction();
|
|
try {
|
|
$deleteDaily = $pdo->prepare('DELETE FROM visit_counter_daily WHERE visit_token = :visit_token');
|
|
$deleteDaily->bindValue(':visit_token', $visitToken);
|
|
$deleteDaily->execute();
|
|
|
|
$deleteSession = $pdo->prepare('DELETE FROM visit_counter_sessions WHERE visit_token = :visit_token');
|
|
$deleteSession->bindValue(':visit_token', $visitToken);
|
|
$deleteSession->execute();
|
|
|
|
if ($deleteSession->rowCount() > 0) {
|
|
visit_counter_decrement_total($timestamp);
|
|
}
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $exception) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
throw $exception;
|
|
}
|
|
|
|
return visit_counter_snapshot($now);
|
|
}
|