feat: update analytics so to meet IABv2 requirements

- https://iabtechlab.com/wp-content/uploads/2017/12/Podcast_Measurement_v2-Dec-20-2017.pdf
  - no IP address is ever stored on the server. Only aggregate data is stored in the dababase.
  - rolling 24-hour window
  - castopod does not do pre-load
  - IP Blacklisting https://github.com/client9/ipcat
  - user-agent Filtering https://github.com/opawg/user-agents
  - ignores 2 bytes range "Range: 0-1" (performed by official Apple iOS Podcast app)
  - in case of partial content, adds up all requests to check >1mn was downloaded
  - identifying Uniques is done with a combination of IP Address and User Agent
- add AMcharts
- add some graphs
- add regions to analytics
- add ipcat blacklist
- enhance useragents performances
- add filesize and header size in order to calculate 1mn downloads
- update publisher ID3 field
- update castopod icon
- add disclaimer and warning import form translation
- update docs/setup-development.md

closes #10
This commit is contained in:
Benjamin Bellamy 2020-10-06 15:39:27 +00:00 committed by Yassine Doghri
parent 5417be0049
commit 03e23a28bf
61 changed files with 3163 additions and 541 deletions

View File

@ -39,13 +39,11 @@ bundle_app:
script:
# build all assets for views
- npm run build
# download GeoLite2-Country and opawg/user-agents archives and extract them to writable/uploads
- wget -c "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=$MAXMIND_LICENCE_KEY&suffix=tar.gz" -O - | tar -xz -C ./writable/uploads/
- wget -c "https://github.com/opawg/user-agents/archive/master.tar.gz" -O - | tar -xz -C ./writable/uploads/
# download GeoLite2-City archive and extract it to writable/uploads
- wget -c "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=$MAXMIND_LICENCE_KEY&suffix=tar.gz" -O - | tar -xz -C ./writable/uploads/
# rename extracted archives' folders
- mv ./writable/uploads/GeoLite2-Country* ./writable/uploads/GeoLite2-Country
- mv ./writable/uploads/user-agents* ./writable/uploads/user-agents
- mv ./writable/uploads/GeoLite2-City* ./writable/uploads/GeoLite2-City
# create bundle folder: uses .rsync-filter (-F) file to copy only needed files
- rsync -avF --progress . ./bundle

View File

@ -12,16 +12,20 @@ PHP Dependencies:
- [commonmark](https://commonmark.thephpleague.com/) ([BSD 3-Clause "New" or "Revised" License](https://github.com/thephpleague/commonmark/blob/latest/LICENSE))
- [phpdotenv](https://github.com/vlucas/phpdotenv) ([ BSD-3-Clause License ](https://github.com/vlucas/phpdotenv/blob/master/LICENSE))
- [HTML To Markdown for PHP](https://github.com/thephpleague/html-to-markdown) ([MIT License](https://github.com/thephpleague/html-to-markdown/blob/master/LICENSE))
- [podlibre/user-agents-php](https://github.com/podlibre/user-agents-php) ([MIT License](https://github.com/podlibre/user-agents-php/blob/main/LICENSE))
- [podlibre/ipcat](https://github.com/podlibre/ipcat) ([GNU General Public License v3.0](https://github.com/podlibre/ipcat/blob/master/LICENSE))
Javascript dependencies:
- [rollup](https://rollupjs.org/) ([MIT License](https://github.com/rollup/rollup/blob/master/LICENSE.md))
- [tailwindcss](https://tailwindcss.com/) ([MIT License](https://github.com/tailwindcss/tailwindcss/blob/master/LICENSE))
- [ProseMirror](https://prosemirror.net/) ([MIT License](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE))
- [D3: Data-Driven Documents](https://d3js.org) ([BSD 3-Clause "New" or "Revised" License](https://github.com/d3/d3/blob/master/LICENSE))
- [amCharts 4](https://github.com/amcharts/amcharts4) ([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/) ([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
Other:
- [RemixIcon](https://remixicon.com/) ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License))
- [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))
- [OPAWG/User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE))
- [client9/ipcat](https://github.com/client9/ipcat) ([GNU General Public License v3.0](https://github.com/client9/ipcat/blob/master/LICENSE))
- [GeoLite2 City](https://dev.maxmind.com/geoip/geoip2/geolite2/) ([Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)](https://www.maxmind.com/en/geolite2/eula))

View File

@ -53,10 +53,14 @@ $routes->group(config('App')->installGateway, function ($routes) {
]);
});
// 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',
]);
// Route for podcast audio file analytics (/audio/podcast_id/episode_id/bytes_threshold/filesize/podcast_folder/filename.mp3)
$routes->add(
'audio/(:num)/(:num)/(:num)/(:num)/(:any)',
'Analytics::hit/$1/$2/$3/$4/$5',
[
'as' => 'analytics_hit',
]
);
// Show the Unknown UserAgents
$routes->get('.well-known/unknown-useragents', 'UnknownUserAgents');
@ -113,6 +117,26 @@ $routes->group(
'as' => 'podcast-delete',
'filter' => 'permission:podcasts-delete',
]);
$routes->get('analytics', 'Podcast::analytics/$1', [
'as' => 'podcast-analytics',
'filter' => 'permission:podcasts-view,podcast-view',
]);
$routes->get(
'analytics-data/(:segment)/(:segment)',
'AnalyticsData::getData/$1/$2/$3',
[
'as' => 'analytics-data',
'filter' => 'permission:podcasts-view,podcast-view',
]
);
$routes->get(
'analytics-data/(:segment)/(:segment)/(:num)',
'AnalyticsData::getData/$1/$2/$3/$4',
[
'as' => 'analytics-filtered-data',
'filter' => 'permission:podcasts-view,podcast-view',
]
);
// Podcast episodes
$routes->group('episodes', function ($routes) {

View File

@ -0,0 +1,69 @@
<?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\PodcastModel;
use App\Models\EpisodeModel;
class AnalyticsData extends BaseController
{
/**
* @var \App\Entities\Podcast|null
*/
protected $podcast;
protected $className;
protected $methodName;
protected $episode;
public function _remap($method, ...$params)
{
if (count($params) > 2) {
if (!($this->podcast = (new PodcastModel())->find($params[0]))) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(
'Podcast not found: ' . $params[0]
);
}
$this->className = '\App\Models\Analytics' . $params[1] . 'Model';
$this->methodName = 'getData' . $params[2];
if (count($params) > 3) {
if (
!($this->episode = (new EpisodeModel())
->where([
'podcast_id' => $this->podcast->id,
'id' => $params[3],
])
->first())
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(
'Episode not found: ' . $params[3]
);
}
}
}
return $this->$method();
}
public function getData()
{
$analytics_model = new $this->className();
$methodName = $this->methodName;
if ($this->episode) {
return $this->response->setJSON(
$analytics_model->$methodName(
$this->podcast->id,
$this->episode->id
)
);
} else {
return $this->response->setJSON(
$analytics_model->$methodName($this->podcast->id)
);
}
}
}

View File

@ -58,6 +58,14 @@ class Podcast extends BaseController
return view('admin/podcast/view', $data);
}
public function analytics()
{
$data = ['podcast' => $this->podcast];
replace_breadcrumb_params([0 => $this->podcast->title]);
return view('admin/podcast/analytics', $data);
}
public function create()
{
helper(['form', 'misc']);
@ -204,7 +212,9 @@ class Podcast extends BaseController
$podcast = new \App\Entities\Podcast([
'name' => $this->request->getPost('name'),
'imported_feed_url' => $this->request->getPost('imported_feed_url'),
'new_feed_url' => base_url(
route_to('podcast_feed', $this->request->getPost('name'))
),
'title' => $feed->channel[0]->title,
'description' => $feed->channel[0]->description,
'image' => download_file($nsItunes->image->attributes()),
@ -214,7 +224,9 @@ class Podcast extends BaseController
? null
: (in_array($nsItunes->explicit, ['yes', 'true'])
? 'explicit'
: null),
: (in_array($nsItunes->explicit, ['no', 'false'])
? 'clean'
: null)),
'owner_name' => $nsItunes->owner->name,
'owner_email' => $nsItunes->owner->email,
'publisher' => $nsItunes->author,
@ -302,11 +314,13 @@ class Podcast extends BaseController
'image' => empty($nsItunes->image->attributes())
? null
: download_file($nsItunes->image->attributes()),
'explicit' => $nsItunes->explicit
? (in_array($nsItunes->explicit, ['yes', 'true'])
'parental_advisory' => empty($nsItunes->explicit)
? null
: (in_array($nsItunes->explicit, ['yes', 'true'])
? 'explicit'
: null)
: null,
: (in_array($nsItunes->explicit, ['no', 'false'])
? 'clean'
: null)),
'number' =>
$this->request->getPost('force_renumber') === 'yes'
? $itemNumber

View File

@ -40,16 +40,22 @@ class Analytics extends Controller
// E.g.:
// $this->session = \Config\Services::session();
set_user_session_country();
set_user_session_deny_list_ip();
set_user_session_location();
set_user_session_player();
}
// Add one hit to this episode:
public function hit($p_podcastId, $p_episodeId, ...$filename)
{
public function hit(
$podcastId,
$episodeId,
$bytesThreshold,
$fileSize,
...$filename
) {
helper('media');
podcast_hit($p_podcastId, $p_episodeId);
podcast_hit($podcastId, $episodeId, $bytesThreshold, $fileSize);
return redirect()->to(media_url(implode('/', $filename)));
}
}

View File

@ -45,9 +45,10 @@ class BaseController extends Controller
// E.g.:
// $this->session = \Config\Services::session();
set_user_session_country();
set_user_session_deny_list_ip();
set_user_session_browser();
set_user_session_referer();
set_user_session_entry_page();
}
protected static function triggerWebpageHit($podcastId)

View File

@ -110,6 +110,13 @@ class AddPodcasts extends Migration
'The RSS feed URL if this podcast was imported, NULL otherwise.',
'null' => true,
],
'new_feed_url' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'comment' =>
'The RSS new feed URL if this podcast is moving out, NULL otherwise.',
'null' => true,
],
'created_at' => [
'type' => 'TIMESTAMP',
],

View File

@ -61,6 +61,12 @@ class AddEpisodes extends Migration
'unsigned' => true,
'comment' => 'File size in bytes',
],
'enclosure_headersize' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
'comment' => 'Header size in bytes',
],
'description' => [
'type' => 'TEXT',
'null' => true,

View File

@ -0,0 +1,49 @@
<?php
/**
* Class AddAnalyticsPodcastsByCountry
* Creates analytics_podcasts_by_country 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 AddAnalyticsPodcasts extends Migration
{
public function up()
{
$this->forge->addField([
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'date' => [
'type' => 'date',
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addPrimaryKey(['podcast_id', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
$this->forge->addField(
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->createTable('analytics_podcasts');
}
public function down()
{
$this->forge->dropTable('analytics_podcasts');
}
}

View File

@ -12,34 +12,28 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAnalyticsEpisodesByCountry extends Migration
class AddAnalyticsPodcastsByEpisode extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'date' => [
'type' => 'date',
],
'episode_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'country_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-1 code.',
],
'date' => [
'type' => 'date',
'age' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
],
'hits' => [
'type' => 'INT',
@ -47,13 +41,7 @@ class AddAnalyticsEpisodesByCountry extends Migration
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey([
'podcast_id',
'episode_id',
'country_code',
'date',
]);
$this->forge->addPrimaryKey(['podcast_id', 'episode_id', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
@ -62,11 +50,11 @@ class AddAnalyticsEpisodesByCountry extends Migration
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('episode_id', 'episodes', 'id');
$this->forge->createTable('analytics_episodes_by_country');
$this->forge->createTable('analytics_podcasts_by_episode');
}
public function down()
{
$this->forge->dropTable('analytics_episodes_by_country');
$this->forge->dropTable('analytics_podcasts_by_episode');
}
}

View File

@ -17,32 +17,45 @@ class AddAnalyticsPodcastsByPlayer extends Migration
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'player' => [
'type' => 'VARCHAR',
'constraint' => 191,
],
'date' => [
'type' => 'date',
],
'app' => [
'type' => 'VARCHAR',
'constraint' => 128,
],
'device' => [
'type' => 'VARCHAR',
'constraint' => 32,
],
'os' => [
'type' => 'VARCHAR',
'constraint' => 32,
],
'bot' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'player', 'date']);
$this->forge->addPrimaryKey([
'podcast_id',
'app',
'device',
'os',
'bot',
'date',
]);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);

View File

@ -17,33 +17,26 @@ class AddAnalyticsPodcastsByCountry extends Migration
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'date' => [
'type' => 'date',
],
'country_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-1 code.',
],
'date' => [
'type' => 'date',
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'country_code', 'date']);
$this->forge->addPrimaryKey(['podcast_id', 'country_code', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);

View File

@ -1,8 +1,8 @@
<?php
/**
* Class AddAnalyticsWebsiteByCountry
* Creates analytics_website_by_country table in database
* Class AddAnalyticsPodcastsByRegion
* Creates analytics_podcasts_by_region table in database
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
@ -12,29 +12,36 @@ namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAnalyticsWebsiteByCountry extends Migration
class AddAnalyticsPodcastsByRegion extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'date' => [
'type' => 'date',
],
'country_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-1 code.',
],
'date' => [
'type' => 'date',
'region_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-2 code.',
],
'latitude' => [
'type' => 'FLOAT',
'null' => true,
],
'longitude' => [
'type' => 'FLOAT',
'null' => true,
],
'hits' => [
'type' => 'INT',
@ -42,8 +49,12 @@ class AddAnalyticsWebsiteByCountry extends Migration
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'country_code', 'date']);
$this->forge->addPrimaryKey([
'podcast_id',
'country_code',
'region_code',
'date',
]);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
@ -51,11 +62,11 @@ class AddAnalyticsWebsiteByCountry extends Migration
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->createTable('analytics_website_by_country');
$this->forge->createTable('analytics_podcasts_by_region');
}
public function down()
{
$this->forge->dropTable('analytics_website_by_country');
$this->forge->dropTable('analytics_podcasts_by_region');
}
}

View File

@ -17,32 +17,26 @@ class AddAnalyticsWebsiteByBrowser extends Migration
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'date' => [
'type' => 'date',
],
'browser' => [
'type' => 'VARCHAR',
'constraint' => 191,
],
'date' => [
'type' => 'date',
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'browser', 'date']);
$this->forge->addPrimaryKey(['podcast_id', 'browser', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);

View File

@ -17,33 +17,36 @@ class AddAnalyticsWebsiteByReferer extends Migration
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'referer' => [
'type' => 'VARCHAR',
'constraint' => 191,
'comment' => 'Referer URL.',
],
'date' => [
'type' => 'date',
],
'referer' => [
'type' => 'VARCHAR',
'constraint' => 512,
'comment' => 'Referer URL.',
],
'domain' => [
'type' => 'VARCHAR',
'constraint' => 128,
'null' => true,
],
'keywords' => [
'type' => 'VARCHAR',
'constraint' => 384,
'null' => true,
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['podcast_id', 'referer', 'date']);
$this->forge->addPrimaryKey(['podcast_id', 'referer', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);

View File

@ -0,0 +1,54 @@
<?php
/**
* Class AddAnalyticsWebsiteByReferer
* Creates analytics_website_by_referer 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 AddAnalyticsWebsiteByEntryPage extends Migration
{
public function up()
{
$this->forge->addField([
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'date' => [
'type' => 'date',
],
'entry_page' => [
'type' => 'VARCHAR',
'constraint' => 512,
'comment' => 'Entry page URL.',
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addPrimaryKey(['podcast_id', 'entry_page', 'date']);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
$this->forge->addField(
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->createTable('analytics_website_by_entry_page');
}
public function down()
{
$this->forge->dropTable('analytics_website_by_entry_page');
}
}

View File

@ -1,71 +0,0 @@
<?php
/**
* Class AddAnalyticsEpisodesByPlayer
* Creates analytics_episodes_by_player 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 AddAnalyticsEpisodesByPlayer extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
'auto_increment' => true,
],
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'episode_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'player' => [
'type' => 'VARCHAR',
'constraint' => 191,
],
'date' => [
'type' => 'date',
],
'hits' => [
'type' => 'INT',
'constraint' => 10,
'default' => 1,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey([
'podcast_id',
'episode_id',
'player',
'date',
]);
$this->forge->addField(
'`created_at` timestamp NOT NULL DEFAULT current_timestamp()'
);
$this->forge->addField(
'`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()'
);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('episode_id', 'episodes', 'id');
$this->forge->createTable('analytics_episodes_by_player');
}
public function down()
{
$this->forge->dropTable('analytics_episodes_by_player');
}
}

View File

@ -18,26 +18,42 @@ class AddAnalyticsPodcastsStoredProcedure extends Migration
{
// Creates Stored Procedure for data insertion
// Example: CALL analytics_podcasts(1,2,'FR','phone/android/Deezer');
$procedureName = $this->db->prefixTable('analytics_podcasts');
$episodesTableName = $this->db->prefixTable('analytics_episodes');
$prefix = $this->db->getPrefix();
$createQuery = <<<EOD
CREATE PROCEDURE `$procedureName` (IN `p_podcast_id` BIGINT(20) UNSIGNED, IN `p_episode_id` BIGINT(20) UNSIGNED, IN `p_country_code` VARCHAR(3) CHARSET utf8mb4, IN `p_player` VARCHAR(191) CHARSET utf8mb4) MODIFIES SQL DATA
CREATE PROCEDURE `{$prefix}analytics_podcasts` (
IN `p_podcast_id` BIGINT(20) UNSIGNED,
IN `p_episode_id` BIGINT(20) UNSIGNED,
IN `p_country_code` VARCHAR(3) CHARSET utf8mb4,
IN `p_region_code` VARCHAR(3) CHARSET utf8mb4,
IN `p_latitude` FLOAT,
IN `p_longitude` FLOAT,
IN `p_app` VARCHAR(128) CHARSET utf8mb4,
IN `p_device` VARCHAR(32) CHARSET utf8mb4,
IN `p_os` VARCHAR(32) CHARSET utf8mb4,
IN `p_bot` TINYINT(1) UNSIGNED
) MODIFIES SQL DATA
DETERMINISTIC
SQL SECURITY INVOKER
COMMENT 'Add one hit in podcast logs tables.'
BEGIN
INSERT INTO `{$procedureName}_by_country`(`podcast_id`, `country_code`, `date`)
VALUES (p_podcast_id, p_country_code, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO `{$procedureName}_by_player`(`podcast_id`, `player`, `date`)
VALUES (p_podcast_id, p_player, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO `{$episodesTableName}_by_country`(`podcast_id`, `episode_id`, `country_code`, `date`)
VALUES (p_podcast_id, p_episode_id, p_country_code, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO `{$episodesTableName}_by_player`(`podcast_id`, `episode_id`, `player`, `date`)
VALUES (p_podcast_id, p_episode_id, p_player, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
IF NOT `p_bot` THEN
INSERT INTO `{$prefix}analytics_podcasts`(`podcast_id`, `date`)
VALUES (p_podcast_id, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO `{$prefix}analytics_podcasts_by_episode`(`podcast_id`, `episode_id`, `date`, `age`)
SELECT p_podcast_id, p_episode_id, DATE(NOW()), datediff(now(),`published_at`) FROM `{$prefix}episodes` WHERE `id`= p_episode_id
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO `{$prefix}analytics_podcasts_by_country`(`podcast_id`, `country_code`, `date`)
VALUES (p_podcast_id, p_country_code, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO `{$prefix}analytics_podcasts_by_region`(`podcast_id`, `country_code`, `region_code`, `latitude`, `longitude`, `date`)
VALUES (p_podcast_id, p_country_code, p_region_code, p_latitude, p_longitude, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
END IF;
INSERT INTO `{$prefix}analytics_podcasts_by_player`(`podcast_id`, `app`, `device`, `os`, `bot`, `date`)
VALUES (p_podcast_id, p_app, p_device, p_os, p_bot, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
END
EOD;
$this->db->query($createQuery);
@ -45,7 +61,9 @@ EOD;
public function down()
{
$procedureName = $this->db->prefixTable('analytics_podcasts');
$this->db->query("DROP PROCEDURE IF EXISTS `$procedureName`");
$prefix = $this->db->getPrefix();
$this->db->query(
"DROP PROCEDURE IF EXISTS `{$prefix}analytics_podcasts`"
);
}
}

View File

@ -20,20 +20,20 @@ class AddAnalyticsWebsiteStoredProcedure extends Migration
// Example: CALL analytics_website(1,'FR','Firefox');
$procedureName = $this->db->prefixTable('analytics_website');
$createQuery = <<<EOD
CREATE PROCEDURE `$procedureName` (IN `p_podcast_id` BIGINT(20) UNSIGNED, IN `p_country_code` VARCHAR(3) CHARSET utf8mb4, IN `p_browser` VARCHAR(191) CHARSET utf8mb4, IN `p_referer` VARCHAR(191) CHARSET utf8mb4) MODIFIES SQL DATA
CREATE PROCEDURE `$procedureName` (IN `p_podcast_id` BIGINT(20) UNSIGNED, IN `p_browser` VARCHAR(191) CHARSET utf8mb4, IN `p_entry_page` VARCHAR(512) CHARSET utf8mb4, IN `p_referer` VARCHAR(512) CHARSET utf8mb4, IN `p_domain` VARCHAR(128) CHARSET utf8mb4, IN `p_keywords` VARCHAR(384) CHARSET utf8mb4) MODIFIES SQL DATA
DETERMINISTIC
SQL SECURITY INVOKER
COMMENT 'Add one hit in website logs tables.'
BEGIN
INSERT INTO {$procedureName}_by_country(`podcast_id`, `country_code`, `date`)
VALUES (p_podcast_id, p_country_code, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO {$procedureName}_by_browser(`podcast_id`, `browser`, `date`)
VALUES (p_podcast_id, p_browser, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO {$procedureName}_by_referer(`podcast_id`, `referer`, `date`)
VALUES (p_podcast_id, p_referer, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
VALUES (p_podcast_id, p_browser, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO {$procedureName}_by_referer(`podcast_id`, `referer`, `domain`, `keywords`, `date`)
VALUES (p_podcast_id, p_referer, p_domain, p_keywords, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
INSERT INTO {$procedureName}_by_entry_page(`podcast_id`, `entry_page`, `date`)
VALUES (p_podcast_id, p_entry_page, DATE(NOW()))
ON DUPLICATE KEY UPDATE `hits`=`hits`+1;
END
EOD;
$this->db->query($createQuery);

View File

@ -0,0 +1,176 @@
<?php
/**
* Class FakePodcastsAnalyticsSeeder
* Inserts Fake Analytics in the database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Seeds;
use App\Models\PodcastModel;
use App\Models\EpisodeModel;
use CodeIgniter\Database\Seeder;
class FakePodcastsAnalyticsSeeder extends Seeder
{
public function run()
{
$podcast = (new PodcastModel())->first();
$jsonUserAgents = json_decode(
file_get_contents(
'https://raw.githubusercontent.com/opawg/user-agents/master/src/user-agents.json'
),
true
);
if ($podcast) {
$firstEpisode = (new EpisodeModel())
->selectMin('published_at')
->first();
for (
$date = strtotime($firstEpisode->published_at);
$date < strtotime('now');
$date = strtotime(date('Y-m-d', $date) . ' +1 day')
) {
$analytics_podcasts = [];
$analytics_podcasts_by_country = [];
$analytics_podcasts_by_episode = [];
$analytics_podcasts_by_player = [];
$analytics_podcasts_by_region = [];
$episodes = (new EpisodeModel())
->where([
'podcast_id' => $podcast->id,
'DATE(published_at) <=' => date('Y-m-d', $date),
])
->findAll();
foreach ($episodes as $episode) {
$age = floor(
($date - strtotime($episode->published_at)) / 86400
);
$proba1 = floor(exp(3 - $age / 40)) + 1;
for (
$num_line = 0;
$num_line < rand(1, $proba1);
$num_line++
) {
$proba2 = floor(exp(6 - $age / 20)) + 10;
$player =
$jsonUserAgents[
rand(1, count($jsonUserAgents) - 1)
];
$app = isset($player['app']) ? $player['app'] : '';
$device = isset($player['device'])
? $player['device']
: '';
$os = isset($player['os']) ? $player['os'] : '';
$bot = isset($player['bot']) ? $player['bot'] : 0;
$fakeIp =
rand(0, 255) .
'.' .
rand(0, 255) .
'.' .
rand(0, 255) .
'.' .
rand(0, 255);
$cityReader = new \GeoIp2\Database\Reader(
WRITEPATH .
'uploads/GeoLite2-City/GeoLite2-City.mmdb'
);
$countryCode = 'N/A';
$regionCode = 'N/A';
$latitude = null;
$longitude = null;
try {
$city = $cityReader->city($fakeIp);
$countryCode = empty($city->country->isoCode)
? 'N/A'
: $city->country->isoCode;
$regionCode = empty($city->subdivisions[0]->isoCode)
? 'N/A'
: $city->subdivisions[0]->isoCode;
$latitude = round($city->location->latitude, 3);
$longitude = round($city->location->longitude, 3);
} catch (\GeoIp2\Exception\AddressNotFoundException $ex) {
//Bad luck, bad IP, nothing to do.
}
$hits = rand(0, $proba2);
$analytics_podcasts[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'hits' => $hits,
];
$analytics_podcasts_by_country[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'country_code' => $countryCode,
'hits' => $hits,
];
$analytics_podcasts_by_episode[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'episode_id' => $episode->id,
'age' => $age,
'hits' => $hits,
];
$analytics_podcasts_by_player[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'app' => $app,
'device' => $device,
'os' => $os,
'bot' => $bot,
'hits' => $hits,
];
$analytics_podcasts_by_region[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'country_code' => $countryCode,
'region_code' => $regionCode,
'latitude' => $latitude,
'longitude' => $longitude,
'hits' => $hits,
];
}
}
$this->db
->table('analytics_podcasts')
->ignore(true)
->insertBatch($analytics_podcasts);
$this->db
->table('analytics_podcasts_by_country')
->ignore(true)
->insertBatch($analytics_podcasts_by_country);
$this->db
->table('analytics_podcasts_by_episode')
->ignore(true)
->insertBatch($analytics_podcasts_by_episode);
$this->db
->table('analytics_podcasts_by_player')
->ignore(true)
->insertBatch($analytics_podcasts_by_player);
$this->db
->table('analytics_podcasts_by_region')
->ignore(true)
->insertBatch($analytics_podcasts_by_region);
}
} else {
echo "Create one podcast and some episodes first.\n";
}
}
}

View File

@ -0,0 +1,260 @@
<?php
/**
* Class FakeWebsiteAnalyticsSeeder
* Inserts Fake Analytics in the database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Seeds;
use App\Models\PodcastModel;
use App\Models\EpisodeModel;
use CodeIgniter\Database\Seeder;
class FakeWebsiteAnalyticsSeeder extends Seeder
{
protected $keywords = [
'all the smoke podcast',
'apple podcast',
'bad friends podcast',
'best podcast',
'best podcasts',
'best podcasts 2020',
'blood ties',
'call her daddy',
'call her daddy podcast',
'call her daddy podcast controversy',
'call her daddy podcast drama',
'counter clock podcast',
'counterclock podcast',
'crime junkie podcast',
'crime podcast',
'down the hill podcast',
'gerry callahan podcast',
'google podcast',
'history podcast',
'joe rogan',
'joe rogan podcast',
'lana rhoades and logan paul podcast',
'last podcast on the left',
'michael moore podcast',
'michelle obama podcast',
'missing in alaska podcast',
'murder podcast',
'nice white parents podcast',
'nick cannon podcast',
'npr podcast',
'office ladies podcast',
'podcast app',
'podcasts',
'rogan podcast',
'rudy giuliani podcast',
'savage podcast',
'serial podcast',
'smartless podcast',
'ted cruz podcast',
'the daily',
'the daily podcast',
'the last podcast on the left',
'the new abnormal podcast',
'tiger king podcast',
'trey gowdy podcast',
'true crime podcast',
'what is a podcast',
'what is podcast',
'wind of change podcast',
'your own backyard podcast',
];
protected $domains = [
'360.cn ',
'adobe.com ',
'aliexpress.com ',
'alipay.com ',
'amazon.co.jp ',
'amazon.com ',
'amazon.in ',
'apple.com ',
'baidu.com ',
'bing.com ',
'bongacams.com ',
'chaturbate.com ',
'china.com.cn ',
'csdn.net ',
'ebay.com ',
'facebook.com ',
'google.co.in ',
'google.com ',
'google.com.hk ',
'instagram.com ',
'jd.com ',
'live.com ',
'livejasmin.com ',
'microsoft.com ',
'microsoftonline.com ',
'myshopify.com ',
'naver.com ',
'netflix.com ',
'office.com ',
'okezone.com ',
'panda.tv ',
'qq.com ',
'reddit.com ',
'sina.com.cn ',
'sohu.com ',
'taobao.com ',
'tianya.cn ',
'tmall.com ',
'tribunnews.com ',
'twitch.tv ',
'twitter.com ',
'vk.com ',
'weibo.com ',
'wikipedia.org ',
'xinhuanet.com ',
'yahoo.co.jp ',
'yahoo.com ',
'youtube.com ',
'zhanqi.tv ',
'zoom.us ',
];
protected $browsers = [
'Android Browser',
'Avast Secure Browser',
'BlackBerry Browser',
'Chrome',
'Chrome Mobile',
'Chrome Mobile iOS',
'Chrome Webview',
'Chromium',
'Ecosia',
'Fennec',
'Firebird',
'Firefox',
'Firefox Mobile',
'Firefox Mobile iOS',
'Galeon',
'GNOME Web',
'Headless Chrome',
'Huawei Browser',
'IE Mobile',
'Inconnu',
'Internet Explorer',
'Kindle Browser',
'Konqueror',
'Maxthon',
'Meizu Browser',
'Microsoft Edge',
'MIUI Browser',
'Mobile Safari',
'Mobile Silk',
'OmniWeb',
'Openwave Mobile Browser',
'Opera',
'Opera Mini',
'Opera Mobile',
'Opera Next',
'Palm Blazer',
'Puffin',
'QupZilla',
'Safari',
'Samsung Browser',
'UC Browser',
'WOSBrowser',
];
public function run()
{
$podcast = (new PodcastModel())->first();
if ($podcast) {
$firstEpisode = (new EpisodeModel())
->selectMin('published_at')
->first();
for (
$date = strtotime($firstEpisode->published_at);
$date < strtotime('now');
$date = strtotime(date('Y-m-d', $date) . ' +1 day')
) {
$website_by_browser = [];
$website_by_entry_page = [];
$website_by_referer = [];
$episodes = (new EpisodeModel())
->where([
'podcast_id' => $podcast->id,
'DATE(published_at) <=' => date('Y-m-d', $date),
])
->findAll();
foreach ($episodes as $episode) {
$age = floor(
($date - strtotime($episode->published_at)) / 86400
);
$proba1 = floor(exp(3 - $age / 40)) + 1;
for (
$num_line = 0;
$num_line < rand(1, $proba1);
$num_line++
) {
$proba2 = floor(exp(6 - $age / 20)) + 10;
$domain =
$this->domains[rand(0, count($this->domains) - 1)];
$keyword =
$this->keywords[
rand(0, count($this->keywords) - 1)
];
$browser =
$this->browsers[
rand(0, count($this->browsers) - 1)
];
$hits = rand(0, $proba2);
$website_by_browser[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'browser' => $browser,
'hits' => $hits,
];
$website_by_entry_page[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'entry_page' => $episode->link,
'hits' => $hits,
];
$website_by_referer[] = [
'podcast_id' => $podcast->id,
'date' => date('Y-m-d', $date),
'referer' =>
'http://' . $domain . '/?q=' . $keyword,
'domain' => $domain,
'keywords' => $keyword,
'hits' => $hits,
];
}
}
$this->db
->table('analytics_website_by_browser')
->ignore(true)
->insertBatch($website_by_browser);
$this->db
->table('analytics_website_by_entry_page')
->ignore(true)
->insertBatch($website_by_entry_page);
$this->db
->table('analytics_website_by_referer')
->ignore(true)
->insertBatch($website_by_referer);
}
} else {
echo "Create one podcast and some episodes first.\n";
}
}
}

View File

@ -1,8 +1,8 @@
<?php
/**
* Class AnalyticsWebsiteByCountry
* Entity for AnalyticsWebsiteByCountry
* Class AnalyticsPodcasts
* Entity for AnalyticsPodcasts
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
@ -12,11 +12,10 @@ namespace App\Entities;
use CodeIgniter\Entity;
class AnalyticsWebsiteByCountry extends Entity
class AnalyticsPodcasts extends Entity
{
protected $casts = [
'podcast_id' => 'integer',
'country_code' => 'string',
'date' => 'datetime',
'hits' => 'integer',
];

View File

@ -1,8 +1,8 @@
<?php
/**
* Class AnalyticsEpisodesByPlayer
* Entity for AnalyticsEpisodesByPlayer
* Class AnalyticsPodcastsByEpisode
* Entity for AnalyticsPodcastsByEpisode
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
@ -12,12 +12,11 @@ namespace App\Entities;
use CodeIgniter\Entity;
class AnalyticsEpisodesByPlayer extends Entity
class AnalyticsPodcastsByEpisode extends Entity
{
protected $casts = [
'podcast_id' => 'integer',
'episode_id' => 'integer',
'player' => 'string',
'date' => 'datetime',
'hits' => 'integer',
];

View File

@ -16,7 +16,10 @@ class AnalyticsPodcastsByPlayer extends Entity
{
protected $casts = [
'podcast_id' => 'integer',
'player' => 'string',
'app' => '?string',
'device' => '?string',
'os' => '?string',
'bot' => 'boolean',
'date' => 'datetime',
'hits' => 'integer',
];

View File

@ -0,0 +1,26 @@
<?php
/**
* Class AnalyticsPodcastsByRegion
* Entity for AnalyticsPodcastsByRegion
* @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;
class AnalyticsPodcastsByRegion extends Entity
{
protected $casts = [
'podcast_id' => 'integer',
'country_code' => 'string',
'region_code' => '?string',
'latitude' => '?float',
'longitude' => '?float',
'date' => 'datetime',
'hits' => 'integer',
];
}

View File

@ -1,8 +1,8 @@
<?php
/**
* Class AnalyticsEpisodesByCountry
* Entity for AnalyticsEpisodesByCountry
* Class AnalyticsWebsiteByEntryPage
* Entity for AnalyticsWebsiteByEntryPage
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
@ -12,12 +12,11 @@ namespace App\Entities;
use CodeIgniter\Entity;
class AnalyticsEpisodesByCountry extends Entity
class AnalyticsWebsiteByEntryPage extends Entity
{
protected $casts = [
'podcast_id' => 'integer',
'episode_id' => 'integer',
'country_code' => 'string',
'entry_page' => '?string',
'date' => 'datetime',
'hits' => 'integer',
];

View File

@ -64,6 +64,7 @@ class Episode extends Entity
'enclosure_duration' => 'integer',
'enclosure_mimetype' => 'string',
'enclosure_filesize' => 'integer',
'enclosure_headersize' => 'integer',
'description' => 'string',
'image_uri' => '?string',
'parental_advisory' => '?string',
@ -143,6 +144,8 @@ class Episode extends Entity
$enclosure_metadata['mime_type'];
$this->attributes['enclosure_filesize'] =
$enclosure_metadata['filesize'];
$this->attributes['enclosure_headersize'] =
$enclosure_metadata['avdataoffset'];
return $this;
}
@ -167,6 +170,19 @@ class Episode extends Entity
'analytics_hit',
$this->attributes['podcast_id'],
$this->attributes['id'],
// bytes_threshold: number of bytes that must be downloaded for an episode to be counted in download analytics
// - if file is shorter than 60sec, then it's enclosure_filesize
// - if file is longer than 60 seconds then it's enclosure_headersize + 60 seconds
$this->attributes['enclosure_duration'] <= 60
? $this->attributes['enclosure_filesize']
: $this->attributes['enclosure_headersize'] +
floor(
(($this->attributes['enclosure_filesize'] -
$this->attributes['enclosure_headersize']) /
$this->attributes['enclosure_duration']) *
60
),
$this->attributes['enclosure_filesize'],
$this->attributes['enclosure_uri']
)
);

View File

@ -82,6 +82,7 @@ class Podcast extends Entity
'created_by' => 'integer',
'updated_by' => 'integer',
'imported_feed_url' => '?string',
'new_feed_url' => '?string',
];
/**

View File

@ -33,25 +33,56 @@ if (!function_exists('getallheaders')) {
/**
* Set user country in session variable, for analytics purpose
*/
function set_user_session_country()
function set_user_session_deny_list_ip()
{
$session = \Config\Services::session();
$session->start();
$country = 'N/A';
if (!$session->has('denyListIp')) {
$session->set(
'denyListIp',
\Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null
);
}
}
// Finds country:
if (!$session->has('country')) {
/**
* Set user country in session variable, for analytics purpose
*/
function set_user_session_location()
{
$session = \Config\Services::session();
$session->start();
$location = [
'countryCode' => 'N/A',
'regionCode' => 'N/A',
'latitude' => null,
'longitude' => null,
];
// Finds location:
if (!$session->has('location')) {
try {
$reader = new \GeoIp2\Database\Reader(
WRITEPATH . 'uploads/GeoLite2-Country/GeoLite2-Country.mmdb'
$cityReader = new \GeoIp2\Database\Reader(
WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb'
);
$geoip = $reader->country($_SERVER['REMOTE_ADDR']);
$country = $geoip->country->isoCode;
$city = $cityReader->city($_SERVER['REMOTE_ADDR']);
$location = [
'countryCode' => empty($city->country->isoCode)
? 'N/A'
: $city->country->isoCode,
'regionCode' => empty($city->subdivisions[0]->isoCode)
? 'N/A'
: $city->subdivisions[0]->isoCode,
'latitude' => round($city->location->latitude, 3),
'longitude' => round($city->location->longitude, 3),
];
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
$session->set('country', $country);
$session->set('location', $location);
}
}
@ -67,58 +98,36 @@ function set_user_session_player()
$session = \Config\Services::session();
$session->start();
$playerName = '- Unknown Player -';
$useragent = $_SERVER['HTTP_USER_AGENT'];
$playerFound = null;
$userAgent = $_SERVER['HTTP_USER_AGENT'];
try {
$jsonUserAgents = json_decode(
file_get_contents(
WRITEPATH . 'uploads/user-agents/src/user-agents.json'
),
true
);
//Search for current HTTP_USER_AGENT in json file:
foreach ($jsonUserAgents as $player) {
foreach ($player['user_agents'] as $useragentsRegexp) {
//Does the HTTP_USER_AGENT match this regexp:
if (preg_match("#{$useragentsRegexp}#", $useragent)) {
if (isset($player['bot'])) {
//Its a bot!
$playerName = '- Bot -';
} else {
//It isnt a bot, we store device/os/app:
$playerName =
(isset($player['device'])
? $player['device'] . '/'
: '') .
(isset($player['os'])
? $player['os'] . '/'
: '') .
(isset($player['app']) ? $player['app'] : '?');
}
//We found it!
break 2;
}
}
}
$playerFound = \Podlibre\UserAgentsPhp\UserAgents::find($userAgent);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
if ($playerName == '- Unknown Player -') {
if ($playerFound) {
$session->set('player', $playerFound);
} else {
$session->set('player', [
'app' => '- unknown -',
'device' => '',
'os' => '',
'bot' => 0,
]);
// Add to unknown list
try {
$db = \Config\Database::connect();
$procedureNameAUU = $db->prefixTable(
$procedureNameAnalyticsUnknownUseragents = $db->prefixTable(
'analytics_unknown_useragents'
);
$db->query("CALL $procedureNameAUU(?)", [$useragent]);
$db->query("CALL $procedureNameAnalyticsUnknownUseragents(?)", [
$userAgent,
]);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
}
$session->set('player', $playerName);
}
}
@ -165,49 +174,149 @@ function set_user_session_referer()
}
}
/**
* Set user entry page in session variable, for analytics purpose
*/
function set_user_session_entry_page()
{
$session = \Config\Services::session();
$session->start();
$entryPage = $_SERVER['REQUEST_URI'];
if (!$session->has('entryPage')) {
$session->set('entryPage', $entryPage);
}
}
function webpage_hit($podcast_id)
{
$session = \Config\Services::session();
$session->start();
$db = \Config\Database::connect();
$procedureName = $db->prefixTable('analytics_website');
$db->query("call $procedureName(?,?,?,?)", [
$podcast_id,
$session->get('country'),
$session->get('browser'),
$session->get('referer'),
]);
if (!$session->get('denyListIp')) {
$db = \Config\Database::connect();
$referer = $session->get('referer');
$domain = empty(parse_url($referer, PHP_URL_HOST))
? null
: parse_url($referer, PHP_URL_HOST);
parse_str(parse_url($referer, PHP_URL_QUERY), $queries);
$keywords = empty($queries['q']) ? null : $queries['q'];
$procedureName = $db->prefixTable('analytics_website');
$db->query("call $procedureName(?,?,?,?,?,?)", [
$podcast_id,
$session->get('browser'),
$session->get('entryPage'),
$referer,
$domain,
$keywords,
]);
}
}
function podcast_hit($p_podcast_id, $p_episode_id)
/**
* Counting podcast episode downloads for analytics purposes
* No IP address is ever stored on the server.
* Only aggregate data is stored in the database.
* We follow IAB Podcast Measurement Technical Guidelines Version 2.0:
* https://iabtechlab.com/standards/podcast-measurement-guidelines/
* https://iabtechlab.com/wp-content/uploads/2017/12/Podcast_Measurement_v2-Dec-20-2017.pdf
* Rolling 24-hour window
* Castopod does not do pre-load
* IP deny list https://github.com/client9/ipcat
* User-agent Filtering https://github.com/opawg/user-agents
* Ignores 2 bytes range "Range: 0-1" (performed by official Apple iOS Podcast app)
* In case of partial content, adds up all requests to check >1mn was downloaded
* Identifying Uniques is done with a combination of IP Address and User Agent
* @param int $podcastId The podcast ID
* @param int $episodeId The Episode ID
* @param int $bytesThreshold The minimum total number of bytes that must be downloaded so that an episode is counted (>1mn)
* @param int $fileSize The podcast complete file size
*
* @return void
*/
function podcast_hit($podcastId, $episodeId, $bytesThreshold, $fileSize)
{
$session = \Config\Services::session();
$session->start();
$first_time_for_this_episode = true;
if ($session->has('episodes')) {
if (in_array($p_episode_id, $session->get('episodes'))) {
$first_time_for_this_episode = false;
// We try to count (but if things went wrong the show should go on and the user should be able to download the file):
try {
// If the user IP is denied it's probably a bot:
if ($session->get('denyListIp')) {
$session->get('player')['bot'] = true;
}
$httpRange = $_SERVER['HTTP_RANGE'];
// We create a sha1 hash for this IP_Address+User_Agent+Episode_ID:
$hashID =
'_IpUaEp_' .
sha1(
$_SERVER['REMOTE_ADDR'] .
'_' .
$_SERVER['HTTP_USER_AGENT'] .
'_' .
$episodeId
);
// Was this episode downloaded in the past 24h:
$downloadedBytes = cache($hashID);
// Rolling window is 24 hours (86400 seconds):
$ttl = 86400;
if ($downloadedBytes) {
// In case it was already downloaded, TTL should be adjusted (rolling window is 24h since 1st download):
$ttl = cache()->getMetadata($hashID)['expire'] - time();
} else {
$session->push('episodes', [$p_episode_id]);
// If it was never downloaded that means that zero byte were downloaded:
$downloadedBytes = 0;
}
} else {
$session->set('episodes', [$p_episode_id]);
}
// If the number of downloaded bytes was previously below the 1mn threshold we go on:
// (Otherwise it means that this was already counted, therefore we don't do anything)
if ($downloadedBytes < $bytesThreshold) {
// If HTTP_RANGE is null we are downloading the complete file:
if (!isset($httpRange)) {
$downloadedBytes = $fileSize;
} else {
// [0-1] bytes range requests are used (by Apple) to check that file exists and that 206 partial content is working.
// We don't count these requests:
if ($httpRange != 'bytes=0-1') {
// We calculate how many bytes are being downloaded based on HTTP_RANGE values:
$ranges = explode(',', substr($httpRange, 6));
foreach ($ranges as $range) {
$parts = explode('-', $range);
$downloadedBytes += empty($parts[1])
? $fileSize
: $parts[1] - (empty($parts[0]) ? 0 : $parts[0]);
}
}
}
// We save the number of downloaded bytes for this user and this episode:
cache()->save($hashID, $downloadedBytes, $ttl);
if ($first_time_for_this_episode) {
$db = \Config\Database::connect();
$procedureName = $db->prefixTable('analytics_podcasts');
try {
$db->query("CALL $procedureName(?,?,?,?);", [
$p_podcast_id,
$p_episode_id,
$session->get('country'),
$session->get('player'),
]);
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
// If more that 1mn was downloaded, we send that to the database:
if ($downloadedBytes >= $bytesThreshold) {
$db = \Config\Database::connect();
$procedureName = $db->prefixTable('analytics_podcasts');
$app = $session->get('player')['app'];
$device = $session->get('player')['device'];
$os = $session->get('player')['os'];
$bot = $session->get('player')['bot'];
$db->query("CALL $procedureName(?,?,?,?,?,?,?,?,?,?);", [
$podcastId,
$episodeId,
$session->get('location')['countryCode'],
$session->get('location')['regionCode'],
$session->get('location')['latitude'],
$session->get('location')['longitude'],
$app == null ? '' : $app,
$device == null ? '' : $device,
$os == null ? '' : $os,
$bot == null ? 0 : $bot,
]);
}
}
} catch (\Exception $e) {
// If things go wrong the show must go on and the user must be able to download the file
}
}

View File

@ -24,6 +24,7 @@ function get_file_tags($file)
return [
'filesize' => $FileInfo['filesize'],
'mime_type' => $FileInfo['mime_type'],
'avdataoffset' => $FileInfo['avdataoffset'],
'playtime_seconds' => $FileInfo['playtime_seconds'],
];
}
@ -68,7 +69,11 @@ function write_enclosure_tags($episode)
'comment' => [$episode->description],
'track_number' => [strval($episode->number)],
'copyright_message' => [$episode->podcast->copyright],
'publisher' => ['Podlibre'],
'publisher' => [
empty($episode->podcast->publisher)
? $episode->podcast->owner_name
: $episode->podcast->publisher,
],
'encoded_by' => ['Castopod'],
// TODO: find a way to add the remaining tags for podcasts as the library doesn't seem to allow it

View File

@ -36,6 +36,14 @@ function get_rss_feed($podcast)
$atom_link->addAttribute('rel', 'self');
$atom_link->addAttribute('type', 'application/rss+xml');
if (!empty($podcast->new_feed_url)) {
$channel->addChild(
'new-feed-url',
$podcast->new_feed_url,
$itunes_namespace
);
}
// the last build date corresponds to the creation of the feed.xml cache
$channel->addChild(
'lastBuildDate',
@ -50,7 +58,7 @@ function get_rss_feed($podcast)
$channel->addChild('title', $podcast->title);
$channel->addChildWithCDATA('description', $podcast->description_html);
$itunes_image = $channel->addChild('image', null, $itunes_namespace);
$itunes_image->addAttribute('href', $podcast->image->url);
$itunes_image->addAttribute('href', $podcast->image->original_url);
$channel->addChild('language', $podcast->language);
// set main category first, then other categories as apple

View File

@ -22,4 +22,5 @@ return [
'import' => 'feed import',
'settings' => 'settings',
'platforms' => 'platforms',
'analytics' => 'Analytics',
];

View File

@ -12,7 +12,7 @@ return [
'create' => 'Create a podcast',
'import' => 'Import a podcast',
'new_episode' => 'New Episode',
'feed' => 'RSS feed',
'feed' => 'RSS',
'view' => 'View podcast',
'edit' => 'Edit podcast',
'delete' => 'Delete podcast',

View File

@ -7,6 +7,12 @@
*/
return [
'legal_dislaimer_title' => 'Legal Disclaimer',
'legal_dislaimer_content' =>
'Make sure you own the rights for this podcast before importing it.<br/>Copying and broadcasting a podcast without the proper rights is piracy and is liable to prosecution.',
'warning_title' => 'Warning',
'warning_content' =>
'This procedure may take a long time.<br/>The current version does not show any progress while it runs. You will not see anything updated until it is done.<br/>In case of timeout error, increase max_execution_time value.',
'old_podcast_section_title' => 'The podcast to import',
'old_podcast_section_subtitle' => '',
'imported_feed_url' => 'Feed URL',

View File

@ -20,4 +20,5 @@ return [
'contributor-add' => 'Add contributor',
'settings' => 'Settings',
'platforms' => 'Podcast platforms',
'podcast-analytics' => 'Audiences Overview',
];

View File

@ -2,7 +2,7 @@
/**
* Class AnalyticsPodcastsByCountryModel
* Model for analytics_episodes_by_country table in database
* Model for analytics_podcasts_by_country table in database
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
@ -14,8 +14,7 @@ use CodeIgniter\Model;
class AnalyticsPodcastsByCountryModel extends Model
{
protected $table = 'analytics_episodes_by_country';
protected $primaryKey = 'id';
protected $table = 'analytics_podcasts_by_country';
protected $allowedFields = [];

View File

@ -0,0 +1,113 @@
<?php
/**
* Class AnalyticsPodcastsByEpisodeModel
* Model for analytics_podcasts_by_episodes table in database
* @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 AnalyticsPodcastsByEpisodeModel extends Model
{
protected $table = 'analytics_podcasts_by_episode';
protected $allowedFields = [];
protected $returnType = \App\Entities\AnalyticsPodcastsByEpisode::class;
protected $useSoftDeletes = false;
protected $useTimestamps = false;
/**
* @param int $podcastId, $episodeId
*
* @return array
*/
public function getDataByDay(int $podcastId, int $episodeId = null): array
{
if (!$episodeId) {
if (
!($found = cache(
"{$podcastId}_analytics_podcast_by_episode_by_day"
))
) {
$lastEpisodes = (new EpisodeModel())
->select('id, season_number, number, title')
->orderBy('id', 'DESC')
->where(['podcast_id' => $podcastId])
->findAll(5);
$found = $this->select('age AS X');
$letter = 97;
foreach ($lastEpisodes as $episode) {
$found = $found
->selectSum(
'(CASE WHEN `episode_id`=' .
$episode->id .
' THEN `hits` END)',
chr($letter) . 'Y'
)
->select(
'"' .
(empty($episode->season_number)
? ''
: $episode->season_number) .
(empty($episode->number)
? ''
: '-' . $episode->number . '/ ') .
$episode->title .
'" AS ' .
chr($letter) .
'Value'
);
$letter++;
}
$found = $found
->where([
'podcast_id' => $podcastId,
'age <' => 60,
])
->groupBy('X')
->orderBy('X', 'ASC')
->findAll();
cache()->save(
"{$podcastId}_analytics_podcast_by_episode_by_day",
$found,
14400
);
}
return $found;
} else {
if (
!($found = cache(
"{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day"
))
) {
$found = $this->select('date as labels')
->selectSum('hits', 'values')
->where([
'episode_id' => $episodeId,
'podcast_id' => $podcastId,
])
->groupBy('labels')
->orderBy('labels', 'ASC')
->findAll();
cache()->save(
"{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day",
$found,
14400
);
}
return $found;
}
}
}

View File

@ -15,7 +15,6 @@ use CodeIgniter\Model;
class AnalyticsPodcastsByPlayerModel extends Model
{
protected $table = 'analytics_podcasts_by_player';
protected $primaryKey = 'id';
protected $allowedFields = [];
@ -23,4 +22,120 @@ class AnalyticsPodcastsByPlayerModel extends Model
protected $useSoftDeletes = false;
protected $useTimestamps = false;
/**
* Gets all data for a podcast
*
* @param int $podcastId
*
* @return array
*/
public function getDataByApp(int $podcastId): array
{
if (
!($found = cache(
"{$podcastId}_analytics_podcasts_by_player_by_app"
))
) {
$found = $this->select('`app` as `labels`')
->selectSum('`hits`', '`values`')
->where([
'`podcast_id`' => $podcastId,
'`app` !=' => null,
'`bot`' => 0,
'`date` >' => date('Y-m-d', strtotime('-1 week')),
])
->groupBy('`labels`')
->orderBy('`values``', 'DESC')
->findAll(10);
cache()->save(
"{$podcastId}_analytics_podcasts_by_player_by_app",
$found,
14400
);
}
return $found;
}
/**
* Gets all data for a podcast
*
* @param int $podcastId
*
* @return array
*/
public function getDataByDevice(int $podcastId): array
{
if (
!($found = cache(
"{$podcastId}_analytics_podcasts_by_player_by_device"
))
) {
$foundApp = $this->select(
'CONCAT_WS("/", `device`, `os`, `app`) as `ids`, `app` as `labels`, CONCAT_WS("/", `device`, `os`) as `parents`'
)
->selectSum('`hits`', '`values`')
->where([
'`podcast_id`' => $podcastId,
'`app` !=' => null,
'`bot`' => 0,
'`date` >' => date('Y-m-d', strtotime('-1 week')),
])
->groupBy('`ids`')
->orderBy('`values``', 'DESC')
->findAll();
$foundOs = $this->select(
'CONCAT_WS("/", `device`, `os`) as `ids`, `os` as `labels`, `device` as `parents`'
)
->selectSum('`hits`', '`values`')
->where([
'`podcast_id`' => $podcastId,
'`os` !=' => null,
'`bot`' => 0,
'`date` >' => date('Y-m-d', strtotime('-1 week')),
])
->groupBy('`ids`')
->orderBy('`values``', 'DESC')
->findAll();
$foundDevice = $this->select(
'`device` as `ids`, `device` as `labels`, "" as `parents`'
)
->selectSum('`hits`', '`values`')
->where([
'`podcast_id`' => $podcastId,
'`device` !=' => null,
'`bot`' => 0,
'`date` >' => date('Y-m-d', strtotime('-1 week')),
])
->groupBy('`ids`')
->orderBy('`values``', 'DESC')
->findAll();
$foundBot = $this->select(
'"bots" as `ids`, "Bots" as `labels`, "" as `parents`'
)
->selectSum('`hits`', '`values`')
->where([
'`podcast_id`' => $podcastId,
'`bot`' => 1,
'`date` >' => date('Y-m-d', strtotime('-1 week')),
])
->groupBy('`ids`')
->orderBy('`values``', 'DESC')
->findAll();
$found = array_merge($foundApp, $foundOs, $foundDevice, $foundBot);
cache()->save(
"{$podcastId}_analytics_podcasts_by_player_by_device",
$found,
14400
);
}
return $found;
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* Class AnalyticsPodcastsByRegionModel
* Model for analytics_podcasts_by_region table in database
* @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 AnalyticsPodcastsByRegionModel extends Model
{
protected $table = 'analytics_podcasts_by_region';
protected $allowedFields = [];
protected $returnType = \App\Entities\AnalyticsPodcastsByRegion::class;
protected $useSoftDeletes = false;
protected $useTimestamps = false;
}

View File

@ -0,0 +1,55 @@
<?php
/**
* Class AnalyticsPodcastsModel
* Model for analytics_podcasts table in database
* @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 AnalyticsPodcastsModel extends Model
{
protected $table = 'analytics_podcasts';
protected $allowedFields = [];
protected $returnType = \App\Entities\AnalyticsPodcasts::class;
protected $useSoftDeletes = false;
protected $useTimestamps = false;
/**
* Gets all data for a podcast
*
* @param int $podcastId
*
* @return array
*/
public function getDataByDay(int $podcastId): array
{
if (!($found = cache("{$podcastId}_analytics_podcast_by_day"))) {
$found = $this->select('`date` as `labels`')
->selectSum('`hits`', '`values`')
->where([
'`podcast_id`' => $podcastId,
'`date` >' => date('Y-m-d', strtotime('-1 year')),
])
->groupBy('`labels`')
->orderBy('`labels``', 'ASC')
->findAll();
cache()->save(
"{$podcastId}_analytics_podcast_by_day",
$found,
14400
);
}
return $found;
}
}

View File

@ -15,7 +15,6 @@ use CodeIgniter\Model;
class AnalyticsWebsiteByBrowserModel extends Model
{
protected $table = 'analytics_website_by_browser';
protected $primaryKey = 'id';
protected $allowedFields = [];

View File

@ -0,0 +1,25 @@
<?php
/**
* Class AnalyticsWebsiteByEntryPageModel
* Model for analytics_website_by_entry_page table in database
* @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 AnalyticsWebsiteByEntryPageModel extends Model
{
protected $table = 'analytics_website_by_entry_page';
protected $allowedFields = [];
protected $returnType = \App\Entities\AnalyticsWebsiteByEntryPage::class;
protected $useSoftDeletes = false;
protected $useTimestamps = false;
}

View File

@ -15,7 +15,6 @@ use CodeIgniter\Model;
class AnalyticsWebsiteByRefererModel extends Model
{
protected $table = 'analytics_website_by_referer';
protected $primaryKey = 'id';
protected $allowedFields = [];

View File

@ -24,6 +24,7 @@ class EpisodeModel extends Model
'enclosure_duration',
'enclosure_mimetype',
'enclosure_filesize',
'enclosure_headersize',
'description',
'image_uri',
'parental_advisory',

View File

@ -35,6 +35,7 @@ class PodcastModel extends Model
'created_by',
'updated_by',
'imported_feed_url',
'new_feed_url',
];
protected $returnType = \App\Entities\Podcast::class;

View File

@ -0,0 +1,4 @@
import "core-js";
import DrawCharts from "./modules/Charts";
DrawCharts();

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata>
<style type="text/css">
.st0{fill:#009486;}
.st1{fill:#E7F9E4;}
.st2{fill:none;}
.st3{fill:#E7FFE3;}
</style>
<circle cx="32" cy="32" r="32" fill="#e7f9e4" stroke-width="2.0334"/><g transform="matrix(.24971 0 0 .24971 6.7291 14.595)">
<path id="dark_greeen_19_" class="st0" d="m181.9 131.7h-32.5s-1.2-2.5-2.5-4.9-4.4-2.3-4.4-2.3h-82.8s-3-0.4-4.5 2.3c-1.6 2.7-2.6 4.9-2.6 4.9h-32c-6.9 0-12.6-5.6-12.6-12.5v-98.9c0-6.9 5.6-12.6 12.5-12.6h161.3c6.9 0 12.6 5.6 12.6 12.5v98.9c0.1 6.9-5.6 12.6-12.5 12.6z"/>
<path class="st1" d="m143.7 34.5h-85.1c-14.6 0-26.5 12-26.5 26.6s11.9 26.5 26.5 26.5h85.1c14.6 0 26.5-11.9 26.5-26.5 0.1-14.8-11.8-26.7-26.5-26.6zm-75.4 34.2s-3.9-2.9-9.4-2.9c-4.1 0-8.9 2.5-8.9 2.5-1.3-1.9-2.1-4.1-2.1-6.6 0-6.3 5.1-11.4 11.4-11.4s11.4 5.1 11.4 11.4c0 2.7-0.9 5.1-2.4 7zm32.9 6.6c-12.5 0-12-9.6-12-9.6-0.2-1.8 2.1-2.4 2.9-1.3 0.4 0.6 0.4 0.6 0.7 1.7 1.7 5.9 8.4 5.6 8.4 5.6s6.7 0.4 8.4-5.6c0.3-1 0.3-1.1 0.7-1.7 0.8-1 3.1-0.5 2.9 1.3 0 0 0.5 9.6-12 9.6zm51.1-6.9s-4.8-2.5-8.9-2.5c-5.5 0-9.4 2.9-9.4 2.9-1.5-1.9-2.4-4.3-2.4-7 0-6.3 5.1-11.4 11.4-11.4s11.4 5.1 11.4 11.4c0.1 2.4-0.7 4.7-2.1 6.6z"/>
<path class="st2" d="m110.3 64.3c-0.4 0.6-0.4 0.6-0.7 1.7-1.7 5.9-8.4 5.6-8.4 5.6s-6.7 0.4-8.4-5.6c-0.3-1-0.3-1.1-0.7-1.7-0.8-1-3.1-0.5-2.9 1.3 0 0-0.5 9.6 12 9.6s12-9.6 12-9.6c0.2-1.7-2.1-2.3-2.9-1.3z"/>
<path class="st2" d="m143.1 50.4c-6.3 0-11.4 5.1-11.4 11.4 0 2.6 0.9 5 2.4 7 0 0 3.9-2.9 9.4-2.9 4.1 0 8.9 2.5 8.9 2.5 1.3-1.9 2.1-4.1 2.1-6.6 0-6.3-5.1-11.4-11.4-11.4z"/>
<path class="st2" d="m59.3 50.4c-6.3 0-11.4 5.1-11.4 11.4 0 2.5 0.8 4.7 2.1 6.6 0 0 4.8-2.5 8.9-2.5 5.5 0 9.4 2.9 9.4 2.9 1.5-1.9 2.4-4.3 2.4-7 0-6.3-5.1-11.4-11.4-11.4z"/>
<path class="st3" d="m47.1 23.3c-6.3-1.7-11.7 2.1-14.7 7.3-0.7 1.2-0.2 2.2 0.5 2.6 1 0.3 1.7 0.1 2.8-1.5 2.2-3.9 5.9-6.1 10.1-5.3 0 0 2.9 0.9 3.3-1 0.3-1.2-0.8-1.8-2-2.1z"/>
<path class="st3" d="m159.9 27.3c-0.1 1.9 2.9 1.9 2.9 1.9 4.2 0.4 6.8 2.3 7.8 6.7 0.6 1.9 1.2 2.2 2.3 2.2 0.8-0.1 1.6-1 1.2-2.4-1.4-5.8-5.1-9.8-11.7-9.9-1.2-0.1-2.4 0.2-2.5 1.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,86 +1,40 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:version="1.0beta1 (ee59332, 2019-11-28)"
sodipodi:docname="castopod.svg"
id="svg839"
version="1.1"
viewBox="0 0 64 63.999998"
height="64"
width="64">
<metadata
id="metadata845">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs843" />
<sodipodi:namedview
inkscape:current-layer="svg839"
inkscape:window-maximized="0"
inkscape:window-y="23"
inkscape:window-x="0"
inkscape:cy="33.560512"
inkscape:cx="32"
inkscape:zoom="8.9714173"
showgrid="false"
id="namedview841"
inkscape:window-height="1035"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
inkscape:document-rotation="0"
bordercolor="#666666"
pagecolor="#ffffff" />
<circle
id="greencircle"
fill="#37c837"
cx="32"
cy="32"
r="31.684" />
<g
id="speak">
<path
d="M45.21 20.22H18.79c-6.473 0-11.74 5.266-11.74 11.74S12.317 43.7 18.79 43.7h10.756c1.08 0 1.957-.875 1.957-1.956 0-1.08-.877-1.957-1.957-1.957H18.79c-4.315 0-7.826-3.51-7.826-7.827 0-4.316 3.51-7.828 7.827-7.828h26.42c4.315 0 7.826 3.512 7.826 7.828 0 4.316-3.51 7.827-7.827 7.827H43.34v.002c-5.41.096-9.783 4.527-9.783 9.96 0 1.08.875 1.957 1.956 1.957 1.08 0 1.956-.876 1.956-1.957 0-3.336 2.714-6.05 6.05-6.05h1.687c6.473 0 11.74-5.266 11.74-11.74s-5.267-11.74-11.74-11.74"
fill="#fff"
id="phylactery" />
<g
id="threedots">
<circle
r="2"
cy="32"
cx="24.256159"
id="leftdot"
style="fill:#ffffff;fill-opacity:1;stroke:none;" />
<circle
style="fill:#ffffff;fill-opacity:1;stroke:none;"
id="middledot"
cx="32"
cy="32"
r="2" />
<circle
r="2"
cy="32"
cx="39.743839"
id="rightdot"
style="fill:#ffffff;fill-opacity:1;stroke:none;" />
</g>
</g>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 202.4 137.8" style="enable-background:new 0 0 202.4 137.8;" xml:space="preserve">
<style type="text/css">
.st0{fill:#009486;}
.st1{fill:#E7F9E4;}
.st2{fill:none;}
.st3{fill:#E7FFE3;}
</style>
<g>
<path id="dark_greeen_19_" class="st0" d="M181.9,131.7h-32.5c0,0-1.2-2.5-2.5-4.9c-1.3-2.4-4.4-2.3-4.4-2.3H59.7
c0,0-3-0.4-4.5,2.3c-1.6,2.7-2.6,4.9-2.6,4.9H20.6c-6.9,0-12.6-5.6-12.6-12.5V20.3c0-6.9,5.6-12.6,12.5-12.6h161.3
c6.9,0,12.6,5.6,12.6,12.5v98.9C194.5,126,188.8,131.7,181.9,131.7z"/>
<path class="st1" d="M143.7,34.5H58.6c-14.6,0-26.5,12-26.5,26.6c0,14.6,11.9,26.5,26.5,26.5h85.1c14.6,0,26.5-11.9,26.5-26.5
C170.3,46.3,158.4,34.4,143.7,34.5z M68.3,68.7c0,0-3.9-2.9-9.4-2.9c-4.1,0-8.9,2.5-8.9,2.5c-1.3-1.9-2.1-4.1-2.1-6.6
c0-6.3,5.1-11.4,11.4-11.4s11.4,5.1,11.4,11.4C70.7,64.4,69.8,66.8,68.3,68.7z M101.2,75.3c-12.5,0-12-9.6-12-9.6
c-0.2-1.8,2.1-2.4,2.9-1.3c0.4,0.6,0.4,0.6,0.7,1.7c1.7,5.9,8.4,5.6,8.4,5.6s6.7,0.4,8.4-5.6c0.3-1,0.3-1.1,0.7-1.7
c0.8-1,3.1-0.5,2.9,1.3C113.2,65.7,113.7,75.3,101.2,75.3z M152.3,68.4c0,0-4.8-2.5-8.9-2.5c-5.5,0-9.4,2.9-9.4,2.9
c-1.5-1.9-2.4-4.3-2.4-7c0-6.3,5.1-11.4,11.4-11.4s11.4,5.1,11.4,11.4C154.5,64.2,153.7,66.5,152.3,68.4z"/>
<path class="st2" d="M110.3,64.3c-0.4,0.6-0.4,0.6-0.7,1.7c-1.7,5.9-8.4,5.6-8.4,5.6s-6.7,0.4-8.4-5.6c-0.3-1-0.3-1.1-0.7-1.7
c-0.8-1-3.1-0.5-2.9,1.3c0,0-0.5,9.6,12,9.6c12.5,0,12-9.6,12-9.6C113.4,63.9,111.1,63.3,110.3,64.3z"/>
<path class="st2" d="M143.1,50.4c-6.3,0-11.4,5.1-11.4,11.4c0,2.6,0.9,5,2.4,7c0,0,3.9-2.9,9.4-2.9c4.1,0,8.9,2.5,8.9,2.5
c1.3-1.9,2.1-4.1,2.1-6.6C154.5,55.5,149.4,50.4,143.1,50.4z"/>
<path class="st2" d="M59.3,50.4c-6.3,0-11.4,5.1-11.4,11.4c0,2.5,0.8,4.7,2.1,6.6c0,0,4.8-2.5,8.9-2.5c5.5,0,9.4,2.9,9.4,2.9
c1.5-1.9,2.4-4.3,2.4-7C70.7,55.5,65.6,50.4,59.3,50.4z"/>
<g>
<g>
<path class="st3" d="M47.1,23.3c-6.3-1.7-11.7,2.1-14.7,7.3c-0.7,1.2-0.2,2.2,0.5,2.6c1,0.3,1.7,0.1,2.8-1.5
c2.2-3.9,5.9-6.1,10.1-5.3c0,0,2.9,0.9,3.3-1C49.4,24.2,48.3,23.6,47.1,23.3z"/>
</g>
</g>
<g>
<g>
<path class="st3" d="M159.9,27.3c-0.1,1.9,2.9,1.9,2.9,1.9c4.2,0.4,6.8,2.3,7.8,6.7c0.6,1.9,1.2,2.2,2.3,2.2
c0.8-0.1,1.6-1,1.2-2.4c-1.4-5.8-5.1-9.8-11.7-9.9C161.2,25.7,160,26,159.9,27.3z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,7 +1,26 @@
<svg id="default" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
<rect width="300" height="300" rx="67" fill="#ebfaeb"/>
<path id="phylactery" d="M195,112.36H105a40,40,0,0,0,0,80h36.65a6.67,6.67,0,0,0,0-13.33H105a26.67,26.67,0,0,1,0-53.34h90A26.67,26.67,0,0,1,195,179h-6.37A34,34,0,0,0,155.31,213a6.67,6.67,0,1,0,13.33,0,20.64,20.64,0,0,1,20.61-20.61H195a40,40,0,0,0,0-80" fill="#37c837"/>
<circle id="leftdot" cx="123.62" cy="152.5" r="6.81" fill="#37c837"/>
<circle id="middledot" cx="150.01" cy="152.5" r="6.81" fill="#37c837"/>
<circle id="rightdot" cx="176.39" cy="152.5" r="6.81" fill="#37c837"/>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata>
<style type="text/css">
.st0{fill:#AAAAAA;}
.st1{fill:#CCCCCC;}
.st2{fill:none;}
.st3{fill:#EEEEEE;}
</style>
<rect width="64" height="64" fill="#fff" stroke-width=".94495"/><g transform="matrix(.32439 0 0 .32439 -.82859 9.3899)">
<path id="dark_greeen_19_" class="st0" d="m181.9 131.7h-32.5s-1.2-2.5-2.5-4.9-4.4-2.3-4.4-2.3h-82.8s-3-0.4-4.5 2.3c-1.6 2.7-2.6 4.9-2.6 4.9h-32c-6.9 0-12.6-5.6-12.6-12.5v-98.9c0-6.9 5.6-12.6 12.5-12.6h161.3c6.9 0 12.6 5.6 12.6 12.5v98.9c0.1 6.9-5.6 12.6-12.5 12.6z"/>
<path class="st1" d="m143.7 34.5h-85.1c-14.6 0-26.5 12-26.5 26.6s11.9 26.5 26.5 26.5h85.1c14.6 0 26.5-11.9 26.5-26.5 0.1-14.8-11.8-26.7-26.5-26.6zm-75.4 34.2s-3.9-2.9-9.4-2.9c-4.1 0-8.9 2.5-8.9 2.5-1.3-1.9-2.1-4.1-2.1-6.6 0-6.3 5.1-11.4 11.4-11.4s11.4 5.1 11.4 11.4c0 2.7-0.9 5.1-2.4 7zm32.9 6.6c-12.5 0-12-9.6-12-9.6-0.2-1.8 2.1-2.4 2.9-1.3 0.4 0.6 0.4 0.6 0.7 1.7 1.7 5.9 8.4 5.6 8.4 5.6s6.7 0.4 8.4-5.6c0.3-1 0.3-1.1 0.7-1.7 0.8-1 3.1-0.5 2.9 1.3 0 0 0.5 9.6-12 9.6zm51.1-6.9s-4.8-2.5-8.9-2.5c-5.5 0-9.4 2.9-9.4 2.9-1.5-1.9-2.4-4.3-2.4-7 0-6.3 5.1-11.4 11.4-11.4s11.4 5.1 11.4 11.4c0.1 2.4-0.7 4.7-2.1 6.6z"/>
<path class="st2" d="m110.3 64.3c-0.4 0.6-0.4 0.6-0.7 1.7-1.7 5.9-8.4 5.6-8.4 5.6s-6.7 0.4-8.4-5.6c-0.3-1-0.3-1.1-0.7-1.7-0.8-1-3.1-0.5-2.9 1.3 0 0-0.5 9.6 12 9.6s12-9.6 12-9.6c0.2-1.7-2.1-2.3-2.9-1.3z"/>
<path class="st2" d="m143.1 50.4c-6.3 0-11.4 5.1-11.4 11.4 0 2.6 0.9 5 2.4 7 0 0 3.9-2.9 9.4-2.9 4.1 0 8.9 2.5 8.9 2.5 1.3-1.9 2.1-4.1 2.1-6.6 0-6.3-5.1-11.4-11.4-11.4z"/>
<path class="st2" d="m59.3 50.4c-6.3 0-11.4 5.1-11.4 11.4 0 2.5 0.8 4.7 2.1 6.6 0 0 4.8-2.5 8.9-2.5 5.5 0 9.4 2.9 9.4 2.9 1.5-1.9 2.4-4.3 2.4-7 0-6.3-5.1-11.4-11.4-11.4z"/>
<path class="st3" d="m47.1 23.3c-6.3-1.7-11.7 2.1-14.7 7.3-0.7 1.2-0.2 2.2 0.5 2.6 1 0.3 1.7 0.1 2.8-1.5 2.2-3.9 5.9-6.1 10.1-5.3 0 0 2.9 0.9 3.3-1 0.3-1.2-0.8-1.8-2-2.1z"/>
<path class="st3" d="m159.9 27.3c-0.1 1.9 2.9 1.9 2.9 1.9 4.2 0.4 6.8 2.3 7.8 6.7 0.6 1.9 1.2 2.2 2.3 2.2 0.8-0.1 1.6-1 1.2-2.4-1.4-5.8-5.1-9.8-11.7-9.9-1.2-0.1-2.4 0.2-2.5 1.5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,134 @@
// Import modules
import * as am4charts from "@amcharts/amcharts4/charts";
import * as am4core from "@amcharts/amcharts4/core";
import am4themes_material from "@amcharts/amcharts4/themes/material";
const drawPieChart = (chartDivId: string, dataUrl: string | null): void => {
// Create chart instance
const chart = am4core.create(chartDivId, am4charts.PieChart);
am4core.percent(100);
// Set theme
am4core.useTheme(am4themes_material);
chart.innerRadius = am4core.percent(10);
// Add data
chart.dataSource.url = dataUrl || "";
chart.dataSource.parser.options.emptyAs = 0;
// Add and configure Series
const pieSeries = chart.series.push(new am4charts.PieSeries());
pieSeries.dataFields.value = "values";
pieSeries.dataFields.category = "labels";
pieSeries.slices.template.stroke = am4core.color("#ffffff");
pieSeries.slices.template.strokeWidth = 1;
pieSeries.slices.template.strokeOpacity = 1;
pieSeries.labels.template.disabled = true;
pieSeries.ticks.template.disabled = true;
chart.legend = new am4charts.Legend();
chart.legend.position = "right";
chart.legend.scrollable = true;
};
const drawXYChart = (chartDivId: string, dataUrl: string | null): void => {
// Create chart instance
const chart = am4core.create(chartDivId, am4charts.XYChart);
am4core.percent(100);
// Set theme
am4core.useTheme(am4themes_material);
// Create axes
const dateAxis = chart.xAxes.push(new am4charts.DateAxis());
dateAxis.renderer.minGridDistance = 60;
chart.yAxes.push(new am4charts.ValueAxis());
// Add data
chart.dataSource.url = dataUrl || "";
chart.dataSource.parser.options.emptyAs = 0;
// Create series
const series = chart.series.push(new am4charts.LineSeries());
series.dataFields.valueY = "values";
series.dataFields.dateX = "labels";
series.tooltipText = "{valueY} downloads";
series.tooltip.pointerOrientation = "vertical";
chart.cursor = new am4charts.XYCursor();
chart.cursor.snapToSeries = series;
chart.cursor.xAxis = dateAxis;
chart.scrollbarX = new am4core.Scrollbar();
};
const drawXYSeriesChart = (
chartDivId: string,
dataUrl: string | null
): void => {
// Create chart instance
const chart = am4core.create(chartDivId, am4charts.XYChart);
am4core.percent(100);
// Set theme
am4core.useTheme(am4themes_material);
// Create axes
chart.xAxes.push(new am4charts.ValueAxis());
chart.yAxes.push(new am4charts.ValueAxis());
// Add data
chart.dataSource.url = dataUrl || "";
chart.dataSource.parser.options.emptyAs = 0;
// Create series
const series1 = chart.series.push(new am4charts.LineSeries());
series1.dataFields.valueX = "X";
series1.dataFields.valueY = "aY";
const series2 = chart.series.push(new am4charts.LineSeries());
series2.dataFields.valueX = "X";
series2.dataFields.valueY = "bY";
const series3 = chart.series.push(new am4charts.LineSeries());
series3.dataFields.valueX = "X";
series3.dataFields.valueY = "cY";
const series4 = chart.series.push(new am4charts.LineSeries());
series4.dataFields.valueX = "X";
series4.dataFields.valueY = "dY";
const series5 = chart.series.push(new am4charts.LineSeries());
series5.dataFields.valueX = "X";
series5.dataFields.valueY = "eY";
};
const DrawCharts = (): void => {
const chartDivs: NodeListOf<HTMLDivElement> = document.querySelectorAll(
"div[data-chart-type]"
);
for (let i = 0; i < chartDivs.length; i++) {
const chartDiv: HTMLDivElement = chartDivs[i];
const chartType = chartDiv.dataset.chartType;
switch (chartType) {
case "pie-chart":
drawPieChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));
break;
case "xy-chart":
drawXYChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));
break;
case "xy-series-chart":
drawXYSeriesChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));
break;
default:
console.error("Unknown chart type:" + chartType);
}
}
};
export default DrawCharts;

View File

@ -24,7 +24,7 @@
<?= render_page_links() ?>
<small><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
'<a class="underline hover:no-underline" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></small>
</footer>
</body>

View File

@ -9,7 +9,7 @@
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/admin.css"/>
<link rel="stylesheet" href="/assets/index.css"/>
<script src="/assets/admin.js" defer></script>
<script src="/assets/admin.js" type="module" defer></script>
</head>
<body class="relative bg-gray-100 holy-grail-grid">
@ -43,12 +43,12 @@
<footer class="px-2 py-2 mx-auto text-xs text-right holy-grail-footer">
<small><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
'<a class="underline hover:no-underline" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></small>
</footer>
<button
type="button"
id="sidebar-toggler"
class="fixed bottom-0 left-0 z-50 p-3 mb-3 ml-3 text-xl transition duration-300 ease-in-out bg-white border-2 rounded-full shadow-lg focus:outline-none md:hidden hover:bg-gray-100 focus:shadow-outline"
class="fixed bottom-0 left-0 z-50 p-3 mb-3 ml-3 text-xl transition duration-300 ease-in-out bg-white border-2 rounded-full shadow-lg focus:outline-none md:hidden hover:bg-gray-100 focus:shadow-outline"
style="transform: translateX(0px);"><?= icon('menu') ?></button>
</body>

View File

@ -10,7 +10,7 @@ $podcastNavigation = [
],
'analytics' => [
'icon' => 'line-chart',
'items' => [],
'items' => ['podcast-analytics'],
],
'contributors' => [
'icon' => 'group',

View File

@ -0,0 +1,32 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= $podcast->title ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= $podcast->title ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="h-64" id="by-app-pie" data-chart-type="pie-chart" data-chart-url="<?= route_to(
'analytics-data',
$podcast->id,
'PodcastsByPlayer',
'ByApp'
) ?>"></div>
<div class="h-64" id="by-day-graph" data-chart-type="xy-chart" data-chart-url="<?= route_to(
'analytics-data',
$podcast->id,
'Podcasts',
'ByDay'
) ?>"></div>
<div class="h-64" id="by-age-graph" data-chart-type="xy-series-chart" data-chart-url="<?= route_to(
'analytics-data',
$podcast->id,
'PodcastsByEpisode',
'ByDay'
) ?>"></div>
<script src="/assets/charts.js" type="module"></script>
<?= $this->endSection() ?>

View File

@ -13,7 +13,9 @@
"codeigniter4/codeigniter4": "dev-develop",
"league/commonmark": "^1.5",
"vlucas/phpdotenv": "^5.2",
"league/html-to-markdown": "^4.10"
"league/html-to-markdown": "^4.10",
"podlibre/user-agents-php": "*",
"podlibre/ipcat": "*"
},
"require-dev": {
"mikey179/vfsstream": "1.6.*",
@ -27,8 +29,14 @@
},
"scripts": {
"test": "phpunit",
"post-install-cmd": [
"@php vendor/podlibre/user-agents-php/src/UserAgentsGenerate.php > vendor/podlibre/user-agents-php/src/UserAgents.php",
"@php vendor/podlibre/ipcat/IpDbGenerate.php > vendor/podlibre/ipcat/IpDb.php"
],
"post-update-cmd": [
"@composer dump-autoload"
"@composer dump-autoload",
"@php vendor/podlibre/user-agents-php/src/UserAgentsGenerate.php > vendor/podlibre/user-agents-php/src/UserAgents.php",
"@php vendor/podlibre/ipcat/IpDbGenerate.php > vendor/podlibre/ipcat/IpDb.php"
]
},
"support": {

146
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "38eeae7f5d0143863430cda9df10d487",
"content-hash": "47b9f628f03f8c494a9339b054359ec8",
"packages": [
{
"name": "codeigniter4/codeigniter4",
@ -12,12 +12,12 @@
"source": {
"type": "git",
"url": "https://github.com/codeigniter4/CodeIgniter4.git",
"reference": "9204aef421921f2c07021dda418ebfc200fe4a31"
"reference": "ccf68e1d7fc44bfe5abacc39bf16edae45794a83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/9204aef421921f2c07021dda418ebfc200fe4a31",
"reference": "9204aef421921f2c07021dda418ebfc200fe4a31",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/ccf68e1d7fc44bfe5abacc39bf16edae45794a83",
"reference": "ccf68e1d7fc44bfe5abacc39bf16edae45794a83",
"shasum": ""
},
"require": {
@ -37,6 +37,7 @@
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^8.5",
"predis/predis": "^1.1",
"rector/rector": "^0.8",
"squizlabs/php_codesniffer": "^3.3"
},
"type": "project",
@ -45,6 +46,11 @@
"CodeIgniter\\": "system/"
}
},
"autoload-dev": {
"psr-4": {
"Utils\\": "utils"
}
},
"scripts": {
"post-update-cmd": [
"@composer dump-autoload",
@ -69,7 +75,7 @@
"slack": "https://codeigniterchat.slack.com",
"issues": "https://github.com/codeigniter4/CodeIgniter4/issues"
},
"time": "2020-09-24T17:15:24+00:00"
"time": "2020-10-04T20:15:33+00:00"
},
{
"name": "composer/ca-bundle",
@ -143,27 +149,27 @@
},
{
"name": "geoip2/geoip2",
"version": "v2.10.0",
"version": "v2.11.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/GeoIP2-php.git",
"reference": "419557cd21d9fe039721a83490701a58c8ce784a"
"reference": "d01be5894a5c1a3381c58c9b1795cd07f96c30f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/419557cd21d9fe039721a83490701a58c8ce784a",
"reference": "419557cd21d9fe039721a83490701a58c8ce784a",
"url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/d01be5894a5c1a3381c58c9b1795cd07f96c30f7",
"reference": "d01be5894a5c1a3381c58c9b1795cd07f96c30f7",
"shasum": ""
},
"require": {
"ext-json": "*",
"maxmind-db/reader": "~1.5",
"maxmind/web-service-common": "~0.6",
"php": ">=5.6"
"maxmind-db/reader": "~1.8",
"maxmind/web-service-common": "~0.8",
"php": ">=7.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "2.*",
"phpunit/phpunit": "5.*",
"phpunit/phpunit": "^8.0 || ^9.0",
"squizlabs/php_codesniffer": "3.*"
},
"type": "library",
@ -192,7 +198,7 @@
"geolocation",
"maxmind"
],
"time": "2019-12-12T18:48:39+00:00"
"time": "2020-10-01T18:48:34+00:00"
},
{
"name": "graham-campbell/result-type",
@ -689,23 +695,23 @@
},
{
"name": "maxmind-db/reader",
"version": "v1.7.0",
"version": "v1.8.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git",
"reference": "942553da239f12051275f9c666538b5dd09e2908"
"reference": "b566d429ac9aec10594b0935be8ff38302f8d5c8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/942553da239f12051275f9c666538b5dd09e2908",
"reference": "942553da239f12051275f9c666538b5dd09e2908",
"url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/b566d429ac9aec10594b0935be8ff38302f8d5c8",
"reference": "b566d429ac9aec10594b0935be8ff38302f8d5c8",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"conflict": {
"ext-maxminddb": "<1.7.0,>=2.0.0"
"ext-maxminddb": "<1.8.0,>=2.0.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "2.*",
@ -745,31 +751,31 @@
"geolocation",
"maxmind"
],
"time": "2020-08-07T22:10:05+00:00"
"time": "2020-10-01T17:30:21+00:00"
},
{
"name": "maxmind/web-service-common",
"version": "v0.7.0",
"version": "v0.8.0",
"source": {
"type": "git",
"url": "https://github.com/maxmind/web-service-common-php.git",
"reference": "74c996c218ada5c639c8c2f076756e059f5552fc"
"reference": "ba67d9532cfaf499bd71774b8170d05df4f75fb7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/74c996c218ada5c639c8c2f076756e059f5552fc",
"reference": "74c996c218ada5c639c8c2f076756e059f5552fc",
"url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/ba67d9532cfaf499bd71774b8170d05df4f75fb7",
"reference": "ba67d9532cfaf499bd71774b8170d05df4f75fb7",
"shasum": ""
},
"require": {
"composer/ca-bundle": "^1.0.3",
"ext-curl": "*",
"ext-json": "*",
"php": ">=5.6"
"php": ">=7.2"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "2.*",
"phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0",
"phpunit/phpunit": "^8.0 || ^9.0",
"squizlabs/php_codesniffer": "3.*"
},
"type": "library",
@ -791,7 +797,7 @@
],
"description": "Internal MaxMind Web Service API",
"homepage": "https://github.com/maxmind/web-service-common-php",
"time": "2020-05-06T14:07:26+00:00"
"time": "2020-10-01T15:28:36+00:00"
},
{
"name": "myth/auth",
@ -918,6 +924,76 @@
],
"time": "2020-07-20T17:29:33+00:00"
},
{
"name": "podlibre/ipcat",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://github.com/podlibre/ipcat.git",
"reference": "1adfc821be508ddc8a742f6a5d5e6e42fdf28e86"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/podlibre/ipcat/zipball/1adfc821be508ddc8a742f6a5d5e6e42fdf28e86",
"reference": "1adfc821be508ddc8a742f6a5d5e6e42fdf28e86",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"Podlibre\\Ipcat\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-3.0-only"
],
"authors": [
{
"name": "Benjamin Bellamy",
"email": "ben@podlibre.org",
"homepage": "https://podlibre.org/"
}
],
"description": "Categorization of IP Addresses forked from https://github.com/client9/ipcat",
"homepage": "https://github.com/podlibre/ipcat",
"time": "2020-10-05T17:15:07+00:00"
},
{
"name": "podlibre/user-agents-php",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/podlibre/user-agents-php.git",
"reference": "891066bae6b4881a8b7a57eb72a67fca1fcf67c0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/podlibre/user-agents-php/zipball/891066bae6b4881a8b7a57eb72a67fca1fcf67c0",
"reference": "891066bae6b4881a8b7a57eb72a67fca1fcf67c0",
"shasum": ""
},
"type": "library",
"autoload": {
"psr-4": {
"Podlibre\\UserAgentsPhp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Benjamin Bellamy",
"email": "ben@podlibre.org",
"homepage": "https://podlibre.org/"
}
],
"description": "PHP implementation for opawg/user-agents.",
"homepage": "https://github.com/podlibre/user-agents-php",
"time": "2020-10-05T16:58:13+00:00"
},
{
"name": "psr/cache",
"version": "1.0.1",
@ -1801,28 +1877,28 @@
},
{
"name": "phpspec/prophecy",
"version": "1.11.1",
"version": "1.12.1",
"source": {
"type": "git",
"url": "https://github.com/phpspec/prophecy.git",
"reference": "b20034be5efcdab4fb60ca3a29cba2949aead160"
"reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/b20034be5efcdab4fb60ca3a29cba2949aead160",
"reference": "b20034be5efcdab4fb60ca3a29cba2949aead160",
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/8ce87516be71aae9b956f81906aaf0338e0d8a2d",
"reference": "8ce87516be71aae9b956f81906aaf0338e0d8a2d",
"shasum": ""
},
"require": {
"doctrine/instantiator": "^1.2",
"php": "^7.2",
"phpdocumentor/reflection-docblock": "^5.0",
"php": "^7.2 || ~8.0, <8.1",
"phpdocumentor/reflection-docblock": "^5.2",
"sebastian/comparator": "^3.0 || ^4.0",
"sebastian/recursion-context": "^3.0 || ^4.0"
},
"require-dev": {
"phpspec/phpspec": "^6.0",
"phpunit/phpunit": "^8.0"
"phpunit/phpunit": "^8.0 || ^9.0 <9.3"
},
"type": "library",
"extra": {
@ -1860,7 +1936,7 @@
"spy",
"stub"
],
"time": "2020-07-08T12:44:21+00:00"
"time": "2020-09-29T09:10:42+00:00"
},
{
"name": "phpunit/php-code-coverage",

View File

@ -104,6 +104,13 @@ docker ps -a
docker-compose run --rm app php spark migrate -all
```
In case you need to roll back, use this command:
```
# rolls back database schema loading (deletes all tables and their content)
docker-compose run --rm app php spark migrate:rollback
```
2. Populate the database with the required data:
```bash

1448
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,8 @@
"commit": "git-cz"
},
"dependencies": {
"@amcharts/amcharts4": "^4.9.37",
"@amcharts/amcharts4-geodata": "^4.1.17",
"@popperjs/core": "^2.5.3",
"choices.js": "^9.0.1",
"prosemirror-example-setup": "^1.1.2",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB