chore(*.php): use pint for php formatting

This commit is contained in:
Cory Dransfeldt 2025-06-14 16:55:03 -07:00
parent 029caaaa9e
commit cf1ee4c97f
No known key found for this signature in database
40 changed files with 2261 additions and 1900 deletions

View file

@ -13,3 +13,6 @@ vendor/
# env
.env
# php
*.php

View file

@ -1,187 +1,207 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class ArtistImportHandler extends ApiHandler
{
private string $artistImportToken;
private string $placeholderImageId = "4cef75db-831f-4f5d-9333-79eaa5bb55ee";
private string $navidromeApiUrl;
private string $navidromeAuthToken;
private string $artistImportToken;
public function __construct()
{
parent::__construct();
private string $placeholderImageId = '4cef75db-831f-4f5d-9333-79eaa5bb55ee';
$this->ensureCliAccess();
$this->artistImportToken = getenv("ARTIST_IMPORT_TOKEN");
$this->navidromeApiUrl = getenv("NAVIDROME_API_URL");
$this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN");
}
private string $navidromeApiUrl;
public function handleRequest(): void
{
$input = json_decode(file_get_contents("php://input"), true);
private string $navidromeAuthToken;
if (!$input) $this->sendJsonResponse("error", "Invalid or missing JSON body", 400);
public function __construct()
{
parent::__construct();
$providedToken = $input["token"] ?? null;
$artistId = $input["artistId"] ?? null;
if ($providedToken !== $this->artistImportToken) $this->sendJsonResponse("error", "Unauthorized access", 401);
if (!$artistId) $this->sendJsonResponse("error", "Artist ID is required", 400);
try {
$artistData = $this->fetchNavidromeArtist($artistId);
$albumData = $this->fetchNavidromeAlbums($artistId);
$genre = $albumData[0]["genre"] ?? ($albumData[0]["genres"][0]["name"] ?? "");
$artistExists = $this->processArtist($artistData, $genre);
if ($artistExists) $this->processAlbums($artistId, $artistData->name, $albumData);
$this->sendJsonResponse("message", "Artist and albums synced successfully", 200);
} catch (\Exception $e) {
$this->sendJsonResponse("error", "Error: " . $e->getMessage(), 500);
$this->ensureCliAccess();
$this->artistImportToken = getenv('ARTIST_IMPORT_TOKEN');
$this->navidromeApiUrl = getenv('NAVIDROME_API_URL');
$this->navidromeAuthToken = getenv('NAVIDROME_API_TOKEN');
}
}
private function sendJsonResponse(string $key, string $message, int $statusCode): void
{
http_response_code($statusCode);
header("Content-Type: application/json");
public function handleRequest(): void
{
$input = json_decode(file_get_contents('php://input'), true);
echo json_encode([$key => $message]);
if (! $input) {
$this->sendJsonResponse('error', 'Invalid or missing JSON body', 400);
}
exit();
}
$providedToken = $input['token'] ?? null;
$artistId = $input['artistId'] ?? null;
private function fetchNavidromeArtist(string $artistId): object
{
$client = new Client();
$response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [
"headers" => [
"x-nd-authorization" => "Bearer {$this->navidromeAuthToken}",
"Accept" => "application/json"
]
]);
if ($providedToken !== $this->artistImportToken) {
$this->sendJsonResponse('error', 'Unauthorized access', 401);
}
if (! $artistId) {
$this->sendJsonResponse('error', 'Artist ID is required', 400);
}
return json_decode($response->getBody());
}
try {
$artistData = $this->fetchNavidromeArtist($artistId);
$albumData = $this->fetchNavidromeAlbums($artistId);
$genre = $albumData[0]['genre'] ?? ($albumData[0]['genres'][0]['name'] ?? '');
$artistExists = $this->processArtist($artistData, $genre);
private function fetchNavidromeAlbums(string $artistId): array
{
$client = new Client();
$response = $client->get("{$this->navidromeApiUrl}/api/album", [
"query" => [
"_end" => 0,
"_order" => "ASC",
"_sort" => "max_year",
"_start" => 0,
"artist_id" => $artistId
],
"headers" => [
"x-nd-authorization" => "Bearer {$this->navidromeAuthToken}",
"Accept" => "application/json"
]
]);
if ($artistExists) {
$this->processAlbums($artistId, $artistData->name, $albumData);
}
return json_decode($response->getBody(), true);
}
$this->sendJsonResponse('message', 'Artist and albums synced successfully', 200);
} catch (\Exception $e) {
$this->sendJsonResponse('error', 'Error: '.$e->getMessage(), 500);
}
}
private function processArtist(object $artistData, string $genreName = ""): bool
{
$artistName = $artistData->name ?? "";
private function sendJsonResponse(string $key, string $message, int $statusCode): void
{
http_response_code($statusCode);
header('Content-Type: application/json');
if (!$artistName) throw new \Exception("Artist name is missing.");
echo json_encode([$key => $message]);
$existingArtist = $this->getArtistByName($artistName);
exit();
}
if ($existingArtist) return true;
private function fetchNavidromeArtist(string $artistId): object
{
$client = new Client();
$response = $client->get("{$this->navidromeApiUrl}/api/artist/{$artistId}", [
'headers' => [
'x-nd-authorization' => "Bearer {$this->navidromeAuthToken}",
'Accept' => 'application/json',
],
]);
$artistKey = sanitizeMediaString($artistName);
$slug = "/music/artists/{$artistKey}";
$description = strip_tags($artistData->biography ?? "");
$genre = $this->resolveGenreId(strtolower($genreName));
$starred = $artistData->starred ?? false;
$artistPayload = [
"name_string" => $artistName,
"slug" => $slug,
"description" => $description,
"tentative" => true,
"art" => $this->placeholderImageId,
"mbid" => "",
"favorite" => $starred,
"genres" => $genre,
];
return json_decode($response->getBody());
}
$this->makeRequest("POST", "artists", ["json" => $artistPayload]);
private function fetchNavidromeAlbums(string $artistId): array
{
$client = new Client();
$response = $client->get("{$this->navidromeApiUrl}/api/album", [
'query' => [
'_end' => 0,
'_order' => 'ASC',
'_sort' => 'max_year',
'_start' => 0,
'artist_id' => $artistId,
],
'headers' => [
'x-nd-authorization' => "Bearer {$this->navidromeAuthToken}",
'Accept' => 'application/json',
],
]);
return true;
}
return json_decode($response->getBody(), true);
}
private function processAlbums(string $artistId, string $artistName, array $albumData): void
{
$artist = $this->getArtistByName($artistName);
private function processArtist(object $artistData, string $genreName = ''): bool
{
$artistName = $artistData->name ?? '';
if (!$artist) throw new \Exception("Artist not found after insert.");
if (! $artistName) {
throw new \Exception('Artist name is missing.');
}
$existingAlbums = $this->getExistingAlbums($artist["id"]);
$existingAlbumKeys = array_column($existingAlbums, "key");
$existingArtist = $this->getArtistByName($artistName);
foreach ($albumData as $album) {
$albumName = $album["name"] ?? "";
$releaseYearRaw = $album["date"] ?? null;
$releaseYear = null;
if ($existingArtist) {
return true;
}
if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) $releaseYear = (int)$matches[0];
$artistKey = sanitizeMediaString($artistName);
$albumKey = "{$artistKey}-" . sanitizeMediaString($albumName);
if (in_array($albumKey, $existingAlbumKeys)) {
error_log("Skipping existing album: {$albumName}");
continue;
}
try {
$albumPayload = [
"name" => $albumName,
"key" => $albumKey,
"release_year" => $releaseYear,
"artist" => $artist["id"],
"artist_name" => $artistName,
"art" => $this->placeholderImageId,
"tentative" => true,
$artistKey = sanitizeMediaString($artistName);
$slug = "/music/artists/{$artistKey}";
$description = strip_tags($artistData->biography ?? '');
$genre = $this->resolveGenreId(strtolower($genreName));
$starred = $artistData->starred ?? false;
$artistPayload = [
'name_string' => $artistName,
'slug' => $slug,
'description' => $description,
'tentative' => true,
'art' => $this->placeholderImageId,
'mbid' => '',
'favorite' => $starred,
'genres' => $genre,
];
$this->makeRequest("POST", "albums", ["json" => $albumPayload]);
} catch (\Exception $e) {
error_log("Error adding album '{$albumName}': " . $e->getMessage());
}
$this->makeRequest('POST', 'artists', ['json' => $artistPayload]);
return true;
}
}
private function getArtistByName(string $nameString): ?array
{
$response = $this->fetchFromApi("artists", "name_string=eq." . urlencode($nameString));
private function processAlbums(string $artistId, string $artistName, array $albumData): void
{
$artist = $this->getArtistByName($artistName);
return $response[0] ?? null;
}
if (! $artist) {
throw new \Exception('Artist not found after insert.');
}
private function getExistingAlbums(string $artistId): array
{
return $this->fetchFromApi("albums", "artist=eq." . urlencode($artistId));
}
$existingAlbums = $this->getExistingAlbums($artist['id']);
$existingAlbumKeys = array_column($existingAlbums, 'key');
private function resolveGenreId(string $genreName): ?string
{
$genres = $this->fetchFromApi("genres", "name=eq." . urlencode(strtolower($genreName)));
foreach ($albumData as $album) {
$albumName = $album['name'] ?? '';
$releaseYearRaw = $album['date'] ?? null;
$releaseYear = null;
return $genres[0]["id"] ?? null;
}
if ($releaseYearRaw && preg_match('/^\d{4}/', $releaseYearRaw, $matches)) {
$releaseYear = (int) $matches[0];
}
$artistKey = sanitizeMediaString($artistName);
$albumKey = "{$artistKey}-".sanitizeMediaString($albumName);
if (in_array($albumKey, $existingAlbumKeys)) {
error_log("Skipping existing album: {$albumName}");
continue;
}
try {
$albumPayload = [
'name' => $albumName,
'key' => $albumKey,
'release_year' => $releaseYear,
'artist' => $artist['id'],
'artist_name' => $artistName,
'art' => $this->placeholderImageId,
'tentative' => true,
];
$this->makeRequest('POST', 'albums', ['json' => $albumPayload]);
} catch (\Exception $e) {
error_log("Error adding album '{$albumName}': ".$e->getMessage());
}
}
}
private function getArtistByName(string $nameString): ?array
{
$response = $this->fetchFromApi('artists', 'name_string=eq.'.urlencode($nameString));
return $response[0] ?? null;
}
private function getExistingAlbums(string $artistId): array
{
return $this->fetchFromApi('albums', 'artist=eq.'.urlencode($artistId));
}
private function resolveGenreId(string $genreName): ?string
{
$genres = $this->fetchFromApi('genres', 'name=eq.'.urlencode(strtolower($genreName)));
return $genres[0]['id'] ?? null;
}
}
$handler = new ArtistImportHandler();

View file

@ -1,96 +1,108 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class BookImportHandler extends ApiHandler
{
private string $bookImportToken;
private string $bookImportToken;
public function __construct()
{
parent::__construct();
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
$this->bookImportToken = $_ENV["BOOK_IMPORT_TOKEN"] ?? getenv("BOOK_IMPORT_TOKEN");
}
public function handleRequest(): void
{
$input = json_decode(file_get_contents("php://input"), true);
if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400);
$providedToken = $input["token"] ?? null;
$isbn = $input["isbn"] ?? null;
if ($providedToken !== $this->bookImportToken) $this->sendErrorResponse("Unauthorized access", 401);
if (!$isbn) $this->sendErrorResponse("isbn parameter is required", 400);
try {
$bookData = $this->fetchBookData($isbn);
$this->processBook($bookData);
$this->sendResponse(["message" => "Book imported successfully"], 200);
} catch (\Exception $e) {
$this->sendErrorResponse("Error: " . $e->getMessage(), 500);
$this->ensureCliAccess();
$this->bookImportToken = $_ENV['BOOK_IMPORT_TOKEN'] ?? getenv('BOOK_IMPORT_TOKEN');
}
}
private function fetchBookData(string $isbn): array
{
$client = new Client();
$response = $client->get("https://openlibrary.org/api/books", [
"query" => [
"bibkeys" => "ISBN:{$isbn}",
"format" => "json",
"jscmd" => "data",
],
"headers" => ["Accept" => "application/json"],
]);
public function handleRequest(): void
{
$input = json_decode(file_get_contents('php://input'), true);
$data = json_decode($response->getBody(), true);
$bookKey = "ISBN:{$isbn}";
if (! $input) {
$this->sendErrorResponse('Invalid or missing JSON body', 400);
}
if (empty($data[$bookKey])) throw new \Exception("Book data not found for ISBN: {$isbn}");
$providedToken = $input['token'] ?? null;
$isbn = $input['isbn'] ?? null;
return $data[$bookKey];
}
if ($providedToken !== $this->bookImportToken) {
$this->sendErrorResponse('Unauthorized access', 401);
}
if (! $isbn) {
$this->sendErrorResponse('isbn parameter is required', 400);
}
private function processBook(array $bookData): void
{
$isbn =
$bookData["identifiers"]["isbn_13"][0] ??
($bookData["identifiers"]["isbn_10"][0] ?? null);
$title = $bookData["title"] ?? null;
$author = $bookData["authors"][0]["name"] ?? null;
$description = $bookData["description"] ?? ($bookData["notes"] ?? "");
try {
$bookData = $this->fetchBookData($isbn);
$this->processBook($bookData);
$this->sendResponse(['message' => 'Book imported successfully'], 200);
} catch (\Exception $e) {
$this->sendErrorResponse('Error: '.$e->getMessage(), 500);
}
}
if (!$isbn || !$title || !$author) throw new \Exception("Missing essential book data (title, author, or ISBN).");
private function fetchBookData(string $isbn): array
{
$client = new Client();
$response = $client->get('https://openlibrary.org/api/books', [
'query' => [
'bibkeys' => "ISBN:{$isbn}",
'format' => 'json',
'jscmd' => 'data',
],
'headers' => ['Accept' => 'application/json'],
]);
$existingBook = $this->getBookByISBN($isbn);
$data = json_decode($response->getBody(), true);
$bookKey = "ISBN:{$isbn}";
if ($existingBook) throw new \Exception("Book with ISBN {$isbn} already exists.");
if (empty($data[$bookKey])) {
throw new \Exception("Book data not found for ISBN: {$isbn}");
}
$bookPayload = [
"isbn" => $isbn,
"title" => $title,
"author" => $author,
"description" => $description,
"read_status" => "want to read",
"slug" => "/reading/books/" . $isbn,
];
return $data[$bookKey];
}
$this->makeRequest("POST", "books", ["json" => $bookPayload]);
}
private function processBook(array $bookData): void
{
$isbn =
$bookData['identifiers']['isbn_13'][0] ??
($bookData['identifiers']['isbn_10'][0] ?? null);
$title = $bookData['title'] ?? null;
$author = $bookData['authors'][0]['name'] ?? null;
$description = $bookData['description'] ?? ($bookData['notes'] ?? '');
private function getBookByISBN(string $isbn): ?array
{
$response = $this->fetchFromApi("books", "isbn=eq." . urlencode($isbn));
if (! $isbn || ! $title || ! $author) {
throw new \Exception('Missing essential book data (title, author, or ISBN).');
}
return $response[0] ?? null;
}
$existingBook = $this->getBookByISBN($isbn);
if ($existingBook) {
throw new \Exception("Book with ISBN {$isbn} already exists.");
}
$bookPayload = [
'isbn' => $isbn,
'title' => $title,
'author' => $author,
'description' => $description,
'read_status' => 'want to read',
'slug' => '/reading/books/'.$isbn,
];
$this->makeRequest('POST', 'books', ['json' => $bookPayload]);
}
private function getBookByISBN(string $isbn): ?array
{
$response = $this->fetchFromApi('books', 'isbn=eq.'.urlencode($isbn));
return $response[0] ?? null;
}
}
$handler = new BookImportHandler();

View file

