├── .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 | [](https://github.com/manualdousuario/sintoniza/blob/master/README.md)
4 | [](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 | 
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 | [](https://github.com/manualdousuario/sintoniza/blob/master/README.md)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------
/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 |