chore(*.php): use pint for php formatting
This commit is contained in:
parent
bd1855a65e
commit
753f3433ce
40 changed files with 2261 additions and 1900 deletions
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
399
api/contact.php
399
api/contact.php
|
@ -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);
|
||||
}
|
||||
|
|
289
api/mastodon.php
289
api/mastodon.php
|
@ -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()]);
|
||||
}
|
||||
|
|
183
api/oembed.php
183
api/oembed.php
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
172
api/query.php
172
api/query.php
|
@ -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();
|
||||
|
|
494
api/scrobble.php
494
api/scrobble.php
|
@ -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()]);
|
||||
}
|
||||
|
|
276
api/search.php
276
api/search.php
|
@ -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 ? "§ions={$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 ? "§ions={$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();
|
||||
|
|
|
@ -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();
|
||||
|
|
106
api/umami.php
106
api/umami.php
|
@ -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 ?: '';
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue