├── .github └── workflows │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── README.pt-br.md ├── TODO.md ├── app ├── .env.sample ├── assets │ ├── favicon │ │ ├── apple-touch-icon.png │ │ ├── favicon-96x96.png │ │ ├── favicon.ico │ │ └── favicon.svg │ └── opengraph.png ├── cache │ └── index.php ├── cli │ └── sintoniza ├── composer.json ├── config.php ├── inc │ ├── API.php │ ├── DB.php │ ├── Errors.php │ ├── Feed.php │ ├── GPodder.php │ ├── Language.php │ ├── index.php │ ├── languages │ │ ├── en.php │ │ ├── es.php │ │ └── pt-BR.php │ └── mysql.sql ├── index.php ├── logs │ └── index.php └── templates │ ├── admin.php │ ├── dashboard.php │ ├── dashboard │ ├── profile.php │ └── subscriptions.php │ ├── footer.php │ ├── forget-password.php │ ├── forget-password │ └── reset.php │ ├── header.php │ ├── index.php │ ├── login.php │ └── register.php ├── assets ├── antennapod_350.gif └── antennapod_350.mp4 ├── default.conf ├── docker-compose.yml └── docker-entrypoint.sh /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 🛠️ Main 2 | run-name: 🚀 Deploy de versão 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*.*.*' 8 | 9 | env: 10 | DOCKER_REGISTRY: ghcr.io 11 | DOCKER_IMAGE_NAME: ${{ github.repository }} 12 | 13 | jobs: 14 | docker-build: 15 | name: 🐳 Build e Push 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: 📥 Checkout código 23 | uses: actions/checkout@v4 24 | 25 | - name: 🏷️ Extrair versão da tag 26 | id: get_version 27 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 28 | 29 | - name: 🔧 Configurar QEMU 30 | uses: docker/setup-qemu-action@v3 31 | 32 | - name: 🛠️ Configurar Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | with: 35 | platforms: linux/amd64,linux/arm64,linux/arm/v7 36 | 37 | - name: 📋 Extrair metadata Docker 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_IMAGE_NAME }} 42 | tags: | 43 | type=semver,pattern={{version}} 44 | type=semver,pattern={{major}}.{{minor}} 45 | type=sha 46 | 47 | - name: 🔐 Login no Registry 48 | uses: docker/login-action@v3 49 | with: 50 | registry: ${{ env.DOCKER_REGISTRY }} 51 | username: ${{ github.actor }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - name: 🏗️ Build e Push 55 | uses: docker/build-push-action@v5 56 | with: 57 | context: . 58 | platforms: linux/amd64,linux/arm64,linux/arm/v7 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max 64 | 65 | publish-release: 66 | name: 📦 Publicar Release 67 | runs-on: ubuntu-latest 68 | needs: docker-build 69 | permissions: 70 | contents: write 71 | 72 | steps: 73 | - name: 📥 Checkout código 74 | uses: actions/checkout@v4 75 | 76 | - name: 🏷️ Extrair versão da tag 77 | id: get_version 78 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 79 | 80 | - name: 📝 Criar Release 81 | uses: softprops/action-gh-release@v1 82 | with: 83 | name: "🎉 Release v${{ steps.get_version.outputs.VERSION }}" 84 | tag_name: ${{ steps.get_version.outputs.VERSION }} 85 | generate_release_notes: true 86 | draft: false 87 | prerelease: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | composer.lock 3 | .env 4 | app/logs/*.log 5 | TODO.md 6 | app/cache/*.json 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/composer,windows,macos,linux 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=composer,windows,macos,linux 10 | 11 | ### Composer ### 12 | composer.phar 13 | /vendor/ 14 | 15 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 16 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 17 | # composer.lock 18 | 19 | ### Linux ### 20 | *~ 21 | 22 | # temporary files which can be created if a process still has a handle open of a deleted file 23 | .fuse_hidden* 24 | 25 | # KDE directory preferences 26 | .directory 27 | 28 | # Linux trash folder which might appear on any partition or disk 29 | .Trash-* 30 | 31 | # .nfs files are created when an open file is removed but is still being accessed 32 | .nfs* 33 | 34 | ### macOS ### 35 | # General 36 | .DS_Store 37 | .AppleDouble 38 | .LSOverride 39 | 40 | # Icon must end with two \r 41 | Icon 42 | 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear in the root of a volume 48 | .DocumentRevisions-V100 49 | .fseventsd 50 | .Spotlight-V100 51 | .TemporaryItems 52 | .Trashes 53 | .VolumeIcon.icns 54 | .com.apple.timemachine.donotpresent 55 | 56 | # Directories potentially created on remote AFP share 57 | .AppleDB 58 | .AppleDesktop 59 | Network Trash Folder 60 | Temporary Items 61 | .apdisk 62 | 63 | ### macOS Patch ### 64 | # iCloud generated files 65 | *.icloud 66 | 67 | ### Windows ### 68 | # Windows thumbnail cache files 69 | Thumbs.db 70 | Thumbs.db:encryptable 71 | ehthumbs.db 72 | ehthumbs_vista.db 73 | 74 | # Dump file 75 | *.stackdump 76 | 77 | # Folder config file 78 | [Dd]esktop.ini 79 | 80 | # Recycle Bin used on file shares 81 | $RECYCLE.BIN/ 82 | 83 | # Windows Installer files 84 | *.cab 85 | *.msi 86 | *.msix 87 | *.msm 88 | *.msp 89 | 90 | # Windows shortcuts 91 | *.lnk 92 | 93 | # End of https://www.toptal.com/developers/gitignore/api/composer,windows,macos,linux -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.0-fpm 2 | 3 | RUN apt-get update && apt-get install -y nginx cron nano procps unzip git \ 4 | && docker-php-ext-install pdo_mysql 5 | 6 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 7 | 8 | COPY default.conf /etc/nginx/sites-available/default 9 | 10 | RUN mkdir -p /app 11 | COPY app/ /app/ 12 | 13 | WORKDIR /app 14 | RUN composer install --no-interaction --optimize-autoloader 15 | 16 | # Copia e configura permissões do script de inicialização 17 | COPY docker-entrypoint.sh /usr/local/bin/ 18 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 19 | 20 | RUN touch /app/logs/cron.log 21 | RUN echo '* */12 * * * root php "/app/cli/sintoniza" >> /app/logs/cron.log 2>&1' >> /etc/crontab 22 | 23 | RUN chown -R www-data:www-data /app && chmod -R 755 /app 24 | 25 | EXPOSE 80 26 | 27 | ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎧 Sintoniza 2 | 3 | [![en](https://img.shields.io/badge/lang-en-red.svg)](https://github.com/manualdousuario/sintoniza/blob/master/README.md) 4 | [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](https://github.com/manualdousuario/sintoniza/blob/master/README.pt-br.md) 5 | 6 | Sintoniza is a powerful podcast synchronization server based on the gPodder protocol. It helps you keep your podcast subscriptions, episodes, and listening history in sync across all your devices. 7 | 8 | This project is a fork of [oPodSync](https://github.com/kd2org/opodsync). 9 | 10 | ## ✨ Features 11 | 12 | - Full compatibility with GPodder and NextCloud gPodder 13 | - Smart subscription and episode history tracking 14 | - Seamless device-to-device synchronization 15 | - Complete podcast and episode metadata 16 | - Global statistics dashboard 17 | - Administrative interface for user management 18 | - Built with PHP 8.0+ and MySQL/MariaDB 19 | 20 | ## 📱 Tested Applications 21 | 22 | - [AntennaPod](https://github.com/AntennaPod/AntennaPod) 3.5.0 - Android 23 | 24 | ![AntennaPod 3.5.0](https://github.com/manualdousuario/sintoniza/blob/main/assets/antennapod_350.gif?raw=true) 25 | 26 | - [Cardo](https://cardo-podcast.github.io) 1.90 - Windows/MacOS/Linux 27 | - [Kasts](https://invent.kde.org/multimedia/kasts) 21.88 - [Windows](https://cdn.kde.org/ci-builds/multimedia/kasts/)/Android/Linux 28 | - [gPodder](https://gpodder.github.io/) 3.11.4 - Windows/macOS/Linux/BSD 29 | 30 | ## 🐳 Docker Installation 31 | 32 | ### Prerequisites 33 | 34 | You only need: 35 | - Docker and docker compose 36 | 37 | ### Setup 38 | 39 | 1. First, get the compose file: 40 | ```bash 41 | curl -o ./docker-compose.yml https://raw.githubusercontent.com/manualdousuario/sintoniza/main/docker-compose.yml 42 | ``` 43 | 44 | 2. Configure the settings: 45 | ```bash 46 | nano docker-compose.yml 47 | ``` 48 | 49 | 3. Update the following configuration: 50 | ```yaml 51 | services: 52 | sintoniza: 53 | container_name: sintoniza 54 | image: ghcr.io/manualdousuario/sintoniza:latest 55 | ports: 56 | - "80:80" 57 | environment: 58 | DB_HOST: ${DB_HOST:-db} 59 | DB_USER: ${DB_USER} 60 | DB_PASS: ${DB_PASS} 61 | DB_NAME: ${DB_NAME} 62 | BASE_URL: ${BASE_URL:-https://sintoniza.xyz/} 63 | TITLE: ${TITLE:-Sintoniza} 64 | ADMIN_PASSWORD: ${ADMIN_PASSWORD:-p@ssw0rd} 65 | DEBUG: ${DEBUG:-false} 66 | ENABLE_SUBSCRIPTIONS: ${ENABLE_SUBSCRIPTIONS:-false} 67 | DISABLE_USER_METADATA_UPDATE: ${DISABLE_USER_METADATA_UPDATE:-false} 68 | SMTP_USER: ${SMTP_USER} 69 | SMTP_PASS: ${SMTP_PASS} 70 | SMTP_HOST: ${SMTP_HOST} 71 | SMTP_FROM: ${SMTP_FROM} 72 | SMTP_NAME: ${SMTP_NAME:-"Sintoniza"} 73 | SMTP_PORT: ${SMTP_PORT:-587}587 74 | SMTP_SECURE: ${SMTP_SECURE:-tls} 75 | SMTP_AUTH: ${SMTP_AUTH:-true} 76 | depends_on: 77 | - db 78 | db: 79 | image: mariadb:10.11 80 | container_name: db 81 | environment: 82 | MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASS} 83 | MYSQL_DATABASE: ${DB_NAME} 84 | MYSQL_USER: ${DB_USER} 85 | MYSQL_PASSWORD: ${DB_PASS} 86 | ports: 87 | - 3306:3306 88 | volumes: 89 | - ./mariadb/data:/var/lib/mysql 90 | ``` 91 | 92 | Note: All environment variables are required. 93 | 94 | ### Environment Variables 95 | 96 | | Variable | Description | Example | 97 | |----------|-------------|---------| 98 | | DB_HOST | Database host address | db | 99 | | DB_USER | Database username | user | 100 | | DB_PASS | Database password | password | 101 | | DB_NAME | Database name | database_name | 102 | | BASE_URL | Base URL for the application | https://sintoniza.xyz/ | 103 | | TITLE | Application title | Sintoniza | 104 | | ADMIN_PASSWORD | Administrator password | p@ssw0rd | 105 | | DEBUG | Enable debug mode | true | 106 | | ENABLE_SUBSCRIPTIONS | Allow subscriptions | true | 107 | | DISABLE_USER_METADATA_UPDATE | Prevent users from updating their metadata | false | 108 | | SMTP_USER | SMTP username for email | email@email.com | 109 | | SMTP_PASS | SMTP password | password | 110 | | SMTP_HOST | SMTP server host | smtp.email.com | 111 | | SMTP_FROM | Email address to send from | email@email.com | 112 | | SMTP_NAME | Sender name for emails | "Sintoniza" | 113 | | SMTP_PORT | SMTP server port | 587 | 114 | | SMTP_SECURE | SMTP security type | tls | 115 | | SMTP_AUTH | Enable SMTP authentication | true | 116 | 117 | 4. Start the services: 118 | ```bash 119 | docker compose up -d 120 | ``` 121 | 122 | ## 🛠️ Maintenance 123 | 124 | ### Logs 125 | 126 | View application logs: 127 | ```bash 128 | docker-compose logs sintoniza 129 | ``` 130 | 131 | Debug information can be found in `/app/logs` 132 | 133 | ### Security 134 | 135 | It's recommended to use [NGINX Proxy Manager](https://nginxproxymanager.com/) as a frontend web service for this container to add security and caching layers. Other web services like Caddy will also work correctly. 136 | 137 | --- 138 | 139 | Made with ❤️! If you have questions or suggestions, open an issue and we'll help! 😉 140 | 141 | A public instance is available at [PC do Manual](https://sintoniza.pcdomanual.com/) 142 | -------------------------------------------------------------------------------- /README.pt-br.md: -------------------------------------------------------------------------------- 1 | # 🎧 Sintoniza 2 | 3 | [![en](https://img.shields.io/badge/lang-en-red.svg)](https://github.com/manualdousuario/sintoniza/blob/master/README.md) 4 | [![pt-br](https://img.shields.io/badge/lang-pt--br-green.svg)](https://github.com/manualdousuario/sintoniza/blob/master/README.pt-br.md) 5 | 6 | Sintoniza é um poderoso servidor de sincronização de podcasts baseado no protocolo gPodder. Ele ajuda você a manter suas assinaturas, episódios e histórico de reprodução sincronizados em todos os seus dispositivos. 7 | 8 | Este projeto é um fork do [oPodSync](https://github.com/kd2org/opodsync). 9 | 10 | ## ✨ Recursos 11 | 12 | - Compatibilidade total com GPodder e NextCloud gPodder 13 | - Rastreamento inteligente de assinaturas e histórico de episódios 14 | - Sincronização perfeita entre dispositivos 15 | - Metadados completos de podcasts e episódios 16 | - Painel de estatísticas globais 17 | - Interface administrativa para gerenciamento de usuários 18 | - Desenvolvido com PHP 8.0+ e MySQL/MariaDB 19 | 20 | ## 📱 Aplicativos Testados 21 | 22 | - [AntennaPod](https://github.com/AntennaPod/AntennaPod) 3.5.0 - Android 23 | 24 | ![AntennaPod 3.5.0](https://github.com/manualdousuario/sintoniza/blob/main/assets/antennapod_350.gif?raw=true) 25 | 26 | - [Cardo](https://cardo-podcast.github.io) 1.90 - Windows/MacOS/Linux 27 | - [Kasts](https://invent.kde.org/multimedia/kasts) 21.88 - [Windows](https://cdn.kde.org/ci-builds/multimedia/kasts/)/Android/Linux 28 | - [gPodder](https://gpodder.github.io/) 3.11.4 - Windows/macOS/Linux/BSD 29 | 30 | ## 🐳 Instalação via Docker 31 | 32 | ### Pré-requisitos 33 | 34 | Você só precisa ter instalado: 35 | - Docker e docker compose 36 | 37 | ### Configuração 38 | 39 | 1. Primeiro, baixe o arquivo compose: 40 | ```bash 41 | curl -o ./docker-compose.yml https://raw.githubusercontent.com/manualdousuario/sintoniza/main/docker-compose.yml 42 | ``` 43 | 44 | 2. Configure as definições: 45 | ```bash 46 | nano docker-compose.yml 47 | ``` 48 | 49 | 3. Atualize as seguintes configurações: 50 | ```yaml 51 | services: 52 | sintoniza: 53 | container_name: sintoniza 54 | image: ghcr.io/manualdousuario/sintoniza:latest 55 | ports: 56 | - "80:80" 57 | environment: 58 | DB_HOST: mariadb 59 | DB_USER: user 60 | DB_PASS: password 61 | DB_NAME: database_name 62 | BASE_URL: https://sintoniza.xyz/ 63 | TITLE: Sintoniza 64 | ADMIN_PASSWORD: p@ssw0rd 65 | DEBUG: true 66 | ENABLE_SUBSCRIPTIONS: true 67 | DISABLE_USER_METADATA_UPDATE: false 68 | SMTP_USER: email@email.com 69 | SMTP_PASS: password 70 | SMTP_HOST: smtp.email.com 71 | SMTP_FROM: email@email.com 72 | SMTP_NAME: "Sintoniza" 73 | SMTP_PORT: 587 74 | SMTP_SECURE: tls 75 | SMTP_AUTH: true 76 | depends_on: 77 | - db 78 | db: 79 | image: mariadb:10.11 80 | container_name: db 81 | environment: 82 | MYSQL_ROOT_PASSWORD: root_password 83 | MYSQL_DATABASE: database_name 84 | MYSQL_USER: database_user 85 | MYSQL_PASSWORD: database_password 86 | ports: 87 | - 3306:3306 88 | volumes: 89 | - ./mariadb/data:/var/lib/mysql 90 | ``` 91 | 92 | Observação: Todas as variáveis de ambiente são obrigatórias. 93 | 94 | 4. Inicie os serviços: 95 | ```bash 96 | docker compose up -d 97 | ``` 98 | 99 | ## 🛠️ Manutenção 100 | 101 | ### Logs 102 | 103 | Visualize os logs da aplicação: 104 | ```bash 105 | docker-compose logs sintoniza 106 | ``` 107 | 108 | Informações de debug podem ser encontradas em `/app/logs` 109 | 110 | ### Segurança 111 | 112 | Recomenda-se usar o [NGINX Proxy Manager](https://nginxproxymanager.com/) como serviço web na frente deste container para adicionar camadas de segurança e cache. Outros serviços web como Caddy também funcionarão corretamente. 113 | 114 | --- 115 | 116 | Feito com ❤️! Se tiver dúvidas ou sugestões, abra uma issue que a gente ajuda! 😉 117 | 118 | Uma instância pública está disponível em [PC do Manual](https://sintoniza.pcdomanual.com/) 119 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | - Migrate to PHPRouter: https://github.com/miladrahimi/phprouter -------------------------------------------------------------------------------- /app/.env.sample: -------------------------------------------------------------------------------- 1 | DB_HOST=localhost 2 | DB_PORT=3306 3 | DB_NAME=sintoniza 4 | DB_USERNAME=root 5 | DB_PASSWORD= 6 | 7 | BASE_URL=https://sintoniza.test/ 8 | TITLE="Sintoniza" 9 | 10 | ENABLE_SUBSCRIPTIONS=true 11 | DEBUG=null 12 | DISABLE_USER_METADATA_UPDATE=false 13 | 14 | SMTP_USER=email@email.com 15 | SMTP_PASS=password 16 | SMTP_HOST=smtp.email.com 17 | SMTP_FROM=email@email.com 18 | SMTP_NAME="Sintoniza" 19 | SMTP_PORT=587 20 | SMTP_SECURE=tls 21 | SMTP_AUTH=true -------------------------------------------------------------------------------- /app/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/sintoniza/022d8b7bb24737b3fa72b9bf025d61d6a0ca4086/app/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /app/assets/favicon/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/sintoniza/022d8b7bb24737b3fa72b9bf025d61d6a0ca4086/app/assets/favicon/favicon-96x96.png -------------------------------------------------------------------------------- /app/assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/sintoniza/022d8b7bb24737b3fa72b9bf025d61d6a0ca4086/app/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /app/assets/favicon/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/assets/opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/sintoniza/022d8b7bb24737b3fa72b9bf025d61d6a0ca4086/app/assets/opengraph.png -------------------------------------------------------------------------------- /app/cache/index.php: -------------------------------------------------------------------------------- 1 | updateAllFeeds(true); 9 | } 10 | echo "Feeds metadata updated successfully at " . date('Y-m-d H:i:s') . "\n"; 11 | -------------------------------------------------------------------------------- /app/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "vlucas/phpdotenv": "^5.6.1", 4 | "jessedp/php-timezones": "^0.2.3", 5 | "phpmailer/phpmailer": "^6.9" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/config.php: -------------------------------------------------------------------------------- 1 | load(); 6 | 7 | // Define 8 | define("DB_HOST", isset($_ENV['DB_HOST']) ? $_ENV['DB_HOST'] : 'localhost'); 9 | define("DB_USER", isset($_ENV['DB_USER']) ? $_ENV['DB_USER'] : 'root'); 10 | define("DB_PASS", isset($_ENV['DB_PASS']) ? $_ENV['DB_PASS'] : ''); 11 | define("DB_NAME", isset($_ENV['DB_NAME']) ? $_ENV['DB_NAME'] : 'sintoniza'); 12 | define("BASE_URL", isset($_ENV['BASE_URL']) ? $_ENV['BASE_URL'] : ''); 13 | define("TITLE", isset($_ENV['TITLE']) ? $_ENV['TITLE'] : 'Sintoniza'); 14 | define("ENABLE_SUBSCRIPTIONS", isset($_ENV['ENABLE_SUBSCRIPTIONS'])? filter_var($_ENV['ENABLE_SUBSCRIPTIONS'], FILTER_VALIDATE_BOOLEAN) : false); 15 | define("DEBUG", isset($_ENV['DEBUG']) ? __DIR__ . '/logs/debug.log' : null); 16 | define("DISABLE_USER_METADATA_UPDATE", isset($_ENV['DISABLE_USER_METADATA_UPDATE']) ? filter_var($_ENV['DISABLE_USER_METADATA_UPDATE'], FILTER_VALIDATE_BOOLEAN) : false); 17 | 18 | // PHPMailer SMTP Configuration 19 | define("SMTP_USER", isset($_ENV['SMTP_USER']) ? $_ENV['SMTP_USER'] : ''); 20 | define("SMTP_PASS", isset($_ENV['SMTP_PASS']) ? $_ENV['SMTP_PASS'] : ''); 21 | define("SMTP_HOST", isset($_ENV['SMTP_HOST']) ? $_ENV['SMTP_HOST'] : ''); 22 | define("SMTP_FROM", isset($_ENV['SMTP_FROM']) ? $_ENV['SMTP_FROM'] : ''); 23 | define("SMTP_NAME", isset($_ENV['SMTP_NAME']) ? $_ENV['SMTP_NAME'] : ''); 24 | define("SMTP_PORT", isset($_ENV['SMTP_PORT']) ? $_ENV['SMTP_PORT'] : '587'); 25 | define("SMTP_SECURE", isset($_ENV['SMTP_SECURE']) ? $_ENV['SMTP_SECURE'] : 'tls'); 26 | define("SMTP_AUTH", isset($_ENV['SMTP_AUTH']) ? filter_var($_ENV['SMTP_AUTH'], FILTER_VALIDATE_BOOLEAN) : true); 27 | 28 | // Functions and classes 29 | require_once __DIR__ . '/inc/Errors.php'; 30 | require_once __DIR__ . '/inc/DB.php'; 31 | require_once __DIR__ . '/inc/API.php'; 32 | require_once __DIR__ . '/inc/GPodder.php'; 33 | require_once __DIR__ . '/inc/Feed.php'; 34 | require_once __DIR__ . '/inc/Language.php'; 35 | 36 | Language::getInstance(); 37 | 38 | // Templates 39 | require_once __DIR__ . '/templates/header.php'; 40 | require_once __DIR__ . '/templates/footer.php'; 41 | -------------------------------------------------------------------------------- /app/inc/API.php: -------------------------------------------------------------------------------- 1 | '/^[\w.-]+$/', 24 | 25 | // url: Valida URLs HTTP/HTTPS, garantindo que comecem com http:// ou https:// seguido de um domínio 26 | // Exemplos válidos: http://example.com, https://test.com 27 | // Exemplos inválidos: ftp://example.com, example.com (sem protocolo) 28 | 'url' => '!^https?://[^/]+!', 29 | 30 | // username: Permite apenas letras (maiúsculas e minúsculas), números, hífens e underscores 31 | // Exemplos válidos: user123, user-name, user_name 32 | // Exemplos inválidos: user@123, user.name, user space 33 | 'username' => '/^[a-zA-Z0-9_-]+$/', 34 | 35 | // timestamp: Valida timestamps no formato ISO 8601 36 | // Exemplos válidos: 2023-12-25T10:30:00Z, 2023-12-25T10:30:00.123Z, 2023-12-25T10:30:00+03:00 37 | // Exemplos inválidos: 2023-12-25, 10:30:00, 2023/12/25T10:30:00Z 38 | 'timestamp' => '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:?\d{2})?$/' 39 | ]; 40 | 41 | public function __construct(DB $db) 42 | { 43 | session_name('sessionid'); 44 | $this->db = $db; 45 | $url = defined('BASE_URL') ? BASE_URL : null; 46 | $url ??= getenv('BASE_URL', true) ?: null; 47 | 48 | if (!$url) { 49 | if (!isset($_SERVER['SERVER_PORT'], $_SERVER['SERVER_NAME'], $_SERVER['SCRIPT_FILENAME'], $_SERVER['DOCUMENT_ROOT'])) { 50 | echo __('messages.auto_url_error') . "\n"; 51 | exit(1); 52 | } 53 | 54 | $url = 'http'; 55 | 56 | if (!empty($_SERVER['HTTPS']) || $_SERVER['SERVER_PORT'] === 443) { 57 | $url .= 's'; 58 | } 59 | 60 | $url .= '://' . $_SERVER['SERVER_NAME']; 61 | 62 | if (!in_array($_SERVER['SERVER_PORT'], [80, 443])) { 63 | $url .= ':' . $_SERVER['SERVER_PORT']; 64 | } 65 | 66 | $path = substr(dirname($_SERVER['SCRIPT_FILENAME']), strlen($_SERVER['DOCUMENT_ROOT'])); 67 | $path = trim($path, '/'); 68 | $url .= $path ? '/' . $path . '/' : '/'; 69 | } 70 | 71 | $this->base_path = parse_url($url, PHP_URL_PATH) ?? ''; 72 | $this->base_url = $url; 73 | } 74 | 75 | /** 76 | * Validate input against pattern 77 | * @throws InvalidArgumentException 78 | */ 79 | protected function validatePattern(string $input, string $pattern, string $fieldName): void 80 | { 81 | if (!isset(self::VALIDATION_PATTERNS[$pattern])) { 82 | throw new InvalidArgumentException("Invalid validation pattern specified"); 83 | } 84 | 85 | if (!preg_match(self::VALIDATION_PATTERNS[$pattern], $input)) { 86 | // Log the validation error with the original input string 87 | $log_message = sprintf( 88 | "[%s] Validation error for pattern '%s' (field: %s): '%s'\n", 89 | date('Y-m-d H:i:s'), 90 | $pattern, 91 | $fieldName, 92 | $input 93 | ); 94 | file_put_contents('logs/inject.log', $log_message, FILE_APPEND); 95 | 96 | if ($pattern !== 'url') { 97 | $this->error(400, sprintf(__('errors.invalid_%s'), $fieldName)); 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Sanitize input string 104 | */ 105 | protected function sanitizeString(string $input): string 106 | { 107 | return htmlspecialchars(strip_tags(trim($input)), ENT_QUOTES, 'UTF-8'); 108 | } 109 | 110 | public function url(string $path = ''): string 111 | { 112 | return $this->base_url . $this->sanitizeString($path); 113 | } 114 | 115 | public function debug(string $message, ...$params): void 116 | { 117 | if (!DEBUG) { 118 | return; 119 | } 120 | 121 | file_put_contents(DEBUG, date('Y-m-d H:i:s ') . vsprintf($message, $params) . PHP_EOL, FILE_APPEND); 122 | } 123 | 124 | public function queryWithData(string $sql, ...$params): array { 125 | // Validate SQL query 126 | if (empty($sql)) { 127 | throw new InvalidArgumentException("SQL query cannot be empty"); 128 | } 129 | 130 | $result = $this->db->iterate($sql, ...$params); 131 | $out = []; 132 | 133 | foreach ($result as $row) { 134 | if (isset($row->data) && is_string($row->data)) { 135 | try { 136 | $jsonData = json_decode($row->data, true, 512, JSON_THROW_ON_ERROR); 137 | $row = (object) array_merge($jsonData, (array) $row); 138 | unset($row->data); 139 | } catch (JsonException $e) { 140 | $this->debug('JSON decode error: %s', $e->getMessage()); 141 | continue; 142 | } 143 | } 144 | $out[] = (array) $row; 145 | } 146 | 147 | return $out; 148 | } 149 | 150 | /** 151 | * @throws JsonException 152 | */ 153 | public function error(int $code, string $message): void { 154 | $this->debug('RETURN: %d - %s', $code, $message); 155 | 156 | http_response_code($code); 157 | header('Content-Type: application/json', true); 158 | echo json_encode(['code' => $code, 'message' => $this->sanitizeString($message)], 159 | JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 160 | exit; 161 | } 162 | 163 | /** 164 | * @throws JsonException 165 | */ 166 | public function requireMethod(string $method): void { 167 | if ($method !== $this->method) { 168 | $this->error(405, 'Invalid HTTP method: ' . $this->sanitizeString($this->method)); 169 | } 170 | } 171 | 172 | /** 173 | * Validate URL and return true if valid, false if invalid 174 | */ 175 | public function validateURL(string $url): bool 176 | { 177 | if (!isset(self::VALIDATION_PATTERNS['url'])) { 178 | return false; 179 | } 180 | 181 | if (!preg_match(self::VALIDATION_PATTERNS['url'], $url)) { 182 | // Log the validation error with the original input string 183 | $log_message = sprintf( 184 | "[%s] URL validation error: '%s'\n", 185 | date('Y-m-d H:i:s'), 186 | $url 187 | ); 188 | file_put_contents('logs/inject.log', $log_message, FILE_APPEND); 189 | return false; 190 | } 191 | 192 | return true; 193 | } 194 | 195 | public function getDeviceID(string $deviceid, int $user_id) { 196 | if (isset($deviceid)) { 197 | $this->validatePattern($deviceid, 'deviceid', 'device_id'); 198 | 199 | $this->debug('Procurando o ID do dispositivo para deviceid: %s e usuário: %d', $deviceid, $user_id); 200 | $device_id = $this->db->firstColumn('SELECT id FROM devices WHERE deviceid = ? AND user = ?;', 201 | $deviceid, $user_id); 202 | $this->debug('ID do dispositivo encontrado: %s', $device_id ?? 'null'); 203 | return $device_id; 204 | } else { 205 | $this->error(400, __('messages.device_id_not_registered')); 206 | return null; 207 | } 208 | } 209 | 210 | /** 211 | * @throws JsonException 212 | */ 213 | public function getInput() 214 | { 215 | if ($this->format === 'txt') { 216 | return array_filter(file('php://input'), 'trim'); 217 | } 218 | 219 | $input = file_get_contents('php://input'); 220 | 221 | if (empty($input)) { 222 | return null; 223 | } 224 | 225 | try { 226 | return json_decode($input, false, 512, JSON_THROW_ON_ERROR); 227 | } catch (JsonException $e) { 228 | $this->error(400, __('messages.invalid_json')); 229 | } 230 | } 231 | 232 | /** 233 | * @see https://gpoddernet.readthedocs.io/en/latest/api/reference/auth.html 234 | * @throws JsonException 235 | */ 236 | public function handleAuth(): void 237 | { 238 | $this->requireMethod('POST'); 239 | 240 | strtok($this->path, '/'); 241 | $action = strtok(''); 242 | 243 | if ($action === 'logout') { 244 | $_SESSION = []; 245 | session_destroy(); 246 | $this->error(200, __('messages.logged_out')); 247 | } 248 | elseif ($action !== 'login') { 249 | $this->error(404, __('messages.unknown_login_action') . ' ' . $this->sanitizeString($action)); 250 | } 251 | 252 | if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { 253 | $this->error(401, __('messages.no_username_password')); 254 | } 255 | 256 | $this->requireAuth(); 257 | 258 | $this->error(200, __('messages.login_success')); 259 | } 260 | 261 | public function login() 262 | { 263 | $login = $_SERVER['PHP_AUTH_USER']; 264 | list($login) = explode('__', $login, 2); 265 | 266 | // Validate username 267 | $this->validatePattern($login, 'username', 'username'); 268 | 269 | $user = $this->db->firstRow('SELECT id, password FROM users WHERE name = ?;', $login); 270 | 271 | if(!$user) { 272 | $this->error(401, __('messages.invalid_username')); 273 | } 274 | 275 | if (!password_verify($_SERVER['PHP_AUTH_PW'], $user->password ?? '')) { 276 | $this->error(401, __('messages.invalid_username_password')); 277 | } 278 | 279 | $this->debug('Usuário conectado: %s', $login); 280 | 281 | @session_start(); 282 | $_SESSION['user'] = $user; 283 | } 284 | 285 | /** 286 | * @throws JsonException 287 | */ 288 | public function requireAuth(?string $username = null): void 289 | { 290 | if (isset($this->user)) { 291 | return; 292 | } 293 | 294 | // For gPodder desktop 295 | if ($username && false !== strpos($username, '__')) { 296 | $gpodder = new GPodder($this->db); 297 | if (!$gpodder->validateToken($username)) { 298 | $this->error(401, __('messages.invalid_gpodder_token')); 299 | } 300 | 301 | $this->user = $gpodder->user; 302 | return; 303 | } 304 | 305 | if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { 306 | $this->login(); 307 | $this->user = $_SESSION['user']; 308 | return; 309 | } 310 | 311 | if (empty($_COOKIE['sessionid'])) { 312 | $this->error(401, __('messages.session_cookie_required')); 313 | } 314 | 315 | @session_start(); 316 | 317 | if (empty($_SESSION['user'])) { 318 | $this->error(401, __('messages.session_expired')); 319 | } 320 | 321 | if (!$this->db->firstColumn('SELECT 1 FROM users WHERE id = ?;', $_SESSION['user']->id)) { 322 | $this->error(401, __('messages.user_not_exists')); 323 | } 324 | 325 | $this->user = $_SESSION['user']; 326 | $this->debug('ID do usuário do cookie: %s', $this->user->id); 327 | } 328 | 329 | /** 330 | * @throws JsonException 331 | */ 332 | public function route() 333 | { 334 | switch ($this->section) { 335 | case 'tag': 336 | case 'tags': 337 | case 'data': 338 | case 'toplist': 339 | case 'suggestions': 340 | case 'favorites': 341 | return []; 342 | case 'devices': 343 | return $this->devices(); 344 | case 'updates': 345 | return $this->updates(); 346 | case 'subscriptions': 347 | return $this->subscriptions(); 348 | case 'episodes': 349 | return $this->episodes(); 350 | case 'settings': 351 | case 'lists': 352 | case 'sync-device': 353 | $this->error(503, __('messages.not_implemented')); 354 | default: 355 | return null; 356 | } 357 | } 358 | 359 | /** 360 | * Map NextCloud endpoints to GPodder 361 | * @see https://github.com/thrillfall/nextcloud-gpodder 362 | * @throws JsonException 363 | */ 364 | public function handleNextCloud(): ?array 365 | { 366 | if ($this->url === 'index.php/login/v2') { 367 | $this->requireMethod('POST'); 368 | 369 | $id = bin2hex(random_bytes(16)); 370 | 371 | return [ 372 | 'poll' => [ 373 | 'token' => $id, 374 | 'endpoint' => $this->url('index.php/login/v2/poll'), 375 | ], 376 | 'login' => $this->url('login?token=' . $id), 377 | ]; 378 | } 379 | 380 | if ($this->url === 'index.php/login/v2/poll') { 381 | $this->requireMethod('POST'); 382 | 383 | if (empty($_POST['token']) || !ctype_alnum($_POST['token'])) { 384 | $this->error(400, __('messages.invalid_gpodder_token')); 385 | } 386 | 387 | session_id($_POST['token']); 388 | session_start(); 389 | 390 | if (empty($_SESSION['user']) || empty($_SESSION['app_password'])) { 391 | $this->error(404, __('messages.session_expired')); 392 | } 393 | 394 | return [ 395 | 'server' => $this->url(), 396 | 'loginName' => $_SESSION['user']->name, 397 | 'appPassword' => $_SESSION['app_password'], 398 | ]; 399 | } 400 | 401 | $nextcloud_path = 'index.php/apps/gpoddersync/'; 402 | 403 | if (0 !== strpos($this->url, $nextcloud_path)) { 404 | return null; 405 | } 406 | 407 | if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { 408 | $this->error(401, __('messages.no_username_password')); 409 | } 410 | 411 | $this->debug('Compatibilidade com Nextcloud: %s / %s', $_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW']); 412 | 413 | $user = $this->db->firstRow('SELECT id, password FROM users WHERE name = ?;', $_SERVER['PHP_AUTH_USER']); 414 | 415 | if (!$user) { 416 | $this->error(401, __('messages.invalid_username')); 417 | } 418 | 419 | $token = strtok($_SERVER['PHP_AUTH_PW'], ':'); 420 | $password = strtok(''); 421 | $app_password = sha1($user->password . $token); 422 | 423 | if ($app_password !== $password) { 424 | $this->error(401, __('messages.invalid_username_password')); 425 | } 426 | 427 | $this->user = $_SESSION['user'] = $user; 428 | 429 | $path = substr($this->url, strlen($nextcloud_path)); 430 | 431 | if ($path === 'subscriptions') { 432 | $this->url = 'api/2/subscriptions/current/default.json'; 433 | } 434 | elseif ($path === 'subscription_change/create') { 435 | $this->url = 'api/2/subscriptions/current/default.json'; 436 | } 437 | elseif ($path === 'episode_action' || $path === 'episode_action/create') { 438 | $this->url = 'api/2/episodes/current.json'; 439 | } 440 | else { 441 | $this->error(404, __('messages.nextcloud_undefined_endpoint')); 442 | } 443 | 444 | return null; 445 | } 446 | 447 | /** 448 | * @throws JsonException 449 | */ 450 | public function handleRequest(): void 451 | { 452 | $this->method = $_SERVER['REQUEST_METHOD'] ?? null; 453 | $url = '/' . trim($_SERVER['REQUEST_URI'] ?? '', '/'); 454 | $url = substr($url, strlen($this->base_path)); 455 | $this->url = strtok($url, '?'); 456 | 457 | $this->debug('Recebi uma solicitação %s em %s', $this->method, $this->url); 458 | 459 | $return = $this->handleNextCloud(); 460 | 461 | if ($return) { 462 | echo json_encode($return, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 463 | exit; 464 | } 465 | 466 | if (!preg_match('!^(suggestions|subscriptions|toplist|api/2/(auth|subscriptions|devices|updates|episodes|favorites|settings|lists|sync-devices|tags?|data))/!', $this->url, $match)) { 467 | return; 468 | } 469 | 470 | $this->section = $match[2] ?? $match[1]; 471 | $this->path = substr($this->url, strlen($match[0])); 472 | $username = null; 473 | 474 | if (preg_match('/\.(json|opml|txt|jsonp|xml)$/', $this->url, $match)) { 475 | $this->format = $match[1]; 476 | $this->path = substr($this->path, 0, -strlen($match[0])); 477 | } 478 | 479 | if (!in_array($this->format, ['json', 'opml', 'txt'])) { 480 | $this->error(501, __('messages.output_format_not_implemented')); 481 | } 482 | 483 | if (preg_match('!(\w+__\w{10})!i', $this->path, $match)) { 484 | $username = $match[1]; 485 | $this->validatePattern($username, 'username', 'username'); 486 | } 487 | 488 | if ($this->section === 'auth') { 489 | $this->handleAuth(); 490 | return; 491 | } 492 | 493 | $this->requireAuth($username); 494 | 495 | $return = $this->route(); 496 | 497 | $this->debug("RETURN:\n%s", json_encode($return, JSON_PRETTY_PRINT)); 498 | 499 | if ($this->format === 'opml') { 500 | if ($this->section !== 'subscriptions') { 501 | $this->error(501, __('messages.output_format_not_implemented')); 502 | } 503 | 504 | header('Content-Type: text/x-opml; charset=utf-8'); 505 | echo $this->opml($return); 506 | } 507 | else { 508 | header('Content-Type: application/json'); 509 | 510 | if ($return !== null) { 511 | echo json_encode($return, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 512 | } 513 | } 514 | 515 | exit; 516 | } 517 | 518 | /** 519 | * @throws JsonException 520 | */ 521 | public function devices(): array 522 | { 523 | if ($this->method === 'GET') { 524 | return $this->queryWithData('SELECT deviceid as id, user, deviceid, name, data 525 | FROM devices WHERE user = ?;', $this->user->id); 526 | } 527 | 528 | if ($this->method === 'POST') { 529 | $deviceid = explode('/', $this->path)[1] ?? null; 530 | 531 | if (!$deviceid) { 532 | $this->error(400, __('messages.invalid_device_id')); 533 | } 534 | 535 | $this->validatePattern($deviceid, 'deviceid', 'device_id'); 536 | 537 | $json = $this->getInput(); 538 | $json ??= new stdClass(); 539 | $json->subscriptions = 0; 540 | 541 | $params = [ 542 | 'deviceid' => $deviceid, 543 | 'data' => json_encode($json, JSON_THROW_ON_ERROR), 544 | 'name' => $json->caption ?? null, 545 | 'user' => $this->user->id, 546 | ]; 547 | 548 | $this->db->upsert('devices', $params, ['deviceid', 'user']); 549 | $this->error(200, __('messages.device_updated')); 550 | } 551 | $this->error(400, __('messages.invalid_request_method')); 552 | exit; 553 | } 554 | 555 | /** 556 | * @throws JsonException 557 | */ 558 | public function subscriptions() 559 | { 560 | $v2 = strpos($this->url, 'api/2/') !== false; 561 | $deviceid = explode('/', $this->path)[1] ?? null; 562 | 563 | if ($this->method === 'GET' && !$v2) { 564 | return $this->db->rowsFirstColumn('SELECT url FROM subscriptions WHERE user = ?;', 565 | $this->user->id); 566 | } 567 | 568 | if (!$deviceid) { 569 | $this->error(400, __('messages.invalid_device_id')); 570 | } 571 | 572 | $this->validatePattern($deviceid, 'deviceid', 'device_id'); 573 | 574 | if ($v2 && $this->method === 'GET') { 575 | $timestamp = (int)($_GET['since'] ?? 0); 576 | 577 | return [ 578 | 'add' => $this->db->rowsFirstColumn( 579 | 'SELECT url FROM subscriptions WHERE user = ? AND deleted = 0 AND changed >= ?;', 580 | $this->user->id, 581 | $timestamp 582 | ), 583 | 'remove' => $this->db->rowsFirstColumn( 584 | 'SELECT url FROM subscriptions WHERE user = ? AND deleted = 1 AND changed >= ?;', 585 | $this->user->id, 586 | $timestamp 587 | ), 588 | 'update_urls' => [], 589 | 'timestamp' => time(), 590 | ]; 591 | } 592 | 593 | if ($this->method === 'PUT') { 594 | $lines = $this->getInput(); 595 | 596 | if (!is_array($lines)) { 597 | $this->error(400, __('messages.invalid_input_array')); 598 | } 599 | 600 | try { 601 | $this->db->beginTransaction(); 602 | $st = $this->db->prepare('INSERT IGNORE INTO subscriptions (user, url, changed) 603 | VALUES (:user, :url, :changed)'); 604 | 605 | foreach ($lines as $url) { 606 | if (!$this->validateURL($url)) { 607 | continue; 608 | } 609 | 610 | $st->execute([ 611 | ':url' => $url, 612 | ':user' => $this->user->id, 613 | ':changed' => time() 614 | ]); 615 | } 616 | 617 | $this->db->commit(); 618 | return null; 619 | } 620 | catch (Exception $e) { 621 | $this->db->rollBack(); 622 | throw $e; 623 | } 624 | } 625 | 626 | if ($this->method === 'POST') { 627 | $input = $this->getInput(); 628 | 629 | try { 630 | $this->db->beginTransaction(); 631 | $ts = time(); 632 | 633 | if (!empty($input->add) && is_array($input->add)) { 634 | foreach ($input->add as $url) { 635 | if (!$this->validateURL($url)) { 636 | continue; 637 | } 638 | 639 | $this->db->upsert('subscriptions', [ 640 | 'user' => $this->user->id, 641 | 'url' => $url, 642 | 'changed' => $ts, 643 | 'deleted' => 0, 644 | ], ['user', 'url']); 645 | } 646 | } 647 | 648 | if (!empty($input->remove) && is_array($input->remove)) { 649 | foreach ($input->remove as $url) { 650 | if (!$this->validateURL($url)) { 651 | continue; 652 | } 653 | 654 | $this->db->upsert('subscriptions', [ 655 | 'user' => $this->user->id, 656 | 'url' => $url, 657 | 'changed' => $ts, 658 | 'deleted' => 1, 659 | ], ['user', 'url']); 660 | } 661 | } 662 | 663 | $this->db->commit(); 664 | return ['timestamp' => $ts, 'update_urls' => []]; 665 | } 666 | catch (Exception $e) { 667 | $this->db->rollBack(); 668 | throw $e; 669 | } 670 | } 671 | 672 | $this->error(501, __('messages.not_implemented')); 673 | } 674 | 675 | /** 676 | * @throws JsonException 677 | */ 678 | public function updates(): mixed 679 | { 680 | $this->error(501, __('messages.not_implemented')); 681 | exit; 682 | } 683 | 684 | /** 685 | * Validate episode action 686 | * @throws InvalidArgumentException 687 | */ 688 | protected function validateEpisodeAction(object $action): void 689 | { 690 | if (!isset($action->podcast, $action->action, $action->episode)) { 691 | throw new InvalidArgumentException(__('messages.missing_action_key')); 692 | } 693 | 694 | if (!in_array(strtolower($action->action), self::ALLOWED_EPISODE_ACTIONS)) { 695 | throw new InvalidArgumentException(__('messages.invalid_action')); 696 | } 697 | 698 | if (!$this->validateURL($action->podcast) || !$this->validateURL($action->episode)) { 699 | throw new InvalidArgumentException(__('messages.invalid_url')); 700 | } 701 | 702 | if (!empty($action->timestamp)) { 703 | $this->validatePattern($action->timestamp, 'timestamp', 'timestamp'); 704 | } 705 | } 706 | 707 | /** 708 | * @throws JsonException 709 | */ 710 | public function episodes(): array 711 | { 712 | if ($this->method === 'GET') { 713 | $since = isset($_GET['since']) ? (int)$_GET['since'] : 0; 714 | 715 | return [ 716 | 'timestamp' => time(), 717 | 'actions' => $this->queryWithData( 718 | 'SELECT e.url AS episode, e.action, e.data, s.url AS podcast, 719 | DATE_FORMAT(FROM_UNIXTIME(e.changed), "%Y-%m-%dT%H:%i:%sZ") AS timestamp 720 | FROM episodes_actions e 721 | INNER JOIN subscriptions s ON s.id = e.subscription 722 | WHERE e.user = ? AND e.changed >= ?;', 723 | $this->user->id, 724 | $since 725 | ) 726 | ]; 727 | } 728 | 729 | $this->requireMethod('POST'); 730 | 731 | $input = $this->getInput(); 732 | 733 | if (!is_array($input)) { 734 | $this->error(400, __('messages.invalid_array')); 735 | } 736 | 737 | try { 738 | $this->db->beginTransaction(); 739 | 740 | $timestamp = time(); 741 | $st = $this->db->prepare( 742 | 'INSERT INTO episodes_actions 743 | (user, subscription, url, episode, changed, action, data, device) 744 | VALUES 745 | (:user, :subscription, :url, :episode, :changed, :action, :data, :device)' 746 | ); 747 | 748 | foreach ($input as $action) { 749 | try { 750 | $this->validateEpisodeAction($action); 751 | } catch (InvalidArgumentException $e) { 752 | continue; 753 | } 754 | 755 | // Get subscription ID or create new subscription 756 | $subscription_id = $this->db->firstColumn( 757 | 'SELECT id FROM subscriptions WHERE url = ? AND user = ?;', 758 | $action->podcast, 759 | $this->user->id 760 | ); 761 | 762 | if (!$subscription_id) { 763 | $this->db->simple( 764 | 'INSERT INTO subscriptions (user, url, changed) VALUES (?, ?, ?);', 765 | $this->user->id, 766 | $action->podcast, 767 | $timestamp 768 | ); 769 | $subscription_id = $this->db->lastInsertId(); 770 | } 771 | 772 | // Get feed ID from subscription 773 | $feed_id = $this->db->firstColumn('SELECT feed FROM subscriptions WHERE id = ?', 774 | $subscription_id); 775 | 776 | // Try to get episode ID from episodes table 777 | $episode_id = null; 778 | if ($feed_id) { 779 | $episode_id = $this->db->firstColumn( 780 | 'SELECT id FROM episodes WHERE media_url = ? AND feed = ?', 781 | $action->episode, 782 | $feed_id 783 | ); 784 | } 785 | 786 | // Get device ID if device is provided 787 | $device_id = null; 788 | if (!empty($action->device)) { 789 | $device_id = $this->getDeviceID($action->device, $this->user->id); 790 | } 791 | 792 | $actionData = clone $action; 793 | unset($actionData->action, $actionData->episode, $actionData->podcast, $actionData->device); 794 | 795 | $st->execute([ 796 | ':user' => $this->user->id, 797 | ':subscription' => $subscription_id, 798 | ':url' => $action->episode, 799 | ':episode' => $episode_id, 800 | ':changed' => !empty($action->timestamp) ? strtotime($action->timestamp) : $timestamp, 801 | ':action' => strtolower($action->action), 802 | ':device' => $device_id, 803 | ':data' => json_encode($actionData, JSON_THROW_ON_ERROR) 804 | ]); 805 | } 806 | 807 | $this->db->commit(); 808 | 809 | return ['timestamp' => $timestamp, 'update_urls' => []]; 810 | } 811 | catch (Exception $e) { 812 | $this->db->rollBack(); 813 | throw $e; 814 | } 815 | } 816 | 817 | public function opml(array $data): string 818 | { 819 | $out = ''; 820 | $out .= PHP_EOL . 'My Feeds'; 821 | 822 | foreach ($data as $row) { 823 | $out .= PHP_EOL . sprintf('', 824 | htmlspecialchars($row ?? '', ENT_XML1) 825 | ); 826 | } 827 | 828 | $out .= PHP_EOL . ''; 829 | return $out; 830 | } 831 | } 832 | -------------------------------------------------------------------------------- /app/inc/DB.php: -------------------------------------------------------------------------------- 1 | 'is_string', 10 | 'int' => 'is_int', 11 | 'float' => 'is_float', 12 | 'bool' => 'is_bool', 13 | 'array' => 'is_array', 14 | 'null' => 'is_null' 15 | ]; 16 | 17 | // Common regex patterns for validation 18 | protected const PATTERNS = [ 19 | 'email' => '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/', 20 | 'url' => '/^https?:\/\/[^\s\/$.?#].[^\s]*$/', 21 | 'date' => '/^\d{4}-\d{2}-\d{2}$/', 22 | 'datetime' => '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', 23 | 'alphanumeric' => '/^[a-zA-Z0-9]+$/', 24 | 'numeric' => '/^[0-9]+$/' 25 | ]; 26 | 27 | // Define max lengths for different types of strings 28 | protected const MAX_LENGTHS = [ 29 | 'sql_query' => 10000, // Allow longer SQL queries 30 | 'identifier' => 64, // Database identifiers (table names, column names) 31 | 'default' => 255 // Default max length for other strings 32 | ]; 33 | 34 | public function __construct(string $host, string $dbname, string $user, string $password) 35 | { 36 | // Validate connection parameters 37 | $this->validateString($host, 'Host'); 38 | $this->validateString($dbname, 'Database name'); 39 | $this->validateString($user, 'Username'); 40 | $this->validateString($password, 'Password'); 41 | 42 | $dsn = sprintf('mysql:host=%s;charset=utf8mb4', $this->sanitizeIdentifier($host)); 43 | 44 | parent::__construct($dsn, $user, $password, [ 45 | PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 46 | PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ, 47 | PDO::ATTR_EMULATE_PREPARES => false, 48 | PDO::MYSQL_ATTR_FOUND_ROWS => true 49 | ]); 50 | 51 | $this->exec('SET time_zone = "+00:00"'); 52 | 53 | try { 54 | $this->exec(sprintf('USE `%s`', $this->sanitizeIdentifier($dbname))); 55 | } 56 | catch (PDOException $e) { 57 | if ($e->getCode() == 1049) { 58 | $this->createDatabase($dbname); 59 | } else { 60 | throw $e; 61 | } 62 | } 63 | 64 | if (!$this->checkTablesExist()) { 65 | $this->installSchema(); 66 | } 67 | } 68 | 69 | /** 70 | * Validates a parameter against a specific type 71 | * @throws InvalidArgumentException 72 | */ 73 | protected function validateType($value, string $type, string $paramName): void 74 | { 75 | if (!isset(self::VALID_TYPES[$type])) { 76 | throw new InvalidArgumentException("Invalid type specified for parameter '$paramName'"); 77 | } 78 | 79 | $validationFunction = self::VALID_TYPES[$type]; 80 | if (!$validationFunction($value)) { 81 | throw new InvalidArgumentException("Parameter '$paramName' must be of type $type"); 82 | } 83 | } 84 | 85 | /** 86 | * Validates string parameters with context-aware max lengths 87 | * @throws InvalidArgumentException 88 | */ 89 | protected function validateString($value, string $paramName, ?string $context = null): void 90 | { 91 | if (!is_string($value)) { 92 | throw new InvalidArgumentException("Parameter '$paramName' must be a string"); 93 | } 94 | 95 | // Determine max length based on context 96 | $maxLength = self::MAX_LENGTHS[$context ?? 'default'] ?? self::MAX_LENGTHS['default']; 97 | 98 | if (strlen($value) > $maxLength) { 99 | throw new InvalidArgumentException("Parameter '$paramName' exceeds maximum length of $maxLength"); 100 | } 101 | } 102 | 103 | /** 104 | * Validates numeric parameters 105 | * @throws InvalidArgumentException 106 | */ 107 | protected function validateNumeric($value, string $paramName, $min = null, $max = null): void 108 | { 109 | if (!is_numeric($value)) { 110 | throw new InvalidArgumentException("Parameter '$paramName' must be numeric"); 111 | } 112 | 113 | if ($min !== null && $value < $min) { 114 | throw new InvalidArgumentException("Parameter '$paramName' must be greater than or equal to $min"); 115 | } 116 | 117 | if ($max !== null && $value > $max) { 118 | throw new InvalidArgumentException("Parameter '$paramName' must be less than or equal to $max"); 119 | } 120 | } 121 | 122 | /** 123 | * Sanitizes database identifiers (table names, column names) 124 | */ 125 | protected function sanitizeIdentifier(string $identifier): string 126 | { 127 | // Validate identifier length 128 | $this->validateString($identifier, 'Database identifier', 'identifier'); 129 | return preg_replace('/[^a-zA-Z0-9_]/', '', $identifier); 130 | } 131 | 132 | /** 133 | * Validates parameters against specific patterns 134 | * @throws InvalidArgumentException 135 | */ 136 | protected function validatePattern($value, string $pattern, string $paramName): void 137 | { 138 | if (!isset(self::PATTERNS[$pattern])) { 139 | throw new InvalidArgumentException("Invalid pattern specified"); 140 | } 141 | 142 | if (!preg_match(self::PATTERNS[$pattern], $value)) { 143 | throw new InvalidArgumentException("Parameter '$paramName' does not match required pattern"); 144 | } 145 | } 146 | 147 | protected function createDatabase(string $dbname): void 148 | { 149 | $dbname = $this->sanitizeIdentifier($dbname); 150 | $this->exec(sprintf('CREATE DATABASE IF NOT EXISTS `%s` 151 | DEFAULT CHARACTER SET utf8mb4 152 | DEFAULT COLLATE utf8mb4_general_ci', $dbname)); 153 | $this->exec(sprintf('USE `%s`', $dbname)); 154 | } 155 | 156 | protected function checkTablesExist(): bool 157 | { 158 | $requiredTables = ['users', 'devices', 'episodes', 'episodes_actions', 'feeds', 'subscriptions']; 159 | $existingTables = $this->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN); 160 | return count(array_intersect($requiredTables, $existingTables)) === count($requiredTables); 161 | } 162 | 163 | protected function installSchema(): void 164 | { 165 | $sqlFile = __DIR__ . '/mysql.sql'; 166 | 167 | if (!file_exists($sqlFile)) { 168 | throw new RuntimeException(__('db.schema_not_found')); 169 | } 170 | 171 | $sql = file_get_contents($sqlFile); 172 | 173 | $commands = array_filter( 174 | array_map( 175 | 'trim', 176 | preg_split("/;[\r\n]+/", $sql) 177 | ) 178 | ); 179 | 180 | $this->exec('SET FOREIGN_KEY_CHECKS = 0'); 181 | 182 | foreach ($commands as $command) { 183 | if (empty($command)) continue; 184 | 185 | if (preg_match('/^(\/\*|SET|--)/i', trim($command))) { 186 | continue; 187 | } 188 | 189 | try { 190 | $this->exec($command); 191 | } 192 | catch (PDOException $e) { 193 | $this->exec('SET FOREIGN_KEY_CHECKS = 1'); 194 | throw new RuntimeException( 195 | sprintf("Error: %s\n - %s", 196 | $e->getMessage(), 197 | $command 198 | ) 199 | ); 200 | } 201 | } 202 | 203 | $this->exec('SET FOREIGN_KEY_CHECKS = 1'); 204 | } 205 | 206 | /** 207 | * Enhanced upsert with parameter validation 208 | * @throws InvalidArgumentException 209 | */ 210 | public function upsert(string $table, array $params, array $conflict_columns): ?PDOStatement 211 | { 212 | // Validate table name 213 | $this->validateString($table, 'Table name', 'identifier'); 214 | $table = $this->sanitizeIdentifier($table); 215 | 216 | // Validate parameters 217 | if (empty($params)) { 218 | throw new InvalidArgumentException("Parameters array cannot be empty"); 219 | } 220 | 221 | // Validate conflict columns 222 | if (empty($conflict_columns)) { 223 | throw new InvalidArgumentException("Conflict columns array cannot be empty"); 224 | } 225 | 226 | foreach ($conflict_columns as $column) { 227 | $this->validateString($column, 'Conflict column', 'identifier'); 228 | } 229 | 230 | $columns = array_keys($params); 231 | $placeholders = array_map(fn($col) => ":$col", $columns); 232 | $updates = array_map(fn($col) => "$col = VALUES($col)", $columns); 233 | 234 | // Sanitize column names 235 | $columns = array_map([$this, 'sanitizeIdentifier'], $columns); 236 | 237 | $sql = sprintf( 238 | 'INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s', 239 | $table, 240 | implode(', ', $columns), 241 | implode(', ', $placeholders), 242 | implode(', ', $updates) 243 | ); 244 | 245 | return $this->simple($sql, $params); 246 | } 247 | 248 | /** 249 | * Enhanced prepare with parameter validation 250 | * @throws InvalidArgumentException 251 | */ 252 | public function prepare2(string $sql, ...$params): PDOStatement 253 | { 254 | // Validate SQL query with higher length limit 255 | $this->validateString($sql, 'SQL query', 'sql_query'); 256 | 257 | $hash = md5($sql); 258 | 259 | if (!array_key_exists($hash, $this->statements)) { 260 | $st = $this->statements[$hash] = $this->prepare($sql); 261 | } 262 | else { 263 | $st = $this->statements[$hash]; 264 | } 265 | 266 | if (isset($params[0]) && is_array($params[0])) { 267 | $params = $params[0]; 268 | } 269 | 270 | foreach ($params as $key => $value) { 271 | // Determine parameter type for proper binding 272 | $type = PDO::PARAM_STR; 273 | if (is_int($value)) $type = PDO::PARAM_INT; 274 | elseif (is_bool($value)) $type = PDO::PARAM_BOOL; 275 | elseif (is_null($value)) $type = PDO::PARAM_NULL; 276 | 277 | if (is_int($key)) { 278 | $st->bindValue($key + 1, $value, $type); 279 | } 280 | else { 281 | $st->bindValue(':' . $key, $value, $type); 282 | } 283 | } 284 | 285 | return $st; 286 | } 287 | 288 | /** 289 | * Enhanced simple query with validation 290 | * @throws InvalidArgumentException 291 | */ 292 | public function simple(string $sql, ...$params): ?PDOStatement 293 | { 294 | $this->validateString($sql, 'SQL query', 'sql_query'); 295 | $st = $this->prepare2($sql, ...$params); 296 | $st->execute(); 297 | return $st; 298 | } 299 | 300 | /** 301 | * Enhanced firstRow with validation 302 | * @throws InvalidArgumentException 303 | */ 304 | public function firstRow(string $sql, ...$params): ?stdClass 305 | { 306 | $this->validateString($sql, 'SQL query', 'sql_query'); 307 | $st = $this->simple($sql, ...$params); 308 | $row = $st->fetch(); 309 | return $row ?: null; 310 | } 311 | 312 | /** 313 | * Enhanced firstColumn with validation 314 | * @throws InvalidArgumentException 315 | */ 316 | public function firstColumn(string $sql, ...$params) 317 | { 318 | $this->validateString($sql, 'SQL query', 'sql_query'); 319 | $st = $this->simple($sql, ...$params); 320 | return $st->fetchColumn() ?: null; 321 | } 322 | 323 | /** 324 | * Enhanced rowsFirstColumn with validation 325 | * @throws InvalidArgumentException 326 | */ 327 | public function rowsFirstColumn(string $sql, ...$params): array 328 | { 329 | $this->validateString($sql, 'SQL query', 'sql_query'); 330 | $st = $this->simple($sql, ...$params); 331 | return $st->fetchAll(PDO::FETCH_COLUMN); 332 | } 333 | 334 | /** 335 | * Enhanced iterate with validation 336 | * @throws InvalidArgumentException 337 | */ 338 | public function iterate(string $sql, ...$params): Generator 339 | { 340 | $this->validateString($sql, 'SQL query', 'sql_query'); 341 | $st = $this->simple($sql, ...$params); 342 | 343 | while ($row = $st->fetch()) { 344 | yield $row; 345 | } 346 | } 347 | 348 | /** 349 | * Enhanced all with validation 350 | * @throws InvalidArgumentException 351 | */ 352 | public function all(string $sql, ...$params): array 353 | { 354 | $this->validateString($sql, 'SQL query', 'sql_query'); 355 | return iterator_to_array($this->iterate($sql, ...$params)); 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /app/inc/Errors.php: -------------------------------------------------------------------------------- 1 | 'http://www.itunes.com/dtds/podcast-1.0.dtd', 17 | 'content' => 'http://purl.org/rss/1.0/modules/content/', 18 | 'media' => 'http://search.yahoo.com/mrss/', 19 | 'dc' => 'http://purl.org/dc/elements/1.1/', 20 | 'atom' => 'http://www.w3.org/2005/Atom' 21 | ]; 22 | 23 | public function __construct(string $url) 24 | { 25 | $this->feed_url = $url; 26 | } 27 | 28 | protected const MAX_DURATION = 86400; 29 | protected const MIN_DURATION = 20; 30 | protected function validateDuration($duration): ?int 31 | { 32 | if ($duration === null) { 33 | return null; 34 | } 35 | 36 | $duration = (int)$duration; 37 | if ($duration > self::MAX_DURATION) { 38 | $duration = (int)($duration / (128 * 1024 / 8)); 39 | } 40 | 41 | if ($duration < self::MIN_DURATION || $duration > self::MAX_DURATION) { 42 | return null; 43 | } 44 | 45 | return $duration; 46 | } 47 | 48 | public function load(\stdClass $data): void 49 | { 50 | foreach ($data as $key => $value) { 51 | if ($key === 'id') { 52 | continue; 53 | } 54 | elseif ($key === 'pubdate' && $value) { 55 | $this->$key = new \DateTime($value); 56 | } 57 | else { 58 | $this->$key = $value; 59 | } 60 | } 61 | } 62 | 63 | protected function registerNamespaces(\SimpleXMLElement $xml): void 64 | { 65 | foreach (self::NAMESPACES as $prefix => $uri) { 66 | $xml->registerXPathNamespace($prefix, $uri); 67 | } 68 | } 69 | 70 | protected function safeXPath(\SimpleXMLElement $xml, string $path): array 71 | { 72 | try { 73 | return $xml->xpath($path) ?: []; 74 | } catch (Exception $e) { 75 | return []; 76 | } 77 | } 78 | 79 | public function sync(DB $db): void 80 | { 81 | $db->exec('START TRANSACTION'); 82 | 83 | try { 84 | // Insert/update feed and get ID in one operation 85 | $db->upsert('feeds', $this->export(), ['feed_url']); 86 | $feed_id = $db->firstColumn('SELECT id FROM feeds WHERE feed_url = ?', $this->feed_url); 87 | 88 | // Batch update subscriptions 89 | $db->simple('UPDATE subscriptions SET feed = ? WHERE url = ?', $feed_id, $this->feed_url); 90 | 91 | // Prepare batch episode data 92 | $episode_data = []; 93 | foreach ($this->episodes as $episode) { 94 | $episode = (array) $episode; 95 | 96 | // Skip episodes without required media_url 97 | if (empty($episode['media_url'])) { 98 | continue; 99 | } 100 | 101 | $episode['pubdate'] = $episode['pubdate'] ? $episode['pubdate']->format('Y-m-d H:i:s') : null; 102 | $episode['feed'] = $feed_id; 103 | $episode['title'] = $episode['title'] ?? null; 104 | $episode['description'] = $episode['description'] ?? null; 105 | $episode['url'] = $episode['url'] ?? null; 106 | $episode['image_url'] = $episode['image_url'] ?? null; 107 | $episode['duration'] = $this->validateDuration($episode['duration']); 108 | 109 | $episode_data[] = $episode; 110 | } 111 | 112 | // Only proceed if we have valid episodes 113 | if (!empty($episode_data)) { 114 | // Create temporary table with proper UTF8MB4 encoding and column types 115 | $db->exec('CREATE TEMPORARY TABLE tmp_episodes ( 116 | id INT AUTO_INCREMENT PRIMARY KEY, 117 | media_url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, 118 | feed INT NOT NULL, 119 | title TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, 120 | description MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, 121 | url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, 122 | image_url TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, 123 | pubdate DATETIME, 124 | duration INT, 125 | INDEX (id), 126 | INDEX (feed) 127 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'); 128 | 129 | // Batch insert into temporary table 130 | foreach ($episode_data as $episode) { 131 | $db->simple('INSERT INTO tmp_episodes (media_url, feed, title, description, url, image_url, pubdate, duration) 132 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)', 133 | $episode['media_url'], 134 | $episode['feed'], 135 | $episode['title'], 136 | $episode['description'], 137 | $episode['url'], 138 | $episode['image_url'], 139 | $episode['pubdate'], 140 | $episode['duration'] 141 | ); 142 | } 143 | 144 | // Perform batch upsert using a JOIN approach 145 | $db->exec('INSERT INTO episodes (feed, media_url, title, description, url, image_url, pubdate, duration) 146 | SELECT tmp.feed, tmp.media_url, tmp.title, tmp.description, tmp.url, tmp.image_url, tmp.pubdate, tmp.duration 147 | FROM tmp_episodes tmp 148 | ON DUPLICATE KEY UPDATE 149 | title = VALUES(title), 150 | description = VALUES(description), 151 | url = VALUES(url), 152 | image_url = VALUES(image_url), 153 | pubdate = VALUES(pubdate), 154 | duration = VALUES(duration)'); 155 | 156 | // Update episode actions using a JOIN approach 157 | $db->simple('UPDATE episodes_actions ea 158 | INNER JOIN episodes e ON e.media_url = ea.url 159 | SET ea.episode = e.id 160 | WHERE e.feed = ?', $feed_id); 161 | 162 | // Clean up temporary table 163 | $db->exec('DROP TEMPORARY TABLE IF EXISTS tmp_episodes'); 164 | } 165 | 166 | $db->exec('COMMIT'); 167 | } 168 | catch (Exception $e) { 169 | $db->exec('ROLLBACK'); 170 | $db->exec('DROP TEMPORARY TABLE IF EXISTS tmp_episodes'); 171 | throw $e; 172 | } 173 | } 174 | 175 | public function fetch(): bool 176 | { 177 | if (function_exists('curl_exec')) { 178 | $ch = curl_init($this->feed_url); 179 | curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); 180 | curl_setopt($ch, CURLOPT_HTTPHEADER, [ 181 | 'User-Agent: oPodSync', 182 | 'Accept-Encoding: gzip' 183 | ]); 184 | curl_setopt($ch, CURLOPT_TIMEOUT, 30); 185 | curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 186 | curl_setopt($ch, CURLOPT_MAXREDIRS, 5); 187 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 188 | curl_setopt($ch, CURLOPT_ENCODING, ''); // Handle compressed responses automatically 189 | 190 | $body = @curl_exec($ch); 191 | 192 | if (false === $body) { 193 | $error = curl_error($ch); 194 | } 195 | 196 | curl_close($ch); 197 | } 198 | else { 199 | $ctx = stream_context_create([ 200 | 'http' => [ 201 | 'header' => "User-Agent: oPodSync\r\nAccept-Encoding: gzip", 202 | 'max_redirects' => 5, 203 | 'follow_location' => true, 204 | 'timeout' => 30, 205 | 'ignore_errors' => true, 206 | ], 207 | 'ssl' => [ 208 | 'verify_peer' => true, 209 | 'verify_peer_name' => true, 210 | 'allow_self_signed' => true, 211 | 'SNI_enabled' => true, 212 | ], 213 | ]); 214 | 215 | $body = @file_get_contents($this->feed_url, false, $ctx); 216 | 217 | // Check if response is gzipped 218 | if ($body && substr($body, 0, 2) === "\x1f\x8b") { 219 | $body = @gzdecode($body); 220 | } 221 | } 222 | 223 | $this->last_fetch = time(); 224 | 225 | if (!$body) { 226 | return false; 227 | } 228 | 229 | // Remove any UTF-8 BOM if present 230 | $body = preg_replace("/^(\xef\xbb\xbf|\\x00\\x00\\xfe\\xff|\\xff\\xfe\\x00\\x00|\\xff\\xfe|\\xfe\\xff)/", "", $body); 231 | 232 | $xml = @simplexml_load_string($body); 233 | 234 | if (!$xml) { 235 | return false; 236 | } 237 | 238 | // Register all namespaces early 239 | $this->registerNamespaces($xml); 240 | 241 | // Handle both RSS and Atom feed formats 242 | if (isset($xml->channel)) { 243 | // RSS feed 244 | $channel = $xml->channel; 245 | $items = $channel->item; 246 | $this->title = (string)$channel->title; 247 | $this->url = (string)$channel->link; 248 | $this->description = (string)$channel->description; 249 | $pubdate = $channel->lastBuildDate; 250 | $language = $channel->language; 251 | 252 | // Try multiple image sources 253 | $itunesImage = $this->safeXPath($channel, 'itunes:image/@href'); 254 | if (!empty($itunesImage)) { 255 | $this->image_url = trim((string)$itunesImage[0]); 256 | } elseif(isset($channel->image->url)) { 257 | $this->image_url = trim((string)$channel->image->url); 258 | } 259 | } elseif (isset($xml->entry)) { 260 | // Atom feed 261 | $channel = $xml; 262 | $items = $xml->entry; 263 | $this->title = (string)$channel->title; 264 | 265 | // Handle Atom link 266 | foreach ($channel->link as $link) { 267 | if ((string)$link['rel'] === 'alternate' || !isset($link['rel'])) { 268 | $this->url = (string)$link['href']; 269 | break; 270 | } 271 | } 272 | 273 | $this->description = (string)($channel->subtitle ?? $channel->summary ?? ''); 274 | $pubdate = $channel->updated; 275 | $language = $channel->{'xml:lang'}; 276 | 277 | if(isset($channel->logo)) { 278 | $this->image_url = trim((string)$channel->logo); 279 | } elseif(isset($channel->icon)) { 280 | $this->image_url = trim((string)$channel->icon); 281 | } 282 | } else { 283 | // Unknown feed format 284 | return false; 285 | } 286 | 287 | if (!$this->title) { 288 | return false; 289 | } 290 | 291 | if ($items) { 292 | foreach ($items as $item) { 293 | // For Atom feeds, handle enclosure differently 294 | $audioUrl = null; 295 | if (isset($item->enclosure['url'])) { 296 | $audioUrl = trim((string)$item->enclosure['url']); 297 | } elseif (isset($item->link)) { 298 | // Check if link is audio content in Atom feeds 299 | foreach ($item->link as $link) { 300 | $type = (string)$link['type']; 301 | if (strpos($type, 'audio/') === 0) { 302 | $audioUrl = trim((string)$link['href']); 303 | break; 304 | } 305 | } 306 | } 307 | 308 | // Skip if no audio URL found 309 | if (!$audioUrl) { 310 | continue; 311 | } 312 | 313 | $title = isset($item->title) ? trim((string)$item->title) : null; 314 | 315 | // Handle different description elements 316 | if(isset($item->description)) { 317 | $description = trim((string)$item->description); 318 | } elseif(isset($item->{'content:encoded'})) { 319 | $description = trim((string)$item->{'content:encoded'}); 320 | } elseif(isset($item->content)) { 321 | $description = trim((string)$item->content); 322 | } else { 323 | $description = null; 324 | } 325 | 326 | // Handle different link formats 327 | $link = null; 328 | if (isset($item->link)) { 329 | if (is_string($item->link)) { 330 | $link = trim((string)$item->link); 331 | } elseif (isset($item->link['href'])) { 332 | $link = trim((string)$item->link['href']); 333 | } 334 | } 335 | 336 | // Handle different date formats 337 | $pubDate = null; 338 | if (isset($item->pubDate)) { 339 | $pubDate = trim((string)$item->pubDate); 340 | } elseif (isset($item->published)) { 341 | $pubDate = trim((string)$item->published); 342 | } elseif (isset($item->updated)) { 343 | $pubDate = trim((string)$item->updated); 344 | } 345 | 346 | // Get duration using safe xpath 347 | $duration = null; 348 | if (isset($item->enclosure['length']) && ctype_digit((string)$item->enclosure['length'])) { 349 | $duration = (int)$item->enclosure['length']; 350 | } else { 351 | $durationNodes = $this->safeXPath($item, 'itunes:duration'); 352 | if (!empty($durationNodes)) { 353 | $duration = $this->getDuration((string)$durationNodes[0]); 354 | } 355 | } 356 | 357 | // Handle different image formats using safe xpath 358 | $imageUrl = null; 359 | $itunesImage = $this->safeXPath($item, 'itunes:image/@href'); 360 | if (!empty($itunesImage)) { 361 | $imageUrl = trim((string)$itunesImage[0]); 362 | } elseif(isset($item->{'media:content'}['url'])) { 363 | $imageUrl = trim((string)$item->{'media:content'}['url']); 364 | } elseif(isset($item->{'media:thumbnail'}['url'])) { 365 | $imageUrl = trim((string)$item->{'media:thumbnail'}['url']); 366 | } 367 | 368 | $this->episodes[] = (object) [ 369 | 'image_url' => $imageUrl, 370 | 'url' => $link, 371 | 'media_url' => $audioUrl, 372 | 'pubdate' => $pubDate ? new \DateTime($pubDate) : null, 373 | 'title' => $title, 374 | 'description' => $description, 375 | 'duration' => $duration, 376 | ]; 377 | } 378 | } 379 | 380 | $this->language = $language ? substr((string)$language, 0, 2) : null; 381 | $this->pubdate = $pubdate ? new \DateTime((string)$pubdate) : null; 382 | 383 | return true; 384 | } 385 | 386 | protected function getDuration(?string $str): ?int 387 | { 388 | if (!$str) { 389 | return null; 390 | } 391 | 392 | if (false !== strpos($str, ':')) { 393 | $parts = explode(':', $str); 394 | $duration = ($parts[2] ?? 0) * 3600 + ($parts[1] ?? 0) * 60 + $parts[0] ?? 0; 395 | } 396 | else { 397 | $duration = (int) $str; 398 | } 399 | 400 | return $this->validateDuration($duration); 401 | } 402 | 403 | public function export(): array 404 | { 405 | $out = get_object_vars($this); 406 | $out['pubdate'] = $out['pubdate'] ? $out['pubdate']->format('Y-m-d H:i:s \U\T\C') : null; 407 | unset($out['episodes']); 408 | return $out; 409 | } 410 | 411 | public function listEpisodes(): array 412 | { 413 | return $this->episodes; 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /app/inc/GPodder.php: -------------------------------------------------------------------------------- 1 | db = $db; 11 | 12 | if (!empty($_POST['login']) || isset($_COOKIE[session_name()])) { 13 | if (isset($_GET['token']) && ctype_alnum($_GET['token'])) { 14 | session_id($_GET['token']); 15 | } 16 | 17 | @session_start(); 18 | 19 | if (!empty($_SESSION['user'])) { 20 | $this->user = $_SESSION['user']; 21 | } 22 | } 23 | } 24 | 25 | public function login(): ?string 26 | { 27 | if (empty($_POST['login']) || empty($_POST['password'])) { 28 | return null; 29 | } 30 | 31 | $user = $this->db->firstRow('SELECT * FROM users WHERE name = ?;', trim($_POST['login'])); 32 | 33 | if (!$user || !password_verify(trim($_POST['password']), $user->password ?? '')) { 34 | return __('messages.invalid_username_password'); 35 | } 36 | 37 | $_SESSION['user'] = $this->user = $user; 38 | 39 | if (!empty($_GET['token'])) { 40 | $_SESSION['app_password'] = sprintf('%s:%s', $_GET['token'], sha1($user->password . $_GET['token'])); 41 | } 42 | 43 | return null; 44 | } 45 | 46 | public function isLogged(): bool 47 | { 48 | return !empty($_SESSION['user']); 49 | } 50 | 51 | public function logout(): void 52 | { 53 | session_destroy(); 54 | } 55 | 56 | public function getUserToken(): string 57 | { 58 | return $this->user->name . '__' . substr(sha1($this->user->password), 0, 10); 59 | } 60 | 61 | public function validateToken(string $username): bool 62 | { 63 | $login = strtok($username, '__'); 64 | $token = strtok(''); 65 | 66 | $this->user = $this->db->firstRow('SELECT * FROM users WHERE name = ?;', $login); 67 | 68 | if (!$this->user) { 69 | return false; 70 | } 71 | 72 | return $username === $this->getUserToken(); 73 | } 74 | 75 | public function changePassword(string $currentPassword, string $newPassword): ?string 76 | { 77 | if (!$this->user) { 78 | return __('messages.user_not_logged'); 79 | } 80 | 81 | // Verify current password 82 | if (!password_verify(trim($currentPassword), $this->user->password)) { 83 | return __('messages.current_password_incorrect'); 84 | } 85 | 86 | // Update password in database 87 | $hashedPassword = password_hash($newPassword, null); 88 | $this->db->simple('UPDATE users SET password = ? WHERE id = ?;', $hashedPassword, $this->user->id); 89 | 90 | // Update session user object 91 | $this->user->password = $hashedPassword; 92 | $_SESSION['user'] = $this->user; 93 | 94 | return null; 95 | } 96 | 97 | public function getUserByEmail(string $email): ?stdClass 98 | { 99 | $sql = "SELECT * FROM users WHERE email = :email"; 100 | return $this->db->firstRow($sql, ['email' => $email]); 101 | } 102 | 103 | public function getUserByPasswordResetToken(string $token): ?stdClass 104 | { 105 | $sql = "SELECT * FROM users WHERE password_reset_token = :token AND password_reset_token_expires_at > :now"; 106 | return $this->db->firstRow($sql, ['token' => $token, 'now' => time()]); 107 | } 108 | 109 | public function updatePasswordResetToken(int $userId, ?string $token, ?int $expiresAt = null): void 110 | { 111 | $sql = "UPDATE users SET password_reset_token = :token, password_reset_token_expires_at = :expiresAt WHERE id = :userId"; 112 | $this->db->simple($sql, ['userId' => $userId, 'token' => $token, 'expiresAt' => $expiresAt]); 113 | } 114 | 115 | public function updateLanguage(string $language): ?string 116 | { 117 | if (!$this->user) { 118 | return __('messages.user_not_logged'); 119 | } 120 | 121 | // Validate language using Language class 122 | $validLanguages = Language::getInstance()->getAvailableLanguages(); 123 | if (!array_key_exists($language, $validLanguages)) { 124 | return __('messages.invalid_language'); 125 | } 126 | 127 | // Update language in database 128 | $this->db->simple('UPDATE users SET language = ? WHERE id = ?;', $language, $this->user->id); 129 | 130 | // Update current language instance 131 | Language::getInstance()->setLanguage($language); 132 | 133 | // Update session user object 134 | $this->user->language = $language; 135 | $_SESSION['user'] = $this->user; 136 | 137 | return null; 138 | } 139 | 140 | public function updateTimezone(string $timezone): ?string 141 | { 142 | if (!$this->user) { 143 | return __('messages.user_not_logged'); 144 | } 145 | 146 | // Validate timezone 147 | if (!in_array($timezone, DateTimeZone::listIdentifiers())) { 148 | return __('messages.invalid_timezone'); 149 | } 150 | 151 | // Update timezone in database 152 | $this->db->simple('UPDATE users SET timezone = ? WHERE id = ?;', $timezone, $this->user->id); 153 | 154 | // Update session user object 155 | $this->user->timezone = $timezone; 156 | $_SESSION['user'] = $this->user; 157 | 158 | return null; 159 | } 160 | 161 | public function canSubscribe(): bool 162 | { 163 | if (ENABLE_SUBSCRIPTIONS) { 164 | return true; 165 | } 166 | 167 | if (!$this->db->firstColumn('SELECT COUNT(*) FROM users;')) { 168 | return true; 169 | } 170 | 171 | return false; 172 | } 173 | 174 | public function validateEmail(string $email) { 175 | if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { 176 | return false; 177 | } 178 | 179 | $domain = substr(strrchr($email, "@"), 1); 180 | if (!checkdnsrr($domain, "MX")) { 181 | return false; 182 | } 183 | 184 | return true; 185 | } 186 | 187 | public function subscribe(string $name, string $password, string $email): ?string 188 | { 189 | if (trim($name) === '' || !preg_match('/^\w[\w_-]+$/', $name)) { 190 | return 'Nome de usuário inválido. Permitido é: \w[\w\d_-]+'; 191 | } 192 | 193 | if ($name === 'current') { 194 | return 'Este nome de usuário está bloqueado, escolha outro.'; 195 | } 196 | 197 | $password = trim($password); 198 | 199 | if (strlen($password) < 8) { 200 | return 'A senha é muito curta'; 201 | } 202 | 203 | $email = trim($email); 204 | 205 | if ($this->validateEmail($email) === false) { 206 | return 'Email invalido'; 207 | } 208 | 209 | if ($this->db->firstColumn('SELECT 1 FROM users WHERE name = ?;', $name)) { 210 | return 'O nome de usuário já existe'; 211 | } 212 | 213 | $this->db->simple('INSERT INTO users (name, password, email, language, timezone) VALUES (?, ?, ?, ?, ?);', trim($name), password_hash($password, null), trim($email), 'en', 'UTC'); 214 | return null; 215 | } 216 | 217 | /** 218 | * @throws Exception 219 | */ 220 | public function generateCaptcha(): string 221 | { 222 | $n = ''; 223 | $c = ''; 224 | 225 | for ($i = 0; $i < 4; $i++) { 226 | $j = random_int(0, 9); 227 | $c .= $j; 228 | $n .= sprintf('%d%d', random_int(0, 9), $j); 229 | } 230 | 231 | $n .= sprintf('', sha1($c . __DIR__)); 232 | 233 | return $n; 234 | } 235 | 236 | public function checkCaptcha(string $captcha, string $check): bool 237 | { 238 | $captcha = trim($captcha); 239 | return sha1($captcha . __DIR__) === $check; 240 | } 241 | 242 | public function countActiveSubscriptions(): int 243 | { 244 | $count = $this->db->firstColumn( 245 | 'SELECT COUNT(*) FROM subscriptions WHERE user = ? AND deleted = 0', 246 | $this->user->id 247 | ); 248 | 249 | return (int)$count; 250 | } 251 | 252 | public function listActiveSubscriptions(): array 253 | { 254 | return $this->db->all('SELECT s.*, 255 | COUNT(a.id) AS count, 256 | f.title, 257 | f.image_url, 258 | f.description, 259 | GREATEST(COALESCE(MAX(a.changed), 0), s.changed) AS last_change 260 | FROM subscriptions s 261 | LEFT JOIN episodes_actions a ON a.subscription = s.id 262 | LEFT JOIN feeds f ON f.id = s.feed 263 | WHERE s.user = ? AND s.deleted = 0 264 | GROUP BY s.id, s.user, s.url, s.feed, s.changed, s.deleted, f.title 265 | ORDER BY last_change DESC', 266 | $this->user->id 267 | ); 268 | } 269 | 270 | public function listActions(int $subscription): array 271 | { 272 | return $this->db->all('SELECT a.*, 273 | d.name AS device_name, 274 | e.title, 275 | e.image_url, 276 | e.duration, 277 | e.url AS episode_url 278 | FROM episodes_actions a 279 | LEFT JOIN devices d ON d.id = a.device AND a.user = d.user 280 | LEFT JOIN episodes e ON e.id = a.episode 281 | WHERE a.user = ? AND a.subscription = ? 282 | ORDER BY changed DESC;', $this->user->id, $subscription); 283 | } 284 | 285 | public function updateFeedForSubscription(int $subscription): ?Feed 286 | { 287 | $url = $this->db->firstColumn('SELECT url FROM subscriptions WHERE id = ?;', $subscription); 288 | 289 | if (!$url) { 290 | return null; 291 | } 292 | 293 | $feed = new Feed($url); 294 | 295 | if (!$feed->fetch()) { 296 | return null; 297 | } 298 | 299 | $feed->sync($this->db); 300 | 301 | return $feed; 302 | } 303 | 304 | public function getFeedForSubscription(int $subscription): ?Feed 305 | { 306 | $data = $this->db->firstRow('SELECT f.* 307 | FROM subscriptions s INNER JOIN feeds f ON f.id = s.feed 308 | WHERE s.id = ?;', $subscription); 309 | 310 | if (!$data) { 311 | return null; 312 | } 313 | 314 | $feed = new Feed($data->feed_url); 315 | $feed->load($data); 316 | return $feed; 317 | } 318 | 319 | public function updateAllFeeds(bool $cli = false): void 320 | { 321 | $sql = 'SELECT s.id AS subscription, s.url, 322 | GREATEST(COALESCE(MAX(a.changed), 0), s.changed) AS changed 323 | FROM subscriptions s 324 | LEFT JOIN episodes_actions a ON a.subscription = s.id 325 | LEFT JOIN feeds f ON f.id = s.feed 326 | WHERE f.last_fetch IS NULL 327 | OR f.last_fetch < s.changed 328 | OR f.last_fetch < COALESCE(a.changed, 0) 329 | GROUP BY s.id, s.url, s.changed'; 330 | 331 | @ini_set('max_execution_time', 3600); 332 | @ob_end_flush(); 333 | @ob_implicit_flush(true); 334 | $i = 0; 335 | 336 | foreach ($this->db->iterate($sql) as $row) { 337 | @set_time_limit(30); 338 | 339 | if ($cli) { 340 | printf("Atualizando %s\n", $row->url); 341 | } 342 | else { 343 | printf("

Atualizando %s

", htmlspecialchars($row->url)); 344 | echo str_pad(' ', 4096); 345 | flush(); 346 | } 347 | 348 | $this->updateFeedForSubscription($row->subscription); 349 | $i++; 350 | } 351 | 352 | if (!$i) { 353 | echo "Nada para atualizar\n"; 354 | } 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /app/inc/Language.php: -------------------------------------------------------------------------------- 1 | db = new DB(DB_HOST, DB_NAME, DB_USER, DB_PASS); 13 | } catch (Exception $e) { 14 | error_log("Error connecting to database: " . $e->getMessage()); 15 | $this->db = null; 16 | } 17 | 18 | // Carrega o idioma inicial 19 | $this->loadInitialLanguage(); 20 | } 21 | 22 | private function loadInitialLanguage() { 23 | if (isset($_SESSION['user'], $_SESSION['user']->id) && $this->db !== null) { 24 | try { 25 | $result = $this->db->firstRow( 26 | "SELECT language FROM users WHERE id = ?", 27 | $_SESSION['user']->id 28 | ); 29 | 30 | if ($result && isset($result->language) && $this->isValidLanguage($result->language)) { 31 | $this->currentLang = $result->language; 32 | } 33 | } catch (Exception $e) { 34 | error_log("Error loading initial language: " . $e->getMessage()); 35 | } 36 | } 37 | 38 | // Carrega os arquivos de tradução 39 | $this->loadLanguage($this->currentLang); 40 | } 41 | 42 | public function getCurrentLanguage() { 43 | // Se tem usuário logado e conexão com banco, sempre busca do banco 44 | if (isset($_SESSION['user'], $_SESSION['user']->id) && $this->db !== null) { 45 | try { 46 | $result = $this->db->firstRow( 47 | "SELECT language FROM users WHERE id = ?", 48 | $_SESSION['user']->id 49 | ); 50 | 51 | if ($result && isset($result->language) && $this->isValidLanguage($result->language)) { 52 | $this->currentLang = $result->language; 53 | } 54 | } catch (Exception $e) { 55 | error_log("Error getting current language: " . $e->getMessage()); 56 | } 57 | } 58 | 59 | return $this->currentLang; 60 | } 61 | 62 | public function setLanguage($lang) { 63 | if (!$this->isValidLanguage($lang)) { 64 | return false; 65 | } 66 | 67 | $this->currentLang = $lang; 68 | 69 | // Se tem usuário logado e conexão com banco, atualiza no banco 70 | if (isset($_SESSION['user'], $_SESSION['user']->id) && $this->db !== null) { 71 | try { 72 | $this->db->simple( 73 | "UPDATE users SET language = ? WHERE id = ?", 74 | $lang, 75 | $_SESSION['user']->id 76 | ); 77 | } catch (Exception $e) { 78 | error_log("Error setting language: " . $e->getMessage()); 79 | return false; 80 | } 81 | } 82 | 83 | $this->loadLanguage($lang); 84 | return true; 85 | } 86 | 87 | public static function getInstance() { 88 | if (self::$instance === null) { 89 | self::$instance = new self(); 90 | } 91 | return self::$instance; 92 | } 93 | 94 | private function loadLanguage($lang) { 95 | $langFile = __DIR__ . "/languages/{$lang}.php"; 96 | if (file_exists($langFile)) { 97 | $this->translations = require $langFile; 98 | } else { 99 | $this->translations = require __DIR__ . "/languages/en.php"; 100 | $this->currentLang = 'en'; 101 | } 102 | } 103 | 104 | public function get($key) { 105 | $keys = explode('.', $key); 106 | $value = $this->translations; 107 | 108 | foreach ($keys as $k) { 109 | if (!isset($value[$k])) { 110 | return $key; 111 | } 112 | $value = $value[$k]; 113 | } 114 | 115 | return $value; 116 | } 117 | 118 | public function getAvailableLanguages() { 119 | return [ 120 | 'en' => 'English', 121 | 'es' => 'Español', 122 | 'pt-BR' => 'Português (Brasil)' 123 | ]; 124 | } 125 | 126 | private function isValidLanguage($lang) { 127 | return array_key_exists($lang, $this->getAvailableLanguages()); 128 | } 129 | } 130 | 131 | // Helper function for easy translation 132 | function __($key) { 133 | return Language::getInstance()->get($key); 134 | } 135 | -------------------------------------------------------------------------------- /app/inc/index.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'profile' => 'Profile', 5 | 'logout' => 'Logout', 6 | 'login' => 'Login', 7 | 'register' => 'Register', 8 | 'welcome' => 'Welcome', 9 | 'language' => 'Language', 10 | 'save' => 'Save', 11 | 'home' => 'Home', 12 | 'administration' => 'Administration', 13 | 'subscriptions' => 'Subscriptions', 14 | 'podcast_sync' => 'Podcast Synchronization', 15 | 'site_description' => 'Podcast synchronization server based on gPodder protocol with AntennaPod support', 16 | 'back' => 'Back', 17 | 'add' => 'Add', 18 | 'delete' => 'Delete', 19 | 'download' => 'Download', 20 | 'update' => 'Update', 21 | 'hello' => 'Hello', 22 | 'duration' => 'Duration', 23 | 'statistics' => 'Statistics', 24 | 'username' => 'Username', 25 | 'password' => 'Password', 26 | 'email' => 'Email', 27 | 'min_password_length' => 'Password (minimum 8 characters)', 28 | 'latest_updates' => 'Latest Updates', 29 | 'devices' => 'Devices', 30 | 'forgot_password' => 'Forgot password?', 31 | 'send_reset_link' => 'Send reset link', 32 | 'reset_password' => 'Reset password', 33 | 'new_password' => 'New password', 34 | ], 35 | 'errors' => [ 36 | 'schema_file_not_found' => 'MySQL schema file not found', 37 | 'sql_error' => 'Error executing SQL command: %s\nThe command was: %s' 38 | ], 39 | 'profile' => [ 40 | 'change_password' => 'Change Password', 41 | 'current_password' => 'Current Password', 42 | 'new_password' => 'New Password', 43 | 'confirm_password' => 'Confirm Password', 44 | 'language_settings' => 'Language Settings', 45 | 'select_language' => 'Select Language', 46 | 'settings_saved' => 'Settings saved successfully', 47 | 'error_saving' => 'Error saving settings', 48 | 'language_updated' => 'Language updated successfully', 49 | 'password_changed' => 'Password changed successfully', 50 | 'passwords_dont_match' => 'New passwords do not match', 51 | 'min_password_length' => 'Minimum 8 characters', 52 | 'timezone_settings' => 'Timezone Settings', 53 | 'select_timezone' => 'Select Timezone', 54 | 'timezone_updated' => 'Timezone updated successfully' 55 | ], 56 | 'languages' => [ 57 | 'en' => 'English', 58 | 'pt-BR' => 'Portuguese (Brazil)' 59 | ], 60 | 'admin' => [ 61 | 'add_user' => 'Add New User', 62 | 'user_list' => 'Users List', 63 | 'confirm_delete' => 'Are you sure you want to delete this user?', 64 | 'user_deleted' => 'User deleted successfully', 65 | 'user_registered' => 'User registered successfully' 66 | ], 67 | 'dashboard' => [ 68 | 'secret_user' => 'GPodder Secret User', 69 | 'secret_user_note' => '(Use this username in GPodder Desktop, as it does not support passwords)', 70 | 'latest_updates' => 'Latest 10 Updates', 71 | 'registered_devices' => 'Registered Devices', 72 | 'no_info' => 'No information available for this feed', 73 | 'last_update' => 'Last Update', 74 | 'update_all_metadata' => 'Update all feed metadata', 75 | 'cron_notice' => 'Feed metadata updates are configured to be done by routines directly on the server, updates are done every hour.', 76 | 'opml_feed' => 'OPML Feed' 77 | ], 78 | 'devices' => [ 79 | 'mobile' => 'Mobile', 80 | 'desktop' => 'Desktop', 81 | 'unavailable' => 'Unavailable' 82 | ], 83 | 'actions' => [ 84 | 'played' => 'Played', 85 | 'downloaded' => 'Downloaded', 86 | 'deleted' => 'Deleted', 87 | 'unavailable' => 'Unavailable', 88 | 'on' => 'on', 89 | 'at' => 'at', 90 | 'from' => 'from', 91 | ], 92 | 'messages' => [ 93 | 'subscriptions_disabled' => 'Subscriptions are disabled.', 94 | 'invalid_captcha' => 'Invalid captcha.', 95 | 'login_success' => 'You are logged in, you can close this and return to the application.', 96 | 'metadata_warning' => 'Episode titles and images may be missing due to trackers/ads used by some podcast providers.', 97 | 'app_requesting_access' => 'An application is requesting access to your account.', 98 | 'fill_captcha' => 'Fill in the following number:', 99 | 'auto_url_error' => 'Cannot automatically detect application URL. Set the BASE_URL constant or environment variable.', 100 | 'invalid_url' => 'Invalid URL:', 101 | 'device_id_not_registered' => 'Device ID not registered', 102 | 'invalid_username' => 'Invalid username', 103 | 'invalid_username_password' => 'Invalid username/password', 104 | 'no_username_password' => 'No username or password provided', 105 | 'session_cookie_required' => 'Session cookie is required', 106 | 'session_expired' => 'Session ID cookie expired and no authorization header was provided', 107 | 'user_not_exists' => 'User does not exist', 108 | 'logged_out' => 'Logged out', 109 | 'unknown_login_action' => 'Unknown login action:', 110 | 'invalid_gpodder_token' => 'Invalid gpodder token', 111 | 'invalid_device_id' => 'Invalid device ID', 112 | 'invalid_input_array' => 'Invalid input: requires an array with one line per feed', 113 | 'not_implemented' => 'Not implemented yet', 114 | 'invalid_array' => 'No valid array found', 115 | 'missing_action_key' => 'Missing action key', 116 | 'nextcloud_undefined_endpoint' => 'Undefined Nextcloud API endpoint', 117 | 'output_format_not_implemented' => 'Output format not implemented', 118 | 'email_already_registered' => 'Email address is already registered', 119 | 'subscriptions_metadata' => 'Episode titles and images may be missing due to trackers/ads used by some podcast providers.', 120 | 'user_not_logged' => 'User is not logged in', 121 | 'current_password_incorrect' => 'Current password incorrect', 122 | 'invalid_language' => 'Invalid language', 123 | 'invalid_timezone' => 'Invalid Timezone', 124 | 'invalid_username' => 'Invalid username. Allowed: \w[\w\d_-]+', 125 | 'username_blocked' => 'This username is blocked, please choose another.', 126 | 'password_too_short' => 'Password is too short', 127 | 'email_invalid' => 'Email is invalid', 128 | 'username_already_exists' => 'Username already exists' 129 | ], 130 | 'statistics' => [ 131 | 'registered_users' => 'Registered Users', 132 | 'registered_devices' => 'Registered Devices', 133 | 'top_10' => 'Top 10', 134 | 'most_subscribed' => 'Most Subscribed', 135 | 'most_downloaded' => 'Most Downloaded', 136 | 'most_played' => 'Most Played' 137 | ], 138 | 'footer' => [ 139 | 'managed_by' => 'Instance managed and maintained by', 140 | 'with_love_by' => 'With ❤️ by', 141 | 'version' => 'Version' 142 | ], 143 | 'home' => [ 144 | 'intro' => 'This is a podcast synchronization server based on the gPodder "protocol".', 145 | 'fork_note' => 'This project is a fork of', 146 | 'github_project' => 'Project published on Github', 147 | 'tested_apps' => 'Tested Applications' 148 | ], 149 | 'forget_password' => [ 150 | 'email_sent' => 'A password reset email has been sent to your email address.', 151 | 'email_not_registered' => 'The email address you provided is not registered.' 152 | ], 153 | 'db' => [ 154 | 'schema_not_found' => 'mysql.sql schema file not found' 155 | ], 156 | 'erros' => [ 157 | 'debug_log' => 'An error happened and has been logged to logs/error.log', 158 | 'debug_enable' => 'Enable DEBUG constant to see errors directly', 159 | 'invalid_deviceid' => 'Invalid Device ID', 160 | 'invalid_url' => 'Invalid URL', 161 | 'invalid_username' => 'Invalid Username', 162 | 'invalid_timestamp' => 'Invalid Timestamp' 163 | ] 164 | ]; 165 | -------------------------------------------------------------------------------- /app/inc/languages/es.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'profile' => 'Perfil', 5 | 'logout' => 'Cerrar sesión', 6 | 'login' => 'Iniciar sesión', 7 | 'register' => 'Registrarse', 8 | 'welcome' => 'Bienvenido/a', 9 | 'language' => 'Idioma', 10 | 'save' => 'Grabar', 11 | 'home' => 'Inicio', 12 | 'administration' => 'Administración', 13 | 'subscriptions' => 'Subscripciones', 14 | 'podcast_sync' => 'Sincronización de podcasts', 15 | 'site_description' => 'Servidor de sincronización de podcasts basado en el protocolo gPodder con soporte para AntennaPod', 16 | 'back' => 'Atrás', 17 | 'add' => 'Agregar', 18 | 'delete' => 'Borrar', 19 | 'download' => 'Descargar', 20 | 'update' => 'Actualizar', 21 | 'hello' => 'Hola, ', 22 | 'duration' => 'Duración', 23 | 'statistics' => 'Estadísticas', 24 | 'username' => 'Usuario', 25 | 'password' => 'Contraseña', 26 | 'email' => 'Email', 27 | 'min_password_length' => 'Contraseña (mínimo 8 caracters)', 28 | 'latest_updates' => 'Últimas actualizaciones', 29 | 'devices' => 'Dispositivos', 30 | 'forgot_password' => '¿Olvidó su contraseña?', 31 | 'send_reset_link' => 'Enviar enlace para restablecer', 32 | 'reset_password' => 'Restablecer contraseña', 33 | 'new_password' => 'Nueva contraseña', 34 | ], 35 | 'errors' => [ 36 | 'schema_file_not_found' => 'Archivo de esquema MySQL no encontrado', 37 | 'sql_error' => 'Error ejecutando instrucción SQL: %s\nLa instrucción era: %s' 38 | ], 39 | 'profile' => [ 40 | 'change_password' => 'Cambiar contraseña', 41 | 'current_password' => 'Contraseña actual', 42 | 'new_password' => 'Nueva contraseña', 43 | 'confirm_password' => 'Confirmar contraseña', 44 | 'language_settings' => 'Configuración de idioma', 45 | 'select_language' => 'Elija idioma', 46 | 'settings_saved' => 'Configuración grabada con éxito', 47 | 'error_saving' => 'Error grabando configuración', 48 | 'language_updated' => 'Idioma actualizado con éxito', 49 | 'password_changed' => 'Contraseña cambiada con éxito', 50 | 'passwords_dont_match' => 'Las nuevas contraseñas no coinciden', 51 | 'min_password_length' => 'Mínimo 8 caracteres', 52 | 'timezone_settings' => 'Configuración de zona horaria', 53 | 'select_timezone' => 'Elija la zona horaria', 54 | 'timezone_updated' => 'Zona horaria actualizada con éxito' 55 | ], 56 | 'languages' => [ 57 | 'en' => 'English', 58 | 'es' => 'Spanish', 59 | 'pt-BR' => 'Portuguese (Brazil)' 60 | ], 61 | 'admin' => [ 62 | 'add_user' => 'Agregar usuario nuevo', 63 | 'user_list' => 'Lista de usuarios', 64 | 'confirm_delete' => '¿Está seguro de que quiere borrar este usuario?', 65 | 'user_deleted' => 'Usuario borrado con éxito', 66 | 'user_registered' => 'Usuario registrado con éxito' 67 | ], 68 | 'dashboard' => [ 69 | 'secret_user' => 'Usuario secreto de GPodder', 70 | 'secret_user_note' => '(Use este nombre de usuario en GPodder Desktop, porque no soporta contraseñas)', 71 | 'latest_updates' => 'Últimas 10 actualizaciones', 72 | 'registered_devices' => 'Dispositivos registrados', 73 | 'no_info' => 'No hay información disponible para este origen', 74 | 'last_update' => 'Última actualización', 75 | 'update_all_metadata' => 'Actualice todos los metadatos de orígenes', 76 | 'cron_notice' => 'Las actualizaciones de los metadatos de orígenes están configuradas para que las realizen rutinas directamente en el servidor, las actualizaciones se realizan una vez cada hora.', 77 | 'opml_feed' => 'OPML Feed' 78 | ], 79 | 'devices' => [ 80 | 'mobile' => 'Móvil', 81 | 'desktop' => 'Escritorio', 82 | 'unavailable' => 'No disponible' 83 | ], 84 | 'actions' => [ 85 | 'played' => 'Reproducido', 86 | 'downloaded' => 'Descargado', 87 | 'deleted' => 'Borrado', 88 | 'unavailable' => 'No disponible', 89 | 'on' => 'el', 90 | 'at' => 'a las', 91 | 'from' => 'de', 92 | ], 93 | 'messages' => [ 94 | 'subscriptions_disabled' => 'Suscripciones desactivadas.', 95 | 'invalid_captcha' => 'CAPTCHA inválido.', 96 | 'login_success' => 'Su sesión está iniciada, puede cerrar esto y volver a la aplicación.', 97 | 'metadata_warning' => 'Puede que falten títulos e imágenes debido a los seguimientos y publicidades utilizados por algunos proveedores de podcasts.', 98 | 'app_requesting_access' => 'Una aplicación está pidiendo acceso a su cuenta.', 99 | 'fill_captcha' => 'Ingrese el número siguiente:', 100 | 'auto_url_error' => 'No se puede detectar automáticamente el URL de la aplicación. Asigne un valor a la constante o variable de entorno BASE_URL.', 101 | 'invalid_url' => 'URL inválido:', 102 | 'device_id_not_registered' => 'ID de dispositivo no registrado', 103 | 'invalid_username' => 'Nombre de usuario inválido', 104 | 'invalid_username_password' => 'Combinación inválida de usuario y contraseña', 105 | 'no_username_password' => 'No se indicó usuario o contraseña', 106 | 'session_cookie_required' => 'Se requiere una cookie de sesión', 107 | 'session_expired' => 'La cookie de sesión expiró y no se proporcionó una cabecera de autenticación', 108 | 'user_not_exists' => 'El usuario no existe', 109 | 'logged_out' => 'Sesión cerrada', 110 | 'unknown_login_action' => 'Acción de inicio de sesión desconocida:', 111 | 'invalid_gpodder_token' => 'El token de gpodder no es válido', 112 | 'invalid_device_id' => 'ID de dispositivo no válido', 113 | 'invalid_input_array' => 'Ingreso incorrecto: se requiere un arreglo de una línea por origen', 114 | 'not_implemented' => 'Not implemented yet', 115 | 'invalid_array' => 'No se encontró un arreglo válido', 116 | 'missing_action_key' => 'Falta la clave de acción', 117 | 'nextcloud_undefined_endpoint' => 'No está definido el endpoint de la API de Nextcloud', 118 | 'output_format_not_implemented' => 'Formato de salida no implementado', 119 | 'email_already_registered' => 'Esa dirección de correo ya está registrada', 120 | 'subscriptions_metadata' => 'Puede que falten títulos e imágenes debido a los seguimientos y publicidades utilizados por algunos proveedores de podcasts.', 121 | 'user_not_logged' => 'No se inició la sesión del usuario', 122 | 'current_password_incorrect' => 'Contraseña actual incorrecta', 123 | 'invalid_language' => 'Idioma inválido', 124 | 'invalid_timezone' => 'Zona horaria inválida', 125 | 'invalid_username' => 'Nombre de usuario inválido. Se permiten: \w[\w\d_-]+', 126 | 'username_blocked' => 'Este nombre de usuario está bloqueado, por favor elija otro.', 127 | 'password_too_short' => 'Contraseña muy corta', 128 | 'email_invalid' => 'Dirección de correo inválida', 129 | 'username_already_exists' => 'Nombre de usuario ya existe' 130 | ], 131 | 'statistics' => [ 132 | 'registered_users' => 'Usuarios registrados', 133 | 'registered_devices' => 'Dispositivos registrados', 134 | 'top_10' => 'Los 10 principales', 135 | 'most_subscribed' => 'más suscritos', 136 | 'most_downloaded' => 'más descargados', 137 | 'most_played' => 'más reproducidos' 138 | ], 139 | 'footer' => [ 140 | 'managed_by' => 'Instancia manejada y mantenida por', 141 | 'with_love_by' => 'Con ❤️ por', 142 | 'version' => 'Versión' 143 | ], 144 | 'home' => [ 145 | 'intro' => 'Este es un servidor de sincronización de podcasts basado en el "protocolo" gPodder.', 146 | 'fork_note' => 'Este proyecto es un fork de', 147 | 'github_project' => 'Projecto publicado en Github', 148 | 'tested_apps' => 'Aplicaciones probadas' 149 | ], 150 | 'forget_password' => [ 151 | 'email_sent' => 'Enviamos un correo electrónico de restablecimiento de contraseña a su dirección de correo.', 152 | 'email_not_registered' => 'La dirección de correo electrónico que ingresó no está registrada.' 153 | ], 154 | 'db' => [ 155 | 'schema_not_found' => 'archivo de esquema mysql.sql no encontrado' 156 | ], 157 | 'erros' => [ 158 | 'debug_log' => 'Ocurrió un error y se registró en logs/error.log', 159 | 'debug_enable' => 'Establezca la constante DEBUG para ver los errores directamente', 160 | 'invalid_deviceid' => 'ID inválido de dispositivo', 161 | 'invalid_url' => 'URL inválido', 162 | 'invalid_username' => 'Nombre de usuario inválido', 163 | 'invalid_timestamp' => 'Hora inválida' 164 | ] 165 | ]; 166 | -------------------------------------------------------------------------------- /app/inc/languages/pt-BR.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'profile' => 'Perfil', 5 | 'logout' => 'Sair', 6 | 'login' => 'Entrar', 7 | 'register' => 'Registrar', 8 | 'welcome' => 'Bem-vindo', 9 | 'language' => 'Idioma', 10 | 'save' => 'Salvar', 11 | 'home' => 'Início', 12 | 'administration' => 'Administração', 13 | 'subscriptions' => 'Inscrições', 14 | 'podcast_sync' => 'Sincronização de Podcasts', 15 | 'site_description' => 'Servidor de sincronização de podcast baseado no protocolo gPodder com suporte ao AntennaPod', 16 | 'back' => 'Voltar', 17 | 'add' => 'Adicionar', 18 | 'delete' => 'Deletar', 19 | 'download' => 'Download', 20 | 'update' => 'Atualizar', 21 | 'hello' => 'Olá', 22 | 'duration' => 'Duração', 23 | 'statistics' => 'Estatísticas', 24 | 'username' => 'Usuário', 25 | 'password' => 'Senha', 26 | 'email' => 'Email', 27 | 'min_password_length' => 'Senha (mínimo de 8 caracteres)', 28 | 'latest_updates' => 'Últimas atualizações', 29 | 'devices' => 'Dispositivos', 30 | 'forgot_password' => 'Esqueceu a senha?', 31 | 'send_reset_link' => 'Enviar email de recuperação', 32 | 'reset_password' => 'Recuperar senha', 33 | 'new_password' => 'Nova senha', 34 | ], 35 | 'errors' => [ 36 | 'schema_file_not_found' => 'Arquivo de esquema mysql.sql não encontrado', 37 | 'sql_error' => 'Erro ao executar o comando SQL: %s\nO comando foi: %s' 38 | ], 39 | 'profile' => [ 40 | 'change_password' => 'Alterar Senha', 41 | 'current_password' => 'Senha Atual', 42 | 'new_password' => 'Nova Senha', 43 | 'confirm_password' => 'Confirmar Senha', 44 | 'language_settings' => 'Configurações de Idioma', 45 | 'select_language' => 'Selecionar Idioma', 46 | 'settings_saved' => 'Configurações salvas com sucesso', 47 | 'error_saving' => 'Erro ao salvar configurações', 48 | 'language_updated' => 'Idioma atualizado com sucesso', 49 | 'password_changed' => 'Senha alterada com sucesso', 50 | 'passwords_dont_match' => 'As novas senhas não coincidem', 51 | 'min_password_length' => 'Mínimo de 8 caracteres', 52 | 'timezone_settings' => 'Configuração de fuso horário', 53 | 'select_timezone' => 'Selecionar fuso horário', 54 | 'timezone_updated' => 'Fuso horário atualizado com sucesso' 55 | ], 56 | 'languages' => [ 57 | 'en' => 'Inglês', 58 | 'pt-BR' => 'Português (Brasil)' 59 | ], 60 | 'admin' => [ 61 | 'add_user' => 'Adicionar Novo Usuário', 62 | 'user_list' => 'Lista de Usuários', 63 | 'confirm_delete' => 'Tem certeza que deseja deletar este usuário?', 64 | 'user_deleted' => 'Usuário deletado com sucesso', 65 | 'user_registered' => 'Usuário registrado com sucesso' 66 | ], 67 | 'dashboard' => [ 68 | 'secret_user' => 'Usuário secreto do GPodder', 69 | 'secret_user_note' => '(Use este nome de usuário no GPodder Desktop, pois ele não suporta senhas)', 70 | 'latest_updates' => 'Últimas 10 atualizações', 71 | 'registered_devices' => 'Dispositivos registrados', 72 | 'no_info' => 'Nenhuma informação disponível neste feedNenhuma informação disponível neste feed', 73 | 'last_update' => 'Última atualização', 74 | 'update_all_metadata' => 'Atualizar todos os metadados dos feeds', 75 | 'cron_notice' => 'A atualização de meta dados das inscrições está configurada para ser feita por rotinas diretamente no servidor, as atualização são feitas a cada uma hora.', 76 | 'opml_feed' => 'Feed OPML' 77 | ], 78 | 'devices' => [ 79 | 'mobile' => 'Mobile', 80 | 'desktop' => 'Desktop', 81 | 'unavailable' => 'Indisponível' 82 | ], 83 | 'actions' => [ 84 | 'played' => 'Tocado', 85 | 'downloaded' => 'Baixado', 86 | 'deleted' => 'Deletado', 87 | 'unavailable' => 'Indisponível', 88 | 'on' => 'em', 89 | 'at' => 'às', 90 | 'from' => 'no', 91 | ], 92 | 'messages' => [ 93 | 'subscriptions_disabled' => 'As assinaturas estão desabilitadas.', 94 | 'invalid_captcha' => 'Captcha inválido.', 95 | 'login_success' => 'Você está logado, pode fechar isso e voltar para o aplicativo.', 96 | 'metadata_warning' => 'Os títulos e imagens dos episódios podem estar faltando devido a rastreadores/anúncios usados por alguns provedores de podcast.', 97 | 'app_requesting_access' => 'Um aplicativo está solicitando acesso à sua conta.', 98 | 'fill_captcha' => 'Preencha com seguinte número:', 99 | 'auto_url_error' => 'Não é possível detectar automaticamente a URL do aplicativo. Defina a constante BASE_URL ou a variável de ambiente.', 100 | 'invalid_url' => 'URL inválida:', 101 | 'device_id_not_registered' => 'ID do dispositivo não registrado', 102 | 'invalid_username' => 'Nome de usuário inválido', 103 | 'invalid_username_password' => 'Nome de usuário/senha inválidos', 104 | 'no_username_password' => 'Nenhum nome de usuário ou senha fornecidos', 105 | 'session_cookie_required' => 'Cookie de sessão é necessário', 106 | 'session_expired' => 'Cookie de ID de sessão expirado e nenhum cabeçalho de autorização foi fornecido', 107 | 'user_not_exists' => 'O usuário não existe', 108 | 'logged_out' => 'Desconectado', 109 | 'unknown_login_action' => 'Ação de login desconhecida:', 110 | 'invalid_gpodder_token' => 'Token gpodder inválido', 111 | 'invalid_device_id' => 'ID do dispositivo inválido', 112 | 'invalid_input_array' => 'Entrada inválida: requer uma matriz com uma linha por feed', 113 | 'not_implemented' => 'Ainda não implementado', 114 | 'invalid_array' => 'Nenhuma matriz válida encontrada', 115 | 'missing_action_key' => 'Chave de ação ausente', 116 | 'nextcloud_undefined_endpoint' => 'Ponto de extremidade da API Nextcloud indefinido', 117 | 'output_format_not_implemented' => 'Formato de saída não implementado', 118 | 'email_already_registered' => 'Endereço de e-mail já registrado', 119 | 'subscriptions_metadata' => 'Os títulos e imagens dos episódios podem estar faltando devido a rastreadores/anúncios usados ​​por alguns provedores de podcast.', 120 | 'user_not_logged' => 'Usuário não está logado', 121 | 'current_password_incorrect' => 'Senha atual incorreta', 122 | 'invalid_language' => 'Idioma inválido', 123 | 'invalid_timezone' => 'Fuso horário inválido', 124 | 'invalid_username' => 'Nome de usuário inválido. Permitido é: \w[\w\d_-]+', 125 | 'username_blocked' => 'Este nome de usuário está bloqueado, escolha outro.', 126 | 'password_too_short' => 'A senha é muito curta', 127 | 'email_invalid' => 'Email invalido', 128 | 'username_already_exists' => 'O nome de usuário já existe' 129 | ], 130 | 'statistics' => [ 131 | 'registered_users' => 'Usuários Registrados', 132 | 'registered_devices' => 'Dispositivos Registrados', 133 | 'top_10' => 'Top 10', 134 | 'most_subscribed' => 'Mais Inscritos', 135 | 'most_downloaded' => 'Mais Baixados', 136 | 'most_played' => 'Mais Tocados' 137 | ], 138 | 'footer' => [ 139 | 'managed_by' => 'Instância gerenciada e mantida por', 140 | 'fork_note' => 'Esse projeto é um fork do', 141 | 'github_project' => 'Projeto publicado no Github', 142 | 'tested_apps' => 'Aplicativos testados' 143 | ], 144 | 'forget_password' => [ 145 | 'email_sent' => 'Um e-mail de redefinição de senha foi enviado para seu endereço de e-mail.', 146 | 'email_not_registered' => 'O endereço de e-mail que você forneceu não está registrado.' 147 | ], 148 | 'db' => [ 149 | 'schema_not_found' => 'Arquivo de esquema mysql.sql não encontrado' 150 | ], 151 | 'erros' => [ 152 | 'debug_log' => 'Ocorreu um erro e foi registrado em logs/error.log', 153 | 'debug_enable' => 'Habilitar constante DEBUG para ver erros', 154 | 'invalid_deviceid' => 'Device ID inválido', 155 | 'invalid_url' => 'URL inválida', 156 | 'invalid_username' => 'Usuario inválido', 157 | 'invalid_timestamp' => 'Timestamp inválido' 158 | ] 159 | ]; 160 | -------------------------------------------------------------------------------- /app/inc/mysql.sql: -------------------------------------------------------------------------------- 1 | SET NAMES utf8mb4; 2 | SET FOREIGN_KEY_CHECKS = 0; 3 | 4 | -- ---------------------------- 5 | -- Table structure for devices 6 | -- ---------------------------- 7 | DROP TABLE IF EXISTS `devices`; 8 | CREATE TABLE `devices` ( 9 | `id` int(11) NOT NULL AUTO_INCREMENT, 10 | `user` int(11) NOT NULL, 11 | `deviceid` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 12 | `name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 13 | `data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 14 | PRIMARY KEY (`id`) USING BTREE, 15 | UNIQUE INDEX `deviceid`(`deviceid`(255), `user`) USING BTREE, 16 | INDEX `devices_FK_0_0`(`user`) USING BTREE, 17 | CONSTRAINT `devices_FK_0_0` FOREIGN KEY (`user`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 18 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; 19 | 20 | -- ---------------------------- 21 | -- Table structure for episodes 22 | -- ---------------------------- 23 | DROP TABLE IF EXISTS `episodes`; 24 | CREATE TABLE `episodes` ( 25 | `id` int(11) NOT NULL AUTO_INCREMENT, 26 | `feed` int(11) NOT NULL, 27 | `media_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 28 | `url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 29 | `image_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 30 | `duration` int(11) NULL DEFAULT NULL, 31 | `title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 32 | `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 33 | `pubdate` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 34 | PRIMARY KEY (`id`) USING BTREE, 35 | UNIQUE INDEX `episodes_unique`(`feed`, `media_url`(255)) USING BTREE, 36 | CONSTRAINT `episodes_FK_0_0` FOREIGN KEY (`feed`) REFERENCES `feeds` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 37 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; 38 | 39 | -- ---------------------------- 40 | -- Table structure for episodes_actions 41 | -- ---------------------------- 42 | DROP TABLE IF EXISTS `episodes_actions`; 43 | CREATE TABLE `episodes_actions` ( 44 | `id` int(11) NOT NULL AUTO_INCREMENT, 45 | `user` int(11) NOT NULL, 46 | `subscription` int(11) NOT NULL, 47 | `episode` int(11) NULL DEFAULT NULL, 48 | `device` int(11) NULL DEFAULT NULL, 49 | `url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 50 | `changed` int(11) NOT NULL, 51 | `action` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 52 | `data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 53 | PRIMARY KEY (`id`) USING BTREE, 54 | INDEX `episodes_actions_link`(`episode`) USING BTREE, 55 | INDEX `episodes_idx`(`user`, `action`(255), `changed`) USING BTREE, 56 | INDEX `episodes_actions_FK_0_0`(`device`) USING BTREE, 57 | INDEX `episodes_actions_FK_2_0`(`subscription`) USING BTREE, 58 | CONSTRAINT `episodes_actions_FK_0_0` FOREIGN KEY (`device`) REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE NO ACTION, 59 | CONSTRAINT `episodes_actions_FK_1_0` FOREIGN KEY (`episode`) REFERENCES `episodes` (`id`) ON DELETE SET NULL ON UPDATE NO ACTION, 60 | CONSTRAINT `episodes_actions_FK_2_0` FOREIGN KEY (`subscription`) REFERENCES `subscriptions` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION, 61 | CONSTRAINT `episodes_actions_FK_3_0` FOREIGN KEY (`user`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 62 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; 63 | 64 | -- ---------------------------- 65 | -- Table structure for feeds 66 | -- ---------------------------- 67 | DROP TABLE IF EXISTS `feeds`; 68 | CREATE TABLE `feeds` ( 69 | `id` int(11) NOT NULL AUTO_INCREMENT, 70 | `feed_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 71 | `image_url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 72 | `url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 73 | `language` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 74 | `title` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 75 | `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 76 | `pubdate` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 77 | `last_fetch` int(11) NOT NULL, 78 | PRIMARY KEY (`id`) USING BTREE, 79 | UNIQUE INDEX `feed_url`(`feed_url`(255)) USING BTREE 80 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; 81 | 82 | -- ---------------------------- 83 | -- Table structure for subscriptions 84 | -- ---------------------------- 85 | DROP TABLE IF EXISTS `subscriptions`; 86 | CREATE TABLE `subscriptions` ( 87 | `id` int(11) NOT NULL AUTO_INCREMENT, 88 | `user` int(11) NOT NULL, 89 | `feed` int(11) NULL DEFAULT NULL, 90 | `url` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 91 | `deleted` int(11) NOT NULL DEFAULT 0, 92 | `changed` int(11) NOT NULL, 93 | `data` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 94 | PRIMARY KEY (`id`) USING BTREE, 95 | UNIQUE INDEX `subscription_url`(`url`(255), `user`) USING BTREE, 96 | INDEX `subscription_feed`(`feed`) USING BTREE, 97 | INDEX `subscriptions_FK_1_0`(`user`) USING BTREE, 98 | CONSTRAINT `subscriptions_FK_0_0` FOREIGN KEY (`feed`) REFERENCES `feeds` (`id`) ON DELETE SET NULL ON UPDATE NO ACTION, 99 | CONSTRAINT `subscriptions_FK_1_0` FOREIGN KEY (`user`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE NO ACTION 100 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; 101 | 102 | -- ---------------------------- 103 | -- Table structure for users 104 | -- ---------------------------- 105 | DROP TABLE IF EXISTS `users`; 106 | CREATE TABLE `users` ( 107 | `id` int(11) NOT NULL AUTO_INCREMENT, 108 | `name` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 109 | `password` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 110 | `admin` tinyint(1) NOT NULL DEFAULT 0, 111 | `email` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 112 | `language` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 113 | `timezone` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, 114 | `password_reset_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL, 115 | `password_reset_token_expires_at` int(11) NULL DEFAULT NULL, 116 | PRIMARY KEY (`id`) USING BTREE, 117 | UNIQUE INDEX `users_name`(`name`(255)) USING BTREE 118 | ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic; 119 | 120 | SET FOREIGN_KEY_CHECKS = 1; 121 | -------------------------------------------------------------------------------- /app/index.php: -------------------------------------------------------------------------------- 1 | 36 |

Internal error

'; 37 | 38 | error_log((string) $e); 39 | 40 | if (defined('DEBUG') && DEBUG) { 41 | echo $e; 42 | 43 | global $backtrace; 44 | $backtrace ??= debug_backtrace(); 45 | 46 | error_log(print_r($backtrace, true)); 47 | 48 | echo '
'; 49 | print_r($backtrace); 50 | } 51 | else { ?> 52 |
53 | 54 | '; 57 | exit; 58 | }); 59 | 60 | $db = new DB(DB_HOST, DB_NAME, DB_USER, DB_PASS); 61 | $api = new API($db); 62 | 63 | try { 64 | if ($api->handleRequest()) { 65 | return; 66 | } 67 | } catch (JsonException $e) { 68 | return; 69 | } 70 | 71 | $gpodder = new GPodder($db); 72 | 73 | 74 | function isAdmin(): bool { 75 | global $gpodder; 76 | return $gpodder->user && $gpodder->user->admin === 1; 77 | } 78 | 79 | if($gpodder->isLogged()) { 80 | date_default_timezone_set($gpodder->user->timezone); 81 | } else { 82 | date_default_timezone_set('UTC'); 83 | } 84 | 85 | function format_description(?string $str): string { 86 | if ($str === null) { 87 | return ''; 88 | } 89 | $str = str_replace('

', "\n\n", $str); 90 | $str = preg_replace_callback('!]*href=(".*?"|\'.*?\'|\S+)[^>]*>(.*?)!i', function ($match) { 91 | $url = trim($match[1], '"\''); 92 | if ($url === $match[2]) { 93 | return $match[1]; 94 | } 95 | else { 96 | return '[' . $match[2] . '](' . $url . ')'; 97 | } 98 | }, $str); 99 | $str = htmlspecialchars(strip_tags($str)); 100 | $str = preg_replace("!(?:\r?\n){3,}!", "\n\n", $str); 101 | $str = preg_replace('!\[([^\]]+)\]\(([^\)]+)\)!', '$1', $str); 102 | $str = preg_replace(';(?$0', $str); 103 | $str = nl2br($str); 104 | return $str; 105 | } 106 | 107 | if ($api->url === 'logout') { 108 | $gpodder->logout(); 109 | header('Location: ./'); 110 | exit; 111 | } 112 | elseif ($gpodder->user && $api->url === 'admin' && isAdmin()) { 113 | html_head('Administração', $gpodder->isLogged()); 114 | 115 | // Handle delete user action 116 | if (isset($_POST['delete_user'])) { 117 | $user_id = (int)$_POST['delete_user']; 118 | $db->simple('DELETE FROM users WHERE id = ?', $user_id); 119 | echo ''; 120 | } 121 | 122 | // Handle new user registration from admin 123 | if (isset($_POST['new_username'], $_POST['new_password'], $_POST['new_email'])) { 124 | if ($error = $gpodder->subscribe($_POST['new_username'], $_POST['new_password'], $_POST['new_email'])) { 125 | printf('', htmlspecialchars($error)); 126 | } else { 127 | echo ''; 128 | } 129 | } 130 | 131 | require_once __DIR__ . '/templates/admin.php'; 132 | 133 | html_foot(); 134 | } 135 | elseif ($gpodder->user && $api->url === 'dashboard/subscriptions') { 136 | html_head('Inscrições', $gpodder->isLogged()); 137 | 138 | require_once __DIR__ . '/templates/dashboard/subscriptions.php'; 139 | 140 | html_foot(); 141 | } 142 | elseif ($gpodder->user && $api->url === 'dashboard/profile') { 143 | html_head('Painel', $gpodder->isLogged()); 144 | 145 | if (isset($_GET['oktoken'])) { 146 | echo ''; 147 | } 148 | 149 | require_once __DIR__ . '/templates/dashboard/profile.php'; 150 | 151 | html_foot(); 152 | } 153 | elseif ($gpodder->user && $api->url === 'dashboard') { 154 | html_head('Painel', $gpodder->isLogged()); 155 | 156 | if (isset($_GET['oktoken'])) { 157 | echo ''; 158 | } 159 | 160 | require_once __DIR__ . '/templates/dashboard.php'; 161 | 162 | html_foot(); 163 | } 164 | elseif ($gpodder->user) { 165 | // Redirect to dashboard if user is logged in 166 | header('Location: /dashboard'); 167 | exit; 168 | } 169 | elseif ($api->url === 'login') { 170 | $error = $gpodder->login(); 171 | 172 | if ($gpodder->isLogged()) { 173 | $token = isset($_GET['token']) ? '?oktoken' : ''; 174 | header('Location: ./' . $token); 175 | exit; 176 | } 177 | 178 | html_head('Entrar'); 179 | 180 | require_once __DIR__ . '/templates/login.php'; 181 | 182 | html_foot(); 183 | } 184 | elseif ($api->url === 'register' && !$gpodder->canSubscribe()) { 185 | html_head('Registrar'); 186 | echo ''; 187 | html_foot(); 188 | } 189 | elseif ($api->url === 'register') { 190 | html_head('Registrar'); 191 | 192 | require_once __DIR__ . '/templates/register.php'; 193 | 194 | html_foot(); 195 | } 196 | elseif ($api->url === 'forget-password') { 197 | html_head('Recuperar Senha'); 198 | require_once __DIR__ . '/templates/forget-password.php'; 199 | html_foot(); 200 | } 201 | elseif ($api->url === 'forget-password/reset') { 202 | html_head('Recuperar Senha'); 203 | require_once __DIR__ . '/templates/forget-password/reset.php'; 204 | html_foot(); 205 | } 206 | else { 207 | html_head(); 208 | require_once __DIR__ . '/templates/index.php'; 209 | html_foot(); 210 | } 211 | -------------------------------------------------------------------------------- /app/logs/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 |
17 | 18 |
19 |
    20 | all('SELECT id, name, email FROM users ORDER BY id DESC'); 22 | foreach ($users as $user) { 23 | ?> 24 |
  • 25 |

    name); ?>

    26 | email; ?> 27 |
    28 | 29 | 32 |
    33 |
  • 34 | 37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 | 54 |
55 |
56 | 59 |
60 |
61 |
62 |
63 | 64 |
-------------------------------------------------------------------------------- /app/templates/dashboard.php: -------------------------------------------------------------------------------- 1 |
2 |

, user->name; ?>!

3 | 7 |
8 | 9 | 21 |
22 |
23 | listActiveSubscriptions(); 26 | $actions = []; 27 | 28 | foreach ($subscriptions as $sub) { 29 | $feed_actions = $gpodder->listActions($sub->id); 30 | $actions = array_merge($actions, $feed_actions); 31 | } 32 | 33 | usort($actions, function ($a, $b) { 34 | return $b->changed - $a->changed; 35 | }); 36 | 37 | $actions = array_slice($actions, 0, 10); 38 | 39 | if (!empty($actions)) { ?> 40 |
    41 | url), '?'); 44 | strtok(''); 45 | $title = $row->title ?? $url; 46 | $image_url = !empty($row->image_url) ? '
    ' : ''; 47 | 48 | if ($row->action == 'play') { 49 | $action = '
    ' . __('actions.played') . '
    '; 50 | } else if ($row->action == 'download') { 51 | $action = '
    ' . __('actions.downloaded') . '
    '; 52 | } else if ($row->action == 'delete') { 53 | $action = '
    ' . __('actions.deleted') . '
    '; 54 | } else { 55 | $action = '
    ' . __('actions.unavailable') . '
    '; 56 | } 57 | 58 | $device_name = $row->device_name ? '
    ' . $row->device_name . '
    ' : '
    ' . __('devices.unavailable') . '
    '; 59 | $duration = gmdate("H:i:s", $row->duration); 60 | 61 | ?> 62 |
  • 63 |
    64 | 65 |
    66 |
    67 | 68 |
    69 |
    70 | :
    71 | 72 |
    73 |
    74 |
  • 75 | 76 | 79 |
