diff --git a/.env.example b/.env.example index f01cd28a..cf389c40 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,7 @@ cache.handler="file" # REST API configuration #-------------------------------------------------------------------- # restapi.enabled=true +# restapi.basicAuthUsername=castopod +# restapi.basicAuthPassword=password +# restapi.basicAuth=true + diff --git a/app/Database/Migrations/2023-06-12-010000_add_full_text_search_indexes.php b/app/Database/Migrations/2023-06-12-010000_add_full_text_search_indexes.php new file mode 100644 index 00000000..d5051530 --- /dev/null +++ b/app/Database/Migrations/2023-06-12-010000_add_full_text_search_indexes.php @@ -0,0 +1,52 @@ +db->getPrefix(); + + $createQuery = <<db->query($createQuery); + + $createQuery = <<db->query($createQuery); + + $createQuery = <<db->query($createQuery); + } + + public function down(): void + { + $prefix = $this->db->getPrefix(); + + $createQuery = <<db->query($createQuery); + + $createQuery = <<db->query($createQuery); + } +} diff --git a/app/Database/Seeds/FakeSinglePodcastApiSeeder.php b/app/Database/Seeds/FakeSinglePodcastApiSeeder.php index 6bff2b03..7c31e606 100644 --- a/app/Database/Seeds/FakeSinglePodcastApiSeeder.php +++ b/app/Database/Seeds/FakeSinglePodcastApiSeeder.php @@ -8,6 +8,27 @@ use CodeIgniter\Database\Seeder; class FakeSinglePodcastApiSeeder extends Seeder { + /** + * @return array{id: int, file_key: string, file_size: string, file_mimetype: string, file_metadata: string, type: string, description: null, language_code: string, uploaded_by: string, updated_by: string, uploaded_at: string, updated_at: string} + */ + public static function audio(): array + { + return [ + 'id' => 3, + 'file_key' => 'podcasts/test/1685531765_84fb3309111ece22ca37.mp3', + 'file_size' => '2737773', + 'file_mimetype' => 'audio/mpeg', + 'file_metadata' => '{"GETID3_VERSION":"2.0.x-202207161647","filesize":2737773,"filepath":"\\/tmp","filename":"php76vXQR","filenamepath":"\\/tmp\\/php76vXQR","avdataoffset":45,"avdataend":2737773,"fileformat":"mp3","audio":{"dataformat":"mp3","channels":2,"sample_rate":48000,"bitrate":128008.9774161874,"channelmode":"stereo","bitrate_mode":"cbr","lossless":false,"encoder_options":"CBR128","compression_ratio":0.08333917800533033,"streams":[{"dataformat":"mp3","channels":2,"sample_rate":48000,"bitrate":128008.9774161874,"channelmode":"stereo","bitrate_mode":"cbr","lossless":false,"encoder_options":"CBR128","compression_ratio":0.08333917800533033}]},"tags":{"id3v2":{"encoder_settings":["Lavf58.29.100"]}},"encoding":"UTF-8","id3v2":{"header":true,"flags":{"unsynch":false,"exthead":false,"experim":false,"isfooter":false},"majorversion":4,"minorversion":0,"headerlength":45,"tag_offset_start":0,"tag_offset_end":45,"encoding":"UTF-8","comments":{"encoder_settings":["Lavf58.29.100"]},"TSSE":[{"frame_name":"TSSE","frame_flags_raw":0,"data":"Lavf58.29.100","datalength":15,"dataoffset":10,"framenamelong":"Software\\/Hardware and settings used for encoding","framenameshort":"encoder_settings","flags":{"TagAlterPreservation":false,"FileAlterPreservation":false,"ReadOnly":false,"GroupingIdentity":false,"compression":false,"Encryption":false,"Unsynchronisation":false,"DataLengthIndicator":false},"encodingid":3,"encoding":"UTF-8"}],"padding":{"start":35,"length":10,"valid":true}},"mime_type":"audio\\/mpeg","mpeg":{"audio":{"raw":{"synch":4094,"version":3,"layer":1,"protection":1,"bitrate":5,"sample_rate":1,"padding":0,"private":0,"channelmode":0,"modeextension":0,"copyright":0,"original":0,"emphasis":0},"version":"1","layer":3,"channelmode":"stereo","channels":2,"sample_rate":48000,"protection":false,"private":false,"modeextension":"","copyright":false,"original":false,"emphasis":"none","padding":false,"bitrate":128008.9774161874,"framelength":384,"bitrate_mode":"cbr","VBR_method":"Xing","xing_flags_raw":15,"xing_flags":{"frames":true,"bytes":true,"toc":true,"vbr_scale":true},"VBR_frames":7129,"VBR_bytes":2737728,"toc":[0,3,5,8,10,13,16,18,20,22,26,28,31,33,36,39,41,43,45,49,51,54,56,59,62,64,66,68,72,74,77,79,82,85,87,89,91,95,97,99,102,105,108,110,112,114,118,120,122,125,128,131,133,135,137,141,143,145,148,150,153,156,158,160,164,166,168,171,173,176,179,181,183,187,189,191,194,196,199,202,204,206,210,212,214,217,219,222,225,227,229,233,235,237,240,242,245,248,250,252],"VBR_scale":0,"VBR_bitrate":128008.9774161874}},"playtime_seconds":171.0720016831475,"tags_html":{"id3v2":{"encoder_settings":["Lavf58.29.100"]}},"bitrate":128008.9774161874,"playtime_string":"2:51"}', + 'type' => 'audio', + 'description' => null, + 'language_code' => 'pl', + 'uploaded_by' => '1', + 'updated_by' => '1', + 'uploaded_at' => '2023-05-31 11:16:05', + 'updated_at' => '2023-05-31 11:16:05', + ]; + } + /** * @return array{id: int, file_key: string, file_size: int, file_mimetype: string, file_metadata: string, type: string, description: null, language_code: null, uploaded_by: int, updated_by: int, uploaded_at: string, updated_at: string} */ @@ -125,6 +146,46 @@ class FakeSinglePodcastApiSeeder extends Seeder ]; } + /** + * @return array{id: int, podcast_id: int, guid: string, title: string, slug: string, audio_id: int, description_markdown: string, description_html: string, cover_id: int, transcript_id: null, transcript_remote_url: null, chapters_id: null, chapters_remote_url: null, parental_advisory: null, number: int, season_number: null, type: string, is_blocked: false, location_name: null, location_geo: null, location_osm: null, custom_rss: null, is_published_on_hubs: false, posts_count: int, comments_count: int, is_premium: false, created_by: int, updated_by: int, published_at: null, created_at: string, updated_at: string} + */ + public static function episode(): array + { + return [ + 'id' => 1, + 'podcast_id' => 1, + 'guid' => 'http://localhost:8080/@test/episodes/muzyka-marzen', + 'title' => 'Episode title', + 'slug' => 'episode-slug', + 'audio_id' => 3, + 'description_markdown' => '123', + 'description_html' => '

