Commit 9c224a8a authored by Yassine Doghri's avatar Yassine Doghri
Browse files

feat: add pages table to store custom instance pages (eg. legal-notice, cookie policy, etc.)

- add pages  migration, model and entity
- add page controllers
- update routes config to input page forms and page view in public
- fix markdow editor focus area
- show pages links in public side footer

closes #24
parent a1a28de7
......@@ -30,7 +30,7 @@ $routes->setAutoRoute(false);
*/
$routes->addPlaceholder('podcastName', '[a-zA-Z0-9\_]{1,191}');
$routes->addPlaceholder('episodeSlug', '[a-zA-Z0-9\-]{1,191}');
$routes->addPlaceholder('slug', '[a-zA-Z0-9\-]{1,191}');
/**
* --------------------------------------------------------------------
......@@ -53,15 +53,6 @@ $routes->group(config('App')->installGateway, function ($routes) {
]);
});
// Public routes
$routes->group('@(:podcastName)', function ($routes) {
$routes->get('/', 'Podcast/$1', ['as' => 'podcast']);
$routes->get('(:episodeSlug)', 'Episode/$1/$2', [
'as' => 'episode',
]);
$routes->get('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']);
});
// Route for podcast audio file analytics (/stats/podcast_id/episode_id/podcast_folder/filename.mp3)
$routes->add('stats/(:num)/(:num)/(:any)', 'Analytics::hit/$1/$2/$3', [
'as' => 'analytics_hit',
......@@ -80,10 +71,6 @@ $routes->group(
'as' => 'admin',
]);
$routes->get('my-podcasts', 'Podcast::myPodcasts', [
'as' => 'my-podcasts',
]);
// Podcasts
$routes->group('podcasts', function ($routes) {
$routes->get('/', 'Podcast::list', [
......@@ -201,6 +188,27 @@ $routes->group(
});
});
// Pages
$routes->group('pages', function ($routes) {
$routes->get('/', 'Page::list', ['as' => 'page-list']);
$routes->get('new', 'Page::create', [
'as' => 'page-create',
]);
$routes->post('new', 'Page::attemptCreate');
$routes->group('(:num)', function ($routes) {
$routes->get('/', 'Page::view/$1', ['as' => 'page-view']);
$routes->get('edit', 'Page::edit/$1', [
'as' => 'page-edit',
]);
$routes->post('edit', 'Page::attemptEdit/$1');
$routes->add('delete', 'Page::delete/$1', [
'as' => 'page-delete',
]);
});
});
// Users
$routes->group('users', function ($routes) {
$routes->get('/', 'User::list', [
......@@ -294,6 +302,16 @@ $routes->group(config('App')->authGateway, function ($routes) {
$routes->post('reset-password', 'Auth::attemptReset');
});
// Public routes
$routes->group('@(:podcastName)', function ($routes) {
$routes->get('/', 'Podcast/$1', ['as' => 'podcast']);
$routes->get('(:slug)', 'Episode/$1/$2', [
'as' => 'episode',
]);
$routes->get('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']);
});
$routes->get('/(:slug)', 'Page/$1', ['as' => 'page']);
/**
* --------------------------------------------------------------------
* Additional Routing
......
......@@ -19,6 +19,7 @@ class Validation
\CodeIgniter\Validation\FormatRules::class,
\CodeIgniter\Validation\FileRules::class,
\CodeIgniter\Validation\CreditCardRules::class,
\App\Validation\Rules::class,
\Myth\Auth\Authentication\Passwords\ValidationRules::class,
];
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Controllers\Admin;
use App\Models\PageModel;
class Page extends BaseController
{
/**
* @var \App\Entities\Page|null
*/
protected $page;
public function _remap($method, ...$params)
{
if (count($params) > 0) {
if (!($this->page = (new PageModel())->find($params[0]))) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
return $this->$method();
}
function list()
{
$data = [
'pages' => (new PageModel())->findAll(),
];
return view('admin/page/list', $data);
}
function view()
{
return view('admin/page/view', ['page' => $this->page]);
}
function create()
{
helper('form');
return view('admin/page/create');
}
function attemptCreate()
{
$page = new \App\Entities\Page([
'title' => $this->request->getPost('title'),
'slug' => $this->request->getPost('slug'),
'content' => $this->request->getPost('content'),
]);
$pageModel = new PageModel();
if (!$pageModel->save($page)) {
return redirect()
->back()
->withInput()
->with('errors', $pageModel->errors());
}
return redirect()
->route('page-list')
->with(
'message',
lang('Page.messages.createSuccess', [
'pageTitle' => $page->title,
])
);
}
function edit()
{
helper('form');
replace_breadcrumb_params([0 => $this->page->title]);
return view('admin/page/edit', ['page' => $this->page]);
}
function attemptEdit()
{
$this->page->title = $this->request->getPost('title');
$this->page->slug = $this->request->getPost('slug');
$this->page->content = $this->request->getPost('content');
$pageModel = new PageModel();
if (!$pageModel->save($this->page)) {
return redirect()
->back()
->withInput()
->with('errors', $pageModel->errors());
}
return redirect()->route('page-list');
}
public function delete()
{
(new PageModel())->delete($this->page->id);
return redirect()->route('page-list');
}
}
......@@ -31,23 +31,16 @@ class Podcast extends BaseController
return $this->$method();
}
public function myPodcasts()
{
$data = [
'podcasts' => (new PodcastModel())->getUserPodcasts(user()->id),
];
return view('admin/podcast/list', $data);
}
public function list()
{
if (!has_permission('podcasts-list')) {
return redirect()->route('my-podcasts');
$data = [
'podcasts' => (new PodcastModel())->getUserPodcasts(user()->id),
];
} else {
$data = ['podcasts' => (new PodcastModel())->findAll()];
}
$data = ['podcasts' => (new PodcastModel())->findAll()];
return view('admin/podcast/list', $data);
}
......@@ -155,7 +148,7 @@ class Podcast extends BaseController
$db->transComplete();
return redirect()->route('podcast-list');
return redirect()->route('podcast-view', [$newPodcastId]);
}
public function edit()
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Controllers;
use App\Models\PageModel;
class Page extends BaseController
{
/**
* @var \App\Entities\Page|null
*/
protected $page;
public function _remap($method, ...$params)
{
if (count($params) > 0) {
if (
!($this->page = (new PageModel())
->where('slug', $params[0])
->first())
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
return $this->$method();
}
public function index()
{
// The page cache is set to a decade so it is deleted manually upon page update
$this->cachePage(DECADE);
$data = [
'page' => $this->page,
];
return view('page', $data);
}
}
......@@ -54,17 +54,15 @@ class AddPodcasts extends Migration
'constraint' => 1,
'default' => 0,
],
'author' => [
'owner_name' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'null' => true,
],
'owner_name' => [
'owner_email' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'null' => true,
],
'owner_email' => [
'author' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'null' => true,
......
......@@ -41,7 +41,6 @@ class AddEpisodes extends Migration
'type' => 'VARCHAR',
'constraint' => 1024,
],
'description' => [
'type' => 'TEXT',
'null' => true,
......
<?php
/**
* Class AddLanguages
* Creates languages table in database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddPages extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'title' => [
'type' => 'VARCHAR',
'constraint' => 1024,
],
'slug' => [
'type' => 'VARCHAR',
'constraint' => 191,
'unique' => true,
],
'content' => [
'type' => 'TEXT',
],
'created_at' => [
'type' => 'TIMESTAMP',
],
'updated_at' => [
'type' => 'TIMESTAMP',
],
'deleted_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->createTable('pages');
}
public function down()
{
$this->forge->dropTable('pages');
}
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use CodeIgniter\Entity;
use League\CommonMark\CommonMarkConverter;
class Page extends Entity
{
/**
* @var string
*/
protected $link;
/**
* @var string
*/
protected $content_html;
protected $casts = [
'id' => 'integer',
'title' => 'string',
'slug' => 'string',
'content' => 'string',
];
public function getLink()
{
return base_url($this->attributes['slug']);
}
public function getContentHtml()
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
return $converter->convertToHtml($this->attributes['content']);
}
}
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
use App\Models\PageModel;
/**
* Returns instance pages as links inside nav tag
*
* @return string html pages navigation
*/
function render_page_links()
{
$pages = (new PageModel())->findAll();
$links = '';
foreach ($pages as $page) {
$links .= anchor($page->link, $page->title, [
'class' => 'px-2 underline hover:no-underline',
]);
}
return '<nav class="inline-flex">' . $links . '</nav>';
}
......@@ -95,13 +95,9 @@ function get_rss_feed($podcast)
$channel->addChild('author', $podcast->author, $itunes_namespace);
$channel->addChild('link', $podcast->link);
if ($podcast->owner_name || $podcast->owner_email) {
$owner = $channel->addChild('owner', null, $itunes_namespace);
$podcast->owner_name &&
$owner->addChild('name', $podcast->owner_name, $itunes_namespace);
$podcast->owner_email &&
$owner->addChild('email', $podcast->owner_email, $itunes_namespace);
}
$owner = $channel->addChild('owner', null, $itunes_namespace);
$owner->addChild('name', $podcast->owner_name, $itunes_namespace);
$owner->addChild('email', $podcast->owner_email, $itunes_namespace);
$channel->addChild('type', $podcast->type, $itunes_namespace);
$podcast->copyright && $channel->addChild('copyright', $podcast->copyright);
......
......@@ -10,11 +10,13 @@ return [
'dashboard' => 'Dashboard',
'podcasts' => 'Podcasts',
'users' => 'Users',
'pages' => 'Pages',
'admin' => 'Home',
'my-podcasts' => 'My podcasts',
'podcast-list' => 'All podcasts',
'podcast-create' => 'New podcast',
'user-list' => 'All users',
'user-create' => 'New user',
'page-list' => 'All pages',
'page-create' => 'New Page',
'go_to_website' => 'Go to website',
];
......@@ -9,10 +9,10 @@
return [
'label' => 'breadcrumb',
config('App')->adminGateway => 'Home',
'my-podcasts' => 'my podcasts',
'podcasts' => 'podcasts',
'episodes' => 'episodes',
'contributors' => 'contributors',
'pages' => 'pages',
'add' => 'add',
'new' => 'new',
'edit' => 'edit',
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'all_pages' => 'All pages',
'create' => 'New page',
'go_to_page' => 'Go to page',
'edit' => 'Edit page',
'delete' => 'Delete page',
'form' => [
'title' => 'Title',
'slug' => 'Slug',
'content' => 'Content',
'submit_create' => 'Create page',
'submit_edit' => 'Save',
],
'messages' => [
'createSuccess' => 'The {pageTitle} page was created successfully!',
],
];
......@@ -42,15 +42,15 @@ return [
'explicit' => 'Explicit',
'explicit_help' =>
'The podcast parental advisory information. Does it contain explicit content?',
'author' => 'Author',
'author_help' =>
'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.',
'owner_name' => 'Owner name',
'owner_name_help' =>
'The podcast owner contact name. For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'owner_email' => 'Owner email',
'owner_email_help' =>
'The podcast owner contact e-mail. For administrative use only. It will mostly be used by some platforms to verify this podcast ownerhip. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'author' => 'Author',
'author_help' =>
'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.',
'type' => [
'label' => 'Type',
'episodic' => 'Episodic',
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'not_in_protected_slugs' =>
'The {field} field conflicts with one of the gateway routes (admin, auth or install).',
];
......@@ -53,8 +53,10 @@ class EpisodeModel extends Model
];
protected $validationMessages = [];
protected $afterInsert = ['writeEnclosureMetadata', 'clearCache'];
protected $afterUpdate = ['writeEnclosureMetadata', 'clearCache'];
protected $afterInsert = ['writeEnclosureMetadata'];
// clear cache beforeUpdate because if slug changes, so will the episode link
protected $beforeUpdate = ['clearCache'];
protected $afterUpdate = ['writeEnclosureMetadata'];
protected $beforeDelete = ['clearCache'];
protected function writeEnclosureMetadata(array $data)
......
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Models;
use CodeIgniter\Model;
class PageModel extends Model
{
protected $table = 'pages';
protected $primaryKey = 'id';
protected $allowedFields = ['id', 'title', 'slug', 'content'];
protected $returnType = \App\Entities\Page::class;
protected $useSoftDeletes = true;
protected $useTimestamps = true;
protected $validationRules = [
'title' => 'required',
'slug' =>
'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]|is_unique[pages.slug,id,{id}]|not_in_protected_slugs',
'content' => 'required',
];
protected $validationMessages = [];
// Before update because slug might change
protected $beforeUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];
protected function clearCache(array $data)
{
$page = (new PageModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
// delete page cache