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 00000000..468422fe Binary files /dev/null and b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff differ 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 00000000..4312f3d1 Binary files /dev/null and b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-700.woff2 differ diff --git a/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff new file mode 100644 index 00000000..e3c9edce Binary files /dev/null and b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff differ 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 00000000..69b6584a Binary files /dev/null and b/app/Views/_assets/fonts/kumbh-sans/kumbh-sans-regular.woff2 differ 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 00000000..e7f8a31b Binary files /dev/null and b/app/Views/_assets/fonts/montserrat/montserrat-600.woff differ diff --git a/app/Views/_assets/fonts/montserrat/montserrat-600.woff2 b/app/Views/_assets/fonts/montserrat/montserrat-600.woff2 new file mode 100644 index 00000000..29cc1a97 Binary files /dev/null and b/app/Views/_assets/fonts/montserrat/montserrat-600.woff2 differ 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 00000000..676a065e Binary files /dev/null and b/app/Views/_assets/fonts/montserrat/montserrat-regular.woff differ diff --git a/app/Views/_assets/fonts/montserrat/montserrat-regular.woff2 b/app/Views/_assets/fonts/montserrat/montserrat-regular.woff2 new file mode 100644 index 00000000..70788c27 Binary files /dev/null and b/app/Views/_assets/fonts/montserrat/montserrat-regular.woff2 differ 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 00000000..9fe87fd6 Binary files /dev/null and b/app/Views/_assets/images/castopod-cover-default.jpg differ diff --git a/app/Views/_assets/images/logo-castopod.svg b/app/Views/_assets/images/castopod-logo.svg similarity index 96% rename from app/Views/_assets/images/logo-castopod.svg rename to app/Views/_assets/images/castopod-logo.svg index 0208232a..444036e3 100644 --- a/app/Views/_assets/images/logo-castopod.svg +++ b/app/Views/_assets/images/castopod-logo.svg @@ -1,5 +1,4 @@ - -