From 2f525c0f6e44d320bff16e22c223481923ba683e Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Fri, 2 Apr 2021 17:20:02 +0000 Subject: [PATCH] feat(fediverse): implement activitypub protocols + update user interface - add "ActivityPub" library to handle server to server federation and basic client to server protocols using activitypub: - add webfinger endpoint to look for actor - add actor definition with inbox / outbox / followers - remote follow an actor - create notes with possible preview cards - interract with favourites, reblogs and replies - block incoming actors and/or domains - broadcast/schedule activities to fediverse followers using a cron task - For castopod, the podcast is the actor: - overwrite the activitypub library for castopod's specific needs - perform basic interactions administrating a podcast to interact with fediverse users: - create notes with episode attachment - favourite and share a note + reply - add specific castopod_namespaces for podcasts and episodes definitions - overwrite CodeIgniter's Route service to include alternate-content option for activitystream requests - update episode publication logic: - remove publication inputs in create / edit episode form - publish / schedule or unpublish an episode after creation - the podcaster publishes a note when publishing an episode - Javascript / Typescript modules: - fix Dropdown.ts to keep dropdown menu in foreground - add Modal.ts for funding links modal - add Toggler.ts to toggle various css states in ui - User Interface: - update tailwindcss to v2 - use castopod's pine and rose colors - update public layout to a 3 column layout - add pages in public for podcast activity, episode list and notes - update episode page to include linked notes - remove previous and next episodes from episode pages - show different public views depending on whether user is authenticated or not - use Kumbh Sans and Montserrat fonts - update CodeIgniter's config files - with CodeIgniter's new requirements, update docker environments are now based on php v7.3 image - move Image entity to Libraries - update composer and npm packages to latest versions closes #69 #65 #85, fixes #51 #91 #92 #88 --- .devcontainer/Dockerfile | 4 +- .gitignore | 11 +- .prettierrc.json | 2 +- .rsync-filter | 2 +- .stylelintrc.json | 3 +- .svgo.icons.js | 17 + .svgo.icons.yml | 9 - .svgo.js | 12 + .svgo.yml | 5 - DEPENDENCIES.md | 16 +- Dockerfile | 8 +- INSTALL.md | 25 +- LICENSE | 661 -- LICENSE.md | 598 + README.md | 12 +- app/Config/ActivityPub.php | 14 + app/Config/App.php | 686 +- app/Config/Autoload.php | 118 +- app/Config/Boot/development.php | 45 +- app/Config/Boot/production.php | 31 +- app/Config/Boot/testing.php | 45 +- app/Config/Cache.php | 202 +- app/Config/Constants.php | 116 +- app/Config/ContentSecurityPolicy.php | 136 +- app/Config/Database.php | 32 +- app/Config/DocTypes.php | 11 +- app/Config/Encryption.php | 63 +- app/Config/Events.php | 102 +- app/Config/Exceptions.php | 59 +- app/Config/Filters.php | 55 +- app/Config/ForeignCharacters.php | 4 +- app/Config/Format.php | 95 +- app/Config/Generators.php | 44 + app/Config/Honeypot.php | 8 + app/Config/Images.php | 34 +- app/Config/Kint.php | 47 +- app/Config/Logger.php | 138 +- app/Config/Migrations.php | 78 +- app/Config/Mimes.php | 84 +- app/Config/Modules.php | 76 +- app/Config/Pager.php | 44 +- app/Config/Paths.php | 48 +- app/Config/Routes.php | 262 +- app/Config/Security.php | 92 + app/Config/Services.php | 63 +- app/Config/Toolbar.php | 124 +- app/Config/UserAgents.php | 57 +- app/Config/Validation.php | 25 +- app/Config/View.php | 10 +- app/Controllers/Actor.php | 22 + app/Controllers/Admin/BaseController.php | 22 +- app/Controllers/Admin/Episode.php | 309 +- app/Controllers/Admin/Fediverse.php | 41 + app/Controllers/Admin/Person.php | 2 +- app/Controllers/Admin/Podcast.php | 27 +- app/Controllers/Admin/PodcastImport.php | 107 +- app/Controllers/Auth.php | 26 +- app/Controllers/BaseController.php | 29 +- app/Controllers/Episode.php | 123 +- app/Controllers/Home.php | 4 +- app/Controllers/Install.php | 1 + app/Controllers/Note.php | 212 + app/Controllers/Page.php | 44 +- app/Controllers/Podcast.php | 85 +- .../2020-05-29-152000_add_categories.php | 2 +- .../2020-05-30-101000_add_languages.php | 2 +- .../2020-05-30-101500_add_podcasts.php | 28 +- .../2020-06-05-170000_add_episodes.php | 32 +- .../2020-06-05-180000_add_soundbites.php | 16 +- .../2020-06-05-190000_add_platforms.php | 2 +- ...20-06-08-120000_add_analytics_podcasts.php | 6 +- ...0000_add_analytics_podcasts_by_episode.php | 7 +- ...-130000_add_analytics_podcasts_by_hour.php | 6 +- ...40000_add_analytics_podcasts_by_player.php | 6 +- ...0000_add_analytics_podcasts_by_country.php | 6 +- ...60000_add_analytics_podcasts_by_region.php | 6 +- ...20-06-08-160000_add_podcasts_platforms.php | 2 - ...70000_add_analytics_website_by_browser.php | 6 +- ...80000_add_analytics_website_by_referer.php | 6 +- ...00_add_analytics_website_by_entry_page.php | 6 +- ...10000_add_analytics_unknown_useragents.php | 4 +- ...10000_add_analytics_podcasts_procedure.php | 103 +- ...analytics_unknown_useragents_procedure.php | 17 +- ...210000_add_analytics_website_procedure.php | 35 +- .../2020-07-03-191500_add_podcasts_users.php | 22 +- .../2020-08-17-150000_add_pages.php | 6 +- ...0-09-29-150000_add_podcasts_categories.php | 16 +- .../2020-12-25-120000_add_persons.php | 6 + ...2020-12-25-130000_add_podcasts_persons.php | 16 +- ...2020-12-25-140000_add_episodes_persons.php | 24 +- .../2020-12-25-150000_add_credit_view.php | 20 +- ...1-02-23-100000_add_episode_id_to_notes.php | 38 + ...1-03-09-113000_add_created_by_to_notes.php | 38 + app/Database/Seeds/AuthSeeder.php | 38 +- app/Database/Seeds/PlatformSeeder.php | 5 +- app/Entities/Credit.php | 51 +- app/Entities/Episode.php | 136 +- app/Entities/Image.php | 151 - app/Entities/Note.php | 56 + app/Entities/Person.php | 22 +- app/Entities/Podcast.php | 134 +- app/Entities/Soundbite.php | 7 - .../{Permission.php => PermissionFilter.php} | 2 +- app/Helpers/analytics_helper.php | 36 +- app/Helpers/auth_helper.php | 89 + app/Helpers/components_helper.php | 129 +- app/Helpers/media_helper.php | 28 +- app/Helpers/misc_helper.php | 2 + app/Helpers/persons_helper.php | 85 +- app/Helpers/rss_helper.php | 4 +- app/Helpers/svg_helper.php | 33 +- app/Helpers/url_helper.php | 31 + app/Language/en/ActivityPub.php | 34 + app/Language/en/Admin.php | 1 + app/Language/en/AdminNavigation.php | 3 + app/Language/en/Breadcrumb.php | 5 + app/Language/en/Common.php | 4 + app/Language/en/Episode.php | 78 +- app/Language/en/Fediverse.php | 23 + app/Language/en/Note.php | 38 + app/Language/en/Page.php | 3 +- app/Language/en/Podcast.php | 21 +- app/Language/en/PodcastNavigation.php | 2 + app/Language/fr/ActivityPub.php | 35 + app/Language/fr/AdminNavigation.php | 3 + app/Language/fr/Breadcrumb.php | 5 + app/Language/fr/Common.php | 4 + app/Language/fr/Episode.php | 80 +- app/Language/fr/Fediverse.php | 20 + app/Language/fr/Note.php | 39 + app/Language/fr/Page.php | 1 + app/Language/fr/Podcast.php | 21 +- app/Language/fr/PodcastNavigation.php | 2 + .../ActivityPub/Activities/AcceptActivity.php | 24 + .../Activities/AnnounceActivity.php | 37 + .../ActivityPub/Activities/CreateActivity.php | 24 + .../ActivityPub/Activities/DeleteActivity.php | 24 + .../ActivityPub/Activities/FollowActivity.php | 24 + .../ActivityPub/Activities/LikeActivity.php | 24 + .../ActivityPub/Activities/UndoActivity.php | 24 + app/Libraries/ActivityPub/ActivityRequest.php | 119 + .../ActivityPub/Config/ActivityPub.php | 22 + app/Libraries/ActivityPub/Config/Routes.php | 106 + .../Controllers/ActorController.php | 376 + .../Controllers/BlockController.php | 105 + .../Controllers/NoteController.php | 278 + .../Controllers/SchedulerController.php | 36 + .../Controllers/WebFingerController.php | 28 + .../ActivityPub/Core/AbstractObject.php | 50 + app/Libraries/ActivityPub/Core/Activity.php | 32 + app/Libraries/ActivityPub/Core/ObjectType.php | 52 + .../2018-01-01-010000_add_actors.php | 120 + .../2018-01-01-020000_add_notes.php | 108 + .../2018-01-01-100000_add_activities.php | 90 + .../2018-01-01-100000_add_favourites.php | 55 + .../2018-01-01-100000_add_follows.php | 57 + .../2018-01-01-100000_add_preview_cards.php | 82 + ...8-01-01-110000_add_notes_preview_cards.php | 53 + .../2018-01-01-120000_add_blocked_domains.php | 37 + .../ActivityPub/Entities/Activity.php | 99 + app/Libraries/ActivityPub/Entities/Actor.php | 84 + .../ActivityPub/Entities/BlockedDomain.php | 18 + .../ActivityPub/Entities/Favourite.php | 21 + app/Libraries/ActivityPub/Entities/Follow.php | 19 + app/Libraries/ActivityPub/Entities/Note.php | 200 + .../ActivityPub/Entities/PreviewCard.php | 29 + .../ActivityPub/Filters/ActivityPubFilter.php | 99 + .../Helpers/activitypub_helper.php | 513 + app/Libraries/ActivityPub/HttpSignature.php | 170 + .../ActivityPub/Models/ActivityModel.php | 83 + .../ActivityPub/Models/ActorModel.php | 125 + .../ActivityPub/Models/BlockedDomainModel.php | 79 + .../ActivityPub/Models/FavouriteModel.php | 178 + .../ActivityPub/Models/FollowModel.php | 148 + .../ActivityPub/Models/NoteModel.php | 548 + .../ActivityPub/Models/PreviewCardModel.php | 56 + .../ActivityPub/Models/UuidModel.php | 206 + .../ActivityPub/Objects/ActorObject.php | 113 + .../ActivityPub/Objects/NoteObject.php | 61 + .../Objects/OrderedCollectionObject.php | 66 + .../Objects/OrderedCollectionPage.php | 55 + .../ActivityPub/Objects/TombstoneObject.php | 19 + app/Libraries/ActivityPub/WebFinger.php | 126 + app/Libraries/Breadcrumb.php | 2 +- app/Libraries/Image.php | 153 + app/Libraries/Negotiate.php | 14 + app/Libraries/NoteObject.php | 30 + app/Libraries/PodcastActor.php | 31 + app/Libraries/Router.php | 205 + app/Libraries/SimpleRSSElement.php | 2 +- app/Models/AnalyticsPodcastByCountryModel.php | 32 +- app/Models/AnalyticsPodcastByEpisodeModel.php | 60 +- app/Models/AnalyticsPodcastByHourModel.php | 14 +- app/Models/AnalyticsPodcastByPlayerModel.php | 98 +- app/Models/AnalyticsPodcastByRegionModel.php | 20 +- app/Models/AnalyticsPodcastByServiceModel.php | 20 +- app/Models/AnalyticsPodcastModel.php | 86 +- app/Models/AnalyticsWebsiteByBrowserModel.php | 15 +- .../AnalyticsWebsiteByEntryPageModel.php | 14 +- app/Models/AnalyticsWebsiteByRefererModel.php | 42 +- app/Models/EpisodeModel.php | 158 +- app/Models/NoteModel.php | 45 + app/Models/PersonModel.php | 5 +- app/Models/PodcastModel.php | 135 +- app/Models/UserModel.php | 4 +- app/Views/_assets/admin.ts | 2 +- .../fonts/kumbh-sans/kumbh-sans-700.woff | Bin 0 -> 21208 bytes .../fonts/kumbh-sans/kumbh-sans-700.woff2 | Bin 0 -> 17060 bytes .../fonts/kumbh-sans/kumbh-sans-regular.woff | Bin 0 -> 21772 bytes .../fonts/kumbh-sans/kumbh-sans-regular.woff2 | Bin 0 -> 17624 bytes .../fonts/montserrat/montserrat-600.woff | Bin 0 -> 23628 bytes .../fonts/montserrat/montserrat-600.woff2 | Bin 0 -> 19264 bytes .../fonts/montserrat/montserrat-regular.woff | Bin 0 -> 23480 bytes .../fonts/montserrat/montserrat-regular.woff2 | Bin 0 -> 19172 bytes app/Views/_assets/icons/add-box.svg | 7 +- app/Views/_assets/icons/add.svg | 0 app/Views/_assets/icons/alert.svg | 2 +- app/Views/_assets/icons/bookmark.svg | 7 +- app/Views/_assets/icons/chat.svg | 6 + app/Views/_assets/icons/cloud-off.svg | 6 + app/Views/_assets/icons/dashboard.svg | 2 +- app/Views/_assets/icons/delete-bin.svg | 2 +- app/Views/_assets/icons/download.svg | 2 +- app/Views/_assets/icons/edit.svg | 2 +- app/Views/_assets/icons/external-link.svg | 2 +- app/Views/_assets/icons/eye.svg | 2 +- app/Views/_assets/icons/file-copy.svg | 7 +- app/Views/_assets/icons/file.svg | 7 +- app/Views/_assets/icons/folder-user.svg | 7 +- app/Views/_assets/icons/funding/gofundme.svg | 1 + app/Views/_assets/icons/funding/helloasso.svg | 1 + app/Views/_assets/icons/funding/indiegogo.svg | 1 + .../_assets/icons/funding/kickstarter.svg | 1 + .../icons/funding/kisskissbankbank.svg | 1 + app/Views/_assets/icons/funding/liberapay.svg | 1 + app/Views/_assets/icons/funding/patreon.svg | 1 + app/Views/_assets/icons/funding/paypal.svg | 1 + app/Views/_assets/icons/funding/tipeee.svg | 1 + app/Views/_assets/icons/funding/ulule.svg | 1 + app/Views/_assets/icons/group.svg | 2 +- app/Views/_assets/icons/heart.svg | 6 + app/Views/_assets/icons/line-chart.svg | 2 +- app/Views/_assets/icons/link.svg | 6 + app/Views/_assets/icons/links.svg | 1 - app/Views/_assets/icons/map-pin.svg | 7 +- app/Views/_assets/icons/menu.svg | 0 app/Views/_assets/icons/mic.svg | 2 +- app/Views/_assets/icons/more.svg | 2 +- app/Views/_assets/icons/movie.svg | 7 +- app/Views/_assets/icons/pages.svg | 2 +- app/Views/_assets/icons/podcasting/amazon.svg | 1 + .../_assets/icons/podcasting/antennapod.svg | 1 + app/Views/_assets/icons/podcasting/apple.svg | 1 + .../_assets/icons/podcasting/blubrry.svg | 1 + .../_assets/icons/podcasting/breaker.svg | 1 + .../_assets/icons/podcasting/castbox.svg | 1 + .../_assets/icons/podcasting/castopod.svg | 1 + app/Views/_assets/icons/podcasting/castro.svg | 1 + .../_assets/icons/podcasting/chartable.svg | 1 + app/Views/_assets/icons/podcasting/deezer.svg | 1 + app/Views/_assets/icons/podcasting/fyyd.svg | 1 + app/Views/_assets/icons/podcasting/google.svg | 1 + app/Views/_assets/icons/podcasting/ivoox.svg | 1 + .../_assets/icons/podcasting/listennotes.svg | 1 + .../_assets/icons/podcasting/overcast.svg | 1 + .../_assets/icons/podcasting/playerfm.svg | 1 + .../_assets/icons/podcasting/pocketcasts.svg | 1 + .../_assets/icons/podcasting/podbean.svg | 1 + .../icons/podcasting/podcastaddict.svg | 1 + .../_assets/icons/podcasting/podcastindex.svg | 1 + .../_assets/icons/podcasting/podchaser.svg | 1 + .../_assets/icons/podcasting/podcloud.svg | 1 + .../_assets/icons/podcasting/podfriend.svg | 1 + .../_assets/icons/podcasting/podinstall.svg | 1 + .../_assets/icons/podcasting/podlink.svg | 1 + .../_assets/icons/podcasting/podtail.svg | 1 + .../_assets/icons/podcasting/podverse.svg | 1 + .../_assets/icons/podcasting/radiopublic.svg | 1 + .../_assets/icons/podcasting/spotify.svg | 1 + .../_assets/icons/podcasting/spreaker.svg | 1 + .../_assets/icons/podcasting/stitcher.svg | 1 + app/Views/_assets/icons/podcasting/tunein.svg | 1 + app/Views/_assets/icons/question.svg | 2 +- app/Views/_assets/icons/repeat.svg | 6 + app/Views/_assets/icons/rss.svg | 2 +- app/Views/_assets/icons/scales.svg | 2 +- app/Views/_assets/icons/settings.svg | 2 +- app/Views/_assets/icons/social/castopod.svg | 1 + app/Views/_assets/icons/social/discord.svg | 1 + app/Views/_assets/icons/social/facebook.svg | 1 + app/Views/_assets/icons/social/funkwhale.svg | 1 + app/Views/_assets/icons/social/instagram.svg | 1 + app/Views/_assets/icons/social/linkedin.svg | 1 + app/Views/_assets/icons/social/mastodon.svg | 1 + app/Views/_assets/icons/social/mobilizon.svg | 1 + app/Views/_assets/icons/social/peertube.svg | 1 + app/Views/_assets/icons/social/pixelfed.svg | 1 + app/Views/_assets/icons/social/plume.svg | 1 + app/Views/_assets/icons/social/reddit.svg | 1 + app/Views/_assets/icons/social/slack.svg | 1 + app/Views/_assets/icons/social/tiktok.svg | 1 + app/Views/_assets/icons/social/twitch.svg | 1 + app/Views/_assets/icons/social/twitter.svg | 1 + .../_assets/icons/social/writefreely.svg | 1 + app/Views/_assets/icons/social/youtube.svg | 1 + app/Views/_assets/icons/star-smile.svg | 6 + app/Views/_assets/icons/timer.svg | 7 +- app/Views/_assets/icons/upload-cloud.svg | 6 + app/Views/_assets/icons/user-add.svg | 2 +- app/Views/_assets/icons/user.svg | 2 +- .../_assets/images/castopod-cover-default.jpg | Bin 0 -> 13020 bytes .../{logo-castopod.svg => castopod-logo.svg} | 1 - .../_assets/images/logo-castopod-circle.svg | 26 - .../_assets/images/platforms/_default.svg | 11 - .../images/platforms/funding/gofundme.svg | 19 - .../images/platforms/funding/helloasso.svg | 19 - .../images/platforms/funding/indiegogo.svg | 16 - .../images/platforms/funding/kickstarter.svg | 4 - .../platforms/funding/kisskissbankbank.svg | 4 - .../images/platforms/funding/liberapay.svg | 20 - .../images/platforms/funding/patreon.svg | 24 - .../images/platforms/funding/paypal.svg | 11 - .../images/platforms/funding/tipeee.svg | 14 - .../images/platforms/funding/ulule.svg | 37 - .../images/platforms/podcasting/amazon.svg | 9 - .../platforms/podcasting/antennapod.svg | 14 - .../images/platforms/podcasting/apple.svg | 14 - .../images/platforms/podcasting/blubrry.svg | 6 - .../images/platforms/podcasting/breaker.svg | 11 - .../images/platforms/podcasting/castbox.svg | 17 - .../images/platforms/podcasting/castopod.svg | 12 - .../images/platforms/podcasting/castro.svg | 17 - .../images/platforms/podcasting/chartable.svg | 5 - .../images/platforms/podcasting/deezer.svg | 54 - .../images/platforms/podcasting/fyyd.svg | 6 - .../images/platforms/podcasting/google.svg | 14 - .../images/platforms/podcasting/ivoox.svg | 7 - .../platforms/podcasting/listennotes.svg | 10 - .../images/platforms/podcasting/overcast.svg | 6 - .../images/platforms/podcasting/playerfm.svg | 9 - .../platforms/podcasting/pocketcasts.svg | 6 - .../images/platforms/podcasting/podbean.svg | 9 - .../platforms/podcasting/podcastaddict.svg | 13 - .../platforms/podcasting/podcastindex.svg | 4 - .../images/platforms/podcasting/podchaser.svg | 8 - .../images/platforms/podcasting/podcloud.svg | 8 - .../images/platforms/podcasting/podfriend.svg | 20 - .../platforms/podcasting/podinstall.svg | 4 - .../images/platforms/podcasting/podlink.svg | 1 - .../images/platforms/podcasting/podtail.svg | 13 - .../images/platforms/podcasting/podverse.svg | 4 - .../platforms/podcasting/radiopublic.svg | 8 - .../images/platforms/podcasting/spotify.svg | 6 - .../images/platforms/podcasting/spreaker.svg | 6 - .../images/platforms/podcasting/stitcher.svg | 10 - .../images/platforms/podcasting/tunein.svg | 8 - .../images/platforms/social/castopod.svg | 1 - .../images/platforms/social/discord.svg | 1 - .../images/platforms/social/facebook.svg | 1 - .../images/platforms/social/funkwhale.svg | 1 - .../images/platforms/social/instagram.svg | 1 - .../images/platforms/social/linkedin.svg | 1 - .../images/platforms/social/mastodon.svg | 1 - .../images/platforms/social/mobilizon.svg | 1 - .../images/platforms/social/peertube.svg | 1 - .../images/platforms/social/pixelfed.svg | 1 - .../_assets/images/platforms/social/plume.svg | 1 - .../_assets/images/platforms/social/slack.svg | 1 - .../images/platforms/social/twitch.svg | 1 - .../images/platforms/social/twitter.svg | 1 - .../images/platforms/social/writefreely.svg | 1 - .../images/platforms/social/youtube.svg | 1 - app/Views/_assets/modules/Clipboard.ts | 6 +- app/Views/_assets/modules/Dropdown.ts | 113 +- app/Views/_assets/modules/MarkdownEditor.ts | 8 +- app/Views/_assets/modules/Modal.ts | 34 + app/Views/_assets/modules/Soundbites.ts | 12 +- app/Views/_assets/modules/ThemePicker.ts | 6 +- app/Views/_assets/modules/Toggler.ts | 31 + app/Views/_assets/podcast.ts | 6 + app/Views/_assets/styles/dropdown.css | 8 + app/Views/_assets/styles/fonts.css | 41 + app/Views/_assets/styles/index.css | 5 + app/Views/_assets/styles/layout.css | 3 +- app/Views/_assets/styles/multiSelect.css | 306 +- app/Views/_assets/styles/note.css | 22 + app/Views/_assets/styles/radioBtn.css | 40 +- app/Views/_assets/styles/radioToggler.css | 9 + app/Views/_assets/styles/switch.css | 42 +- app/Views/_assets/styles/tabs.css | 37 + app/Views/admin/_layout.php | 19 +- app/Views/admin/_sidebar.php | 77 +- app/Views/admin/contributor/list.php | 2 +- app/Views/admin/episode/create.php | 138 +- app/Views/admin/episode/edit.php | 146 +- app/Views/admin/episode/embeddable_player.php | 93 +- app/Views/admin/episode/list.php | 108 +- app/Views/admin/episode/publish.php | 171 + app/Views/admin/episode/publish_edit.php | 186 + app/Views/admin/episode/unpublish.php | 53 + app/Views/admin/episode/view.php | 60 +- app/Views/admin/fediverse/blocked_actors.php | 83 + app/Views/admin/fediverse/blocked_domains.php | 80 + app/Views/admin/page/list.php | 2 +- app/Views/admin/page/view.php | 2 +- app/Views/admin/podcast/_sidebar.php | 61 +- app/Views/admin/podcast/edit.php | 85 +- app/Views/admin/podcast/import.php | 44 +- app/Views/admin/podcast/latest_episodes.php | 102 +- app/Views/admin/podcast/list.php | 34 +- app/Views/admin/podcast/platforms.php | 29 +- app/Views/admin/podcast/view.php | 11 +- app/Views/admin/user/list.php | 2 +- app/Views/auth/_layout.php | 10 +- app/Views/credits.php | 50 +- app/Views/embeddable_player.php | 245 +- app/Views/episode.php | 149 - app/Views/errors/cli/error_exception.php | 95 +- app/Views/errors/html/debug.css | 1 + app/Views/errors/html/error_404.php | 4 +- app/Views/errors/html/error_exception.php | 836 +- app/Views/errors/html/production.php | 2 +- app/Views/home.php | 75 +- app/Views/install/_layout.php | 2 +- app/Views/install/cache_config.php | 10 +- app/Views/install/create_superadmin.php | 6 +- app/Views/install/database_config.php | 10 +- app/Views/install/instance_config.php | 21 +- app/Views/install/manual_config.php | 6 +- app/Views/page.php | 43 +- app/Views/pager/default_full.php | 2 +- app/Views/podcast.php | 261 - app/Views/podcast/_layout.php | 120 + app/Views/podcast/_layout_authenticated.php | 147 + app/Views/podcast/_partials/episode_card.php | 36 + app/Views/podcast/_partials/header.php | 95 + app/Views/podcast/_partials/note.php | 41 + app/Views/podcast/_partials/note_actions.php | 36 + .../_partials/note_actions_authenticated.php | 89 + .../podcast/_partials/note_authenticated.php | 41 + .../podcast/_partials/note_with_replies.php | 21 + .../note_with_replies_authenticated.php | 45 + app/Views/podcast/_partials/preview_card.php | 45 + app/Views/podcast/_partials/reblog.php | 48 + .../_partials/reblog_authenticated.php | 48 + app/Views/podcast/_partials/reply.php | 29 + app/Views/podcast/_partials/reply_actions.php | 36 + .../_partials/reply_actions_authenticated.php | 89 + .../podcast/_partials/reply_authenticated.php | 29 + app/Views/podcast/_partials/sidebar.php | 91 + app/Views/podcast/activity.php | 53 + app/Views/podcast/activity_authenticated.php | 99 + app/Views/podcast/episode.php | 186 + app/Views/podcast/episode_authenticated.php | 220 + app/Views/podcast/episodes.php | 171 + app/Views/podcast/episodes_authenticated.php | 171 + app/Views/podcast/follow.php | 91 + app/Views/podcast/note.php | 38 + app/Views/podcast/note_authenticated.php | 40 + app/Views/podcast/note_remote_action.php | 69 + castopod_namespace.json | 31 + composer.json | 34 +- composer.lock | 1932 +++- crontab | 1 + docker-compose.yml | 3 +- docs/setup-development.md | 20 +- env | 38 +- package-lock.json | 9935 ++++++++++++----- package.json | 95 +- phpunit.xml.dist | 69 +- public/.htaccess | 2 +- public/index.php | 23 +- public/media/persons/index.html | 0 public/media/podcasts/index.html | 9 + spark | 31 +- tailwind.config.js | 53 +- 476 files changed, 24041 insertions(+), 9081 deletions(-) create mode 100644 .svgo.icons.js delete mode 100644 .svgo.icons.yml create mode 100644 .svgo.js delete mode 100644 .svgo.yml delete mode 100644 LICENSE create mode 100644 LICENSE.md create mode 100644 app/Config/ActivityPub.php create mode 100644 app/Config/Generators.php create mode 100644 app/Config/Security.php create mode 100644 app/Controllers/Actor.php create mode 100644 app/Controllers/Admin/Fediverse.php create mode 100644 app/Controllers/Note.php create mode 100644 app/Database/Migrations/2021-02-23-100000_add_episode_id_to_notes.php create mode 100644 app/Database/Migrations/2021-03-09-113000_add_created_by_to_notes.php delete mode 100644 app/Entities/Image.php create mode 100644 app/Entities/Note.php rename app/Filters/{Permission.php => PermissionFilter.php} (98%) create mode 100644 app/Helpers/auth_helper.php create mode 100644 app/Language/en/ActivityPub.php create mode 100644 app/Language/en/Fediverse.php create mode 100644 app/Language/en/Note.php create mode 100644 app/Language/fr/ActivityPub.php create mode 100644 app/Language/fr/Fediverse.php create mode 100644 app/Language/fr/Note.php create mode 100644 app/Libraries/ActivityPub/Activities/AcceptActivity.php create mode 100644 app/Libraries/ActivityPub/Activities/AnnounceActivity.php create mode 100644 app/Libraries/ActivityPub/Activities/CreateActivity.php create mode 100644 app/Libraries/ActivityPub/Activities/DeleteActivity.php create mode 100644 app/Libraries/ActivityPub/Activities/FollowActivity.php create mode 100644 app/Libraries/ActivityPub/Activities/LikeActivity.php create mode 100644 app/Libraries/ActivityPub/Activities/UndoActivity.php create mode 100644 app/Libraries/ActivityPub/ActivityRequest.php create mode 100644 app/Libraries/ActivityPub/Config/ActivityPub.php create mode 100644 app/Libraries/ActivityPub/Config/Routes.php create mode 100644 app/Libraries/ActivityPub/Controllers/ActorController.php create mode 100644 app/Libraries/ActivityPub/Controllers/BlockController.php create mode 100644 app/Libraries/ActivityPub/Controllers/NoteController.php create mode 100644 app/Libraries/ActivityPub/Controllers/SchedulerController.php create mode 100644 app/Libraries/ActivityPub/Controllers/WebFingerController.php create mode 100644 app/Libraries/ActivityPub/Core/AbstractObject.php create mode 100644 app/Libraries/ActivityPub/Core/Activity.php create mode 100644 app/Libraries/ActivityPub/Core/ObjectType.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-010000_add_actors.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_notes.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_notes_preview_cards.php create mode 100644 app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php create mode 100644 app/Libraries/ActivityPub/Entities/Activity.php create mode 100644 app/Libraries/ActivityPub/Entities/Actor.php create mode 100644 app/Libraries/ActivityPub/Entities/BlockedDomain.php create mode 100644 app/Libraries/ActivityPub/Entities/Favourite.php create mode 100644 app/Libraries/ActivityPub/Entities/Follow.php create mode 100644 app/Libraries/ActivityPub/Entities/Note.php create mode 100644 app/Libraries/ActivityPub/Entities/PreviewCard.php create mode 100644 app/Libraries/ActivityPub/Filters/ActivityPubFilter.php create mode 100644 app/Libraries/ActivityPub/Helpers/activitypub_helper.php create mode 100644 app/Libraries/ActivityPub/HttpSignature.php create mode 100644 app/Libraries/ActivityPub/Models/ActivityModel.php create mode 100644 app/Libraries/ActivityPub/Models/ActorModel.php create mode 100644 app/Libraries/ActivityPub/Models/BlockedDomainModel.php create mode 100644 app/Libraries/ActivityPub/Models/FavouriteModel.php create mode 100644 app/Libraries/ActivityPub/Models/FollowModel.php create mode 100644 app/Libraries/ActivityPub/Models/NoteModel.php create mode 100644 app/Libraries/ActivityPub/Models/PreviewCardModel.php create mode 100644 app/Libraries/ActivityPub/Models/UuidModel.php create mode 100644 app/Libraries/ActivityPub/Objects/ActorObject.php create mode 100644 app/Libraries/ActivityPub/Objects/NoteObject.php create mode 100644 app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php create mode 100644 app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php create mode 100644 app/Libraries/ActivityPub/Objects/TombstoneObject.php create mode 100644 app/Libraries/ActivityPub/WebFinger.php create mode 100644 app/Libraries/Image.php create mode 100644 app/Libraries/Negotiate.php create mode 100644 app/Libraries/NoteObject.php create mode 100644 app/Libraries/PodcastActor.php create mode 100644 app/Libraries/Router.php create mode 100644 app/Models/NoteModel.php create mode 100644 app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff create mode 100644 app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff2 create mode 100644 app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff create mode 100644 app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff2 create mode 100644 app/Views/_assets/fonts/montserrat/montserrat-600.woff create mode 100644 app/Views/_assets/fonts/montserrat/montserrat-600.woff2 create mode 100644 app/Views/_assets/fonts/montserrat/montserrat-regular.woff create mode 100644 app/Views/_assets/fonts/montserrat/montserrat-regular.woff2 mode change 100644 => 100755 app/Views/_assets/icons/add-box.svg mode change 100644 => 100755 app/Views/_assets/icons/add.svg mode change 100644 => 100755 app/Views/_assets/icons/alert.svg mode change 100644 => 100755 app/Views/_assets/icons/bookmark.svg create mode 100755 app/Views/_assets/icons/chat.svg create mode 100755 app/Views/_assets/icons/cloud-off.svg mode change 100644 => 100755 app/Views/_assets/icons/dashboard.svg mode change 100644 => 100755 app/Views/_assets/icons/delete-bin.svg mode change 100644 => 100755 app/Views/_assets/icons/download.svg mode change 100644 => 100755 app/Views/_assets/icons/edit.svg mode change 100644 => 100755 app/Views/_assets/icons/external-link.svg mode change 100644 => 100755 app/Views/_assets/icons/eye.svg mode change 100644 => 100755 app/Views/_assets/icons/file-copy.svg mode change 100644 => 100755 app/Views/_assets/icons/file.svg mode change 100644 => 100755 app/Views/_assets/icons/folder-user.svg create mode 100755 app/Views/_assets/icons/funding/gofundme.svg create mode 100755 app/Views/_assets/icons/funding/helloasso.svg create mode 100755 app/Views/_assets/icons/funding/indiegogo.svg create mode 100755 app/Views/_assets/icons/funding/kickstarter.svg create mode 100755 app/Views/_assets/icons/funding/kisskissbankbank.svg create mode 100755 app/Views/_assets/icons/funding/liberapay.svg create mode 100755 app/Views/_assets/icons/funding/patreon.svg create mode 100755 app/Views/_assets/icons/funding/paypal.svg create mode 100755 app/Views/_assets/icons/funding/tipeee.svg create mode 100755 app/Views/_assets/icons/funding/ulule.svg mode change 100644 => 100755 app/Views/_assets/icons/group.svg create mode 100755 app/Views/_assets/icons/heart.svg mode change 100644 => 100755 app/Views/_assets/icons/line-chart.svg create mode 100755 app/Views/_assets/icons/link.svg delete mode 100644 app/Views/_assets/icons/links.svg mode change 100644 => 100755 app/Views/_assets/icons/menu.svg mode change 100644 => 100755 app/Views/_assets/icons/mic.svg mode change 100644 => 100755 app/Views/_assets/icons/more.svg mode change 100644 => 100755 app/Views/_assets/icons/movie.svg mode change 100644 => 100755 app/Views/_assets/icons/pages.svg create mode 100755 app/Views/_assets/icons/podcasting/amazon.svg create mode 100755 app/Views/_assets/icons/podcasting/antennapod.svg create mode 100755 app/Views/_assets/icons/podcasting/apple.svg create mode 100755 app/Views/_assets/icons/podcasting/blubrry.svg create mode 100755 app/Views/_assets/icons/podcasting/breaker.svg create mode 100755 app/Views/_assets/icons/podcasting/castbox.svg create mode 100755 app/Views/_assets/icons/podcasting/castopod.svg create mode 100755 app/Views/_assets/icons/podcasting/castro.svg create mode 100755 app/Views/_assets/icons/podcasting/chartable.svg create mode 100755 app/Views/_assets/icons/podcasting/deezer.svg create mode 100755 app/Views/_assets/icons/podcasting/fyyd.svg create mode 100755 app/Views/_assets/icons/podcasting/google.svg create mode 100755 app/Views/_assets/icons/podcasting/ivoox.svg create mode 100755 app/Views/_assets/icons/podcasting/listennotes.svg create mode 100755 app/Views/_assets/icons/podcasting/overcast.svg create mode 100755 app/Views/_assets/icons/podcasting/playerfm.svg create mode 100755 app/Views/_assets/icons/podcasting/pocketcasts.svg create mode 100755 app/Views/_assets/icons/podcasting/podbean.svg create mode 100755 app/Views/_assets/icons/podcasting/podcastaddict.svg create mode 100755 app/Views/_assets/icons/podcasting/podcastindex.svg create mode 100755 app/Views/_assets/icons/podcasting/podchaser.svg create mode 100755 app/Views/_assets/icons/podcasting/podcloud.svg create mode 100755 app/Views/_assets/icons/podcasting/podfriend.svg create mode 100755 app/Views/_assets/icons/podcasting/podinstall.svg create mode 100755 app/Views/_assets/icons/podcasting/podlink.svg create mode 100755 app/Views/_assets/icons/podcasting/podtail.svg create mode 100755 app/Views/_assets/icons/podcasting/podverse.svg create mode 100755 app/Views/_assets/icons/podcasting/radiopublic.svg create mode 100755 app/Views/_assets/icons/podcasting/spotify.svg create mode 100755 app/Views/_assets/icons/podcasting/spreaker.svg create mode 100755 app/Views/_assets/icons/podcasting/stitcher.svg create mode 100755 app/Views/_assets/icons/podcasting/tunein.svg mode change 100644 => 100755 app/Views/_assets/icons/question.svg create mode 100644 app/Views/_assets/icons/repeat.svg mode change 100644 => 100755 app/Views/_assets/icons/rss.svg mode change 100644 => 100755 app/Views/_assets/icons/scales.svg mode change 100644 => 100755 app/Views/_assets/icons/settings.svg create mode 100755 app/Views/_assets/icons/social/castopod.svg create mode 100755 app/Views/_assets/icons/social/discord.svg create mode 100755 app/Views/_assets/icons/social/facebook.svg create mode 100755 app/Views/_assets/icons/social/funkwhale.svg create mode 100755 app/Views/_assets/icons/social/instagram.svg create mode 100755 app/Views/_assets/icons/social/linkedin.svg create mode 100755 app/Views/_assets/icons/social/mastodon.svg create mode 100755 app/Views/_assets/icons/social/mobilizon.svg create mode 100755 app/Views/_assets/icons/social/peertube.svg create mode 100755 app/Views/_assets/icons/social/pixelfed.svg create mode 100755 app/Views/_assets/icons/social/plume.svg create mode 100755 app/Views/_assets/icons/social/reddit.svg create mode 100755 app/Views/_assets/icons/social/slack.svg create mode 100755 app/Views/_assets/icons/social/tiktok.svg create mode 100755 app/Views/_assets/icons/social/twitch.svg create mode 100755 app/Views/_assets/icons/social/twitter.svg create mode 100755 app/Views/_assets/icons/social/writefreely.svg create mode 100755 app/Views/_assets/icons/social/youtube.svg create mode 100755 app/Views/_assets/icons/star-smile.svg mode change 100644 => 100755 app/Views/_assets/icons/timer.svg create mode 100755 app/Views/_assets/icons/upload-cloud.svg mode change 100644 => 100755 app/Views/_assets/icons/user-add.svg mode change 100644 => 100755 app/Views/_assets/icons/user.svg create mode 100644 app/Views/_assets/images/castopod-cover-default.jpg rename app/Views/_assets/images/{logo-castopod.svg => castopod-logo.svg} (96%) delete mode 100644 app/Views/_assets/images/logo-castopod-circle.svg delete mode 100644 app/Views/_assets/images/platforms/_default.svg delete mode 100644 app/Views/_assets/images/platforms/funding/gofundme.svg delete mode 100644 app/Views/_assets/images/platforms/funding/helloasso.svg delete mode 100644 app/Views/_assets/images/platforms/funding/indiegogo.svg delete mode 100644 app/Views/_assets/images/platforms/funding/kickstarter.svg delete mode 100644 app/Views/_assets/images/platforms/funding/kisskissbankbank.svg delete mode 100644 app/Views/_assets/images/platforms/funding/liberapay.svg delete mode 100644 app/Views/_assets/images/platforms/funding/patreon.svg delete mode 100644 app/Views/_assets/images/platforms/funding/paypal.svg delete mode 100644 app/Views/_assets/images/platforms/funding/tipeee.svg delete mode 100644 app/Views/_assets/images/platforms/funding/ulule.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/amazon.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/antennapod.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/apple.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/blubrry.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/breaker.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/castbox.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/castopod.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/castro.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/chartable.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/deezer.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/fyyd.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/google.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/ivoox.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/listennotes.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/overcast.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/playerfm.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/pocketcasts.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podbean.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podcastaddict.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podcastindex.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podchaser.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podcloud.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podfriend.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podinstall.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podlink.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podtail.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/podverse.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/radiopublic.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/spotify.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/spreaker.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/stitcher.svg delete mode 100644 app/Views/_assets/images/platforms/podcasting/tunein.svg delete mode 100644 app/Views/_assets/images/platforms/social/castopod.svg delete mode 100644 app/Views/_assets/images/platforms/social/discord.svg delete mode 100644 app/Views/_assets/images/platforms/social/facebook.svg delete mode 100644 app/Views/_assets/images/platforms/social/funkwhale.svg delete mode 100644 app/Views/_assets/images/platforms/social/instagram.svg delete mode 100644 app/Views/_assets/images/platforms/social/linkedin.svg delete mode 100644 app/Views/_assets/images/platforms/social/mastodon.svg delete mode 100644 app/Views/_assets/images/platforms/social/mobilizon.svg delete mode 100644 app/Views/_assets/images/platforms/social/peertube.svg delete mode 100644 app/Views/_assets/images/platforms/social/pixelfed.svg delete mode 100644 app/Views/_assets/images/platforms/social/plume.svg delete mode 100644 app/Views/_assets/images/platforms/social/slack.svg delete mode 100644 app/Views/_assets/images/platforms/social/twitch.svg delete mode 100644 app/Views/_assets/images/platforms/social/twitter.svg delete mode 100644 app/Views/_assets/images/platforms/social/writefreely.svg delete mode 100644 app/Views/_assets/images/platforms/social/youtube.svg create mode 100644 app/Views/_assets/modules/Modal.ts create mode 100644 app/Views/_assets/modules/Toggler.ts create mode 100644 app/Views/_assets/styles/dropdown.css create mode 100644 app/Views/_assets/styles/fonts.css create mode 100644 app/Views/_assets/styles/note.css create mode 100644 app/Views/_assets/styles/radioToggler.css create mode 100644 app/Views/_assets/styles/tabs.css create mode 100644 app/Views/admin/episode/publish.php create mode 100644 app/Views/admin/episode/publish_edit.php create mode 100644 app/Views/admin/episode/unpublish.php create mode 100644 app/Views/admin/fediverse/blocked_actors.php create mode 100644 app/Views/admin/fediverse/blocked_domains.php delete mode 100644 app/Views/episode.php delete mode 100644 app/Views/podcast.php create mode 100644 app/Views/podcast/_layout.php create mode 100644 app/Views/podcast/_layout_authenticated.php create mode 100644 app/Views/podcast/_partials/episode_card.php create mode 100644 app/Views/podcast/_partials/header.php create mode 100644 app/Views/podcast/_partials/note.php create mode 100644 app/Views/podcast/_partials/note_actions.php create mode 100644 app/Views/podcast/_partials/note_actions_authenticated.php create mode 100644 app/Views/podcast/_partials/note_authenticated.php create mode 100644 app/Views/podcast/_partials/note_with_replies.php create mode 100644 app/Views/podcast/_partials/note_with_replies_authenticated.php create mode 100644 app/Views/podcast/_partials/preview_card.php create mode 100644 app/Views/podcast/_partials/reblog.php create mode 100644 app/Views/podcast/_partials/reblog_authenticated.php create mode 100644 app/Views/podcast/_partials/reply.php create mode 100644 app/Views/podcast/_partials/reply_actions.php create mode 100644 app/Views/podcast/_partials/reply_actions_authenticated.php create mode 100644 app/Views/podcast/_partials/reply_authenticated.php create mode 100644 app/Views/podcast/_partials/sidebar.php create mode 100644 app/Views/podcast/activity.php create mode 100644 app/Views/podcast/activity_authenticated.php create mode 100644 app/Views/podcast/episode.php create mode 100644 app/Views/podcast/episode_authenticated.php create mode 100644 app/Views/podcast/episodes.php create mode 100644 app/Views/podcast/episodes_authenticated.php create mode 100644 app/Views/podcast/follow.php create mode 100644 app/Views/podcast/note.php create mode 100644 app/Views/podcast/note_authenticated.php create mode 100644 app/Views/podcast/note_remote_action.php create mode 100644 castopod_namespace.json create mode 100644 crontab create mode 100644 public/media/persons/index.html create mode 100644 public/media/podcasts/index.html diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1fd510e7..aa74483c 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,8 @@ -FROM php:7.2-fpm +FROM php:7.3-fpm COPY --from=composer /usr/bin/composer /usr/bin/composer -RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - +RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - RUN apt-get update && \ apt-get install -y nodejs diff --git a/.gitignore b/.gitignore index 1baa8fe3..d66b6f23 100644 --- a/.gitignore +++ b/.gitignore @@ -138,7 +138,6 @@ node_modules # public folder public/* !public/media -!public/media/~person !public/.htaccess !public/favicon.ico !public/index.php @@ -147,10 +146,14 @@ public/* # public media folder public/media/* !public/media/index.html +!public/media/podcasts +!public/media/persons -# public person folder -public/media/~person/* -!public/media/~person/index.html +public/media/podcasts/* +!public/media/podcasts/index.html + +public/media/persons/* +!public/media/persons/index.html # Generated files app/Language/en/PersonsTaxonomy.php diff --git a/.prettierrc.json b/.prettierrc.json index 194ebab7..a766ac8d 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -4,7 +4,7 @@ { "files": "*.php", "options": { - "phpVersion": "7.2", + "phpVersion": "7.3", "singleQuote": true } }, diff --git a/.rsync-filter b/.rsync-filter index 606c8a91..f466ad22 100644 --- a/.rsync-filter +++ b/.rsync-filter @@ -7,7 +7,7 @@ + writable/*** + .env.example + DEPENDENCIES.md -+ LICENSE ++ LICENSE.md + README.md + INSTALL.md - ** diff --git a/.stylelintrc.json b/.stylelintrc.json index 97675e6d..2cd0132c 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -9,7 +9,8 @@ "apply", "responsive", "variants", - "screen" + "screen", + "layer" ] } ], diff --git a/.svgo.icons.js b/.svgo.icons.js new file mode 100644 index 00000000..05d15897 --- /dev/null +++ b/.svgo.icons.js @@ -0,0 +1,17 @@ +module.exports = { + plugins: [ + "removeXMLNS", + "removeDimensions", + "sortAttrs", + { + name: "addAttributesToSVGElement", + params: { + attributes: [ + { fill: "currentColor" }, + { width: "1em" }, + { height: "1em" }, + ], + }, + }, + ], +}; diff --git a/.svgo.icons.yml b/.svgo.icons.yml deleted file mode 100644 index 32c60fea..00000000 --- a/.svgo.icons.yml +++ /dev/null @@ -1,9 +0,0 @@ -plugins: - - removeXMLNS: true - - removeDimensions: true - - addAttributesToSVGElement: - attributes: - - fill: currentColor - - width: "1em" - - height: "1em" - - sortAttrs: true diff --git a/.svgo.js b/.svgo.js new file mode 100644 index 00000000..c6a27c95 --- /dev/null +++ b/.svgo.js @@ -0,0 +1,12 @@ +module.exports = { + plugins: [ + { + name: "removeViewBox", + active: false, + }, + "removeXMLNS", + "removeDimensions", + "sortAttrs", + "prefixIds", + ], +}; diff --git a/.svgo.yml b/.svgo.yml deleted file mode 100644 index b4177b4f..00000000 --- a/.svgo.yml +++ /dev/null @@ -1,5 +0,0 @@ -plugins: - - removeXMLNS: true - - removeDimensions: true - - sortAttrs: true - - prefixIds: true diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index f987fd27..325e823b 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -4,7 +4,7 @@ Castopod uses the following components: PHP Dependencies: -- [Code Igniter 4](https://codeigniter.com) +- [CodeIgniter 4](https://codeigniter.com) ([MIT License](https://codeigniter.com/user_guide/license.html)) - [WhichBrowser/Parser-PHP](https://github.com/WhichBrowser/Parser-PHP) ([MIT License](https://github.com/WhichBrowser/Parser-PHP/blob/master/LICENSE)) @@ -24,6 +24,14 @@ PHP Dependencies: ([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)) +- [podlibre/podcast-namespace](https://code.podlibre.org/podlibre/podcastnamespace) + ([MIT License](https://code.podlibre.org/podlibre/podcastnamespace/-/blob/master/LICENSE)) +- [phpseclib](https://phpseclib.com/) + ([MIT License](https://github.com/phpseclib/phpseclib/blob/master/LICENSE)) +- [codeigniter4-uuid](https://github.com/michalsn/codeigniter4-uuid) + ([MIT License](https://github.com/michalsn/codeigniter4-uuid/blob/develop/LICENSE)) +- [essence](https://github.com/essence/essence) + ([The FreeBSD License](https://github.com/essence/essence/blob/master/LICENSE.txt)) Javascript dependencies: @@ -39,9 +47,15 @@ Javascript dependencies: ([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE)) - [flatpickr](https://flatpickr.js.org/) ([MIT License](https://github.com/flatpickr/flatpickr/blob/master/LICENSE.md)) +- [popperjs](https://popper.js.org/) + ([MIT License](https://github.com/popperjs/popper-core/blob/master/LICENSE.md)) Other: +- [Kumbh Sans](https://fonts.google.com/specimen/Kumbh+Sans) + ([Open Font License](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL)) +- [Montserrat](https://fonts.google.com/specimen/Montserrat) + ([Open Font License](https://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=OFL)) - [RemixIcon](https://remixicon.com/) ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License)) - [OPAWG/User agent list](https://github.com/opawg/user-agents) diff --git a/Dockerfile b/Dockerfile index a2123509..01ab228e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:7.2-fpm +FROM php:7.3-fpm COPY . /castopod WORKDIR /castopod @@ -25,3 +25,9 @@ RUN echo "file_uploads = On\n" \ "post_max_size = 120M\n" \ "max_execution_time = 300\n" \ > /usr/local/etc/php/conf.d/uploads.ini + +# install cron +RUN apt-get update && \ + apt-get install -y cron + +RUN crontab /castopod/crontab diff --git a/INSTALL.md b/INSTALL.md index 71ad0e62..efc5f995 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,13 +1,16 @@ -# How to install Castopod +# How to install Castopod Castopod was thought to be easy to install. Whether using dedicated or shared hosting, you can install it on most PHP-MySQL compatible web servers. +## Table of contents + - [Install instructions](#install-instructions) - [(optional) Manual configuration](#optional-manual-configuration) - [Web Server Requirements](#web-server-requirements) - - [PHP v7.2 or higher](#php-v72-or-higher) + - [PHP v7.3 or higher](#php-v73-or-higher) - [MySQL compatible database](#mysql-compatible-database) + - [Privileges](#privileges) - [(Optional) Other recommendations](#optional-other-recommendations) - [Security concerns](#security-concerns) @@ -19,9 +22,16 @@ hosting, you can install it on most PHP-MySQL compatible web servers. 1. Download and unzip the Castopod package onto the web server if you haven’t already. - ⚠️ Set the web server document root to the `public/` sub-folder. -2. Run the Castopod install script by going to the install wizard page +2. ⚠️ For broadcasting social activities to the fediverse, add a cron task on + your web server to run every minute (replace the paths accordingly): + + ```php + * * * * * /path/to/php /path/to/castopod/public/index.php scheduled-activities + ``` + +3. Run the Castopod install script by going to the install wizard page (`https://your_domain_name.com/cp-install`) in your favorite web browser. -3. Follow the instructions on your screen. +4. Follow the instructions on your screen. All done, start podcasting! @@ -36,13 +46,12 @@ Before uploading Castopod files to your web server: ## Web Server Requirements -### PHP v7.2 or higher +### PHP v7.3 or higher -PHP version 7.2 or higher is required, with the following extensions installed: +PHP version 7.3 or higher is required, with the following extensions installed: - [intl](http://php.net/manual/en/intl.requirements.php) -- [libcurl](http://php.net/manual/en/curl.requirements.php) if you plan to use - the HTTP\CURLRequest library +- [libcurl](http://php.net/manual/en/curl.requirements.php) - [mbstring](http://php.net/manual/en/mbstring.installation.php) Additionally, make sure that the following extensions are enabled in your PHP: diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d0829d82..00000000 --- a/LICENSE +++ /dev/null @@ -1,661 +0,0 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - Castopod - Copyright (C) 2020 Podlibre - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..9b0be861 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,598 @@ +# GNU Affero General Public License + +_Version 3, 19 November 2007_ _Copyright © 2007 Free Software Foundation, Inc. +<>_ + +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for software +and other kinds of works, specifically designed to ensure cooperation with the +community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General Public +Licenses are intended to guarantee your freedom to share and change all versions +of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom to +distribute copies of free software (and charge for them if you wish), that you +receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can do +these things. + +Developers that use our General Public Licenses protect your rights with two +steps: **(1)** assert copyright on the software, and **(2)** offer you this +License which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made in +alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free software +are heartened and encouraged by the resulting cooperation. However, in the case +of software used on network servers, this result may fail to come about. The GNU +General Public License permits making a modified version and letting the public +access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, +in such cases, the modified source code becomes available to the community. It +requires the operator of a network server to provide the source code of the +modified version running there to the users of that server. Therefore, public +use of a modified version, on a publicly accessible server, gives the public +access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different license, +not a version of the Affero GPL, but Affero has released a new version of the +Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +## TERMS AND CONDITIONS + +### 0. Definitions + +“This License” refers to version 3 of the GNU Affero General Public License. + +“Copyright” also means copyright-like laws that apply to other kinds of works, +such as semiconductor masks. + +“The Program” refers to any copyrightable work licensed under this License. Each +licensee is addressed as “you”. “Licensees” and “recipients” may be individuals +or organizations. + +To “modify” a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact copy. +The resulting work is called a “modified version” of the earlier work or a work +“based on” the earlier work. + +A “covered work” means either the unmodified Program or a work based on the +Program. + +To “propagate” a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under applicable +copyright law, except executing it on a computer or modifying a private copy. +Propagation includes copying, distribution (with or without modification), +making available to the public, and in some countries other activities as well. + +To “convey” a work means any kind of propagation that enables other parties to +make or receive copies. Mere interaction with a user through a computer network, +with no transfer of a copy, is not conveying. + +An interactive user interface displays “Appropriate Legal Notices” to the extent +that it includes a convenient and prominently visible feature that **(1)** +displays an appropriate copyright notice, and **(2)** tells the user that there +is no warranty for the work (except to the extent that warranties are provided), +that licensees may convey the work under this License, and how to view a copy of +this License. If the interface presents a list of user commands or options, such +as a menu, a prominent item in the list meets this criterion. + +### 1. Source Code + +The “source code” for a work means the preferred form of the work for making +modifications to it. “Object code” means any non-source form of a work. + +A “Standard Interface” means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces specified +for a particular programming language, one that is widely used among developers +working in that language. + +The “System Libraries” of an executable work include anything, other than the +work as a whole, that **(a)** is included in the normal form of packaging a +Major Component, but which is not part of that Major Component, and **(b)** +serves only to enable use of the work with that Major Component, or to implement +a Standard Interface for which an implementation is available to the public in +source code form. A “Major Component”, in this context, means a major essential +component (kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to produce the +work, or an object code interpreter used to run it. + +The “Corresponding Source” for a work in object code form means all the source +code needed to generate, install, and (for an executable work) run the object +code and to modify the work, including scripts to control those activities. +However, it does not include the work's System Libraries, or general-purpose +tools or generally available free programs which are used unmodified in +performing those activities but which are not part of the work. For example, +Corresponding Source includes interface definition files associated with source +files for the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, such as by +intimate data communication or control flow between those subprograms and other +parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +### 2. Basic Permissions + +All rights granted under this License are granted for the term of copyright on +the Program, and are irrevocable provided the stated conditions are met. This +License explicitly affirms your unlimited permission to run the unmodified +Program. The output from running a covered work is covered by this License only +if the output, given its content, constitutes a covered work. This License +acknowledges your rights of fair use or other equivalent, as provided by +copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make modifications +exclusively for you, or provide you with facilities for running those works, +provided that you comply with the terms of this License in conveying all +material for which you do not control copyright. Those thus making or running +the covered works for you must do so exclusively on your behalf, under your +direction and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions +stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law + +No covered work shall be deemed part of an effective technological measure under +any applicable law fulfilling obligations under article 11 of the WIPO copyright +treaty adopted on 20 December 1996, or similar laws prohibiting or restricting +circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of the +work as a means of enforcing, against the work's users, your or third parties' +legal rights to forbid circumvention of technological measures. + +### 4. Conveying Verbatim Copies + +You may convey verbatim copies of the Program's source code as you receive it, +in any medium, provided that you conspicuously and appropriately publish on each +copy an appropriate copyright notice; keep intact all notices stating that this +License and any non-permissive terms added in accord with section 7 apply to the +code; keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may +offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions + +You may convey a work based on the Program, or the modifications to produce it +from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +- **a)** The work must carry prominent notices stating that you modified it, and + giving a relevant date. +- **b)** The work must carry prominent notices stating that it is released under + this License and any conditions added under section 7. This requirement + modifies the requirement in section 4 to “keep intact all notices”. +- **c)** You must license the entire work, as a whole, under this License to + anyone who comes into possession of a copy. This License will therefore apply, + along with any applicable section 7 additional terms, to the whole of the + work, and all its parts, regardless of how they are packaged. This License + gives no permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- **d)** If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive interfaces + that do not display Appropriate Legal Notices, your work need not make them do + so. + +A compilation of a covered work with other separate and independent works, which +are not by their nature extensions of the covered work, and which are not +combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an “aggregate” if the compilation and +its resulting copyright are not used to limit the access or legal rights of the +compilation's users beyond what the individual works permit. Inclusion of a +covered work in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms + +You may convey a covered work in object code form under the terms of sections 4 +and 5, provided that you also convey the machine-readable Corresponding Source +under the terms of this License, in one of these ways: + +- **a)** Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the Corresponding + Source fixed on a durable physical medium customarily used for software + interchange. +- **b)** Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a written offer, + valid for at least three years and valid for as long as you offer spare parts + or customer support for that product model, to give anyone who possesses the + object code either **(1)** a copy of the Corresponding Source for all the + software in the product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no more than + your reasonable cost of physically performing this conveying of source, or + **(2)** access to copy the Corresponding Source from a network server at no + charge. +- **c)** Convey individual copies of the object code with a copy of the written + offer to provide the Corresponding Source. This alternative is allowed only + occasionally and noncommercially, and only if you received the object code + with such an offer, in accord with subsection 6b. +- **d)** Convey the object code by offering access from a designated place + (gratis or for a charge), and offer equivalent access to the Corresponding + Source in the same way through the same place at no further charge. You need + not require recipients to copy the Corresponding Source along with the object + code. If the place to copy the object code is a network server, the + Corresponding Source may be on a different server (operated by you or a third + party) that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the Corresponding + Source, you remain obligated to ensure that it is available for as long as + needed to satisfy these requirements. +- **e)** Convey the object code using peer-to-peer transmission, provided you + inform other peers where the object code and Corresponding Source of the work + are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the +Corresponding Source as a System Library, need not be included in conveying the +object code work. + +A “User Product” is either **(1)** a “consumer product”, which means any +tangible personal property which is normally used for personal, family, or +household purposes, or **(2)** anything designed or sold for incorporation into +a dwelling. In determining whether a product is a consumer product, doubtful +cases shall be resolved in favor of coverage. For a particular product received +by a particular user, “normally used” refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected to use, +the product. A product is a consumer product regardless of whether the product +has substantial commercial, industrial or non-consumer uses, unless such uses +represent the only significant mode of use of the product. + +“Installation Information” for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified version +of its Corresponding Source. The information must suffice to ensure that the +continued functioning of the modified object code is in no case prevented or +interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of a +transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed under +this section must be accompanied by the Installation Information. But this +requirement does not apply if neither you nor any third party retains the +ability to install modified object code on the User Product (for example, the +work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for a +work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may be +denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for communication +across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord +with this section must be in a format that is publicly documented (and with an +implementation available to the public in source code form), and must require no +special password or key for unpacking, reading or copying. + +### 7. Additional Terms + +“Additional permissions” are terms that supplement the terms of this License by +making exceptions from one or more of its conditions. Additional permissions +that are applicable to the entire Program shall be treated as though they were +included in this License, to the extent that they are valid under applicable +law. If additional permissions apply only to part of the Program, that part may +be used separately under those permissions, but the entire Program remains +governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added by +you to a covered work, for which you have or can give appropriate copyright +permission. + +Notwithstanding any other provision of this License, for material you add to a +covered work, you may (if authorized by the copyright holders of that material) +supplement the terms of this License with terms: + +- **a)** Disclaiming warranty or limiting liability differently from the terms + of sections 15 and 16 of this License; or +- **b)** Requiring preservation of specified reasonable legal notices or author + attributions in that material or in the Appropriate Legal Notices displayed by + works containing it; or +- **c)** Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in reasonable ways + as different from the original version; or +- **d)** Limiting the use for publicity purposes of names of licensors or + authors of the material; or +- **e)** Declining to grant rights under trademark law for use of some trade + names, trademarks, or service marks; or +- **f)** Requiring indemnification of licensors and authors of that material by + anyone who conveys the material (or modified versions of it) with contractual + assumptions of liability to the recipient, for any liability that these + contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered “further restrictions” +within the meaning of section 10. If the Program as you received it, or any part +of it, contains a notice stating that it is governed by this License along with +a term that is a further restriction, you may remove that term. If a license +document contains a further restriction but permits relicensing or conveying +under this License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does not survive +such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, +in the relevant source files, a statement of the additional terms that apply to +those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a +separately written license, or stated as exceptions; the above requirements +apply either way. + +### 8. Termination + +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, and +will automatically terminate your rights under this License (including any +patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated **(a)** provisionally, unless and +until the copyright holder explicitly and finally terminates your license, and +**(b)** permanently, if the copyright holder fails to notify you of the +violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of violation +of this License (for any work) from that copyright holder, and you cure the +violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of +parties who have received copies or rights from you under this License. If your +rights have been terminated and not permanently reinstated, you do not qualify +to receive new licenses for the same material under section 10. + +### 9. Acceptance Not Required for Having Copies + +You are not required to accept this License in order to receive or run a copy of +the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise does +not require acceptance. However, nothing other than this License grants you +permission to propagate or modify any covered work. These actions infringe +copyright if you do not accept this License. Therefore, by modifying or +propagating a covered work, you indicate your acceptance of this License to do +so. + +### 10. Automatic Licensing of Downstream Recipients + +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work results +from an entity transaction, each party to that transaction who receives a copy +of the work also receives whatever licenses to the work the party's predecessor +in interest had or could give under the previous paragraph, plus a right to +possession of the Corresponding Source of the work from the predecessor in +interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under this +License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +### 11. Patents + +A “contributor” is a copyright holder who authorizes use under this License of +the Program or a work on which the Program is based. The work thus licensed is +called the contributor's “contributor version”. + +A contributor's “essential patent claims” are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter acquired, +that would be infringed by some manner, permitted by this License, of making, +using, or selling its contributor version, but do not include claims that would +be infringed only as a consequence of further modification of the contributor +version. For purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents of +its contributor version. + +In the following three paragraphs, a “patent license” is any express agreement +or commitment, however denominated, not to enforce a patent (such as an express +permission to practice a patent or covenant not to sue for patent infringement). +To “grant” such a patent license to a party means to make such an agreement or +commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available network +server or other readily accessible means, then you must either **(1)** cause the +Corresponding Source to be so available, or **(2)** arrange to deprive yourself +of the benefit of the patent license for this particular work, or **(3)** +arrange, in a manner consistent with the requirements of this License, to extend +the patent license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the covered +work in a country, or your recipient's use of the covered work in a country, +would infringe one or more identifiable patents in that country that you have +reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you +convey, or propagate by procuring conveyance of, a covered work, and grant a +patent license to some of the parties receiving the covered work authorizing +them to use, propagate, modify or convey a specific copy of the covered work, +then the patent license you grant is automatically extended to all recipients of +the covered work and works based on it. + +A patent license is “discriminatory” if it does not include within the scope of +its coverage, prohibits the exercise of, or is conditioned on the non-exercise +of one or more of the rights that are specifically granted under this License. +You may not convey a covered work if you are a party to an arrangement with a +third party that is in the business of distributing software, under which you +make payment to the third party based on the extent of your activity of +conveying the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory patent +license **(a)** in connection with copies of the covered work conveyed by you +(or copies made from those copies), or **(b)** primarily for and in connection +with specific products or compilations that contain the covered work, unless you +entered into that arrangement, or that patent license was granted, prior to 28 +March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied +license or other defenses to infringement that may otherwise be available to you +under applicable patent law. + +### 12. No Surrender of Others' Freedom + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work so +as to satisfy simultaneously your obligations under this License and any other +pertinent obligations, then as a consequence you may not convey it at all. For +example, if you agree to terms that obligate you to collect a royalty for +further conveying from those to whom you convey the Program, the only way you +could satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License + +Notwithstanding any other provision of this License, if you modify the Program, +your modified version must prominently offer all users interacting with it +remotely through a computer network (if your version supports such interaction) +an opportunity to receive the Corresponding Source of your version by providing +access to the Corresponding Source from a network server at no charge, through +some standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any work covered +by version 3 of the GNU General Public License that is incorporated pursuant to +the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link +or combine any covered work with a work licensed under version 3 of the GNU +General Public License into a single combined work, and to convey the resulting +work. The terms of this License will continue to apply to the part which is the +covered work, but the work with which it is combined will remain governed by +version 3 of the GNU General Public License. + +### 14. Revised Versions of this License + +The Free Software Foundation may publish revised and/or new versions of the GNU +Affero General Public License from time to time. Such new versions will be +similar in spirit to the present version, but may differ in detail to address +new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies +that a certain numbered version of the GNU Affero General Public License “or any +later version” applies to it, you have the option of following the terms and +conditions either of that numbered version or of any later version published by +the Free Software Foundation. If the Program does not specify a version number +of the GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement of +acceptance of a version permanently authorizes you to choose that version for +the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright holder +as a result of your choosing to follow a later version. + +### 15. Disclaimer of Warranty + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER +PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER +EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE +QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +### 16. Limitation of Liability + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY +COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS +PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, +INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE +THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED +INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE +PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY +HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16 + +If the disclaimer of warranty and limitation of liability provided above cannot +be given local legal effect according to their terms, reviewing courts shall +apply local law that most closely approximates an absolute waiver of all civil +liability in connection with the Program, unless a warranty or assumption of +liability accompanies a copy of the Program in return for a fee. + +_END OF TERMS AND CONDITIONS_ + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use +to the public, the best way to achieve this is to make it free software which +everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion of +warranty; and each file should have at least the “copyright” line and a pointer +to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could display a +“Source” link that leads users to an archive of the code. There are many ways +you could offer source, and different solutions will be better for different +programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a “copyright disclaimer” for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +<>. diff --git a/README.md b/README.md index 1ca335e1..4449f422 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@ Castopod is an open-source podcast hosting solution for everyone.\ Whether you are a beginner, an amateur or a professional, you will get everything you need:\ -Create, upload, publish, and get comprehensive audience measurement that respects your -listeners privacy. +Create, upload, publish, and get comprehensive audience measurement that +respects your listeners privacy. Castopod is a free and open-source solution (AGPL v3).\ -Whether you choose to install it on your own server or have it hosted by a -professional, all your data and analytics belong to you and you only. +Whether you choose to install it on your own server or have it hosted by a professional, +all your data and analytics belong to you and you only. ![Castopod Logo](https://podlibre.org/static/images/Castopod-Mascot-Server.svg) @@ -18,7 +18,9 @@ Castopod can be hosted on any PHP/MySQL server:\ Unzip it and you are ready to broadcast. To install Castopod on your server: -- Download [Castopod latest Package (zip or tar.gz)](https://code.podlibre.org/podlibre/castopod/-/releases), + +- Download + [Castopod latest Package (zip or tar.gz)](https://code.podlibre.org/podlibre/castopod/-/releases), - Follow the procedure “[How to install Castopod](./INSTALL.md)”. ## Documentation diff --git a/app/Config/ActivityPub.php b/app/Config/ActivityPub.php new file mode 100644 index 00000000..7f2ea61c --- /dev/null +++ b/app/Config/ActivityPub.php @@ -0,0 +1,14 @@ + SYSTEMPATH, + * 'App' => APPPATH + * ]; + * + * @var array + */ public $psr4 = [ - 'App' => APPPATH, + APP_NAMESPACE => APPPATH, // For custom app namespace + 'Config' => APPPATH . 'Config', + 'ActivityPub' => APPPATH . 'Libraries/ActivityPub', ]; - public $classmap = []; - - //-------------------------------------------------------------------- - /** - * Collects the application-specific autoload settings and merges - * them with the framework's required settings. + * ------------------------------------------------------------------- + * Class Map + * ------------------------------------------------------------------- + * The class map provides a map of class names and their exact + * location on the drive. Classes loaded in this manner will have + * slightly faster performance because they will not have to be + * searched for within one or more directories as they would if they + * were being autoloaded through a namespace. * - * NOTE: If you use an identical key in $psr4 or $classmap, then - * the values in this file will overwrite the framework's values. + * Prototype: + * + * $classmap = [ + * 'MyClass' => '/path/to/class/file.php' + * ]; + * + * @var array */ - public function __construct() - { - parent::__construct(); - - /** - * ------------------------------------------------------------------- - * Namespaces - * ------------------------------------------------------------------- - * This maps the locations of any namespaces in your application - * to their location on the file system. These are used by the - * Autoloader to locate files the first time they have been instantiated. - * - * The '/app' and '/system' directories are already mapped for - * you. You may change the name of the 'App' namespace if you wish, - * but this should be done prior to creating any namespaced classes, - * else you will need to modify all of those classes for this to work. - * - * DO NOT change the name of the CodeIgniter namespace or your application - * WILL break. * - * Prototype: - * - * $Config['psr4'] = [ - * 'CodeIgniter' => SYSPATH - * `]; - */ - $psr4 = [ - 'App' => APPPATH, // To ensure filters, etc still found, - APP_NAMESPACE => APPPATH, // For custom namespace - 'Config' => APPPATH . 'Config', - ]; - - /** - * ------------------------------------------------------------------- - * Class Map - * ------------------------------------------------------------------- - * The class map provides a map of class names and their exact - * location on the drive. Classes loaded in this manner will have - * slightly faster performance because they will not have to be - * searched for within one or more directories as they would if they - * were being autoloaded through a namespace. - * - * Prototype: - * - * $Config['classmap'] = [ - * 'MyClass' => '/path/to/class/file.php' - * ]; - */ - $classmap = []; - - //-------------------------------------------------------------------- - // Do Not Edit Below This Line - //-------------------------------------------------------------------- - - $this->psr4 = array_merge($this->psr4, $psr4); - $this->classmap = array_merge($this->classmap, $classmap); - - unset($psr4, $classmap); - } - - //-------------------------------------------------------------------- + public $classmap = []; } diff --git a/app/Config/Boot/development.php b/app/Config/Boot/development.php index 59c9732a..036960ed 100644 --- a/app/Config/Boot/development.php +++ b/app/Config/Boot/development.php @@ -1,33 +1,32 @@ + */ + public $file = [ + 'storePath' => WRITEPATH . 'cache/', + 'mode' => 0640, + ]; + + /** + * ------------------------------------------------------------------------- + * Memcached settings + * ------------------------------------------------------------------------- + * Your Memcached servers can be specified below, if you are using + * the Memcached drivers. + * + * @see https://codeigniter.com/user_guide/libraries/caching.html#memcached + * + * @var array + */ public $memcached = [ 'host' => '127.0.0.1', 'port' => 11211, @@ -86,14 +114,15 @@ class Cache extends BaseConfig 'raw' => false, ]; - /* - | ------------------------------------------------------------------------- - | Redis settings - | ------------------------------------------------------------------------- - | Your Redis server can be specified below, if you are using - | the Redis or Predis drivers. - | - */ + /** + * ------------------------------------------------------------------------- + * Redis settings + * ------------------------------------------------------------------------- + * Your Redis server can be specified below, if you are using + * the Redis or Predis drivers. + * + * @var array + */ public $redis = [ 'host' => '127.0.0.1', 'password' => null, @@ -102,21 +131,22 @@ class Cache extends BaseConfig 'database' => 0, ]; - /* - |-------------------------------------------------------------------------- - | Available Cache Handlers - |-------------------------------------------------------------------------- - | - | This is an array of cache engine alias' and class names. Only engines - | that are listed here are allowed to be used. - | - */ + /** + * -------------------------------------------------------------------------- + * Available Cache Handlers + * -------------------------------------------------------------------------- + * + * This is an array of cache engine alias' and class names. Only engines + * that are listed here are allowed to be used. + * + * @var array + */ public $validHandlers = [ - 'dummy' => \CodeIgniter\Cache\Handlers\DummyHandler::class, - 'file' => \CodeIgniter\Cache\Handlers\FileHandler::class, - 'memcached' => \CodeIgniter\Cache\Handlers\MemcachedHandler::class, - 'predis' => \CodeIgniter\Cache\Handlers\PredisHandler::class, - 'redis' => \CodeIgniter\Cache\Handlers\RedisHandler::class, - 'wincache' => \CodeIgniter\Cache\Handlers\WincacheHandler::class, + 'dummy' => DummyHandler::class, + 'file' => FileHandler::class, + 'memcached' => MemcachedHandler::class, + 'predis' => PredisHandler::class, + 'redis' => RedisHandler::class, + 'wincache' => WincacheHandler::class, ]; } diff --git a/app/Config/Constants.php b/app/Config/Constants.php index 75f00692..80c4d755 100644 --- a/app/Config/Constants.php +++ b/app/Config/Constants.php @@ -1,46 +1,50 @@ ` element. + * + * Will default to self if not overridden + * + * @var string|string[]|null + */ + public $baseURI = null; + + /** + * Lists the URLs for workers and embedded frame contents + * + * @var string|string[] + */ public $childSrc = 'self'; + + /** + * Limits the origins that you can connect to (via XHR, + * WebSockets, and EventSource). + * + * @var string|string[] + */ public $connectSrc = 'self'; + + /** + * Specifies the origins that can serve web fonts. + * + * @var string|string[] + */ public $fontSrc = null; + + /** + * Lists valid endpoints for submission from `
` tags. + * + * @var string|string[] + */ public $formAction = 'self'; + + /** + * Specifies the sources that can embed the current page. + * This directive applies to ``, `', + 'width' => 600, + 'height' => 200, + 'thumbnail_url' => $this->episode->image->large_url, + 'thumbnail_width' => config('Images')->largeSize, + 'thumbnail_height' => config('Images')->largeSize, + ]); + } + + public function oembedXML() + { + $oembed = new SimpleXMLElement( + "", + ); + + $oembed->addChild('type', 'rich'); + $oembed->addChild('version', '1.0'); + $oembed->addChild('title', $this->episode->title); + $oembed->addChild('provider_name', $this->podcast->title); + $oembed->addChild('provider_url', $this->podcast->link); + $oembed->addChild('author_name', $this->podcast->title); + $oembed->addChild('author_url', $this->podcast->link); + $oembed->addChild('thumbnail', $this->episode->image->large_url); + $oembed->addChild('thumbnail_width', config('Images')->largeSize); + $oembed->addChild('thumbnail_height', config('Images')->largeSize); + $oembed->addChild( + 'html', + htmlentities( + '', + ), + ); + $oembed->addChild('width', 600); + $oembed->addChild('height', 200); + + return $this->response->setXML($oembed); + } } diff --git a/app/Controllers/Home.php b/app/Controllers/Home.php index 7f8aad52..ef8b0bb0 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/Home.php @@ -20,7 +20,9 @@ class Home extends BaseController // check if there's only one podcast to redirect user to it if (count($allPodcasts) == 1) { - return redirect()->route('podcast', [$allPodcasts[0]->name]); + return redirect()->route('podcast-activity', [ + $allPodcasts[0]->name, + ]); } // default behavior: list all podcasts on home page diff --git a/app/Controllers/Install.php b/app/Controllers/Install.php index 1d53f724..d612b919 100644 --- a/app/Controllers/Install.php +++ b/app/Controllers/Install.php @@ -257,6 +257,7 @@ class Install extends Controller $migrations = \Config\Services::migrations(); !$migrations->setNamespace('Myth\Auth')->latest(); + !$migrations->setNamespace('ActivityPub')->latest(); !$migrations->setNamespace(APP_NAMESPACE)->latest(); } diff --git a/app/Controllers/Note.php b/app/Controllers/Note.php new file mode 100644 index 00000000..c685a690 --- /dev/null +++ b/app/Controllers/Note.php @@ -0,0 +1,212 @@ +podcast = (new PodcastModel())->getPodcastByName( + $params[0], + )) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + + $this->actor = $this->podcast->actor; + + if (count($params) > 1) { + if (!($this->note = model('NoteModel')->getNoteById($params[1]))) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + unset($params[0]); + unset($params[1]); + + return $this->$method(...$params); + } + + public function index() + { + helper('persons'); + $persons = []; + construct_person_array($this->podcast->persons, $persons); + + $data = [ + 'podcast' => $this->podcast, + 'actor' => $this->actor, + 'note' => $this->note, + 'persons' => $persons, + ]; + + // if user is logged in then send to the authenticated activity view + if (can_user_interact()) { + helper('form'); + return view('podcast/note_authenticated', $data); + } else { + return view('podcast/note', $data); + } + } + + public function attemptCreate() + { + $rules = [ + 'message' => 'required|max_length[500]', + 'episode_url' => 'valid_url|permit_empty', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $message = $this->request->getPost('message'); + + $newNote = new \App\Entities\Note([ + 'actor_id' => interact_as_actor_id(), + 'published_at' => Time::now(), + 'created_by' => user_id(), + ]); + + // get episode if episodeUrl has been set + $episodeUri = $this->request->getPost('episode_url'); + if ( + $episodeUri && + ($params = extract_params_from_episode_uri(new URI($episodeUri))) + ) { + if ( + $episode = (new EpisodeModel())->getEpisodeBySlug( + $params['podcastName'], + $params['episodeSlug'], + ) + ) { + $newNote->episode_id = $episode->id; + } + } + + $newNote->message = $message; + + if ( + !model('NoteModel')->addNote( + $newNote, + $newNote->episode_id ? false : true, + true, + ) + ) { + return redirect() + ->back() + ->withInput() + ->with('errors', model('NoteModel')->errors()); + } + + // Note has been successfully created + return redirect()->back(); + } + + public function attemptReply() + { + $rules = [ + 'message' => 'required|max_length[500]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $newNote = new \ActivityPub\Entities\Note([ + 'actor_id' => interact_as_actor_id(), + 'in_reply_to_id' => $this->note->id, + 'message' => $this->request->getPost('message'), + 'published_at' => Time::now(), + 'created_by' => user_id(), + ]); + + if (!model('NoteModel')->addReply($newNote)) { + return redirect() + ->back() + ->withInput() + ->with('errors', model('NoteModel')->errors()); + } + + // Reply note without preview card has been successfully created + return redirect()->back(); + } + + public function attemptFavourite() + { + model('FavouriteModel')->toggleFavourite( + interact_as_actor(), + $this->note, + ); + + return redirect()->back(); + } + + public function attemptReblog() + { + model('NoteModel')->toggleReblog(interact_as_actor(), $this->note); + + return redirect()->back(); + } + + public function attemptAction() + { + $rules = [ + 'action' => 'required|in_list[favourite,reblog,reply]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + switch ($this->request->getPost('action')) { + case 'favourite': + return $this->attemptFavourite(); + case 'reblog': + return $this->attemptReblog(); + case 'reply': + return $this->attemptReply(); + } + } + + public function remoteAction($action) + { + $data = [ + 'podcast' => $this->podcast, + 'actor' => $this->actor, + 'note' => $this->note, + 'action' => $action, + ]; + + helper('form'); + + return view('podcast/note_remote_action', $data); + } +} diff --git a/app/Controllers/Page.php b/app/Controllers/Page.php index 74735dee..46d28d22 100644 --- a/app/Controllers/Page.php +++ b/app/Controllers/Page.php @@ -85,14 +85,23 @@ class Page extends BaseController 'role_label' => $credit->role_label, 'is_in' => [ [ - 'link' => $credit->episode + 'link' => $credit->episode_id ? $credit->episode->link : $credit->podcast->link, - 'title' => $credit->episode + 'title' => $credit->episode_id ? (count($allPodcasts) > 1 ? "{$credit->podcast->title} ▸ " : '') . - "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + $credit->episode + ->title . + episode_numbering( + $credit->episode + ->number, + $credit->episode + ->season_number, + 'text-xs ml-2', + true, + ) : $credit->podcast->title, ], ], @@ -114,14 +123,21 @@ class Page extends BaseController 'role_label' => $credit->role_label, 'is_in' => [ [ - 'link' => $credit->episode + 'link' => $credit->episode_id ? $credit->episode->link : $credit->podcast->link, - 'title' => $credit->episode + 'title' => $credit->episode_id ? (count($allPodcasts) > 1 ? "{$credit->podcast->title} ▸ " : '') . - "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + $credit->episode->title . + episode_numbering( + $credit->episode->number, + $credit->episode + ->season_number, + 'text-xs ml-2', + true, + ) : $credit->podcast->title, ], ], @@ -143,7 +159,13 @@ class Page extends BaseController ? (count($allPodcasts) > 1 ? "{$credit->podcast->title} ▸ " : '') . - "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + $credit->episode->title . + episode_numbering( + $credit->episode->number, + $credit->episode->season_number, + 'text-xs ml-2', + true, + ) : $credit->podcast->title, ], ], @@ -159,7 +181,13 @@ class Page extends BaseController ? (count($allPodcasts) > 1 ? "{$credit->podcast->title} ▸ " : '') . - "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + $credit->episode->title . + episode_numbering( + $credit->episode->number, + $credit->episode->season_number, + 'text-xs ml-2', + true, + ) : $credit->podcast->title, ]; } diff --git a/app/Controllers/Podcast.php b/app/Controllers/Podcast.php index d7a80a6e..56a23e36 100644 --- a/app/Controllers/Podcast.php +++ b/app/Controllers/Podcast.php @@ -10,6 +10,7 @@ namespace App\Controllers; use App\Models\EpisodeModel; use App\Models\PodcastModel; +use App\Models\NoteModel; class Podcast extends BaseController { @@ -23,17 +24,41 @@ class Podcast extends BaseController if (count($params) > 0) { if ( !($this->podcast = (new PodcastModel())->getPodcastByName( - $params[0] + $params[0], )) ) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); } + unset($params[0]); } - return $this->$method(); + return $this->$method(...$params); } - public function index() + public function activity() + { + helper('persons'); + $persons = []; + construct_person_array($this->podcast->persons, $persons); + + $data = [ + 'podcast' => $this->podcast, + 'notes' => (new NoteModel())->getActorNotes( + $this->podcast->actor_id, + ), + 'persons' => $persons, + ]; + + // if user is logged in then send to the authenticated activity view + if (can_user_interact()) { + helper('form'); + return view('podcast/activity_authenticated', $data); + } else { + return view('podcast/activity', $data); + } + } + + public function episodes() { self::triggerWebpageHit($this->podcast->id); @@ -42,7 +67,7 @@ class Podcast extends BaseController if (!$yearQuery and !$seasonQuery) { $defaultQuery = (new EpisodeModel())->getDefaultQuery( - $this->podcast->id + $this->podcast->id, ); if ($defaultQuery['type'] == 'season') { $seasonQuery = $defaultQuery['data']['season_number']; @@ -59,7 +84,7 @@ class Podcast extends BaseController $yearQuery, $seasonQuery ? 'season' . $seasonQuery : null, service('request')->getLocale(), - ]) + ]), ); if (!($found = cache($cacheName))) { @@ -73,14 +98,19 @@ class Podcast extends BaseController foreach ($years as $year) { $isActive = $yearQuery == $year['year']; if ($isActive) { - $activeQuery = ['type' => 'year', 'value' => $year['year']]; + $activeQuery = [ + 'type' => 'year', + 'value' => $year['year'], + 'label' => $year['year'], + 'number_of_episodes' => $year['number_of_episodes'], + ]; } array_push($episodesNavigation, [ 'label' => $year['year'], 'number_of_episodes' => $year['number_of_episodes'], 'route' => - route_to('podcast', $this->podcast->name) . + route_to('podcast-episodes', $this->podcast->name) . '?year=' . $year['year'], 'is_active' => $isActive, @@ -93,6 +123,10 @@ class Podcast extends BaseController $activeQuery = [ 'type' => 'season', 'value' => $season['season_number'], + 'label' => lang('Podcast.season', [ + 'seasonNumber' => $season['season_number'], + ]), + 'number_of_episodes' => $season['number_of_episodes'], ]; } @@ -102,19 +136,16 @@ class Podcast extends BaseController ]), 'number_of_episodes' => $season['number_of_episodes'], 'route' => - route_to('podcast', $this->podcast->name) . + route_to('podcast-episodes', $this->podcast->name) . '?season=' . $season['season_number'], 'is_active' => $isActive, ]); } - helper(['persons']); + helper('persons'); $persons = []; - constructs_podcast_person_array( - $this->podcast->podcast_persons, - $persons - ); + construct_person_array($this->podcast->persons, $persons); $data = [ 'podcast' => $this->podcast, @@ -124,21 +155,31 @@ class Podcast extends BaseController $this->podcast->id, $this->podcast->type, $yearQuery, - $seasonQuery + $seasonQuery, ), - 'personArray' => $persons, + 'persons' => $persons, ]; $secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode( - $this->podcast->id + $this->podcast->id, ); - return view('podcast', $data, [ - 'cache' => $secondsToNextUnpublishedEpisode - ? $secondsToNextUnpublishedEpisode - : DECADE, - 'cache_name' => $cacheName, - ]); + // if user is logged in then send to the authenticated episodes view + if (can_user_interact()) { + return view('podcast/episodes_authenticated', $data, [ + 'cache' => $secondsToNextUnpublishedEpisode + ? $secondsToNextUnpublishedEpisode + : DECADE, + 'cache_name' => $cacheName . '_authenticated', + ]); + } else { + return view('podcast/episodes', $data, [ + 'cache' => $secondsToNextUnpublishedEpisode + ? $secondsToNextUnpublishedEpisode + : DECADE, + 'cache_name' => $cacheName, + ]); + } } return $found; diff --git a/app/Database/Migrations/2020-05-29-152000_add_categories.php b/app/Database/Migrations/2020-05-29-152000_add_categories.php index 2fa255a3..900f5b24 100644 --- a/app/Database/Migrations/2020-05-29-152000_add_categories.php +++ b/app/Database/Migrations/2020-05-29-152000_add_categories.php @@ -39,7 +39,7 @@ class AddCategories extends Migration 'constraint' => 32, ], ]); - $this->forge->addKey('id', true); + $this->forge->addPrimaryKey('id'); $this->forge->addUniqueKey('code'); $this->forge->addForeignKey('parent_id', 'categories', 'id'); $this->forge->createTable('categories'); diff --git a/app/Database/Migrations/2020-05-30-101000_add_languages.php b/app/Database/Migrations/2020-05-30-101000_add_languages.php index cc58df49..65689906 100644 --- a/app/Database/Migrations/2020-05-30-101000_add_languages.php +++ b/app/Database/Migrations/2020-05-30-101000_add_languages.php @@ -28,7 +28,7 @@ class AddLanguages extends Migration 'constraint' => 128, ], ]); - $this->forge->addKey('code', true); + $this->forge->addPrimaryKey('code'); $this->forge->createTable('languages'); } diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php index 2351f51a..0eddb82f 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -23,14 +23,17 @@ class AddPodcasts extends Migration 'unsigned' => true, 'auto_increment' => true, ], - 'title' => [ - 'type' => 'VARCHAR', - 'constraint' => 128, + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, ], 'name' => [ 'type' => 'VARCHAR', 'constraint' => 32, - 'unique' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, ], 'description_markdown' => [ 'type' => 'TEXT', @@ -42,6 +45,12 @@ class AddPodcasts extends Migration 'type' => 'VARCHAR', 'constraint' => 255, ], + // constraint is 13 because the longest safe mimetype for images is image/svg+xml, + // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types + 'image_mimetype' => [ + 'type' => 'VARCHAR', + 'constraint' => 13, + ], 'language_code' => [ 'type' => 'VARCHAR', 'constraint' => 2, @@ -140,6 +149,7 @@ class AddPodcasts extends Migration ], 'custom_rss' => [ 'type' => 'JSON', + 'null' => true, ], 'partner_id' => [ 'type' => 'VARCHAR', @@ -176,7 +186,15 @@ class AddPodcasts extends Migration ], ]); - $this->forge->addKey('id', true); + $this->forge->addPrimaryKey('id'); + $this->forge->addUniqueKey('name'); + $this->forge->addForeignKey( + 'actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); $this->forge->addForeignKey('category_id', 'categories', 'id'); $this->forge->addForeignKey('language_code', 'languages', 'code'); $this->forge->addForeignKey('created_by', 'users', 'id'); diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index 72f7f199..775e64c1 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -73,6 +73,13 @@ class AddEpisodes extends Migration 'constraint' => 255, 'null' => true, ], + // constraint is 13 because the longest safe mimetype for images is image/svg+xml, + // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types + 'image_mimetype' => [ + 'type' => 'VARCHAR', + 'constraint' => 13, + 'null' => true, + ], 'transcript_uri' => [ 'type' => 'VARCHAR', 'constraint' => 255, @@ -128,6 +135,21 @@ class AddEpisodes extends Migration 'type' => 'JSON', 'null' => true, ], + 'favourites_total' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'reblogs_total' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'notes_total' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], 'created_by' => [ 'type' => 'INT', 'unsigned' => true, @@ -151,9 +173,15 @@ class AddEpisodes extends Migration 'null' => true, ], ]); - $this->forge->addKey('id', true); + $this->forge->addPrimaryKey('id'); $this->forge->addUniqueKey(['podcast_id', 'slug']); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); + $this->forge->addForeignKey( + 'podcast_id', + 'podcasts', + 'id', + false, + 'CASCADE', + ); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); $this->forge->createTable('episodes'); diff --git a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php b/app/Database/Migrations/2020-06-05-180000_add_soundbites.php index 57af8f31..1f1aec53 100644 --- a/app/Database/Migrations/2020-06-05-180000_add_soundbites.php +++ b/app/Database/Migrations/2020-06-05-180000_add_soundbites.php @@ -63,8 +63,20 @@ class AddSoundbites extends Migration ]); $this->forge->addKey('id', true); $this->forge->addUniqueKey(['episode_id', 'start_time', 'duration']); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); - $this->forge->addForeignKey('episode_id', 'episodes', 'id'); + $this->forge->addForeignKey( + 'podcast_id', + 'podcasts', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'episode_id', + 'episodes', + 'id', + false, + 'CASCADE', + ); $this->forge->addForeignKey('created_by', 'users', 'id'); $this->forge->addForeignKey('updated_by', 'users', 'id'); $this->forge->createTable('soundbites'); diff --git a/app/Database/Migrations/2020-06-05-190000_add_platforms.php b/app/Database/Migrations/2020-06-05-190000_add_platforms.php index b79e7939..fb82e824 100644 --- a/app/Database/Migrations/2020-06-05-190000_add_platforms.php +++ b/app/Database/Migrations/2020-06-05-190000_add_platforms.php @@ -45,7 +45,7 @@ class AddPlatforms extends Migration $this->forge->addField( '`updated_at` timestamp NOT NULL DEFAULT NOW() ON UPDATE NOW()' ); - $this->forge->addKey('slug', true); + $this->forge->addPrimaryKey('slug'); $this->forge->createTable('platforms'); } diff --git a/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php b/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php index 96b1419f..35821439 100644 --- a/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php +++ b/app/Database/Migrations/2020-06-08-120000_add_analytics_podcasts.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsPodcasts * Creates analytics_podcasts table in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -45,12 +46,11 @@ class AddAnalyticsPodcasts extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'date']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', ); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->createTable('analytics_podcasts'); } diff --git a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php b/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php index 546e9181..39dba610 100644 --- a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php +++ b/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_episode.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsPodcastsByEpisode * Creates analytics_episodes_by_episode table in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -41,13 +42,11 @@ class AddAnalyticsPodcastsByEpisode extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'date', 'episode_id']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`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_podcasts_by_episode'); } diff --git a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php b/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php index 828e3066..ae2d85e3 100644 --- a/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php +++ b/app/Database/Migrations/2020-06-08-130000_add_analytics_podcasts_by_hour.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsPodcastsByHour * Creates analytics_podcasts_by_hour table in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -36,12 +37,11 @@ class AddAnalyticsPodcastsByHour extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'date', 'hour']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', ); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->createTable('analytics_podcasts_by_hour'); } diff --git a/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php b/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php index a05d7352..a1ab3174 100644 --- a/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php +++ b/app/Database/Migrations/2020-06-08-140000_add_analytics_podcasts_by_player.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsPodcastsByPlayer * Creates analytics_podcasts_by_player table in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -61,12 +62,11 @@ class AddAnalyticsPodcastsByPlayer extends Migration 'is_bot', ]); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', ); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->createTable('analytics_podcasts_by_player'); } diff --git a/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php b/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php index 4fae6c03..ce728e96 100644 --- a/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php +++ b/app/Database/Migrations/2020-06-08-150000_add_analytics_podcasts_by_country.php @@ -3,6 +3,7 @@ /** * 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/ @@ -37,12 +38,11 @@ class AddAnalyticsPodcastsByCountry extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'date', 'country_code']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', ); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->createTable('analytics_podcasts_by_country'); } diff --git a/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php b/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php index 3b7d4816..009894fd 100644 --- a/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php +++ b/app/Database/Migrations/2020-06-08-160000_add_analytics_podcasts_by_region.php @@ -3,6 +3,7 @@ /** * 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/ @@ -55,12 +56,11 @@ class AddAnalyticsPodcastsByRegion extends Migration 'region_code', ]); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()', ); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); $this->forge->createTable('analytics_podcasts_by_region'); } diff --git a/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php b/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php index 4b2a9f9e..68df0a81 100644 --- a/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php +++ b/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php @@ -48,8 +48,6 @@ class AddPodcastsPlatforms extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'platform_slug']); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); - $this->forge->addForeignKey('platform_slug', 'platforms', 'slug'); $this->forge->createTable('podcasts_platforms'); } diff --git a/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php b/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php index cc2e7467..891a76e7 100644 --- a/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php +++ b/app/Database/Migrations/2020-06-08-170000_add_analytics_website_by_browser.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsWebsiteByBrowser * Creates analytics_website_by_browser table in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -37,12 +38,11 @@ class AddAnalyticsWebsiteByBrowser extends Migration $this->forge->addPrimaryKey(['podcast_id', 'date', 'browser']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`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_browser'); } diff --git a/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php b/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php index ca761a56..0fa2fa70 100644 --- a/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php +++ b/app/Database/Migrations/2020-06-08-180000_add_analytics_website_by_referer.php @@ -3,6 +3,7 @@ /** * 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/ @@ -46,12 +47,11 @@ class AddAnalyticsWebsiteByReferer extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'date', 'referer_url']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`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_referer'); } diff --git a/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php b/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php index 2203d17f..366b75af 100644 --- a/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php +++ b/app/Database/Migrations/2020-06-08-190000_add_analytics_website_by_entry_page.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsWebsiteByEntryPage * Creates 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/ @@ -36,12 +37,11 @@ class AddAnalyticsWebsiteByEntryPage extends Migration ]); $this->forge->addPrimaryKey(['podcast_id', 'date', 'entry_page_url']); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', ); $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`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'); } diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php index 1f57fd7c..fcce6f49 100644 --- a/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php +++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsUnknownUseragents * Creates analytics_unknown_useragents table in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -33,7 +34,8 @@ class AddAnalyticsUnknownUseragents extends Migration 'default' => 1, ], ]); - $this->forge->addKey('id', true); + + $this->forge->addPrimaryKey('id'); // `created_at` and `updated_at` are created with SQL because Model class won’t be used for insertion (Procedure will be used instead) $this->forge->addField( '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php b/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php index d77a5d19..f1792a6c 100644 --- a/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php +++ b/app/Database/Migrations/2020-06-11-210000_add_analytics_podcasts_procedure.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsPodcastsProcedure * Creates analytics_podcasts procedure in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -21,58 +22,58 @@ class AddAnalyticsPodcastsProcedure extends Migration $prefix = $this->db->getPrefix(); $createQuery = <<db->query($createQuery); } @@ -80,7 +81,7 @@ EOD; { $prefix = $this->db->getPrefix(); $this->db->query( - "DROP PROCEDURE IF EXISTS `{$prefix}analytics_podcasts`" + "DROP PROCEDURE IF EXISTS `{$prefix}analytics_podcasts`", ); } } diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php b/app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php index 6b05164d..39e8d3aa 100644 --- a/app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php +++ b/app/Database/Migrations/2020-06-11-210000_add_analytics_unknown_useragents_procedure.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsUnknownUseragentsProcedure * Creates analytics_unknown_useragents procedure in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -20,14 +21,14 @@ class AddAnalyticsUnknownUseragentsProcedure extends Migration // Example: CALL analytics_unknown_useragents('Podcasts/1430.46 CFNetwork/1125.2 Darwin/19.4.0'); $procedureName = $this->db->prefixTable('analytics_unknown_useragents'); $createQuery = <<db->query($createQuery); } diff --git a/app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php b/app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php index 8d46ad7f..01e89391 100644 --- a/app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php +++ b/app/Database/Migrations/2020-06-11-210000_add_analytics_website_procedure.php @@ -3,6 +3,7 @@ /** * Class AddAnalyticsWebsiteProcedure * Creates analytics_website stored procedure in database + * * @copyright 2020 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ @@ -20,25 +21,25 @@ class AddAnalyticsWebsiteProcedure extends Migration // Example: CALL analytics_website(1,'FR','Firefox'); $procedureName = $this->db->prefixTable('analytics_website'); $createQuery = <<db->query($createQuery); } diff --git a/app/Database/Migrations/2020-07-03-191500_add_podcasts_users.php b/app/Database/Migrations/2020-07-03-191500_add_podcasts_users.php index 3884d57a..37bb9d56 100644 --- a/app/Database/Migrations/2020-07-03-191500_add_podcasts_users.php +++ b/app/Database/Migrations/2020-07-03-191500_add_podcasts_users.php @@ -1,8 +1,8 @@ forge->addPrimaryKey(['user_id', 'podcast_id']); - $this->forge->addForeignKey('user_id', 'users', 'id'); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); - $this->forge->addForeignKey('group_id', 'auth_groups', 'id'); + $this->forge->addForeignKey('user_id', 'users', 'id', false, 'CASCADE'); + $this->forge->addForeignKey( + 'podcast_id', + 'podcasts', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'group_id', + 'auth_groups', + 'id', + false, + 'CASCADE', + ); $this->forge->createTable('podcasts_users'); } diff --git a/app/Database/Migrations/2020-08-17-150000_add_pages.php b/app/Database/Migrations/2020-08-17-150000_add_pages.php index b35cd443..cd271ab5 100644 --- a/app/Database/Migrations/2020-08-17-150000_add_pages.php +++ b/app/Database/Migrations/2020-08-17-150000_add_pages.php @@ -1,8 +1,8 @@ true, ], ]); - $this->forge->addKey('id', true); + $this->forge->addPrimaryKey('id'); $this->forge->createTable('pages'); } diff --git a/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php b/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php index 6c0bd504..17c60b71 100644 --- a/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php +++ b/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php @@ -28,8 +28,20 @@ class AddPodcastsCategories extends Migration ], ]); $this->forge->addPrimaryKey(['podcast_id', 'category_id']); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); - $this->forge->addForeignKey('category_id', 'categories', 'id'); + $this->forge->addForeignKey( + 'podcast_id', + 'podcasts', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'category_id', + 'categories', + 'id', + false, + 'CASCADE', + ); $this->forge->createTable('podcasts_categories'); } diff --git a/app/Database/Migrations/2020-12-25-120000_add_persons.php b/app/Database/Migrations/2020-12-25-120000_add_persons.php index bacdafcc..f9933604 100644 --- a/app/Database/Migrations/2020-12-25-120000_add_persons.php +++ b/app/Database/Migrations/2020-12-25-120000_add_persons.php @@ -45,6 +45,12 @@ class AddPersons extends Migration 'type' => 'VARCHAR', 'constraint' => 255, ], + // constraint is 13 because the longest safe mimetype for images is image/svg+xml, + // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types + 'image_mimetype' => [ + 'type' => 'VARCHAR', + 'constraint' => 13, + ], 'created_by' => [ 'type' => 'INT', 'unsigned' => true, diff --git a/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php b/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php index 1e7bc16b..af6c7ee5 100644 --- a/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php +++ b/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php @@ -47,8 +47,20 @@ class AddPodcastsPersons extends Migration 'person_group', 'person_role', ]); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); - $this->forge->addForeignKey('person_id', 'persons', 'id'); + $this->forge->addForeignKey( + 'podcast_id', + 'podcasts', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'person_id', + 'persons', + 'id', + false, + 'CASCADE', + ); $this->forge->createTable('podcasts_persons'); } diff --git a/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php b/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php index 4c1c6383..7cc30914 100644 --- a/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php +++ b/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php @@ -52,9 +52,27 @@ class AddEpisodesPersons extends Migration 'person_group', 'person_role', ]); - $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); - $this->forge->addForeignKey('episode_id', 'episodes', 'id'); - $this->forge->addForeignKey('person_id', 'persons', 'id'); + $this->forge->addForeignKey( + 'podcast_id', + 'podcasts', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'episode_id', + 'episodes', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'person_id', + 'persons', + 'id', + false, + 'CASCADE', + ); $this->forge->createTable('episodes_persons'); } diff --git a/app/Database/Migrations/2020-12-25-150000_add_credit_view.php b/app/Database/Migrations/2020-12-25-150000_add_credit_view.php index 42731dfc..68dfd05f 100644 --- a/app/Database/Migrations/2020-12-25-150000_add_credit_view.php +++ b/app/Database/Migrations/2020-12-25-150000_add_credit_view.php @@ -22,16 +22,16 @@ class AddCreditView extends Migration $podcastPersonTable = $this->db->prefixTable('podcasts_persons'); $episodePersonTable = $this->db->prefixTable('episodes_persons'); $createQuery = <<db->query($createQuery); } diff --git a/app/Database/Migrations/2021-02-23-100000_add_episode_id_to_notes.php b/app/Database/Migrations/2021-02-23-100000_add_episode_id_to_notes.php new file mode 100644 index 00000000..8278f5a9 --- /dev/null +++ b/app/Database/Migrations/2021-02-23-100000_add_episode_id_to_notes.php @@ -0,0 +1,38 @@ +db->getPrefix(); + + $createQuery = <<db->query($createQuery); + } + + public function down() + { + $this->forge->dropForeignKey( + 'activitypub_notes', + 'activitypub_notes_episode_id_foreign', + ); + $this->forge->dropColumn('activitypub_notes', 'episode_id'); + } +} diff --git a/app/Database/Migrations/2021-03-09-113000_add_created_by_to_notes.php b/app/Database/Migrations/2021-03-09-113000_add_created_by_to_notes.php new file mode 100644 index 00000000..67512542 --- /dev/null +++ b/app/Database/Migrations/2021-03-09-113000_add_created_by_to_notes.php @@ -0,0 +1,38 @@ +db->getPrefix(); + + $createQuery = <<db->query($createQuery); + } + + public function down() + { + $this->forge->dropForeignKey( + 'activitypub_notes', + 'activitypub_notes_created_by_foreign', + ); + $this->forge->dropColumn('activitypub_notes', 'created_by'); + } +} diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php index eb567ad0..455040f1 100644 --- a/app/Database/Seeds/AuthSeeder.php +++ b/app/Database/Seeds/AuthSeeder.php @@ -158,6 +158,18 @@ class AuthSeeder extends Seeder 'description' => 'Set / remove platform links of a podcast', 'has_permission' => ['podcast_admin'], ], + [ + 'name' => 'manage_publications', + 'description' => + 'Publish / unpublish episodes & notes of a podcast', + 'has_permission' => ['podcast_admin'], + ], + [ + 'name' => 'interact_as', + 'description' => + 'Interact as the podcast to favourite / share or reply to notes.', + 'has_permission' => ['podcast_admin'], + ], ], 'podcast_episodes' => [ [ @@ -192,11 +204,6 @@ class AuthSeeder extends Seeder 'Delete all occurrences of an episode of a podcast from the database', 'has_permission' => ['podcast_admin'], ], - [ - 'name' => 'manage_publications', - 'description' => 'Publish / unpublish episodes of a podcast', - 'has_permission' => ['podcast_admin'], - ], ], 'person' => [ [ @@ -220,8 +227,23 @@ class AuthSeeder extends Seeder 'has_permission' => ['superadmin'], ], [ - 'name' => 'delete_permanently', - 'description' => 'Delete any person from the database', + 'name' => 'delete', + 'description' => + 'Delete permanently any person from the database', + 'has_permission' => ['superadmin'], + ], + ], + 'fediverse' => [ + [ + 'name' => 'block_actors', + 'description' => + 'Block an activitypub actors from interacting with the instance.', + 'has_permission' => ['superadmin'], + ], + [ + 'name' => 'block_domains', + 'description' => + 'Block an activitypub domains from interacting with the instance.', 'has_permission' => ['superadmin'], ], ], @@ -266,7 +288,7 @@ class AuthSeeder extends Seeder array_push($dataGroupsPermissions, [ 'group_id' => $this->getGroupIdByName( $role, - $dataGroups + $dataGroups, ), 'permission_id' => $permissionId, ]); diff --git a/app/Database/Seeds/PlatformSeeder.php b/app/Database/Seeds/PlatformSeeder.php index 9eaf24e1..309b767c 100644 --- a/app/Database/Seeds/PlatformSeeder.php +++ b/app/Database/Seeds/PlatformSeeder.php @@ -98,7 +98,6 @@ class PlatformSeeder extends Seeder 'home_url' => 'https://fyyd.de/', 'submit_url' => 'https://fyyd.de/add-feed', ], - [ 'slug' => 'google', 'type' => 'podcasting', @@ -249,7 +248,6 @@ class PlatformSeeder extends Seeder 'submit_url' => 'https://help.tunein.com/contact/add-podcast-S19TR3Sdf', ], - [ 'slug' => 'paypal', 'type' => 'funding', @@ -257,7 +255,6 @@ class PlatformSeeder extends Seeder 'home_url' => 'https://www.paypal.com/', 'submit_url' => 'https://www.paypal.com/paypalme/my/grab', ], - [ 'slug' => 'gofundme', 'type' => 'funding', @@ -322,7 +319,6 @@ class PlatformSeeder extends Seeder 'home_url' => 'https://www.ulule.com/', 'submit_url' => 'https://www.ulule.com/projects/create/#/', ], - [ 'slug' => 'discord', 'type' => 'social', @@ -431,6 +427,7 @@ class PlatformSeeder extends Seeder 'submit_url' => 'https://creatoracademy.youtube.com/page/home', ], ]; + $this->db ->table('platforms') ->ignore(true) diff --git a/app/Entities/Credit.php b/app/Entities/Credit.php index 0988e7ca..94dd5f4a 100644 --- a/app/Entities/Credit.php +++ b/app/Entities/Credit.php @@ -27,7 +27,7 @@ class Credit extends Entity protected $podcast; /** - * @var \App\Entities\Episode + * @var \App\Entities\Episode|null */ protected $episode; @@ -44,50 +44,61 @@ class Credit extends Entity public function getPodcast() { return (new PodcastModel())->getPodcastById( - $this->attributes['podcast_id'] + $this->attributes['podcast_id'], ); } public function getEpisode() { - if (empty($this->attributes['episode_id'])) { - return null; - } else { - return (new EpisodeModel())->getEpisodeById( - $this->attributes['podcast_id'], - $this->attributes['episode_id'] + if (empty($this->episode_id)) { + throw new \RuntimeException( + 'Credit must have episode_id before getting episode.', ); } + + if (empty($this->episode)) { + $this->episode = (new EpisodeModel())->getPublishedEpisodeById( + $this->episode_id, + $this->podcast_id, + ); + } + + return $this->episode; } public function getPerson() { - return (new PersonModel())->getPersonById( - $this->attributes['person_id'] - ); + if (empty($this->person_id)) { + throw new \RuntimeException( + 'Credit must have person_id before getting person.', + ); + } + + if (empty($this->person)) { + $this->person = (new PersonModel())->getPersonById( + $this->person_id, + ); + } + + return $this->person; } public function getGroupLabel() { - if (empty($this->attributes['person_group'])) { + if (empty($this->person_group)) { return null; } else { - return lang( - "PersonsTaxonomy.persons.{$this->attributes['person_group']}.label" - ); + return lang("PersonsTaxonomy.persons.{$this->person_group}.label"); } } public function getRoleLabel() { - if ( - empty($this->attributes['person_group']) || - empty($this->attributes['person_role']) - ) { + if (empty($this->person_group) || empty($this->person_role)) { return null; } else { return lang( - "PersonsTaxonomy.persons.{$this->attributes['person_group']}.roles.{$this->attributes['person_role']}.label" + "PersonsTaxonomy.persons.{$this->person_group}.roles.{$this->person_role}.label", ); } } diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 6d3042dd..ced727eb 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -11,6 +11,7 @@ namespace App\Entities; use App\Models\PodcastModel; use App\Models\SoundbiteModel; use App\Models\EpisodePersonModel; +use App\Models\NoteModel; use CodeIgniter\Entity; use CodeIgniter\I18n\Time; use League\CommonMark\CommonMarkConverter; @@ -28,7 +29,7 @@ class Episode extends Entity protected $link; /** - * @var \App\Entities\Image + * @var \App\Libraries\Image */ protected $image; @@ -80,13 +81,18 @@ class Episode extends Entity /** * @var \App\Entities\EpisodePerson[] */ - protected $episode_persons; + protected $persons; /** * @var \App\Entities\Soundbite[] */ protected $soundbites; + /** + * @var \App\Entities\Note[] + */ + protected $notes; + /** * Holds text only description, striped of any markdown or html special characters * @@ -122,6 +128,7 @@ class Episode extends Entity protected $casts = [ 'id' => 'integer', + 'podcast_id' => 'integer', 'guid' => 'string', 'slug' => 'string', 'title' => 'string', @@ -133,6 +140,7 @@ class Episode extends Entity 'description_markdown' => 'string', 'description_html' => 'string', 'image_uri' => '?string', + 'image_mimetype' => '?string', 'transcript_uri' => '?string', 'chapters_uri' => '?string', 'parental_advisory' => '?string', @@ -144,6 +152,9 @@ class Episode extends Entity 'location_geo' => '?string', 'location_osmid' => '?string', 'custom_rss' => '?json-array', + 'favourites_total' => 'integer', + 'reblogs_total' => 'integer', + 'notes_total' => 'integer', 'created_by' => 'integer', 'updated_by' => 'integer', ]; @@ -163,15 +174,16 @@ class Episode extends Entity ) { helper('media'); - // check whether the user has inputted an image and store it - $this->attributes['image_uri'] = save_podcast_media( + // check whether the user has inputted an image and store + $this->attributes['image_mimetype'] = $image->getMimeType(); + $this->attributes['image_uri'] = save_media( $image, - $this->getPodcast()->name, - $this->attributes['slug'] + 'podcasts/' . $this->getPodcast()->name, + $this->attributes['slug'], ); - - $this->image = new \App\Entities\Image( - $this->attributes['image_uri'] + $this->image = new \App\Libraries\Image( + $this->attributes['image_uri'], + $this->attributes['image_mimetype'], ); $this->image->saveSizes(); } @@ -179,10 +191,13 @@ class Episode extends Entity return $this; } - public function getImage(): \App\Entities\Image + public function getImage(): \App\Libraries\Image { if ($image_uri = $this->attributes['image_uri']) { - return new \App\Entities\Image($image_uri); + return new \App\Libraries\Image( + $image_uri, + $this->attributes['image_mimetype'], + ); } return $this->getPodcast()->image; } @@ -204,13 +219,13 @@ class Episode extends Entity $enclosure_metadata = get_file_tags($enclosure); - $this->attributes['enclosure_uri'] = save_podcast_media( + $this->attributes['enclosure_uri'] = save_media( $enclosure, - $this->getPodcast()->name, - $this->attributes['slug'] + 'podcasts/' . $this->getPodcast()->name, + $this->attributes['slug'], ); $this->attributes['enclosure_duration'] = round( - $enclosure_metadata['playtime_seconds'] + $enclosure_metadata['playtime_seconds'], ); $this->attributes['enclosure_mimetype'] = $enclosure_metadata['mime_type']; @@ -238,10 +253,10 @@ class Episode extends Entity ) { helper('media'); - $this->attributes['transcript_uri'] = save_podcast_media( + $this->attributes['transcript_uri'] = save_media( $transcript, $this->getPodcast()->name, - $this->attributes['slug'] . '-transcript' + $this->attributes['slug'] . '-transcript', ); } @@ -263,10 +278,10 @@ class Episode extends Entity ) { helper('media'); - $this->attributes['chapters_uri'] = save_podcast_media( + $this->attributes['chapters_uri'] = save_media( $chapters, $this->getPodcast()->name, - $this->attributes['slug'] . '-chapters' + $this->attributes['slug'] . '-chapters', ); } @@ -343,15 +358,15 @@ class Episode extends Entity $this->attributes[ 'enclosure_duration' ]) * - 60 + 60, ), $this->attributes['enclosure_filesize'], $this->attributes['enclosure_duration'], - strtotime($this->attributes['published_at']) - ) + strtotime($this->attributes['published_at']), + ), ), - $this->attributes['enclosure_uri'] - ) + $this->attributes['enclosure_uri'], + ), ); } @@ -384,22 +399,22 @@ class Episode extends Entity * * @return \App\Entities\EpisodePerson[] */ - public function getEpisodePersons() + public function getPersons() { if (empty($this->id)) { throw new \RuntimeException( - 'Episode must be created before getting persons.' + 'Episode must be created before getting persons.', ); } - if (empty($this->episode_persons)) { - $this->episode_persons = (new EpisodePersonModel())->getPersonsByEpisodeId( + if (empty($this->persons)) { + $this->persons = (new EpisodePersonModel())->getPersonsByEpisodeId( $this->podcast_id, - $this->id + $this->id, ); } - return $this->episode_persons; + return $this->persons; } /** @@ -411,28 +426,43 @@ class Episode extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Episode must be created before getting soundbites.' + 'Episode must be created before getting soundbites.', ); } if (empty($this->soundbites)) { $this->soundbites = (new SoundbiteModel())->getEpisodeSoundbites( $this->getPodcast()->id, - $this->id + $this->id, ); } return $this->soundbites; } + public function getNotes() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Episode must be created before getting soundbites.', + ); + } + + if (empty($this->notes)) { + $this->notes = (new NoteModel())->getEpisodeNotes($this->id); + } + + return $this->notes; + } + public function getLink() { return base_url( route_to( 'episode', $this->getPodcast()->name, - $this->attributes['slug'] - ) + $this->attributes['slug'], + ), ); } @@ -444,13 +474,13 @@ class Episode extends Entity 'embeddable-player-theme', $this->getPodcast()->name, $this->attributes['slug'], - $theme + $theme, ) : route_to( 'embeddable-player', $this->getPodcast()->name, - $this->attributes['slug'] - ) + $this->attributes['slug'], + ), ); } @@ -464,7 +494,7 @@ class Episode extends Entity public function getPodcast() { return (new PodcastModel())->getPodcastById( - $this->attributes['podcast_id'] + $this->attributes['podcast_id'], ); } @@ -477,7 +507,7 @@ class Episode extends Entity $this->attributes['description_markdown'] = $descriptionMarkdown; $this->attributes['description_html'] = $converter->convertToHtml( - $descriptionMarkdown + $descriptionMarkdown, ); return $this; @@ -510,25 +540,11 @@ class Episode extends Entity preg_replace( '/\s+/', ' ', - strip_tags($this->attributes['description_html']) - ) + strip_tags($this->attributes['description_html']), + ), ); } - public function setCreatedBy(\App\Entities\User $user) - { - $this->attributes['created_by'] = $user->id; - - return $this; - } - - public function setUpdatedBy(\App\Entities\User $user) - { - $this->attributes['updated_by'] = $user->id; - - return $this; - } - public function getPublicationStatus() { if ($this->publication_status) { @@ -588,7 +604,7 @@ class Episode extends Entity return ''; } else { $xmlNode = (new \App\Libraries\SimpleRSSElement( - '' + '', )) ->addChild('channel') ->addChild('item'); @@ -596,7 +612,7 @@ class Episode extends Entity [ 'elements' => $this->custom_rss, ], - $xmlNode + $xmlNode, ); return str_replace(['', ''], '', $xmlNode->asXML()); } @@ -615,12 +631,12 @@ class Episode extends Entity simplexml_load_string( '' . $customRssString . - '' - ) + '', + ), )['elements'][0]['elements'][0]; if (array_key_exists('elements', $customRssArray)) { $this->attributes['custom_rss'] = json_encode( - $customRssArray['elements'] + $customRssArray['elements'], ); } else { $this->attributes['custom_rss'] = null; diff --git a/app/Entities/Image.php b/app/Entities/Image.php deleted file mode 100644 index 0f64b16c..00000000 --- a/app/Entities/Image.php +++ /dev/null @@ -1,151 +0,0 @@ - $filename, - 'dirname' => $dirname, - 'extension' => $extension, - ] = pathinfo($originalPath); - - // load images extensions from config - $imageConfig = config('Images'); - $thumbnailExtension = $imageConfig->thumbnailExtension; - $mediumExtension = $imageConfig->mediumExtension; - $largeExtension = $imageConfig->largeExtension; - $feedExtension = $imageConfig->feedExtension; - $id3Extension = $imageConfig->id3Extension; - - $thumbnail = - $dirname . '/' . $filename . $thumbnailExtension . '.' . $extension; - $medium = - $dirname . '/' . $filename . $mediumExtension . '.' . $extension; - $large = - $dirname . '/' . $filename . $largeExtension . '.' . $extension; - $feed = $dirname . '/' . $filename . $feedExtension . '.' . $extension; - $id3 = $dirname . '/' . $filename . $id3Extension . '.' . $extension; - - parent::__construct([ - 'original_path' => $originalPath, - 'original_url' => media_url($originalUri), - 'thumbnail_path' => $thumbnail, - 'thumbnail_url' => base_url($thumbnail), - 'medium_path' => $medium, - 'medium_url' => base_url($medium), - 'large_path' => $large, - 'large_url' => base_url($large), - 'feed_path' => $feed, - 'feed_url' => base_url($feed), - 'id3_path' => $id3, - ]); - } - - public function saveSizes() - { - // load images sizes from config - $imageConfig = config('Images'); - $thumbnailSize = $imageConfig->thumbnailSize; - $mediumSize = $imageConfig->mediumSize; - $largeSize = $imageConfig->largeSize; - $feedSize = $imageConfig->feedSize; - $id3Size = $imageConfig->id3Size; - - $imageService = \Config\Services::image(); - - $imageService - ->withFile($this->attributes['original_path']) - ->resize($thumbnailSize, $thumbnailSize) - ->save($this->attributes['thumbnail_path']); - - $imageService - ->withFile($this->attributes['original_path']) - ->resize($mediumSize, $mediumSize) - ->save($this->attributes['medium_path']); - - $imageService - ->withFile($this->attributes['original_path']) - ->resize($largeSize, $largeSize) - ->save($this->attributes['large_path']); - - $imageService - ->withFile($this->attributes['original_path']) - ->resize($feedSize, $feedSize) - ->save($this->attributes['feed_path']); - - $imageService - ->withFile($this->attributes['original_path']) - ->resize($id3Size, $id3Size) - ->save($this->attributes['id3_path']); - } -} diff --git a/app/Entities/Note.php b/app/Entities/Note.php new file mode 100644 index 00000000..6ec48029 --- /dev/null +++ b/app/Entities/Note.php @@ -0,0 +1,56 @@ + 'string', + 'uri' => 'string', + 'actor_id' => 'integer', + 'in_reply_to_id' => '?string', + 'reblog_of_id' => '?string', + 'episode_id' => '?integer', + 'message' => 'string', + 'message_html' => 'string', + 'favourites_count' => 'integer', + 'reblogs_count' => 'integer', + 'replies_count' => 'integer', + 'created_by' => 'integer', + ]; + + /** + * Returns the note's attached episode + * + * @return \App\Entities\Episode + */ + public function getEpisode() + { + if (empty($this->episode_id)) { + throw new \RuntimeException( + 'Note must have an episode_id before getting episode.', + ); + } + + if (empty($this->episode)) { + $this->episode = (new EpisodeModel())->getEpisodeById( + $this->episode_id, + ); + } + + return $this->episode; + } +} diff --git a/app/Entities/Person.php b/app/Entities/Person.php index 8f20885c..222aa56f 100644 --- a/app/Entities/Person.php +++ b/app/Entities/Person.php @@ -13,7 +13,7 @@ use CodeIgniter\Entity; class Person extends Entity { /** - * @var \App\Entities\Image + * @var \App\Libraries\Image */ protected $image; @@ -23,12 +23,13 @@ class Person extends Entity 'unique_name' => 'string', 'information_url' => '?string', 'image_uri' => 'string', + 'image_mimetype' => 'string', 'created_by' => 'integer', 'updated_by' => 'integer', ]; /** - * Saves a picture in `public/media/~person/` + * Saves a picture in `public/media/persons/` * * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image * @@ -38,13 +39,15 @@ class Person extends Entity if ($image) { helper('media'); - $this->attributes['image_uri'] = save_podcast_media( + $this->attributes['image_mimetype'] = $image->getMimeType(); + $this->attributes['image_uri'] = save_media( $image, - '~person', - $this->attributes['unique_name'] + 'persons', + $this->attributes['unique_name'], ); - $this->image = new \App\Entities\Image( - $this->attributes['image_uri'] + $this->image = new \App\Libraries\Image( + $this->attributes['image_uri'], + $this->attributes['image_mimetype'], ); $this->image->saveSizes(); } @@ -54,6 +57,9 @@ class Person extends Entity public function getImage() { - return new \App\Entities\Image($this->attributes['image_uri']); + return new \App\Libraries\Image( + $this->attributes['image_uri'], + $this->attributes['image_mimetype'], + ); } } diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index ec6c8e3f..82922b2d 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -8,6 +8,7 @@ namespace App\Entities; +use ActivityPub\Models\ActorModel; use App\Models\CategoryModel; use App\Models\EpisodeModel; use App\Models\PlatformModel; @@ -24,7 +25,12 @@ class Podcast extends Entity protected $link; /** - * @var \App\Entities\Image + * @var \ActivityPub\Entities\Actor + */ + protected $actor; + + /** + * @var \App\Libraries\Image */ protected $image; @@ -36,7 +42,7 @@ class Podcast extends Entity /** * @var \App\Entities\PodcastPerson[] */ - protected $podcast_persons; + protected $persons; /** * @var \App\Entities\Category @@ -89,11 +95,13 @@ class Podcast extends Entity protected $casts = [ 'id' => 'integer', - 'title' => 'string', + 'actor_id' => 'integer', 'name' => 'string', + 'title' => 'string', 'description_markdown' => 'string', 'description_html' => 'string', 'image_uri' => 'string', + 'image_mimetype' => 'string', 'language_code' => 'string', 'category_id' => 'integer', 'parental_advisory' => '?string', @@ -121,6 +129,26 @@ class Podcast extends Entity 'updated_by' => 'integer', ]; + /** + * Returns the podcast actor + * + * @return \App\Entities\Actor + */ + public function getActor() + { + if (!$this->attributes['actor_id']) { + throw new \RuntimeException( + 'Podcast must have an actor_id before getting actor.', + ); + } + + if (empty($this->actor)) { + $this->actor = (new ActorModel())->getActorById($this->actor_id); + } + + return $this->actor; + } + /** * Saves a cover image to the corresponding podcast folder in `public/media/podcast_name/` * @@ -132,13 +160,16 @@ class Podcast extends Entity if ($image) { helper('media'); - $this->attributes['image_uri'] = save_podcast_media( + $this->attributes['image_mimetype'] = $image->getMimeType(); + $this->attributes['image_uri'] = save_media( $image, - $this->attributes['name'], - 'cover' + 'podcasts/' . $this->attributes['name'], + 'cover', ); - $this->image = new \App\Entities\Image( - $this->attributes['image_uri'] + + $this->image = new \App\Libraries\Image( + $this->attributes['image_uri'], + $this->attributes['image_mimetype'], ); $this->image->saveSizes(); } @@ -148,17 +179,20 @@ class Podcast extends Entity public function getImage() { - return new \App\Entities\Image($this->attributes['image_uri']); + return new \App\Libraries\Image( + $this->attributes['image_uri'], + $this->attributes['image_mimetype'], + ); } public function getLink() { - return base_url(route_to('podcast', $this->attributes['name'])); + return url_to('podcast-activity', $this->attributes['name']); } public function getFeedUrl() { - return base_url(route_to('podcast_feed', $this->attributes['name'])); + return url_to('podcast_feed', $this->attributes['name']); } /** @@ -170,14 +204,14 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting episodes.' + 'Podcast must be created before getting episodes.', ); } if (empty($this->episodes)) { $this->episodes = (new EpisodeModel())->getPodcastEpisodes( $this->id, - $this->type + $this->type, ); } @@ -189,21 +223,21 @@ class Podcast extends Entity * * @return \App\Entities\PodcastPerson[] */ - public function getPodcastPersons() + public function getPersons() { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting persons.' + 'Podcast must be created before getting persons.', ); } - if (empty($this->podcast_persons)) { - $this->podcast_persons = (new PodcastPersonModel())->getPersonsByPodcastId( - $this->id + if (empty($this->persons)) { + $this->persons = (new PodcastPersonModel())->getPersonsByPodcastId( + $this->id, ); } - return $this->podcast_persons; + return $this->persons; } /** @@ -215,7 +249,7 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting category.' + 'Podcast must be created before getting category.', ); } @@ -235,13 +269,13 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcasts must be created before getting contributors.' + 'Podcasts must be created before getting contributors.', ); } if (empty($this->contributors)) { $this->contributors = (new UserModel())->getPodcastContributors( - $this->id + $this->id, ); } @@ -257,7 +291,7 @@ class Podcast extends Entity $this->attributes['description_markdown'] = $descriptionMarkdown; $this->attributes['description_html'] = $converter->convertToHtml( - $descriptionMarkdown + $descriptionMarkdown, ); return $this; @@ -293,25 +327,11 @@ class Podcast extends Entity preg_replace( '/\s+/', ' ', - strip_tags($this->attributes['description_html']) - ) + strip_tags($this->attributes['description_html']), + ), ); } - public function setCreatedBy(\App\Entities\User $user) - { - $this->attributes['created_by'] = $user->id; - - return $this; - } - - public function setUpdatedBy(\App\Entities\User $user) - { - $this->attributes['updated_by'] = $user->id; - - return $this; - } - /** * Returns the podcast's podcasting platform links * @@ -321,14 +341,14 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting podcasting platform links.' + 'Podcast must be created before getting podcasting platform links.', ); } if (empty($this->podcastingPlatforms)) { $this->podcastingPlatforms = (new PlatformModel())->getPodcastPlatforms( $this->id, - 'podcasting' + 'podcasting', ); } @@ -342,7 +362,7 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting podcasting platform.' + 'Podcast must be created before getting podcasting platform.', ); } foreach ($this->getPodcastingPlatforms() as $podcastingPlatform) { @@ -362,14 +382,14 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting social platform links.' + 'Podcast must be created before getting social platform links.', ); } if (empty($this->socialPlatforms)) { $this->socialPlatforms = (new PlatformModel())->getPodcastPlatforms( $this->id, - 'social' + 'social', ); } @@ -383,7 +403,7 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting social platform.' + 'Podcast must be created before getting social platform.', ); } foreach ($this->getSocialPlatforms() as $socialPlatform) { @@ -403,14 +423,14 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting funding platform links.' + 'Podcast must be created before getting funding platform links.', ); } if (empty($this->fundingPlatforms)) { $this->fundingPlatforms = (new PlatformModel())->getPodcastPlatforms( $this->id, - 'funding' + 'funding', ); } @@ -424,7 +444,7 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting Funding platform.' + 'Podcast must be created before getting Funding platform.', ); } foreach ($this->getFundingPlatforms() as $fundingPlatform) { @@ -439,13 +459,13 @@ class Podcast extends Entity { if (empty($this->id)) { throw new \RuntimeException( - 'Podcast must be created before getting other categories.' + 'Podcast must be created before getting other categories.', ); } if (empty($this->other_categories)) { $this->other_categories = (new CategoryModel())->getPodcastCategories( - $this->id + $this->id, ); } @@ -457,7 +477,7 @@ class Podcast extends Entity if (empty($this->other_categories_ids)) { $this->other_categories_ids = array_column( $this->getOtherCategories(), - 'id' + 'id', ); } @@ -505,18 +525,18 @@ class Podcast extends Entity return ''; } else { $xmlNode = (new \App\Libraries\SimpleRSSElement( - '' + '', ))->addChild('channel'); array_to_rss( [ 'elements' => $this->custom_rss, ], - $xmlNode + $xmlNode, ); return str_replace( ['', ''], '', - $xmlNode->asXML() + $xmlNode->asXML(), ); } } @@ -534,12 +554,12 @@ class Podcast extends Entity simplexml_load_string( '' . $customRssString . - '' - ) + '', + ), )['elements'][0]; if (array_key_exists('elements', $customRssArray)) { $this->attributes['custom_rss'] = json_encode( - $customRssArray['elements'] + $customRssArray['elements'], ); } else { $this->attributes['custom_rss'] = null; diff --git a/app/Entities/Soundbite.php b/app/Entities/Soundbite.php index 33373c8b..04d8f293 100644 --- a/app/Entities/Soundbite.php +++ b/app/Entities/Soundbite.php @@ -23,13 +23,6 @@ class Soundbite extends Entity 'updated_by' => 'integer', ]; - public function setCreatedBy(\App\Entities\User $user) - { - $this->attributes['created_by'] = $user->id; - - return $this; - } - public function setUpdatedBy(\App\Entities\User $user) { $this->attributes['updated_by'] = $user->id; diff --git a/app/Filters/Permission.php b/app/Filters/PermissionFilter.php similarity index 98% rename from app/Filters/Permission.php rename to app/Filters/PermissionFilter.php index 462257b9..f40c6a49 100644 --- a/app/Filters/Permission.php +++ b/app/Filters/PermissionFilter.php @@ -9,7 +9,7 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Filters\FilterInterface; use Myth\Auth\Exceptions\PermissionException; -class Permission implements FilterInterface +class PermissionFilter implements FilterInterface { /** * Do whatever processing this filter needs to do. diff --git a/app/Helpers/analytics_helper.php b/app/Helpers/analytics_helper.php index 3360de83..e7b393db 100644 --- a/app/Helpers/analytics_helper.php +++ b/app/Helpers/analytics_helper.php @@ -6,30 +6,6 @@ * @link https://castopod.org/ */ -/** - * For compatibility with PHP-FPM v7.2 and below: - */ -if (!function_exists('getallheaders')) { - function getallheaders() - { - $headers = []; - foreach ($_SERVER as $name => $value) { - if (substr($name, 0, 5) == 'HTTP_') { - $headers[ - str_replace( - ' ', - '-', - ucwords( - strtolower(str_replace('_', ' ', substr($name, 5))) - ) - ) - ] = $value; - } - } - return $headers; - } -} - /** * Encode Base64 for URLs */ @@ -57,7 +33,7 @@ function set_user_session_deny_list_ip() if (!$session->has('denyListIp')) { $session->set( 'denyListIp', - \Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null + \Podlibre\Ipcat\IpDb::find($_SERVER['REMOTE_ADDR']) != null, ); } } @@ -81,7 +57,7 @@ function set_user_session_location() if (!$session->has('location')) { try { $cityReader = new \GeoIp2\Database\Reader( - WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb' + WRITEPATH . 'uploads/GeoLite2-City/GeoLite2-City.mmdb', ); $city = $cityReader->city($_SERVER['REMOTE_ADDR']); @@ -132,7 +108,7 @@ function set_user_session_player() try { $db = \Config\Database::connect(); $procedureNameAnalyticsUnknownUseragents = $db->prefixTable( - 'analytics_unknown_useragents' + 'analytics_unknown_useragents', ); $db->query("CALL $procedureNameAnalyticsUnknownUseragents(?)", [ $userAgent, @@ -283,7 +259,7 @@ function podcast_hit( '_' . $_SERVER['HTTP_USER_AGENT'] . '_' . - $episodeId + $episodeId, ); // Was this episode downloaded in the past 24h: $downloadedBytes = cache($episodeHashId); @@ -335,7 +311,7 @@ function podcast_hit( '_' . $_SERVER['HTTP_USER_AGENT'] . '_' . - $podcastId + $podcastId, ); $newListener = 1; // Has this listener already downloaded an episode today: @@ -370,7 +346,7 @@ function podcast_hit( $duration, $age, $newListener, - ] + ], ); } } diff --git a/app/Helpers/auth_helper.php b/app/Helpers/auth_helper.php new file mode 100644 index 00000000..d1d095ba --- /dev/null +++ b/app/Helpers/auth_helper.php @@ -0,0 +1,89 @@ +check(); + + $session = session(); + $session->set('interact_as_actor_id', $actorId); + } +} + +if (!function_exists('remove_interact_as_actor')) { + /** + * Removes the actor id of which the user is acting as + * + * @return void + */ + function remove_interact_as_actor() + { + $session = session(); + $session->remove('interact_as_actor_id'); + } +} + +if (!function_exists('interact_as_actor_id')) { + /** + * Sets the podcast id of which the user is acting as + * + * @return integer + */ + function interact_as_actor_id() + { + $authenticate = Services::authentication(); + $authenticate->check(); + + $session = session(); + return $session->get('interact_as_actor_id'); + } +} + +if (!function_exists('interact_as_actor')) { + /** + * Get the actor the user is currently interacting as + * + * @return \ActivityPub\Entities\Actor|false + */ + function interact_as_actor() + { + $authenticate = Services::authentication(); + $authenticate->check(); + + $session = session(); + if ($session->has('interact_as_actor_id')) { + return (new ActorModel())->getActorById( + $session->get('interact_as_actor_id'), + ); + } + + return false; + } +} + +if (!function_exists('can_user_interact')) { + /** + * @return bool + * @throws DataException + */ + function can_user_interact() + { + return interact_as_actor() ? true : false; + } +} diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php index 78b1516e..7c85e951 100644 --- a/app/Helpers/components_helper.php +++ b/app/Helpers/components_helper.php @@ -30,34 +30,34 @@ if (!function_exists('button')) { 'size' => 'base', 'iconLeft' => null, 'iconRight' => null, - 'isRoundedFull' => false, 'isSquared' => false, ]; $options = array_merge($defaultOptions, $customOptions); $baseClass = - 'inline-flex items-center shadow-xs outline-none focus:shadow-outline'; + 'inline-flex items-center font-semibold shadow-xs rounded-full focus:outline-none focus:ring'; $variantClass = [ - 'default' => 'bg-gray-300 hover:bg-gray-400', - 'primary' => 'text-white bg-green-500 hover:bg-green-600', + 'default' => 'text-black bg-gray-300 hover:bg-gray-400', + 'primary' => 'text-white bg-pine-700 hover:bg-pine-800', 'secondary' => 'text-white bg-gray-700 hover:bg-gray-800', + 'accent' => 'text-white bg-rose-600 hover:bg-rose-800', 'success' => 'text-white bg-green-600 hover:bg-green-700', 'danger' => 'text-white bg-red-600 hover:bg-red-700', 'warning' => 'text-black bg-yellow-500 hover:bg-yellow-600', - 'info' => 'text-white bg-teal-500 hover:bg-teal-600', + 'info' => 'text-white bg-blue-500 hover:bg-blue-600', ]; $sizeClass = [ - 'small' => 'text-xs md:text-sm ', + 'small' => 'text-xs md:text-sm', 'base' => 'text-sm md:text-base', 'large' => 'text-lg md:text-xl', ]; $basePaddings = [ - 'small' => 'px-1 md:px-2 md:py-1', - 'base' => 'px-2 py-1 md:px-3 md:py-2', - 'large' => 'px-3 py-2 md:px-4 md:py-2', + 'small' => 'px-2 md:px-3 md:py-1', + 'base' => 'px-3 py-1 md:px-4 md:py-2', + 'large' => 'px-3 py-2 md:px-5', ]; $squaredPaddings = [ @@ -66,20 +66,9 @@ if (!function_exists('button')) { 'large' => 'p-3', ]; - $roundedClass = [ - 'full' => 'rounded-full', - 'small' => 'rounded-sm md:rounded', - 'base' => 'rounded md:rounded-md', - 'large' => 'rounded-md md:rounded-lg', - ]; - $buttonClass = $baseClass . ' ' . - ($options['isRoundedFull'] - ? $roundedClass['full'] - : $roundedClass[$options['size']]) . - ' ' . ($options['isSquared'] ? $squaredPaddings[$options['size']] : $basePaddings[$options['size']]) . @@ -109,23 +98,23 @@ if (!function_exists('button')) { [ 'class' => $buttonClass, ], - $customAttributes - ) + $customAttributes, + ), ); } $defaultButtonAttributes = [ 'type' => 'button', ]; - $attributes = array_merge($defaultButtonAttributes, $customAttributes); + $attributes = stringify_attributes( + array_merge($defaultButtonAttributes, $customAttributes), + ); - return ''; + return << + $label + + HTML; } } @@ -152,7 +141,6 @@ if (!function_exists('icon_button')) { $customAttributes = [] ): string { $defaultOptions = [ - 'isRoundedFull' => true, 'isSquared' => true, ]; $options = array_merge($defaultOptions, $customOptions); @@ -185,7 +173,7 @@ if (!function_exists('hint_tooltip')) { $tooltip = '', 'cell_alt_start' => '', - 'row_start' => '', - 'row_alt_start' => '', + 'row_start' => '', + 'row_alt_start' => '', ]; $table->setTemplate($template); @@ -276,8 +264,8 @@ if (!function_exists('publication_pill')) { ): string { $class = $publicationStatus === 'published' - ? 'text-green-500 border-green-500' - : 'text-orange-600 border-orange-600'; + ? 'text-pine-500 border-pine-500' + : 'text-red-600 border-red-600'; $transParam = []; if ($publicationDate) { @@ -294,10 +282,10 @@ if (!function_exists('publication_pill')) { $label = lang( 'Episode.publication_status.' . $publicationStatus, - $transParam + $transParam, ); - return ' $variant, + 'iconLeft' => $iconLeft, + ]); + } +} + +// ------------------------------------------------------------------------ + if (!function_exists('episode_numbering')) { /** * Returns relevant translated episode numbering. @@ -387,21 +425,16 @@ if (!function_exists('location_link')) { $link = ''; if (!empty($locationName)) { - $link = button( - $locationName, + $link = anchor( location_url($locationName, $locationGeo, $locationOsmid), - [ - 'variant' => 'default', - 'size' => 'small', - 'isRoundedFull' => true, - 'iconLeft' => 'map-pin', - ], + icon('map-pin', 'mr-2') . $locationName, [ 'class' => - 'text-gray-800' . (empty($class) ? '' : " $class"), + 'inline-flex items-baseline hover:underline' . + (empty($class) ? '' : " $class"), 'target' => '_blank', 'rel' => 'noreferrer noopener', - ] + ], ); } diff --git a/app/Helpers/media_helper.php b/app/Helpers/media_helper.php index 8db7a663..88e494c7 100644 --- a/app/Helpers/media_helper.php +++ b/app/Helpers/media_helper.php @@ -6,6 +6,8 @@ * @link https://castopod.org/ */ +use CodeIgniter\Files\File; +use CodeIgniter\HTTP\Exceptions\HTTPException; use CodeIgniter\HTTP\ResponseInterface; /** @@ -17,25 +19,31 @@ use CodeIgniter\HTTP\ResponseInterface; * * @return string The episode's file path in media root */ -function save_podcast_media($file, $podcast_name, $media_name) +function save_media($file, $folder, $mediaName) { - $file_name = $media_name . '.' . $file->getExtension(); + $file_name = $mediaName . '.' . $file->getExtension(); - $mediaRoot = config('App')->mediaRoot; + $mediaRoot = config('App')->mediaRoot . '/' . $folder; - if (!file_exists($mediaRoot . '/' . $podcast_name)) { - mkdir($mediaRoot . '/' . $podcast_name, 0777, true); - touch($mediaRoot . '/' . $podcast_name . '/index.html'); + if (!file_exists($mediaRoot)) { + mkdir($mediaRoot, 0777, true); + touch($mediaRoot . '/index.html'); } // move to media folder and overwrite file if already existing - $file->move($mediaRoot . '/' . $podcast_name . '/', $file_name, true); + $file->move($mediaRoot . '/', $file_name, true); - return $podcast_name . '/' . $file_name; + return $folder . '/' . $file_name; } +/** + * @param string $fileUrl + * @return File + */ function download_file($fileUrl) { + var_dump($fileUrl); + $client = \Config\Services::curlrequest(); $uri = new \CodeIgniter\HTTP\URI($fileUrl); @@ -58,11 +66,11 @@ function download_file($fileUrl) ResponseInterface::HTTP_TEMPORARY_REDIRECT, ResponseInterface::HTTP_PERMANENT_REDIRECT, ], - true + true, ) ) { $newFileUrl = (string) trim( - $response->getHeader('location')->getValue() + $response->getHeader('location')->getValue(), ); $newLocation = new \CodeIgniter\HTTP\URI($newFileUrl); $response = $client->get($newLocation, [ diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php index b87051c4..92c16ffa 100644 --- a/app/Helpers/misc_helper.php +++ b/app/Helpers/misc_helper.php @@ -167,3 +167,5 @@ if (!function_exists('format_duration')) { ); } } + +//-------------------------------------------------------------------- diff --git a/app/Helpers/persons_helper.php b/app/Helpers/persons_helper.php index 5fe79d76..62dd6b12 100644 --- a/app/Helpers/persons_helper.php +++ b/app/Helpers/persons_helper.php @@ -9,87 +9,40 @@ /** * Fetches persons from an episode * - * @param array $podcast_persons - * @param array &$persons + * @param array $persons + * @param array &$personsArray */ -function constructs_podcast_person_array($podcast_persons, &$persons) +function construct_person_array($persons, &$personsArray) { - foreach ($podcast_persons as $podcastPerson) { - if (array_key_exists($podcastPerson->person->id, $persons)) { - $persons[$podcastPerson->person->id]['roles'] .= - empty($podcastPerson->person_group) || - empty($podcastPerson->person_role) + foreach ($persons as $person) { + if (array_key_exists($person->person->id, $personsArray)) { + $personsArray[$person->person->id]['roles'] .= + empty($person->person_group) || empty($person->person_role) ? '' - : (empty($persons[$podcastPerson->person->id]['roles']) + : (empty($personsArray[$person->person->id]['roles']) ? '' : ', ') . lang( 'PersonsTaxonomy.persons.' . - $podcastPerson->person_group . + $person->person_group . '.roles.' . - $podcastPerson->person_role . - '.label' + $person->person_role . + '.label', ); } else { - $persons[$podcastPerson->person->id] = [ - 'full_name' => $podcastPerson->person->full_name, - 'information_url' => $podcastPerson->person->information_url, - 'thumbnail_url' => $podcastPerson->person->image->thumbnail_url, + $personsArray[$person->person->id] = [ + 'full_name' => $person->person->full_name, + 'information_url' => $person->person->information_url, + 'thumbnail_url' => $person->person->image->thumbnail_url, 'roles' => - empty($podcastPerson->person_group) || - empty($podcastPerson->person_role) + empty($person->person_group) || empty($person->person_role) ? '' : lang( 'PersonsTaxonomy.persons.' . - $podcastPerson->person_group . + $person->person_group . '.roles.' . - $podcastPerson->person_role . - '.label' - ), - ]; - } - } -} - -/** - * Fetches persons from an episode - * - * @param array $episode_persons - * @param array &$persons - */ -function construct_episode_person_array($episode_persons, &$persons) -{ - foreach ($episode_persons as $episodePerson) { - if (array_key_exists($episodePerson->person->id, $persons)) { - $persons[$episodePerson->person->id]['roles'] .= - empty($episodePerson->person_group) || - empty($episodePerson->person_role) - ? '' - : (empty($persons[$episodePerson->person->id]['roles']) - ? '' - : ', ') . - lang( - 'PersonsTaxonomy.persons.' . - $episodePerson->person_group . - '.roles.' . - $episodePerson->person_role . - '.label' - ); - } else { - $persons[$episodePerson->person->id] = [ - 'full_name' => $episodePerson->person->full_name, - 'information_url' => $episodePerson->person->information_url, - 'thumbnail_url' => $episodePerson->person->image->thumbnail_url, - 'roles' => - empty($episodePerson->person_group) || - empty($episodePerson->person_role) - ? '' - : lang( - 'PersonsTaxonomy.persons.' . - $episodePerson->person_group . - '.roles.' . - $episodePerson->person_role . - '.label' + $person->person_role . + '.label', ), ]; } diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 0da88a19..90767aae 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -165,7 +165,7 @@ function get_rss_feed($podcast, $serviceSlug = '') } } - foreach ($podcast->podcast_persons as $podcastPerson) { + foreach ($podcast->persons as $podcastPerson) { $podcastPersonElement = $channel->addChild( 'person', htmlspecialchars($podcastPerson->person->full_name), @@ -358,7 +358,7 @@ function get_rss_feed($podcast, $serviceSlug = '') $soundbiteElement->addAttribute('duration', $soundbite->duration); } - foreach ($episode->episode_persons as $episodePerson) { + foreach ($episode->persons as $episodePerson) { $episodePersonElement = $item->addChild( 'person', htmlspecialchars($episodePerson->person->full_name), diff --git a/app/Helpers/svg_helper.php b/app/Helpers/svg_helper.php index 9474c4a0..56e36ccf 100644 --- a/app/Helpers/svg_helper.php +++ b/app/Helpers/svg_helper.php @@ -20,7 +20,7 @@ function icon(string $name, string $class = '') $svg_contents = str_replace( '[a-zA-Z0-9\_]{1,32})\/episodes\/(?P[a-zA-Z0-9\-]{1,191})/', + $episodeUri->getPath(), + $matches + ); + + if ( + $matches && + array_key_exists('podcastName', $matches) && + array_key_exists('episodeSlug', $matches) + ) { + return [ + 'podcastName' => $matches['podcastName'], + 'episodeSlug' => $matches['episodeSlug'], + ]; + } + + return null; + } +} diff --git a/app/Language/en/ActivityPub.php b/app/Language/en/ActivityPub.php new file mode 100644 index 00000000..38b9573d --- /dev/null +++ b/app/Language/en/ActivityPub.php @@ -0,0 +1,34 @@ + 'Your handle', + 'your_handle_hint' => 'Enter the @username@domain you want to act from.', + 'follow' => [ + 'label' => 'Follow', + 'title' => 'Follow {actorDisplayName}', + 'subtitle' => 'You are going to follow:', + 'accountNotFound' => 'The account could not be found.', + 'submit' => 'Proceed to follow', + ], + 'favourite' => [ + 'title' => 'Favourite {actorDisplayName}\'s note', + 'subtitle' => 'You are going to favourite:', + 'submit' => 'Proceed to favourite', + ], + 'reblog' => [ + 'title' => 'Share {actorDisplayName}\'s note', + 'subtitle' => 'You are going to share:', + 'submit' => 'Proceed to share', + ], + 'reply' => [ + 'title' => 'Reply to {actorDisplayName}\'s note', + 'subtitle' => 'You are going to reply to:', + 'submit' => 'Proceed to reply', + ], +]; diff --git a/app/Language/en/Admin.php b/app/Language/en/Admin.php index 62a75a8e..5585b4ff 100644 --- a/app/Language/en/Admin.php +++ b/app/Language/en/Admin.php @@ -9,4 +9,5 @@ return [ 'dashboard' => 'Admin dashboard', 'welcome_message' => 'Welcome to the admin area!', + 'choose_interact' => 'Choose how to interact', ]; diff --git a/app/Language/en/AdminNavigation.php b/app/Language/en/AdminNavigation.php index aa36aabf..820767f4 100644 --- a/app/Language/en/AdminNavigation.php +++ b/app/Language/en/AdminNavigation.php @@ -17,6 +17,9 @@ return [ 'persons' => 'Persons', 'person-list' => 'All persons', 'person-create' => 'New person', + 'fediverse' => 'Fediverse', + 'fediverse-blocked-actors' => 'Blocked accounts', + 'fediverse-blocked-domains' => 'Blocked domains', 'users' => 'Users', 'user-list' => 'All users', 'user-create' => 'New user', diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php index ab1e511c..68f510f2 100644 --- a/app/Language/en/Breadcrumb.php +++ b/app/Language/en/Breadcrumb.php @@ -17,6 +17,11 @@ return [ 'new' => 'new', 'edit' => 'edit', 'persons' => 'persons', + 'publish' => 'publish', + 'publish-edit' => 'edit publication', + 'unpublish' => 'unpublish', + 'fediverse' => 'fediverse', + 'block-lists' => 'block lists', 'users' => 'users', 'my-account' => 'my account', 'change-password' => 'change password', diff --git a/app/Language/en/Common.php b/app/Language/en/Common.php index f1d101e1..d2a5966c 100644 --- a/app/Language/en/Common.php +++ b/app/Language/en/Common.php @@ -9,14 +9,18 @@ return [ 'yes' => 'Yes', 'no' => 'No', + 'cancel' => 'Cancel', 'optional' => 'Optional', + 'more' => 'More', 'no_data' => 'No data found!', + 'close' => 'Close', 'home' => 'Home', 'explicit' => 'Explicit', 'mediumDate' => '{0,date,medium}', 'powered_by' => 'Powered by {castopod}.', 'actions' => 'Actions', 'pageInfo' => 'Page {currentPage} out of {pageCount}', + 'go_back' => 'Go back', 'forms' => [ 'multiSelect' => [ 'selectText' => 'Press to select', diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php index 27f448e1..457df6fe 100644 --- a/app/Language/en/Episode.php +++ b/app/Language/en/Episode.php @@ -7,19 +7,33 @@ */ return [ - 'previous_episode' => 'Previous episode', - 'previous_season' => 'Previous season', - 'next_episode' => 'Next episode', - 'next_season' => 'Next season', 'season' => 'Season {seasonNumber}', 'season_abbr' => 'S{seasonNumber}', 'number' => 'Episode {episodeNumber}', 'number_abbr' => 'Ep. {episodeNumber}', 'season_episode' => 'Season {seasonNumber} episode {episodeNumber}', 'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}', + 'back_to_episodes' => 'Back to episodes of {podcast}', + 'activity' => 'Activity', + 'description' => 'Description', + 'total_favourites' => '{numberOfTotalFavourites, plural, + one {# total favourite} + other {# total favourites} + }', + 'total_reblogs' => '{numberOfTotalReblogs, plural, + one {# total share} + other {# total shares} + }', + 'total_notes' => '{numberOfTotalNotes, plural, + one {# note} + other {# total notes} + }', 'all_podcast_episodes' => 'All podcast episodes', 'back_to_podcast' => 'Go back to podcast', 'edit' => 'Edit', + 'publish' => 'Publish', + 'publish_edit' => 'Edit publication', + 'unpublish' => 'Unpublish', 'delete' => 'Delete', 'go_to_page' => 'Go to page', 'create' => 'Add an episode', @@ -51,19 +65,6 @@ return [ 'trailer' => 'Trailer', 'bonus' => 'Bonus', ], - 'show_notes_section_title' => 'Show notes', - 'show_notes_section_subtitle' => - 'Up to 4000 characters, be clear and concise. Show notes help potential listeners in finding the episode.', - 'description' => 'Description', - 'description_footer' => 'Description footer', - 'description_footer_hint' => - 'This text is added at the end of each episode description, it is a good place to input your social links for example.', - 'publication_section_title' => 'Publication info', - 'publication_section_subtitle' => '', - 'publication_date' => 'Publication date', - 'publication_date_clear' => 'Clear publication date', - 'publication_date_hint' => - 'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm', 'parental_advisory' => [ 'label' => 'Parental advisory', 'hint' => 'Does the episode contain explicit content?', @@ -71,30 +72,59 @@ return [ 'clean' => 'Clean', 'explicit' => 'Explicit', ], - 'block' => 'Episode should be hidden from all platforms', - 'block_hint' => - 'The episode show or hide status. If you want this episode removed from the Apple directory, toggle this on.', + 'show_notes_section_title' => 'Show notes', + 'show_notes_section_subtitle' => + 'Up to 4000 characters, be clear and concise. Show notes help potential listeners in finding the episode.', + 'description' => 'Description', + 'description_footer' => 'Description footer', + 'description_footer_hint' => + 'This text is added at the end of each episode description, it is a good place to input your social links for example.', 'additional_files_section_title' => 'Additional files', 'additional_files_section_subtitle' => 'These files may be used by other platforms to provide better experience to your audience.
See the {podcastNamespaceLink} for more information.', + 'location_section_title' => 'Location', + 'location_section_subtitle' => 'What place is this episode about?', + 'location_name' => 'Location name or address', + 'location_name_hint' => 'This can be a real or fictional location', 'transcript' => 'Transcript or closed captions', 'transcript_hint' => 'Allowed formats are txt, html, srt or json.', 'transcript_delete' => 'Delete transcript', 'chapters' => 'Chapters', 'chapters_hint' => 'File should be in JSON Chapters Format.', 'chapters_delete' => 'Delete chapters', - 'location_section_title' => 'Location', - 'location_section_subtitle' => 'What place is this episode about?', - 'location_name' => 'Location name or address', - 'location_name_hint' => 'This can be a real place or fictional', 'advanced_section_title' => 'Advanced Parameters', 'advanced_section_subtitle' => 'If you need RSS tags that Castopod does not handle, set them here.', 'custom_rss' => 'Custom RSS tags for the episode', 'custom_rss_hint' => 'This will be injected within the ❬item❭ tag.', + 'block' => 'Episode should be hidden from all platforms', + 'block_hint' => + 'The episode show or hide status. If you want this episode removed from the Apple directory, toggle this on.', 'submit_create' => 'Create episode', 'submit_edit' => 'Save episode', ], + 'publish_form' => [ + 'note' => 'Your note', + 'note_hint' => + 'The message you write will be broadcasted to all your followers in the fediverse.', + 'publication_date' => 'Publication date', + 'publication_method' => [ + 'now' => 'Now', + 'schedule' => 'Schedule', + ], + 'scheduled_publication_date' => 'Scheduled publication date', + 'scheduled_publication_date_clear' => 'Clear publication date', + 'scheduled_publication_date_hint' => + 'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm', + 'submit' => 'Publish', + 'submit_edit' => 'Edit publication', + ], + 'unpublish_form' => [ + 'disclaimer' => + 'Unpublishing the episode will delete all the notes associated with the episode and remove it from the podcast\'s RSS feed.', + 'understand' => 'I understand, I want to unpublish the episode', + 'submit' => 'Unpublish', + ], 'soundbites' => 'Soundbites', 'soundbites_form' => [ 'title' => 'Edit soundbites', diff --git a/app/Language/en/Fediverse.php b/app/Language/en/Fediverse.php new file mode 100644 index 00000000..17cd0d0b --- /dev/null +++ b/app/Language/en/Fediverse.php @@ -0,0 +1,23 @@ + 'Blocked accounts', + 'blocked_domains' => 'Blocked domains', + 'block_lists_form' => [ + 'handle' => 'Account handle', + 'handle_hint' => 'Input @username@domain account.', + 'domain' => 'Domain name', + 'submit' => 'Block!', + ], + 'list' => [ + 'actor' => 'Account', + 'domain' => 'Domain name', + 'unblock' => 'Unblock', + ], +]; diff --git a/app/Language/en/Note.php b/app/Language/en/Note.php new file mode 100644 index 00000000..ba759032 --- /dev/null +++ b/app/Language/en/Note.php @@ -0,0 +1,38 @@ + '{actorDisplayName}\'s Note', + 'back_to_actor_notes' => 'Back to {actor} notes', + 'actor_shared' => '{actor} shared', + 'reply_to' => 'Reply to @{actorUsername}', + 'form' => [ + 'message_placeholder' => 'Write a message...', + 'episode_message_placeholder' => 'Write a message for the episode...', + 'episode_url_placeholder' => 'Episode URL', + 'reply_to_placeholder' => 'Reply to @{actorUsername}', + 'submit' => 'Send!', + 'submit_reply' => 'Reply', + ], + 'favourites' => '{numberOfFavourites, plural, + one {# favourite} + other {# favourites} + }', + 'reblogs' => '{numberOfReblogs, plural, + one {# share} + other {# shares} + }', + 'replies' => '{numberOfReplies, plural, + one {# reply} + other {# replies} + }', + 'expand' => 'Expand note', + 'block_actor' => 'Block user @{actorUsername}', + 'block_domain' => 'Block domain @{actorDomain}', + 'delete' => 'Delete note', +]; diff --git a/app/Language/en/Page.php b/app/Language/en/Page.php index c15145b1..4ddf9602 100644 --- a/app/Language/en/Page.php +++ b/app/Language/en/Page.php @@ -7,6 +7,7 @@ */ return [ + 'back_to_home' => 'Back to home', 'page' => 'Page', 'all_pages' => 'All pages', 'create' => 'New page', @@ -21,6 +22,6 @@ return [ 'submit_edit' => 'Save', ], 'messages' => [ - 'createSuccess' => 'The {pageTitle} page was created successfully!', + 'createSuccess' => 'The page "{pageTitle}" was created successfully!', ], ]; diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php index 67f135b2..a80477b7 100644 --- a/app/Language/en/Podcast.php +++ b/app/Language/en/Podcast.php @@ -209,9 +209,26 @@ return [ ], 'by' => 'By {publisher}', 'season' => 'Season {seasonNumber}', - 'list_of_episodes_year' => '{year} episodes', - 'list_of_episodes_season' => 'Season {seasonNumber} episodes', + 'list_of_episodes_year' => '{year} episodes ({episodeCount})', + 'list_of_episodes_season' => + 'Season {seasonNumber} episodes ({episodeCount})', 'no_episode' => 'No episode found!', 'no_episode_hint' => 'Navigate the podcast episodes with the navigation bar above.', + 'follow' => 'Follow', + 'followers' => '{numberOfFollowers, plural, + one {# follower} + other {# followers} + }', + 'notes' => '{numberOfNotes, plural, + one {# note} + other {# notes} + }', + 'activity' => 'Activity', + 'episodes' => 'Episodes', + 'sponsor_title' => 'Enjoying the show?', + 'sponsor' => 'Sponsor', + 'funding_links' => 'Funding links for {podcastTitle}', + 'find_on' => 'Find {podcastTitle} on', + 'listen_on' => 'Listen on', ]; diff --git a/app/Language/en/PodcastNavigation.php b/app/Language/en/PodcastNavigation.php index 49062fe3..f577326d 100644 --- a/app/Language/en/PodcastNavigation.php +++ b/app/Language/en/PodcastNavigation.php @@ -14,6 +14,8 @@ return [ 'episodes' => 'Episodes', 'episode-list' => 'All episodes', 'episode-create' => 'New episode', + 'fediverse' => 'Fediverse', + 'fediverse-block_lists' => 'Block lists', 'analytics' => 'Analytics', 'persons' => 'Persons', 'podcast-person-manage' => 'Manage persons', diff --git a/app/Language/fr/ActivityPub.php b/app/Language/fr/ActivityPub.php new file mode 100644 index 00000000..b60d822d --- /dev/null +++ b/app/Language/fr/ActivityPub.php @@ -0,0 +1,35 @@ + 'Votre pseudonyme', + 'your_handle_hint' => + 'Entrez le @utilisateur@domaine avec lequel vous voulez interagir.', + 'follow' => [ + 'label' => 'Suivre', + 'title' => 'Suivre {actorDisplayName}', + 'subtitle' => 'Vous allez suivre :', + 'accountNotFound' => 'Le compte n’a pas pu être trouvé.', + 'submit' => 'Poursuivre', + ], + 'favourite' => [ + 'title' => 'Mettez la note de {actorDisplayName} en favori', + 'subtitle' => 'Vous allez mettre en favori :', + 'submit' => 'Poursuivre', + ], + 'reblog' => [ + 'title' => 'Partagez la note de {actorDisplayName}', + 'subtitle' => 'Vous allez partager :', + 'submit' => 'Poursuivre', + ], + 'reply' => [ + 'title' => 'Répondre à la note de {actorDisplayName}', + 'subtitle' => 'Vous allez répondre à :', + 'submit' => 'Poursuivre', + ], +]; diff --git a/app/Language/fr/AdminNavigation.php b/app/Language/fr/AdminNavigation.php index b22523f3..01188045 100644 --- a/app/Language/fr/AdminNavigation.php +++ b/app/Language/fr/AdminNavigation.php @@ -17,6 +17,9 @@ return [ 'persons' => 'Intervenants', 'person-list' => 'Tous les intervenants', 'person-create' => 'Nouvel intervenant', + 'fediverse' => 'Fédiverse', + 'fediverse-blocked_actors' => 'Utilisateurs blockés', + 'fediverse-blocked_domains' => 'Domaines blockés', 'users' => 'Utilisateurs', 'user-list' => 'Tous les utilisateurs', 'user-create' => 'Créer un utilisateur', diff --git a/app/Language/fr/Breadcrumb.php b/app/Language/fr/Breadcrumb.php index 179238e9..ba60f46f 100644 --- a/app/Language/fr/Breadcrumb.php +++ b/app/Language/fr/Breadcrumb.php @@ -17,6 +17,11 @@ return [ 'new' => 'créer', 'edit' => 'modifier', 'persons' => 'intervenants', + 'publish' => 'publier', + 'publish-edit' => 'modifier la publication', + 'unpublish' => 'dépublier', + 'fediverse' => 'fédiverse', + 'block-lists' => 'listes de blocage', 'users' => 'utilisateurs', 'my-account' => 'mon compte', 'change-password' => 'changer le mot de passe', diff --git a/app/Language/fr/Common.php b/app/Language/fr/Common.php index 59a9514e..48b599e0 100644 --- a/app/Language/fr/Common.php +++ b/app/Language/fr/Common.php @@ -9,14 +9,18 @@ return [ 'yes' => 'Oui', 'no' => 'Non', + 'cancel' => 'Annuler', 'optional' => 'Optionnel', + 'more' => 'Plus', 'no_data' => 'Aucune donnée trouvée !', + 'close' => 'Fermer', 'home' => 'Accueil', 'explicit' => 'Explicite', 'mediumDate' => '{0,date,medium}', 'powered_by' => 'Propulsé par {castopod}.', 'actions' => 'Actions', 'pageInfo' => 'Page {currentPage} sur {pageCount}', + 'go_back' => 'Retour en arrière', 'forms' => [ 'multiSelect' => [ 'selectText' => 'Cliquez pour selectionner', diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php index 70f233af..f9d38684 100644 --- a/app/Language/fr/Episode.php +++ b/app/Language/fr/Episode.php @@ -7,19 +7,33 @@ */ return [ - 'previous_episode' => 'Épisode précédent', - 'previous_season' => 'Saison précédente', - 'next_episode' => 'Épisode suivant', - 'next_season' => 'Saison suivante', 'season' => 'Saison {seasonNumber}', 'season_abbr' => 'S{seasonNumber}', 'number' => 'Épisode {episodeNumber}', 'number_abbr' => 'Ep. {episodeNumber}', 'season_episode' => 'Saison {seasonNumber} épisode {episodeNumber}', 'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}', + 'back_to_episodes' => 'Retour aux épisodes de {podcast}', + 'activity' => 'Activité', + 'description' => 'Description', + 'total_favourites' => '{numberOfTotalFavourites, plural, + one {# favori en tout} + other {# favoris en tout} + }', + 'total_reblogs' => '{numberOfTotalReblogs, plural, + one {# partage en tout} + other {# partages en tout} + }', + 'total_notes' => '{numberOfTotalNotes, plural, + one {# note} + other {# notes} + }', 'all_podcast_episodes' => 'Tous les épisodes du podcast', 'back_to_podcast' => 'Revenir au podcast', 'edit' => 'Modifier', + 'publish' => 'Publier', + 'publish_edit' => 'Modifier la publication', + 'unpublish' => 'Dépublier', 'delete' => 'Supprimer', 'go_to_page' => 'Voir', 'create' => 'Ajouter un épisode', @@ -51,19 +65,6 @@ return [ 'trailer' => 'Bande-annonce', 'bonus' => 'Bonus', ], - 'show_notes_section_title' => 'Notes d’épisode (Show Notes)', - 'show_notes_section_subtitle' => - 'Jusque 4000 caractères, soyez clairs et concis. Les notes d’épisode aident les auditeurs potentiels à le trouver.', - 'description' => 'Description', - 'description_footer' => 'Pied de description', - 'description_footer_hint' => - 'Ce texte est ajouté à la fin de chaque description d’épisode, c’est un bon endroit pour placer vos liens sociaux par exemple.', - 'publication_section_title' => 'Information de publication', - 'publication_section_subtitle' => '', - 'publication_date' => 'Date de publication', - 'publication_date_clear' => 'Effacer la date de publication', - 'publication_date_hint' => - 'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm', 'parental_advisory' => [ 'label' => 'Avertissement parental', 'hint' => 'L’épisode contient-il un contenu explicite ?', @@ -71,12 +72,20 @@ return [ 'clean' => 'Convenable', 'explicit' => 'Explicite', ], - 'block' => 'L’épisode doit être masqué de toutes les plateformes', - 'block_hint' => - 'La visibilité de l’épisode. Si vous souhaitez retirer cet épisode de l’index Apple, activez ce champ.', + 'show_notes_section_title' => 'Notes d’épisode (Show Notes)', + 'show_notes_section_subtitle' => + 'Jusque 4000 caractères, soyez clairs et concis. Les notes d’épisode aident les auditeurs potentiels à le trouver.', + 'description' => 'Description', + 'description_footer' => 'Pied de description', + 'description_footer_hint' => + 'Ce texte est ajouté à la fin de chaque description d’épisode, c’est un bon endroit pour placer vos liens sociaux par exemple.', 'additional_files_section_title' => 'Fichiers additionels', 'additional_files_section_subtitle' => 'Ces fichiers pourront être utilisées par d’autres plate-formes pour procurer une meilleure expérience à vos auditeurs.
Consulter le {podcastNamespaceLink} pour plus d’informations.', + 'location_section_title' => 'Localisation', + 'location_section_subtitle' => 'De quel lieu cet épisode parle-t-il ?', + 'location_name' => 'Nom ou adresse du lieu', + 'location_name_hint' => 'Ce lieu peut être réel ou fictif', 'transcript' => 'Transcription ou sous-titrage', 'transcript_hint' => 'Les formats autorisés sont txt, html, srt ou json.', @@ -84,18 +93,39 @@ return [ 'chapters' => 'Chapitrage', 'chapters_hint' => 'Le fichier doit être en "JSON Chapters Format".', 'chapters_delete' => 'Supprimer le chapitrage', - 'location_section_title' => 'Localisation', - 'location_section_subtitle' => 'De quel lieu cet épisode parle-t-il ?', - 'location_name' => 'Nom ou adresse du lieu', - 'location_name_hint' => 'Ce lieu peut être réel ou fictif', 'advanced_section_title' => 'Paramètres avancés', 'advanced_section_subtitle' => - 'Si vous avez besoin d’une balise que nous n’avons pas couverte, définissez-la ici.', + 'Si vous avez besoin d’une balise que Castopod ne couvre pas, définissez-la ici.', 'custom_rss' => 'Balises RSS personnalisées pour l’épisode', 'custom_rss_hint' => 'Ceci sera injecté dans la balise ❬item❭.', + 'block' => 'L’épisode doit être masqué de toutes les plateformes', + 'block_hint' => + 'La visibilité de l’épisode. Si vous souhaitez retirer cet épisode de l’index Apple, activez ce champ.', 'submit_create' => 'Créer l’épisode', 'submit_edit' => 'Enregistrer l’épisode', ], + 'publish_form' => [ + 'publication_date' => 'Date de publication', + 'publication_date_clear' => 'Effacer la date de publication', + 'publication_date_hint' => + 'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm', + ], + 'publish_form' => [ + 'note' => 'Votre note', + 'note_hint' => + 'Le message que vous écrirez sera diffusé à toutes les personnes qui vous suivent dans le fédiverse.', + 'publication_date' => 'Date de publication', + 'publication_method' => [ + 'now' => 'Maintenant', + 'schedule' => 'Planifier', + ], + 'scheduled_publication_date' => 'Date de publication programmée', + 'scheduled_publication_date_clear' => 'Effacer la date de publication', + 'scheduled_publication_date_hint' => + 'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm', + 'submit' => 'Publier', + 'submit_edit' => 'Modifier la publication', + ], 'soundbites' => 'Extraits sonores', 'soundbites_form' => [ 'title' => 'Modifier les extraits sonores', diff --git a/app/Language/fr/Fediverse.php b/app/Language/fr/Fediverse.php new file mode 100644 index 00000000..3d7da1af --- /dev/null +++ b/app/Language/fr/Fediverse.php @@ -0,0 +1,20 @@ + 'Listes de blocage', + 'block_lists_form' => [ + 'blocked_users' => 'Utilisateurs bloqués', + 'blocked_users_hint' => + 'Entrez les pseudonymes @utilisateur@domaine séparés par une virgule.', + 'blocked_domains' => 'Domaines bloqués', + 'blocked_domains_hint' => + 'Entrez les noms de domaine séparés par une virgule.', + 'submit' => 'Sauvegarder les listes', + ], +]; diff --git a/app/Language/fr/Note.php b/app/Language/fr/Note.php new file mode 100644 index 00000000..fe97031c --- /dev/null +++ b/app/Language/fr/Note.php @@ -0,0 +1,39 @@ + 'Note de {actorDisplayName}', + 'back_to_actor_notes' => 'Retour aux notes de {actor}', + 'actor_shared' => '{actor} a partagé', + 'reply_to' => 'Répondre à @{actorUsername}', + 'form' => [ + 'message_placeholder' => 'Écrivez votre message...', + 'episode_message_placeholder' => + 'Écrivez votre message pour l’épisode...', + 'episode_url_placeholder' => 'URL de l’épisode', + 'reply_to_placeholder' => 'Répondre à @{actorUsername}', + 'submit' => 'Envoyer!', + 'submit_reply' => 'Répondre', + ], + 'favourites' => '{numberOfFavourites, plural, + one {# favori} + other {# favoris} + }', + 'reblogs' => '{numberOfReblogs, plural, + one {# partage} + other {# partages} + }', + 'replies' => '{numberOfReplies, plural, + one {# réponse} + other {# réponses} + }', + 'expand' => 'Ouvrir la note', + 'block_actor' => 'Bloquer l’utilisateur @{actorUsername}', + 'block_domain' => 'Bloquer le domaine @{actorDomain}', + 'delete' => 'Supprimer la note', +]; diff --git a/app/Language/fr/Page.php b/app/Language/fr/Page.php index adc10995..05140602 100644 --- a/app/Language/fr/Page.php +++ b/app/Language/fr/Page.php @@ -7,6 +7,7 @@ */ return [ + 'back_to_home' => 'Retour à l’accueil', 'page' => 'Page', 'all_pages' => 'Toutes les pages', 'create' => 'Créer une page', diff --git a/app/Language/fr/Podcast.php b/app/Language/fr/Podcast.php index 7daaf771..3c4ef204 100644 --- a/app/Language/fr/Podcast.php +++ b/app/Language/fr/Podcast.php @@ -211,9 +211,26 @@ return [ ], 'by' => 'Par {publisher}', 'season' => 'Saison {seasonNumber}', - 'list_of_episodes_year' => 'épisodes {year}', - 'list_of_episodes_season' => 'Épisodes de la saison {seasonNumber}', + 'list_of_episodes_year' => 'Épisodes de {year} (episodeCount)', + 'list_of_episodes_season' => + 'Épisodes de la saison {seasonNumber} (episodeCount)', 'no_episode' => 'Aucun épisode trouvé !', 'no_episode_hint' => 'Naviguez au sein des épisodes du podcast episodes grâce à la barre de navigation ci-dessus.', + 'follow' => 'Suivre', + 'followers' => '{numberOfFollowers, plural, + one {# abonné·e} + other {# abonné·e·s} + }', + 'notes' => '{numberOfNotes, plural, + one {# note} + other {# notes} + }', + 'activity' => 'Activité', + 'episodes' => 'Épisodes', + 'sponsor_title' => 'Vous aimez le podcast ?', + 'sponsor' => 'Soutenez-nous', + 'funding_links' => 'Liens de financement pour {podcastTitle}', + 'find_on' => 'Trouvez {podcastTitle} sur', + 'listen_on' => 'Écoutez sur', ]; diff --git a/app/Language/fr/PodcastNavigation.php b/app/Language/fr/PodcastNavigation.php index 1b5414bc..77bf5041 100644 --- a/app/Language/fr/PodcastNavigation.php +++ b/app/Language/fr/PodcastNavigation.php @@ -14,6 +14,8 @@ return [ 'episodes' => 'Épisodes', 'episode-list' => 'Tous les épisodes', 'episode-create' => 'Créer un épisode', + 'fediverse' => 'Fédiverse', + 'fediverse-block_lists' => 'Listes de blocage', 'analytics' => 'Mesures d’audience', 'persons' => 'Intervenants', 'podcast-person-manage' => 'Gestion des intervenants', diff --git a/app/Libraries/ActivityPub/Activities/AcceptActivity.php b/app/Libraries/ActivityPub/Activities/AcceptActivity.php new file mode 100644 index 00000000..13c18021 --- /dev/null +++ b/app/Libraries/ActivityPub/Activities/AcceptActivity.php @@ -0,0 +1,24 @@ +actor = $reblogNote->actor->uri; + $this->object = $reblogNote->reblog_of_note->uri; + + $this->published = $reblogNote->published_at->format(DATE_W3C); + + $this->cc = [ + $reblogNote->actor->uri, + $reblogNote->actor->followers_url, + ]; + } +} diff --git a/app/Libraries/ActivityPub/Activities/CreateActivity.php b/app/Libraries/ActivityPub/Activities/CreateActivity.php new file mode 100644 index 00000000..bb479e04 --- /dev/null +++ b/app/Libraries/ActivityPub/Activities/CreateActivity.php @@ -0,0 +1,24 @@ + [ + 'Content-Type' => 'application/activity+json', + 'Accept' => 'application/activity+json', // TODO: outgoing and incoming requests + ], + ]; + + /** + * @param string $uri + * @param string $activityPayload + */ + public function __construct($uri, $activityPayload = null) + { + $this->request = \Config\Services::curlrequest(); + + if ($activityPayload) { + $this->request->setBody($activityPayload); + } + + $this->uri = new \CodeIgniter\HTTP\URI($uri); + } + + public function post() + { + // send Message to Fediverse instance + $this->request->post($this->uri, $this->options); + } + + public function get() + { + return $this->request->get($this->uri, $this->options); + } + + public function getDomain() + { + return $this->uri->getHost() . + ($this->uri->getPort() ? ':' . $this->uri->getPort() : ''); + } + + public function sign($keyId, $privateKey) + { + $rsa = new RSA(); + $rsa->loadKey($privateKey); // private key + $rsa->setHash('sha256'); + $rsa->setSignatureMode(RSA::SIGNATURE_PKCS1); + + $path = + $this->uri->getPath() . + ($this->uri->getQuery() ? "?{$this->uri->getQuery()}" : ''); + $host = $this->uri->getHost(); + $date = Time::now('GMT')->format('D, d M Y H:i:s T'); + $digest = 'SHA-256=' . base64_encode($this->getBodyDigest()); + $contentType = $this->options['headers']['Content-Type']; + $contentLength = strval(strlen($this->request->getBody())); + $userAgent = 'Castopod'; + + $plainText = "(request-target): post $path\nhost: $host\ndate: $date\ndigest: $digest\ncontent-type: $contentType\ncontent-length: $contentLength\nuser-agent: $userAgent"; + + $signature = $rsa->sign($plainText); + + $signatureHeader = + 'keyId="' . + $keyId . + '",algorithm="rsa-sha256",headers="(request-target) host date digest content-type content-length user-agent",signature="' . + base64_encode($signature) . + '"'; + + $this->options = [ + 'headers' => [ + 'Content-Type' => $contentType, + 'Content-Length' => $contentLength, + 'Authorization' => "Signature $signatureHeader", + 'Signature' => $signatureHeader, + 'Host' => $host, + 'Date' => $date, + 'User-Agent' => $userAgent, + 'Digest' => $digest, + ], + ]; + } + + protected function getBodyDigest() + { + return hash('sha256', $this->request->getBody(), true); + } +} diff --git a/app/Libraries/ActivityPub/Config/ActivityPub.php b/app/Libraries/ActivityPub/Config/ActivityPub.php new file mode 100644 index 00000000..199817af --- /dev/null +++ b/app/Libraries/ActivityPub/Config/ActivityPub.php @@ -0,0 +1,22 @@ +addPlaceholder('actorUsername', '[a-zA-Z0-9\_]{1,32}'); +$routes->addPlaceholder( + 'uuid', + '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}', +); +$routes->addPlaceholder('noteAction', '\bfavourite|\breblog|\breply'); + +/** + * ActivityPub routes file + */ + +$routes->group('', ['namespace' => 'ActivityPub\Controllers'], function ( + $routes +) { + // webfinger + $routes->get('.well-known/webfinger', 'WebFingerController', [ + 'as' => 'webfinger', + ]); + + // Actor + $routes->group('@(:actorUsername)', function ($routes) { + // Actor + $routes->get('/', 'ActorController/$1', [ + 'as' => 'actor', + ]); + $routes->post('inbox', 'ActorController::inbox/$1', [ + 'as' => 'inbox', + 'filter' => + 'activity-pub:verify-activitystream,verify-blocks,verify-signature', + ]); + $routes->get('outbox', 'ActorController::outbox/$1', [ + 'as' => 'outbox', + 'filter' => 'activity-pub:verify-activitystream', + ]); + $routes->get('followers', 'ActorController::followers/$1', [ + 'as' => 'followers', + 'filter' => 'activity-pub::activity-stream', + ]); + $routes->post('follow', 'ActorController::attemptFollow/$1', [ + 'as' => 'attempt-follow', + ]); + $routes->get('activities/(:uuid)', 'ActorController::activity/$1/$2', [ + 'as' => 'activity', + ]); + }); + + // Note + $routes->post('notes/new', 'NoteController::attemptCreate/$1', [ + 'as' => 'note-attempt-create', + ]); + + $routes->get('notes/(:uuid)', 'NoteController/$1', [ + 'as' => 'note', + ]); + + $routes->get('notes/(:uuid)/replies', 'NoteController/$1', [ + 'as' => 'note-replies', + ]); + + $routes->post( + 'notes/(:uuid)/remote/(:noteAction)', + 'NoteController::attemptRemoteAction/$1/$2/$3', + [ + 'as' => 'note-attempt-remote-action', + ], + ); + + // Blocking actors and domains + $routes->post( + 'fediverse-block-actor', + 'BlockController::attemptBlockActor', + ['as' => 'fediverse-attempt-block-actor'], + ); + + $routes->post( + 'fediverse-block-domain', + 'BlockController::attemptBlockDomain', + ['as' => 'fediverse-attempt-block-domain'], + ); + + $routes->post( + 'fediverse-unblock-actor', + 'BlockController::attemptUnblockActor', + [ + 'as' => 'fediverse-attempt-unblock-actor', + ], + ); + + $routes->post( + 'fediverse-unblock-domain', + 'BlockController::attemptUnblockDomain', + [ + 'as' => 'fediverse-attempt-unblock-domain', + ], + ); + + $routes->cli('scheduled-activities', 'SchedulerController::activity'); +}); diff --git a/app/Libraries/ActivityPub/Controllers/ActorController.php b/app/Libraries/ActivityPub/Controllers/ActorController.php new file mode 100644 index 00000000..e6cdc3a2 --- /dev/null +++ b/app/Libraries/ActivityPub/Controllers/ActorController.php @@ -0,0 +1,376 @@ +config = config('ActivityPub'); + } + + public function _remap($method, ...$params) + { + if (count($params) > 0) { + if ( + !($this->actor = model('ActorModel')->getActorByUsername( + $params[0], + )) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + unset($params[0]); + + return $this->$method(...$params); + } + + public function index() + { + $actorObjectClass = $this->config->actorObject; + $actorObject = new $actorObjectClass($this->actor); + + return $this->response + ->setContentType('application/activity+json') + ->setBody($actorObject->toJSON()); + } + + /** + * Handles incoming requests from fediverse servers + */ + public function inbox() + { + // get json body and parse it + $payload = $this->request->getJSON(); + + // retrieve payload actor from database or create it if it doesn't exist + $payloadActor = get_or_create_actor_from_uri($payload->actor); + + // store activity to database + $activityId = model('ActivityModel')->newActivity( + $payload->type, + $payloadActor->id, + $this->actor->id, + null, + json_encode($payload), + ); + + // switch/case on activity type + switch ($payload->type) { + case 'Create': + switch ($payload->object->type) { + case 'Note': + if (!$payload->object->inReplyTo) { + return $this->response + ->setStatusCode(501) + ->setJSON([]); + } + + $replyToNote = model('NoteModel')->getNoteByUri( + $payload->object->inReplyTo, + ); + + // TODO: strip content from html to retrieve message + // remove all html tags and reconstruct message with mentions? + extract_text_from_html($payload->object->content); + + $reply = new \ActivityPub\Entities\Note([ + 'uri' => $payload->object->id, + 'actor_id' => $payloadActor->id, + 'in_reply_to_id' => $replyToNote->id, + 'message' => $payload->object->content, + 'published_at' => Time::parse( + $payload->object->published, + ), + ]); + + $noteId = model('NoteModel')->addReply( + $reply, + true, + false, + ); + + model('ActivityModel')->update($activityId, [ + 'note_id' => service('uuid') + ->fromBytes($noteId) + ->getString(), + ]); + + return $this->response->setStatusCode(200)->setJSON([]); + default: + // return not handled undo error (501 = not implemented) + return $this->response->setStatusCode(501)->setJSON([]); + } + break; + case 'Delete': + $noteToDelete = model('NoteModel')->getNoteByUri( + $payload->object->id, + ); + + model('NoteModel')->removeNote($noteToDelete, false); + + return $this->response->setStatusCode(200)->setJSON([]); + case 'Follow': + // add to followers table + model('FollowModel')->addFollower( + $payloadActor, + $this->actor, + false, + ); + + // Automatically accept follow by returning accept activity + accept_follow($this->actor, $payloadActor, $payload->id); + + // TODO: return 202 (Accepted) followed! + return $this->response->setStatusCode(202)->setJSON([]); + + case 'Like': + // get favourited note + $note = model('NoteModel')->getNoteByUri($payload->object); + + // Like side-effect + model('FavouriteModel')->addFavourite( + $payloadActor, + $note, + false, + ); + + model('ActivityModel')->update($activityId, [ + 'note_id' => $note->id, + ]); + + return $this->response->setStatusCode(200)->setJSON([]); + case 'Announce': + $note = model('NoteModel')->getNoteByUri($payload->object); + + model('ActivityModel')->update($activityId, [ + 'note_id' => $note->id, + ]); + + model('NoteModel')->reblog($payloadActor, $note, false); + + return $this->response->setStatusCode(200)->setJSON([]); + case 'Undo': + // switch/case on the type of activity to undo + switch ($payload->object->type) { + case 'Follow': + // revert side-effect by removing follow from database + model('FollowModel')->removeFollower( + $payloadActor, + $this->actor, + false, + ); + + // TODO: undo has been accepted! (202 - Accepted) + return $this->response->setStatusCode(202)->setJSON([]); + case 'Like': + $note = model('NoteModel')->getNoteByUri( + $payload->object->object, + ); + + // revert side-effect by removing favourite from database + model('FavouriteModel')->removeFavourite( + $payloadActor, + $note, + false, + ); + + model('ActivityModel')->update($activityId, [ + 'note_id' => $note->id, + ]); + + return $this->response->setStatusCode(200)->setJSON([]); + case 'Announce': + $note = model('NoteModel')->getNoteByUri( + $payload->object->object, + ); + + $reblogNote = model('NoteModel') + ->where([ + 'actor_id' => $payloadActor->id, + 'reblog_of_id' => service('uuid') + ->fromString($note->id) + ->getBytes(), + ]) + ->first(); + + model('NoteModel')->undoReblog($reblogNote, false); + + model('ActivityModel')->update($activityId, [ + 'note_id' => $note->id, + ]); + + return $this->response->setStatusCode(200)->setJSON([]); + default: + // return not handled undo error (501 = not implemented) + return $this->response->setStatusCode(501)->setJSON([]); + } + default: + // return not handled activity error (501 = not implemented) + return $this->response->setStatusCode(501)->setJSON([]); + } + } + + public function outbox() + { + // get published activities by publication date + $actorActivity = model('ActivityModel') + ->where('actor_id', $this->actor->id) + ->where('`created_at` <= NOW()', null, false) + ->orderBy('created_at', 'DESC'); + + $pageNumber = $this->request->getGet('page'); + + if (!isset($pageNumber)) { + $actorActivity->paginate(12); + $pager = $actorActivity->pager; + $collection = new OrderedCollectionObject(null, $pager); + } else { + $paginatedActivity = $actorActivity->paginate( + 12, + 'default', + $pageNumber, + ); + $pager = $actorActivity->pager; + $orderedItems = []; + foreach ($paginatedActivity as $activity) { + array_push($orderedItems, $activity->payload); + } + $collection = new OrderedCollectionPage($pager, $orderedItems); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody($collection->toJSON()); + } + + public function followers() + { + // get followers for a specific actor + $followers = model('ActorModel') + ->join( + 'activitypub_follows', + 'activitypub_follows.actor_id = id', + 'inner', + ) + ->where('activitypub_follows.target_actor_id', $this->actor->id) + ->orderBy('activitypub_follows.created_at', 'DESC'); + + $pageNumber = $this->request->getGet('page'); + + if (!isset($pageNumber)) { + $followers->paginate(12); + $pager = $followers->pager; + $followersCollection = new OrderedCollectionObject(null, $pager); + } else { + $paginatedFollowers = $followers->paginate( + 12, + 'default', + $pageNumber, + ); + $pager = $followers->pager; + + $orderedItems = []; + foreach ($paginatedFollowers as $follower) { + array_push($orderedItems, $follower->uri); + } + $followersCollection = new OrderedCollectionPage( + $pager, + $orderedItems, + ); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody($followersCollection->toJSON()); + } + + public function attemptFollow() + { + $rules = [ + 'handle' => + 'regex_match[/^@?(?P[\w\.\-]+)@(?P[\w\.\-]+)(?P:[\d]+)?$/]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + helper('text'); + + // get webfinger data from actor + // parse activityPub id to get actor and domain + // check if actor and domain exist + + try { + if ($parts = split_handle($this->request->getPost('handle'))) { + extract($parts); + + $data = get_webfinger_data($username, $domain); + } + } catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) { + return redirect() + ->back() + ->withInput() + ->with('error', lang('ActivityPub.follow.accountNotFound')); + } + + $ostatusKey = array_search( + 'http://ostatus.org/schema/1.0/subscribe', + array_column($data->links, 'rel'), + ); + + if (!$ostatusKey) { + // TODO: error, couldn't subscribe to activitypub account + // The instance doesn't allow its users to follow others + return $this->response->setJSON([]); + } + + return redirect()->to( + str_replace( + '{uri}', + urlencode($this->actor->uri), + $data->links[$ostatusKey]->template, + ), + ); + } + + public function activity($activityId) + { + if ( + !($activity = model('ActivityModel')->getActivityById($activityId)) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody(json_encode($activity->payload)); + } +} diff --git a/app/Libraries/ActivityPub/Controllers/BlockController.php b/app/Libraries/ActivityPub/Controllers/BlockController.php new file mode 100644 index 00000000..87ffe0bb --- /dev/null +++ b/app/Libraries/ActivityPub/Controllers/BlockController.php @@ -0,0 +1,105 @@ + 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $handle = $this->request->getPost('handle'); + + if ($parts = split_handle($handle)) { + extract($parts); + + if (!($actor = get_or_create_actor($username, $domain))) { + return redirect() + ->back() + ->withInput() + ->with('error', 'Actor not found.'); + } + + model('ActorModel')->blockActor($actor->id); + } + + return redirect()->back(); + } + + function attemptBlockDomain() + { + $rules = [ + 'domain' => 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + model('BlockedDomainModel')->blockDomain( + $this->request->getPost('domain'), + ); + + return redirect()->back(); + } + + function attemptUnblockActor() + { + $rules = [ + 'actor_id' => 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + model('ActorModel')->unblockActor($this->request->getPost('actor_id')); + + return redirect()->back(); + } + + function attemptUnblockDomain() + { + $rules = [ + 'domain' => 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + model('BlockedDomainModel')->unblockDomain( + $this->request->getPost('domain'), + ); + + return redirect()->back(); + } +} diff --git a/app/Libraries/ActivityPub/Controllers/NoteController.php b/app/Libraries/ActivityPub/Controllers/NoteController.php new file mode 100644 index 00000000..048beaa0 --- /dev/null +++ b/app/Libraries/ActivityPub/Controllers/NoteController.php @@ -0,0 +1,278 @@ +config = config('ActivityPub'); + } + + public function _remap($method, ...$params) + { + if (!($this->note = model('NoteModel')->getNoteById($params[0]))) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + unset($params[0]); + + return $this->$method(...$params); + } + + public function index() + { + $noteObjectClass = $this->config->noteObject; + $noteObject = new $noteObjectClass($this->note); + + return $this->response + ->setContentType('application/activity+json') + ->setBody($noteObject->toJSON()); + } + + public function replies() + { + // get note replies + $noteReplies = model('NoteModel') + ->where( + 'in_reply_to_id', + service('uuid') + ->fromString($this->note->id) + ->getBytes(), + ) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'ASC'); + + $pageNumber = $this->request->getGet('page'); + + if (!isset($pageNumber)) { + $noteReplies->paginate(12); + $pager = $noteReplies->pager; + $collection = new OrderedCollectionObject(null, $pager); + } else { + $paginatedReplies = $noteReplies->paginate( + 12, + 'default', + $pageNumber, + ); + $pager = $noteReplies->pager; + + $orderedItems = []; + $noteObjectClass = $this->config->noteObject; + foreach ($paginatedReplies as $reply) { + $replyObject = new $noteObjectClass($reply); + array_push($orderedItems, $replyObject->toJSON()); + } + $collection = new OrderedCollectionPage($pager, $orderedItems); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody($collection->toJSON()); + } + + public function attemptCreate() + { + $rules = [ + 'actor_id' => 'required|is_natural_no_zero', + 'message' => 'required|max_length[500]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $newNote = new \ActivityPub\Entities\Note([ + 'actor_id' => $this->request->getPost('actor_id'), + 'message' => $this->request->getPost('message'), + 'published_at' => Time::now(), + ]); + + if (!model('NoteModel')->addNote($newNote)) { + return redirect() + ->back() + ->withInput() + // TODO: translate + ->with('error', 'Couldn\'t create Note'); + } + + // Note without preview card has been successfully created + return redirect()->back(); + } + + public function attemptFavourite() + { + $rules = [ + 'actor_id' => 'required|is_natural_no_zero', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $actor = model('ActorModel')->getActorById( + $this->request->getPost('actor_id'), + ); + + model('FavouriteModel')->toggleFavourite($actor, $this->note->id); + + return redirect()->back(); + } + + public function attemptReblog() + { + $rules = [ + 'actor_id' => 'required|is_natural_no_zero', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $actor = model('ActorModel')->getActorById( + $this->request->getPost('actor_id'), + ); + + model('NoteModel')->toggleReblog($actor, $this->note); + + return redirect()->back(); + } + + public function attemptReply() + { + $rules = [ + 'actor_id' => 'required|is_natural_no_zero', + 'message' => 'required|max_length[500]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $newReplyNote = new \ActivityPub\Entities\Note([ + 'actor_id' => $this->request->getPost('actor_id'), + 'in_reply_to_id' => $this->note->id, + 'message' => $this->request->getPost('message'), + 'published_at' => Time::now(), + ]); + + if (!model('NoteModel')->addReply($newReplyNote)) { + return redirect() + ->back() + ->withInput() + // TODO: translate + ->with('error', 'Couldn\'t create Reply'); + } + + // Reply note without preview card has been successfully created + return redirect()->back(); + } + + public function attemptRemoteAction($action) + { + $rules = [ + 'handle' => + 'regex_match[/^@?(?P[\w\.\-]+)@(?P[\w\.\-]+)(?P:[\d]+)?$/]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + helper('text'); + + // get webfinger data from actor + // parse activityPub id to get actor and domain + // check if actor and domain exist + try { + if ($parts = split_handle($this->request->getPost('handle'))) { + extract($parts); + + $data = get_webfinger_data($username, $domain); + } + } catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) { + return redirect() + ->back() + ->withInput() + ->with('error', lang('ActivityPub.follow.accountNotFound')); + } + + $ostatusKey = array_search( + 'http://ostatus.org/schema/1.0/subscribe', + array_column($data->links, 'rel'), + ); + + if (!$ostatusKey) { + // TODO: error, couldn't remote favourite/share/reply to note + // The instance doesn't allow its users remote actions on notes + return $this->response->setJSON([]); + } + + return redirect()->to( + str_replace( + '{uri}', + urlencode($this->note->uri), + $data->links[$ostatusKey]->template, + ), + ); + } + + public function attemptBlockActor() + { + model('ActorModel')->blockActor($this->note->actor->id); + + return redirect()->back(); + } + + public function attemptBlockDomain() + { + model('BlockedDomainModel')->blockDomain($this->note->actor->domain); + + return redirect()->back(); + } + + public function attemptDelete() + { + model('NoteModel', false)->removeNote($this->note); + + return redirect()->back(); + } +} diff --git a/app/Libraries/ActivityPub/Controllers/SchedulerController.php b/app/Libraries/ActivityPub/Controllers/SchedulerController.php new file mode 100644 index 00000000..617290ae --- /dev/null +++ b/app/Libraries/ActivityPub/Controllers/SchedulerController.php @@ -0,0 +1,36 @@ +getScheduledActivities(); + + // Send activity to all followers + foreach ($scheduledActivities as $scheduledActivity) { + // send activity to all actor followers + send_activity_to_followers( + $scheduledActivity->actor, + json_encode($scheduledActivity->payload), + ); + + // set activity status to delivered + model('ActivityModel')->update($scheduledActivity->id, [ + 'status' => 'delivered', + ]); + } + } +} diff --git a/app/Libraries/ActivityPub/Controllers/WebFingerController.php b/app/Libraries/ActivityPub/Controllers/WebFingerController.php new file mode 100644 index 00000000..df671370 --- /dev/null +++ b/app/Libraries/ActivityPub/Controllers/WebFingerController.php @@ -0,0 +1,28 @@ +request->getGet('resource')); + } catch (Exception $e) { + // return 404, actor not found + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + + return $this->response->setJSON($webfinger->toArray()); + } +} diff --git a/app/Libraries/ActivityPub/Core/AbstractObject.php b/app/Libraries/ActivityPub/Core/AbstractObject.php new file mode 100644 index 00000000..d25c9db6 --- /dev/null +++ b/app/Libraries/ActivityPub/Core/AbstractObject.php @@ -0,0 +1,50 @@ +$property = $value; + + return $this; + } + + public function toArray() + { + $objectVars = get_object_vars($this); + $array = []; + foreach ($objectVars as $key => $value) { + if ($key === 'context') { + $key = '@context'; + } + if (is_object($value) && $value instanceof self) { + $array[$key] = $value->toArray(); + } else { + $array[$key] = $value; + } + } + + // removes all NULL, FALSE and Empty Strings but leaves 0 (zero) values + return array_filter($array, function ($value) { + return $value !== null && $value !== false && $value !== ''; + }); + } + + public function toJSON() + { + return json_encode($this->toArray(), JSON_UNESCAPED_UNICODE); + } +} diff --git a/app/Libraries/ActivityPub/Core/Activity.php b/app/Libraries/ActivityPub/Core/Activity.php new file mode 100644 index 00000000..5219bbfc --- /dev/null +++ b/app/Libraries/ActivityPub/Core/Activity.php @@ -0,0 +1,32 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'uri' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'username' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + ], + 'domain' => [ + 'type' => 'VARCHAR', + 'constraint' => 191, + ], + 'private_key' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'public_key' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'display_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, + ], + 'summary' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'avatar_image_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + // constraint is 13 because the longest safe mimetype for images is image/svg+xml, + // see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#image_types + 'avatar_image_mimetype' => [ + 'type' => 'VARCHAR', + 'constraint' => 13, + ], + 'cover_image_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'cover_image_mimetype' => [ + 'type' => 'VARCHAR', + 'constraint' => 13, + 'null' => true, + ], + 'inbox_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'outbox_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'followers_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'followers_count' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'notes_count' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'is_blocked' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + 'updated_at' => [ + 'type' => 'DATETIME', + ], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addUniqueKey('uri'); + $this->forge->addUniqueKey(['username', 'domain']); + $this->forge->createTable('activitypub_actors'); + } + + public function down() + { + $this->forge->dropTable('activitypub_actors'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_notes.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_notes.php new file mode 100644 index 00000000..22f42938 --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-020000_add_notes.php @@ -0,0 +1,108 @@ +forge->addField([ + 'id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + ], + 'uri' => [ + 'type' => 'VARCHAR', + 'constraint' => 191, + ], + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'in_reply_to_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + 'null' => true, + ], + 'reblog_of_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + 'null' => true, + ], + 'message' => [ + 'type' => 'VARCHAR', + 'constraint' => 500, + 'null' => true, + ], + 'message_html' => [ + 'type' => 'VARCHAR', + 'constraint' => 600, + 'null' => true, + ], + 'favourites_count' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'reblogs_count' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'replies_count' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'published_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addUniqueKey('uri'); + // FIXME: an actor must reblog a note only once + // $this->forge->addUniqueKey(['actor_id', 'reblog_of_id']); + $this->forge->addForeignKey( + 'actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'in_reply_to_id', + 'activitypub_notes', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'reblog_of_id', + 'activitypub_notes', + 'id', + false, + 'CASCADE', + ); + $this->forge->createTable('activitypub_notes'); + } + + public function down() + { + $this->forge->dropTable('activitypub_notes'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php new file mode 100644 index 00000000..11555bf1 --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_activities.php @@ -0,0 +1,90 @@ +forge->addField([ + 'id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + ], + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'target_actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + ], + 'note_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + 'null' => true, + ], + 'type' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'payload' => [ + 'type' => 'JSON', + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['queued', 'delivered'], + 'null' => true, + 'default' => null, + ], + 'scheduled_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'default' => null, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + ]); + $this->forge->addPrimaryKey('id'); + $this->forge->addForeignKey( + 'actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'target_actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'note_id', + 'activitypub_notes', + 'id', + false, + 'CASCADE', + ); + $this->forge->createTable('activitypub_activities'); + } + + public function down() + { + $this->forge->dropTable('activitypub_activities'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php new file mode 100644 index 00000000..d76d5452 --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_favourites.php @@ -0,0 +1,55 @@ +forge->addField([ + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'note_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + ], + ]); + $this->forge->addField( + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', + ); + $this->forge->addPrimaryKey(['actor_id', 'note_id']); + $this->forge->addForeignKey( + 'actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'note_id', + 'activitypub_notes', + 'id', + false, + 'CASCADE', + ); + $this->forge->createTable('activitypub_favourites'); + } + + public function down() + { + $this->forge->dropTable('activitypub_favourites'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php new file mode 100644 index 00000000..f3145f4f --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_follows.php @@ -0,0 +1,57 @@ +forge->addField([ + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'comment' => 'Actor that is following', + ], + 'target_actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'comment' => 'Actor that is followed', + ], + ]); + $this->forge->addField( + '`created_at` timestamp NOT NULL DEFAULT current_timestamp()', + ); + $this->forge->addPrimaryKey(['actor_id', 'target_actor_id']); + $this->forge->addForeignKey( + 'actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'target_actor_id', + 'activitypub_actors', + 'id', + false, + 'CASCADE', + ); + $this->forge->createTable('activitypub_follows'); + } + + public function down() + { + $this->forge->dropTable('activitypub_follows'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php new file mode 100644 index 00000000..eaa07eb4 --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-100000_add_preview_cards.php @@ -0,0 +1,82 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'url' => [ + 'type' => 'VARCHAR', + 'constraint' => 512, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, + ], + 'description' => ['type' => 'TEXT'], + 'type' => [ + 'type' => 'ENUM', + 'constraint' => ['link', 'video', 'image', 'rich'], + 'default' => 'link', + ], + 'author_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + 'null' => true, + ], + 'author_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'provider_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'provider_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'image' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'html' => [ + 'type' => 'TEXT', + ], + 'updated_at' => [ + 'type' => 'DATETIME', + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + ]); + + $this->forge->addPrimaryKey('id'); + $this->forge->addUniqueKey('url'); + $this->forge->createTable('activitypub_preview_cards'); + } + + public function down() + { + $this->forge->dropTable('activitypub_preview_cards'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_notes_preview_cards.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_notes_preview_cards.php new file mode 100644 index 00000000..25fd22f9 --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-110000_add_notes_preview_cards.php @@ -0,0 +1,53 @@ +forge->addField([ + 'note_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + ], + 'preview_card_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + ]); + + $this->forge->addPrimaryKey(['note_id', 'preview_card_id']); + $this->forge->addForeignKey( + 'note_id', + 'activitypub_notes', + 'id', + false, + 'CASCADE', + ); + $this->forge->addForeignKey( + 'preview_card_id', + 'activitypub_preview_cards', + 'id', + false, + 'CASCADE', + ); + $this->forge->createTable('activitypub_notes_preview_cards'); + } + + public function down() + { + $this->forge->dropTable('activitypub_notes_preview_cards'); + } +} diff --git a/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php new file mode 100644 index 00000000..3b136dca --- /dev/null +++ b/app/Libraries/ActivityPub/Database/Migrations/2018-01-01-120000_add_blocked_domains.php @@ -0,0 +1,37 @@ +forge->addField([ + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 191, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + ]); + $this->forge->addPrimaryKey('name'); + $this->forge->createTable('activitypub_blocked_domains'); + } + + public function down() + { + $this->forge->dropTable('activitypub_blocked_domains'); + } +} diff --git a/app/Libraries/ActivityPub/Entities/Activity.php b/app/Libraries/ActivityPub/Entities/Activity.php new file mode 100644 index 00000000..14a7ed0d --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/Activity.php @@ -0,0 +1,99 @@ + 'string', + 'actor_id' => 'integer', + 'target_actor_id' => '?integer', + 'note_id' => '?string', + 'type' => 'string', + 'payload' => 'json', + 'status' => '?string', + ]; + + /** + * @return \ActivityPub\Entities\Actor + */ + public function getActor() + { + if (empty($this->actor_id)) { + throw new \RuntimeException( + 'Activity must have an actor_id before getting the actor.', + ); + } + + if (empty($this->actor)) { + $this->actor = model('ActorModel')->getActorById($this->actor_id); + } + + return $this->actor; + } + + /** + * @return \ActivityPub\Entities\Actor + */ + public function getTargetActor() + { + if (empty($this->target_actor_id)) { + throw new \RuntimeException( + 'Activity must have a target_actor_id before getting the target actor.', + ); + } + + if (empty($this->target_actor)) { + $this->target_actor = model('ActorModel')->getActorById( + $this->target_actor_id, + ); + } + + return $this->target_actor; + } + + /** + * @return \ActivityPub\Entities\Note + */ + public function getNote() + { + if (empty($this->note_id)) { + throw new \RuntimeException( + 'Activity must have a note_id before getting note.', + ); + } + + if (empty($this->note)) { + $this->note = model('NoteModel')->getNoteById($this->note_id); + } + + return $this->note; + } +} diff --git a/app/Libraries/ActivityPub/Entities/Actor.php b/app/Libraries/ActivityPub/Entities/Actor.php new file mode 100644 index 00000000..2d8d769d --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/Actor.php @@ -0,0 +1,84 @@ + 'integer', + 'uri' => 'string', + 'username' => 'string', + 'domain' => 'string', + 'display_name' => 'string', + 'summary' => '?string', + 'private_key' => '?string', + 'public_key' => '?string', + 'avatar_image_url' => 'string', + 'avatar_image_mimetype' => 'string', + 'cover_image_url' => '?string', + 'cover_image_mimetype' => '?string', + 'inbox_url' => 'string', + 'outbox_url' => '?string', + 'followers_url' => '?string', + 'followers_count' => 'integer', + 'notes_count' => 'integer', + 'is_blocked' => 'boolean', + ]; + + public function getKeyId() + { + return $this->uri . '#main-key'; + } + + public function getIsLocal() + { + if (!$this->is_local) { + $uri = current_url(true); + + $this->is_local = + $this->domain === + $uri->getHost() . + ($uri->getPort() ? ':' . $uri->getPort() : ''); + } + + return $this->is_local; + } + + public function getFollowers() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Actor must be created before getting followers.', + ); + } + + if (empty($this->followers)) { + $this->followers = model('ActorModel')->getFollowers($this->id); + } + + return $this->followers; + } +} diff --git a/app/Libraries/ActivityPub/Entities/BlockedDomain.php b/app/Libraries/ActivityPub/Entities/BlockedDomain.php new file mode 100644 index 00000000..bf609e7c --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/BlockedDomain.php @@ -0,0 +1,18 @@ + 'string', + ]; +} diff --git a/app/Libraries/ActivityPub/Entities/Favourite.php b/app/Libraries/ActivityPub/Entities/Favourite.php new file mode 100644 index 00000000..759448d4 --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/Favourite.php @@ -0,0 +1,21 @@ + 'integer', + 'note_id' => 'integer', + ]; +} diff --git a/app/Libraries/ActivityPub/Entities/Follow.php b/app/Libraries/ActivityPub/Entities/Follow.php new file mode 100644 index 00000000..dea45eed --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/Follow.php @@ -0,0 +1,19 @@ + 'integer', + 'target_actor_id' => 'integer', + ]; +} diff --git a/app/Libraries/ActivityPub/Entities/Note.php b/app/Libraries/ActivityPub/Entities/Note.php new file mode 100644 index 00000000..9e1736d0 --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/Note.php @@ -0,0 +1,200 @@ + 'string', + 'uri' => 'string', + 'actor_id' => 'integer', + 'in_reply_to_id' => '?string', + 'reblog_of_id' => '?string', + 'message' => 'string', + 'message_html' => 'string', + 'favourites_count' => 'integer', + 'reblogs_count' => 'integer', + 'replies_count' => 'integer', + ]; + + /** + * Returns the note's actor + * + * @return \ActivityPub\Entities\Actor + */ + public function getActor() + { + if (empty($this->actor_id)) { + throw new \RuntimeException( + 'Note must have an actor_id before getting actor.', + ); + } + + if (empty($this->actor)) { + $this->actor = model('ActorModel')->getActorById($this->actor_id); + } + + return $this->actor; + } + + public function getPreviewCard() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Note must be created before getting preview_card.', + ); + } + + if (empty($this->preview_card)) { + $this->preview_card = model('PreviewCardModel')->getNotePreviewCard( + $this->id, + ); + } + + return $this->preview_card; + } + + public function getReplies() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Note must be created before getting replies.', + ); + } + + if (empty($this->replies)) { + $this->replies = model('NoteModel')->getNoteReplies($this->id); + } + + return $this->replies; + } + + public function getIsReply() + { + $this->is_reply = $this->in_reply_to_id !== null; + + return $this->is_reply; + } + + public function getReplyToNote() + { + if (empty($this->in_reply_to_id)) { + throw new \RuntimeException('Note is not a reply.'); + } + + if (empty($this->reply_to_note)) { + $this->reply_to_note = model('NoteModel')->getNoteById( + $this->in_reply_to_id, + ); + } + + return $this->reply_to_note; + } + + public function getReblogs() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Note must be created before getting reblogs.', + ); + } + + if (empty($this->reblogs)) { + $this->reblogs = model('NoteModel')->getNoteReblogs( + service('uuid') + ->fromString($this->id) + ->getBytes(), + ); + } + + return $this->reblogs; + } + + public function getIsReblog() + { + return $this->reblog_of_id != null; + } + + public function getReblogOfNote() + { + if (empty($this->reblog_of_id)) { + throw new \RuntimeException('Note is not a reblog.'); + } + + if (empty($this->reblog_of_note)) { + $this->reblog_of_note = model('NoteModel')->getNoteById( + $this->reblog_of_id, + ); + } + + return $this->reblog_of_note; + } + + public function setMessage(string $message) + { + helper('activitypub'); + + $messageWithoutTags = strip_tags($message); + + $this->attributes['message'] = $messageWithoutTags; + $this->attributes['message_html'] = str_replace( + "\n", + '
', + linkify($messageWithoutTags), + ); + + return $this; + } +} diff --git a/app/Libraries/ActivityPub/Entities/PreviewCard.php b/app/Libraries/ActivityPub/Entities/PreviewCard.php new file mode 100644 index 00000000..cd3523c4 --- /dev/null +++ b/app/Libraries/ActivityPub/Entities/PreviewCard.php @@ -0,0 +1,29 @@ + 'integer', + 'note_id' => 'string', + 'url' => 'string', + 'title' => 'string', + 'description' => 'string', + 'type' => 'string', + 'author_name' => '?string', + 'author_url' => '?string', + 'provider_name' => '?string', + 'provider_url' => '?string', + 'image' => '?string', + 'html' => '?string', + ]; +} diff --git a/app/Libraries/ActivityPub/Filters/ActivityPubFilter.php b/app/Libraries/ActivityPub/Filters/ActivityPubFilter.php new file mode 100644 index 00000000..bf9d8708 --- /dev/null +++ b/app/Libraries/ActivityPub/Filters/ActivityPubFilter.php @@ -0,0 +1,99 @@ +media($allowedContentTypes))) { + // return $this->response->setStatusCode(415)->setJSON([]); + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + + if (in_array('verify-blocks', $params)) { + $payload = $request->getJSON(); + + $actorUri = $payload->actor; + $domain = (new URI($actorUri))->getHost(); + + // check first if domain is blocked + if (model('BlockedDomainModel')->isDomainBlocked($domain)) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + + // check if actor is blocked + if (model('ActorModel')->isActorBlocked($actorUri)) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + + if (in_array('verify-signature', $params)) { + try { + // securityCheck: check activity signature before handling it + (new HttpSignature())->verify(); + } catch (\Exception $e) { + // Invalid HttpSignature (401 = unauthorized) + // TODO: show error message? + return service('response')->setStatusCode(401); + } + } + } + + //-------------------------------------------------------------------- + + /** + * Allows After filters to inspect and modify the response + * object as needed. This method does not allow any way + * to stop execution of other after filters, short of + * throwing an Exception or Error. + * + * @param \CodeIgniter\HTTP\RequestInterface $request + * @param \CodeIgniter\HTTP\ResponseInterface $response + * @param array|null $arguments + * + * @return void + */ + public function after( + RequestInterface $request, + ResponseInterface $response, + $arguments = null + ) { + } + + //-------------------------------------------------------------------- +} diff --git a/app/Libraries/ActivityPub/Helpers/activitypub_helper.php b/app/Libraries/ActivityPub/Helpers/activitypub_helper.php new file mode 100644 index 00000000..b4227371 --- /dev/null +++ b/app/Libraries/ActivityPub/Helpers/activitypub_helper.php @@ -0,0 +1,513 @@ +setScheme('https'); + $webfingerUri->setHost($domain); + isset($port) && $webfingerUri->setPort((int) $port); + $webfingerUri->setPath('/.well-known/webfinger'); + $webfingerUri->setQuery("resource=acct:{$username}@{$domain}"); + + $webfingerRequest = new ActivityRequest($webfingerUri); + $webfingerResponse = $webfingerRequest->get(); + + return json_decode($webfingerResponse->getBody()); + } +} + +if (!function_exists('split_handle')) { + /** + * Splits handle into its parts (username, host and port) + * + * @param string $handle + * @return bool|array + */ + function split_handle(string $handle) + { + if ( + !preg_match( + '/^@?(?P[\w\.\-]+)@(?P[\w\.\-]+)(?P:[\d]+)?$/', + $handle, + $matches, + ) + ) { + return false; + } + + return $matches; + } +} + +if (!function_exists('accept_follow')) { + /** + * Sends an accept activity to the targetActor's inbox + * + * @param \ActivityPub\Entities\Actor $actor Actor which accepts the follow + * @param \ActivityPub\Entities\Actor $targetActor Actor which receives the accept follow + * @param string $objectId + * @return void + */ + function accept_follow($actor, $targetActor, $objectId) + { + $acceptActivity = new AcceptActivity(); + $acceptActivity->set('actor', $actor->uri)->set('object', $objectId); + + $db = \Config\Database::connect(); + $db->transStart(); + + $activityModel = model('ActivityModel'); + $activityId = $activityModel->newActivity( + 'Accept', + $actor->id, + $targetActor->id, + null, + $acceptActivity->toJSON(), + ); + + $acceptActivity->set( + 'id', + url_to('activity', $actor->username, $activityId), + ); + + $activityModel->update($activityId, [ + 'payload' => $acceptActivity->toJSON(), + ]); + + try { + $acceptRequest = new ActivityRequest( + $targetActor->inbox_url, + $acceptActivity->toJSON(), + ); + $acceptRequest->sign($actor->key_id, $actor->private_key); + $acceptRequest->post(); + } catch (\Exception $e) { + $db->transRollback(); + } + + $db->transComplete(); + } +} + +if (!function_exists('send_activity_to_followers')) { + /** + * Sends an activity to all actor followers + * + * @param \ActivityPub\Entities\Actor $actor + * @param string $activity + * @return void + */ + function send_activity_to_followers($actor, $activityPayload) + { + foreach ($actor->followers as $follower) { + try { + $acceptRequest = new ActivityRequest( + $follower->inbox_url, + $activityPayload, + ); + $acceptRequest->sign($actor->key_id, $actor->private_key); + $acceptRequest->post(); + } catch (\Exception $e) { + // log error + log_message('critical', $e); + } + } + } +} + +if (!function_exists('extract_urls_from_message')) { + /** + * Returns an array of all urls from a string + * + * @param mixed $message + * @return string[] + */ + function extract_urls_from_message($message) + { + preg_match_all( + '~(?:(https?)://([^\s<]+)|(www\.[^\s<]+?\.[^\s<]+))(? [ + 'OEmbedProvider' => '//', + 'OpenGraphProvider' => '//', + 'TwitterCardsProvider' => '//', + ], + ]); + $media = $essence->extract((string) $url); + + if ($media) { + $typeMapping = [ + 'photo' => 'image', + 'video' => 'video', + 'website' => 'link', + 'rich' => 'rich', + ]; + + // Check that, at least, the url and title are set + if ($media->url && $media->title) { + $preview_card = new \ActivityPub\Entities\PreviewCard([ + 'url' => (string) $url, + 'title' => $media->title, + 'description' => $media->description, + 'type' => isset($typeMapping[$media->type]) + ? $typeMapping[$media->type] + : 'link', + 'author_name' => $media->authorName, + 'author_url' => $media->authorUrl, + 'provider_name' => $media->providerName, + 'provider_url' => $media->providerUrl, + 'image' => $media->thumbnailUrl, + 'html' => $media->html, + ]); + + if ( + !($newPreviewCardId = model('PreviewCardModel')->insert( + $preview_card, + true, + )) + ) { + return null; + } + + $preview_card->id = $newPreviewCardId; + return $preview_card; + } + } + + return null; + } +} + +if (!function_exists('get_or_create_preview_card_from_url')) { + /** + * Extract open graph metadata from given url and create preview card + * + * @param \CodeIgniter\HTTP\URI $url + * @return \ActivityPub\Entities\PreviewCard|null + */ + function get_or_create_preview_card_from_url($url) + { + // check if preview card has already been generated + if ( + $previewCard = model('PreviewCardModel')->getPreviewCardFromUrl( + (string) $url, + ) + ) { + return $previewCard; + } + + // create preview card + return create_preview_card_from_url($url); + } +} + +if (!function_exists('get_or_create_actor_from_uri')) { + /** + * Retrieves actor from database using the actor uri + * If Actor is not present, it creates the record in the database and returns it. + * + * @param string $actorUri + * @return \ActivityPub\Entities\Actor|null + */ + function get_or_create_actor_from_uri($actorUri) + { + // check if actor exists in database already and return it + if ($actor = model('ActorModel')->getActorByUri($actorUri)) { + return $actor; + } + + // if the actor doesn't exist, request actorUri to create it + return create_actor_from_uri($actorUri); + } +} + +if (!function_exists('get_or_create_actor')) { + /** + * Retrieves actor from database using the actor username and domain + * If actor is not present, it creates the record in the database and returns it. + * + * @param string $username + * @param string $domain + * @return \ActivityPub\Entities\Actor|null + */ + function get_or_create_actor($username, $domain) + { + // check if actor exists in database already and return it + if ( + $actor = model('ActorModel')->getActorByUsername($username, $domain) + ) { + return $actor; + } + + // get actorUri with webfinger request + $webfingerData = get_webfinger_data($username, $domain); + $actorUriKey = array_search( + 'self', + array_column($webfingerData->links, 'rel'), + ); + + return create_actor_from_uri($webfingerData->links[$actorUriKey]->href); + } +} + +if (!function_exists('create_actor_from_uri')) { + /** + * Creates actor record in database using + * the info gathered from the actorUri parameter + * + * @param string $actorUri + * @return \ActivityPub\Entities\Actor|null + */ + function create_actor_from_uri($actorUri) + { + $activityRequest = new ActivityRequest($actorUri); + $actorResponse = $activityRequest->get(); + $actorPayload = json_decode($actorResponse->getBody()); + + $newActor = new \ActivityPub\Entities\Actor(); + $newActor->uri = $actorUri; + $newActor->username = $actorPayload->preferredUsername; + $newActor->domain = $activityRequest->getDomain(); + $newActor->public_key = $actorPayload->publicKey->publicKeyPem; + $newActor->private_key = null; + $newActor->display_name = $actorPayload->name; + $newActor->summary = $actorPayload->summary; + if (property_exists($actorPayload, 'icon')) { + $newActor->avatar_image_url = $actorPayload->icon->url; + $newActor->avatar_image_mimetype = $actorPayload->icon->mediaType; + } + + if (property_exists($actorPayload, 'image')) { + $newActor->cover_image_url = $actorPayload->image->url; + $newActor->cover_image_mimetype = $actorPayload->image->mediaType; + } + $newActor->inbox_url = $actorPayload->inbox; + $newActor->outbox_url = $actorPayload->outbox; + $newActor->followers_url = $actorPayload->followers; + + if (!($newActorId = model('ActorModel')->insert($newActor, true))) { + return null; + } + + $newActor->id = $newActorId; + return $newActor; + } +} + +if (!function_exists('get_current_domain')) { + /** + * Returns instance's domain name + * + * @return string + * @throws HTTPException + */ + function get_current_domain() + { + $uri = current_url(true); + return $uri->getHost() . ($uri->getPort() ? ':' . $uri->getPort() : ''); + } +} + +if (!function_exists('extract_text_from_html')) { + /** + * Extracts the text from html content + * + * @param mixed $content + * @return string|string[]|null + */ + function extract_text_from_html($content) + { + return preg_replace('/\s+/', ' ', strip_tags($content)); + } +} + +if (!function_exists('linkify')) { + /** + * Turn all link elements in clickable links. + * Transforms urls and handles + * + * @param string $value + * @param array $protocols http/https, ftp, mail, twitter + * @param array $attributes + * @return string + */ + function linkify($text, $protocols = ['http', 'handle']) + { + $links = []; + + // Extract text links for each protocol + foreach ((array) $protocols as $protocol) { + switch ($protocol) { + case 'http': + case 'https': + $text = preg_replace_callback( + '~(?:(https?)://([^\s<]+)|(www\.[^\s<]+?\.[^\s<]+))(? '_blank', + 'rel' => 'noopener noreferrer', + ], + ), + ) . + '>'; + }, + $text, + ); + break; + case 'handle': + $text = preg_replace_callback( + '~(?\w++)(?:@(?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]))?~', + function ($match) use (&$links) { + // check if host is set and look for actor in database + if (isset($match['host'])) { + if ( + $actor = model( + 'ActorModel', + )->getActorByUsername( + $match['username'], + $match['domain'], + ) + ) { + // TODO: check that host is local to remove target blank? + return '<' . + array_push( + $links, + anchor($actor->uri, $match[0], [ + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ]), + ) . + '>'; + } else { + try { + $actor = get_or_create_actor( + $match['username'], + $match['domain'], + ); + return '<' . + array_push( + $links, + anchor($actor->uri, $match[0], [ + 'target' => '_blank', + 'rel' => + 'noopener noreferrer', + ]), + ) . + '>'; + } catch (\CodeIgniter\HTTP\Exceptions\HTTPException $e) { + // Couldn't retrieve actor, do not wrap the text in link + return '<' . + array_push($links, $match[0]) . + '>'; + } + } + } else { + if ( + $actor = model( + 'ActorModel', + )->getActorByUsername($match['username']) + ) { + return '<' . + array_push( + $links, + anchor($actor->uri, $match[0]), + ) . + '>'; + } + + return '<' . + array_push($links, $match[0]) . + '>'; + } + }, + $text, + ); + break; + default: + $text = preg_replace_callback( + '~' . + preg_quote($protocol, '~') . + '://([^\s<]+?)(? '_blank', + 'rel' => 'noopener noreferrer', + ]), + ) . + '>'; + }, + $text, + ); + break; + } + } + + // Insert all links + return preg_replace_callback( + '/<(\d+)>/', + function ($match) use (&$links) { + return $links[$match[1] - 1]; + }, + $text, + ); + } +} diff --git a/app/Libraries/ActivityPub/HttpSignature.php b/app/Libraries/ActivityPub/HttpSignature.php new file mode 100644 index 00000000..8b1bde6d --- /dev/null +++ b/app/Libraries/ActivityPub/HttpSignature.php @@ -0,0 +1,170 @@ + + (https?:\/\/[\w\-\.]+[\w]+) + (:[\d]+)? + ([\w\-\.#\/@]+) + )", + algorithm="(?P[\w\-]+)", + (headers="\(request-target\) (?P[\w\-\s]+)",)? + signature="(?P[\w+\/]+={0,2})" + /x'; + + /** + * @var \CodeIgniter\HTTP\IncomingRequest + */ + protected $request; + + /** + * @param \CodeIgniter\HTTP\IncomingRequest $request + */ + public function __construct(IncomingRequest $request = null) + { + if (is_null($request)) { + $request = Services::request(); + } + + $this->request = $request; + } + + /** + * Verify an incoming message based upon its HTTP signature + * + * @return bool True if signature has been verified. Otherwise false + */ + public function verify() + { + if (!($dateHeader = $this->request->header('date'))) { + throw new Exception('Request must include a date header.'); + } + + // verify that request has been made within the last hour + $currentTime = Time::now(); + $requestTime = Time::createFromFormat( + 'D, d M Y H:i:s T', + $dateHeader->getValue(), + ); + + $diff = $requestTime->difference($currentTime); + if ($diff->getSeconds() > 3600) { + throw new Exception('Request must be made within the last hour.'); + } + + // check that digest header is set + if (!($digestHeader = $this->request->header('digest'))) { + throw new Exception('Request must include a digest header'); + } + // compute body digest and compare with header digest + $bodyDigest = hash('sha256', $this->request->getBody(), true); + $digest = 'SHA-256=' . base64_encode($bodyDigest); + if ($digest !== $digestHeader->getValue()) { + throw new Exception('Request digest is incorrect.'); + } + + // read the Signature header + if (!($signature = $this->request->getHeaderLine('signature'))) { + // Signature header not found + throw new Exception('Request must include a signature header'); + } + + // Split it into its parts (keyId, headers and signature) + if (!($parts = $this->splitSignature($signature))) { + throw new Exception('Malformed signature string.'); + } + + // extract parts as $keyId, $headers and $signature variables + extract($parts); + + // Fetch the public key linked from keyId + $actorRequest = new ActivityRequest($keyId); + $actorResponse = $actorRequest->get(); + $actor = json_decode($actorResponse->getBody()); + + $publicKeyPem = $actor->publicKey->publicKeyPem; + + // Create a comparison string from the plaintext headers we got + // in the same order as was given in the signature header, + $data = $this->getPlainText(explode(' ', trim($headers))); + + // Verify that string using the public key and the original signature. + $rsa = new RSA(); + $rsa->setHash('sha256'); + $rsa->setSignatureMode(RSA::SIGNATURE_PKCS1); + $rsa->loadKey($publicKeyPem); + + return $rsa->verify($data, base64_decode($signature, true)); + } + + /** + * Split HTTP signature into its parts (keyId, headers and signature) + * + * @param string $signature + * @return bool|array + */ + private function splitSignature(string $signature) + { + if (!preg_match(self::SIGNATURE_PATTERN, $signature, $matches)) { + // Signature pattern failed + return false; + } + + // Headers are optional + if (!isset($matches['headers']) || $matches['headers'] == '') { + $matches['headers'] = 'date'; + } + + return $matches; + } + + /** + * Get plain text that has been originally signed + * + * @param array $headers HTTP header keys + * @return string + */ + private function getPlainText(array $headers) + { + $strings = []; + $strings[] = sprintf( + '(request-target): %s %s%s', + $this->request->getMethod(), + '/' . $this->request->uri->getPath(), + $this->request->uri->getQuery() + ? '?' . $this->request->uri->getQuery() + : '', + ); + + foreach ($headers as $key) { + if ($this->request->hasHeader($key)) { + $strings[] = "$key: {$this->request->getHeaderLine($key)}"; + } + } + + return implode("\n", $strings); + } +} diff --git a/app/Libraries/ActivityPub/Models/ActivityModel.php b/app/Libraries/ActivityPub/Models/ActivityModel.php new file mode 100644 index 00000000..33c7f45e --- /dev/null +++ b/app/Libraries/ActivityPub/Models/ActivityModel.php @@ -0,0 +1,83 @@ +find($activityId); + } + + /** + * Inserts a new activity record in the database + * + * @param string $type + * @param integer $actorId + * @param integer $targetActorId + * @param integer $noteId + * @param string $payload + * @param \CodeIgniter\I18n\Time $scheduledAt + * @param string $status + * + * @return Michalsn\Uuid\BaseResult|int|string|false + */ + public function newActivity( + $type, + $actorId, + $targetActorId, + $noteId, + $payload, + $scheduledAt = null, + $status = null + ) { + return $this->insert( + [ + 'actor_id' => $actorId, + 'target_actor_id' => $targetActorId, + 'note_id' => $noteId, + 'type' => $type, + 'payload' => $payload, + 'scheduled_at' => $scheduledAt, + 'status' => $status, + ], + true, + ); + } + + public function getScheduledActivities() + { + return $this->where('`scheduled_at` <= NOW()', null, false) + ->where('status', 'queued') + ->orderBy('scheduled_at', 'ASC') + ->findAll(); + } +} diff --git a/app/Libraries/ActivityPub/Models/ActorModel.php b/app/Libraries/ActivityPub/Models/ActorModel.php new file mode 100644 index 00000000..deed724e --- /dev/null +++ b/app/Libraries/ActivityPub/Models/ActorModel.php @@ -0,0 +1,125 @@ +find($id); + } + + /** + * Looks for actor with username and domain, + * if no domain has been specified, the current host will be used + * + * @param mixed $username + * @param mixed|null $domain + * @return mixed + */ + public function getActorByUsername($username, $domain = null) + { + // TODO: is there a better way? + helper('activitypub'); + + if (!$domain) { + $domain = get_current_domain(); + } + + if (!($found = cache("actor@{$username}@{$domain}"))) { + $found = $this->where([ + 'username' => $username, + 'domain' => $domain, + ])->first(); + + cache()->save("actor@{$username}@{$domain}", $found, DECADE); + } + + return $found; + } + + public function getActorByUri($actorUri) + { + return $this->where('uri', $actorUri)->first(); + } + + public function getFollowers($actorId) + { + return $this->join( + 'activitypub_follows', + 'activitypub_follows.actor_id = id', + 'inner', + ) + ->where('activitypub_follows.target_actor_id', $actorId) + ->findAll(); + } + + /** + * Check if an actor is blocked using its uri + * + * @param mixed $actorUri + * @return boolean + */ + public function isActorBlocked($actorUri) + { + return $this->where(['uri' => $actorUri, 'is_blocked' => true])->first() + ? true + : false; + } + + /** + * Retrieves all blocked actors. + * + * @return \ActivityPub\Entities\Actor[] + */ + public function getBlockedActors() + { + return $this->where('is_blocked', 1)->findAll(); + } + + public function blockActor($actorId) + { + $this->update($actorId, ['is_blocked' => 1]); + } + + public function unblockActor($actorId) + { + $this->update($actorId, ['is_blocked' => 0]); + } +} diff --git a/app/Libraries/ActivityPub/Models/BlockedDomainModel.php b/app/Libraries/ActivityPub/Models/BlockedDomainModel.php new file mode 100644 index 00000000..482ef77c --- /dev/null +++ b/app/Libraries/ActivityPub/Models/BlockedDomainModel.php @@ -0,0 +1,79 @@ +findAll(); + } + + public function isDomainBlocked($domain) + { + if ($this->find($domain)) { + return true; + } + + return false; + } + + public function blockDomain($name) + { + $this->db->transStart(); + + // set all actors from the domain as blocked + model('ActorModel') + ->where('domain', $name) + ->set('is_blocked', 1) + ->update(); + + $result = $this->insert([ + 'name' => $name, + ]); + + $this->db->transComplete(); + + return $result; + } + + public function unblockDomain($name) + { + $this->db->transStart(); + // unblock all actors from the domain + model('ActorModel') + ->where('domain', $name) + ->set('is_blocked', 0) + ->update(); + + $result = $this->delete($name); + + $this->db->transComplete(); + + return $result; + } +} diff --git a/app/Libraries/ActivityPub/Models/FavouriteModel.php b/app/Libraries/ActivityPub/Models/FavouriteModel.php new file mode 100644 index 00000000..8a5d781e --- /dev/null +++ b/app/Libraries/ActivityPub/Models/FavouriteModel.php @@ -0,0 +1,178 @@ +db->transStart(); + + $this->insert([ + 'actor_id' => $actor->id, + 'note_id' => $note->id, + ]); + + model('NoteModel') + ->where( + 'id', + service('uuid') + ->fromString($note->id) + ->getBytes(), + ) + ->increment('favourites_count'); + + Events::trigger('on_note_favourite', $actor, $note); + + if ($registerActivity) { + $likeActivity = new LikeActivity(); + $likeActivity->set('actor', $actor->uri)->set('object', $note->uri); + + $activityId = model('ActivityModel')->newActivity( + 'Like', + $actor->id, + null, + $note->id, + $likeActivity->toJSON(), + $note->published_at, + 'queued', + ); + + $likeActivity->set( + 'id', + url_to('activity', $actor->username, $activityId), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $likeActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + } + + public function removeFavourite($actor, $note, $registerActivity = true) + { + $this->db->transStart(); + + model('NoteModel') + ->where( + 'id', + service('uuid') + ->fromString($note->id) + ->getBytes(), + ) + ->decrement('favourites_count'); + + $this->table('activitypub_favourites') + ->where([ + 'actor_id' => $actor->id, + 'note_id' => service('uuid') + ->fromString($note->id) + ->getBytes(), + ]) + ->delete(); + + Events::trigger('on_note_undo_favourite', $actor, $note); + + if ($registerActivity) { + $undoActivity = new UndoActivity(); + // get like activity + $activity = model('ActivityModel') + ->where([ + 'type' => 'Like', + 'actor_id' => $actor->id, + 'note_id' => service('uuid') + ->fromString($note->id) + ->getBytes(), + ]) + ->first(); + + $likeActivity = new LikeActivity(); + $likeActivity + ->set( + 'id', + base_url( + route_to('activity', $actor->username, $activity->id), + ), + ) + ->set('actor', $actor->uri) + ->set('object', $note->uri); + + $undoActivity + ->set('actor', $actor->uri) + ->set('object', $likeActivity); + + $activityId = model('ActivityModel')->newActivity( + 'Undo', + $actor->id, + null, + $note->id, + $undoActivity->toJSON(), + $note->published_at, + 'queued', + ); + + $undoActivity->set( + 'id', + url_to('activity', $actor->username, $activityId), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $undoActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + } + + /** + * Adds or removes favourite from database and increments count + * + * @param \ActivityPub\Entities\Actor $actor + * @param \ActivityPub\Entities\Note $note + * @return void + */ + public function toggleFavourite($actor, $note) + { + if ( + $this->where([ + 'actor_id' => $actor->id, + 'note_id' => service('uuid') + ->fromString($note->id) + ->getBytes(), + ])->first() + ) { + $this->removeFavourite($actor, $note); + } else { + $this->addFavourite($actor, $note); + } + } +} diff --git a/app/Libraries/ActivityPub/Models/FollowModel.php b/app/Libraries/ActivityPub/Models/FollowModel.php new file mode 100644 index 00000000..89831855 --- /dev/null +++ b/app/Libraries/ActivityPub/Models/FollowModel.php @@ -0,0 +1,148 @@ +db->transStart(); + + $this->insert([ + 'actor_id' => $actor->id, + 'target_actor_id' => $targetActor->id, + ]); + + // increment followers_count for target actor + model('ActorModel') + ->where('id', $targetActor->id) + ->increment('followers_count'); + + if ($registerActivity) { + $followActivity = new FollowActivity(); + + $followActivity + ->set('actor', $actor->uri) + ->set('object', $targetActor->uri); + + $activityId = model('ActivityModel')->newActivity( + 'Follow', + $actor->id, + $targetActor->id, + null, + $followActivity->toJSON(), + Time::now(), + 'queued', + ); + + $followActivity->set( + 'id', + base_url( + route_to('activity', $actor->username, $activityId), + ), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $followActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + } catch (\Exception $e) { + // follow already exists, do nothing + } + } + + /** + * @param \ActivityPub\Entities\Actor $actor + * @param \ActivityPub\Entities\Actor $targetActor + * @return void + * @throws InvalidArgumentException + * @throws DatabaseException + */ + public function removeFollower( + $actor, + $targetActor, + $registerActivity = true + ) { + $this->db->transStart(); + + $this->where([ + 'actor_id' => $actor->id, + 'target_actor_id' => $targetActor->id, + ])->delete(); + + // decrement followers_count for target actor + model('ActorModel') + ->where('id', $targetActor->id) + ->decrement('followers_count'); + + if ($registerActivity) { + $undoActivity = new UndoActivity(); + // get follow activity from database + $followActivity = model('ActivityModel') + ->where([ + 'type' => 'Follow', + 'actor_id' => $actor->id, + 'target_actor_id' => $targetActor->id, + ]) + ->first(); + + $undoActivity + ->set('actor', $actor->uri) + ->set('object', $followActivity->payload); + + $activityId = model('ActivityModel')->newActivity( + 'Undo', + $actor->id, + $targetActor->id, + null, + $undoActivity->toJSON(), + Time::now(), + 'queued', + ); + + $undoActivity->set( + 'id', + url_to('activity', $actor->username, $activityId), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $undoActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + } +} diff --git a/app/Libraries/ActivityPub/Models/NoteModel.php b/app/Libraries/ActivityPub/Models/NoteModel.php new file mode 100644 index 00000000..f0914542 --- /dev/null +++ b/app/Libraries/ActivityPub/Models/NoteModel.php @@ -0,0 +1,548 @@ + 'required', + 'message_html' => 'required_without[reblog_of_id]|max_length[500]', + ]; + + protected $beforeInsert = ['setNoteId']; + + public function getNoteById($noteId) + { + return $this->find($noteId); + } + + public function getNoteByUri($noteUri) + { + return $this->where('uri', $noteUri)->first(); + } + + /** + * Retrieves all published notes for a given actor ordered by publication date + * + * @return \ActivityPub\Entities\Note[] + */ + public function getActorNotes($actorId) + { + return $this->where([ + 'actor_id' => $actorId, + 'in_reply_to_id' => null, + ]) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'DESC') + ->findAll(); + } + + /** + * Retrieves all published replies for a given note. + * By default, it does not get replies from blocked actors. + * + * @param mixed $noteId + * @param boolean $withBlocked false by default + * @return array + */ + public function getNoteReplies($noteId, $withBlocked = false) + { + if (!$withBlocked) { + $this->select('activitypub_notes.*') + ->join( + 'activitypub_actors', + 'activitypub_actors.id = activitypub_notes.actor_id', + 'inner', + ) + ->where('activitypub_actors.is_blocked', 0); + } + + $this->where( + 'in_reply_to_id', + service('uuid') + ->fromString($noteId) + ->getBytes(), + ) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'ASC'); + + return $this->findAll(); + } + + /** + * Retrieves all published reblogs for a given note + */ + public function getNoteReblogs($noteId) + { + return $this->where('reblog_of_id', $noteId) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'ASC') + ->findAll(); + } + + public function addPreviewCard($noteId, $previewCardId) + { + return $this->db->table('activitypub_notes_preview_cards')->insert([ + 'note_id' => $noteId, + 'preview_card_id' => $previewCardId, + ]); + } + + /** + * Adds note in database along preview card if relevant + * + * @param \ActivityPub\Entities\Note $note + * @param boolean $registerActivity + * @param boolean $createPreviewCard + * @return string|false returns the new note id if success or false otherwise + */ + public function addNote( + $note, + $createPreviewCard = true, + $registerActivity = true + ) { + helper('activitypub'); + + $this->db->transStart(); + + if (!($newNoteId = $this->insert($note, true))) { + $this->db->transRollback(); + + // Couldn't insert note + return false; + } + + if ($createPreviewCard) { + // parse message + $messageUrls = extract_urls_from_message($note->message); + + if ( + !empty($messageUrls) && + ($previewCard = get_or_create_preview_card_from_url( + new URI($messageUrls[0]), + )) + ) { + if (!$this->addPreviewCard($newNoteId, $previewCard->id)) { + $this->db->transRollback(); + + // problem when linking note to preview card + return false; + } + + $this->db->transComplete(); + + return $newNoteId; + } + } + + model('ActorModel') + ->where('id', $note->actor_id) + ->increment('notes_count'); + + Events::trigger('on_note_add', $note); + + if ($registerActivity) { + $noteUuid = service('uuid') + ->fromBytes($newNoteId) + ->toString(); + + // set note id and uri to construct NoteObject + $note->id = $noteUuid; + $note->uri = base_url( + route_to('note', $note->actor->username, $noteUuid), + ); + + $createActivity = new CreateActivity(); + $noteObjectClass = config('ActivityPub')->noteObject; + $createActivity + ->set('actor', $note->actor->uri) + ->set('object', new $noteObjectClass($note)); + + $activityId = model('ActivityModel')->newActivity( + 'Create', + $note->actor_id, + null, + $noteUuid, + $createActivity->toJSON(), + $note->published_at, + 'queued', + ); + + $createActivity->set( + 'id', + base_url( + route_to('activity', $note->actor->username, $activityId), + ), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $createActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + + return $newNoteId; + } + + public function editNote($updatedNote) + { + $this->db->transStart(); + + // update note create activity schedule in database + $scheduledActivity = model('ActivityModel') + ->where([ + 'type' => 'Create', + 'note_id' => service('uuid') + ->fromString($updatedNote->id) + ->getBytes(), + ]) + ->first(); + + // update published date in payload + $newPayload = $scheduledActivity->payload; + $newPayload->object->published = $updatedNote->published_at->format( + DATE_W3C, + ); + model('ActivityModel')->update($scheduledActivity->id, [ + 'payload' => json_encode($newPayload), + 'scheduled_at' => $updatedNote->published_at, + ]); + + // update note + $updateResult = $this->update($updatedNote->id, $updatedNote); + + $this->db->transComplete(); + + return $updateResult; + } + + /** + * Removes a note from the database and decrements meta data + * + * @param \ActivityPub\Entities\Note $note + * @return mixed + */ + public function removeNote($note, $registerActivity = true) + { + $this->db->transStart(); + + model('ActorModel') + ->where('id', $note->actor_id) + ->decrement('notes_count'); + + if ($note->in_reply_to_id) { + // Note to remove is a reply + model('NoteModel') + ->where( + 'id', + service('uuid') + ->fromString($note->in_reply_to_id) + ->getBytes(), + ) + ->decrement('replies_count'); + } + + // remove all reblogs + foreach ($note->reblogs as $reblog) { + $this->removeNote($reblog); + } + + // remove all replies + foreach ($note->replies as $reply) { + $this->removeNote($reply); + } + + Events::trigger('on_note_remove', $note); + + if ($registerActivity) { + $deleteActivity = new DeleteActivity(); + $tombstoneObject = new TombstoneObject(); + $tombstoneObject->set('id', $note->uri); + $deleteActivity + ->set('actor', $note->actor->uri) + ->set('object', $tombstoneObject); + + $activityId = model('ActivityModel')->newActivity( + 'Delete', + $note->actor_id, + null, + null, + $deleteActivity->toJSON(), + Time::now(), + 'queued', + ); + + $deleteActivity->set( + 'id', + base_url( + route_to('activity', $note->actor->username, $activityId), + ), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $deleteActivity->toJSON(), + ]); + } + + $result = model('NoteModel', false)->delete($note->id); + + $this->db->transComplete(); + + return $result; + } + + public function addReply( + $reply, + $createPreviewCard = true, + $registerActivity = true + ) { + if (!$reply->in_reply_to_id) { + throw new \Exception('Passed note is not a reply!'); + } + + $this->db->transStart(); + + $noteId = $this->addNote($reply, $createPreviewCard, $registerActivity); + + model('NoteModel') + ->where( + 'id', + service('uuid') + ->fromString($reply->in_reply_to_id) + ->getBytes(), + ) + ->increment('replies_count'); + + Events::trigger('on_note_reply', $reply); + + $this->db->transComplete(); + + return $noteId; + } + + /** + * + * @param \ActivityPub\Entities\Actor $actor + * @param \ActivityPub\Entities\Note $note + * @return ActivityPub\Models\BaseResult|int|string|false + */ + public function reblog($actor, $note, $registerActivity = true) + { + $this->db->transStart(); + + $reblog = new Note([ + 'actor_id' => $actor->id, + 'reblog_of_id' => $note->id, + 'published_at' => Time::now(), + ]); + + // add reblog + $reblogId = $this->insert($reblog, true); + + model('ActorModel') + ->where('id', $actor->id) + ->increment('notes_count'); + + model('NoteModel') + ->where( + 'id', + service('uuid') + ->fromString($note->id) + ->getBytes(), + ) + ->increment('reblogs_count'); + + Events::trigger('on_note_reblog', $actor, $note); + + if ($registerActivity) { + $announceActivity = new AnnounceActivity($reblog); + + $activityId = model('ActivityModel')->newActivity( + 'Announce', + $actor->id, + null, + $note->id, + $announceActivity->toJSON(), + $reblog->published_at, + 'queued', + ); + + $announceActivity->set( + 'id', + base_url( + route_to('activity', $note->actor->username, $activityId), + ), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $announceActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + + return $reblogId; + } + + /** + * @param \ActivityPub\Entities\Note $reblogNote + * @return mixed + */ + public function undoReblog($reblogNote, $registerActivity = true) + { + $this->db->transStart(); + + model('ActorModel') + ->where('id', $reblogNote->actor_id) + ->decrement('notes_count'); + + model('NoteModel') + ->where( + 'id', + service('uuid') + ->fromString($reblogNote->reblog_of_id) + ->getBytes(), + ) + ->decrement('reblogs_count'); + + Events::trigger('on_note_undo_reblog', $reblogNote); + + if ($registerActivity) { + $undoActivity = new UndoActivity(); + // get like activity + $activity = model('ActivityModel') + ->where([ + 'type' => 'Announce', + 'actor_id' => $reblogNote->actor_id, + 'note_id' => service('uuid') + ->fromString($reblogNote->reblog_of_id) + ->getBytes(), + ]) + ->first(); + + $announceActivity = new AnnounceActivity($reblogNote); + $announceActivity->set( + 'id', + base_url( + route_to( + 'activity', + $reblogNote->actor->username, + $activity->id, + ), + ), + ); + + $undoActivity + ->set('actor', $reblogNote->actor->uri) + ->set('object', $announceActivity); + + $activityId = model('ActivityModel')->newActivity( + 'Undo', + $reblogNote->actor_id, + null, + $reblogNote->reblog_of_id, + $undoActivity->toJSON(), + Time::now(), + 'queued', + ); + + $undoActivity->set( + 'id', + base_url( + route_to( + 'activity', + $reblogNote->actor->username, + $activityId, + ), + ), + ); + + model('ActivityModel')->update($activityId, [ + 'payload' => $undoActivity->toJSON(), + ]); + } + + $result = model('NoteModel', false)->delete($reblogNote->id); + + $this->db->transComplete(); + + return $result; + } + + public function toggleReblog($actor, $note) + { + if ( + !($reblogNote = $this->where([ + 'actor_id' => $actor->id, + 'reblog_of_id' => service('uuid') + ->fromString($note->id) + ->getBytes(), + ])->first()) + ) { + $this->reblog($actor, $note); + } else { + $this->undoReblog($reblogNote); + } + } + + protected function setNoteId($data) + { + $uuid4 = service('uuid')->uuid4(); + $data['id'] = $uuid4->toString(); + $data['data']['id'] = $uuid4->getBytes(); + + if (!isset($data['data']['uri'])) { + $actor = model('ActorModel')->getActorById( + $data['data']['actor_id'], + ); + + $data['data']['uri'] = base_url( + route_to('note', $actor->username, $uuid4->toString()), + ); + } + + return $data; + } +} diff --git a/app/Libraries/ActivityPub/Models/PreviewCardModel.php b/app/Libraries/ActivityPub/Models/PreviewCardModel.php new file mode 100644 index 00000000..874fe115 --- /dev/null +++ b/app/Libraries/ActivityPub/Models/PreviewCardModel.php @@ -0,0 +1,56 @@ +where('url', $url)->first(); + } + + public function getNotePreviewCard($noteId) + { + return $this->join( + 'activitypub_notes_preview_cards', + 'activitypub_notes_preview_cards.preview_card_id = id', + 'inner', + ) + ->where( + 'note_id', + service('uuid') + ->fromString($noteId) + ->getBytes(), + ) + ->first(); + } +} diff --git a/app/Libraries/ActivityPub/Models/UuidModel.php b/app/Libraries/ActivityPub/Models/UuidModel.php new file mode 100644 index 00000000..2029a846 --- /dev/null +++ b/app/Libraries/ActivityPub/Models/UuidModel.php @@ -0,0 +1,206 @@ +insertID = 0; + + if (empty($data)) { + $data = $this->tempData['data'] ?? null; + $escape = $this->tempData['escape'] ?? null; + $this->tempData = []; + } + + if (empty($data)) { + throw DataException::forEmptyDataset('insert'); + } + + // If $data is using a custom class with public or protected + // properties representing the table elements, we need to grab + // them as an array. + if (is_object($data) && !$data instanceof stdClass) { + $data = static::classToArray( + $data, + $this->primaryKey, + $this->dateFormat, + false, + ); + } + + // If it's still a stdClass, go ahead and convert to + // an array so doProtectFields and other model methods + // don't have to do special checks. + if (is_object($data)) { + $data = (array) $data; + } + + if (empty($data)) { + throw DataException::forEmptyDataset('insert'); + } + + // Validate data before saving. + if ($this->skipValidation === false) { + if ($this->cleanRules()->validate($data) === false) { + return false; + } + } + + // Must be called first so we don't + // strip out created_at values. + $data = $this->doProtectFields($data); + + // Set created_at and updated_at with same time + $date = $this->setDate(); + + if ( + $this->useTimestamps && + !empty($this->createdField) && + !array_key_exists($this->createdField, $data) + ) { + $data[$this->createdField] = $date; + } + + if ( + $this->useTimestamps && + !empty($this->updatedField) && + !array_key_exists($this->updatedField, $data) + ) { + $data[$this->updatedField] = $date; + } + + $eventData = ['data' => $data]; + if ($this->tempAllowCallbacks) { + $eventData = $this->trigger('beforeInsert', $eventData); + } + + // Require non empty primaryKey when + // not using auto-increment feature + if ( + !$this->useAutoIncrement && + empty($eventData['data'][$this->primaryKey]) + ) { + throw DataException::forEmptyPrimaryKey('insert'); + } + + if (!empty($this->uuidFields)) { + foreach ($this->uuidFields as $field) { + if ($field === $this->primaryKey) { + $this->uuidTempData[ + $field + ] = $this->uuid->{$this->uuidVersion}(); + + if ($this->uuidUseBytes === true) { + $this->builder()->set( + $field, + $this->uuidTempData[$field]->getBytes(), + ); + } else { + $this->builder()->set( + $field, + $this->uuidTempData[$field]->toString(), + ); + } + } else { + if ( + $this->uuidUseBytes === true && + !empty($eventData['data'][$field]) + ) { + $this->uuidTempData[$field] = $this->uuid->fromString( + $eventData['data'][$field], + ); + $this->builder()->set( + $field, + $this->uuidTempData[$field]->getBytes(), + ); + unset($eventData['data'][$field]); + } + } + } + } + + // Must use the set() method to ensure objects get converted to arrays + $result = $this->builder() + ->set($eventData['data'], '', $escape) + ->insert(); + + // If insertion succeeded then save the insert ID + if ($result) { + if ( + !$this->useAutoIncrement || + isset($eventData['data'][$this->primaryKey]) + ) { + $this->insertID = $eventData['data'][$this->primaryKey]; + } else { + if (in_array($this->primaryKey, $this->uuidFields)) { + $this->insertID = $this->uuidTempData[ + $this->primaryKey + ]->toString(); + } else { + $this->insertID = $this->db->insertID(); + } + } + } + + // Cleanup data before event trigger + if (!empty($this->uuidFields) && $this->uuidUseBytes === true) { + foreach ($this->uuidFields as $field) { + if ( + $field === $this->primaryKey || + empty($this->uuidTempData[$field]) + ) { + continue; + } + + $eventData['data'][$field] = $this->uuidTempData[ + $field + ]->toString(); + } + } + + $eventData = [ + 'id' => $this->insertID, + 'data' => $eventData['data'], + 'result' => $result, + ]; + if ($this->tempAllowCallbacks) { + // Trigger afterInsert events with the inserted data and new ID + $this->trigger('afterInsert', $eventData); + } + $this->tempAllowCallbacks = $this->allowCallbacks; + + // If insertion failed, get out of here + if (!$result) { + return $result; + } + + // otherwise return the insertID, if requested. + return $returnID ? $this->insertID : $result; + } +} diff --git a/app/Libraries/ActivityPub/Objects/ActorObject.php b/app/Libraries/ActivityPub/Objects/ActorObject.php new file mode 100644 index 00000000..5d9f07ee --- /dev/null +++ b/app/Libraries/ActivityPub/Objects/ActorObject.php @@ -0,0 +1,113 @@ +id = $actor->uri; + + $this->name = $actor->display_name; + $this->preferredUsername = $actor->username; + $this->summary = $actor->summary; + $this->url = $actor->uri; + + $this->inbox = $actor->inbox_url; + $this->outbox = $actor->outbox_url; + $this->followers = $actor->followers_url; + + if ($actor->cover_image_url) { + $this->image = [ + 'type' => 'Image', + 'mediaType' => $actor->cover_image_mimetype, + 'url' => $actor->cover_image_url, + ]; + } + $this->icon = [ + 'type' => 'Image', + 'mediaType' => $actor->avatar_image_mimetype, + 'url' => $actor->avatar_image_url, + ]; + + $this->publicKey = [ + 'id' => $actor->key_id, + 'owner' => $actor->uri, + 'publicKeyPem' => $actor->public_key, + ]; + } +} diff --git a/app/Libraries/ActivityPub/Objects/NoteObject.php b/app/Libraries/ActivityPub/Objects/NoteObject.php new file mode 100644 index 00000000..92a83c53 --- /dev/null +++ b/app/Libraries/ActivityPub/Objects/NoteObject.php @@ -0,0 +1,61 @@ +id = $note->uri; + + $this->content = $note->message_html; + $this->published = $note->published_at->format(DATE_W3C); + $this->attributedTo = $note->actor->uri; + + if ($note->is_reply) { + $this->inReplyTo = $note->reply_to_note->uri; + } + + $this->replies = base_url( + route_to('note-replies', $note->actor->username, $note->id), + ); + + $this->cc = [$note->actor->followers_url]; + } +} diff --git a/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php b/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php new file mode 100644 index 00000000..faabb78b --- /dev/null +++ b/app/Libraries/ActivityPub/Objects/OrderedCollectionObject.php @@ -0,0 +1,66 @@ +id = current_url(); + + if ($pager) { + $totalItems = $pager->getTotal(); + $this->totalItems = $totalItems; + + if ($totalItems) { + $this->first = $pager->getPageURI($pager->getFirstPage()); + $this->current = $pager->getPageURI(); + $this->last = $pager->getPageURI($pager->getLastPage()); + } + } + + $this->orderedItems = $orderedItems; + } +} diff --git a/app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php b/app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php new file mode 100644 index 00000000..98eba8f2 --- /dev/null +++ b/app/Libraries/ActivityPub/Objects/OrderedCollectionPage.php @@ -0,0 +1,55 @@ +getCurrentPage() === $pager->getFirstPage(); + $isLastPage = $pager->getCurrentPage() === $pager->getLastPage(); + $isFirstPage && ($this->first = null); + $isLastPage && ($this->last = null); + + $this->id = $pager->getPageURI($pager->getCurrentPage()); + $this->partOf = $pager->getPageURI(); + $this->prev = $pager->getPreviousPageURI(); + $this->current = $pager->getPageURI($pager->getCurrentPage()); + $this->next = $pager->getNextPageURI(); + } +} diff --git a/app/Libraries/ActivityPub/Objects/TombstoneObject.php b/app/Libraries/ActivityPub/Objects/TombstoneObject.php new file mode 100644 index 00000000..a29bdd7a --- /dev/null +++ b/app/Libraries/ActivityPub/Objects/TombstoneObject.php @@ -0,0 +1,19 @@ +([\w_]+))@(?P([\w\-\.]+[\w]+)(:[\d]+)?)$/x'; + + /** + * @var string + */ + protected $username; + + /** + * @var string + */ + protected $host; + + /** + * @var string + */ + protected $port; + + /** + * @var string + */ + protected $subject; + + /** + * @var array + */ + protected $aliases; + + /** + * @var string + */ + protected $links; + + /** + * @param string $resource + */ + public function __construct($resource) + { + $this->subject = $resource; + + // Split resource into its parts (username, domain) + $parts = $this->splitResource($resource); + if (!$parts) { + throw new Exception('Wrong WebFinger resource pattern.'); + } + extract($parts); + + $this->username = $username; + $this->domain = $domain; + + $currentUrl = current_url(true); + $currentDomain = + $currentUrl->getHost() . + ($currentUrl->getPort() ? ':' . $currentUrl->getPort() : ''); + if ($currentDomain !== $domain) { + // TODO: return error code + throw new Exception('Domain does not correspond to Instance.'); + } + + if ( + !($actor = model('ActorModel')->getActorByUsername( + $username, + $domain, + )) + ) { + throw new Exception('Could not find actor'); + } + + $this->aliases = [$actor->id]; + $this->links = [ + [ + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => $actor->uri, + ], + [ + 'rel' => 'http://webfinger.net/rel/profile-page', + 'type' => 'text/html', + 'href' => $actor->uri, # TODO: should there be 2 values? @actorUsername + ], + ]; + } + + /** + * Split resource into its parts (username, domain) + * + * @param string $resource + * @return bool|array + */ + private function splitResource(string $resource) + { + if (!preg_match(self::RESOURCE_PATTERN, $resource, $matches)) { + // Resource pattern failed + return false; + } + + return $matches; + } + + /** + * Get WebFinger response as an array + * + * @return array + */ + public function toArray() + { + return [ + 'subject' => $this->subject, + 'aliases' => $this->aliases, + 'links' => $this->links, + ]; + } +} diff --git a/app/Libraries/Breadcrumb.php b/app/Libraries/Breadcrumb.php index 816f61eb..5f5bea27 100644 --- a/app/Libraries/Breadcrumb.php +++ b/app/Libraries/Breadcrumb.php @@ -3,7 +3,7 @@ /** * Generates and renders a breadcrumb based on the current url segments * - * @copyright 2020 Podlibre + * @copyright 2021 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ */ diff --git a/app/Libraries/Image.php b/app/Libraries/Image.php new file mode 100644 index 00000000..c7133937 --- /dev/null +++ b/app/Libraries/Image.php @@ -0,0 +1,153 @@ + $filename, + 'dirname' => $dirname, + 'extension' => $extension, + ] = pathinfo($originalPath); + + // load images extensions from config + $this->config = config('Images'); + + $thumbnailExtension = $this->config->thumbnailExtension; + $mediumExtension = $this->config->mediumExtension; + $largeExtension = $this->config->largeExtension; + $feedExtension = $this->config->feedExtension; + $id3Extension = $this->config->id3Extension; + + $thumbnail = + $dirname . '/' . $filename . $thumbnailExtension . '.' . $extension; + $medium = + $dirname . '/' . $filename . $mediumExtension . '.' . $extension; + $large = + $dirname . '/' . $filename . $largeExtension . '.' . $extension; + $feed = $dirname . '/' . $filename . $feedExtension . '.' . $extension; + $id3 = $dirname . '/' . $filename . $id3Extension . '.' . $extension; + + $this->original_path = $originalPath; + $this->original_url = media_url($originalUri); + $this->thumbnail_path = $thumbnail; + $this->thumbnail_url = base_url($thumbnail); + $this->medium_path = $medium; + $this->medium_url = base_url($medium); + $this->large_path = $large; + $this->large_url = base_url($large); + $this->feed_path = $feed; + $this->feed_url = base_url($feed); + $this->id3_path = $id3; + + $this->mimetype = $mimetype; + } + + public function saveSizes() + { + $thumbnailSize = $this->config->thumbnailSize; + $mediumSize = $this->config->mediumSize; + $largeSize = $this->config->largeSize; + $feedSize = $this->config->feedSize; + $id3Size = $this->config->id3Size; + + $imageService = \Config\Services::image(); + + $imageService + ->withFile($this->original_path) + ->resize($thumbnailSize, $thumbnailSize) + ->save($this->thumbnail_path); + + $imageService + ->withFile($this->original_path) + ->resize($mediumSize, $mediumSize) + ->save($this->medium_path); + + $imageService + ->withFile($this->original_path) + ->resize($largeSize, $largeSize) + ->save($this->large_path); + + $imageService + ->withFile($this->original_path) + ->resize($feedSize, $feedSize) + ->save($this->feed_path); + + $imageService + ->withFile($this->original_path) + ->resize($id3Size, $id3Size) + ->save($this->id3_path); + } +} diff --git a/app/Libraries/Negotiate.php b/app/Libraries/Negotiate.php new file mode 100644 index 00000000..8e52ca03 --- /dev/null +++ b/app/Libraries/Negotiate.php @@ -0,0 +1,14 @@ +match($acceptable, $supported, $enforceTypes); + } +} diff --git a/app/Libraries/NoteObject.php b/app/Libraries/NoteObject.php new file mode 100644 index 00000000..4602784e --- /dev/null +++ b/app/Libraries/NoteObject.php @@ -0,0 +1,30 @@ +episode_id) { + $this->content = + '' . + $note->episode->title . + '
' . + $note->message_html; + } + } +} diff --git a/app/Libraries/PodcastActor.php b/app/Libraries/PodcastActor.php new file mode 100644 index 00000000..acc55e2c --- /dev/null +++ b/app/Libraries/PodcastActor.php @@ -0,0 +1,31 @@ +where('actor_id', $actor->id)->first(); + + $this->rss = $podcast->feed_url; + } +} diff --git a/app/Libraries/Router.php b/app/Libraries/Router.php new file mode 100644 index 00000000..fddaa2ba --- /dev/null +++ b/app/Libraries/Router.php @@ -0,0 +1,205 @@ +controller, etal as needed. + * + * @param string $uri The URI path to compare against the routes + * + * @return boolean Whether the route was matched or not. + * @throws RedirectException + */ + protected function checkRoutes(string $uri): bool + { + $routes = $this->collection->getRoutes( + $this->collection->getHTTPVerb(), + ); + + // Don't waste any time + if (empty($routes)) { + return false; + } + + $uri = $uri === '/' ? $uri : ltrim($uri, '/ '); + + // Loop through the route array looking for wildcards + foreach ($routes as $key => $val) { + // Reset localeSegment + $localeSegment = null; + + $key = $key === '/' ? $key : ltrim($key, '/ '); + + $matchedKey = $key; + + // Are we dealing with a locale? + if (strpos($key, '{locale}') !== false) { + $localeSegment = array_search( + '{locale}', + preg_split( + '/[\/]*((^[a-zA-Z0-9])|\(([^()]*)\))*[\/]+/m', + $key, + ), + true, + ); + + // Replace it with a regex so it + // will actually match. + $key = str_replace('/', '\/', $key); + $key = str_replace('{locale}', '[^\/]+', $key); + } + + // Does the RegEx match? + if (preg_match('#^' . $key . '$#u', $uri, $matches)) { + $this->matchedRouteOptions = $this->collection->getRoutesOptions( + $matchedKey, + ); + + // Is this route supposed to redirect to another? + if ($this->collection->isRedirect($key)) { + throw new RedirectException( + is_array($val) ? key($val) : $val, + $this->collection->getRedirectCode($key), + ); + } + // Store our locale so CodeIgniter object can + // assign it to the Request. + if (isset($localeSegment)) { + // The following may be inefficient, but doesn't upset NetBeans :-/ + $temp = explode('/', $uri); + $this->detectedLocale = $temp[$localeSegment]; + } + + // Are we using Closures? If so, then we need + // to collect the params into an array + // so it can be passed to the controller method later. + if (!is_string($val) && is_callable($val)) { + $this->controller = $val; + + // Remove the original string from the matches array + array_shift($matches); + + $this->params = $matches; + + $this->matchedRoute = [$matchedKey, $val]; + + return true; + } + + // Is there an alternate content for the matchedRoute? + + // check if the alternate-content has been requested in the accept + // header and overwrite the $val with the matching controller method + if ( + array_key_exists( + 'alternate-content', + $this->matchedRouteOptions, + ) && + is_array($this->matchedRouteOptions['alternate-content']) + ) { + $request = Services::request(); + $negotiate = Services::negotiator(); + + $acceptHeader = $request->getHeader('Accept')->getValue(); + $parsedHeader = $negotiate->parseHeader($acceptHeader); + + $supported = array_keys( + $this->matchedRouteOptions['alternate-content'], + ); + + $expectedContentType = $parsedHeader[0]; + foreach ($supported as $available) { + if ( + $negotiate->callMatch( + $expectedContentType, + $available, + true, + ) + ) { + if ( + array_key_exists( + 'namespace', + $this->matchedRouteOptions[ + 'alternate-content' + ][$available], + ) + ) { + $this->collection->setDefaultNamespace( + $this->matchedRouteOptions[ + 'alternate-content' + ][$available]['namespace'], + ); + } + $val = + $this->collection->getDefaultNamespace() . + $this->directory . + $this->matchedRouteOptions['alternate-content'][ + $available + ]['controller-method']; + + // no need to continue loop as $val has been overwritten + break; + } + } + } + + // Are we using the default method for back-references? + + // Support resource route when function with subdirectory + // ex: $routes->resource('Admin/Admins'); + if ( + strpos($val, '$') !== false && + strpos($key, '(') !== false && + strpos($key, '/') !== false + ) { + $replacekey = str_replace('/(.*)', '', $key); + $val = preg_replace('#^' . $key . '$#u', $val, $uri); + $val = str_replace( + $replacekey, + str_replace('/', '\\', $replacekey), + $val, + ); + } elseif ( + strpos($val, '$') !== false && + strpos($key, '(') !== false + ) { + $val = preg_replace('#^' . $key . '$#u', $val, $uri); + } elseif (strpos($val, '/') !== false) { + [$controller, $method] = explode('::', $val); + + // Only replace slashes in the controller, not in the method. + $controller = str_replace('/', '\\', $controller); + + $val = $controller . '::' . $method; + } + + $this->setRequest(explode('/', $val)); + + $this->matchedRoute = [$matchedKey, $val]; + + return true; + } + } + + return false; + } + + //-------------------------------------------------------------------- +} diff --git a/app/Libraries/SimpleRSSElement.php b/app/Libraries/SimpleRSSElement.php index 3aad8965..f37453c1 100644 --- a/app/Libraries/SimpleRSSElement.php +++ b/app/Libraries/SimpleRSSElement.php @@ -1,7 +1,7 @@ select('`country_code` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('country_code as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_by_country_weekly", $found, - 600 + 600, ); } return $found; @@ -68,24 +68,24 @@ class AnalyticsPodcastByCountryModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcast_by_country_yearly" + "{$podcastId}_analytics_podcast_by_country_yearly", )) ) { $oneYearAgo = date('Y-m-d', strtotime('-1 year')); - $found = $this->select('`country_code` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('country_code as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneYearAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneYearAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_by_country_yearly", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsPodcastByEpisodeModel.php b/app/Models/AnalyticsPodcastByEpisodeModel.php index fce15445..15725032 100644 --- a/app/Models/AnalyticsPodcastByEpisodeModel.php +++ b/app/Models/AnalyticsPodcastByEpisodeModel.php @@ -33,25 +33,25 @@ class AnalyticsPodcastByEpisodeModel extends Model if (!$episodeId) { if ( !($found = cache( - "{$podcastId}_analytics_podcast_by_episode_by_day" + "{$podcastId}_analytics_podcast_by_episode_by_day", )) ) { $lastEpisodes = (new EpisodeModel()) - ->select('`id`, `season_number`, `number`, `title`') - ->orderBy('`id`', 'DESC') - ->where(['`podcast_id`' => $podcastId]) + ->select('id, season_number, number, title') + ->orderBy('id', 'DESC') + ->where(['podcast_id' => $podcastId]) ->findAll(5); - $found = $this->select('`age` AS `X`'); + $found = $this->select('age AS X'); $letter = 97; foreach ($lastEpisodes as $episode) { $found = $found ->selectSum( - '(CASE WHEN `episode_id`=' . + '(CASE WHEN episode_id=' . $episode->id . - ' THEN `hits` END)', - '`' . chr($letter) . 'Y`' + ' THEN hits END)', + '' . chr($letter) . 'Y', ) ->select( '"' . @@ -62,50 +62,50 @@ class AnalyticsPodcastByEpisodeModel extends Model ? '' : '-' . $episode->number . '/ ') . $episode->title . - '" AS `' . + '" AS ' . chr($letter) . - 'Value`' + 'Value', ); $letter++; } $found = $found ->where([ - '`podcast_id`' => $podcastId, - '`age` <' => 60, + 'podcast_id' => $podcastId, + 'age <' => 60, ]) - ->groupBy('`X`') - ->orderBy('`X`', 'ASC') + ->groupBy('X') + ->orderBy('X', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_by_episode_by_day", $found, - 600 + 600, ); } return $found; } else { if ( !($found = cache( - "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day" + "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day", )) ) { - $found = $this->select('`date as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('date as labels') + ->selectSum('hits', 'values') ->where([ - '`episode_id`' => $episodeId, - '`podcast_id`' => $podcastId, - '`age` <' => 60, + 'episode_id' => $episodeId, + 'podcast_id' => $podcastId, + 'age <' => 60, ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_day", $found, - 600 + 600, ); } return $found; @@ -121,23 +121,23 @@ class AnalyticsPodcastByEpisodeModel extends Model { if ( !($found = cache( - "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month" + "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month", )) ) { - $found = $this->select('DATE_FORMAT(`date`,"%Y-%m-01") as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') + ->selectSum('hits', 'values') ->where([ 'episode_id' => $episodeId, 'podcast_id' => $podcastId, ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_{$episodeId}_analytics_podcast_by_episode_by_month", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsPodcastByHourModel.php b/app/Models/AnalyticsPodcastByHourModel.php index 2d65209d..df43d038 100644 --- a/app/Models/AnalyticsPodcastByHourModel.php +++ b/app/Models/AnalyticsPodcastByHourModel.php @@ -34,21 +34,21 @@ class AnalyticsPodcastByHourModel extends Model { if (!($found = cache("{$podcastId}_analytics_podcasts_by_hour"))) { $found = $this->select( - 'right(concat(\'0\',`hour`,\'h\'),3) as `labels`' + 'right(concat(\'0\',hour,\'h\'),3) as labels', ) - ->selectSum('`hits`', '`values`') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => date('Y-m-d', strtotime('-60 days')), + 'podcast_id' => $podcastId, + 'date >' => date('Y-m-d', strtotime('-60 days')), ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_hour", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsPodcastByPlayerModel.php b/app/Models/AnalyticsPodcastByPlayerModel.php index 39359b8c..668ca123 100644 --- a/app/Models/AnalyticsPodcastByPlayerModel.php +++ b/app/Models/AnalyticsPodcastByPlayerModel.php @@ -34,25 +34,25 @@ class AnalyticsPodcastByPlayerModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcasts_by_player_by_app_weekly" + "{$podcastId}_analytics_podcasts_by_player_by_app_weekly", )) ) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); - $found = $this->select('`app` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('app as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`app` !=' => '', - '`is_bot`' => 0, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'app !=' => '', + 'is_bot' => 0, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_player_by_app_weekly", $found, - 600 + 600, ); } return $found; @@ -69,25 +69,25 @@ class AnalyticsPodcastByPlayerModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcasts_by_player_by_app_yearly" + "{$podcastId}_analytics_podcasts_by_player_by_app_yearly", )) ) { $oneYearAgo = date('Y-m-d', strtotime('-1 year')); - $found = $this->select('`app` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('app as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`app` !=' => '', - '`is_bot`' => 0, - '`date` >' => $oneYearAgo, + 'podcast_id' => $podcastId, + 'app !=' => '', + 'is_bot' => 0, + 'date >' => $oneYearAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_player_by_app_yearly", $found, - 600 + 600, ); } return $found; @@ -104,26 +104,26 @@ class AnalyticsPodcastByPlayerModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcasts_by_player_by_os_weekly" + "{$podcastId}_analytics_podcasts_by_player_by_os_weekly", )) ) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); - $found = $this->select('`os` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('os as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`app` !=' => '', - '`os` !=' => '', - '`is_bot`' => 0, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'app !=' => '', + 'os !=' => '', + 'is_bot' => 0, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_player_by_os_weekly", $found, - 600 + 600, ); } return $found; @@ -140,25 +140,25 @@ class AnalyticsPodcastByPlayerModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcasts_by_player_by_device_weekly" + "{$podcastId}_analytics_podcasts_by_player_by_device_weekly", )) ) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); - $found = $this->select('`device` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('device as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`device` !=' => '', - '`is_bot`' => 0, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'device !=' => '', + 'is_bot' => 0, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_player_by_device_weekly", $found, - 600 + 600, ); } return $found; @@ -177,21 +177,21 @@ class AnalyticsPodcastByPlayerModel extends Model !($found = cache("{$podcastId}_analytics_podcasts_by_player_bots")) ) { $oneYearAgo = date('Y-m-d', strtotime('-1 year')); - $found = $this->select('DATE_FORMAT(`date`,"%Y-%m-01") as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`is_bot`' => 1, - '`date` >' => $oneYearAgo, + 'podcast_id' => $podcastId, + 'is_bot' => 1, + 'date >' => $oneYearAgo, ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_player_bots", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsPodcastByRegionModel.php b/app/Models/AnalyticsPodcastByRegionModel.php index 511a0769..be9a81c0 100644 --- a/app/Models/AnalyticsPodcastByRegionModel.php +++ b/app/Models/AnalyticsPodcastByRegionModel.php @@ -35,25 +35,25 @@ class AnalyticsPodcastByRegionModel extends Model $locale = service('request')->getLocale(); if ( !($found = cache( - "{$podcastId}_analytics_podcast_by_region_{$locale}" + "{$podcastId}_analytics_podcast_by_region_{$locale}", )) ) { - $found = $this->select('`country_code`, `region_code`') - ->selectSum('`hits`', '`value`') - ->selectAvg('`latitude`') - ->selectAvg('`longitude`') - ->groupBy('`country_code`, `region_code`') + $found = $this->select('country_code, region_code') + ->selectSum('hits', 'value') + ->selectAvg('latitude') + ->selectAvg('longitude') + ->groupBy('country_code, region_code') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => date('Y-m-d', strtotime('-1 week')), + 'podcast_id' => $podcastId, + 'date >' => date('Y-m-d', strtotime('-1 week')), ]) - ->orderBy('`value`', 'DESC') + ->orderBy('value', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_by_region_{$locale}", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsPodcastByServiceModel.php b/app/Models/AnalyticsPodcastByServiceModel.php index d65531a0..9170f861 100644 --- a/app/Models/AnalyticsPodcastByServiceModel.php +++ b/app/Models/AnalyticsPodcastByServiceModel.php @@ -34,25 +34,25 @@ class AnalyticsPodcastByServiceModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcasts_by_service_weekly" + "{$podcastId}_analytics_podcasts_by_service_weekly", )) ) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); - $found = $this->select('`service` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('service as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`service` !=' => '', - '`is_bot`' => 0, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'service !=' => '', + 'is_bot' => 0, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_service_weekly", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsPodcastModel.php b/app/Models/AnalyticsPodcastModel.php index 1d44a551..b8e84fb1 100644 --- a/app/Models/AnalyticsPodcastModel.php +++ b/app/Models/AnalyticsPodcastModel.php @@ -33,12 +33,12 @@ class AnalyticsPodcastModel extends Model public function getDataByDay(int $podcastId): array { if (!($found = cache("{$podcastId}_analytics_podcast_by_day"))) { - $found = $this->select('`date` as `labels`, `hits` as `values`') + $found = $this->select('date as labels, hits as values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => date('Y-m-d', strtotime('-60 days')), + 'podcast_id' => $podcastId, + 'date >' => date('Y-m-d', strtotime('-60 days')), ]) - ->orderBy('`labels`', 'ASC') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save("{$podcastId}_analytics_podcast_by_day", $found, 600); @@ -57,21 +57,21 @@ class AnalyticsPodcastModel extends Model { if (!($found = cache("{$podcastId}_analytics_podcasts_by_weekday"))) { $found = $this->select( - 'LEFT(DAYNAME(`date`),3) as `labels`, WEEKDAY(`date`) as `sort_labels`' + 'LEFT(DAYNAME(date),3) as labels, WEEKDAY(date) as sort_labels', ) - ->selectSum('`hits`', '`values`') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => date('Y-m-d', strtotime('-60 days')), + 'podcast_id' => $podcastId, + 'date >' => date('Y-m-d', strtotime('-60 days')), ]) - ->groupBy('`labels`, `sort_labels`') - ->orderBy('`sort_labels`', 'ASC') + ->groupBy('labels, sort_labels') + ->orderBy('sort_labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcasts_by_weekday", $found, - 600 + 600, ); } return $found; @@ -88,19 +88,19 @@ class AnalyticsPodcastModel extends Model { if (!($found = cache("{$podcastId}_analytics_podcast_by_bandwidth"))) { $found = $this->select( - '`date` as `labels`, round(`bandwidth` / 1048576, 1) as `values`' + 'date as labels, round(bandwidth / 1048576, 1) as `values`', ) ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => date('Y-m-d', strtotime('-60 days')), + 'podcast_id' => $podcastId, + 'date >' => date('Y-m-d', strtotime('-60 days')), ]) - ->orderBy('`labels`', 'ASC') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_by_bandwidth", $found, - 600 + 600, ); } return $found; @@ -116,19 +116,19 @@ class AnalyticsPodcastModel extends Model public function getDataByMonth(int $podcastId): array { if (!($found = cache("{$podcastId}_analytics_podcast_by_month"))) { - $found = $this->select('DATE_FORMAT(`date`,"%Y-%m-01") as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, + 'podcast_id' => $podcastId, ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_by_month", $found, - 600 + 600, ); } return $found; @@ -145,23 +145,21 @@ class AnalyticsPodcastModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcast_unique_listeners_by_day" + "{$podcastId}_analytics_podcast_unique_listeners_by_day", )) ) { - $found = $this->select( - '`date` as `labels`, `unique_listeners` as `values`' - ) + $found = $this->select('date as labels, unique_listeners as values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => date('Y-m-d', strtotime('-60 days')), + 'podcast_id' => $podcastId, + 'date >' => date('Y-m-d', strtotime('-60 days')), ]) - ->orderBy('`labels`', 'ASC') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_unique_listeners_by_day", $found, - 600 + 600, ); } return $found; @@ -178,22 +176,22 @@ class AnalyticsPodcastModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcast_unique_listeners_by_month" + "{$podcastId}_analytics_podcast_unique_listeners_by_month", )) ) { - $found = $this->select('DATE_FORMAT(`date`,"%Y-%m-01") as `labels`') - ->selectSum('`unique_listeners`', '`values`') + $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') + ->selectSum('unique_listeners', 'values') ->where([ - '`podcast_id`' => $podcastId, + 'podcast_id' => $podcastId, ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_unique_listeners_by_month", $found, - 600 + 600, ); } return $found; @@ -210,7 +208,7 @@ class AnalyticsPodcastModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcast_listening_time_by_day" + "{$podcastId}_analytics_podcast_listening_time_by_day", )) ) { $found = $this->select('date as labels') @@ -226,7 +224,7 @@ class AnalyticsPodcastModel extends Model cache()->save( "{$podcastId}_analytics_podcast_listening_time_by_day", $found, - 600 + 600, ); } return $found; @@ -243,22 +241,22 @@ class AnalyticsPodcastModel extends Model { if ( !($found = cache( - "{$podcastId}_analytics_podcast_listening_time_by_month" + "{$podcastId}_analytics_podcast_listening_time_by_month", )) ) { - $found = $this->select('DATE_FORMAT(`date`,"%Y-%m-01") as `labels`') + $found = $this->select('DATE_FORMAT(date,"%Y-%m-01") as labels') ->selectSum('duration', 'values') ->where([ $this->table . '.podcast_id' => $podcastId, ]) - ->groupBy('`labels`') - ->orderBy('`labels`', 'ASC') + ->groupBy('labels') + ->orderBy('labels', 'ASC') ->findAll(); cache()->save( "{$podcastId}_analytics_podcast_listening_time_by_month", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsWebsiteByBrowserModel.php b/app/Models/AnalyticsWebsiteByBrowserModel.php index d2da9b3a..b7b7c132 100644 --- a/app/Models/AnalyticsWebsiteByBrowserModel.php +++ b/app/Models/AnalyticsWebsiteByBrowserModel.php @@ -34,19 +34,20 @@ class AnalyticsWebsiteByBrowserModel extends Model { if (!($found = cache("{$podcastId}_analytics_website_by_browser"))) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); - $found = $this->select('`browser` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('browser as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); + cache()->save( "{$podcastId}_analytics_website_by_browser", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsWebsiteByEntryPageModel.php b/app/Models/AnalyticsWebsiteByEntryPageModel.php index ad19f6a6..220719d4 100644 --- a/app/Models/AnalyticsWebsiteByEntryPageModel.php +++ b/app/Models/AnalyticsWebsiteByEntryPageModel.php @@ -35,20 +35,20 @@ class AnalyticsWebsiteByEntryPageModel extends Model if (!($found = cache("{$podcastId}_analytics_website_by_entry_page"))) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); $found = $this->select( - 'IF(`entry_page_url`=\'/\',\'/\',SUBSTRING_INDEX(`entry_page_url`,\'/\',-1)) as `labels`' + 'IF(entry_page_url=\'/\',\'/\',SUBSTRING_INDEX(entry_page_url,\'/\',-1)) as labels', ) - ->selectSum('`hits`', '`values`') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_website_by_entry_page", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/AnalyticsWebsiteByRefererModel.php b/app/Models/AnalyticsWebsiteByRefererModel.php index 570f2ec8..aed2f46b 100644 --- a/app/Models/AnalyticsWebsiteByRefererModel.php +++ b/app/Models/AnalyticsWebsiteByRefererModel.php @@ -34,19 +34,19 @@ class AnalyticsWebsiteByRefererModel extends Model { if (!($found = cache("{$podcastId}_analytics_website_by_referer"))) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); - $found = $this->select('`referer_url` as `labels`') - ->selectSum('`hits`', '`values`') + $found = $this->select('referer_url as labels') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_website_by_referer", $found, - 600 + 600, ); } return $found; @@ -66,20 +66,20 @@ class AnalyticsWebsiteByRefererModel extends Model ) { $oneWeekAgo = date('Y-m-d', strtotime('-1 week')); $found = $this->select( - 'SUBSTRING_INDEX(`domain`, \'.\', -2) as `labels`' + 'SUBSTRING_INDEX(domain, \'.\', -2) as labels', ) - ->selectSum('`hits`', '`values`') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneWeekAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneWeekAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_website_by_domain_weekly", $found, - 600 + 600, ); } return $found; @@ -99,20 +99,20 @@ class AnalyticsWebsiteByRefererModel extends Model ) { $oneYearAgo = date('Y-m-d', strtotime('-1 year')); $found = $this->select( - 'SUBSTRING_INDEX(`domain`, \'.\', -2) as `labels`' + 'SUBSTRING_INDEX(domain, \'.\', -2) as labels', ) - ->selectSum('`hits`', '`values`') + ->selectSum('hits', 'values') ->where([ - '`podcast_id`' => $podcastId, - '`date` >' => $oneYearAgo, + 'podcast_id' => $podcastId, + 'date >' => $oneYearAgo, ]) - ->groupBy('`labels`') - ->orderBy('`values`', 'DESC') + ->groupBy('labels') + ->orderBy('values', 'DESC') ->findAll(); cache()->save( "{$podcastId}_analytics_website_by_domain_yearly", $found, - 600 + 600, ); } return $found; diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 6a071434..429ac676 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -16,6 +16,7 @@ class EpisodeModel extends Model protected $primaryKey = 'id'; protected $allowedFields = [ + 'id', 'podcast_id', 'guid', 'title', @@ -28,6 +29,7 @@ class EpisodeModel extends Model 'description_markdown', 'description_html', 'image_uri', + 'image_mimetype', 'transcript_uri', 'chapters_uri', 'parental_advisory', @@ -39,6 +41,9 @@ class EpisodeModel extends Model 'location_geo', 'location_osmid', 'custom_rss', + 'favourites_total', + 'reblogs_total', + 'notes_total', 'published_at', 'created_by', 'updated_by', @@ -70,6 +75,7 @@ class EpisodeModel extends Model protected $afterUpdate = ['writeEnclosureMetadata']; protected $beforeDelete = ['clearCache']; + // TODO: remove public static $themes = [ 'light-transparent' => [ 'style' => @@ -99,89 +105,79 @@ class EpisodeModel extends Model ], ]; + /** + * + * @param int|string $podcastId Podcast Id or name + * @param mixed $episodeSlug + * @return mixed + */ public function getEpisodeBySlug($podcastId, $episodeSlug) { - if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) { - $found = $this->where([ - 'podcast_id' => $podcastId, - 'slug' => $episodeSlug, - ]) - ->where('`published_at` <= NOW()', null, false) - ->first(); + if (!($found = cache("podcast@{$podcastId}_episode@{$episodeSlug}"))) { + $builder = $this->select('episodes.*') + ->where('slug', $episodeSlug) + ->where('`published_at` <= NOW()', null, false); + + if (is_numeric($podcastId)) { + // passed argument is the podcast id + $builder->where('podcast_id', $podcastId); + } else { + // passed argument is the podcast name, must perform join + $builder + ->join('podcasts', 'podcasts.id = episodes.podcast_id') + ->where('podcasts.name', $podcastId); + } + + $found = $builder->first(); cache()->save( "podcast{$podcastId}_episode@{$episodeSlug}", $found, - DECADE + DECADE, ); } return $found; } - public function getEpisodeById($podcastId, $episodeId) + public function getEpisodeById($episodeId) + { + if (!($found = cache("podcast_episode{$episodeId}"))) { + $builder = $this->where([ + 'id' => $episodeId, + ]); + + $found = $builder->first(); + + cache()->save("podcast_episode{$episodeId}", $found, DECADE); + } + + return $found; + } + + public function getPublishedEpisodeById($episodeId, $podcastId = null) { if (!($found = cache("podcast{$podcastId}_episode{$episodeId}"))) { - $found = $this->where([ - 'podcast_id' => $podcastId, + $builder = $this->where([ 'id' => $episodeId, - ]) - ->where('published_at <=', 'NOW()') - ->first(); + ])->where('`published_at` <= NOW()', null, false); + + if ($podcastId) { + $builder->where('podcast_id', $podcastId); + } + + $found = $builder->first(); cache()->save( "podcast{$podcastId}_episode{$episodeId}", $found, - DECADE + DECADE, ); } return $found; } - /** - * Returns the previous episode based on episode ordering - */ - public function getPreviousNextEpisodes($episode, $podcastType) - { - $sortNumberField = - $podcastType == 'serial' - ? 'if(isnull(season_number),0,season_number)*1000+number' - : 'if(isnull(season_number),0,season_number)*100000000000000+published_at'; - $sortNumberValue = - $podcastType == 'serial' - ? (empty($episode->season_number) - ? 0 - : $episode->season_number) * - 1000 + - $episode->number - : (empty($episode->season_number) - ? '' - : $episode->season_number) . - date('YmdHis', strtotime($episode->published_at)); - - $previousData = $this->orderBy('(' . $sortNumberField . ') DESC') - ->where([ - 'podcast_id' => $episode->podcast_id, - $sortNumberField . ' <' => $sortNumberValue, - ]) - ->where('`published_at` <= NOW()', null, false) - ->first(); - - $nextData = $this->orderBy('(' . $sortNumberField . ') ASC') - ->where([ - 'podcast_id' => $episode->podcast_id, - $sortNumberField . ' >' => $sortNumberValue, - ]) - ->where('`published_at` <= NOW()', null, false) - ->first(); - - return [ - 'previous' => $previousData, - 'next' => $nextData, - ]; - } - /** * Gets all episodes for a podcast ordered according to podcast type * Filtered depending on year or season @@ -203,7 +199,7 @@ class EpisodeModel extends Model $year, $season ? 'season' . $season : null, 'episodes', - ]) + ]), ); if (!($found = cache($cacheName))) { @@ -232,7 +228,7 @@ class EpisodeModel extends Model } $secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode( - $podcastId + $podcastId, ); cache()->save( @@ -240,7 +236,7 @@ class EpisodeModel extends Model $found, $secondsToNextUnpublishedEpisode ? $secondsToNextUnpublishedEpisode - : DECADE + : DECADE, ); } @@ -251,7 +247,7 @@ class EpisodeModel extends Model { if (!($found = cache("podcast{$podcastId}_years"))) { $found = $this->select( - 'YEAR(published_at) as year, count(*) as number_of_episodes' + 'YEAR(published_at) as year, count(*) as number_of_episodes', ) ->where([ 'podcast_id' => $podcastId, @@ -265,7 +261,7 @@ class EpisodeModel extends Model ->getResultArray(); $secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode( - $podcastId + $podcastId, ); cache()->save( @@ -273,7 +269,7 @@ class EpisodeModel extends Model $found, $secondsToNextUnpublishedEpisode ? $secondsToNextUnpublishedEpisode - : DECADE + : DECADE, ); } @@ -284,7 +280,7 @@ class EpisodeModel extends Model { if (!($found = cache("podcast{$podcastId}_seasons"))) { $found = $this->select( - 'season_number, count(*) as number_of_episodes' + 'season_number, count(*) as number_of_episodes', ) ->where([ 'podcast_id' => $podcastId, @@ -298,7 +294,7 @@ class EpisodeModel extends Model ->getResultArray(); $secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode( - $podcastId + $podcastId, ); cache()->save( @@ -306,7 +302,7 @@ class EpisodeModel extends Model $found, $secondsToNextUnpublishedEpisode ? $secondsToNextUnpublishedEpisode - : DECADE + : DECADE, ); } @@ -341,7 +337,7 @@ class EpisodeModel extends Model cache()->save( "podcast{$podcastId}_defaultQuery", $defaultQuery, - DECADE + DECADE, ); } return $defaultQuery; @@ -358,7 +354,7 @@ class EpisodeModel extends Model public function getSecondsToNextUnpublishedEpisode(int $podcastId) { $result = $this->select( - 'TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff' + 'TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff', ) ->where([ 'podcast_id' => $podcastId, @@ -376,7 +372,7 @@ class EpisodeModel extends Model helper('id3'); $episode = (new EpisodeModel())->find( - is_array($data['id']) ? $data['id'][0] : $data['id'] + is_array($data['id']) ? $data['id'][0] : $data['id'], ); write_enclosure_tags($episode); @@ -386,16 +382,15 @@ class EpisodeModel extends Model public function clearCache(array $data) { - $episodeModel = new EpisodeModel(); $episode = (new EpisodeModel())->find( - is_array($data['id']) ? $data['id'][0] : $data['id'] + is_array($data['id']) ? $data['id'][0] : $data['id'], ); // delete cache for rss feed cache()->delete("podcast{$episode->podcast_id}_feed"); foreach (\Opawg\UserAgentsPhp\UserAgentsRSS::$db as $service) { cache()->delete( - "podcast{$episode->podcast_id}_feed_{$service['slug']}" + "podcast{$episode->podcast_id}_feed_{$service['slug']}", ); } @@ -403,40 +398,43 @@ class EpisodeModel extends Model cache()->delete("podcast{$episode->podcast_id}_episodes"); cache()->delete( - "podcast{$episode->podcast_id}_episode@{$episode->slug}" + "podcast{$episode->podcast_id}_episode@{$episode->slug}", ); + cache()->delete("podcast_episode{$episode->id}"); + // delete episode lists cache per year / season for a podcast // and localized pages + $episodeModel = new EpisodeModel(); $years = $episodeModel->getYears($episode->podcast_id); $seasons = $episodeModel->getSeasons($episode->podcast_id); $supportedLocales = config('App')->supportedLocales; foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$episode->podcast->id}_episode{$episode->id}_{$locale}" + "page_podcast{$episode->podcast->id}_episode{$episode->id}_{$locale}", ); cache()->delete("credits_{$locale}"); } foreach ($years as $year) { cache()->delete( - "podcast{$episode->podcast_id}_{$year['year']}_episodes" + "podcast{$episode->podcast_id}_{$year['year']}_episodes", ); foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$episode->podcast_id}_{$year['year']}_{$locale}" + "page_podcast{$episode->podcast_id}_{$year['year']}_{$locale}", ); } } foreach ($seasons as $season) { cache()->delete( - "podcast{$episode->podcast_id}_season{$season['season_number']}_episodes" + "podcast{$episode->podcast_id}_season{$season['season_number']}_episodes", ); foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$episode->podcast_id}_season{$season['season_number']}_{$locale}" + "page_podcast{$episode->podcast_id}_season{$season['season_number']}_{$locale}", ); } } @@ -444,7 +442,7 @@ class EpisodeModel extends Model foreach (array_keys(self::$themes) as $themeKey) { foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$episode->podcast_id}_episode{$episode->id}_embeddable_player_{$themeKey}_{$locale}" + "page_podcast{$episode->podcast_id}_episode{$episode->id}_embeddable_player_{$themeKey}_{$locale}", ); } } diff --git a/app/Models/NoteModel.php b/app/Models/NoteModel.php new file mode 100644 index 00000000..5a95a16e --- /dev/null +++ b/app/Models/NoteModel.php @@ -0,0 +1,45 @@ +where([ + 'episode_id' => $episodeId, + ]) + ->where('`published_at` <= NOW()', null, false) + ->orderBy('published_at', 'DESC') + ->findAll(); + } +} diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php index ac8661c8..e1812cf4 100644 --- a/app/Models/PersonModel.php +++ b/app/Models/PersonModel.php @@ -21,6 +21,7 @@ class PersonModel extends Model 'unique_name', 'information_url', 'image_uri', + 'image_mimetype', 'created_by', 'updated_by', ]; @@ -86,7 +87,7 @@ class PersonModel extends Model $result[$person->id] = $person->full_name; return $result; }, - [] + [], ); cache()->save('person_options', $options, DECADE); } @@ -116,7 +117,7 @@ class PersonModel extends Model protected function clearCache(array $data) { $person = (new PersonModel())->getPersonById( - is_array($data['id']) ? $data['id'][0] : $data['id'] + is_array($data['id']) ? $data['id'][0] : $data['id'], ); cache()->delete('person_options'); diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index ffdcbfe7..dfd4044d 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -8,7 +8,10 @@ namespace App\Models; +use ActivityPub\Models\ActorModel; +use CodeIgniter\HTTP\URI; use CodeIgniter\Model; +use phpseclib\Crypt\RSA; class PodcastModel extends Model { @@ -24,6 +27,7 @@ class PodcastModel extends Model 'episode_description_footer_markdown', 'episode_description_footer_html', 'image_uri', + 'image_mimetype', 'language_code', 'category_id', 'parental_advisory', @@ -69,6 +73,10 @@ class PodcastModel extends Model ]; protected $validationMessages = []; + protected $beforeInsert = ['createPodcastActor']; + protected $afterInsert = ['setAvatarImageUrl']; + protected $afterUpdate = ['updatePodcastActor']; + // clear cache before update if by any chance, the podcast name changes, so will the podcast link protected $beforeUpdate = ['clearCache']; protected $beforeDelete = ['clearCache']; @@ -107,7 +115,7 @@ class PodcastModel extends Model $found = $this->select('podcasts.*') ->join( 'podcasts_users', - 'podcasts_users.podcast_id = podcasts.id' + 'podcasts_users.podcast_id = podcasts.id', ) ->where('podcasts_users.user_id', $userId) ->findAll(); @@ -159,15 +167,29 @@ class PodcastModel extends Model public function getContributorGroupId($userId, $podcastId) { - $user_podcast = $this->db - ->table('podcasts_users') - ->select('group_id') - ->where([ - 'user_id' => $userId, - 'podcast_id' => $podcastId, - ]) - ->get() - ->getResultObject(); + if (!is_numeric($podcastId)) { + // identifier is the podcast name, request must be a join + $user_podcast = $this->db + ->table('podcasts_users') + ->select('group_id', 'user_id') + ->join('podcasts', 'podcasts.id = podcasts_users.podcast_id') + ->where([ + 'user_id' => $userId, + 'name' => $podcastId, + ]) + ->get() + ->getResultObject(); + } else { + $user_podcast = $this->db + ->table('podcasts_users') + ->select('group_id') + ->where([ + 'user_id' => $userId, + 'podcast_id' => $podcastId, + ]) + ->get() + ->getResultObject(); + } return (int) count($user_podcast) > 0 ? $user_podcast[0]->group_id @@ -177,7 +199,7 @@ class PodcastModel extends Model public function clearCache(array $data) { $podcast = (new PodcastModel())->getPodcastById( - is_array($data['id']) ? $data['id'][0] : $data['id'] + is_array($data['id']) ? $data['id'][0] : $data['id'], ); $supportedLocales = config('App')->supportedLocales; @@ -195,14 +217,14 @@ class PodcastModel extends Model foreach ($podcast->episodes as $episode) { foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$podcast->id}_episode{$episode->id}_{$locale}" + "page_podcast{$podcast->id}_episode{$episode->id}_{$locale}", ); foreach ( array_keys(\App\Models\EpisodeModel::$themes) as $themeKey ) { cache()->delete( - "page_podcast{$podcast->id}_episode{$episode->id}_embeddable_player_{$themeKey}_{$locale}" + "page_podcast{$podcast->id}_episode{$episode->id}_embeddable_player_{$themeKey}_{$locale}", ); } } @@ -222,17 +244,17 @@ class PodcastModel extends Model cache()->delete("podcast{$podcast->id}_{$year['year']}_episodes"); foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$podcast->id}_{$year['year']}_{$locale}" + "page_podcast{$podcast->id}_{$year['year']}_{$locale}", ); } } foreach ($seasons as $season) { cache()->delete( - "podcast{$podcast->id}_season{$season['season_number']}_episodes" + "podcast{$podcast->id}_season{$season['season_number']}_episodes", ); foreach ($supportedLocales as $locale) { cache()->delete( - "page_podcast{$podcast->id}_season{$season['season_number']}_{$locale}" + "page_podcast{$podcast->id}_season{$season['season_number']}_{$locale}", ); } } @@ -244,4 +266,85 @@ class PodcastModel extends Model return $data; } + + /** + * Creates an actor linked to the podcast + * (Triggered before insert) + * + * @param array $data + */ + protected function createPodcastActor(array $data) + { + $rsa = new RSA(); + $rsa->setHash('sha256'); + + // extracts $privatekey and $publickey variables + extract($rsa->createKey(2048)); + + $url = new URI(base_url()); + $username = $data['data']['name']; + $domain = + $url->getHost() . ($url->getPort() ? ':' . $url->getPort() : ''); + + $actorId = (new ActorModel())->insert( + [ + 'uri' => url_to('actor', $username), + 'username' => $username, + 'domain' => $domain, + 'private_key' => $privatekey, + 'public_key' => $publickey, + 'display_name' => $data['data']['title'], + 'summary' => $data['data']['description_html'], + 'avatar_image_url' => '', + 'avatar_image_mimetype' => '', + 'cover_image_url' => base_url( + 'assets/images/castopod-cover-default.jpg', + ), + 'cover_image_mimetype' => 'image/jpeg', + 'inbox_url' => url_to('inbox', $username), + 'outbox_url' => url_to('outbox', $username), + 'followers_url' => url_to('followers', $username), + ], + true, + ); + + $data['data']['actor_id'] = $actorId; + + return $data; + } + + protected function setAvatarImageUrl($data) + { + $podcast = (new PodcastModel())->getPodcastById( + is_array($data['id']) ? $data['id'][0] : $data['id'], + ); + + $podcast->actor->avatar_image_url = $podcast->image->thumbnail_url; + $podcast->actor->avatar_image_mimetype = $podcast->image_mimetype; + + (new ActorModel())->update($podcast->actor->id, $podcast->actor); + + return $data; + } + + protected function updatePodcastActor(array $data) + { + $podcast = (new PodcastModel())->getPodcastById( + is_array($data['id']) ? $data['id'][0] : $data['id'], + ); + + $actorModel = new ActorModel(); + $actor = $actorModel->find($podcast->actor_id); + + // update values + $actor->display_name = $podcast->title; + $actor->summary = $podcast->description_html; + $actor->avatar_image_url = $podcast->image->thumbnail_url; + + if ($actor->hasChanged()) { + $actorModel->update($actor->id, $actor); + } + + return $data; + } } diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index af71d7a1..4a034461 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -19,7 +19,7 @@ class UserModel extends \Myth\Auth\Models\UserModel ->join('podcasts_users', 'podcasts_users.user_id = users.id') ->join( 'auth_groups', - 'auth_groups.id = podcasts_users.group_id' + 'auth_groups.id = podcasts_users.group_id', ) ->where('podcasts_users.podcast_id', $podcastId) ->findAll(); @@ -33,7 +33,7 @@ class UserModel extends \Myth\Auth\Models\UserModel public function getPodcastContributor($user_id, $podcast_id) { return $this->select( - 'users.*, podcasts_users.podcast_id as podcast_id, auth_groups.name as podcast_role' + 'users.*, podcasts_users.podcast_id as podcast_id, auth_groups.name as podcast_role', ) ->join('podcasts_users', 'podcasts_users.user_id = users.id') ->join('auth_groups', 'auth_groups.id = podcasts_users.group_id') diff --git a/app/Views/_assets/admin.ts b/app/Views/_assets/admin.ts index 8cb179e0..be731524 100644 --- a/app/Views/_assets/admin.ts +++ b/app/Views/_assets/admin.ts @@ -1,6 +1,5 @@ import ClientTimezone from "./modules/ClientTimezone"; import Clipboard from "./modules/Clipboard"; -import ThemePicker from "./modules/ThemePicker"; import DateTimePicker from "./modules/DateTimePicker"; import Dropdown from "./modules/Dropdown"; import MarkdownEditor from "./modules/MarkdownEditor"; @@ -8,6 +7,7 @@ import MultiSelect from "./modules/MultiSelect"; import SidebarToggler from "./modules/SidebarToggler"; import Slugify from "./modules/Slugify"; import Soundbites from "./modules/Soundbites"; +import ThemePicker from "./modules/ThemePicker"; import Time from "./modules/Time"; import Tooltip from "./modules/Tooltip"; diff --git a/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff new file mode 100644 index 0000000000000000000000000000000000000000..468422fe537a00b04a15e4d033e667568dcbe350 GIT binary patch literal 21208 zcmYhgV{|S}7cKh4ws&mX$&PK?wr$(C^~Sbs+t!ZlF`PcscnEzS-ZxIs~5d{E%tbaMxUj%|Sf{u#G%PIYGLjV9Y z8~}ihBhB1*CZ|Nt1OUJ+{c2=?q3_Vn;%a1LU=IMmlKg55003ZJbZ!DfBUfhv003_6 zS0nw4U`1D`Ra1L28vp3)3744i&_QRjX&kpCBmX4W32zuX!Cz%~j1 zXnZf;Dp8r67#ITpQu)6cmS3>pga(Y7|B}Dl|6;-a1qs496sWn4v->ai_!|cl003fs zNGytAZD;hW`(L;~b-!ql4P}$EF>wFAuiUQjwfC1VRMs^RPkgDdbO# zCxVpNCId+LgPzfCH{~F~`X`h)#7V@J3rtN74@g$o46Vps;ttJAWP53QYHts9r-{+a z(I0P5mZjOz_vZ4+b3%Cm0PY*Glg#OO3aI}3H?h9E0@j-?Hk}by#Su1}vM6Pe_}D5N zYua9Bvk$sBh5j5a=dtshH+%DEZ%V23!bU*Bgy8uMC)4Uw%V0{WGesgJd1Gk@#?H%&DfJKGmOZC#pNH&w3opVhsZ zyXPagh;t?r8WNro4*tX7!JLjxiQ$RRlS!7%miCrCBHu0Eu#{6+JdoD&OI^>vCJ@yyCgfg82F>FzoeH{)@Qo>xzi>6;vVM$H>}-`w9DP zBo8j-A0)bugjg0Wt`IA!SSjctsSt&i_)^GjgVjf{!JLfbY=~-fHsU^?BvFW=ltHRQ ztue^Y7G7YDHn=uy8``KAeS|q`@=%2-ghTx)L)z{#5XJ&6x2S$l>X;R>-jupY_SuJY zp20zyyec7`Er#BAH+!ewxCYLyTjyYCy=9ci3N;rW5*(2cL>s7JPh!=kZj3DD6o-nX zHI`-%AdDH>aIDGpdG5Xu%RA}&nz1lGDO=q|g(J$E%Tt@Ita#B?>8{nZ%3HvhF?DOM zG+8{#&b32GZNyK`JFr%L%oAPsLI1h2nd_{5sov>c(i+QbYRNojS`U!z)`4J|k28oq z&zt9s68raX1ia!%&EA}$S1sH~@9kmuO`5wipXqtFqvY#f=fA?2(ko^plcb8E!DK{l z*>XMqd8fu&A#wPKv^#T*7X@LcGZi{Q7qfjNw&ovYAv{~Jz}(BjB;o-#jWoDZMFOqD z{k~$`Gc$2Svw2$Y4~NOX0&o z_0N3qmUK%#nhnL$l>^39vLUB1ycIT~n8FAq+XEWi@Fi^*XB>>V7Y>{I?AC<)p1?&IFIAB+}ig;d3O<;yR=T+0D6E{LF*n^R|G( zQf1+_^&w@;lnXg`(NcKkODF5i@}_*Bi4E?VV5{eR#5tppL^r$assHk36>ln>6?VPn zx+RKru5vEh2{!r+*5Q%qZubYhlxyS79Z2p0xpg7(X>EjD4#kPtt`F14#ReQkGt)Gq zBVDgp*})X+-Lax7Y8GC@QEgoGr9aXilk7-^R`~ANuboCAy9Sv5Ub=L;v$~ABlZlfd zns#U9UWBGj+|KLUPf{>$rG1d-o2&KFto~h$T%XnEBi8fQJ^^nibe2ms>kRXE@GX10 zzMQUAUHnU1z&e>sXxxn9Zi^b^wqvXJF110TPf@Fj#Qj3EVV|Pt?28^BQEe*lcXcMu zs^(9PH%qJg;>G%af5wlKZ0HTVuoxz5!qK8tZD=K3GSoxM&}r;V(0W*_q$hY&!S{OK z$F{|*IYMnF=*=7btQ@J}d6Y7K4R)APH%XQAjuM|qg5jVnv~pfGdTOnI*E3vI3Kdmb zTYUVemo2Lc_J!ROXOWK@@r@iAXHy}*c0eAF<*VOZUfcf;7oB)~qq`a5&2H+9VQkDA zUpfBkx{TV&`q;QHu@X+!jNX7|(`LQ{ylydcRd*<{ra*GDAhRBRN&LZRUP356m!a63 zXSNzv?^Ia)sZJ)Pu+STRdg30C_U_C3N~J}pRxj!YvbiPzcIbjWnRV3Op`BNyMi+l| zWa6B!G}9&Fy3b~bx32f_slwBxNJsgS=p*YPj5CNS;x4-J!!?_GTQ$~b`5yXrkSKT2 z>$g?ay2wW#M$@D%!S1kCC0&b_Q(BI99q$~Me3%5Yg;s~&XWMz+Xi9QErht~bWcFqo z11%m~FptV>*=zjXuY>07Ys0Rjvtsr<&kWQs=avI_0zSPk4+v`Y5a!VvAXS{5AYkg} zMp2fc-#p02_OZsD>isy+sH#*6&9V8IQmyMvW0{4j%;0O-a~xkiWonET;#mnf!>}* zQ+7fN2|FsOVLBAo2JK!`XX!as_^M%B~q)OiH`9RoDZd4J_M5Ma z{@kJCzx<%o1(9(hX8!ks`=G1-0oMHzT|SZ?%JS~y9WefhZM55E2xGiuK4*A?Ty6PG zTH5rL^O-5ed?X~SN_^3SIw{G^&Y4(`EP?3YZVWfi?r>{zKZK<8ZWAFC~j zx6HlTlUlP|^*54Dk+Fskvte>i#%QC;w_`d(p`fn8I$wiSn>BlFyxM$$cT86-wmUw4 zzSIZpeM>s}gWF!%zu$2W_F{L4exmGwhIlZa_FAu7F02>P)oEI-oql;t;_#>}9W-xE z<3)?SdddFaCh;OnkFRNOEsfsiiEG^Uk?Ms|m+mN-=PfbYqg98y?B6-6rPit%Lj_RPh`k&)p6@io`oFylmg-G7vU9AMx1>uL`taQW6tVr`HH4Y z_4KC(*L@|mY(dMbi%9D7aRSFWpXGX#gTwPf(spi@RgD+lq*qwkX1Kr;p|CbX4GrD8 znMJ9kx^DoVm&ax08+%&yb6*<0;~aZ&Rl4K+tm<0$*bdxmjGWPA!ePTt%3pYXXBq`W zo-d@|X~w22p)6sGQn->N;ji&~u-$C=*u5;R=i;ra(|8>wMEIQz`~KJpk$23c*ghHo zgb+Eaum?pAQNr3*WcdXXlG+?WYd9>E2 z1!A32C$~VaACg!5#P&MnVdekIBbO+G%d;IT5T?j;swmL1$}>01bGyj1Kg|=&Aq_Q+T#q69L*usA6*}YWH+y(gAihMrY(<71zb^J~?-8YQ%4c5n+ z!U9qP0+!Q{X+S^*X&|SaQwstq1A(Ae7#RK|ejv{N`octKV4#R-sQ>)x@9XRD|0@#^ zV8A3E`v!J_OHk6+2MS1pgB$-}d)AZ8LFo1Xh`m#hn#MQ2lbL8U)dOC@5p z<)s={0UGET2Dx{CSTpnsCCIW0MH_mi|L$ zpV3FNTrOYLZ+Sa8YgvO9f|Vzl=P!B3rc$YQ3<;HpC(=l)2d8A7fFy=v-$=Iu>t{9u z`R(_<57XZqNZW8d)!fJ7o*mBtU;e{NdA?}ZmrqDM5}nClvQgL|(H(lF)lxVb$kTdK zt;uXQ7z=W7&Ex)fv>rvgs@v`HvcH%nL*DcC`gFRRLJK57Xn>?(W;--QN>WmM#g&qf zlB8x|ezw-`TAk@)uA~I?pNk*D|NqVjK%)ubpD;i`9-@Biz8#$GKgR`+86%k^nLiC* zhdsjr0-oR>V8BED{_3l^ee(g||9tvAyg-B_uwP&;QX%4t%R$To2yjRv+&8LJS6w$n z5q0Xy)zC58V7p2SAz?JHscO^%qC5X_@>3Bg`pV^g!i~+so_(jvO~Ue5q@h+$U(0s8 znW_mF0ESaKm`~qugXO;QLzPt=?11MYTbO@=cqLn@u#RK+v>m9RrA5 z&bTb7g0Cz6*@>x5I8)STU9)Nx1Iy|wh@rNLmFDc_r>rNBC)|As5-_+fb#?DGS(C>U zu$tUyG`v@_YH%;ebBZ0?4GLsA!u9`dK({mdQ{mB3Pi(3z^BxRjE^-5)aQk{y>lq%5 z+*U9bbvFkSPqor(AujmdjIBp__x@4g@(@~S?=1@=@+WF2i8MMdZ34|S`sUJ5Rv-+C z;sC+dz7ysEE!S^^*@Jo`gIgol6feV9UeC47<#L-{!Ow(q zaflabpbn-w%gzbi8e$2QiM24a6GD>lu9HS`*xRFQ;6gs_>2R^kfg z0L>=az{ysnXZtFi(}1cuT#NNS)z|1&f6%iw4l5NoJ$~#eL}sjR{WSK}EJAAMFnLHR zns-cs`ev5!oBL0KfTYrQ;1NleJvLxbc19y{>)ncZF$w0YlpKiALg9;+i)XD0MKc{z zA$2HC^zJ_^Owo<%3gn0}k1SqUDU823X>_p?e>gD)W>06a4JDI9E;^?8L!3pEmBDR3 z7ujl&bXW(}n{oe^AqO3M9Aq7Xr5tJKDoX^ElqV8RN>NePcU2_i@4p z^i^GQVDz4A-$;Ypo5lxiXI*ueid_%o*qMNo-)J z#j#t;*}A)!(?WBJW-K2@GeTgmD&Ba=QLKVV)k`>nd5%2a?{|sAU5|B!$KHOQMv28a zYYL-Wq);^syh&cXhj#X$jZ}*X*e~mqU#MdnZ16wT@O@GdlYLGm>ZNmXRw>btDB_Ks z2yEm%0cplGgiHbAs4&v==V~A!U!~ltw;LFF&<7ubQg=rCw-VeH=LjW)b=dl11ie+n z@Z8H@MS(Ef2oYQtahjzQi8(1zRunSb@gurdP@MAOIv4iY403B^znh6&&` z?j%$qM@0X$g>%23br-s|Ss)SKwE;QkW}ez(5W7U z;&(+m-s>%aImv@)$o17C$cBs4lm80O$_ooZwf#6R?SABd33us+`v~MxMCsBT_)JpR zX#C<^_IP@ny>bVZGoHM}Y8fGh=Uo;R7TuDL;~PEk_PeUmRQfBf7r}Y?20Oj#TxDYk za=r13##?~vWDBWR*H}mp>R)2$iuL9j=$`#!@SP&9@6X@|4AhzblJ9t{^{BK-?jkiF z!)?t+nEo|BY2NW4?((dPsDdaU^3UtF(F9O3XQJ(0*Ywl2S=qk~53z}NSCBP+d~~_u zE5-`9BX*z_K?4~n$O2=kL8j-QD@ZT+Q~qI)0WmG~hAa?btwdHLnuwCiQi`z1m7N35 zu}GL{5;oH`&C!WO3-MQ3fm%TYUa{~*!)76n0AaJ}L%)E@sjx{&cU8}`P$Mnn;S%_8 zE(PIQB+NM%k8?2E2gTc@SgiQ#&_XH4dB$eBIpRFb)MhAW|t4M-9&3E z5UM$_zD=?dzW^9aumDNZ!%=XE=2V3slqxWwfd5|r(E<`bS_?e)Mszmo~Ilm8sxyZfN4;azy&}jQ_ zi0QbvTA)_-^}KYgt$l%?`yrYd2^DO(%HEp>cb4cR3icx%XI8r>FKf0>(Ua6PnHVyI z^Ea$S`KJB0LGv91X;J2cdAb=22C`1xkepO!mboDy5SV z?nc6vJzt#VRsIt}0irmw6BRLN?o&7L%THbcCKL%aqJlwmdY{ZC`+UKzrmG1rYF6K)qn6rRCabgFOX0Z4rC>E! z;=0<{-#{>prufTcJlMa$bExXmX1^b1R$8mWoujv;wekgj^N-I0!!_0UyT@jA6+T|J zWU_a??Jnzhr?zz_vsp!(KfnA*>zQj*pQX5u$Y+rd6|ITLAb4=(RS@fLkOT`_ zJPJ{~BnlOkqP#f|CN=dL0>E)TL>&zu0Wl9bFL5cf*K&438Usc+0;6?14#TLvJ|pp1 zdX3&e$7V1n2Y%+gY2U%-YT}07?%g!xd3LRI-Y9kSAeIo$WY{i_PValW6i$!UZ|_2e z*fplh=-rQ zu9muGLr1)gBQzUnYOl3Ne>;gAg#-=rJX~C?-k(yvcRU3={FNf$aB@*aBM{X91pTS> z9-H7Tuvtn-{0$>xAsTRwGX%q`e?1K!PEgd_q&{Ac)0*O3DNnn|Wp%sU6Q7@R-LeCDl`Jx><`nW z+4EPpWvA15bGIjFE`C`~!z&gquhaAfnm-Hsjm%-ku$2OvL%2qQQ(j^}@@obgRtT@M zvR9ftH?twz*i=RXL>*WQNh*&2X%fyZrV@!%EqR0~;@<$?CD>n) zj7T`FEDF%>3rGVTbefYiqk>(2+AQbFm&N>)Y86eLBx^%p?70@Wlk^#aL_u%u;awR; zGs$p155EqOo8kE>eYk7>RGhz)e>qMJ@U2SQel58rG-a?_UGwzZJoW;;-m;1b{Vs!0 zKc!lLO5~#;;}63S_3Ch6U$pvaHc;RRilsZIE&OXK%&WORy9{*v*!Sl)H^fFqr%Ux_ z=x;uTHFg_G?!!`GSccSeqj-^Nf5uiEN#KTz-4>a1WGj$0GC*FjSCChNgtW0fFAPO= zIr~-D5*2$U`Y&8KAFPUEWoQVV!N~uC zX#>N-@91)|dJsZjwcGZ5BaH3^3VvT=AE<=~v|m9Nc%MkqQ6n99X&CNge}^K%!N5ep zMm3of!;IUaXok+xmbN%(uf$w#dlk18GRT~h*}l2ZM*E(}thTGQi$&8rgA*`BR^^_t z74WvHdZY*g?6f__K~CW@rAjXT3f zAP@|1+Zv5K%e*}H{2!0o8tP0x-DuQ1S+#zNQtYggYIyB!*6YJ!cyEU13-quY+DGz$SRz?kOlN3zpyE+7N_3R? z{u-;;p}T>=6~X*)LX^cqG>{5N=vLt=NDM_9$D*YJVsao>sQQ|s4x_MPi;|)i>#uU& z@O*O(P2cIU!yA-WpZMX9vRP#|i!GK;3)G ze-!DSO^?IxoU3;%o%=bygQ>oEkfVKdzMBx&NyZ^=L=Y2bS75%?t2Ll8Hb71=kGxd8Zo`RO9s zRE!C{9D$GXxjJW?-SJZwY&H4$rCGO14*9O>(cIAval92iKI*$*cqzP&f#k9IL%Adb z{k;6uOV+b!#t~x8jfQ}ov&t_i4syZ0>b(d61OLbpliZjkzT8s!-sYS8@q zX{XO=$U31vkGIaOMS>->|1nf7WyQx|0EYgc9fuYX0WB!Dv9yS86kUEE+lQ@NV}!~R z9^Q|UkPtb^m#L(MnnQ}1p^Udh7r3#~=oI->y(!Y`W^?#-xOSPyDlKNCA8%35Za=u| z?%=C+zp=Zja`Rg6^%>~S7H4Kcjles^P528!=;Q^1fsiZwP^nST7RG1wky_)W=sZB;Vkzm}@aWGE(`xbReU9p2?#o^#cj$D!-!H9qx%3|K0Ia)}Hlqi! zScH(-2?B_Km^9`fpgoy_I5#Gy?%I<}pUe;E*Gi;ulS|<1u0C=`8z~0TRD;bJ{ z!XU%$bk5}!j}#+Afl!(Q++W=3V68K-ObLf;=ml$tS}0b=1NLdYO(*&#c+$MCrO%9C zU|;tkt`2wS>C<4B6RVes8ILJJA z(7O28M{_Hnl*HwOBSrcm-dEndJP|J=yIMOpr#C?I?;N#vDZ%_5pHy6P%;#IaTrwT`h*HD1e(eXEliW4IJ!xb?B;+s;2 zDJz&CpnG35TNO#g8qx4GP-o#e;dz!h=YyKix;nD|v{YYPTFxG2z2xbI`%rQ1kp#^% zk8jC>dm?hju<`VJEkXMD!*{#e;q?*qK$s?W<*%R?--a`6Z>16**JLz8U$7akhBrM; z+|F67;J)tnyBXDNdUp%9;m7vDtc&U-fF1CA9of?7>iCP@^f5UIty9>9xhH1~gI$j` z3AwH0ZM1l0VM)9;)PcA5@WZU=4Vc$B-gQ>MMfk|<@)`?21%xLn+(=!a zg5du3=mS6vdg@U80oJ^x{tMfNgoloTH~0~1$|aqB9~lfc(~RN-{j+A-7Aq{m?W$4q zb-%S6i_h2Nf)N*Q(d*rD{^_kp{UI-a^4j5^B$j4n`+Wz#w#hU&RpEP4!QaJ z#@!4Q+mOc~z?7BA*39vEI1|LUe#Z4J3=p+?bv+}~5eaBaGJ7bj=ulj9|Js|T<=<3& z_Nk_!&=!3&oA~F<=g<*aikr|8sHrQ`?6&jdRM?fT4QfMEfIKtFv57}EAz3I*pQJ#h z0wVURPNXx;-2RUk_$%))7Dvv{%&m_kxVcqRz=jS>WcCVb*U8114cWF&?OBIZ27MJr zf3<1IT-F;a*T$T#p?3fM8Ulu63)bF;bjPLD80<|>k5O?R5bZF{C&_*gB_t&K*bI%1 zYakv{c#C8Vb)#VYK~cP>x6a!3j-JJ)mCcWUUw^otr%)61d)pBVZw(boRRu#NA_FSu zomNpe;-g>!MRM}>>Wn4+G89JWwA~OxFUCWKBi!ZOCW@h0@&3ZhyNxzb!qQmI$00Oi z6XTi1lS3No4581fc^K@h|7~-W6oF>kbb6+4f94bZPFb_1n#7CM&T8GDl3Ofhj|;(O z6tP*ZrlWS{k9_C-X*W)6Y)8No^9e|RLUnCp7)RW&09PDm=m-H~)ln93(5fM7{};c0 zBHu{{sWLwifqY<70y{{&C0IyqJT8QT9pO%Fe4?>joLp-<0sNt`HXtYoixE`#a^oeqILb^T2EQ6)16R4%iVVRJr0BKi05}*-242P z?H1a_vuOOCDpie!!1gcEieNUlMp08Z`QnG((TN8Xofe(ykZr~psX2m<>PVk##+m7M zhgvMR$NQ!`EsxjnSeGVy5@h^F+P-F zM(jA!O!IF2v}EgjRdhOM{28)6W4kax$a_#ekrvloy)O0WjNjh=hnOcc`?l;=j~^)- zhTXTxwrTZKJ=m#P4n4&tlf^`6)lrZ!Ok9h0Bh9<>Lkr9U=Xk&Hi~)8aXSDx}fzR)z zAr!C)Bs4NKldDS(|0XD(Sn;wb^|0ThfdHcx{9i;$VI_$O9tuzC729`7NA-sW5Ow>L zp-h-oaxacB+L~&uCjaYwoR({paf3F<%9AeU*BpKL2dPFXLIe?(w25wcKbB#76d?D; z3hsS2N`v}{+!cB5Sd5VVXWhx#E!`r91g9Zr_zYot0i@`|)NVlr#5#g}hC&wbwN-pk z_P(T}Q$4@Yd{ZRV+3}g#NxBKji80$W(kf)wRNe|oOhwX`e1MxhJWiQRF6Yaai`5UO z+etB4Wtz@L`@=%RaxE)7_Qo2Av-;sUcPdSLkRD6*9s=_zPI)Cali5grU~HKx&D-ST zxt~VE0jew(i@`vCzl+PxPUFb(RJI1QZE7yeXqpKPjiR}0X#YtKpB4xrlBudmofr~2 zwTnC{#lfvK+lh&l+GhfIS6obQOXYevV>qX~GNFbAI5gS5nJ_e49h?yZoQuqkXP*cTNeN!Br zdK?`ap#Gt`C3ArkBF0K%=yV|uh;-1%0S`J7VMjdyaTUYfLC((@?;mV^g@}?r-xpT7 zU47WFf;2QoeBI+ls>`{?!GuBYPwmx9I$J$C)RX7tDEKJ2mI2UJkSLdin6C4p;XC53 zujPdh3FpN`DU)KvhP$~@eq=pmh&)2G3@4%bygIiHuHPkhPQ9ZMyS753rwG!2-OXw< zut;ZPEV+2R_9FSBbL?qmKQ#lHIhnoUJHN+9o$^+D+zqtz#Af)op7a(g&@Fn+?P`0R z)*^wrO<(X{`}nW^4ZJYE&yOYud5KL+ZM66PU1{iXoEUR(nxV;kzpMaaEyAc-maTPz z&GFQqYKh8vr+cqF(c${Y>&;th{DZ1f5dr2hB6|FhriGTP=lBgopP>d)l)8}$?kRlyJAoC;2CHty3NGyfP=MoYC ziE@c@%5m?8AvUNxSJ@V}${+HLY^v~?6V$ZMv1xiOq?ELA3o0g)jvTU3klsJ9Ouao3 zAFLmdI@tHi@o8^Qt8H5z0;Tj^ zlvbTbSw%ZoT*1!d;4y72D;mJoD#x$mhCFt>5t# zM^pZW64zr(!#wo`K39_W*$bi0rfWG!8@ZE`Y7I~&=4d5?4;$}T2hfN z5Y4b3NJJi*_^;5QJXn-QvvdNM0Mp8D~X$=<#EfA{8JO)vk;K>j4P7D?m$$2Fl)*q?-|Vu&5n9j3lL3daCbV7OD^Q+6K`+C;thJ8|&?`D?;+((_+A|Igy=d$RabZ=&1rl3VHtPCCNI z0z}X7HKW!*STRk-9fr~M?Hj|wy9Z^gEfA=%7 z;9Zxb%B(Dmnl`l@2#+HVB^2@Dy!4NVFbFp2^Y|iRV&| zt<$|Hy?bjL_AdRilepdU#a7N#Dw?m^N%61hQAG19^t7$Jk+jaLL zH?dQA*#>1h2fjsKusxLuS!vw$o5`ea8A7?K8(Hu6X-hC{a>@izM=yrf+Au8V_j)iG z-OX`h%v3@4V~a%s1wd|+;-I_$KrBP30H(YTi)?HVJf+$;JRw!lICG}kVW|vug3V@+ z&c_+*_tibx=f^pFb4wb%6(>{a$Y}=IDJ8tI0I<~|iiW@xqoAxTA`rw`dAvPjT+&@O zvOz{CPRGqoj9epJM!Mq18O!(Ky~*cCR#|h48l6>p+2?y&4@7}JM#sF}-&4@a-RNjo zEK7vgd##}Btl&bp9&@q7oz}@Vpe3^bS464;IK;8WG8l?>JJy{F0^FUARFZKQ(TTrd z`4U`&b=pU^mv$JG^6oQy>e#T{-=_K|n)RkY{MW?I!>#i`s0Tdt0`Xu#YZt+{74BHL z@7yvQZpNh(eV}OH2bcr$HRPBIP=*A+>IlJ#Oau5eB+0}ymnRImGze061n(xbY9BMk ze2Wc7(NmJQ8C#B}c;ApDk*6r1-(IQqaffiLW#8;1?pv12hqh;X^EzhrL2b?AwHWkP zyEzKz+4FaDv(&4_%bbtuL`41mhatio%~2+Ei#PeNQEkD8$950|kj0F1RE3jR8S6HW zOYwS$!gcCqqj*d9GdBINc%S`W*m^iBOxB-uiTG51{!m+rBIv4c4aISk^n8S`k6J=Z zmt!$sCA>e?R$FJwYwD_{_+2FiNMU`Pez?n9e9v;KI1~8HxwW2GIZg2bV0^u8+FPx7L}6Onsq~fCmnIy0>x~9^0Wv zbv$RZ{r54o%xw7Nj=wGB34(@(+?znruXUR6NR}D;lEVupcmb#bu?MFpmZ)~J%1w|Q zoj%1K2G{0xJb@MeR&xlQx=zJ(oKBxZ%%3;3M6`#VyP4+HEkiS7c|fICVD0{Mj=T+) z2dNJE6P2$GEx<@)B3yN1dXf9UFqM9|7QQ}Re^J&ueWE;6%H>b$iL8)} zA`kH$KBZLA{kEFAM^Dg7-X_yd(`weXf{O8AG@{^yRN;BIE2STFs{Yt3Jfu8v@wgu& zTB}o+tdkxjRN{F^i->+z+7hF~B;UCN08fzko&T3yXkj>>tbh0<8wtEDx(1BeV=6_X zXN^5B^ekAf-Kwsg+Ni#gII?E+HW4zfilqL}wf;6z*F>Bi{=bfJWxbLX4;#qcz?KX< z0YNP6HL!mGZs(*nDoCMXf1*S~u?2S2f;dE)NL6sUokw zV9zB>5SL}KbWJ+B!&*kD&W=sg*l3qW}cWWd-`pyk}UQIPP+%f)E9^M$-wJ8cTQn*LB zwUc5*4wQ`il+2o@_G-7)C#Qp>%-32(<|gtp;-BFvNlE){%RopFKJMfX&7apZcZndr zMDq?*L6G!XHf!=efsS+MAgUZr>!EG8I83&rE(Y(B=>1RKG*0KJXvO9<=S$_!+`zn8 z3dZ7+cw<#+W)hCszRheprNz^=pNCYJ3zxTt0hoSAntTY`2A;r20ERxicBRf6jvWT^ znH_%Fb~5Qt6nZ_>73n9c$v9RwaLQ(-t^ox2W+hlav?yJ738guG11GSnNq^Y-hTb{m zp!b@e4aV5Nq}8*R2@3I0L%9M$Ff+08VEycANy>th& z!!+6YzZCL;eh60ZJ{Ai3kjG007($ssr#xD_w=`Ox{Td&Ci%^e;J)Ze`5}Hn~LZWtb zjfOBTjL{ZJ!we7k7oY;)wflqpj*ym$=FPd zPuST>tsflJD-FWFQ2jFcp0#>Up0=e)*K{PSZtT9V4>(;3Wx=VqT0(=A4#QY?lceGx z#DDkj6cYlettYUjjy%XqV( zyfSuwz14ramQ zb5KnEPGO!`Pd<+)9Z{`WVgJymHiIuA}218 z8YeB$Fb&?2oRNAOL?clr1gQ93i{0)`lg?e|Lpm(>sT5VH!To8f@LZ}0pigz7p`xb# zr#_NjFf)KxCNd#$zHB<-(?UoQLM}kaLkWF%{$e$Z9%Le7TE~B-r@5i0-8Sr3 zh8o!`kk(ThqI|>}wa12e5&u>#xoRdW@gzc|SAUwyJaO zf8`x7{a%$vjVP10?xB0}o0$R#z(N9d!CmioU|MAUq*2~WQv0o0~Pc5y}CK~*v zyi%iqxYYsJx0!fb&eFIKO9XEcEhK|z4QtD%sy$|BKfzdquF zV_6M}i%==KyoBLHf&S1`d;BW(geobL`BX}jhxv$R$O1@p`cyD&csg|zGJ=+lfsHX$ z4l?Xo(L}2kr%QpcoY-h1ZNz-O;rKUjl#&27nV%$o$pIu?!w=h-MGiwL9j2G=WzGu^*cL z=mAiflhTH!3I{?6ks7~Oupr`56n=z{O0;6AtDSgrMnC!%!4XKlPs<~1}8>RFD@Y5m_UXAZBb*oQx*;Y^>lL z+gWADk%~`6f6-&9X{2{m`TBU23_MyN;y}DrWY(PG6xPtYNKc6M$Hevg=b&)FL`JT# ztLU>vlP#RzL<70?0VU5#7aKo1v+8d0wXInXwPMY0}0vQ=0 zjesWpw0Z_d1PVbdt)qPp63`oP0^vWGh53Lnf8l)~MDM+m)8*0VA}BVV8WFYcqb$km zc_Id-=-9Zs`J1oV`BnA1R5$qcm%jwbN#x@|$3#OB6QynZU-5k8^Q7_|ODW1G5x|5n zFCefng;{l^p@NJlwH&-&A4g);_H8#mx;%4)%wEpi!`~|tBS_d7=wH6uhkkd)CuvW> zoTp^>=y}Z89iCk42@M=1EN}`VR_Z0YA1|YPouHOfk|`A}8CbEfWtRKvgOzQ^tn?JFvAV;2V`xfUn&39`m$tvx>YtCp1j)wd8E+&y1iNo7- z4QFzfhucNnwa5V~-_Hsivvo*Mjk1Nw5?wXoS(}(7(EWJWDdsXw=>*8cDp9=$2P+U~ zccWf<-CiTq52w9cP9v2MbeD#QDL6zJE8-$Cm343ezDiNwUW* zda2zthjQg&dnli!$v@;MvQO(z4-X+`;Ujs{WH9IOFk4?Wmkky`-~-gdUpD^`q7N|O zq5YxSX2DWJG(`O`uxTeS5=%vX30xo6QW_E#eC;r}8h;Sxt_>lXiZt$Eh)9HJELeR= z31mhcuiFg+?Jxx9u^EQ;2BxaI5SosY#rIR8`uyqeKW`4rgStMgsMV%mp*TL@GbgP1 zVCa?59=L+Pq84OKqUWMb#F>aXuffkVcvGXFJR98iL4KqKXB!3G^n6V(ZuwDuW9p~7 z>RPfGU_}0?RHQ&g{gnOuj$JbHAbv~ufl)F-4-bj%=zobN8AqcW_x!C0%A9-?o((#ltlTs8q92LW&?SBphX|c&5e`N@c(dG z&u06gOu}#%7pFN_bUEEV^38ac*FC$=)-7Cvbh=z_H;=~Kep%hpIemA zBhSv?DHIGeQZMMJ&><7aJ7KIZ`g1|lhze_W*Z!XXIugb02kyfD(NF*E z#_`&HlyZlAm;2|ZqqS$W@jdpD{Z?mmw9~o^Lou;s$C_Q~8BXV}H9J}`5ksY%J;4@| zW&d5y@{l!Ic1C~0JpX@q!}RN~X8jFloy4}NJAiUJBQkPsM;HBRpEq=$x?|kc%yF%) zBHN^-U^@|b)N?F&P$t5v=okQ?lBZrVc*+4=1xM*Pi40K)gD_NR0`iZ_#OqH?`jcwe zQDy-c<%?w|qza=`K;g|JBu-Q_K?S+0atczVCYh)Klfq25=vD2B6X)MVwMJyPh@Hx) zyv2yhD_Df6OHg#(QoUd#=TmQI?Ti$#bjz~)pP$3EO)su(j=%b3hs>POoMT&@>=Rw` zdVif5ta`HAmw^s17DnIWkm`?_091wh;XNJ;C3vFsJrURx(!O;Hdx*mkeqQVTBjuVwULKTbO^o;UR+g_bQGTv=Er)giXf_eKUA#(iSc zzK5~LMYK&+E(WKtfT_spf$zEoOeU8}Od)<`94R=JCCLQ){e*qB<3DM~Lg*atO4<;} zX=!C=0GOCbeM&hG?Vtm6g&r_fO4IT2NSPf8O*gb{540!+7-CT?Rn$-BHTu=B837av zD2?H03BwSRC8R+-GS!5l2}}dJck9}vb0;^)PPEmQ>k?Wy!VtE%*35iU?tpHN^($u= z7NPS-k=)c&)HlD|?RC8dOepSJnA5&*LdblzU}^1wO`~1qwfi4#e54cm9Vdkg;m^Wz~Ef^z@X*2p|f3g_;S+p&3+WR&PF=YcUZc7G;YT z_4LM12tQ!vM2FpBGcM}jxJL(7NeG?AVx7LqPGnVvZ&nZTSEo0jyYG$L?(c@{aPgQf zk0|FZYY$)6>YH&>{i#W0IjPyD{T1}H<>oD10;fQGIMeU2Rh-31K?I9&^o=vRcwqKF zut+`^-5mbYzh)Gn6cdW|TTs*pt0>o>s0)Mwe@8BhO|>9uvu<%1xjz@uYAvX0zKEIT zg9*%Bm^TLA{m*Yyb6dP!+pKJ-Rn&D!C630)W7Q->cB&eD;yTW5?RdjU(Gun}PCdVb zM4WrB`ToJZ+Bc`}zWWr$?tSq48h&tM`QPTx|J!nYe8PW%IW$CjLc6l4-{9-#CI z+!z3m1-QYFc_~9?Mn+y{o-?*Wo!pla* zJ`~84o`K>NCxWwWMow~@h%iXN1f#InA5%8M$u1)>XZOdI>6tB2Ll>1%T`j!a)$^|DI6iZZk36wjqi8~4S8%JR`S{$9TRk(nsr3;o&`Ue2pyetwfD&kpOsvOhhY)FF z45e<#;*cxF6eOd~T83aVHFCG)(3kB2FAp9p63Moq)lQ2Y(66*glB|z6SuX7EKtx)z z3JR4-t~OY0U>yuL(%b$=PX}yPumfFPwE<}-hSSmji*41SNkwsCd`?bwZcc7ibOaw{ z0dS}spXXu-Psjj>iEVBzomhRebSB!FpI+U+Tb8GAKr`>mlE>ZH90w@#f=)|)Xk|&HKAYG zcLZ>h3_g~>YW&xDeatK=h+wkzP>1#hSL@s?Q5OQHwew=9_D9y~SQB}&fN5P_ejVOq zMrGv;0&4k6Khy1=x#-AWMAz!#1Dwj2SJq4qCf+-n_kQ2W@S!=C<3`(6<_)e0A;9f|6yY zwrxGVtfb)DrJ$taU8=l7|9O!{)+!mp>Of9TFzd(~m? ziQj;xX;(B=j*xzVJV;6dRA7)c4bFh=X@*aop$Q-3o-=b~i4*pQwu)1@*8ztSMA0$7 z9lD5pr9?U+GcNN5y^mXiJuGN9DKkTG@=}wt`8k>~kn-}S{Bi>18E@yJGX-LJhE)By zpx@fUs4@N2BL(|M_n5JD?#Bgt7OtOt{A-F3DNa z?~n?)ps%?(1yM%Y)n&4U%~oz8$GrIxGLU5$NWVcINegeeCx8wg%gW41PcztvcKK#% zIm2CBpVRBNHHnRwO>D$8MvlaMzRcA3?u|&aJCY*qd3%$j+?W#OePCu(N~A2at6#Df z-){PjU1EFHdsCXGrZu}6hhIxsgltY$+f4NwI&wSZ^xysT?QHLYYkSy8O%v=9&M zE1brSS8CL_;6fP9Gp5Fa8Nu}|yl#0sWxlkY430<85vAXBjROE=7@>q(DP%5Gr*>uf zttU;ZGsUCdMiD1PS7eJ32CoK>Fu~yD`LKyx zw-2>at_z_$wX3z?`i5!sxTwx1(<+o}*8>2Jm9t=sk0(sK3~nIxBHg+(K<&~bFh>E! zE$lQ3$S(MUyrv>WO?5JIGLgp;D@xNQ)Jml(g6h<+c7E$g)9NhtTi-CPo+)JIgbf}; zU6Ee9P%EWZ0@bNq9sJfeOsi)aSvfI-YwP*q7z2L$8>Za@50TwCAz`%h!&l5~wS`H) zgB{}Zr@uWC5m0zgbN9(4MPevhnc-C0gdY7(EgaQoVU~r#F9Xqx91Z|SoFk4<9o!>{ z&P+;;J&xwAqi6{QI8dGq$$Uy&X5w3sSO-8ro2+U3W$Zhid4h6R{dTG3RNJ=o!rC^%+O};stZid@-u!hp$(M7ItA{HKpaDRUs8fLKMMk#`v@P(7mg(@V z1E}VB^qtUFc0jv6J=Ns=%3BU!3EJ8pp`GC<>U5@NX3j95gBDCddgAwG7jynyo@bDT&D&y+H>jO zvtP!@T<-v9X9w)w!8^Mwidt(?)Go@L=V&Uo(7?9P)Q(~Q-Doe*$p*%rpuMUBZ7t^A z&S0>82wJwHy*q=xF`7EHpjB4#Jj6G-A&W>OG*H{2Ni}KB9;1SH2AKqH^Zt^Z%9&Ab z=-IpKQwiyV~k2P{S0b4Q$zMdW}CN?bL`g)J#7`7vwA>B zePnzyW7Vj9Bf9{6-nEKVhida(D~D)$t=2K`Gnq&xF+UIU>Z6LPM}0_E!FR4QHVuQN zH+44O4$$5g`niC;gl|U zJ2Am$^Czk@{Tj44yAdlt_UBJj$zYHw8R}HBgS!^~{eO7Se`0pR>~O4@_niJWDtj={ z+=u#o?LfSXWfvr!qhnCogke}Z1z}Kr0*9>!gAoASIL(p(004N}Vqjo!gu;!C42+YQ zn3zJCmNMO94q$F$e!!y2(#rClHJG)ZbwBG%HfOeMwiWD*?E38C?338nvY+CR<8a~F z$#Iucjx&aHJ}00dcz~iC1^@s!M2c z;wf2`a>_F0gz`z{)J$r9b*Uz3!?m+Itryk%87YlI#vZetgf+~1W=nP^ zyN!LozVG->cjvhC+O6T9bRW6DJ<7AZrQUY$sjvHM{G)*s91BZ?Ytl`QQb$zOAetAg zkNr4nJUw0-Z;wyLx8t`UDd0c=a)Q#JE@%%1g2`YZ*bI(>>)<8$4Jqirtgtw&30uQ% za1a~|XTbSz13U!J!kh33d=G!4G>Atwf+zs441z`k>)x5}JjU{l5URbZa zL2Wx;+uk4Ak7(R)YK&sC*5i2QUSOO6LktX002Qdp11wXi%7cvaS(S${@Ku$ES?9Yd zk8n1ySd~Xv9r&oqvseal9FrtPnG9iqgvg;Vpp&b3A%q{rHF@HA2=R=Y1X&ahWJnUh zk5{q|$&({Ql8pGbq*{&?SsZB55+p1Uc|2(Fk|d6nl1TqG-Q>}B>9yTtQ+4YRNn-fW zqd_Oz$p^>WbBD7_mHm1~w`!{HeZwyZ!ZMX=sjmc4Kqk$RBt)15IYC>HNE;fAZ1?Nm z!Alme@Sh?_7LD|dp%Es7Xu0Q%n_o4AP@H($Y{7$p0ssI2z^mT2ZL7C!|7UN}fVD)+ z)B@cJ0@bjhdMX5hfsl|xJ6=6_7Ip02n{Pj!{QL9k-MT%ymMvMaYQctWi@vPc^x@Hq zm`^DQNpWcz5l2pJ2|KXw)R}V^1`L|eZ^*C_qsEMD&}d$hW-V^CYSXU6q+V0HUAi`> z&y_ASdfdt>n>OoCR$f6#XN@3u*a`#y002144_(`v`LbP?Y}>Z^GSAs{ZbpwT-8$_` zup`Z$3bEo0$rs~Tx_F0DBr8&9*ob;Z3RJps>&}g0y;?Sqiva5 zWhqmt?b$1rE}S?uAytz(Gn&2e);kN{n>BC6q9w~dShs3Ti;uQ!*!0PnFFyO~yKjE@ z>9=40`0Kxa0t5;YB3P(!VIoC{675|7v)9j_#?HvW;uq@U!{i*q;#yji&t96BX=G$* z3Ze}R91Wnf3s~I1+ynr`7i_fv004N}WANU=a3UyTBO_y9XctEV10#z=M{)>E0tB{Ov8Zu~ huru>%?O^=hx`Cy4BNKzmX4Vuo1{W6qmYFU>002F;>O%kk literal 0 HcmV?d00001 diff --git a/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff2 b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4312f3d1a34dd865724c73068da4d57ae44d99d2 GIT binary patch literal 17060 zcmV)4K+3;&Pew8T0RR91079ey5C8xG0FFQa0766n0RR9100000000000000000000 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 literal 0 HcmV?d00001 diff --git a/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff2 b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..69b6584a65724b22d6c9f6792ae62a92b0e5c539 GIT binary patch literal 17624 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

$(W6y!&?uM2}!xMMMI4tL&pd*?pyh)`)aH) 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 literal 0 HcmV?d00001 diff --git a/app/Views/_assets/fonts/montserrat/montserrat-600.woff b/app/Views/_assets/fonts/montserrat/montserrat-600.woff new file mode 100644 index 0000000000000000000000000000000000000000..e7f8a31ba35c59891483cc67b9471ff78c7b000f GIT binary patch literal 23628 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 literal 0 HcmV?d00001 diff --git a/app/Views/_assets/fonts/montserrat/montserrat-regular.woff b/app/Views/_assets/fonts/montserrat/montserrat-regular.woff new file mode 100644 index 0000000000000000000000000000000000000000..676a065e24ff77f050f1fe75c0f295d4ab48acbb GIT binary patch literal 23480 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 literal 0 HcmV?d00001 diff --git a/app/Views/_assets/icons/add-box.svg b/app/Views/_assets/icons/add-box.svg old mode 100644 new mode 100755 index dc56100a..5a6fd80c --- a/app/Views/_assets/icons/add-box.svg +++ b/app/Views/_assets/icons/add-box.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/add.svg b/app/Views/_assets/icons/add.svg old mode 100644 new mode 100755 diff --git a/app/Views/_assets/icons/alert.svg b/app/Views/_assets/icons/alert.svg old mode 100644 new mode 100755 index 02da88f9..7dd74af7 --- a/app/Views/_assets/icons/alert.svg +++ b/app/Views/_assets/icons/alert.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/bookmark.svg b/app/Views/_assets/icons/bookmark.svg old mode 100644 new mode 100755 index f340d6ed..d3bde5f3 --- a/app/Views/_assets/icons/bookmark.svg +++ b/app/Views/_assets/icons/bookmark.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/chat.svg b/app/Views/_assets/icons/chat.svg new file mode 100755 index 00000000..594b1503 --- /dev/null +++ b/app/Views/_assets/icons/chat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/cloud-off.svg b/app/Views/_assets/icons/cloud-off.svg new file mode 100755 index 00000000..7177145a --- /dev/null +++ b/app/Views/_assets/icons/cloud-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/dashboard.svg b/app/Views/_assets/icons/dashboard.svg old mode 100644 new mode 100755 index 1d2279e5..a25c9e47 --- a/app/Views/_assets/icons/dashboard.svg +++ b/app/Views/_assets/icons/dashboard.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/delete-bin.svg b/app/Views/_assets/icons/delete-bin.svg old mode 100644 new mode 100755 index 91a963dd..bd1f9b30 --- a/app/Views/_assets/icons/delete-bin.svg +++ b/app/Views/_assets/icons/delete-bin.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/download.svg b/app/Views/_assets/icons/download.svg old mode 100644 new mode 100755 index 42702f57..b3ea2a9f --- a/app/Views/_assets/icons/download.svg +++ b/app/Views/_assets/icons/download.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/edit.svg b/app/Views/_assets/icons/edit.svg old mode 100644 new mode 100755 index ace6db3a..d9efb56c --- a/app/Views/_assets/icons/edit.svg +++ b/app/Views/_assets/icons/edit.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/external-link.svg b/app/Views/_assets/icons/external-link.svg old mode 100644 new mode 100755 index 2a69c5f3..2efc6259 --- a/app/Views/_assets/icons/external-link.svg +++ b/app/Views/_assets/icons/external-link.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/eye.svg b/app/Views/_assets/icons/eye.svg old mode 100644 new mode 100755 index 0b8b52e0..f14a8b7d --- a/app/Views/_assets/icons/eye.svg +++ b/app/Views/_assets/icons/eye.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/file-copy.svg b/app/Views/_assets/icons/file-copy.svg old mode 100644 new mode 100755 index 491df11d..0b907436 --- a/app/Views/_assets/icons/file-copy.svg +++ b/app/Views/_assets/icons/file-copy.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/file.svg b/app/Views/_assets/icons/file.svg old mode 100644 new mode 100755 index dcddb396..d10c86cf --- a/app/Views/_assets/icons/file.svg +++ b/app/Views/_assets/icons/file.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/folder-user.svg b/app/Views/_assets/icons/folder-user.svg old mode 100644 new mode 100755 index 590e6aa1..6dcd37c4 --- a/app/Views/_assets/icons/folder-user.svg +++ b/app/Views/_assets/icons/folder-user.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/funding/gofundme.svg b/app/Views/_assets/icons/funding/gofundme.svg new file mode 100755 index 00000000..8573eaa3 --- /dev/null +++ b/app/Views/_assets/icons/funding/gofundme.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/helloasso.svg b/app/Views/_assets/icons/funding/helloasso.svg new file mode 100755 index 00000000..a16faf73 --- /dev/null +++ b/app/Views/_assets/icons/funding/helloasso.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/indiegogo.svg b/app/Views/_assets/icons/funding/indiegogo.svg new file mode 100755 index 00000000..0d6240d3 --- /dev/null +++ b/app/Views/_assets/icons/funding/indiegogo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/kickstarter.svg b/app/Views/_assets/icons/funding/kickstarter.svg new file mode 100755 index 00000000..2f055f75 --- /dev/null +++ b/app/Views/_assets/icons/funding/kickstarter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/kisskissbankbank.svg b/app/Views/_assets/icons/funding/kisskissbankbank.svg new file mode 100755 index 00000000..f3041450 --- /dev/null +++ b/app/Views/_assets/icons/funding/kisskissbankbank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/liberapay.svg b/app/Views/_assets/icons/funding/liberapay.svg new file mode 100755 index 00000000..e3e261bc --- /dev/null +++ b/app/Views/_assets/icons/funding/liberapay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/patreon.svg b/app/Views/_assets/icons/funding/patreon.svg new file mode 100755 index 00000000..0c02798f --- /dev/null +++ b/app/Views/_assets/icons/funding/patreon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/paypal.svg b/app/Views/_assets/icons/funding/paypal.svg new file mode 100755 index 00000000..5e055a78 --- /dev/null +++ b/app/Views/_assets/icons/funding/paypal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/tipeee.svg b/app/Views/_assets/icons/funding/tipeee.svg new file mode 100755 index 00000000..2984b9b3 --- /dev/null +++ b/app/Views/_assets/icons/funding/tipeee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/funding/ulule.svg b/app/Views/_assets/icons/funding/ulule.svg new file mode 100755 index 00000000..c4231b3e --- /dev/null +++ b/app/Views/_assets/icons/funding/ulule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/group.svg b/app/Views/_assets/icons/group.svg old mode 100644 new mode 100755 index fff52909..5c2f10ee --- a/app/Views/_assets/icons/group.svg +++ b/app/Views/_assets/icons/group.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/heart.svg b/app/Views/_assets/icons/heart.svg new file mode 100755 index 00000000..f10aafa4 --- /dev/null +++ b/app/Views/_assets/icons/heart.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/line-chart.svg b/app/Views/_assets/icons/line-chart.svg old mode 100644 new mode 100755 index c3080e57..dc43cd7d --- a/app/Views/_assets/icons/line-chart.svg +++ b/app/Views/_assets/icons/line-chart.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/link.svg b/app/Views/_assets/icons/link.svg new file mode 100755 index 00000000..3b7c8e06 --- /dev/null +++ b/app/Views/_assets/icons/link.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/links.svg b/app/Views/_assets/icons/links.svg deleted file mode 100644 index 3bff8657..00000000 --- a/app/Views/_assets/icons/links.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/Views/_assets/icons/map-pin.svg b/app/Views/_assets/icons/map-pin.svg index 8e2366f3..5950f056 100644 --- a/app/Views/_assets/icons/map-pin.svg +++ b/app/Views/_assets/icons/map-pin.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/menu.svg b/app/Views/_assets/icons/menu.svg old mode 100644 new mode 100755 diff --git a/app/Views/_assets/icons/mic.svg b/app/Views/_assets/icons/mic.svg old mode 100644 new mode 100755 index 836c580d..becff50c --- a/app/Views/_assets/icons/mic.svg +++ b/app/Views/_assets/icons/mic.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/more.svg b/app/Views/_assets/icons/more.svg old mode 100644 new mode 100755 index d77a746c..5f6b5dba --- a/app/Views/_assets/icons/more.svg +++ b/app/Views/_assets/icons/more.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/movie.svg b/app/Views/_assets/icons/movie.svg old mode 100644 new mode 100755 index a3eaa1b7..f92dd60f --- a/app/Views/_assets/icons/movie.svg +++ b/app/Views/_assets/icons/movie.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/pages.svg b/app/Views/_assets/icons/pages.svg old mode 100644 new mode 100755 index e33ed93f..3d28c400 --- a/app/Views/_assets/icons/pages.svg +++ b/app/Views/_assets/icons/pages.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/podcasting/amazon.svg b/app/Views/_assets/icons/podcasting/amazon.svg new file mode 100755 index 00000000..82ba8b79 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/amazon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/antennapod.svg b/app/Views/_assets/icons/podcasting/antennapod.svg new file mode 100755 index 00000000..26e9699d --- /dev/null +++ b/app/Views/_assets/icons/podcasting/antennapod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/apple.svg b/app/Views/_assets/icons/podcasting/apple.svg new file mode 100755 index 00000000..358ce6f5 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/blubrry.svg b/app/Views/_assets/icons/podcasting/blubrry.svg new file mode 100755 index 00000000..d3db4e25 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/blubrry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/breaker.svg b/app/Views/_assets/icons/podcasting/breaker.svg new file mode 100755 index 00000000..98fe46f3 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/breaker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/castbox.svg b/app/Views/_assets/icons/podcasting/castbox.svg new file mode 100755 index 00000000..f3b4a197 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/castbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/castopod.svg b/app/Views/_assets/icons/podcasting/castopod.svg new file mode 100755 index 00000000..ff91790c --- /dev/null +++ b/app/Views/_assets/icons/podcasting/castopod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/castro.svg b/app/Views/_assets/icons/podcasting/castro.svg new file mode 100755 index 00000000..d3716c8e --- /dev/null +++ b/app/Views/_assets/icons/podcasting/castro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/chartable.svg b/app/Views/_assets/icons/podcasting/chartable.svg new file mode 100755 index 00000000..6383bbfc --- /dev/null +++ b/app/Views/_assets/icons/podcasting/chartable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/deezer.svg b/app/Views/_assets/icons/podcasting/deezer.svg new file mode 100755 index 00000000..869b06ef --- /dev/null +++ b/app/Views/_assets/icons/podcasting/deezer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/fyyd.svg b/app/Views/_assets/icons/podcasting/fyyd.svg new file mode 100755 index 00000000..f8b6518c --- /dev/null +++ b/app/Views/_assets/icons/podcasting/fyyd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/google.svg b/app/Views/_assets/icons/podcasting/google.svg new file mode 100755 index 00000000..51056db8 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/ivoox.svg b/app/Views/_assets/icons/podcasting/ivoox.svg new file mode 100755 index 00000000..6715e452 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/ivoox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/listennotes.svg b/app/Views/_assets/icons/podcasting/listennotes.svg new file mode 100755 index 00000000..05f99886 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/listennotes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/overcast.svg b/app/Views/_assets/icons/podcasting/overcast.svg new file mode 100755 index 00000000..74252263 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/overcast.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/playerfm.svg b/app/Views/_assets/icons/podcasting/playerfm.svg new file mode 100755 index 00000000..fc24e26d --- /dev/null +++ b/app/Views/_assets/icons/podcasting/playerfm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/pocketcasts.svg b/app/Views/_assets/icons/podcasting/pocketcasts.svg new file mode 100755 index 00000000..bb551312 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/pocketcasts.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podbean.svg b/app/Views/_assets/icons/podcasting/podbean.svg new file mode 100755 index 00000000..c8577b5f --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podbean.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podcastaddict.svg b/app/Views/_assets/icons/podcasting/podcastaddict.svg new file mode 100755 index 00000000..2db58457 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podcastaddict.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podcastindex.svg b/app/Views/_assets/icons/podcasting/podcastindex.svg new file mode 100755 index 00000000..4b88645e --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podcastindex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podchaser.svg b/app/Views/_assets/icons/podcasting/podchaser.svg new file mode 100755 index 00000000..ca80217d --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podchaser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podcloud.svg b/app/Views/_assets/icons/podcasting/podcloud.svg new file mode 100755 index 00000000..9abda5d7 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podcloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podfriend.svg b/app/Views/_assets/icons/podcasting/podfriend.svg new file mode 100755 index 00000000..6c96c60d --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podfriend.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podinstall.svg b/app/Views/_assets/icons/podcasting/podinstall.svg new file mode 100755 index 00000000..9bec6ac6 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podinstall.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podlink.svg b/app/Views/_assets/icons/podcasting/podlink.svg new file mode 100755 index 00000000..c980f8f6 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podlink.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podtail.svg b/app/Views/_assets/icons/podcasting/podtail.svg new file mode 100755 index 00000000..09426777 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podtail.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/podverse.svg b/app/Views/_assets/icons/podcasting/podverse.svg new file mode 100755 index 00000000..ccec56af --- /dev/null +++ b/app/Views/_assets/icons/podcasting/podverse.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/radiopublic.svg b/app/Views/_assets/icons/podcasting/radiopublic.svg new file mode 100755 index 00000000..1803cccd --- /dev/null +++ b/app/Views/_assets/icons/podcasting/radiopublic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/spotify.svg b/app/Views/_assets/icons/podcasting/spotify.svg new file mode 100755 index 00000000..da84da85 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/spotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/spreaker.svg b/app/Views/_assets/icons/podcasting/spreaker.svg new file mode 100755 index 00000000..06ddebe3 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/spreaker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/stitcher.svg b/app/Views/_assets/icons/podcasting/stitcher.svg new file mode 100755 index 00000000..b2f7c0d0 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/stitcher.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/podcasting/tunein.svg b/app/Views/_assets/icons/podcasting/tunein.svg new file mode 100755 index 00000000..8ebef8d4 --- /dev/null +++ b/app/Views/_assets/icons/podcasting/tunein.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/question.svg b/app/Views/_assets/icons/question.svg old mode 100644 new mode 100755 index 984376ae..b7fd91ed --- a/app/Views/_assets/icons/question.svg +++ b/app/Views/_assets/icons/question.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/repeat.svg b/app/Views/_assets/icons/repeat.svg new file mode 100644 index 00000000..c5a26047 --- /dev/null +++ b/app/Views/_assets/icons/repeat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/rss.svg b/app/Views/_assets/icons/rss.svg old mode 100644 new mode 100755 index e8cff801..723552d9 --- a/app/Views/_assets/icons/rss.svg +++ b/app/Views/_assets/icons/rss.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/scales.svg b/app/Views/_assets/icons/scales.svg old mode 100644 new mode 100755 index 2592d2ca..7383e06a --- a/app/Views/_assets/icons/scales.svg +++ b/app/Views/_assets/icons/scales.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/settings.svg b/app/Views/_assets/icons/settings.svg old mode 100644 new mode 100755 index 8ab66f65..893c92d2 --- a/app/Views/_assets/icons/settings.svg +++ b/app/Views/_assets/icons/settings.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/social/castopod.svg b/app/Views/_assets/icons/social/castopod.svg new file mode 100755 index 00000000..ff91790c --- /dev/null +++ b/app/Views/_assets/icons/social/castopod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/discord.svg b/app/Views/_assets/icons/social/discord.svg new file mode 100755 index 00000000..ad22eebd --- /dev/null +++ b/app/Views/_assets/icons/social/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/facebook.svg b/app/Views/_assets/icons/social/facebook.svg new file mode 100755 index 00000000..4d215877 --- /dev/null +++ b/app/Views/_assets/icons/social/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/funkwhale.svg b/app/Views/_assets/icons/social/funkwhale.svg new file mode 100755 index 00000000..4abbeaad --- /dev/null +++ b/app/Views/_assets/icons/social/funkwhale.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/instagram.svg b/app/Views/_assets/icons/social/instagram.svg new file mode 100755 index 00000000..a56bb3e9 --- /dev/null +++ b/app/Views/_assets/icons/social/instagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/linkedin.svg b/app/Views/_assets/icons/social/linkedin.svg new file mode 100755 index 00000000..78000a7a --- /dev/null +++ b/app/Views/_assets/icons/social/linkedin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/mastodon.svg b/app/Views/_assets/icons/social/mastodon.svg new file mode 100755 index 00000000..f9315563 --- /dev/null +++ b/app/Views/_assets/icons/social/mastodon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/mobilizon.svg b/app/Views/_assets/icons/social/mobilizon.svg new file mode 100755 index 00000000..b7fd11a6 --- /dev/null +++ b/app/Views/_assets/icons/social/mobilizon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/peertube.svg b/app/Views/_assets/icons/social/peertube.svg new file mode 100755 index 00000000..0fb16946 --- /dev/null +++ b/app/Views/_assets/icons/social/peertube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/pixelfed.svg b/app/Views/_assets/icons/social/pixelfed.svg new file mode 100755 index 00000000..b3471340 --- /dev/null +++ b/app/Views/_assets/icons/social/pixelfed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/plume.svg b/app/Views/_assets/icons/social/plume.svg new file mode 100755 index 00000000..a1009fa5 --- /dev/null +++ b/app/Views/_assets/icons/social/plume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/reddit.svg b/app/Views/_assets/icons/social/reddit.svg new file mode 100755 index 00000000..a97eb3e2 --- /dev/null +++ b/app/Views/_assets/icons/social/reddit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/slack.svg b/app/Views/_assets/icons/social/slack.svg new file mode 100755 index 00000000..03fa2ede --- /dev/null +++ b/app/Views/_assets/icons/social/slack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/tiktok.svg b/app/Views/_assets/icons/social/tiktok.svg new file mode 100755 index 00000000..0362bd93 --- /dev/null +++ b/app/Views/_assets/icons/social/tiktok.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/twitch.svg b/app/Views/_assets/icons/social/twitch.svg new file mode 100755 index 00000000..dbc56ddc --- /dev/null +++ b/app/Views/_assets/icons/social/twitch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/twitter.svg b/app/Views/_assets/icons/social/twitter.svg new file mode 100755 index 00000000..c32aa097 --- /dev/null +++ b/app/Views/_assets/icons/social/twitter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/writefreely.svg b/app/Views/_assets/icons/social/writefreely.svg new file mode 100755 index 00000000..e6a02e09 --- /dev/null +++ b/app/Views/_assets/icons/social/writefreely.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/social/youtube.svg b/app/Views/_assets/icons/social/youtube.svg new file mode 100755 index 00000000..dca4bf6f --- /dev/null +++ b/app/Views/_assets/icons/social/youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/Views/_assets/icons/star-smile.svg b/app/Views/_assets/icons/star-smile.svg new file mode 100755 index 00000000..05014c31 --- /dev/null +++ b/app/Views/_assets/icons/star-smile.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/timer.svg b/app/Views/_assets/icons/timer.svg old mode 100644 new mode 100755 index 4f2136e6..21ab4767 --- a/app/Views/_assets/icons/timer.svg +++ b/app/Views/_assets/icons/timer.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/app/Views/_assets/icons/upload-cloud.svg b/app/Views/_assets/icons/upload-cloud.svg new file mode 100755 index 00000000..b87c7581 --- /dev/null +++ b/app/Views/_assets/icons/upload-cloud.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/user-add.svg b/app/Views/_assets/icons/user-add.svg old mode 100644 new mode 100755 index ab808608..2d56227f --- a/app/Views/_assets/icons/user-add.svg +++ b/app/Views/_assets/icons/user-add.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/icons/user.svg b/app/Views/_assets/icons/user.svg old mode 100644 new mode 100755 index 9e64bb56..f6566c8e --- a/app/Views/_assets/icons/user.svg +++ b/app/Views/_assets/icons/user.svg @@ -1,6 +1,6 @@ - + diff --git a/app/Views/_assets/images/castopod-cover-default.jpg b/app/Views/_assets/images/castopod-cover-default.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9fe87fd67b898475400ed24dc255351dfddf2840 GIT binary patch literal 13020 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 -

-