123

', + 'cover_id' => 1, + 'transcript_id' => null, + 'transcript_remote_url' => null, + 'chapters_id' => null, + 'chapters_remote_url' => null, + 'parental_advisory' => null, + 'number' => 1, + 'season_number' => null, + 'type' => 'full', + 'is_blocked' => false, + 'location_name' => null, + 'location_geo' => null, + 'location_osm' => null, + 'custom_rss' => null, + 'is_published_on_hubs' => false, + 'posts_count' => 0, + 'comments_count' => 0, + 'is_premium' => false, + 'created_by' => 1, + 'updated_by' => 1, + 'published_at' => null, + 'created_at' => '2023-05-31 11:16:06', + 'updated_at' => '2023-05-31 11:16:06', + ]; + } + public function run(): void { $this->call(AppSeeder::class); @@ -133,9 +194,13 @@ class FakeSinglePodcastApiSeeder extends Seeder ->insert(self::cover()); $this->db->table('media') ->insert(self::banner()); + $this->db->table('media') + ->insert(self::audio()); $this->db->table('fediverse_actors') ->insert(self::actor()); $this->db->table('podcasts') ->insert(self::podcast()); + $this->db->table('episodes') + ->insert(self::episode()); } } diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 50a808b9..e5704e66 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -11,6 +11,7 @@ declare(strict_types=1); namespace App\Models; use App\Entities\Episode; +use CodeIgniter\Database\BaseBuilder; use CodeIgniter\Database\BaseResult; use CodeIgniter\I18n\Time; use CodeIgniter\Model; @@ -434,6 +435,37 @@ class EpisodeModel extends Model ])->countAllResults() > 0; } + public function fullTextSearch(string $query): ?BaseBuilder + { + $prefix = $this->db->getPrefix(); + $episodeTable = $prefix . $this->builder()->getTable(); + + $podcastModel = (new PodcastModel()); + + $podcastTable = $podcastModel->db->getPrefix() . $podcastModel->builder()->getTable(); + + $this->builder() + ->select('' . $episodeTable . '.*') + ->select(' + ' . $this->getFullTextMatchClauseForEpisodes($episodeTable, $query) . ' as episodes_score, + ' . $podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query) . ' as podcasts_score, + ') + ->select("{$podcastTable}.created_at AS podcast_created_at") + ->select( + "{$podcastTable}.title as podcast_title, {$podcastTable}.handle as podcast_handle, {$podcastTable}.description_markdown as podcast_description_markdown" + ) + ->join($podcastTable, "{$podcastTable} on {$podcastTable}.id = {$episodeTable}.podcast_id") + ->where(' + (' . + $this->getFullTextMatchClauseForEpisodes($episodeTable, $query) + . 'OR' . + $podcastModel->getFullTextMatchClauseForPodcasts($podcastTable, $query) + . ') + '); + + return $this->builder; + } + /** * @param mixed[] $data * @@ -462,4 +494,17 @@ class EpisodeModel extends Model return $data; } + + private function getFullTextMatchClauseForEpisodes(string $table, string $value): string + { + return ' + MATCH ( + ' . $table . '.title, + ' . $table . '.description_markdown, + ' . $table . '.slug, + ' . $table . '.location_name + ) + AGAINST("*' . preg_replace('/[^\p{L}\p{N}_]+/u', ' ', $value) . '*" IN BOOLEAN MODE) + '; + } } diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 5701d631..65fcd363 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -384,6 +384,19 @@ class PodcastModel extends Model return $data; } + public function getFullTextMatchClauseForPodcasts(string $table, string $value): string + { + return ' + MATCH ( + ' . $table . '.title , + ' . $table . '.description_markdown, + ' . $table . '.handle, + ' . $table . '.location_name + ) + AGAINST("*' . preg_replace('/[^\p{L}\p{N}_]+/u', ' ', $value) . '*" IN BOOLEAN MODE) + '; + } + /** * Creates an actor linked to the podcast (Triggered before insert) * diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index b2ad97ca..0631928b 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -86,7 +86,7 @@ class EpisodeController extends BaseController ->select('episodes.*, IFNULL(SUM(ape.hits),0) as downloads') ->join('analytics_podcasts_by_episode ape', 'episodes.id=ape.episode_id', 'left') ->where('episodes.podcast_id', $this->podcast->id) - ->where("MATCH (title, description_markdown) AGAINST ('{$query}')") + ->where("MATCH (title, description_markdown, slug, location_name) AGAINST ('{$query}')") ->groupBy('episodes.id'); } } else { diff --git a/modules/Api/Rest/V1/Config/RestApi.php b/modules/Api/Rest/V1/Config/RestApi.php index d32a6cb9..836a8fb2 100644 --- a/modules/Api/Rest/V1/Config/RestApi.php +++ b/modules/Api/Rest/V1/Config/RestApi.php @@ -15,6 +15,17 @@ class RestApi extends BaseConfig */ public bool $enabled = false; + public bool $basicAuth = false; + + public ?string $basicAuthUsername = null; + + public ?string $basicAuthPassword = null; + + /** + * Default results limit. + */ + public int $limit = 10; + /** * -------------------------------------------------------------------------- * Rest API gateway diff --git a/modules/Api/Rest/V1/Config/Routes.php b/modules/Api/Rest/V1/Config/Routes.php index d39a4671..0a02ebd1 100644 --- a/modules/Api/Rest/V1/Config/Routes.php +++ b/modules/Api/Rest/V1/Config/Routes.php @@ -19,3 +19,17 @@ $routes->group( $routes->get('(:any)', 'ExceptionController::notFound'); } ); + +$routes->group( + config('RestApi') + ->gateway . 'episodes', + [ + 'namespace' => 'Modules\Api\Rest\V1\Controllers', + 'filter' => 'rest-api', + ], + static function ($routes): void { + $routes->get('/', 'EpisodeController::list'); + $routes->get('(:num)', 'EpisodeController::view/$1'); + $routes->get('(:any)', 'ExceptionController::notFound'); + } +); diff --git a/modules/Api/Rest/V1/Controllers/EpisodeController.php b/modules/Api/Rest/V1/Controllers/EpisodeController.php new file mode 100644 index 00000000..e10c7b8f --- /dev/null +++ b/modules/Api/Rest/V1/Controllers/EpisodeController.php @@ -0,0 +1,79 @@ +initialize(); + } + + public function list(): Response + { + $query = $this->request->getGet('query'); + $order = $this->request->getGet('order') ?? 'newest'; + $podcastIds = $this->request->getGet('podcastIds'); + + $builder = (new EpisodeModel()); + + if ($podcastIds !== null) { + $builder->whereIn('podcast_id', explode(',', (string) $podcastIds)); + } + + if ($query !== null) { + $builder->fullTextSearch($query); + + if ($order === 'query') { + $builder->orderBy('(episodes_score + podcasts_score)', 'desc'); + } + } + + if ($order === 'newest') { + $builder->orderBy($builder->db->getPrefix() . $builder->getTable() . '.created_at', 'desc'); + } + + $data = $builder->findAll( + (int) ($this->request->getGet('limit') ?? config('RestApi')->limit), + (int) $this->request->getGet('offset') + ); + + array_map(static function ($episode): void { + self::mapEpisode($episode); + }, $data); + + return $this->respond($data); + } + + public function view(int $id): Response + { + $episode = (new EpisodeModel())->getEpisodeById($id); + + if (! $episode instanceof Episode) { + return $this->failNotFound('Episode not found'); + } + + return $this->respond($this->mapEpisode($episode)); + } + + protected static function mapEpisode(Episode $episode): Episode + { + $episode->cover_url = $episode->getCover() +->file_url; + $episode->audio_url = $episode->getAudioUrl(); + $episode->duration = round($episode->audio->duration); + + return $episode; + } +} diff --git a/modules/Api/Rest/V1/Controllers/PodcastController.php b/modules/Api/Rest/V1/Controllers/PodcastController.php index 3cbe3cd8..4a1ac146 100644 --- a/modules/Api/Rest/V1/Controllers/PodcastController.php +++ b/modules/Api/Rest/V1/Controllers/PodcastController.php @@ -24,19 +24,37 @@ class PodcastController extends Controller { $data = (new PodcastModel())->findAll(); array_map(static function ($podcast): void { - $podcast->feed_url = $podcast->getFeedUrl(); + self::mapPodcast($podcast); }, $data); return $this->respond($data); } public function view(int $id): Response { - $data = (new PodcastModel())->getPodcastById($id); - if (! $data instanceof Podcast) { + $podcast = (new PodcastModel())->getPodcastById($id); + if (! $podcast instanceof Podcast) { return $this->failNotFound('Podcast not found'); } - $data->feed_url = $data->getFeedUrl(); - return $this->respond($data); + return $this->respond(self::mapPodcast($podcast)); + } + + protected static function mapPodcast(Podcast $podcast): Podcast + { + $podcast->feed_url = $podcast->getFeedUrl(); + $podcast->actor_display_name = $podcast->getActor() +->display_name; + $podcast->cover_url = $podcast->getCover() +->file_url; + + $categories = [$podcast->getCategory(), ...$podcast->getOtherCategories()]; + + foreach ($categories as $category) { + $category->translated = lang('Podcast.category_options.' . $category->code, [], null, false); + } + + $podcast->categories = $categories; + + return $podcast; } } diff --git a/modules/Api/Rest/V1/Filters/ApiFilter.php b/modules/Api/Rest/V1/Filters/ApiFilter.php index d6e6b32a..c28af65e 100644 --- a/modules/Api/Rest/V1/Filters/ApiFilter.php +++ b/modules/Api/Rest/V1/Filters/ApiFilter.php @@ -6,16 +6,52 @@ namespace Modules\Api\Rest\V1\Filters; use CodeIgniter\Exceptions\PageNotFoundException; use CodeIgniter\Filters\FilterInterface; +use CodeIgniter\HTTP\Request; use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\Response; use CodeIgniter\HTTP\ResponseInterface; +use Modules\Api\Rest\V1\Config\RestApi; class ApiFilter implements FilterInterface { - public function before(RequestInterface $request, $arguments = null): void + /** + * @param Request $request + */ + public function before(RequestInterface $request, $arguments = null) { - if (! config('RestApi')->enabled) { + /** @var RestApi $restApiConfig */ + $restApiConfig = config('RestApi'); + + if (! $restApiConfig->enabled) { throw PageNotFoundException::forPageNotFound(); } + + if ($restApiConfig->basicAuth) { + /** @var Response $response */ + $response = service('response'); + if (! $request->hasHeader('Authorization')) { + $response->setStatusCode(401); + + return $response; + } + + $authHeader = $request->getHeaderLine('Authorization'); + if (substr($authHeader, 0, 6) !== 'Basic ') { + $response->setStatusCode(401); + + return $response; + } + + $auth_token = base64_decode(substr($authHeader, 6), true); + + list($username, $password) = explode(':', (string) $auth_token); + + if (! ($username === $restApiConfig->basicAuthUsername && $password === $restApiConfig->basicAuthPassword)) { + $response->setStatusCode(401); + + return $response; + } + } } public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void diff --git a/tests/modules/Api/Rest/V1/EpisodeTest.php b/tests/modules/Api/Rest/V1/EpisodeTest.php new file mode 100644 index 00000000..7a009764 --- /dev/null +++ b/tests/modules/Api/Rest/V1/EpisodeTest.php @@ -0,0 +1,108 @@ + + */ + private array $episode = []; + + private readonly string $apiUrl; + + public function __construct(?string $name = null) + { + parent::__construct($name); + + $this->episode = FakeSinglePodcastApiSeeder::episode(); + + $this->episode['created_at'] = []; + $this->episode['updated_at'] = []; + $this->apiUrl = config('RestApi') + ->gateway; + } + + public function testList(): void + { + $result = $this->call('get', $this->apiUrl . 'episodes'); + $result->assertStatus(200); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + $result->assertJSONFragment([ + 0 => $this->episode, + ]); + } + + public function testView(): void + { + $result = $this->call('get', $this->apiUrl . 'episodes/1'); + $result->assertStatus(200); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + $result->assertJSONFragment($this->episode); + } + + public function testViewNotFound(): void + { + $result = $this->call('get', $this->apiUrl . 'episodes/2'); + $result->assertStatus(404); + $result->assertJSONExact( + [ + 'status' => 404, + 'error' => 404, + 'messages' => [ + 'error' => 'Episode not found', + ], + ] + ); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + } + + /* + * Refreshing database to fetch empty array of episodes + */ + public function testListEmpty(): void + { + $this->regressDatabase(); + $this->migrateDatabase(); + $result = $this->call('get', $this->apiUrl . 'episodes'); + $result->assertStatus(200); + $result->assertHeader('Content-Type', 'application/json; charset=UTF-8'); + $result->assertJSONExact([]); + $this->seed($this->seed); + } +}