@ -1,214 +1,241 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\BaseHandler;
use GuzzleHttp\Client;
class ContactHandler extends BaseHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
private string $forwardEmailApiKey;
private Client $httpClient;
protected string $postgrestUrl;
public function __construct(?Client $httpClient = null)
{
parent::__construct();
protected string $postgrestApiKey;
$this->httpClient = $httpClient ?? new Client();
$this->forwardEmailApiKey = $_ENV["FORWARDEMAIL_API_KEY"] ?? getenv("FORWARDEMAIL_API_KEY");
}
private string $forwardEmailApiKey;
public function handleRequest(): void
{
try {
$this->validateReferer();
$this->checkRateLimit();
$this->enforceHttps();
private Client $httpClient;
$contentType = $_SERVER["CONTENT_TYPE"] ?? "";
$formData = null;
public function __construct(?Client $httpClient = null)
{
parent::__construct();
if (strpos($contentType, "application/json") !== false) {
$rawBody = file_get_contents("php://input");
$formData = json_decode($rawBody, true);
if (!$formData || !isset($formData["data"])) throw new \Exception("Invalid JSON payload.");
$formData = $formData["data"];
} elseif (
strpos($contentType, "application/x-www-form-urlencoded") !== false
) {
$formData = $_POST;
} else {
$this->sendErrorResponse("Unsupported Content-Type. Use application/json or application/x-www-form-urlencoded.", 400);
}
if (!empty($formData["hp_name"])) $this->sendErrorResponse("Invalid submission.", 400);
$name = htmlspecialchars(
trim($formData["name"] ?? ""),
ENT_QUOTES,
"UTF-8"
);
$email = filter_var($formData["email"] ?? "", FILTER_VALIDATE_EMAIL);
$message = htmlspecialchars(
trim($formData["message"] ?? ""),
ENT_QUOTES,
"UTF-8"
);
if (empty($name)) $this->sendErrorResponse("Name is required.", 400);
if (!$email) $this->sendErrorResponse("Valid email is required.", 400);
if (empty($message)) $this->sendErrorResponse("Message is required.", 400);
if (strlen($name) > 100) $this->sendErrorResponse("Name is too long. Max 100 characters allowed.", 400);
if (strlen($message) > 1000) $this->sendErrorResponse("Message is too long. Max 1000 characters allowed.", 400);
if ($this->isBlockedDomain($email)) $this->sendErrorResponse("Submission from blocked domain.", 400);
$contactData = [
"name" => $name,
"email" => $email,
"message" => $message,
"replied" => false,
];
$this->saveToDatabase($contactData);
$this->sendNotificationEmail($contactData);
$this->sendRedirect("/contact/success");
} catch (\Exception $e) {
error_log("Error handling contact form submission: " . $e->getMessage());
$this->sendErrorResponse($e->getMessage(), 400);
$this->httpClient = $httpClient ?? new Client();
$this->forwardEmailApiKey = $_ENV['FORWARDEMAIL_API_KEY'] ?? getenv('FORWARDEMAIL_API_KEY');
}
}
private function validateReferer(): void
{
$referer = $_SERVER["HTTP_REFERER"] ?? "";
$allowedDomain = "coryd.dev";
public function handleRequest(): void
{
try {
$this->validateReferer();
$this->checkRateLimit();
$this->enforceHttps();
if (!str_contains($referer, $allowedDomain)) throw new \Exception("Invalid submission origin.");
}
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$formData = null;
private function checkRateLimit(): void
{
$ipAddress = $_SERVER["REMOTE_ADDR"] ?? "unknown";
$cacheFile = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress);
$rateLimitDuration = 60;
$maxRequests = 5;
if (strpos($contentType, 'application/json') !== false) {
$rawBody = file_get_contents('php://input');
$formData = json_decode($rawBody, true);
if (file_exists($cacheFile)) {
$data = json_decode(file_get_contents($cacheFile), true);
if (! $formData || ! isset($formData['data'])) {
throw new \Exception('Invalid JSON payload.');
}
$formData = $formData['data'];
} elseif (
strpos($contentType, 'application/x-www-form-urlencoded') !== false
) {
$formData = $_POST;
} else {
$this->sendErrorResponse('Unsupported Content-Type. Use application/json or application/x-www-form-urlencoded.', 400);
}
if (! empty($formData['hp_name'])) {
$this->sendErrorResponse('Invalid submission.', 400);
}
$name = htmlspecialchars(
trim($formData['name'] ?? ''),
ENT_QUOTES,
'UTF-8'
);
$email = filter_var($formData['email'] ?? '', FILTER_VALIDATE_EMAIL);
$message = htmlspecialchars(
trim($formData['message'] ?? ''),
ENT_QUOTES,
'UTF-8'
);
if (empty($name)) {
$this->sendErrorResponse('Name is required.', 400);
}
if (! $email) {
$this->sendErrorResponse('Valid email is required.', 400);
}
if (empty($message)) {
$this->sendErrorResponse('Message is required.', 400);
}
if (strlen($name) > 100) {
$this->sendErrorResponse('Name is too long. Max 100 characters allowed.', 400);
}
if (strlen($message) > 1000) {
$this->sendErrorResponse('Message is too long. Max 1000 characters allowed.', 400);
}
if ($this->isBlockedDomain($email)) {
$this->sendErrorResponse('Submission from blocked domain.', 400);
}
$contactData = [
'name' => $name,
'email' => $email,
'message' => $message,
'replied' => false,
];
$this->saveToDatabase($contactData);
$this->sendNotificationEmail($contactData);
$this->sendRedirect('/contact/success');
} catch (\Exception $e) {
error_log('Error handling contact form submission: '.$e->getMessage());
$this->sendErrorResponse($e->getMessage(), 400);
}
}
private function validateReferer(): void
{
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$allowedDomain = 'coryd.dev';
if (! str_contains($referer, $allowedDomain)) {
throw new \Exception('Invalid submission origin.');
}
}
private function checkRateLimit(): void
{
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$cacheFile = sys_get_temp_dir().'/rate_limit_'.md5($ipAddress);
$rateLimitDuration = 60;
$maxRequests = 5;
if (file_exists($cacheFile)) {
$data = json_decode(file_get_contents($cacheFile), true);
if (time() < $data['timestamp'] + $rateLimitDuration && $data['count'] >= $maxRequests) {
header('Location: /429', true, 302);
exit();
}
$data['count']++;
} else {
$data = ['count' => 1, 'timestamp' => time()];
}
file_put_contents($cacheFile, json_encode($data));
}
private function enforceHttps(): void
{
if (empty($_SERVER['HTTPS']) || $_SERVER['HTTPS'] !== 'on') {
throw new \Exception('Secure connection required. Use HTTPS.');
}
}
private function isBlockedDomain(string $email): bool
{
$domain = substr(strrchr($email, '@'), 1);
if (! $domain) {
return false;
}
$response = $this->httpClient->get(
"{$this->postgrestUrl}/blocked_domains",
[
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => "Bearer {$this->postgrestApiKey}",
],
'query' => [
'domain_name' => "eq.{$domain}",
'limit' => 1,
],
]
);
$blockedDomains = json_decode($response->getBody(), true);
return ! empty($blockedDomains);
}
private function saveToDatabase(array $contactData): void
{
$response = $this->httpClient->post("{$this->postgrestUrl}/contacts", [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => "Bearer {$this->postgrestApiKey}",
],
'json' => $contactData,
]);
if ($response->getStatusCode() >= 400) {
$errorResponse = json_decode($response->getBody(), true);
throw new \Exception('PostgREST error: '.($errorResponse['message'] ?? 'Unknown error'));
}
}
private function sendNotificationEmail(array $contactData): void
{
$authHeader = 'Basic '.base64_encode("{$this->forwardEmailApiKey}:");
$emailSubject = 'Contact form submission';
$emailText = sprintf(
"Name: %s\nEmail: %s\nMessage: %s\n",
$contactData['name'],
$contactData['email'],
$contactData['message']
);
$response = $this->httpClient->post(
'https://api.forwardemail.net/v1/emails',
[
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded',
'Authorization' => $authHeader,
],
'form_params' => [
'from' => 'coryd.dev <hi@admin.coryd.dev>',
'to' => 'hi@coryd.dev',
'subject' => $emailSubject,
'text' => $emailText,
'replyTo' => $contactData['email'],
],
]
);
if ($response->getStatusCode() >= 400) {
throw new \Exception('Failed to send email notification.');
}
}
private function sendRedirect(string $path): void
{
$protocol = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$redirectUrl = "{$protocol}://{$host}{$path}";
header("Location: $redirectUrl", true, 302);
if ($data["timestamp"] + $rateLimitDuration > time() && $data["count"] >= $maxRequests) {
header("Location: /429", true, 302);
exit();
}
$data["count"]++;
} else {
$data = ["count" => 1, "timestamp" => time()];
}
file_put_contents($cacheFile, json_encode($data));
}
private function enforceHttps(): void
{
if (empty($_SERVER["HTTPS"]) || $_SERVER["HTTPS"] !== "on") throw new \Exception("Secure connection required. Use HTTPS.");
}
private function isBlockedDomain(string $email): bool
{
$domain = substr(strrchr($email, "@"), 1);
if (!$domain) return false;
$response = $this->httpClient->get(
"{$this->postgrestUrl}/blocked_domains",
[
"headers" => [
"Content-Type" => "application/json",
"Authorization" => "Bearer {$this->postgrestApiKey}",
],
"query" => [
"domain_name" => "eq.{$domain}",
"limit" => 1,
],
]
);
$blockedDomains = json_decode($response->getBody(), true);
return !empty($blockedDomains);
}
private function saveToDatabase(array $contactData): void
{
$response = $this->httpClient->post("{$this->postgrestUrl}/contacts", [
"headers" => [
"Content-Type" => "application/json",
"Authorization" => "Bearer {$this->postgrestApiKey}",
],
"json" => $contactData,
]);
if ($response->getStatusCode() >= 400) {
$errorResponse = json_decode($response->getBody(), true);
throw new \Exception("PostgREST error: " . ($errorResponse["message"] ?? "Unknown error"));
}
}
private function sendNotificationEmail(array $contactData): void
{
$authHeader = "Basic " . base64_encode("{$this->forwardEmailApiKey}:");
$emailSubject = "Contact form submission";
$emailText = sprintf(
"Name: %s\nEmail: %s\nMessage: %s\n",
$contactData["name"],
$contactData["email"],
$contactData["message"]
);
$response = $this->httpClient->post(
"https://api.forwardemail.net/v1/emails",
[
"headers" => [
"Content-Type" => "application/x-www-form-urlencoded",
"Authorization" => $authHeader,
],
"form_params" => [
"from" => "coryd.dev <hi@admin.coryd.dev>",
"to" => "hi@coryd.dev",
"subject" => $emailSubject,
"text" => $emailText,
"replyTo" => $contactData["email"],
],
]
);
if ($response->getStatusCode() >= 400) throw new \Exception("Failed to send email notification.");
}
private function sendRedirect(string $path): void
{
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? "https" : "http";
$host = $_SERVER['HTTP_HOST'];
$redirectUrl = "{$protocol}://{$host}{$path}";
header("Location: $redirectUrl", true, 302);
exit();
}
}
try {
$handler = new ContactHandler();
$handler->handleRequest();
$handler = new ContactHandler();
$handler->handleRequest();
} catch (\Exception $e) {
error_log("Contact form error: " . $e->getMessage());
error_log('Contact form error: '.$e->getMessage());
echo json_encode(["error" => $e->getMessage()]);
echo json_encode(['error' => $e->getMessage()]);
http_response_code(500);
http_response_code(500);
}

View file

@ -1,174 +1,187 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class MastodonPostHandler extends ApiHandler
{
private string $mastodonAccessToken;
private string $rssFeedUrl = "https://www.coryd.dev/feeds/syndication.xml";
private string $baseUrl = "https://www.coryd.dev";
private const MASTODON_API_STATUS = "https://follow.coryd.dev/api/v1/statuses";
private Client $httpClient;
private string $mastodonAccessToken;
public function __construct(?Client $httpClient = null)
{
parent::__construct();
private string $rssFeedUrl = 'https://www.coryd.dev/feeds/syndication.xml';
$this->ensureCliAccess();
$this->mastodonAccessToken = getenv("MASTODON_ACCESS_TOKEN") ?: $_ENV["MASTODON_ACCESS_TOKEN"] ?? "";
$this->httpClient = $httpClient ?: new Client();
$this->validateAuthorization();
}
private string $baseUrl = 'https://www.coryd.dev';
private function validateAuthorization(): void
{
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
$expectedToken = "Bearer " . getenv("MASTODON_SYNDICATION_TOKEN");
private const MASTODON_API_STATUS = 'https://follow.coryd.dev/api/v1/statuses';
if ($authHeader !== $expectedToken) {
http_response_code(401);
echo json_encode(["error" => "Unauthorized"]);
exit();
}
}
private Client $httpClient;
public function handlePost(): void
{
if (!$this->isDatabaseAvailable()) {
echo "Database is unavailable. Exiting.\n";
return;
public function __construct(?Client $httpClient = null)
{
parent::__construct();
$this->ensureCliAccess();
$this->mastodonAccessToken = getenv('MASTODON_ACCESS_TOKEN') ?: $_ENV['MASTODON_ACCESS_TOKEN'] ?? '';
$this->httpClient = $httpClient ?: new Client();
$this->validateAuthorization();
}
$latestItems = $this->fetchRSSFeed($this->rssFeedUrl);
private function validateAuthorization(): void
{
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$expectedToken = 'Bearer '.getenv('MASTODON_SYNDICATION_TOKEN');
foreach (array_reverse($latestItems) as $item) {
$existing = $this->fetchFromApi("mastodon_posts", "link=eq." . urlencode($item["link"]));
if (!empty($existing)) continue;
$content = $this->truncateContent(
$item["title"],
strip_tags($item["description"]),
$item["link"],
500
);
$timestamp = date("Y-m-d H:i:s");
if (!$this->storeInDatabase($item["link"], $timestamp)) {
echo "Skipping post: database write failed for {$item["link"]}\n";
continue;
}
$postedUrl = $this->postToMastodon($content, $item["image"] ?? null);
if ($postedUrl) {
echo "Posted: {$postedUrl}\n";
} else {
echo "Failed to post to Mastodon for: {$item["link"]}\n";
}
if ($authHeader !== $expectedToken) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit();
}
}
echo "RSS processed successfully.\n";
}
public function handlePost(): void
{
if (! $this->isDatabaseAvailable()) {
echo "Database is unavailable. Exiting.\n";
private function fetchRSSFeed(string $rssFeedUrl): array
{
$rssText = file_get_contents($rssFeedUrl);
return;
}
if (!$rssText) throw new \Exception("Failed to fetch RSS feed.");
$latestItems = $this->fetchRSSFeed($this->rssFeedUrl);
$rss = new \SimpleXMLElement($rssText);
$items = [];
foreach (array_reverse($latestItems) as $item) {
$existing = $this->fetchFromApi('mastodon_posts', 'link=eq.'.urlencode($item['link']));
foreach ($rss->channel->item as $item) {
$items[] = [
"title" => $this->cleanText((string) $item->title),
"link" => (string) $item->link,
"description" => $this->cleanText((string) $item->description),
];
if (! empty($existing)) {
continue;
}
$content = $this->truncateContent(
$item['title'],
strip_tags($item['description']),
$item['link'],
500
);
$timestamp = date('Y-m-d H:i:s');
if (! $this->storeInDatabase($item['link'], $timestamp)) {
echo "Skipping post: database write failed for {$item['link']}\n";
continue;
}
$postedUrl = $this->postToMastodon($content, $item['image'] ?? null);
if ($postedUrl) {
echo "Posted: {$postedUrl}\n";
} else {
echo "Failed to post to Mastodon for: {$item['link']}\n";
}
}
echo "RSS processed successfully.\n";
}
return $items;
}
private function fetchRSSFeed(string $rssFeedUrl): array
{
$rssText = file_get_contents($rssFeedUrl);
private function cleanText(string $text): string
{
$decoded = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
}
if (! $rssText) {
throw new \Exception('Failed to fetch RSS feed.');
}
private function postToMastodon(string $content): ?string
{
$headers = [
"Authorization" => "Bearer {$this->mastodonAccessToken}",
"Content-Type" => "application/json",
];
$postData = ["status" => $content];
$response = $this->httpClient->request("POST", self::MASTODON_API_STATUS, [
"headers" => $headers,
"json" => $postData
]);
$rss = new \SimpleXMLElement($rssText);
$items = [];
if ($response->getStatusCode() >= 400) throw new \Exception("Mastodon post failed: {$response->getBody()}");
foreach ($rss->channel->item as $item) {
$items[] = [
'title' => $this->cleanText((string) $item->title),
'link' => (string) $item->link,
'description' => $this->cleanText((string) $item->description),
];
}
$body = json_decode($response->getBody()->getContents(), true);
return $body["url"] ?? null;
}
private function storeInDatabase(string $link, string $timestamp): bool
{
try {
$this->makeRequest("POST", "mastodon_posts", [
"json" => [
"link" => $link,
"created_at" => $timestamp
]
]);
return true;
} catch (\Exception $e) {
echo "Error storing post in DB: " . $e->getMessage() . "\n";
return false;
}
}
private function isDatabaseAvailable(): bool
{
try {
$response = $this->fetchFromApi("mastodon_posts", "limit=1");
return is_array($response);
} catch (\Exception $e) {
echo "Database check failed: " . $e->getMessage() . "\n";
return false;
}
}
private function truncateContent(string $title, string $description, string $link, int $maxLength): string
{
$baseLength = strlen("$title\n\n$link");
$available = $maxLength - $baseLength - 4;
if (strlen($description) > $available) {
$description = substr($description, 0, $available);
$description = preg_replace('/\s+\S*$/', "", $description) . "...";
return $items;
}
return "$title\n\n$description\n\n$link";
}
private function cleanText(string $text): string
{
$decoded = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
return mb_convert_encoding($decoded, 'UTF-8', 'UTF-8');
}
private function postToMastodon(string $content): ?string
{
$headers = [
'Authorization' => "Bearer {$this->mastodonAccessToken}",
'Content-Type' => 'application/json',
];
$postData = ['status' => $content];
$response = $this->httpClient->request('POST', self::MASTODON_API_STATUS, [
'headers' => $headers,
'json' => $postData,
]);
if ($response->getStatusCode() >= 400) {
throw new \Exception("Mastodon post failed: {$response->getBody()}");
}
$body = json_decode($response->getBody()->getContents(), true);
return $body['url'] ?? null;
}
private function storeInDatabase(string $link, string $timestamp): bool
{
try {
$this->makeRequest('POST', 'mastodon_posts', [
'json' => [
'link' => $link,
'created_at' => $timestamp,
],
]);
return true;
} catch (\Exception $e) {
echo 'Error storing post in DB: '.$e->getMessage()."\n";
return false;
}
}
private function isDatabaseAvailable(): bool
{
try {
$response = $this->fetchFromApi('mastodon_posts', 'limit=1');
return is_array($response);
} catch (\Exception $e) {
echo 'Database check failed: '.$e->getMessage()."\n";
return false;
}
}
private function truncateContent(string $title, string $description, string $link, int $maxLength): string
{
$baseLength = strlen("$title\n\n$link");
$available = $maxLength - $baseLength - 4;
if (strlen($description) > $available) {
$description = substr($description, 0, $available);
$description = preg_replace('/\s+\S*$/', '', $description).'...';
}
return "$title\n\n$description\n\n$link";
}
}
try {
$handler = new MastodonPostHandler();
$handler->handlePost();
$handler = new MastodonPostHandler();
$handler->handlePost();
} catch (\Exception $e) {
http_response_code(500);
http_response_code(500);
echo json_encode(["error" => $e->getMessage()]);
echo json_encode(['error' => $e->getMessage()]);
}

View file

@ -1,111 +1,124 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\BaseHandler;
use GuzzleHttp\Client;
class OembedHandler extends BaseHandler
{
public function handleRequest(): void
{
$requestUrl = $_GET['url'] ?? null;
$globals = $this->fetchGlobals();
$parsed = $requestUrl ? parse_url($requestUrl) : null;
$relativePath = $parsed['path'] ?? null;
public function handleRequest(): void
{
$requestUrl = $_GET['url'] ?? null;
$globals = $this->fetchGlobals();
$parsed = $requestUrl ? parse_url($requestUrl) : null;
$relativePath = $parsed['path'] ?? null;
if (!$requestUrl || $relativePath === '/') $this->sendResponse($this->buildResponse(
$globals['site_name'],
$globals['url'],
$globals['metadata']['open_graph_image'],
$globals,
$globals['site_description']
));
if (! $requestUrl || $relativePath === '/') {
$this->sendResponse($this->buildResponse(
$globals['site_name'],
$globals['url'],
$globals['metadata']['open_graph_image'],
$globals,
$globals['site_description']
));
}
if (!$relativePath) $this->sendErrorResponse('Invalid url', 400);
if (! $relativePath) {
$this->sendErrorResponse('Invalid url', 400);
}
$relativePath = '/' . ltrim($relativePath ?? '', '/');
$relativePath = '/'.ltrim($relativePath ?? '', '/');
if ($relativePath !== '/' && str_ends_with($relativePath, '/')) $relativePath = rtrim($relativePath, '/');
if ($relativePath !== '/' && str_ends_with($relativePath, '/')) {
$relativePath = rtrim($relativePath, '/');
}
$cacheKey = 'oembed:' . md5($relativePath);
$cacheKey = 'oembed:'.md5($relativePath);
if ($this->cache && $this->cache->exists($cacheKey)) {
$cachedItem = json_decode($this->cache->get($cacheKey), true);
if ($this->cache && $this->cache->exists($cacheKey)) {
$cachedItem = json_decode($this->cache->get($cacheKey), true);
$this->sendResponse($this->buildResponse(
$cachedItem['title'],
$cachedItem['url'],
$cachedItem['image_url'],
$globals,
$cachedItem['description'] ?? ''
));
$this->sendResponse($this->buildResponse(
$cachedItem['title'],
$cachedItem['url'],
$cachedItem['image_url'],
$globals,
$cachedItem['description'] ?? ''
));
}
$results = $this->fetchFromApi('optimized_oembed', 'url=eq.'.urlencode($relativePath));
if (! empty($results)) {
$item = $results[0];
if ($this->cache) {
$this->cache->setex($cacheKey, 300, json_encode($item));
}
$this->sendResponse($this->buildResponse(
$item['title'],
$item['url'],
$item['image_url'],
$globals,
$item['description'] ?? ''
));
}
$segments = explode('/', trim($relativePath, '/'));
if (count($segments) === 1 && $segments[0] !== '') {
$title = ucwords(str_replace('-', ' ', $segments[0])).' • '.$globals['author'];
$this->sendResponse($this->buildResponse(
$title,
$relativePath,
$globals['metadata']['open_graph_image'],
$globals
));
}
$this->sendErrorResponse('No match found', 404);
}
$results = $this->fetchFromApi('optimized_oembed', 'url=eq.' . urlencode($relativePath));
private function buildResponse(string $title, string $url, string $imagePath, array $globals, string $description = ''): array
{
$safeDescription = truncateText(strip_tags(parseMarkdown($description)), 175);
$html = '<p><a href="'.htmlspecialchars($url).'">'.htmlspecialchars($title).'</a></p>';
if (!empty($results)) {
$item = $results[0];
if ($description) {
$html .= '<p>'.htmlspecialchars($safeDescription, ENT_QUOTES, 'UTF-8').'</p>';
}
if ($this->cache) $this->cache->setex($cacheKey, 300, json_encode($item));
$this->sendResponse($this->buildResponse(
$item['title'],
$item['url'],
$item['image_url'],
$globals,
$item['description'] ?? ''
));
return [
'version' => '1.0',
'type' => 'link',
'title' => $title,
'author_name' => $globals['author'],
'provider_name' => $globals['site_name'],
'provider_url' => $globals['url'],
'thumbnail_url' => $globals['url'].'/og/w800'.$imagePath,
'html' => $html ?? $globals['site_description'],
'description' => $safeDescription ?? $globals['site_description'],
];
}
$segments = explode('/', trim($relativePath, '/'));
private function fetchGlobals(): array
{
$cacheKey = 'globals_data';
if (count($segments) === 1 && $segments[0] !== '') {
$title = ucwords(str_replace('-', ' ', $segments[0])) . ' • ' . $globals['author'];
if ($this->cache && $this->cache->exists($cacheKey)) {
return json_decode($this->cache->get($cacheKey), true);
}
$this->sendResponse($this->buildResponse(
$title,
$relativePath,
$globals['metadata']['open_graph_image'],
$globals
));
$globals = $this->fetchFromApi('optimized_globals', 'limit=1')[0];
if ($this->cache) {
$this->cache->setex($cacheKey, 3600, json_encode($globals));
}
return $globals;
}
$this->sendErrorResponse('No match found', 404);
}
private function buildResponse(string $title, string $url, string $imagePath, array $globals, string $description = ''): array
{
$safeDescription = truncateText(strip_tags(parseMarkdown($description)), 175);
$html = '<p><a href="' . htmlspecialchars($url) . '">' . htmlspecialchars($title) . '</a></p>';
if ($description) $html .= '<p>' . htmlspecialchars($safeDescription, ENT_QUOTES, 'UTF-8') . '</p>';
return [
'version' => '1.0',
'type' => 'link',
'title' => $title,
'author_name' => $globals['author'],
'provider_name' => $globals['site_name'],
'provider_url' => $globals['url'],
'thumbnail_url' => $globals['url'] . '/og/w800' . $imagePath,
'html' => $html ?? $globals['site_description'],
'description' => $safeDescription ?? $globals['site_description'],
];
}
private function fetchGlobals(): array
{
$cacheKey = 'globals_data';
if ($this->cache && $this->cache->exists($cacheKey)) return json_decode($this->cache->get($cacheKey), true);
$globals = $this->fetchFromApi('optimized_globals', 'limit=1')[0];
if ($this->cache) $this->cache->setex($cacheKey, 3600, json_encode($globals));
return $globals;
}
}
$handler = new OembedHandler();

View file

@ -1,30 +1,34 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
$id = $_GET['id'] ?? null;
$class = $_GET['class'] ?? null;
$extension = $_GET['extension'] ?? 'png';
$isValidId = is_string($id) && preg_match('/^[a-f0-9\-]{36}$/', $id);
$isValidClass = is_string($class) && preg_match('/^w\d{2,4}$/', $class);
$id = $_GET['id'] ?? null;
$class = $_GET['class'] ?? null;
$extension = $_GET['extension'] ?? 'png';
$isValidId = is_string($id) && preg_match('/^[a-f0-9\-]{36}$/', $id);
$isValidClass = is_string($class) && preg_match('/^w\d{2,4}$/', $class);
if (!$isValidId || !$isValidClass) redirectTo404();
if (! $isValidId || ! $isValidClass) {
redirectTo404();
}
$cdnUrl = "https://cdn.coryd.dev/$id.$extension?class=$class";
$ch = curl_init($cdnUrl);
$cdnUrl = "https://cdn.coryd.dev/$id.$extension?class=$class";
$ch = curl_init($cdnUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT'] ?? 'coryd-bot/1.0');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT'] ?? 'coryd-bot/1.0');
$image = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$image = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
curl_close($ch);
if ($httpCode !== 200 || $image === false || strpos($contentType, 'image/') !== 0) redirectTo404();
if ($httpCode !== 200 || $image === false || strpos($contentType, 'image/') !== 0) {
redirectTo404();
}
header("Content-Type: $contentType");
header("Content-Type: $contentType");
echo $image;
echo $image;

View file

@ -1,96 +1,110 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\BaseHandler;
class QueryHandler extends BaseHandler
{
public function __construct()
{
parent::__construct();
$this->ensureAllowedOrigin();
}
protected function ensureAllowedOrigin(): void
{
$allowedHosts = ['coryd.dev', 'www.coryd.dev', 'localhost'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$hostAllowed = fn($url) => in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true);
if (!$hostAllowed($origin) && !$hostAllowed($referer)) $this->sendErrorResponse("Forbidden: invalid origin", 403);
$allowedSource = $origin ?: $referer;
$scheme = parse_url($allowedSource, PHP_URL_SCHEME) ?? 'https';
$host = parse_url($allowedSource, PHP_URL_HOST);
header("Access-Control-Allow-Origin: {$scheme}://{$host}");
header("Access-Control-Allow-Headers: Content-Type");
header("Access-Control-Allow-Methods: GET, POST");
}
public function handleRequest(): void
{
$data = $_GET['data'] ?? null;
$id = $_GET['id'] ?? null;
$cacheDuration = intval($_GET['cacheDuration'] ?? 3600);
if (!$data) $this->sendErrorResponse("Missing 'data' parameter", 400);
$cacheKey = $this->buildCacheKey($data, $id);
if ($this->cache) {
$cached = $this->cache->get($cacheKey);
if ($cached) {
header('Content-Type: application/json');
echo $cached;
exit();
}
public function __construct()
{
parent::__construct();
$this->ensureAllowedOrigin();
}
$query = $id ? "id=eq.$id" : "";
protected function ensureAllowedOrigin(): void
{
$allowedHosts = ['coryd.dev', 'www.coryd.dev', 'localhost'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$hostAllowed = fn ($url) => in_array(parse_url($url, PHP_URL_HOST), $allowedHosts, true);
try {
$response = $this->fetchFromApi($data, $query);
$markdownFields = $this->getMarkdownFieldsFromQuery();
if (! $hostAllowed($origin) && ! $hostAllowed($referer)) {
$this->sendErrorResponse('Forbidden: invalid origin', 403);
}
if (!empty($response) && !empty($markdownFields)) $response = $this->parseMarkdownFields($response, $markdownFields);
$allowedSource = $origin ?: $referer;
$scheme = parse_url($allowedSource, PHP_URL_SCHEME) ?? 'https';
$host = parse_url($allowedSource, PHP_URL_HOST);
$json = json_encode($response);
if ($this->cache) $this->cache->setex($cacheKey, $cacheDuration, $json);
header('Content-Type: application/json');
echo $json;
} catch (\Exception $e) {
$this->sendErrorResponse("PostgREST fetch failed: " . $e->getMessage(), 500);
}
}
private function buildCacheKey(string $data, ?string $id): string
{
return "proxy_{$data}" . ($id ? "_{$id}" : "");
}
private function getMarkdownFieldsFromQuery(): array {
$fields = $_GET['markdown'] ?? [];
if (!is_array($fields)) $fields = explode(',', $fields);
return array_map('trim', array_filter($fields));
}
private function parseMarkdownFields(array $data, array $fields): array {
foreach ($data as &$item) {
foreach ($fields as $field) {
if (!empty($item[$field])) $item["{$field}_html"] = parseMarkdown($item[$field]);
}
header("Access-Control-Allow-Origin: {$scheme}://{$host}");
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Methods: GET, POST');
}
return $data;
}
public function handleRequest(): void
{
$data = $_GET['data'] ?? null;
$id = $_GET['id'] ?? null;
$cacheDuration = intval($_GET['cacheDuration'] ?? 3600);
if (! $data) {
$this->sendErrorResponse("Missing 'data' parameter", 400);
}
$cacheKey = $this->buildCacheKey($data, $id);
if ($this->cache) {
$cached = $this->cache->get($cacheKey);
if ($cached) {
header('Content-Type: application/json');
echo $cached;
exit();
}
}
$query = $id ? "id=eq.$id" : '';
try {
$response = $this->fetchFromApi($data, $query);
$markdownFields = $this->getMarkdownFieldsFromQuery();
if (! empty($response) && ! empty($markdownFields)) {
$response = $this->parseMarkdownFields($response, $markdownFields);
}
$json = json_encode($response);
if ($this->cache) {
$this->cache->setex($cacheKey, $cacheDuration, $json);
}
header('Content-Type: application/json');
echo $json;
} catch (\Exception $e) {
$this->sendErrorResponse('PostgREST fetch failed: '.$e->getMessage(), 500);
}
}
private function buildCacheKey(string $data, ?string $id): string
{
return "proxy_{$data}".($id ? "_{$id}" : '');
}
private function getMarkdownFieldsFromQuery(): array
{
$fields = $_GET['markdown'] ?? [];
if (! is_array($fields)) {
$fields = explode(',', $fields);
}
return array_map('trim', array_filter($fields));
}
private function parseMarkdownFields(array $data, array $fields): array
{
foreach ($data as &$item) {
foreach ($fields as $field) {
if (! empty($item[$field])) {
$item["{$field}_html"] = parseMarkdown($item[$field]);
}
}
}
return $data;
}
}
$handler = new QueryHandler();

View file

@ -1,274 +1,312 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
header("Content-Type: application/json");
header('Content-Type: application/json');
class NavidromeScrobbleHandler extends ApiHandler
{
private string $navidromeApiUrl;
private string $navidromeAuthToken;
private string $forwardEmailApiKey;
private array $artistCache = [];
private array $albumCache = [];
private string $navidromeApiUrl;
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
$this->loadExternalServiceKeys();
$this->validateAuthorization();
}
private string $navidromeAuthToken;
private function loadExternalServiceKeys(): void
{
$this->navidromeApiUrl = getenv("NAVIDROME_API_URL");
$this->navidromeAuthToken = getenv("NAVIDROME_API_TOKEN");
$this->forwardEmailApiKey = getenv("FORWARDEMAIL_API_KEY");
}
private string $forwardEmailApiKey;
private function validateAuthorization(): void
{
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
$expectedToken = "Bearer " . getenv("NAVIDROME_SCROBBLE_TOKEN");
private array $artistCache = [];
if ($authHeader !== $expectedToken) {
http_response_code(401);
private array $albumCache = [];
echo json_encode(["error" => "Unauthorized."]);
exit();
}
}
public function runScrobbleCheck(): void
{
$recentTracks = $this->fetchRecentlyPlayed();
if (empty($recentTracks)) return;
foreach ($recentTracks as $track) {
if ($this->isTrackAlreadyScrobbled($track)) continue;
$this->handleTrackScrobble($track);
}
}
private function fetchRecentlyPlayed(): array
{
$client = new Client();
try {
$response = $client->request("GET", "{$this->navidromeApiUrl}/api/song", [
"query" => [
"_end" => 20,
"_order" => "DESC",
"_sort" => "play_date",
"_start" => 0,
"recently_played" => "true"
],
"headers" => [
"x-nd-authorization" => "Bearer {$this->navidromeAuthToken}",
"Accept" => "application/json"
]
]);
$data = json_decode($response->getBody()->getContents(), true);
return $data ?? [];
} catch (\Exception $e) {
error_log("Error fetching tracks: " . $e->getMessage());
return [];
}
}
private function isTrackAlreadyScrobbled(array $track): bool
{
$playDateString = $track["playDate"] ?? null;
if (!$playDateString) return false;
$playDate = strtotime($playDateString);
if ($playDate === false) return false;
$existingListen = $this->fetchFromApi("listens", "listened_at=eq.{$playDate}&limit=1");
return !empty($existingListen);
}
private function handleTrackScrobble(array $track): void
{
$artistData = $this->getOrCreateArtist($track["artist"]);
if (empty($artistData)) {
error_log("Failed to retrieve or create artist: " . $track["artist"]);
return;
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
$this->loadExternalServiceKeys();
$this->validateAuthorization();
}
$albumData = $this->getOrCreateAlbum($track["album"], $artistData);
if (empty($albumData)) {
error_log("Failed to retrieve or create album: " . $track["album"]);
return;
private function loadExternalServiceKeys(): void
{
$this->navidromeApiUrl = getenv('NAVIDROME_API_URL');
$this->navidromeAuthToken = getenv('NAVIDROME_API_TOKEN');
$this->forwardEmailApiKey = getenv('FORWARDEMAIL_API_KEY');
}
$this->insertListen($track, $albumData["key"]);
}
private function validateAuthorization(): void
{
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$expectedToken = 'Bearer '.getenv('NAVIDROME_SCROBBLE_TOKEN');
private function getOrCreateArtist(string $artistName): array
{
if (!$this->isDatabaseAvailable()) return [];
if (isset($this->artistCache[$artistName])) return $this->artistCache[$artistName];
if ($authHeader !== $expectedToken) {
http_response_code(401);
$encodedArtist = rawurlencode($artistName);
$existingArtist = $this->fetchFromApi("artists", "name_string=eq.{$encodedArtist}&limit=1");
echo json_encode(['error' => 'Unauthorized.']);
if (!empty($existingArtist)) return $this->artistCache[$artistName] = $existingArtist[0];
$response = $this->makeRequest("POST", "artists", [
"json" => [
"mbid" => "",
"art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
"name_string" => $artistName,
"slug" => "/music",
"country" => "",
"description" => "",
"tentative" => true,
"favorite" => false,
"tattoo" => false,
"total_plays" => 0
],
"headers" => ["Prefer" => "return=representation"]
]);
$inserted = $response[0] ?? null;
if ($inserted) $this->sendFailureEmail("New tentative artist record", "A new tentative artist record was inserted for: $artistName");
return $this->artistCache[$artistName] = $inserted ?? [];
}
private function getOrCreateAlbum(string $albumName, array $artistData): array
{
if (!$this->isDatabaseAvailable()) return [];
$albumKey = $this->generateAlbumKey($artistData["name_string"], $albumName);
if (isset($this->albumCache[$albumKey])) return $this->albumCache[$albumKey];
$encodedAlbumKey = rawurlencode($albumKey);
$existingAlbum = $this->fetchFromApi("albums", "key=eq.{$encodedAlbumKey}&limit=1");
if (!empty($existingAlbum)) return $this->albumCache[$albumKey] = $existingAlbum[0];
$artistId = $artistData["id"] ?? null;
if (!$artistId) {
error_log("Artist ID missing for album creation: " . $albumName);
return [];
exit();
}
}
$response = $this->makeRequest("POST", "albums", [
"json" => [
"mbid" => null,
"art" => "4cef75db-831f-4f5d-9333-79eaa5bb55ee",
"key" => $albumKey,
"name" => $albumName,
"tentative" => true,
"total_plays" => 0,
"artist" => $artistId
],
"headers" => ["Prefer" => "return=representation"]
]);
public function runScrobbleCheck(): void
{
$recentTracks = $this->fetchRecentlyPlayed();
$inserted = $response[0] ?? null;
if ($inserted) $this->sendFailureEmail("New tentative album record", "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey");
if (empty($recentTracks)) {
return;
}
return $this->albumCache[$albumKey] = $inserted ?? [];
}
foreach ($recentTracks as $track) {
if ($this->isTrackAlreadyScrobbled($track)) {
continue;
}
private function insertListen(array $track, string $albumKey): void
{
$payload = [
"artist_name" => $track["artist"],
"album_name" => $track["album"],
"track_name" => $track["title"],
"album_key" => $albumKey
];
if (!empty($track["playDate"])) {
$playDate = strtotime($track["playDate"]);
if ($playDate !== false) $payload["listened_at"] = $playDate;
$this->handleTrackScrobble($track);
}
}
if (!isset($payload["listened_at"])) {
error_log("Skipping track due to missing or invalid listened_at: " . json_encode($track));
return;
private function fetchRecentlyPlayed(): array
{
$client = new Client();
try {
$response = $client->request('GET', "{$this->navidromeApiUrl}/api/song", [
'query' => [
'_end' => 20,
'_order' => 'DESC',
'_sort' => 'play_date',
'_start' => 0,
'recently_played' => 'true',
],
'headers' => [
'x-nd-authorization' => "Bearer {$this->navidromeAuthToken}",
'Accept' => 'application/json',
],
]);
$data = json_decode($response->getBody()->getContents(), true);
return $data ?? [];
} catch (\Exception $e) {
error_log('Error fetching tracks: '.$e->getMessage());
return [];
}
}
$this->makeRequest("POST", "listens", ["json" => $payload]);
}
private function isTrackAlreadyScrobbled(array $track): bool
{
$playDateString = $track['playDate'] ?? null;
private function generateAlbumKey(string $artistName, string $albumName): string
{
$artistKey = sanitizeMediaString($artistName);
$albumKey = sanitizeMediaString($albumName);
if (! $playDateString) {
return false;
}
return "{$artistKey}-{$albumKey}";
}
$playDate = strtotime($playDateString);
private function sendFailureEmail(string $subject, string $message): void
{
if (!$this->isDatabaseAvailable()) return;
if ($playDate === false) {
return false;
}
$authHeader = "Basic " . base64_encode($this->forwardEmailApiKey . ":");
$client = new Client(["base_uri" => "https://api.forwardemail.net/"]);
$existingListen = $this->fetchFromApi('listens', "listened_at=eq.{$playDate}&limit=1");
try {
$client->post("v1/emails", [
"headers" => [
"Authorization" => $authHeader,
"Content-Type" => "application/x-www-form-urlencoded",
],
"form_params" => [
"from" => "coryd.dev <hi@admin.coryd.dev>",
"to" => "hi@coryd.dev",
"subject" => $subject,
"text" => $message,
],
]);
} catch (\GuzzleHttp\Exception\RequestException $e) {
error_log("Request Exception: " . $e->getMessage());
if ($e->hasResponse()) error_log("Error Response: " . (string) $e->getResponse()->getBody());
} catch (\Exception $e) {
error_log("General Exception: " . $e->getMessage());
return ! empty($existingListen);
}
}
private function isDatabaseAvailable(): bool
{
try {
$response = $this->fetchFromApi("listens", "limit=1");
private function handleTrackScrobble(array $track): void
{
$artistData = $this->getOrCreateArtist($track['artist']);
return is_array($response);
} catch (\Exception $e) {
error_log("Database check failed: " . $e->getMessage());
if (empty($artistData)) {
error_log('Failed to retrieve or create artist: '.$track['artist']);
return false;
return;
}
$albumData = $this->getOrCreateAlbum($track['album'], $artistData);
if (empty($albumData)) {
error_log('Failed to retrieve or create album: '.$track['album']);
return;
}
$this->insertListen($track, $albumData['key']);
}
private function getOrCreateArtist(string $artistName): array
{
if (! $this->isDatabaseAvailable()) {
return [];
}
if (isset($this->artistCache[$artistName])) {
return $this->artistCache[$artistName];
}
$encodedArtist = rawurlencode($artistName);
$existingArtist = $this->fetchFromApi('artists', "name_string=eq.{$encodedArtist}&limit=1");
if (! empty($existingArtist)) {
return $this->artistCache[$artistName] = $existingArtist[0];
}
$response = $this->makeRequest('POST', 'artists', [
'json' => [
'mbid' => '',
'art' => '4cef75db-831f-4f5d-9333-79eaa5bb55ee',
'name_string' => $artistName,
'slug' => '/music',
'country' => '',
'description' => '',
'tentative' => true,
'favorite' => false,
'tattoo' => false,
'total_plays' => 0,
],
'headers' => ['Prefer' => 'return=representation'],
]);
$inserted = $response[0] ?? null;
if ($inserted) {
$this->sendFailureEmail('New tentative artist record', "A new tentative artist record was inserted for: $artistName");
}
return $this->artistCache[$artistName] = $inserted ?? [];
}
private function getOrCreateAlbum(string $albumName, array $artistData): array
{
if (! $this->isDatabaseAvailable()) {
return [];
}
$albumKey = $this->generateAlbumKey($artistData['name_string'], $albumName);
if (isset($this->albumCache[$albumKey])) {
return $this->albumCache[$albumKey];
}
$encodedAlbumKey = rawurlencode($albumKey);
$existingAlbum = $this->fetchFromApi('albums', "key=eq.{$encodedAlbumKey}&limit=1");
if (! empty($existingAlbum)) {
return $this->albumCache[$albumKey] = $existingAlbum[0];
}
$artistId = $artistData['id'] ?? null;
if (! $artistId) {
error_log('Artist ID missing for album creation: '.$albumName);
return [];
}
$response = $this->makeRequest('POST', 'albums', [
'json' => [
'mbid' => null,
'art' => '4cef75db-831f-4f5d-9333-79eaa5bb55ee',
'key' => $albumKey,
'name' => $albumName,
'tentative' => true,
'total_plays' => 0,
'artist' => $artistId,
],
'headers' => ['Prefer' => 'return=representation'],
]);
$inserted = $response[0] ?? null;
if ($inserted) {
$this->sendFailureEmail('New tentative album record', "A new tentative album record was inserted:\n\nAlbum: $albumName\nKey: $albumKey");
}
return $this->albumCache[$albumKey] = $inserted ?? [];
}
private function insertListen(array $track, string $albumKey): void
{
$payload = [
'artist_name' => $track['artist'],
'album_name' => $track['album'],
'track_name' => $track['title'],
'album_key' => $albumKey,
];
if (! empty($track['playDate'])) {
$playDate = strtotime($track['playDate']);
if ($playDate !== false) {
$payload['listened_at'] = $playDate;
}
}
if (! isset($payload['listened_at'])) {
error_log('Skipping track due to missing or invalid listened_at: '.json_encode($track));
return;
}
$this->makeRequest('POST', 'listens', ['json' => $payload]);
}
private function generateAlbumKey(string $artistName, string $albumName): string
{
$artistKey = sanitizeMediaString($artistName);
$albumKey = sanitizeMediaString($albumName);
return "{$artistKey}-{$albumKey}";
}
private function sendFailureEmail(string $subject, string $message): void
{
if (! $this->isDatabaseAvailable()) {
return;
}
$authHeader = 'Basic '.base64_encode($this->forwardEmailApiKey.':');
$client = new Client(['base_uri' => 'https://api.forwardemail.net/']);
try {
$client->post('v1/emails', [
'headers' => [
'Authorization' => $authHeader,
'Content-Type' => 'application/x-www-form-urlencoded',
],
'form_params' => [
'from' => 'coryd.dev <hi@admin.coryd.dev>',
'to' => 'hi@coryd.dev',
'subject' => $subject,
'text' => $message,
],
]);
} catch (\GuzzleHttp\Exception\RequestException $e) {
error_log('Request Exception: '.$e->getMessage());
if ($e->hasResponse()) {
error_log('Error Response: '.(string) $e->getResponse()->getBody());
}
} catch (\Exception $e) {
error_log('General Exception: '.$e->getMessage());
}
}
private function isDatabaseAvailable(): bool
{
try {
$response = $this->fetchFromApi('listens', 'limit=1');
return is_array($response);
} catch (\Exception $e) {
error_log('Database check failed: '.$e->getMessage());
return false;
}
}
}
}
try {
$handler = new NavidromeScrobbleHandler();
$handler->runScrobbleCheck();
$handler = new NavidromeScrobbleHandler();
$handler->runScrobbleCheck();
} catch (\Exception $e) {
http_response_code(500);
http_response_code(500);
echo json_encode(["error" => $e->getMessage()]);
echo json_encode(['error' => $e->getMessage()]);
}

View file

@ -1,150 +1,164 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\BaseHandler;
class SearchHandler extends BaseHandler
{
protected int $cacheTTL = 300;
protected int $cacheTTL = 300;
public function __construct()
{
parent::__construct();
$this->initializeCache();
}
public function handleRequest(): void
{
try {
$query = $this->validateAndSanitizeQuery($_GET["q"] ?? null);
$sections = $this->validateAndSanitizeSections($_GET["section"] ?? "");
$page = isset($_GET["page"]) ? intval($_GET["page"]) : 1;
$pageSize = isset($_GET["pageSize"]) ? intval($_GET["pageSize"]) : 10;
$offset = ($page - 1) * $pageSize;
$cacheKey = $this->generateCacheKey($query, $sections, $page, $pageSize);
$results = [];
$results = $this->getCachedResults($cacheKey) ?? $this->fetchSearchResults($query, $sections, $pageSize, $offset);
if (empty($results) || empty($results["data"])) {
$this->sendResponse(["results" => [], "total" => 0, "page" => $page, "pageSize" => $pageSize], 200);
return;
}
$this->cacheResults($cacheKey, $results);
$this->sendResponse(
[
"results" => $results["data"],
"total" => $results["total"],
"page" => $page,
"pageSize" => $pageSize,
],
200
);
} catch (\Exception $e) {
error_log("Search API Error: " . $e->getMessage());
$this->sendErrorResponse("Invalid request. Please check your query and try again.", 400);
public function __construct()
{
parent::__construct();
$this->initializeCache();
}
}
private function validateAndSanitizeQuery(?string $query): string
{
if (empty($query) || !is_string($query)) throw new \Exception("Invalid 'q' parameter. Must be a non-empty string.");
public function handleRequest(): void
{
try {
$query = $this->validateAndSanitizeQuery($_GET['q'] ?? null);
$sections = $this->validateAndSanitizeSections($_GET['section'] ?? '');
$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
$pageSize = isset($_GET['pageSize']) ? intval($_GET['pageSize']) : 10;
$offset = ($page - 1) * $pageSize;
$cacheKey = $this->generateCacheKey($query, $sections, $page, $pageSize);
$results = [];
$results = $this->getCachedResults($cacheKey) ?? $this->fetchSearchResults($query, $sections, $pageSize, $offset);
$query = trim($query);
if (empty($results) || empty($results['data'])) {
$this->sendResponse(['results' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize], 200);
if (strlen($query) > 255) throw new \Exception("Invalid 'q' parameter. Exceeds maximum length of 255 characters.");
if (!preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) throw new \Exception("Invalid 'q' parameter. Contains unsupported characters.");
return;
}
$query = preg_replace("/\s+/", " ", $query);
return $query;
}
private function validateAndSanitizeSections(string $rawSections): ?array
{
$allowedSections = ["post", "artist", "genre", "book", "movie", "show"];
if (empty($rawSections)) return null;
$sections = array_map(
fn($section) => strtolower(
trim(htmlspecialchars($section, ENT_QUOTES, "UTF-8"))
),
explode(",", $rawSections)
);
$invalidSections = array_diff($sections, $allowedSections);
if (!empty($invalidSections)) throw new Exception("Invalid 'section' parameter. Unsupported sections: " . implode(", ", $invalidSections));
return $sections;
}
private function fetchSearchResults(
string $query,
?array $sections,
int $pageSize,
int $offset
): array {
$sectionsParam = $sections && count($sections) > 0 ? "%7B" . implode(",", $sections) . "%7D" : "";
$endpoint = "rpc/search_optimized_index";
$queryString =
"search_query=" .
urlencode($query) .
"&page_size={$pageSize}&page_offset={$offset}" .
($sectionsParam ? "&sections={$sectionsParam}" : "");
$data = $this->makeRequest("GET", "{$endpoint}?{$queryString}");
$total = count($data) > 0 ? $data[0]["total_count"] : 0;
$results = array_map(function ($item) {
unset($item["total_count"]);
if (!empty($item["description"])) $item["description"] = truncateText(strip_tags(parseMarkdown($item["description"])), 225);
return $item;
}, $data);
return ["data" => $results, "total" => $total];
}
private function generateCacheKey(
string $query,
?array $sections,
int $page,
int $pageSize
): string {
$sectionsKey = $sections ? implode(",", $sections) : "all";
return sprintf(
"search:%s:sections:%s:page:%d:pageSize:%d",
md5($query),
$sectionsKey,
$page,
$pageSize
);
}
private function getCachedResults(string $cacheKey): ?array
{
if ($this->cache instanceof \Redis) {
$cachedData = $this->cache->get($cacheKey);
return $cachedData ? json_decode($cachedData, true) : null;
} elseif (is_array($this->cache)) {
return $this->cache[$cacheKey] ?? null;
$this->cacheResults($cacheKey, $results);
$this->sendResponse(
[
'results' => $results['data'],
'total' => $results['total'],
'page' => $page,
'pageSize' => $pageSize,
],
200
);
} catch (\Exception $e) {
error_log('Search API Error: '.$e->getMessage());
$this->sendErrorResponse('Invalid request. Please check your query and try again.', 400);
}
}
return null;
}
private function cacheResults(string $cacheKey, array $results): void
{
if ($this->cache instanceof \Redis) {
$this->cache->set($cacheKey, json_encode($results));
$this->cache->expire($cacheKey, $this->cacheTTL);
} elseif (is_array($this->cache)) {
$this->cache[$cacheKey] = $results;
private function validateAndSanitizeQuery(?string $query): string
{
if (empty($query) || ! is_string($query)) {
throw new \Exception("Invalid 'q' parameter. Must be a non-empty string.");
}
$query = trim($query);
if (strlen($query) > 255) {
throw new \Exception("Invalid 'q' parameter. Exceeds maximum length of 255 characters.");
}
if (! preg_match('/^[a-zA-Z0-9\s\-_\'"]+$/', $query)) {
throw new \Exception("Invalid 'q' parameter. Contains unsupported characters.");
}
$query = preg_replace("/\s+/", ' ', $query);
return $query;
}
private function validateAndSanitizeSections(string $rawSections): ?array
{
$allowedSections = ['post', 'artist', 'genre', 'book', 'movie', 'show'];
if (empty($rawSections)) {
return null;
}
$sections = array_map(
fn ($section) => strtolower(
trim(htmlspecialchars($section, ENT_QUOTES, 'UTF-8'))
),
explode(',', $rawSections)
);
$invalidSections = array_diff($sections, $allowedSections);
if (! empty($invalidSections)) {
throw new Exception("Invalid 'section' parameter. Unsupported sections: ".implode(', ', $invalidSections));
}
return $sections;
}
private function fetchSearchResults(
string $query,
?array $sections,
int $pageSize,
int $offset
): array {
$sectionsParam = $sections && count($sections) > 0 ? '%7B'.implode(',', $sections).'%7D' : '';
$endpoint = 'rpc/search_optimized_index';
$queryString =
'search_query='.
urlencode($query).
"&page_size={$pageSize}&page_offset={$offset}".
($sectionsParam ? "&sections={$sectionsParam}" : '');
$data = $this->makeRequest('GET', "{$endpoint}?{$queryString}");
$total = count($data) > 0 ? $data[0]['total_count'] : 0;
$results = array_map(function ($item) {
unset($item['total_count']);
if (! empty($item['description'])) {
$item['description'] = truncateText(strip_tags(parseMarkdown($item['description'])), 225);
}
return $item;
}, $data);
return ['data' => $results, 'total' => $total];
}
private function generateCacheKey(
string $query,
?array $sections,
int $page,
int $pageSize
): string {
$sectionsKey = $sections ? implode(',', $sections) : 'all';
return sprintf(
'search:%s:sections:%s:page:%d:pageSize:%d',
md5($query),
$sectionsKey,
$page,
$pageSize
);
}
private function getCachedResults(string $cacheKey): ?array
{
if ($this->cache instanceof \Redis) {
$cachedData = $this->cache->get($cacheKey);
return $cachedData ? json_decode($cachedData, true) : null;
} elseif (is_array($this->cache)) {
return $this->cache[$cacheKey] ?? null;
}
return null;
}
private function cacheResults(string $cacheKey, array $results): void
{
if ($this->cache instanceof \Redis) {
$this->cache->set($cacheKey, json_encode($results));
$this->cache->expire($cacheKey, $this->cacheTTL);
} elseif (is_array($this->cache)) {
$this->cache[$cacheKey] = $results;
}
}
}
}
$handler = new SearchHandler();

View file

@ -1,168 +1,196 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class SeasonImportHandler extends ApiHandler
{
private string $tmdbApiKey;
private string $seasonsImportToken;
private string $tmdbApiKey;
public function __construct()
{
parent::__construct();
private string $seasonsImportToken;
$this->ensureCliAccess();
$this->tmdbApiKey = getenv("TMDB_API_KEY") ?: $_ENV["TMDB_API_KEY"];
$this->seasonsImportToken = getenv("SEASONS_IMPORT_TOKEN") ?: $_ENV["SEASONS_IMPORT_TOKEN"];
$this->authenticateRequest();
}
public function __construct()
{
parent::__construct();
private function authenticateRequest(): void
{
if ($_SERVER["REQUEST_METHOD"] !== "POST") $this->sendErrorResponse("Method Not Allowed", 405);
$authHeader = $_SERVER["HTTP_AUTHORIZATION"] ?? "";
if (!preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) $this->sendErrorResponse("Unauthorized", 401);
$providedToken = trim($matches[1]);
if ($providedToken !== $this->seasonsImportToken) $this->sendErrorResponse("Forbidden", 403);
}
public function importSeasons(): void
{
$ongoingShows = $this->fetchFromApi("optimized_shows", "ongoing=eq.true");
if (empty($ongoingShows)) $this->sendResponse(["message" => "No ongoing shows to update"], 200);
foreach ($ongoingShows as $show) {
$this->processShowSeasons($show);
$this->ensureCliAccess();
$this->tmdbApiKey = getenv('TMDB_API_KEY') ?: $_ENV['TMDB_API_KEY'];
$this->seasonsImportToken = getenv('SEASONS_IMPORT_TOKEN') ?: $_ENV['SEASONS_IMPORT_TOKEN'];
$this->authenticateRequest();
}
$this->sendResponse(["message" => "Season import completed"], 200);
}
private function authenticateRequest(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->sendErrorResponse('Method Not Allowed', 405);
}
private function processShowSeasons(array $show): void
{
$tmdbId = $show["tmdb_id"] ?? null;
$showId = $show["id"] ?? null;
$authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!$tmdbId || !$showId) return;
if (! preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
$this->sendErrorResponse('Unauthorized', 401);
}
$tmdbShowData = $this->fetchShowDetails($tmdbId);
$seasons = $tmdbShowData["seasons"] ?? [];
$status = $tmdbShowData["status"] ?? "Unknown";
$providedToken = trim($matches[1]);
if (empty($seasons) && !$this->shouldKeepOngoing($status)) {
$this->disableOngoingStatus($showId);
return;
if ($providedToken !== $this->seasonsImportToken) {
$this->sendErrorResponse('Forbidden', 403);
}
}
foreach ($seasons as $season) {
$this->processSeasonEpisodes($showId, $tmdbId, $season);
public function importSeasons(): void
{
$ongoingShows = $this->fetchFromApi('optimized_shows', 'ongoing=eq.true');
if (empty($ongoingShows)) {
$this->sendResponse(['message' => 'No ongoing shows to update'], 200);
}
foreach ($ongoingShows as $show) {
$this->processShowSeasons($show);
}
$this->sendResponse(['message' => 'Season import completed'], 200);
}
}
private function shouldKeepOngoing(string $status): bool
{
return in_array($status, ["Returning Series", "In Production"]);
}
private function processShowSeasons(array $show): void
{
$tmdbId = $show['tmdb_id'] ?? null;
$showId = $show['id'] ?? null;
private function fetchShowDetails(string $tmdbId): array
{
$client = new Client();
$url = "https://api.themoviedb.org/3/tv/{$tmdbId}?api_key={$this->tmdbApiKey}&append_to_response=seasons";
if (! $tmdbId || ! $showId) {
return;
}
try {
$response = $client->get($url, ["headers" => ["Accept" => "application/json"]]);
$tmdbShowData = $this->fetchShowDetails($tmdbId);
$seasons = $tmdbShowData['seasons'] ?? [];
$status = $tmdbShowData['status'] ?? 'Unknown';
return json_decode($response->getBody(), true) ?? [];
} catch (\Exception $e) {
return [];
if (empty($seasons) && ! $this->shouldKeepOngoing($status)) {
$this->disableOngoingStatus($showId);
return;
}
foreach ($seasons as $season) {
$this->processSeasonEpisodes($showId, $tmdbId, $season);
}
}
}
private function fetchWatchedEpisodes(int $showId): array
{
$episodes = $this->fetchFromApi("optimized_last_watched_episodes", "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1");
if (empty($episodes)) return [];
return [
"season_number" => (int) $episodes[0]["season_number"],
"episode_number" => (int) $episodes[0]["episode_number"],
];
}
private function processSeasonEpisodes(int $showId, string $tmdbId, array $season): void
{
$seasonNumber = $season["season_number"] ?? null;
if ($seasonNumber === null || $seasonNumber == 0) return;
$episodes = $this->fetchSeasonEpisodes($tmdbId, $seasonNumber);
if (empty($episodes)) return;
$watched = $this->fetchWatchedEpisodes($showId);
$lastWatchedSeason = $watched["season_number"] ?? null;
$lastWatchedEpisode = $watched["episode_number"] ?? null;
$scheduled = $this->fetchFromApi(
"optimized_scheduled_episodes",
"show_id=eq.{$showId}&season_number=eq.{$seasonNumber}"
);
$scheduledEpisodeNumbers = array_column($scheduled, "episode_number");
foreach ($episodes as $episode) {
$episodeNumber = $episode["episode_number"] ?? null;
if ($episodeNumber === null) continue;
if (in_array($episodeNumber, $scheduledEpisodeNumbers)) continue;
if ($lastWatchedSeason !== null && $seasonNumber < $lastWatchedSeason) return;
if ($seasonNumber == $lastWatchedSeason && $episodeNumber <= $lastWatchedEpisode) continue;
$this->addEpisodeToSchedule($showId, $seasonNumber, $episode);
private function shouldKeepOngoing(string $status): bool
{
return in_array($status, ['Returning Series', 'In Production']);
}
}
private function fetchSeasonEpisodes(string $tmdbId, int $seasonNumber): array
{
$client = new Client();
$url = "https://api.themoviedb.org/3/tv/{$tmdbId}/season/{$seasonNumber}?api_key={$this->tmdbApiKey}";
private function fetchShowDetails(string $tmdbId): array
{
$client = new Client();
$url = "https://api.themoviedb.org/3/tv/{$tmdbId}?api_key={$this->tmdbApiKey}&append_to_response=seasons";
try {
$response = $client->get($url, ["headers" => ["Accept" => "application/json"]]);
try {
$response = $client->get($url, ['headers' => ['Accept' => 'application/json']]);
return json_decode($response->getBody(), true)["episodes"] ?? [];
} catch (\Exception $e) {
return [];
return json_decode($response->getBody(), true) ?? [];
} catch (\Exception $e) {
return [];
}
}
}
private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void
{
$airDate = $episode["air_date"] ?? null;
private function fetchWatchedEpisodes(int $showId): array
{
$episodes = $this->fetchFromApi('optimized_last_watched_episodes', "show_id=eq.{$showId}&order=last_watched_at.desc&limit=1");
if (!$airDate) return;
if (empty($episodes)) {
return [];
}
$today = date("Y-m-d");
$status = ($airDate < $today) ? "aired" : "upcoming";
$payload = [
"show_id" => $showId,
"season_number" => $seasonNumber,
"episode_number" => $episode["episode_number"],
"air_date" => $airDate,
"status" => $status,
];
return [
'season_number' => (int) $episodes[0]['season_number'],
'episode_number' => (int) $episodes[0]['episode_number'],
];
}
$this->makeRequest("POST", "scheduled_episodes", ["json" => $payload]);
}
private function processSeasonEpisodes(int $showId, string $tmdbId, array $season): void
{
$seasonNumber = $season['season_number'] ?? null;
if ($seasonNumber === null || $seasonNumber == 0) {
return;
}
$episodes = $this->fetchSeasonEpisodes($tmdbId, $seasonNumber);
if (empty($episodes)) {
return;
}
$watched = $this->fetchWatchedEpisodes($showId);
$lastWatchedSeason = $watched['season_number'] ?? null;
$lastWatchedEpisode = $watched['episode_number'] ?? null;
$scheduled = $this->fetchFromApi(
'optimized_scheduled_episodes',
"show_id=eq.{$showId}&season_number=eq.{$seasonNumber}"
);
$scheduledEpisodeNumbers = array_column($scheduled, 'episode_number');
foreach ($episodes as $episode) {
$episodeNumber = $episode['episode_number'] ?? null;
if ($episodeNumber === null) {
continue;
}
if (in_array($episodeNumber, $scheduledEpisodeNumbers)) {
continue;
}
if ($lastWatchedSeason !== null && $seasonNumber < $lastWatchedSeason) {
return;
}
if ($seasonNumber == $lastWatchedSeason && $episodeNumber <= $lastWatchedEpisode) {
continue;
}
$this->addEpisodeToSchedule($showId, $seasonNumber, $episode);
}
}
private function fetchSeasonEpisodes(string $tmdbId, int $seasonNumber): array
{
$client = new Client();
$url = "https://api.themoviedb.org/3/tv/{$tmdbId}/season/{$seasonNumber}?api_key={$this->tmdbApiKey}";
try {
$response = $client->get($url, ['headers' => ['Accept' => 'application/json']]);
return json_decode($response->getBody(), true)['episodes'] ?? [];
} catch (\Exception $e) {
return [];
}
}
private function addEpisodeToSchedule(int $showId, int $seasonNumber, array $episode): void
{
$airDate = $episode['air_date'] ?? null;
if (! $airDate) {
return;
}
$today = date('Y-m-d');
$status = ($airDate < $today) ? 'aired' : 'upcoming';
$payload = [
'show_id' => $showId,
'season_number' => $seasonNumber,
'episode_number' => $episode['episode_number'],
'air_date' => $airDate,
'status' => $status,
];
$this->makeRequest('POST', 'scheduled_episodes', ['json' => $payload]);
}
}
$handler = new SeasonImportHandler();

View file

@ -1,21 +1,21 @@
<?php
$umamiHost = 'https://stats.coryd.dev';
$requestUri = $_SERVER['REQUEST_URI'];
$method = $_SERVER['REQUEST_METHOD'];
$proxyPrefix = '/assets/scripts';
$forwardPath = parse_url($requestUri, PHP_URL_PATH);
$forwardPath = str_replace($proxyPrefix, '', $forwardPath);
$targetUrl = $umamiHost . $forwardPath;
$umamiHost = 'https://stats.coryd.dev';
$requestUri = $_SERVER['REQUEST_URI'];
$method = $_SERVER['REQUEST_METHOD'];
$proxyPrefix = '/assets/scripts';
$forwardPath = parse_url($requestUri, PHP_URL_PATH);
$forwardPath = str_replace($proxyPrefix, '', $forwardPath);
$targetUrl = $umamiHost.$forwardPath;
if (isset($contentType) && preg_match('#^(application/json|text/|application/javascript)#', $contentType)) {
if (isset($contentType) && preg_match('#^(application/json|text/|application/javascript)#', $contentType)) {
ob_start('ob_gzhandler');
} else {
} else {
ob_start();
}
}
if ($method === 'GET' && preg_match('#^/utils\.js$#', $forwardPath)) {
$remoteUrl = $umamiHost . '/script.js';
if ($method === 'GET' && preg_match('#^/utils\.js$#', $forwardPath)) {
$remoteUrl = $umamiHost.'/script.js';
$cacheKey = 'remote_stats_script';
$ttl = 3600;
$js = null;
@ -23,73 +23,83 @@
$redis = null;
try {
if (extension_loaded('redis')) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
if (extension_loaded('redis')) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
if ($redis->exists($cacheKey)) $js = $redis->get($cacheKey);
}
if ($redis->exists($cacheKey)) {
$js = $redis->get($cacheKey);
}
}
} catch (Exception $e) {
error_log("Redis unavailable: " . $e->getMessage());
error_log('Redis unavailable: '.$e->getMessage());
}
if (!is_string($js)) {
$ch = curl_init($remoteUrl);
if (! is_string($js)) {
$ch = curl_init($remoteUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, false);
$js = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$js = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
curl_close($ch);
if ($redis && $code === 200 && $js) $redis->setex($cacheKey, $ttl, $js);
if ($redis && $code === 200 && $js) {
$redis->setex($cacheKey, $ttl, $js);
}
}
if (!is_string($js) || trim($js) === '') {
$js = '// Failed to fetch remote script';
$code = 502;
if (! is_string($js) || trim($js) === '') {
$js = '// Failed to fetch remote script';
$code = 502;
}
http_response_code($code);
header('Content-Type: application/javascript; charset=UTF-8');
echo $js;
exit;
}
}
$headers = [
$headers = [
'Content-Type: application/json',
'Accept: application/json',
];
];
if (isset($_SERVER['HTTP_USER_AGENT'])) $headers[] = 'User-Agent: ' . $_SERVER['HTTP_USER_AGENT'];
if (isset($_SERVER['HTTP_USER_AGENT'])) {
$headers[] = 'User-Agent: '.$_SERVER['HTTP_USER_AGENT'];
}
$ch = curl_init($targetUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$ch = curl_init($targetUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
if ($method === 'POST') {
if ($method === 'POST') {
$body = file_get_contents('php://input');
$data = json_decode($body, true);
if (strpos($forwardPath, '/api/send') === 0 && is_array($data)) $data['payload'] = array_merge($data['payload'] ?? [], [
'ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
]);
if (strpos($forwardPath, '/api/send') === 0 && is_array($data)) {
$data['payload'] = array_merge($data['payload'] ?? [], [
'ip' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
]);
}
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
} else {
} else {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
}
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
http_response_code($httpCode);
curl_close($ch);
http_response_code($httpCode);
if ($contentType) header("Content-Type: $contentType");
if ($contentType) {
header("Content-Type: $contentType");
}
echo $response ?: '';
echo $response ?: '';

View file

@ -1,140 +1,151 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
require_once __DIR__.'/../bootstrap.php';
use App\Classes\ApiHandler;
use GuzzleHttp\Client;
class WatchingImportHandler extends ApiHandler
{
private string $tmdbApiKey;
private string $tmdbImportToken;
private string $tmdbApiKey;
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
private string $tmdbImportToken;
$this->tmdbApiKey = $_ENV["TMDB_API_KEY"] ?? getenv("TMDB_API_KEY");
$this->tmdbImportToken = $_ENV["WATCHING_IMPORT_TOKEN"] ?? getenv("WATCHING_IMPORT_TOKEN");
}
public function __construct()
{
parent::__construct();
$this->ensureCliAccess();
public function handleRequest(): void
{
$input = json_decode(file_get_contents("php://input"), true);
if (!$input) $this->sendErrorResponse("Invalid or missing JSON body", 400);
$providedToken = $input["token"] ?? null;
$tmdbId = $input["tmdb_id"] ?? null;
$mediaType = $input["media_type"] ?? null;
if ($providedToken !== $this->tmdbImportToken) {
$this->sendErrorResponse("Unauthorized access", 401);
$this->tmdbApiKey = $_ENV['TMDB_API_KEY'] ?? getenv('TMDB_API_KEY');
$this->tmdbImportToken = $_ENV['WATCHING_IMPORT_TOKEN'] ?? getenv('WATCHING_IMPORT_TOKEN');
}
if (!$tmdbId || !$mediaType) {
$this->sendErrorResponse("tmdb_id and media_type are required", 400);
public function handleRequest(): void
{
$input = json_decode(file_get_contents('php://input'), true);
if (! $input) {
$this->sendErrorResponse('Invalid or missing JSON body', 400);
}
$providedToken = $input['token'] ?? null;
$tmdbId = $input['tmdb_id'] ?? null;
$mediaType = $input['media_type'] ?? null;
if ($providedToken !== $this->tmdbImportToken) {
$this->sendErrorResponse('Unauthorized access', 401);
}
if (! $tmdbId || ! $mediaType) {
$this->sendErrorResponse('tmdb_id and media_type are required', 400);
}
try {
$mediaData = $this->fetchTMDBData($tmdbId, $mediaType);
$this->processMedia($mediaData, $mediaType);
$this->sendResponse(['message' => 'Media imported successfully'], 200);
} catch (\Exception $e) {
$this->sendErrorResponse('Error: '.$e->getMessage(), 500);
}
}
try {
$mediaData = $this->fetchTMDBData($tmdbId, $mediaType);
$this->processMedia($mediaData, $mediaType);
$this->sendResponse(["message" => "Media imported successfully"], 200);
} catch (\Exception $e) {
$this->sendErrorResponse("Error: " . $e->getMessage(), 500);
}
}
private function fetchTMDBData(string $tmdbId, string $mediaType): array
{
$client = new Client();
$url = "https://api.themoviedb.org/3/{$mediaType}/{$tmdbId}";
private function fetchTMDBData(string $tmdbId, string $mediaType): array
{
$client = new Client();
$url = "https://api.themoviedb.org/3/{$mediaType}/{$tmdbId}";
$response = $client->get($url, [
'query' => ['api_key' => $this->tmdbApiKey],
'headers' => ['Accept' => 'application/json'],
]);
$response = $client->get($url, [
"query" => ["api_key" => $this->tmdbApiKey],
"headers" => ["Accept" => "application/json"],
]);
$data = json_decode($response->getBody(), true);
if (empty($data)) {
throw new \Exception("No data found for TMDB ID: {$tmdbId}");
}
$data = json_decode($response->getBody(), true);
if (empty($data)) throw new \Exception("No data found for TMDB ID: {$tmdbId}");
return $data;
}
private function processMedia(array $mediaData, string $mediaType): void
{
$tagline = $mediaData["tagline"] ?? null;
$overview = $mediaData["overview"] ?? null;
$description = "";
if (!empty($tagline)) $description .= "> " . trim($tagline) . "\n\n";
if (!empty($overview)) $description .= trim($overview);
$id = $mediaData["id"];
$title = $mediaType === "movie" ? $mediaData["title"] : $mediaData["name"];
$year = $mediaData["release_date"] ?? $mediaData["first_air_date"] ?? null;
$year = $year ? substr($year, 0, 4) : null;
$tags = array_map(
fn($genre) => strtolower(trim($genre["name"])),
$mediaData["genres"] ?? []
);
$slug = $mediaType === "movie"
? "/watching/movies/{$id}"
: "/watching/shows/{$id}";
$payload = [
"title" => $title,
"year" => $year,
"description" => $description,
"tmdb_id" => $id,
"slug" => $slug,
];
$table = $mediaType === "movie" ? "movies" : "shows";
try {
$response = $this->makeRequest("POST", $table, [
"json" => $payload,
"headers" => ["Prefer" => "return=representation"]
]);
} catch (\Exception $e) {
$response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? [];
return $data;
}
$record = $response[0] ?? [];
private function processMedia(array $mediaData, string $mediaType): void
{
$tagline = $mediaData['tagline'] ?? null;
$overview = $mediaData['overview'] ?? null;
$description = '';
if (!empty($record["id"])) {
$mediaId = $record["id"];
$tagIds = $this->getTagIds($tags);
if (!empty($tagIds)) $this->associateTagsWithMedia($mediaType, $mediaId, array_values($tagIds));
}
}
if (! empty($tagline)) {
$description .= '> '.trim($tagline)."\n\n";
}
if (! empty($overview)) {
$description .= trim($overview);
}
private function getTagIds(array $tags): array
{
$map = [];
$id = $mediaData['id'];
$title = $mediaType === 'movie' ? $mediaData['title'] : $mediaData['name'];
$year = $mediaData['release_date'] ?? $mediaData['first_air_date'] ?? null;
$year = $year ? substr($year, 0, 4) : null;
$tags = array_map(
fn ($genre) => strtolower(trim($genre['name'])),
$mediaData['genres'] ?? []
);
$slug = $mediaType === 'movie'
? "/watching/movies/{$id}"
: "/watching/shows/{$id}";
$payload = [
'title' => $title,
'year' => $year,
'description' => $description,
'tmdb_id' => $id,
'slug' => $slug,
];
$table = $mediaType === 'movie' ? 'movies' : 'shows';
foreach ($tags as $tag) {
$response = $this->fetchFromApi("tags", "name=ilike." . urlencode($tag));
if (!empty($response[0]["id"])) {
$map[strtolower($tag)] = $response[0]["id"];
}
try {
$response = $this->makeRequest('POST', $table, [
'json' => $payload,
'headers' => ['Prefer' => 'return=representation'],
]);
} catch (\Exception $e) {
$response = $this->fetchFromApi($table, "tmdb_id=eq.{$id}")[0] ?? [];
}
$record = $response[0] ?? [];
if (! empty($record['id'])) {
$mediaId = $record['id'];
$tagIds = $this->getTagIds($tags);
if (! empty($tagIds)) {
$this->associateTagsWithMedia($mediaType, $mediaId, array_values($tagIds));
}
}
}
return $map;
}
private function getTagIds(array $tags): array
{
$map = [];
private function associateTagsWithMedia(string $mediaType, int $mediaId, array $tagIds): void
{
$junction = $mediaType === "movie" ? "movies_tags" : "shows_tags";
$mediaColumn = $mediaType === "movie" ? "movies_id" : "shows_id";
foreach ($tags as $tag) {
$response = $this->fetchFromApi('tags', 'name=ilike.'.urlencode($tag));
if (! empty($response[0]['id'])) {
$map[strtolower($tag)] = $response[0]['id'];
}
}
foreach ($tagIds as $tagId) {
$this->makeRequest("POST", $junction, ["json" => [
$mediaColumn => $mediaId,
"tags_id" => $tagId
]]);
return $map;
}
private function associateTagsWithMedia(string $mediaType, int $mediaId, array $tagIds): void
{
$junction = $mediaType === 'movie' ? 'movies_tags' : 'shows_tags';
$mediaColumn = $mediaType === 'movie' ? 'movies_id' : 'shows_id';
foreach ($tagIds as $tagId) {
$this->makeRequest('POST', $junction, ['json' => [
$mediaColumn => $mediaId,
'tags_id' => $tagId,
]]);
}
}
}
}
$handler = new WatchingImportHandler();

View file

@ -1,11 +1,13 @@
<?php
namespace App\Classes;
namespace App\Classes;
abstract class ApiHandler extends BaseHandler
{
abstract class ApiHandler extends BaseHandler
{
protected function ensureCliAccess(): void
{
if (php_sapi_name() !== 'cli' && $_SERVER['REQUEST_METHOD'] !== 'POST') redirectTo404();
if (php_sapi_name() !== 'cli' && $_SERVER['REQUEST_METHOD'] !== 'POST') {
redirectTo404();
}
}
}
}

View file

@ -1,24 +1,28 @@
<?php
namespace App\Classes;
namespace App\Classes;
class ArtistFetcher extends PageFetcher
{
class ArtistFetcher extends PageFetcher
{
public function fetch(string $url): ?array
{
$cacheKey = "artist_" . md5($url);
$cached = $this->cacheGet($cacheKey);
$cacheKey = 'artist_'.md5($url);
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$artist = $this->fetchSingleFromApi("optimized_artists", $url);
$artist = $this->fetchSingleFromApi('optimized_artists', $url);
if (!$artist) return null;
if (! $artist) {
return null;
}
$artist['globals'] = $this->getGlobals();
$artist['globals'] = $this->getGlobals();
$this->cacheSet($cacheKey, $artist);
$this->cacheSet($cacheKey, $artist);
return $artist;
return $artist;
}
}
}

View file

@ -1,100 +1,106 @@
<?php
namespace App\Classes;
namespace App\Classes;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
abstract class BaseHandler
{
abstract class BaseHandler
{
protected string $postgrestUrl;
protected string $postgrestApiKey;
protected ?\Redis $cache = null;
public function __construct()
{
$this->loadEnvironment();
$this->initializeCache();
$this->loadEnvironment();
$this->initializeCache();
}
private function loadEnvironment(): void
{
$this->postgrestUrl = $_ENV["POSTGREST_URL"] ?? getenv("POSTGREST_URL") ?? "";
$this->postgrestApiKey = $_ENV["POSTGREST_API_KEY"] ?? getenv("POSTGREST_API_KEY") ?? "";
$this->postgrestUrl = $_ENV['POSTGREST_URL'] ?? getenv('POSTGREST_URL') ?? '';
$this->postgrestApiKey = $_ENV['POSTGREST_API_KEY'] ?? getenv('POSTGREST_API_KEY') ?? '';
}
protected function initializeCache(): void
{
if (class_exists("Redis")) {
try {
$redis = new \Redis();
$redis->connect("127.0.0.1", 6379);
if (class_exists('Redis')) {
try {
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$this->cache = $redis;
} catch (\Exception $e) {
error_log("Redis connection failed: " . $e->getMessage());
$this->cache = $redis;
} catch (\Exception $e) {
error_log('Redis connection failed: '.$e->getMessage());
$this->cache = null;
$this->cache = null;
}
} else {
error_log('Redis extension not found — caching disabled.');
$this->cache = null;
}
} else {
error_log("Redis extension not found — caching disabled.");
$this->cache = null;
}
}
protected function makeRequest(string $method, string $endpoint, array $options = []): array
{
$client = new Client();
$url = rtrim($this->postgrestUrl, "/") . "/" . ltrim($endpoint, "/");
$client = new Client();
$url = rtrim($this->postgrestUrl, '/').'/'.ltrim($endpoint, '/');
try {
$response = $client->request($method, $url, array_merge_recursive([
"headers" => [
"Authorization" => "Bearer {$this->postgrestApiKey}",
"Content-Type" => "application/json",
]
], $options));
try {
$response = $client->request($method, $url, array_merge_recursive([
'headers' => [
'Authorization' => "Bearer {$this->postgrestApiKey}",
'Content-Type' => 'application/json',
],
], $options));
$responseBody = $response->getBody()->getContents();
$responseBody = $response->getBody()->getContents();
if (empty($responseBody)) return [];
if (empty($responseBody)) {
return [];
}
$data = json_decode($responseBody, true);
$data = json_decode($responseBody, true);
if (json_last_error() !== JSON_ERROR_NONE) throw new \Exception("Invalid JSON: " . json_last_error_msg());
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Invalid JSON: '.json_last_error_msg());
}
return $data;
} catch (RequestException $e) {
$response = $e->getResponse();
$statusCode = $response ? $response->getStatusCode() : 'N/A';
$responseBody = $response ? $response->getBody()->getContents() : 'No response';
return $data;
} catch (RequestException $e) {
$response = $e->getResponse();
$statusCode = $response ? $response->getStatusCode() : 'N/A';
$responseBody = $response ? $response->getBody()->getContents() : 'No response';
throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}");
} catch (\Exception $e) {
throw new \Exception("Request error: " . $e->getMessage());
}
throw new \Exception("HTTP {$method} {$url} failed with status {$statusCode}: {$responseBody}");
} catch (\Exception $e) {
throw new \Exception('Request error: '.$e->getMessage());
}
}
protected function fetchFromApi(string $endpoint, string $query = ""): array
protected function fetchFromApi(string $endpoint, string $query = ''): array
{
$url = $endpoint . ($query ? "?{$query}" : "");
$url = $endpoint.($query ? "?{$query}" : '');
return $this->makeRequest("GET", $url);
return $this->makeRequest('GET', $url);
}
protected function sendResponse(array $data, int $statusCode = 200): void
{
http_response_code($statusCode);
header("Content-Type: application/json");
http_response_code($statusCode);
header('Content-Type: application/json');
echo json_encode($data);
echo json_encode($data);
exit();
exit();
}
protected function sendErrorResponse(string $message, int $statusCode = 500): void
{
$this->sendResponse(["error" => $message], $statusCode);
$this->sendResponse(['error' => $message], $statusCode);
}
}
}

View file

@ -1,24 +1,28 @@
<?php
namespace App\Classes;
namespace App\Classes;
class BookFetcher extends PageFetcher
{
class BookFetcher extends PageFetcher
{
public function fetch(string $url): ?array
{
$cacheKey = "book_" . md5($url);
$cached = $this->cacheGet($cacheKey);
$cacheKey = 'book_'.md5($url);
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$book = $this->fetchSingleFromApi("optimized_books", $url);
$book = $this->fetchSingleFromApi('optimized_books', $url);
if (!$book) return null;
if (! $book) {
return null;
}
$book['globals'] = $this->getGlobals();
$book['globals'] = $this->getGlobals();
$this->cacheSet($cacheKey, $book);
$this->cacheSet($cacheKey, $book);
return $book;
return $book;
}
}
}

View file

@ -1,24 +1,28 @@
<?php
namespace App\Classes;
namespace App\Classes;
class GenreFetcher extends PageFetcher
{
class GenreFetcher extends PageFetcher
{
public function fetch(string $url): ?array
{
$cacheKey = "genre_" . md5($url);
$cached = $this->cacheGet($cacheKey);
$cacheKey = 'genre_'.md5($url);
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$genre = $this->fetchSingleFromApi("optimized_genres", $url);
$genre = $this->fetchSingleFromApi('optimized_genres', $url);
if (!$genre) return null;
if (! $genre) {
return null;
}
$genre['globals'] = $this->getGlobals();
$genre['globals'] = $this->getGlobals();
$this->cacheSet($cacheKey, $genre);
$this->cacheSet($cacheKey, $genre);
return $genre;
return $genre;
}
}
}

View file

@ -1,22 +1,26 @@
<?php
namespace App\Classes;
namespace App\Classes;
class GlobalsFetcher extends PageFetcher
{
class GlobalsFetcher extends PageFetcher
{
public function fetch(): ?array
{
$cacheKey = "globals";
$cached = $this->cacheGet($cacheKey);
$cacheKey = 'globals';
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$globals = $this->fetchFromApi("optimized_globals");
$globals = $this->fetchFromApi('optimized_globals');
if (empty($globals)) return null;
if (empty($globals)) {
return null;
}
$this->cacheSet($cacheKey, $globals[0]);
$this->cacheSet($cacheKey, $globals[0]);
return $globals[0];
return $globals[0];
}
}
}

View file

@ -2,68 +2,73 @@
namespace App\Classes;
use App\Classes\BaseHandler;
class LatestListenHandler extends BaseHandler
{
protected int $cacheTTL = 60;
protected int $cacheTTL = 60;
public function __construct()
{
parent::__construct();
$this->initializeCache();
}
public function handleRequest(): void
{
$data = $this->getLatestListen();
if (!$data) {
$this->sendResponse(["message" => "No recent tracks found"], 404);
return;
public function __construct()
{
parent::__construct();
$this->initializeCache();
}
$this->sendResponse($data);
}
public function handleRequest(): void
{
$data = $this->getLatestListen();
public function getLatestListen(): ?array
{
try {
$cachedData = $this->cache ? $this->cache->get("latest_listen") : null;
if (! $data) {
$this->sendResponse(['message' => 'No recent tracks found'], 404);
if ($cachedData) return json_decode($cachedData, true);
return;
}
$data = $this->makeRequest("GET", "optimized_latest_listen?select=*");
if (!is_array($data) || empty($data[0])) return null;
$latestListen = $this->formatLatestListen($data[0]);
if ($this->cache) $this->cache->set("latest_listen", json_encode($latestListen), $this->cacheTTL);
return $latestListen;
} catch (\Exception $e) {
error_log("LatestListenHandler::getLatestListen error: " . $e->getMessage());
return null;
$this->sendResponse($data);
}
}
private function formatLatestListen(array $latestListen): array
{
$emoji = $latestListen["artist_emoji"] ?? ($latestListen["genre_emoji"] ?? "🎧");
$trackName = htmlspecialchars($latestListen["track_name"] ?? "Unknown Track", ENT_QUOTES, "UTF-8");
$artistName = htmlspecialchars($latestListen["artist_name"] ?? "Unknown Artist", ENT_QUOTES, "UTF-8");
$url = htmlspecialchars($latestListen["url"] ?? "/", ENT_QUOTES, "UTF-8");
public function getLatestListen(): ?array
{
try {
$cachedData = $this->cache ? $this->cache->get('latest_listen') : null;
return [
"content" => sprintf(
'%s %s by <a href="%s">%s</a>',
$emoji,
$trackName,
$url,
$artistName
),
];
}
if ($cachedData) {
return json_decode($cachedData, true);
}
$data = $this->makeRequest('GET', 'optimized_latest_listen?select=*');
if (! is_array($data) || empty($data[0])) {
return null;
}
$latestListen = $this->formatLatestListen($data[0]);
if ($this->cache) {
$this->cache->set('latest_listen', json_encode($latestListen), $this->cacheTTL);
}
return $latestListen;
} catch (\Exception $e) {
error_log('LatestListenHandler::getLatestListen error: '.$e->getMessage());
return null;
}
}
private function formatLatestListen(array $latestListen): array
{
$emoji = $latestListen['artist_emoji'] ?? ($latestListen['genre_emoji'] ?? '🎧');
$trackName = htmlspecialchars($latestListen['track_name'] ?? 'Unknown Track', ENT_QUOTES, 'UTF-8');
$artistName = htmlspecialchars($latestListen['artist_name'] ?? 'Unknown Artist', ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($latestListen['url'] ?? '/', ENT_QUOTES, 'UTF-8');
return [
'content' => sprintf(
'%s %s by <a href="%s">%s</a>',
$emoji,
$trackName,
$url,
$artistName
),
];
}
}

View file

@ -1,24 +1,28 @@
<?php
namespace App\Classes;
namespace App\Classes;
class MovieFetcher extends PageFetcher
{
class MovieFetcher extends PageFetcher
{
public function fetch(string $url): ?array
{
$cacheKey = "movie_" . md5($url);
$cached = $this->cacheGet($cacheKey);
$cacheKey = 'movie_'.md5($url);
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$movie = $this->fetchSingleFromApi("optimized_movies", $url);
$movie = $this->fetchSingleFromApi('optimized_movies', $url);
if (!$movie) return null;
if (! $movie) {
return null;
}
$movie['globals'] = $this->getGlobals();
$movie['globals'] = $this->getGlobals();
$this->cacheSet($cacheKey, $movie);
$this->cacheSet($cacheKey, $movie);
return $movie;
return $movie;
}
}
}

View file

@ -4,23 +4,27 @@ namespace App\Classes;
class MusicDataHandler extends BaseHandler
{
protected int $cacheTTL = 300;
protected int $cacheTTL = 300;
public function getThisWeekData(): array
{
$cacheKey = 'music_week_data';
$cached = $this->cache ? $this->cache->get($cacheKey) : null;
public function getThisWeekData(): array
{
$cacheKey = 'music_week_data';
$cached = $this->cache ? $this->cache->get($cacheKey) : null;
if ($cached) return json_decode($cached, true);
if ($cached) {
return json_decode($cached, true);
}
$response = $this->makeRequest('GET', 'optimized_week_music?select=*');
$music = $response[0]['week_music'] ?? [];
$music['total_tracks'] = $music['week_summary']['total_tracks'] ?? 0;
$music['total_artists'] = $music['week_summary']['total_artists'] ?? 0;
$music['total_albums'] = $music['week_summary']['total_albums'] ?? 0;
$response = $this->makeRequest('GET', 'optimized_week_music?select=*');
$music = $response[0]['week_music'] ?? [];
$music['total_tracks'] = $music['week_summary']['total_tracks'] ?? 0;
$music['total_artists'] = $music['week_summary']['total_artists'] ?? 0;
$music['total_albums'] = $music['week_summary']['total_albums'] ?? 0;
if ($this->cache) $this->cache->set($cacheKey, json_encode($music), $this->cacheTTL);
if ($this->cache) {
$this->cache->set($cacheKey, json_encode($music), $this->cacheTTL);
}
return $music;
}
return $music;
}
}

View file

@ -2,43 +2,44 @@
namespace App\Classes;
use App\Classes\BaseHandler;
use App\Classes\GlobalsFetcher;
abstract class PageFetcher extends BaseHandler
{
protected ?array $globals = null;
protected ?array $globals = null;
protected function cacheGet(string $key): mixed
{
return $this->cache && $this->cache->exists($key) ? json_decode($this->cache->get($key), true) : null;
}
protected function cacheGet(string $key): mixed
{
return $this->cache && $this->cache->exists($key) ? json_decode($this->cache->get($key), true) : null;
}
protected function cacheSet(string $key, mixed $value, int $ttl = 300): void
{
if ($this->cache) $this->cache->setex($key, $ttl, json_encode($value));
}
protected function cacheSet(string $key, mixed $value, int $ttl = 300): void
{
if ($this->cache) {
$this->cache->setex($key, $ttl, json_encode($value));
}
}
protected function fetchSingleFromApi(string $endpoint, string $url): ?array
{
$data = $this->fetchFromApi($endpoint, "url=eq./{$url}");
protected function fetchSingleFromApi(string $endpoint, string $url): ?array
{
$data = $this->fetchFromApi($endpoint, "url=eq./{$url}");
return $data[0] ?? null;
}
return $data[0] ?? null;
}
protected function fetchPostRpc(string $endpoint, array $body): ?array
{
return $this->makeRequest("POST", $endpoint, ['json' => $body]);
}
protected function fetchPostRpc(string $endpoint, array $body): ?array
{
return $this->makeRequest('POST', $endpoint, ['json' => $body]);
}
public function getGlobals(): ?array
{
if ($this->globals !== null) return $this->globals;
public function getGlobals(): ?array
{
if ($this->globals !== null) {
return $this->globals;
}
$fetcher = new GlobalsFetcher();
$fetcher = new GlobalsFetcher();
$this->globals = $fetcher->fetch();
$this->globals = $fetcher->fetch();
return $this->globals;
}
return $this->globals;
}
}

View file

@ -4,33 +4,37 @@ namespace App\Classes;
class RecentMediaHandler extends BaseHandler
{
protected int $cacheTTL = 300;
protected int $cacheTTL = 300;
public function getRecentMedia(): array
{
try {
$cacheKey = 'recent_media';
public function getRecentMedia(): array
{
try {
$cacheKey = 'recent_media';
if ($this->cache) {
$cached = $this->cache->get($cacheKey);
if ($this->cache) {
$cached = $this->cache->get($cacheKey);
if ($cached) return json_decode($cached, true);
}
if ($cached) {
return json_decode($cached, true);
}
}
$response = $this->makeRequest("GET", "optimized_recent_media?select=*");
$activity = $response[0]['recent_activity'] ?? [];
$data = [
'recentMusic' => $activity['recentMusic'] ?? [],
'recentWatchedRead' => $activity['recentWatchedRead'] ?? [],
];
$response = $this->makeRequest('GET', 'optimized_recent_media?select=*');
$activity = $response[0]['recent_activity'] ?? [];
$data = [
'recentMusic' => $activity['recentMusic'] ?? [],
'recentWatchedRead' => $activity['recentWatchedRead'] ?? [],
];
if ($this->cache) $this->cache->set($cacheKey, json_encode($data), $this->cacheTTL);
if ($this->cache) {
$this->cache->set($cacheKey, json_encode($data), $this->cacheTTL);
}
return $data;
} catch (\Exception $e) {
error_log("RecentMediaHandler error: " . $e->getMessage());
return $data;
} catch (\Exception $e) {
error_log('RecentMediaHandler error: '.$e->getMessage());
return ['recentMusic' => [], 'recentWatchedRead' => []];
return ['recentMusic' => [], 'recentWatchedRead' => []];
}
}
}
}

View file

@ -1,24 +1,28 @@
<?php
namespace App\Classes;
namespace App\Classes;
class ShowFetcher extends PageFetcher
{
class ShowFetcher extends PageFetcher
{
public function fetch(string $url): ?array
{
$cacheKey = "show_" . md5($url);
$cached = $this->cacheGet($cacheKey);
$cacheKey = 'show_'.md5($url);
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$show = $this->fetchSingleFromApi("optimized_shows", $url);
$show = $this->fetchSingleFromApi('optimized_shows', $url);
if (!$show) return null;
if (! $show) {
return null;
}
$show['globals'] = $this->getGlobals();
$show['globals'] = $this->getGlobals();
$this->cacheSet($cacheKey, $show);
$this->cacheSet($cacheKey, $show);
return $show;
return $show;
}
}
}

View file

@ -4,26 +4,30 @@ namespace App\Classes;
class TagFetcher extends PageFetcher
{
public function fetch(string $tag, int $page = 1, int $pageSize = 20): ?array
{
$offset = ($page - 1) * $pageSize;
$cacheKey = "tag_" . md5("{$tag}_{$page}");
$cached = $this->cacheGet($cacheKey);
public function fetch(string $tag, int $page = 1, int $pageSize = 20): ?array
{
$offset = ($page - 1) * $pageSize;
$cacheKey = 'tag_'.md5("{$tag}_{$page}");
$cached = $this->cacheGet($cacheKey);
if ($cached) return $cached;
if ($cached) {
return $cached;
}
$results = $this->fetchPostRpc("rpc/get_tagged_content", [
"tag_query" => $tag,
"page_size" => $pageSize,
"page_offset" => $offset
]);
$results = $this->fetchPostRpc('rpc/get_tagged_content', [
'tag_query' => $tag,
'page_size' => $pageSize,
'page_offset' => $offset,
]);
if (!$results || count($results) === 0) return null;
if (! $results || count($results) === 0) {
return null;
}
$results[0]['globals'] = $this->getGlobals();
$results[0]['globals'] = $this->getGlobals();
$this->cacheSet($cacheKey, $results);
$this->cacheSet($cacheKey, $results);
return $results;
}
return $results;
}
}

View file

@ -1,20 +1,20 @@
<?php
if (!function_exists('getTablerIcon')) {
if (! function_exists('getTablerIcon')) {
function getTablerIcon($iconName, $class = '', $size = 24)
{
$icons = [
'arrow-left' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M5 12l6 6" /><path d="M5 12l6 -6" /></svg>',
'arrow-right' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></svg>',
'article' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-article"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 8h10" /><path d="M7 12h10" /><path d="M7 16h10" /></svg>',
'books' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-books"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M9 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M5 8h4" /><path d="M9 16h4" /><path d="M13.803 4.56l2.184 -.53c.562 -.135 1.133 .19 1.282 .732l3.695 13.418a1.02 1.02 0 0 1 -.634 1.219l-.133 .041l-2.184 .53c-.562 .135 -1.133 -.19 -1.282 -.732l-3.695 -13.418a1.02 1.02 0 0 1 .634 -1.219l.133 -.041z" /><path d="M14 9l4 -1" /><path d="M16 16l3.923 -.98" /></svg>',
'device-tv-old' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-device-tv-old"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M16 3l-4 4l-4 -4" /><path d="M15 7v13" /><path d="M18 15v.01" /><path d="M18 12v.01" /></svg>',
'headphones' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-headphones"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M15 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M4 15v-3a8 8 0 0 1 16 0v3" /></svg>',
'movie' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-movie"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M8 4l0 16" /><path d="M16 4l0 16" /><path d="M4 8l4 0" /><path d="M4 16l4 0" /><path d="M4 12l16 0" /><path d="M16 8l4 0" /><path d="M16 16l4 0" /></svg>',
'star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg>',
'vinyl' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-vinyl"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16 3.937a9 9 0 1 0 5 8.063" /><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M20 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M20 4l-3.5 10l-2.5 2" /></svg>'
];
$icons = [
'arrow-left' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M5 12l6 6" /><path d="M5 12l6 -6" /></svg>',
'arrow-right' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 12l14 0" /><path d="M13 18l6 -6" /><path d="M13 6l6 6" /></svg>',
'article' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-article"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 8h10" /><path d="M7 12h10" /><path d="M7 16h10" /></svg>',
'books' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-books"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M9 4m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /><path d="M5 8h4" /><path d="M9 16h4" /><path d="M13.803 4.56l2.184 -.53c.562 -.135 1.133 .19 1.282 .732l3.695 13.418a1.02 1.02 0 0 1 -.634 1.219l-.133 .041l-2.184 .53c-.562 .135 -1.133 -.19 -1.282 -.732l-3.695 -13.418a1.02 1.02 0 0 1 .634 -1.219l.133 -.041z" /><path d="M14 9l4 -1" /><path d="M16 16l3.923 -.98" /></svg>',
'device-tv-old' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-device-tv-old"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M16 3l-4 4l-4 -4" /><path d="M15 7v13" /><path d="M18 15v.01" /><path d="M18 12v.01" /></svg>',
'headphones' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-headphones"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M15 13m0 2a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-1a2 2 0 0 1 -2 -2z" /><path d="M4 15v-3a8 8 0 0 1 16 0v3" /></svg>',
'movie' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-movie"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M8 4l0 16" /><path d="M16 4l0 16" /><path d="M4 8l4 0" /><path d="M4 16l4 0" /><path d="M4 12l16 0" /><path d="M16 8l4 0" /><path d="M16 16l4 0" /></svg>',
'star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-star"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 17.75l-6.172 3.245l1.179 -6.873l-5 -4.867l6.9 -1l3.086 -6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z" /></svg>',
'vinyl' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-vinyl"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M16 3.937a9 9 0 1 0 5 8.063" /><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M20 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M20 4l-3.5 10l-2.5 2" /></svg>',
];
return $icons[$iconName] ?? '<span class="icon-placeholder">[Missing: ' . htmlspecialchars($iconName) . ']</span>';
return $icons[$iconName] ?? '<span class="icon-placeholder">[Missing: '.htmlspecialchars($iconName).']</span>';
}
}
}

View file

@ -1,8 +1,9 @@
<?php
require_once __DIR__ . '/icons.php';
require_once __DIR__ . '/media.php';
require_once __DIR__ . '/metadata.php';
require_once __DIR__ . '/paginator.php';
require_once __DIR__ . '/routing.php';
require_once __DIR__ . '/strings.php';
require_once __DIR__ . '/tags.php';
require_once __DIR__.'/icons.php';
require_once __DIR__.'/media.php';
require_once __DIR__.'/metadata.php';
require_once __DIR__.'/paginator.php';
require_once __DIR__.'/routing.php';
require_once __DIR__.'/strings.php';
require_once __DIR__.'/tags.php';

View file

@ -1,154 +1,167 @@
<?php
if (!function_exists('renderMediaGrid')) {
if (! function_exists('renderMediaGrid')) {
function renderMediaGrid(array $items, int $count = 0, string $loading = 'lazy')
{
$limit = $count > 0 ? $count : count($items);
$firstType = $items[0]['type'] ?? ($items[0]['grid']['type'] ?? '');
$shapeClass = in_array($firstType, ['books', 'movies', 'tv']) ? 'vertical' : 'square';
$limit = $count > 0 ? $count : count($items);
$firstType = $items[0]['type'] ?? ($items[0]['grid']['type'] ?? '');
$shapeClass = in_array($firstType, ['books', 'movies', 'tv']) ? 'vertical' : 'square';
echo '<div class="media-grid ' . $shapeClass . '">';
echo '<div class="media-grid '.$shapeClass.'">';
foreach (array_slice($items, 0, $limit) as $item) {
$grid = $item['grid'] ?? $item;
$alt = htmlspecialchars($grid['alt'] ?? '');
$image = htmlspecialchars($grid['image'] ?? '');
$title = htmlspecialchars($grid['title'] ?? '');
$subtext = htmlspecialchars($grid['subtext'] ?? '');
$url = $grid['url'] ?? null;
$type = $item['type'] ?? '';
$isVertical = in_array($type, ['books', 'movies', 'tv']);
$imageClass = $isVertical ? 'vertical' : 'square';
$width = $isVertical ? 120 : 150;
$height = $isVertical ? 184 : 150;
foreach (array_slice($items, 0, $limit) as $item) {
$grid = $item['grid'] ?? $item;
$alt = htmlspecialchars($grid['alt'] ?? '');
$image = htmlspecialchars($grid['image'] ?? '');
$title = htmlspecialchars($grid['title'] ?? '');
$subtext = htmlspecialchars($grid['subtext'] ?? '');
$url = $grid['url'] ?? null;
$type = $item['type'] ?? '';
$isVertical = in_array($type, ['books', 'movies', 'tv']);
$imageClass = $isVertical ? 'vertical' : 'square';
$width = $isVertical ? 120 : 150;
$height = $isVertical ? 184 : 150;
$openLink = $url ? '<a class="' . htmlspecialchars($type) . '" href="' . htmlspecialchars($url) . '" title="' . $alt . '">' : '';
$closeLink = $url ? '</a>' : '';
$openLink = $url ? '<a class="'.htmlspecialchars($type).'" href="'.htmlspecialchars($url).'" title="'.$alt.'">' : '';
$closeLink = $url ? '</a>' : '';
echo $openLink;
echo '<div class="media-grid-item">';
echo $openLink;
echo '<div class="media-grid-item">';
if ($title || $subtext) {
echo '<div class="meta-text media-highlight">';
if ($title) echo '<div class="header">' . $title . '</div>';
if ($subtext) echo '<div class="subheader">' . $subtext . '</div>';
echo '</div>';
}
echo '<img
srcset="' . $image . '?class=' . $imageClass . 'sm&type=webp ' . $width . 'w, ' .
$image . '?class=' . $imageClass . 'md&type=webp ' . ($width * 2) . 'w"
sizes="(max-width: 450px) ' . $width . 'px, ' . ($width * 2) . 'px"
src="' . $image . '?class=' . $imageClass . 'sm&type=webp"
alt="' . $alt . '"
loading="' . $loading . '"
decoding="async"
width="' . $width . '"
height="' . $height . '"
>';
echo '</div>';
echo $closeLink;
}
echo '</div>';
}
}
if (!function_exists('renderAssociatedMedia')) {
function renderAssociatedMedia(
array $artists = [],
array $books = [],
array $genres = [],
array $movies = [],
array $posts = [],
array $shows = [],
)
{
$sections = [
"artists" => ["icon" => "headphones", "css_class" => "music", "label" => "Related artist(s)", "hasGrid" => true],
"books" => ["icon" => "books", "css_class" => "books", "label" => "Related book(s)", "hasGrid" => true],
"genres" => ["icon" => "headphones", "css_class" => "music", "label" => "Related genre(s)", "hasGrid" => false],
"movies" => ["icon" => "movie", "css_class" => "movies", "label" => "Related movie(s)", "hasGrid" => true],
"posts" => ["icon" => "article", "css_class" => "article", "label" => "Related post(s)", "hasGrid" => false],
"shows" => ["icon" => "device-tv-old", "css_class" => "tv", "label" => "Related show(s)", "hasGrid" => true]
];
$allMedia = compact('artists', 'books', 'genres', 'movies', 'posts', 'shows');
echo '<div class="associated-media">';
foreach ($sections as $key => $section) {
$items = $allMedia[$key];
if (empty($items)) continue;
echo '<h3 id="' . htmlspecialchars($key) . '" class="' . htmlspecialchars($section['css_class']) . '">';
echo getTablerIcon($section['icon']);
echo htmlspecialchars($section['label']);
echo '</h3>';
if ($section['hasGrid']) {
renderMediaGrid($items);
} else {
echo '<ul>';
foreach ($items as $item) {
echo '<li class="' . htmlspecialchars($section['css_class']) . '">';
echo '<a href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title'] ?? $item['name'] ?? '') . '</a>';
if ($key === "artists" && isset($item['total_plays']) && $item['total_plays'] > 0) {
echo ' (' . htmlspecialchars($item['total_plays']) . ' play' . ($item['total_plays'] > 1 ? 's' : '') . ')';
} elseif ($key === "books" && isset($item['author'])) {
echo ' by ' . htmlspecialchars($item['author']);
} elseif (($key === "movies" || $key === "shows") && isset($item['year'])) {
echo ' (' . htmlspecialchars($item['year']) . ')';
} elseif ($key === "posts" && isset($item['date'])) {
echo ' (' . date("F j, Y", strtotime($item['date'])) . ')';
if ($title || $subtext) {
echo '<div class="meta-text media-highlight">';
if ($title) {
echo '<div class="header">'.$title.'</div>';
}
if ($subtext) {
echo '<div class="subheader">'.$subtext.'</div>';
}
echo '</div>';
}
echo '</li>';
}
echo '</ul>';
echo '<img
srcset="'.$image.'?class='.$imageClass.'sm&type=webp '.$width.'w, '.
$image.'?class='.$imageClass.'md&type=webp '.($width * 2).'w"
sizes="(max-width: 450px) '.$width.'px, '.($width * 2).'px"
src="'.$image.'?class='.$imageClass.'sm&type=webp"
alt="'.$alt.'"
loading="'.$loading.'"
decoding="async"
width="'.$width.'"
height="'.$height.'"
>';
echo '</div>';
echo $closeLink;
}
}
echo '</div>';
echo '</div>';
}
}
}
if (!function_exists('renderMediaLinks')) {
function renderMediaLinks(array $data, string $type, int $count = 10): string {
if (empty($data) || empty($type)) return "";
if (! function_exists('renderAssociatedMedia')) {
function renderAssociatedMedia(
array $artists = [],
array $books = [],
array $genres = [],
array $movies = [],
array $posts = [],
array $shows = [],
) {
$sections = [
'artists' => ['icon' => 'headphones', 'css_class' => 'music', 'label' => 'Related artist(s)', 'hasGrid' => true],
'books' => ['icon' => 'books', 'css_class' => 'books', 'label' => 'Related book(s)', 'hasGrid' => true],
'genres' => ['icon' => 'headphones', 'css_class' => 'music', 'label' => 'Related genre(s)', 'hasGrid' => false],
'movies' => ['icon' => 'movie', 'css_class' => 'movies', 'label' => 'Related movie(s)', 'hasGrid' => true],
'posts' => ['icon' => 'article', 'css_class' => 'article', 'label' => 'Related post(s)', 'hasGrid' => false],
'shows' => ['icon' => 'device-tv-old', 'css_class' => 'tv', 'label' => 'Related show(s)', 'hasGrid' => true],
];
$slice = array_slice($data, 0, $count);
$allMedia = compact('artists', 'books', 'genres', 'movies', 'posts', 'shows');
if (count($slice) === 0) return "";
echo '<div class="associated-media">';
$buildLink = function ($item) use ($type) {
switch ($type) {
case "genre":
return '<a href="' . htmlspecialchars($item['genre_url']) . '">' . htmlspecialchars($item['genre_name']) . '</a>';
case "artist":
return '<a href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['name']) . '</a>';
case "book":
return '<a href="' . htmlspecialchars($item['url']) . '">' . htmlspecialchars($item['title']) . '</a>';
default:
foreach ($sections as $key => $section) {
$items = $allMedia[$key];
if (empty($items)) {
continue;
}
echo '<h3 id="'.htmlspecialchars($key).'" class="'.htmlspecialchars($section['css_class']).'">';
echo getTablerIcon($section['icon']);
echo htmlspecialchars($section['label']);
echo '</h3>';
if ($section['hasGrid']) {
renderMediaGrid($items);
} else {
echo '<ul>';
foreach ($items as $item) {
echo '<li class="'.htmlspecialchars($section['css_class']).'">';
echo '<a href="'.htmlspecialchars($item['url']).'">'.htmlspecialchars($item['title'] ?? $item['name'] ?? '').'</a>';
if ($key === 'artists' && isset($item['total_plays']) && $item['total_plays'] > 0) {
echo ' ('.htmlspecialchars($item['total_plays']).' play'.($item['total_plays'] > 1 ? 's' : '').')';
} elseif ($key === 'books' && isset($item['author'])) {
echo ' by '.htmlspecialchars($item['author']);
} elseif (($key === 'movies' || $key === 'shows') && isset($item['year'])) {
echo ' ('.htmlspecialchars($item['year']).')';
} elseif ($key === 'posts' && isset($item['date'])) {
echo ' ('.date('F j, Y', strtotime($item['date'])).')';
}
echo '</li>';
}
echo '</ul>';
}
}
echo '</div>';
}
}
if (! function_exists('renderMediaLinks')) {
function renderMediaLinks(array $data, string $type, int $count = 10): string
{
if (empty($data) || empty($type)) {
return '';
}
};
if (count($slice) === 1) return $buildLink($slice[0]);
$slice = array_slice($data, 0, $count);
$links = array_map($buildLink, $slice);
$last = array_pop($links);
return implode(', ', $links) . ' and ' . $last;
if (count($slice) === 0) {
return '';
}
$buildLink = function ($item) use ($type) {
switch ($type) {
case 'genre':
return '<a href="'.htmlspecialchars($item['genre_url']).'">'.htmlspecialchars($item['genre_name']).'</a>';
case 'artist':
return '<a href="'.htmlspecialchars($item['url']).'">'.htmlspecialchars($item['name']).'</a>';
case 'book':
return '<a href="'.htmlspecialchars($item['url']).'">'.htmlspecialchars($item['title']).'</a>';
default:
return '';
}
};
if (count($slice) === 1) {
return $buildLink($slice[0]);
}
$links = array_map($buildLink, $slice);
$last = array_pop($links);
return implode(', ', $links).' and '.$last;
}
}
}
if (!function_exists('sanitizeMediaString')) {
if (! function_exists('sanitizeMediaString')) {
function sanitizeMediaString(string $str): string
{
$sanitizedString = preg_replace("/[^a-zA-Z0-9\s-]/", "", iconv("UTF-8", "ASCII//TRANSLIT", $str));
$sanitizedString = preg_replace("/[^a-zA-Z0-9\s-]/", '', iconv('UTF-8', 'ASCII//TRANSLIT', $str));
return strtolower(trim(preg_replace("/[\s-]+/", "-", $sanitizedString), "-"));
return strtolower(trim(preg_replace("/[\s-]+/", '-', $sanitizedString), '-'));
}
}
}

View file

@ -1,37 +1,38 @@
<?php
if (!function_exists('setupPageMetadata')) {
if (! function_exists('setupPageMetadata')) {
function setupPageMetadata(array $page, string $requestUri): array
{
$globals = $page['globals'] ?? [];
$globals = $page['globals'] ?? [];
$title = htmlspecialchars($page["metadata"]["title"] ?? "", ENT_QUOTES, "UTF-8");
$description = htmlspecialchars($page["metadata"]["description"] ?? "", ENT_QUOTES, "UTF-8");
$image = htmlspecialchars(
$page["metadata"]["open_graph_image"] ?? $globals["metadata"]["open_graph_image"] ?? "",
ENT_QUOTES,
"UTF-8"
);
$fullUrl = $globals["url"] . $requestUri;
$oembedUrl = $globals["url"] . "/oembed" . $requestUri;
$title = htmlspecialchars($page['metadata']['title'] ?? '', ENT_QUOTES, 'UTF-8');
$description = htmlspecialchars($page['metadata']['description'] ?? '', ENT_QUOTES, 'UTF-8');
$image = htmlspecialchars(
$page['metadata']['open_graph_image'] ?? $globals['metadata']['open_graph_image'] ?? '',
ENT_QUOTES,
'UTF-8'
);
$fullUrl = $globals['url'].$requestUri;
$oembedUrl = $globals['url'].'/oembed'.$requestUri;
return [
'pageTitle' => $title,
'pageDescription' => $description,
'ogImage' => $image,
'fullUrl' => $fullUrl,
'oembedUrl' => $oembedUrl,
'globals' => $globals
];
return [
'pageTitle' => $title,
'pageDescription' => $description,
'ogImage' => $image,
'fullUrl' => $fullUrl,
'oembedUrl' => $oembedUrl,
'globals' => $globals,
];
}
}
}
if (!function_exists('cleanMeta')) {
if (! function_exists('cleanMeta')) {
function cleanMeta($value)
{
$value = trim($value ?? '');
$value = str_replace(["\r", "\n"], ' ', $value);
$value = preg_replace('/\s+/', ' ', $value);
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
$value = trim($value ?? '');
$value = str_replace(["\r", "\n"], ' ', $value);
$value = preg_replace('/\s+/', ' ', $value);
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
}
}

View file

@ -1,41 +1,43 @@
<?php
if (!function_exists('renderPaginator')) {
if (! function_exists('renderPaginator')) {
function renderPaginator(array $pagination, int $totalPages): void
{
if (!$pagination || $totalPages <= 1) return;
?>
if (! $pagination || $totalPages <= 1) {
return;
}
?>
<script type="module" src="/assets/scripts/components/select-pagination.js" defer></script>
<nav aria-label="Pagination" class="pagination">
<?php if (!empty($pagination['href']['previous'])): ?>
<?php if (! empty($pagination['href']['previous'])) { ?>
<a href="<?= $pagination['href']['previous'] ?>" aria-label="Previous page">
<?= getTablerIcon('arrow-left') ?>
</a>
<?php else: ?>
<?php } else { ?>
<span><?= getTablerIcon('arrow-left') ?></span>
<?php endif; ?>
<?php } ?>
<select-pagination data-base-index="1">
<select class="client-side" aria-label="Page selection">
<?php foreach ($pagination['pages'] as $i): ?>
<?php foreach ($pagination['pages'] as $i) { ?>
<option value="<?= $i ?>" <?= ($pagination['pageNumber'] === $i) ? 'selected' : '' ?>>
<?= $i ?> of <?= $totalPages ?>
</option>
<?php endforeach; ?>
<?php } ?>
</select>
<noscript>
<p><span aria-current="page"><?= $pagination['pageNumber'] ?></span> of <?= $totalPages ?></p>
</noscript>
</select-pagination>
<?php if (!empty($pagination['href']['next'])): ?>
<?php if (! empty($pagination['href']['next'])) { ?>
<a href="<?= $pagination['href']['next'] ?>" aria-label="Next page">
<?= getTablerIcon('arrow-right') ?>
</a>
<?php else: ?>
<?php } else { ?>
<span><?= getTablerIcon('arrow-right') ?></span>
<?php endif; ?>
<?php } ?>
</nav>
<?php
}
}
}

View file

@ -1,9 +1,9 @@
<?php
if (!function_exists('redirectTo404')) {
if (! function_exists('redirectTo404')) {
function redirectTo404(): void
{
header("Location: /404/", true, 302);
exit();
header('Location: /404/', true, 302);
exit();
}
}
}

View file

@ -1,82 +1,94 @@
<?php
use Kaoken\MarkdownIt\MarkdownIt;
use Kaoken\MarkdownIt\Plugins\MarkdownItFootnote;
use Kaoken\MarkdownIt\MarkdownIt;
use Kaoken\MarkdownIt\Plugins\MarkdownItFootnote;
if (!function_exists('truncateText')) {
function truncateText($text, $limit = 50, $ellipsis = "...")
if (! function_exists('truncateText')) {
function truncateText($text, $limit = 50, $ellipsis = '...')
{
if (mb_strwidth($text, "UTF-8") <= $limit) return $text;
$truncated = mb_substr($text, 0, $limit, "UTF-8");
$lastSpace = mb_strrpos($truncated, " ", 0, "UTF-8");
if ($lastSpace !== false) $truncated = mb_substr($truncated, 0, $lastSpace, "UTF-8");
return $truncated . $ellipsis;
}
}
if (!function_exists('parseMarkdown')) {
function parseMarkdown($markdown)
{
if (empty($markdown)) return '';
$md = new MarkdownIt([
"html" => true,
"linkify" => true,
]);
$md->plugin(new MarkdownItFootnote());
return $md->render($markdown);
}
}
if (!function_exists('parseCountryField')) {
function parseCountryField($countryField)
{
if (empty($countryField)) return null;
$delimiters = [',', '/', '&', ' and '];
$countries = [$countryField];
foreach ($delimiters as $delimiter) {
$tempCountries = [];
foreach ($countries as $country) {
$tempCountries = array_merge($tempCountries, explode($delimiter, $country));
if (mb_strwidth($text, 'UTF-8') <= $limit) {
return $text;
}
$countries = $tempCountries;
}
$truncated = mb_substr($text, 0, $limit, 'UTF-8');
$lastSpace = mb_strrpos($truncated, ' ', 0, 'UTF-8');
$countries = array_map('trim', $countries);
$countries = array_map('getCountryName', $countries);
$countries = array_filter($countries);
if ($lastSpace !== false) {
$truncated = mb_substr($truncated, 0, $lastSpace, 'UTF-8');
}
return implode(', ', array_unique($countries));
return $truncated.$ellipsis;
}
}
}
if (!function_exists('getCountryName')) {
if (! function_exists('parseMarkdown')) {
function parseMarkdown($markdown)
{
if (empty($markdown)) {
return '';
}
$md = new MarkdownIt([
'html' => true,
'linkify' => true,
]);
$md->plugin(new MarkdownItFootnote());
return $md->render($markdown);
}
}
if (! function_exists('parseCountryField')) {
function parseCountryField($countryField)
{
if (empty($countryField)) {
return null;
}
$delimiters = [',', '/', '&', ' and '];
$countries = [$countryField];
foreach ($delimiters as $delimiter) {
$tempCountries = [];
foreach ($countries as $country) {
$tempCountries = array_merge($tempCountries, explode($delimiter, $country));
}
$countries = $tempCountries;
}
$countries = array_map('trim', $countries);
$countries = array_map('getCountryName', $countries);
$countries = array_filter($countries);
return implode(', ', array_unique($countries));
}
}
if (! function_exists('getCountryName')) {
function getCountryName($countryName)
{
$isoCodes = new \Sokil\IsoCodes\IsoCodesFactory();
$countries = $isoCodes->getCountries();
$country = $countries->getByAlpha2($countryName);
$isoCodes = new \Sokil\IsoCodes\IsoCodesFactory();
$countries = $isoCodes->getCountries();
$country = $countries->getByAlpha2($countryName);
if ($country) return $country->getName();
if ($country) {
return $country->getName();
}
return ucfirst(strtolower($countryName));
return ucfirst(strtolower($countryName));
}
}
}
if (!function_exists('pluralize')) {
if (! function_exists('pluralize')) {
function pluralize($count, $string, $trailing = '')
{
if ((int)$count === 1) return $string;
if ((int) $count === 1) {
return $string;
}
return $string . 's' . ($trailing ? $trailing : '');
return $string.'s'.($trailing ? $trailing : '');
}
}
}

View file

@ -1,17 +1,19 @@
<?php
if (!function_exists('renderTags')) {
if (! function_exists('renderTags')) {
function renderTags(array $tags): void
{
if (empty($tags)) return;
if (empty($tags)) {
return;
}
echo '<div class="tags">';
echo '<div class="tags">';
foreach ($tags as $tag) {
$slug = strtolower(trim($tag));
echo '<a href="/tags/' . rawurlencode($slug) . '">#' . htmlspecialchars($slug) . '</a>';
}
foreach ($tags as $tag) {
$slug = strtolower(trim($tag));
echo '<a href="/tags/'.rawurlencode($slug).'">#'.htmlspecialchars($slug).'</a>';
}
echo '</div>';
echo '</div>';
}
}
}

View file

@ -1,6 +1,6 @@
<?php
define('PROJECT_ROOT', realpath(__DIR__ . '/..'));
define('PROJECT_ROOT', realpath(__DIR__.'/..'));
require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/app/Utils/init.php';
require_once PROJECT_ROOT.'/vendor/autoload.php';
require_once PROJECT_ROOT.'/app/Utils/init.php';

View file

@ -9,10 +9,11 @@
"sokil/php-isocodes": "^4.2",
"sokil/php-isocodes-db-only": "^4.0"
},
"require-dev": {
"laravel/pint": "^1.22"
},
"scripts": {
"start": [
"@php -S localhost:8000 -t dist"
]
"format:php": "vendor/bin/pint"
},
"autoload": {
"psr-4": {

71
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d44329419cf23b795cc4babc3b019b70",
"content-hash": "e9a46ab9eab33e697fc595a420b63325",
"packages": [
{
"name": "guzzlehttp/guzzle",
@ -754,7 +754,74 @@
"time": "2024-09-25T14:21:43+00:00"
}
],
"packages-dev": [],
"packages-dev": [
{
"name": "laravel/pint",
"version": "v1.22.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "941d1927c5ca420c22710e98420287169c7bcaf7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7",
"reference": "941d1927c5ca420c22710e98420287169c7bcaf7",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"ext-tokenizer": "*",
"ext-xml": "*",
"php": "^8.2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75.0",
"illuminate/view": "^11.44.7",
"larastan/larastan": "^3.4.0",
"laravel-zero/framework": "^11.36.1",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.3.1",
"pestphp/pest": "^2.36.0"
},
"bin": [
"builds/pint"
],
"type": "project",
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Seeders\\": "database/seeders/",
"Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nuno Maduro",
"email": "enunomaduro@gmail.com"
}
],
"description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com",
"keywords": [
"format",
"formatter",
"lint",
"linter",
"php"
],
"support": {
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2025-05-08T08:38:12+00:00"
}
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "coryd.dev",
"version": "10.1.4",
"version": "10.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coryd.dev",
"version": "10.1.4",
"version": "10.2.4",
"license": "MIT",
"dependencies": {
"minisearch": "^7.1.2",

View file

@ -1,6 +1,6 @@
{
"name": "coryd.dev",
"version": "10.1.4",
"version": "10.2.4",
"description": "The source for my personal site. Built using 11ty (and other tools).",
"type": "module",
"engines": {
@ -13,14 +13,15 @@
"php": "export $(grep -v '^#' .env | xargs) && php -d error_reporting=E_ALL^E_DEPRECATED -S localhost:8080 -t dist",
"build": "eleventy",
"clean": "rimraf dist .cache",
"format": "npx prettier --write '**/*.{js,ts,json,css,md}'",
"format": "npx prettier --write '**/*.{js,ts,json,css,md}' && composer format:php",
"update": "composer update && npm upgrade && npm --prefix cli upgrade && ncu && ncu --cwd cli",
"setup": "sh ./scripts/setup.sh",
"setup:deploy": "sh ./scripts/setup.sh --deploy",
"prepare": "husky"
},
"lint-staged": {
"*.{js,json,css,md}": "prettier --write"
"*.{js,json,css,md}": "prettier --write",
"*.php": "composer format:php"
},
"keywords": [
"11ty",

4
pint.json Normal file
View file

@ -0,0 +1,4 @@
{
"preset": "psr12",
"exclude": ["dist"]
}