diff --git a/.gitignore b/.gitignore index b30b7fb4070809c02cc1e9b051f5c2d8e4769c36..5048fc8e8d677d96b8f035b4273bf44892a5881d 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,7 @@ nb-configuration.xml # Visual Studio Code .vscode/ +.history/ tmp/ /results/ diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 85ab79abcebf94bbe7281b8fcec99fefa7fc0a6a..1cf07e27141bf01b54da6b98d09b7bfa3eb4725e 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -73,7 +73,11 @@ class Filters extends BaseConfig 'before' => [ // 'honeypot', 'csrf' => [ - 'except' => ['@[a-zA-Z0-9\_]{1,32}/inbox'], + 'except' => [ + '@[a-zA-Z0-9\_]{1,32}/inbox', + 'api/rest/v1/episodes', + 'api/rest/v1/episodes/[0-9]+/publish', + ], ], // 'invalidchars', ], diff --git a/modules/Api/Rest/V1/Config/Routes.php b/modules/Api/Rest/V1/Config/Routes.php index 6cbba9d86322e2fa30aa47423082c0f5ef3a0cc4..99429fa71829b5fa9d30fd0506a3cb1d0f7ca367 100644 --- a/modules/Api/Rest/V1/Config/Routes.php +++ b/modules/Api/Rest/V1/Config/Routes.php @@ -31,6 +31,8 @@ $routes->group( ], static function ($routes): void { $routes->get('/', 'EpisodeController::list'); + $routes->post('/', 'EpisodeController::attemptCreate'); + $routes->post('(:num)/publish', 'EpisodeController::attemptPublish/$1'); $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 index d992b530e25e5e9c6e2422f6d1db1b487fb5b8e2..53f8d4163ca0bcbde651971773f5ca36088ae290 100644 --- a/modules/Api/Rest/V1/Controllers/EpisodeController.php +++ b/modules/Api/Rest/V1/Controllers/EpisodeController.php @@ -5,11 +5,18 @@ declare(strict_types=1); namespace Modules\Api\Rest\V1\Controllers; use App\Entities\Episode; +use App\Entities\Location; +use App\Entities\Podcast; +use App\Entities\Post; use App\Models\EpisodeModel; +use App\Models\PodcastModel; +use App\Models\PostModel; use CodeIgniter\API\ResponseTrait; use CodeIgniter\Controller; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\I18n\Time; use Modules\Api\Rest\V1\Config\Services; +use Modules\Auth\Models\UserModel; class EpisodeController extends Controller { @@ -68,6 +75,224 @@ class EpisodeController extends Controller return $this->respond(static::mapEpisode($episode)); } + public function attemptCreate(): ResponseInterface + { + $rules = [ + 'created_by' => 'required|is_natural_no_zero', + 'updated_by' => 'required|is_natural_no_zero', + 'title' => 'required', + 'slug' => 'required|max_length[128]', + 'podcast_id' => 'required|is_natural_no_zero', + 'audio_file' => 'uploaded[audio_file]|ext_in[audio_file,mp3,m4a]', + 'cover' => 'permit_empty|is_image[cover]|ext_in[cover,jpg,jpeg,png]|min_dims[cover,1400,1400]|is_image_ratio[cover,1,1]', + 'transcript_file' => 'permit_empty|ext_in[transcript_file,srt,vtt]', + 'chapters_file' => 'permit_empty|ext_in[chapters_file,json]|is_json[chapters_file]', + 'transcript-choice' => 'permit_empty|in_list[upload-file,remote-url]', + 'chapters-choice' => 'permit_empty|in_list[upload-file,remote-url]', + ]; + + if (! $this->validate($rules)) { + return $this->failValidationErrors(array_values($this->validator->getErrors())); + } + + $podcastId = $this->request->getPost('podcast_id'); + + $podcast = (new PodcastModel())->getPodcastById($podcastId); + + if (! $podcast instanceof Podcast) { + return $this->failNotFound('Podcast not found'); + } + + $createdByUserId = $this->request->getPost('created_by'); + + $userModel = new UserModel(); + $createdByUser = $userModel->find($createdByUserId); + + if (! $createdByUser) { + return $this->failNotFound('User not found'); + } + + $updatedByUserId = $this->request->getPost('updated_by'); + + $updatedByUser = $userModel->find($updatedByUserId); + + if (! $updatedByUser) { + return $this->failNotFound('Updated by user not found'); + } + + if ($podcast->type === 'serial' && $this->request->getPost('type') === 'full') { + $rules['episode_number'] = 'required'; + } + + if (! $this->validate($rules)) { + return $this->failValidationErrors(array_values($this->validator->getErrors())); + } + + $validData = $this->validator->getValidated(); + + if ((new EpisodeModel()) + ->where([ + 'slug' => $validData['slug'], + 'podcast_id' => $podcast->id, + ]) + ->first() instanceof Episode) { + return $this->fail('An episode with the same slug already exists in this podcast.', 409); + } + + $newEpisode = new Episode([ + 'created_by' => $createdByUserId, + 'updated_by' => $updatedByUserId, + 'podcast_id' => $podcast->id, + 'title' => $validData['title'], + 'slug' => $validData['slug'], + 'guid' => null, + 'audio' => $this->request->getFile('audio_file'), + 'cover' => $this->request->getFile('cover'), + 'description_markdown' => $this->request->getPost('description'), + 'location' => in_array($this->request->getPost('location_name'), ['', null], true) + ? null + : new Location($this->request->getPost('location_name')), + 'parental_advisory' => $this->request->getPost('parental_advisory') !== 'undefined' + ? $this->request->getPost('parental_advisory') + : null, + 'number' => $this->request->getPost('episode_number') ? (int) $this->request->getPost( + 'episode_number' + ) : null, + 'season_number' => $this->request->getPost('season_number') ? (int) $this->request->getPost( + 'season_number' + ) : null, + 'type' => $this->request->getPost('type'), + 'is_blocked' => $this->request->getPost('block') === 'yes', + 'custom_rss_string' => $this->request->getPost('custom_rss'), + 'is_premium' => $this->request->getPost('premium') === 'yes', + 'published_at' => null, + ]); + + $transcriptChoice = $this->request->getPost('transcript-choice'); + if ($transcriptChoice === 'upload-file') { + $newEpisode->setTranscript($this->request->getFile('transcript_file')); + } elseif ($transcriptChoice === 'remote-url') { + $newEpisode->transcript_remote_url = $this->request->getPost( + 'transcript_remote_url' + ) === '' ? null : $this->request->getPost('transcript_remote_url'); + } + + $chaptersChoice = $this->request->getPost('chapters-choice'); + if ($chaptersChoice === 'upload-file') { + $newEpisode->setChapters($this->request->getFile('chapters_file')); + } elseif ($chaptersChoice === 'remote-url') { + $newEpisode->chapters_remote_url = $this->request->getPost( + 'chapters_remote_url' + ) === '' ? null : $this->request->getPost('chapters_remote_url'); + } + + $episodeModel = new EpisodeModel(); + if (! ($newEpisodeId = $episodeModel->insert($newEpisode, true))) { + return $this->fail($episodeModel->errors(), 400); + } + + $episode = $episodeModel->find($newEpisodeId) + ->toRawArray(); + + return $this->respond($episode); + } + + public function attemptPublish(int $id): ResponseInterface + { + $episodeModel = new EpisodeModel(); + $episode = $episodeModel->getEpisodeById($id); + + if (! $episode instanceof Episode) { + return $this->failNotFound('Episode not found'); + } + + if ($episode->publication_status !== 'not_published') { + return $this->fail('Episode is already published or scheduled for publication', 409); + } + + $rules = [ + 'publication_method' => 'required', + 'created_by' => 'required|is_natural_no_zero', + ]; + + if (! $this->validate($rules)) { + return $this->failValidationErrors(array_values($this->validator->getErrors())); + } + + if ($this->request->getPost('publication_method') === 'schedule') { + $rules['scheduled_publication_date'] = 'required|valid_date[Y-m-d H:i]'; + } + + if (! $this->validate($rules)) { + return $this->failValidationErrors(array_values($this->validator->getErrors())); + } + + $createdByUserId = $this->request->getPost('created_by'); + + $userModel = new UserModel(); + $createdByUser = $userModel->find($createdByUserId); + + if (! $createdByUser) { + return $this->failNotFound('User not found'); + } + + $validData = $this->validator->getValidated(); + + $db = db_connect(); + $db->transStart(); + + $newPost = new Post([ + 'actor_id' => $episode->podcast->actor_id, + 'episode_id' => $episode->id, + 'message' => $this->request->getPost('message') ?? '', + 'created_by' => $createdByUserId, + ]); + + $clientTimezone = $this->request->getPost('client_timezone') ?? app_timezone(); + + if ($episode->podcast->publication_status === 'published') { + $publishMethod = $validData['publication_method']; + if ($publishMethod === 'schedule') { + $scheduledPublicationDate = $validData['scheduled_publication_date'] ?? null; + if ($scheduledPublicationDate) { + $episode->published_at = Time::createFromFormat( + 'Y-m-d H:i', + $scheduledPublicationDate, + $clientTimezone + )->setTimezone(app_timezone()); + } else { + $db->transRollback(); + return $this->fail('Scheduled publication date is required', 400); + } + } else { + $episode->published_at = Time::now(); + } + } elseif ($episode->podcast->publication_status === 'scheduled') { + // podcast publication date has already been set + $episode->published_at = $episode->podcast->published_at->addSeconds(1); + } else { + $episode->published_at = Time::now(); + } + + $newPost->published_at = $episode->published_at; + + $postModel = new PostModel(); + if (! $postModel->addPost($newPost)) { + $db->transRollback(); + return $this->fail($postModel->errors(), 400); + } + + if (! $episodeModel->update($episode->id, $episode)) { + $db->transRollback(); + return $this->fail($episodeModel->errors(), 400); + } + + $db->transComplete(); + + // @phpstan-ignore-next-line + return $this->respond(self::mapEpisode($episode)); + } + protected static function mapEpisode(Episode $episode): Episode { $episode->cover_url = $episode->getCover() diff --git a/modules/Api/Rest/V1/Filters/ApiFilter.php b/modules/Api/Rest/V1/Filters/ApiFilter.php index 8ce40143717cf6eb35a3903e42c22f49d0a19b3c..4b1932d30ff43ebdf70f2eff29dffd611c62e1a3 100644 --- a/modules/Api/Rest/V1/Filters/ApiFilter.php +++ b/modules/Api/Rest/V1/Filters/ApiFilter.php @@ -29,6 +29,13 @@ class ApiFilter implements FilterInterface throw PageNotFoundException::forPageNotFound(); } + if ($request->getMethod() === 'POST' && ! $restApiConfig->basicAuth) { + /** @var Response $response */ + $response = service('response'); + $response->setStatusCode(401); + return $response; + } + if ($restApiConfig->basicAuth) { /** @var Response $response */ $response = service('response'); diff --git a/modules/Api/Rest/V1/podcast.json b/modules/Api/Rest/V1/podcast.json index 4fe37fdeaca9402c16df514dbc9cf221926b40a6..3d24fc83c76a287a739302e14a5253cef7575a43 100644 --- a/modules/Api/Rest/V1/podcast.json +++ b/modules/Api/Rest/V1/podcast.json @@ -72,6 +72,162 @@ } } } + }, + "/api/rest/v1/episodes": { + "get": { + "summary": "List all episodes", + "responses": { + "200": { + "description": "Object of episodes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Episodes" + } + } + } + }, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a new episode", + "requestBody": { + "description": "Episode object that needs to be added", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/EpisodeCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Episode created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Episode" + } + } + } + }, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/rest/v1/episodes/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The id of the episode to retrieve", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maxLength": 10 + } + } + ], + "get": { + "summary": "Info for a specific episode", + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Episode" + } + } + } + }, + "default": { + "description": "Unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/rest/v1/episodes/{id}/publish": { + "post": { + "summary": "Publish an episode", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The id of the episode to publish", + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maxLength": 10 + } + } + ], + "requestBody": { + "description": "Publish parameters", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EpisodePublishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Episode published successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Episode" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } }, "components": { @@ -302,6 +458,209 @@ "$ref": "#/components/schemas/Podcast" } }, + "Episode": { + "type": "object", + "required": [ + "id", + "title", + "slug", + "podcast_id", + "created_by", + "updated_by", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "title": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "podcast_id": { + "type": "integer", + "format": "int64" + }, + "description_markdown": { + "type": "string" + }, + "description_html": { + "type": "string" + }, + "audio_url": { + "type": "string", + "format": "uri" + }, + "cover_url": { + "type": "string", + "format": "uri" + }, + "duration": { + "type": "integer", + "format": "int32" + }, + "published_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "integer", + "format": "int64" + }, + "updated_by": { + "type": "integer", + "format": "int64" + } + } + }, + "Episodes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Episode" + } + }, + "EpisodeCreateRequest": { + "type": "object", + "required": [ + "user_id", + "updated_by", + "title", + "slug", + "podcast_id", + "audio_file" + ], + "properties": { + "user_id": { + "type": "integer", + "format": "int64", + "description": "ID of the user creating the episode" + }, + "updated_by": { + "type": "integer", + "format": "int64", + "description": "ID of the user updating the episode" + }, + "title": { + "type": "string", + "description": "Title of the episode" + }, + "slug": { + "type": "string", + "description": "URL-friendly slug of the episode" + }, + "podcast_id": { + "type": "integer", + "format": "int64", + "description": "ID of the podcast the episode belongs to" + }, + "audio_file": { + "type": "string", + "format": "binary", + "description": "Audio file for the episode" + }, + "cover": { + "type": "string", + "format": "binary", + "description": "Cover image for the episode" + }, + "description": { + "type": "string", + "description": "Description of the episode" + }, + "location_name": { + "type": "string", + "description": "Location associated with the episode" + }, + "parental_advisory": { + "type": "string", + "enum": ["clean", "explicit"], + "description": "Parental advisory rating" + }, + "episode_number": { + "type": "integer", + "format": "int32", + "description": "Episode number (for serial podcasts)" + }, + "season_number": { + "type": "integer", + "format": "int32", + "description": "Season number (for serial podcasts)" + }, + "type": { + "type": "string", + "enum": ["full", "trailer", "bonus"], + "description": "Type of episode" + }, + "block": { + "type": "string", + "enum": ["yes", "no"], + "description": "Block episode from being published" + }, + "custom_rss": { + "type": "string", + "description": "Custom RSS content" + }, + "premium": { + "type": "string", + "enum": ["yes", "no"], + "description": "Mark episode as premium content" + }, + "transcript-choice": { + "type": "string", + "enum": ["upload-file", "remote-url"], + "description": "Transcript source choice" + }, + "transcript_file": { + "type": "string", + "format": "binary", + "description": "Transcript file" + }, + "transcript_remote_url": { + "type": "string", + "format": "uri", + "description": "Remote URL for transcript" + }, + "chapters-choice": { + "type": "string", + "enum": ["upload-file", "remote-url"], + "description": "Chapters source choice" + }, + "chapters_file": { + "type": "string", + "format": "binary", + "description": "Chapters file" + }, + "chapters_remote_url": { + "type": "string", + "format": "uri", + "description": "Remote URL for chapters" + } + } + }, + "EpisodePublishRequest": { + "type": "object", + "required": ["publication_method"], + "properties": { + "publication_method": { + "type": "string", + "enum": ["now", "schedule", "with_podcast"], + "description": "Method of publication" + }, + "scheduled_publication_date": { + "type": "string", + "format": "date-time", + "description": "Scheduled date and time for publication" + }, + "client_timezone": { + "type": "string", + "description": "Timezone of the client" + } + } + }, "Error": { "type": "object", "properties": {