80 | 81 |
82 |
83 | all('SELECT * FROM devices WHERE user = ? ORDER BY name', $gpodder->user->id); 86 | 87 | if (!empty($devices)) { ?> 88 |
89 | data, true); 92 | if ($data['type'] == 'mobile') { 93 | $device_type = 'bi-phone'; 94 | } else { 95 | $device_type = 'bi-window'; 96 | } 97 | ?> 98 |
99 |
100 | 101 |
102 | name); ?> 103 |
104 |
105 |
106 | 108 |
109 | 110 |
111 |
-------------------------------------------------------------------------------- /app/templates/dashboard/profile.php: -------------------------------------------------------------------------------- 1 |

2 | 3 | updateLanguage($_POST['language']); 6 | if ($result === null) { ?> 7 | 8 | 9 | 10 | updateTimezone($_POST['timezone']); 16 | if ($result === null) { ?> 17 | 18 | 19 | 20 | 25 | 26 | changePassword($_POST['current_password'], $_POST['new_password']); 29 | if ($result === null) { ?> 30 | 31 | 32 | 33 | user->timezone; 38 | ?> 39 | 40 | 57 |
58 |
59 |
60 |
61 | 62 | 71 |
72 | 75 |
76 |
77 | 78 |
79 |
80 |
81 | 82 | ['id'=>'timezone', 'required'=>'required', 'placeholder'=> 'Timezone', 'class' => 'form-control']]); 84 | ?> 85 |
86 | 89 |
90 |
91 | 92 |
93 |
94 |
95 | 96 | 97 |
98 |
99 | 100 | 101 |
102 |
103 | 104 | 105 |
106 | 109 |
110 |
111 |
112 | -------------------------------------------------------------------------------- /app/templates/dashboard/subscriptions.php: -------------------------------------------------------------------------------- 1 |

2 | 3 | 5 |

6 | updateAllFeeds(); 8 | exit; 9 | } 10 | elseif (isset($_GET['id'])) { 11 | ?> 12 |

13 | 14 |

15 | getFeedForSubscription((int)$_GET['id']); 17 | if (isset($feed->url, $feed->title, $feed->image_url, $feed->description)) { 18 | ?> 19 |
20 |
21 | 22 |
23 |
24 |

25 | title); ?> 26 | 27 |

28 |

description); ?>

29 |
30 |
31 | 34 | 37 | 38 | 40 |
    41 | listActions((int)$_GET['id']) as $row) { 43 | $url = strtok(basename($row->url), '?'); 44 | strtok(''); 45 | $title = $row->title ?? $url; 46 | $image_url = !empty($row->image_url) ? '
    ' : '' ; 47 | 48 | if($row->action == 'play') { 49 | $action = '
    '.__('actions.played').'
    '; 50 | } else if($row->action == 'download') { 51 | $action = '
    '.__('actions.downloaded').'
    '; 52 | } else if($row->action == 'delete') { 53 | $action = '
    '.__('actions.deleted').'
    '; 54 | } else { 55 | $action = '
    '.__('actions.unavailable').'
    '; 56 | } 57 | 58 | $device_name = $row->device_name ? '
    '.$row->device_name.'
    ' : '
    Indisponivel
    '; 59 | $duration = gmdate("H:i:s", $row->duration); 60 | ?> 61 | 62 |
  • 63 |
    64 | 65 |
    66 |
    67 | 68 |
    69 |
    70 | :
    71 | 72 |
    73 |
    74 |
  • 75 | 78 |
79 | 81 |
82 |
83 | 84 | Feed OPML 85 | 86 | 87 | 88 |
89 |
90 | 91 |
92 | 93 |
94 | 95 | '; 98 | 99 | foreach ($gpodder->listActiveSubscriptions() as $row) { 100 | $image_url = !empty($row->image_url) ? '
' : '' ; 101 | $title = $row->title ?? str_replace(['http://', 'https://'], '', $row->url); 102 | ?> 103 |
  • 104 |
    105 | 106 |
    107 |

    108 | description); ?> 109 | : 110 |
    111 |
    112 |
  • 113 | '; 117 | } -------------------------------------------------------------------------------- /app/templates/footer.php: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | getUserByEmail($email); 8 | if ($user) { 9 | $resetToken = bin2hex(random_bytes(32)); 10 | $gpodder->updatePasswordResetToken($user->id, $resetToken, time() + 1800); 11 | 12 | $mail = new PHPMailer(true); 13 | $mail->isSMTP(); 14 | $mail->Host = SMTP_HOST; 15 | $mail->SMTPAuth = SMTP_AUTH; 16 | $mail->Username = SMTP_USER; 17 | $mail->Password = SMTP_PASS; 18 | $mail->SMTPSecure = SMTP_SECURE; 19 | $mail->Port = SMTP_PORT; 20 | $mail->setFrom(SMTP_FROM, TITLE); 21 | $mail->addAddress($email); 22 | $mail->Subject = TITLE . ' | Password Reset'; 23 | $mail->Body = 'Please click the following link to reset your password: '.BASE_URL.'forget-password/reset?token=' . $resetToken; 24 | $mail->send(); 25 | ?> 26 | 29 | 31 | 34 | 37 | 38 |
    39 |
    40 |
    41 |
    42 |
    43 |

    44 |
    45 | 46 | 47 |
    48 |
    49 | 52 |
    53 |
    54 |
    55 |
    56 |
    57 |
    58 | -------------------------------------------------------------------------------- /app/templates/forget-password/reset.php: -------------------------------------------------------------------------------- 1 | A nova senha é muito curta (mínimo 8 caracteres)'; 5 | } else { 6 | $token = $_GET['token']; 7 | $newPassword = $_POST['new_password']; 8 | 9 | $user = $gpodder->getUserByPasswordResetToken($token); 10 | 11 | if ($user) { 12 | $gpodder->changePassword($user->id, $newPassword); 13 | $gpodder->updatePasswordResetToken($user->id, null, null); 14 | 15 | echo ''; 18 | echo 'Go to Login'; 19 | } else { 20 | echo ''; 23 | } 24 | } 25 | } else { 26 | ?> 27 |
    28 |
    29 |
    30 |
    31 |
    32 |

    33 |
    34 | 35 | 36 |
    37 |
    38 | 41 |
    42 |
    43 |
    44 |
    45 |
    46 |
    47 | 48 | -------------------------------------------------------------------------------- /app/templates/header.php: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | <?php echo htmlspecialchars($title); ?> 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
    31 |
    32 |
    33 | 34 | 35 | 36 | 37 | 44 | 45 |
    46 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 56 |
    57 |
    58 |
    59 | 60 |
    61 |
    62 | 2 |

    3 | 7 | 8 |

    9 |
      10 |
    • 11 | AntennaPod 3.5.0 - Android 12 |
      13 |
    • 14 |
    • 15 | Cardo 1.90 - Windows/MacOS/Linux 16 |
    • 17 |
    • 18 | Kasts 21.88 - Windows/Android/Linux 19 |
    • 20 |
    • 21 | gPodder 3.11.4 - Windows/macOS/Linux/BSD 22 |
    • 23 |
    24 |
    -------------------------------------------------------------------------------- /app/templates/login.php: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 8 | 10 |
    11 |
    12 |
    13 |
    14 |
    15 |

    16 |
    17 | 18 | 19 |
    20 |
    21 | 22 | 23 |
    24 |
    25 | 28 |
    29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    -------------------------------------------------------------------------------- /app/templates/register.php: -------------------------------------------------------------------------------- 1 | A nova senha é muito curta (mínimo 8 caracteres)
    '; 5 | } else { 6 | if (!$gpodder->checkCaptcha($_POST['captcha'] ?? '', $_POST['cc'] ?? '')) { 7 | echo ''; 8 | } else { 9 | $email = $_POST['email'] ?? ''; 10 | $existingUser = $db->firstRow('SELECT * FROM users WHERE email = ?', $email); 11 | if ($existingUser) { ?> 12 | 13 | subscribe($_POST['username'] ?? '', $_POST['password'] ?? '', $email)) { ?> 14 | 15 | 16 | 17 |

    18 | 23 | 24 |
    25 |
    26 |
    27 |
    28 |
    29 |

    30 |
    31 | 32 | 33 |
    34 |
    35 | 36 | 37 |
    38 |
    39 | 40 | 41 |
    42 |
    43 | 44 |
    45 | generateCaptcha() ?> 46 |
    47 | 48 |
    49 |
    50 | 53 |
    54 |
    55 |
    56 |
    57 |
    58 |
    -------------------------------------------------------------------------------- /assets/antennapod_350.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/sintoniza/022d8b7bb24737b3fa72b9bf025d61d6a0ca4086/assets/antennapod_350.gif -------------------------------------------------------------------------------- /assets/antennapod_350.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manualdousuario/sintoniza/022d8b7bb24737b3fa72b9bf025d61d6a0ca4086/assets/antennapod_350.mp4 -------------------------------------------------------------------------------- /default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | root /app; 6 | index index.php index.html index.htm; 7 | 8 | server_name _; 9 | 10 | location / { 11 | try_files $uri $uri/ /index.php$is_args$args; 12 | } 13 | 14 | location ~ \.php$ { 15 | include snippets/fastcgi-php.conf; 16 | fastcgi_pass 127.0.0.1:9000; 17 | } 18 | 19 | location ~ /\.ht { 20 | deny all; 21 | } 22 | 23 | location /logs { 24 | deny all; 25 | } 26 | 27 | location /cache { 28 | deny all; 29 | } 30 | 31 | location /cli { 32 | deny all; 33 | } 34 | 35 | location = /.env { 36 | deny all; 37 | return 404; 38 | } 39 | 40 | location /favicon.ico { 41 | alias /app/assets/favicon/favicon.ico; 42 | } 43 | 44 | access_log /dev/null; 45 | } 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | sintoniza: 3 | container_name: sintoniza 4 | image: ghcr.io/manualdousuario/sintoniza:latest 5 | ports: 6 | - "80:80" 7 | environment: 8 | DB_HOST: db 9 | DB_USER: database_user 10 | DB_PASS: database_password 11 | DB_NAME: database_name 12 | BASE_URL: https://sintoniza.xyz/ 13 | TITLE: Sintoniza 14 | ADMIN_PASSWORD: p@ssw0rd 15 | DEBUG: true 16 | ENABLE_SUBSCRIPTIONS: true 17 | DISABLE_USER_METADATA_UPDATE: false 18 | SMTP_USER: email@email.com 19 | SMTP_PASS: password 20 | SMTP_HOST: smtp.email.com 21 | SMTP_FROM: email@email.com 22 | SMTP_NAME: "Sintoniza" 23 | SMTP_PORT: 587 24 | SMTP_SECURE: tls 25 | SMTP_AUTH: true 26 | depends_on: 27 | - db 28 | db: 29 | image: mariadb:10.11 30 | container_name: db 31 | environment: 32 | MYSQL_ROOT_PASSWORD: root_password 33 | MYSQL_DATABASE: database_name 34 | MYSQL_USER: database_user 35 | MYSQL_PASSWORD: database_password 36 | ports: 37 | - 3306:3306 38 | volumes: 39 | - ./mariadb/data:/var/lib/mysql 40 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ########################################### 4 | # Sintoniza Docker Entrypoint 5 | # 6 | # Este script inicializa o container do Sintoniza: 7 | # - Valida e configura variáveis de ambiente 8 | # - Configura SMTP e outras configurações opcionais 9 | # - Inicia serviços (Cron, PHP-FPM e Nginx) 10 | ########################################### 11 | 12 | # Cores para output 13 | RED='\033[0;31m' 14 | GREEN='\033[0;32m' 15 | YELLOW='\033[1;33m' 16 | NC='\033[0m' # No Color 17 | 18 | # Função para logs de sucesso 19 | log_success() { 20 | echo -e "${GREEN}[✓] $1${NC}" 21 | } 22 | 23 | # Função para logs de erro 24 | log_error() { 25 | echo -e "${RED}[✗] $1${NC}" 26 | exit 1 27 | } 28 | 29 | # Função para logs de informação 30 | log_info() { 31 | echo -e "${YELLOW}[i] $1${NC}" 32 | } 33 | 34 | echo -e "\n${YELLOW}=== Iniciando Container Sintoniza ===${NC}\n" 35 | 36 | # === Validação de Variáveis de Ambiente === 37 | log_info "Validando variáveis de ambiente..." 38 | 39 | check_env_var() { 40 | var_name=$1 41 | if [ -z "${!var_name}" ]; then 42 | log_error "A variável de ambiente $var_name não está definida ou está vazia." 43 | fi 44 | } 45 | 46 | REQUIRED_ENV_VARS=( 47 | "DB_HOST" 48 | "DB_USER" 49 | "DB_PASS" 50 | "DB_NAME" 51 | "BASE_URL" 52 | "TITLE" 53 | "SMTP_USER" 54 | "SMTP_PASS" 55 | "SMTP_HOST" 56 | "SMTP_FROM" 57 | "SMTP_NAME" 58 | ) 59 | 60 | for var in "${REQUIRED_ENV_VARS[@]}"; do 61 | check_env_var "$var" 62 | done 63 | 64 | log_success "Todas as variáveis de ambiente obrigatórias estão definidas" 65 | 66 | # === Configuração de Variáveis de Ambiente === 67 | log_info "Configurando arquivo de variáveis de ambiente..." 68 | 69 | # Variáveis obrigatórias 70 | echo "DB_HOST=${DB_HOST}" >> /app/.env 71 | echo "DB_USER=${DB_USER}" >> /app/.env 72 | echo "DB_PASS=${DB_PASS}" >> /app/.env 73 | echo "DB_NAME=${DB_NAME}" >> /app/.env 74 | echo "BASE_URL=${BASE_URL}" >> /app/.env 75 | echo "TITLE=${TITLE}" >> /app/.env 76 | 77 | # Configurações SMTP 78 | echo "SMTP_USER=${SMTP_USER}" >> /app/.env 79 | echo "SMTP_PASS=${SMTP_PASS}" >> /app/.env 80 | echo "SMTP_HOST=${SMTP_HOST}" >> /app/.env 81 | echo "SMTP_FROM=${SMTP_FROM}" >> /app/.env 82 | echo "SMTP_NAME=${SMTP_NAME}" >> /app/.env 83 | 84 | # Variáveis opcionais 85 | if [ -n "${DEBUG}" ]; then 86 | echo "DEBUG=${DEBUG}" >> /app/.env 87 | fi 88 | 89 | if [ -n "${ENABLE_SUBSCRIPTIONS}" ]; then 90 | echo "ENABLE_SUBSCRIPTIONS=${ENABLE_SUBSCRIPTIONS}" >> /app/.env 91 | fi 92 | 93 | if [ -n "${DISABLE_USER_METADATA_UPDATE}" ]; then 94 | echo "DISABLE_USER_METADATA_UPDATE=${DISABLE_USER_METADATA_UPDATE}" >> /app/.env 95 | fi 96 | 97 | log_success "Variáveis de ambiente configuradas" 98 | 99 | # === Inicialização dos Serviços === 100 | echo -e "\n${YELLOW}=== Iniciando serviços ===${NC}\n" 101 | 102 | # Iniciando Cron 103 | log_info "Iniciando serviço Cron..." 104 | service cron restart 105 | log_success "Serviço Cron iniciado" 106 | 107 | # Funções de verificação de serviços 108 | check_nginx() { 109 | if ! pgrep nginx > /dev/null; then 110 | log_error "Falha ao iniciar Nginx" 111 | else 112 | log_success "Nginx iniciado com sucesso" 113 | fi 114 | } 115 | 116 | check_php_fpm() { 117 | if ! pgrep php-fpm > /dev/null; then 118 | log_error "Falha ao iniciar PHP-FPM" 119 | else 120 | log_success "PHP-FPM iniciado com sucesso" 121 | fi 122 | } 123 | 124 | # Diretório PHP-FPM 125 | if [ ! -d /var/run/php ]; then 126 | log_info "Criando diretório PHP-FPM..." 127 | mkdir -p /var/run/php 128 | chown -R www-data:www-data /var/run/php 129 | log_success "Diretório PHP-FPM criado" 130 | fi 131 | 132 | # Iniciando PHP-FPM 133 | log_info "Iniciando PHP-FPM..." 134 | php-fpm & 135 | sleep 3 136 | check_php_fpm 137 | 138 | # Verificando configuração Nginx 139 | log_info "Verificando configuração do Nginx..." 140 | nginx -t 141 | if [ $? -ne 0 ]; then 142 | log_error "Configuração do Nginx inválida" 143 | else 144 | log_success "Configuração do Nginx válida" 145 | fi 146 | 147 | # Iniciando Nginx 148 | log_info "Iniciando Nginx..." 149 | nginx -g "daemon off;" & 150 | sleep 3 151 | check_nginx 152 | 153 | echo -e "\n${GREEN}=== Container Sintoniza inicializado ===${NC}\n" 154 | 155 | wait -n 156 | 157 | exit $? 158 | --------------------------------------------------------------------------------