redturtle.rssservice 2.2.0__tar.gz → 2.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/CHANGES.rst +17 -0
- redturtle_rssservice-2.2.2/PKG-INFO +293 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/README.rst +16 -0
- redturtle_rssservice-2.2.2/requirements.txt +1 -0
- redturtle_rssservice-2.2.2/requirements_plone60.txt +1 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/setup.cfg +1 -5
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/setup.py +2 -2
- redturtle_rssservice-2.2.2/src/redturtle/rssservice/proxycacheserver/__init__.py +1 -0
- redturtle_rssservice-2.2.2/src/redturtle/rssservice/proxycacheserver/main.py +328 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/rss_mixer.py +12 -4
- redturtle_rssservice-2.2.2/src/redturtle/rssservice/tests/test_proxycache.py +91 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/tests/test_rss_mixer.py +1 -3
- redturtle_rssservice-2.2.2/src/redturtle.rssservice.egg-info/PKG-INFO +293 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle.rssservice.egg-info/SOURCES.txt +3 -0
- redturtle_rssservice-2.2.2/src/redturtle.rssservice.egg-info/entry_points.txt +5 -0
- redturtle.rssservice-2.2.0/PKG-INFO +0 -234
- redturtle.rssservice-2.2.0/requirements.txt +0 -3
- redturtle.rssservice-2.2.0/requirements_plone60.txt +0 -13
- redturtle.rssservice-2.2.0/src/redturtle.rssservice.egg-info/PKG-INFO +0 -234
- redturtle.rssservice-2.2.0/src/redturtle.rssservice.egg-info/entry_points.txt +0 -6
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/CONTRIBUTORS.rst +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/DEVELOP.rst +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/LICENSE.GPL +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/LICENSE.rst +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/MANIFEST.in +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/constraints.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/constraints_plone52.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/constraints_plone60.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/docs/conf.py +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/docs/index.rst +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/requirements_plone52.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/__init__.py +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/__init__.py +1 -1
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/configure.zcml +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/interfaces.py +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/testing.py +1 -1
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle/rssservice/tests/__init__.py +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle.rssservice.egg-info/dependency_links.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle.rssservice.egg-info/namespace_packages.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle.rssservice.egg-info/not-zip-safe +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle.rssservice.egg-info/requires.txt +0 -0
- {redturtle.rssservice-2.2.0 → redturtle_rssservice-2.2.2}/src/redturtle.rssservice.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
+
2.2.2 (2026-10-05)
|
|
5
|
+
------------------
|
|
6
|
+
|
|
7
|
+
- Fix proxycache url replace
|
|
8
|
+
[mamico]
|
|
9
|
+
- Refactor proxycache server: thread-safety, atomic writes, background thread deduplication, ThreadingHTTPServer, and anti-SSRF URL validation.
|
|
10
|
+
[mamico]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
2.2.1 (2023-07-12)
|
|
14
|
+
------------------
|
|
15
|
+
|
|
16
|
+
- Handle case when feed url is an internal url with resolveuid.
|
|
17
|
+
[cekk]
|
|
18
|
+
- Do not print exception on log, but use warning because it's handled.
|
|
19
|
+
[cekk]
|
|
20
|
+
|
|
4
21
|
2.2.0 (2023-03-21)
|
|
5
22
|
------------------
|
|
6
23
|
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redturtle.rssservice
|
|
3
|
+
Version: 2.2.2
|
|
4
|
+
Summary: An add-on for Plone
|
|
5
|
+
Home-page: https://github.com/collective/redturtle.rssservice
|
|
6
|
+
Author: RedTurtle
|
|
7
|
+
Author-email: sviluppo@redturtle.it
|
|
8
|
+
License: GPL version 2
|
|
9
|
+
Project-URL: PyPI, https://pypi.python.org/pypi/redturtle.rssservice
|
|
10
|
+
Project-URL: Source, https://github.com/collective/redturtle.rssservice
|
|
11
|
+
Project-URL: Tracker, https://github.com/collective/redturtle.rssservice/issues
|
|
12
|
+
Keywords: Python Plone
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: Plone
|
|
16
|
+
Classifier: Framework :: Plone :: Addon
|
|
17
|
+
Classifier: Framework :: Plone :: 5.2
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 2.7
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
22
|
+
Classifier: Operating System :: OS Independent
|
|
23
|
+
Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
|
|
24
|
+
Requires-Python: >3.6
|
|
25
|
+
License-File: LICENSE.GPL
|
|
26
|
+
License-File: LICENSE.rst
|
|
27
|
+
Requires-Dist: setuptools
|
|
28
|
+
Requires-Dist: z3c.jbot
|
|
29
|
+
Requires-Dist: plone.api>=1.8.4
|
|
30
|
+
Requires-Dist: plone.restapi
|
|
31
|
+
Requires-Dist: plone.app.dexterity
|
|
32
|
+
Requires-Dist: feedparser
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: plone.app.testing; extra == "test"
|
|
35
|
+
Requires-Dist: plone.testing>=5.0.0; extra == "test"
|
|
36
|
+
Requires-Dist: plone.app.contenttypes; extra == "test"
|
|
37
|
+
Requires-Dist: plone.app.robotframework[debug]; extra == "test"
|
|
38
|
+
Requires-Dist: collective.MockMailHost; extra == "test"
|
|
39
|
+
Requires-Dist: requests_mock; extra == "test"
|
|
40
|
+
Dynamic: author
|
|
41
|
+
Dynamic: author-email
|
|
42
|
+
Dynamic: classifier
|
|
43
|
+
Dynamic: description
|
|
44
|
+
Dynamic: home-page
|
|
45
|
+
Dynamic: keywords
|
|
46
|
+
Dynamic: license
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
Dynamic: project-url
|
|
49
|
+
Dynamic: provides-extra
|
|
50
|
+
Dynamic: requires-dist
|
|
51
|
+
Dynamic: requires-python
|
|
52
|
+
Dynamic: summary
|
|
53
|
+
|
|
54
|
+
.. This README is meant for consumption by humans and pypi. Pypi can render rst files so please do not use Sphinx features.
|
|
55
|
+
If you want to learn more about writing documentation, please check out: http://docs.plone.org/about/documentation_styleguide.html
|
|
56
|
+
This text does not appear on pypi or github. It is a comment.
|
|
57
|
+
|
|
58
|
+
.. image:: https://img.shields.io/pypi/v/redturtle.rssservice.svg
|
|
59
|
+
:target: https://pypi.org/project/redturtle.rssservice/
|
|
60
|
+
:alt: Latest Version
|
|
61
|
+
|
|
62
|
+
.. image:: https://img.shields.io/pypi/pyversions/redturtle.rssservice.svg?style=plastic
|
|
63
|
+
:target: https://pypi.org/project/redturtle.rssservice/
|
|
64
|
+
:alt: Supported - Python Versions
|
|
65
|
+
|
|
66
|
+
.. image:: https://img.shields.io/pypi/dm/redturtle.rssservice.svg
|
|
67
|
+
:target: https://pypi.org/project/redturtle.rssservice/
|
|
68
|
+
:alt: Number of PyPI downloads
|
|
69
|
+
|
|
70
|
+
.. image:: https://img.shields.io/pypi/l/redturtle.rssservice.svg
|
|
71
|
+
:target: https://pypi.org/project/redturtle.rssservice/
|
|
72
|
+
:alt: License
|
|
73
|
+
|
|
74
|
+
.. image:: https://github.com/RedTurtle/redturtle.rssservice/actions/workflows/test.yml/badge.svg
|
|
75
|
+
:target: https://github.com/RedTurtle/redturtle.rssservice/actions
|
|
76
|
+
:alt: Tests
|
|
77
|
+
|
|
78
|
+
.. image:: https://coveralls.io/repos/github/RedTurtle/redturtle.rssservice/badge.svg?branch=master
|
|
79
|
+
:target: https://coveralls.io/github/RedTurtle/redturtle.rssservice?branch=master
|
|
80
|
+
:alt: Coverage
|
|
81
|
+
|
|
82
|
+
=====================
|
|
83
|
+
Redturtle RSS Service
|
|
84
|
+
=====================
|
|
85
|
+
|
|
86
|
+
This package add support to retrieve RSS feeds from external sources.
|
|
87
|
+
|
|
88
|
+
Usage
|
|
89
|
+
=====
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
RSS block
|
|
93
|
+
---------
|
|
94
|
+
|
|
95
|
+
There is a service "**@rss_mixer_data**" that accept a block id, and return a list of sorted feeds by date.
|
|
96
|
+
|
|
97
|
+
This service only accept GET calls and accept following parameters:
|
|
98
|
+
|
|
99
|
+
- block: the id of the rssBlock with the feeds
|
|
100
|
+
|
|
101
|
+
The endpoint should be called on the context that has the rssBlock that you want to show.
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
For example::
|
|
105
|
+
|
|
106
|
+
> curl -i -X GET http://localhost:8080/Plone/example-page/@rss_mixer_data?block=123456789 -H 'Accept: application/json' -H 'Content-Type: application/json'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
Will reply with something like this::
|
|
110
|
+
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
"source": "Foo site",
|
|
114
|
+
"contentSnippet": "some description 2",
|
|
115
|
+
"title": "Foo News 2",
|
|
116
|
+
"date": "Thu, 1 Apr 2020 10:44:01 +0200",
|
|
117
|
+
"url": "http://test.com/foo-news-2"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"source": "",
|
|
121
|
+
"contentSnippet": "some description 2",
|
|
122
|
+
"title": "Bar News 2",
|
|
123
|
+
"date": "Thu, 1 Apr 2020 10:44:01 +0200",
|
|
124
|
+
"url": "http://test.com/bar-news-2"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"source": "Foo site",
|
|
128
|
+
"contentSnippet": "some description",
|
|
129
|
+
"title": "Foo News 1",
|
|
130
|
+
"date": "Thu, 2 Apr 2020 10:44:01 +0200",
|
|
131
|
+
"url": "http://test.com/foo-news-1"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"source": "",
|
|
135
|
+
"contentSnippet": "some description",
|
|
136
|
+
"title": "Bar News 1",
|
|
137
|
+
"date": "Thu, 2 Apr 2020 10:44:01 +0200",
|
|
138
|
+
"url": "http://test.com/bar-news-1"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
This endpoint works with `volto-rss-block <https://github.com/RedTurtle/volto-rss-block/>`_ plugin.
|
|
143
|
+
|
|
144
|
+
Retrieve timeout
|
|
145
|
+
----------------
|
|
146
|
+
|
|
147
|
+
There is a 5s default timeout for retrieve RSS feeds.
|
|
148
|
+
|
|
149
|
+
You can override it with an environment variable: **RSS_SERVICE_TIMEOUT**
|
|
150
|
+
|
|
151
|
+
Set User-Agent
|
|
152
|
+
--------------
|
|
153
|
+
|
|
154
|
+
You can override the default ``User-Agent`` for the requests to get feeds by setting an environment variable: **RSS_USER_AGENT**
|
|
155
|
+
|
|
156
|
+
Installation
|
|
157
|
+
============
|
|
158
|
+
|
|
159
|
+
Install redturtle.rssservice by adding it to your buildout::
|
|
160
|
+
|
|
161
|
+
[buildout]
|
|
162
|
+
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
eggs =
|
|
166
|
+
redturtle.rssservice
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
and then running ``bin/buildout``
|
|
170
|
+
|
|
171
|
+
Resilience
|
|
172
|
+
==========
|
|
173
|
+
|
|
174
|
+
To make the product more resilient against external feed disruptions, it is possible to use an external proxy/cache service. The service is utilized in the following format:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
http://proxyservice/http://feedurl
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The product includes a built-in proxy/cache implementation that can be used. After installing the product, you can use it by running the following process:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
bin/rssmixer-proxy --port 8000 --ttl 1200
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
And eventually set the environment variable `RSSMIXER_PROXY` to `http://127.0.0.1:8000` according to the port used for the proxy.
|
|
187
|
+
|
|
188
|
+
Contribute
|
|
189
|
+
==========
|
|
190
|
+
|
|
191
|
+
- Issue Tracker: https://github.com/RedTurtle/redturtle.rssservice/issues
|
|
192
|
+
- Source Code: https://github.com/RedTurtle/redturtle.rssservice
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
Support
|
|
196
|
+
=======
|
|
197
|
+
|
|
198
|
+
If you are having issues, please let us know.
|
|
199
|
+
We have a mailing list located at: sviluppo@redturtle.it
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
License
|
|
203
|
+
=======
|
|
204
|
+
|
|
205
|
+
The project is licensed under the GPLv2.
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
Contributors
|
|
209
|
+
============
|
|
210
|
+
|
|
211
|
+
- RedTurtle, sviluppo@redturtle.it
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
Changelog
|
|
215
|
+
=========
|
|
216
|
+
|
|
217
|
+
2.2.2 (2026-10-05)
|
|
218
|
+
------------------
|
|
219
|
+
|
|
220
|
+
- Fix proxycache url replace
|
|
221
|
+
[mamico]
|
|
222
|
+
- Refactor proxycache server: thread-safety, atomic writes, background thread deduplication, ThreadingHTTPServer, and anti-SSRF URL validation.
|
|
223
|
+
[mamico]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
2.2.1 (2023-07-12)
|
|
227
|
+
------------------
|
|
228
|
+
|
|
229
|
+
- Handle case when feed url is an internal url with resolveuid.
|
|
230
|
+
[cekk]
|
|
231
|
+
- Do not print exception on log, but use warning because it's handled.
|
|
232
|
+
[cekk]
|
|
233
|
+
|
|
234
|
+
2.2.0 (2023-03-21)
|
|
235
|
+
------------------
|
|
236
|
+
|
|
237
|
+
- Allow configuring the User-Agent for the requests to get feeds,
|
|
238
|
+
via the REQUESTS_USER_AGENT environment variable.
|
|
239
|
+
[davisagli]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
2.1.0 (2023-03-10)
|
|
243
|
+
------------------
|
|
244
|
+
|
|
245
|
+
- Customizable timeout.
|
|
246
|
+
[cekk]
|
|
247
|
+
- Return 404 if block not found instead BadRequest.
|
|
248
|
+
[cekk]
|
|
249
|
+
- Handle site root blocks in plone 6.
|
|
250
|
+
[cekk]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
2.0.0 (2022-04-07)
|
|
254
|
+
------------------
|
|
255
|
+
|
|
256
|
+
- Remove unused and unsafe endpoint.
|
|
257
|
+
[cekk]
|
|
258
|
+
- Now @rss_mixer_data accept GET calls (see README for more infos).
|
|
259
|
+
[cekk]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
1.0.3 (2022-03-22)
|
|
263
|
+
------------------
|
|
264
|
+
|
|
265
|
+
- Allow to use cateogry in rss feed.
|
|
266
|
+
[lucabel]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
1.0.2 (2022-03-04)
|
|
270
|
+
------------------
|
|
271
|
+
|
|
272
|
+
- Allow dates with wrong date format (eg. a date range)
|
|
273
|
+
[lucabel]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
1.0.1 (2021-12-02)
|
|
277
|
+
------------------
|
|
278
|
+
|
|
279
|
+
- Fix python version in setup.py
|
|
280
|
+
[cekk]
|
|
281
|
+
|
|
282
|
+
1.0.0 (2021-10-13)
|
|
283
|
+
------------------
|
|
284
|
+
|
|
285
|
+
- Add @rss_mixer_data endpoint.
|
|
286
|
+
[cekk]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
0.1.0 (2020-04-08)
|
|
290
|
+
------------------
|
|
291
|
+
|
|
292
|
+
- Initial release.
|
|
293
|
+
[cekk]
|
|
@@ -115,6 +115,22 @@ Install redturtle.rssservice by adding it to your buildout::
|
|
|
115
115
|
|
|
116
116
|
and then running ``bin/buildout``
|
|
117
117
|
|
|
118
|
+
Resilience
|
|
119
|
+
==========
|
|
120
|
+
|
|
121
|
+
To make the product more resilient against external feed disruptions, it is possible to use an external proxy/cache service. The service is utilized in the following format:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
http://proxyservice/http://feedurl
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The product includes a built-in proxy/cache implementation that can be used. After installing the product, you can use it by running the following process:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
bin/rssmixer-proxy --port 8000 --ttl 1200
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
And eventually set the environment variable `RSSMIXER_PROXY` to `http://127.0.0.1:8000` according to the port used for the proxy.
|
|
118
134
|
|
|
119
135
|
Contribute
|
|
120
136
|
==========
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
-r requirements_plone60.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
-r https://dist.plone.org/release/6.0-latest/requirements.txt
|
|
@@ -16,7 +16,7 @@ long_description = "\n\n".join(
|
|
|
16
16
|
|
|
17
17
|
setup(
|
|
18
18
|
name="redturtle.rssservice",
|
|
19
|
-
version="2.2.
|
|
19
|
+
version="2.2.2",
|
|
20
20
|
description="An add-on for Plone",
|
|
21
21
|
long_description=long_description,
|
|
22
22
|
# Get more from https://pypi.org/classifiers/
|
|
@@ -76,6 +76,6 @@ setup(
|
|
|
76
76
|
[z3c.autoinclude.plugin]
|
|
77
77
|
target = plone
|
|
78
78
|
[console_scripts]
|
|
79
|
-
|
|
79
|
+
rssmixer-proxy = redturtle.rssservice.proxycacheserver:main
|
|
80
80
|
""",
|
|
81
81
|
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .main import main # NOQA
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This code implements a caching proxy server that stores and serves web content.
|
|
3
|
+
|
|
4
|
+
Key Components:
|
|
5
|
+
* Creates unique filenames for cached content using SHA256 hashing
|
|
6
|
+
* Stores both the content and metadata (URL information) in separate files
|
|
7
|
+
* Background refresh content periodically
|
|
8
|
+
|
|
9
|
+
The Proxy Server:
|
|
10
|
+
|
|
11
|
+
* Listens for incoming requests (protected against SSRF and concurrent requests)
|
|
12
|
+
* Checks if requested content is in cache
|
|
13
|
+
* If found, serves from cache
|
|
14
|
+
* If not found, fetches it, saves it, then serves it
|
|
15
|
+
|
|
16
|
+
Background Refresh:
|
|
17
|
+
|
|
18
|
+
* Automatically updates cached content periodically
|
|
19
|
+
* Runs in separate threads to not block the main server (deduplicated per URL)
|
|
20
|
+
* Time between updates is configurable (TTL - Time To Live)
|
|
21
|
+
|
|
22
|
+
Command Line Interface: Uses Click library to accept parameters like:
|
|
23
|
+
|
|
24
|
+
Host address (default: 127.0.0.1)
|
|
25
|
+
Port number (default: 8080)
|
|
26
|
+
Cache directory location (default: ./var/cache)
|
|
27
|
+
TTL for cache refresh (default: 3600 seconds)
|
|
28
|
+
|
|
29
|
+
Usage Example:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
rssmixer-proxy --host 127.0.0.1 --port 8080 --cache-dir ./var/cache --ttl 3600
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
XXX: this is not actually a real HTTP/HTTPS proxy because needs to act as man-in-the-middle
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import requests
|
|
41
|
+
|
|
42
|
+
RSSMIXER_PROXY = "http://127.0.0.1:8080"
|
|
43
|
+
url = "https://abcnews.go.com/abcnews/usheadlines"
|
|
44
|
+
res = requests.get(f"{RSSMIXER_PROXY}/{url}")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This is particularly useful for:
|
|
48
|
+
|
|
49
|
+
* Reducing load on original servers
|
|
50
|
+
* Improving response times
|
|
51
|
+
* Working with content even when the original source is temporarily unavailable
|
|
52
|
+
* Saving bandwidth by not repeatedly downloading the same content
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import click
|
|
56
|
+
import hashlib
|
|
57
|
+
import http.server
|
|
58
|
+
import json
|
|
59
|
+
import logging
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import requests
|
|
63
|
+
import socketserver
|
|
64
|
+
import threading
|
|
65
|
+
import time
|
|
66
|
+
from urllib.parse import urlparse
|
|
67
|
+
|
|
68
|
+
LOCK = threading.Lock()
|
|
69
|
+
LAST_ACCESS_TIMES = {}
|
|
70
|
+
ACTIVE_REFRESH_THREADS = set()
|
|
71
|
+
MAX_TTL_IN_CACHE = 7 * 24 * 3600 # 1 week
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger("rssmixer-proxy")
|
|
74
|
+
logger.setLevel(logging.INFO)
|
|
75
|
+
if not logger.handlers:
|
|
76
|
+
formatter = logging.Formatter(
|
|
77
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
78
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
79
|
+
)
|
|
80
|
+
stream_handler = logging.StreamHandler()
|
|
81
|
+
stream_handler.setFormatter(formatter)
|
|
82
|
+
logger.addHandler(stream_handler)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cache_path(url, cache_dir):
|
|
86
|
+
hash_url = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
|
87
|
+
return os.path.join(cache_dir, f"{hash_url}.json")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def safe_atomic_write_json(file_path, data):
|
|
91
|
+
tmp_path = f"{file_path}.tmp.{threading.get_ident()}_{time.time_ns()}"
|
|
92
|
+
try:
|
|
93
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
94
|
+
json.dump(data, f, indent=2)
|
|
95
|
+
os.replace(tmp_path, file_path)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("Error writing atomically to %s: %s", file_path, e)
|
|
98
|
+
if os.path.exists(tmp_path):
|
|
99
|
+
try:
|
|
100
|
+
os.remove(tmp_path)
|
|
101
|
+
except OSError:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_json(cache_file):
|
|
106
|
+
try:
|
|
107
|
+
if os.path.exists(cache_file):
|
|
108
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
109
|
+
return json.load(f)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
logger.warning("Error reading cache file %s: %s", cache_file, e)
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_valid_url(url):
|
|
116
|
+
"""Validate URL format and prevent basic SSRF targets."""
|
|
117
|
+
if not re.match(r"^https?:\/\/", url):
|
|
118
|
+
return False
|
|
119
|
+
parsed = urlparse(url)
|
|
120
|
+
hostname = parsed.hostname
|
|
121
|
+
if not hostname:
|
|
122
|
+
return False
|
|
123
|
+
forbidden_hosts = {"localhost", "127.0.0.1", "0.0.0.0", "169.254.169.254", "::1"}
|
|
124
|
+
if hostname.lower() in forbidden_hosts:
|
|
125
|
+
return False
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def fetch_and_cache(url, cache_dir, client_headers=None, timeout=(3, 10)):
|
|
130
|
+
cache_file = cache_path(url, cache_dir)
|
|
131
|
+
headers = {}
|
|
132
|
+
|
|
133
|
+
if client_headers is None:
|
|
134
|
+
data = load_json(cache_file)
|
|
135
|
+
headers = data.get("request_headers", {})
|
|
136
|
+
else:
|
|
137
|
+
headers = dict(client_headers)
|
|
138
|
+
|
|
139
|
+
headers.setdefault("User-Agent", "RSSMixerProxy/1.0")
|
|
140
|
+
headers.pop("Host", None)
|
|
141
|
+
|
|
142
|
+
if not is_valid_url(url):
|
|
143
|
+
logger.error("Invalid or restricted URL path: %s", url)
|
|
144
|
+
return {
|
|
145
|
+
"url": url,
|
|
146
|
+
"request_headers": headers,
|
|
147
|
+
"response_headers": {},
|
|
148
|
+
"status_code": 400,
|
|
149
|
+
"body": f"Invalid or restricted URL: {url}",
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
response = requests.get(url, headers=headers, timeout=timeout)
|
|
154
|
+
cache_content = {
|
|
155
|
+
"url": url,
|
|
156
|
+
"request_headers": headers,
|
|
157
|
+
"response_headers": dict(response.headers),
|
|
158
|
+
"status_code": response.status_code,
|
|
159
|
+
"body": response.text,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if response.status_code == 200:
|
|
163
|
+
safe_atomic_write_json(cache_file, cache_content)
|
|
164
|
+
logger.info("Cached %s: %s in %s", response.status_code, url, cache_dir)
|
|
165
|
+
else:
|
|
166
|
+
logger.error("Failed to fetch %s: status %s", url, response.status_code)
|
|
167
|
+
if not os.path.exists(cache_file):
|
|
168
|
+
safe_atomic_write_json(cache_file, cache_content)
|
|
169
|
+
logger.info(
|
|
170
|
+
"Cached error %s: %s in %s", response.status_code, url, cache_dir
|
|
171
|
+
)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
logger.error("Error fetching %s: %s", url, e)
|
|
174
|
+
cache_content = {
|
|
175
|
+
"url": url,
|
|
176
|
+
"request_headers": headers,
|
|
177
|
+
"response_headers": {},
|
|
178
|
+
"status_code": 502,
|
|
179
|
+
"body": str(e),
|
|
180
|
+
}
|
|
181
|
+
# Do not persist transient connection errors permanently to disk
|
|
182
|
+
return cache_content
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def refresh_cache(url, cache_dir, ttl):
|
|
186
|
+
logger.info("Refresh cache for %s every %s seconds", url, ttl)
|
|
187
|
+
try:
|
|
188
|
+
while True:
|
|
189
|
+
time.sleep(ttl)
|
|
190
|
+
with LOCK:
|
|
191
|
+
last_access = LAST_ACCESS_TIMES.get(url, time.time())
|
|
192
|
+
if last_access + MAX_TTL_IN_CACHE < time.time():
|
|
193
|
+
cache_file = cache_path(url, cache_dir)
|
|
194
|
+
if os.path.exists(cache_file):
|
|
195
|
+
try:
|
|
196
|
+
os.remove(cache_file)
|
|
197
|
+
except OSError:
|
|
198
|
+
pass
|
|
199
|
+
LAST_ACCESS_TIMES.pop(url, None)
|
|
200
|
+
logger.warning("Remove %s from cached files due to inactivity", url)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
logger.info("Refresh cache for %s", url)
|
|
204
|
+
fetch_and_cache(url, cache_dir)
|
|
205
|
+
finally:
|
|
206
|
+
with LOCK:
|
|
207
|
+
ACTIVE_REFRESH_THREADS.discard(url)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def ensure_refresh_thread(url, cache_dir, ttl):
|
|
211
|
+
"""Ensure at most one background refresh thread runs per URL."""
|
|
212
|
+
with LOCK:
|
|
213
|
+
if url not in ACTIVE_REFRESH_THREADS:
|
|
214
|
+
ACTIVE_REFRESH_THREADS.add(url)
|
|
215
|
+
threading.Thread(
|
|
216
|
+
target=refresh_cache, args=(url, cache_dir, ttl), daemon=True
|
|
217
|
+
).start()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def load_urls_from_cache(cache_dir):
|
|
221
|
+
urls = []
|
|
222
|
+
if not os.path.exists(cache_dir):
|
|
223
|
+
return urls
|
|
224
|
+
for file in os.listdir(cache_dir):
|
|
225
|
+
if file.endswith(".json"):
|
|
226
|
+
hash_file = os.path.join(cache_dir, file)
|
|
227
|
+
data = load_json(hash_file)
|
|
228
|
+
url = data.get("url", "")
|
|
229
|
+
if url:
|
|
230
|
+
logger.info("Load: %s from cache %s", url, hash_file)
|
|
231
|
+
urls.append(url)
|
|
232
|
+
return urls
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
236
|
+
daemon_threads = True
|
|
237
|
+
allow_reuse_address = True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class CachingProxyHandler(http.server.BaseHTTPRequestHandler):
|
|
241
|
+
def __init__(self, *args, cache_dir=None, ttl=None, **kwargs):
|
|
242
|
+
self.cache_dir = cache_dir
|
|
243
|
+
self.ttl = ttl
|
|
244
|
+
super().__init__(*args, **kwargs)
|
|
245
|
+
|
|
246
|
+
def do_GET(self):
|
|
247
|
+
url = self.path.lstrip("/").replace("\n", "").replace("\r", "")
|
|
248
|
+
with LOCK:
|
|
249
|
+
LAST_ACCESS_TIMES[url] = time.time()
|
|
250
|
+
|
|
251
|
+
cache_file = cache_path(url, self.cache_dir)
|
|
252
|
+
cache_content = load_json(cache_file)
|
|
253
|
+
|
|
254
|
+
if cache_content:
|
|
255
|
+
logger.info("Serving from cache: %s", url)
|
|
256
|
+
else:
|
|
257
|
+
logger.info("Fetching and caching: %s", url)
|
|
258
|
+
client_headers = dict(self.headers)
|
|
259
|
+
cache_content = fetch_and_cache(url, self.cache_dir, client_headers)
|
|
260
|
+
ensure_refresh_thread(url, self.cache_dir, self.ttl)
|
|
261
|
+
|
|
262
|
+
body_str = cache_content.get("body", "")
|
|
263
|
+
body_bytes = body_str.encode("utf-8")
|
|
264
|
+
status_code = cache_content.get("status_code", 500)
|
|
265
|
+
|
|
266
|
+
self.send_response(status_code)
|
|
267
|
+
response_headers = cache_content.get("response_headers", {})
|
|
268
|
+
for header, value in response_headers.items():
|
|
269
|
+
header_lower = header.lower()
|
|
270
|
+
if header_lower in ("set-cookie", "content-length"):
|
|
271
|
+
continue
|
|
272
|
+
if header_lower in (
|
|
273
|
+
"content-type",
|
|
274
|
+
"cache-control",
|
|
275
|
+
"etag",
|
|
276
|
+
"last-modified",
|
|
277
|
+
):
|
|
278
|
+
self.send_header(header, value)
|
|
279
|
+
|
|
280
|
+
self.send_header("Content-Length", str(len(body_bytes)))
|
|
281
|
+
self.end_headers()
|
|
282
|
+
self.wfile.write(body_bytes)
|
|
283
|
+
|
|
284
|
+
def log_message(self, format, *args):
|
|
285
|
+
logger.debug(
|
|
286
|
+
"%s - - [%s] %s",
|
|
287
|
+
self.address_string(),
|
|
288
|
+
self.log_date_time_string(),
|
|
289
|
+
format % args,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def start_server(host, port, cache_dir, ttl):
|
|
294
|
+
def handler(*args, **kwargs):
|
|
295
|
+
return CachingProxyHandler(*args, cache_dir=cache_dir, ttl=ttl, **kwargs)
|
|
296
|
+
|
|
297
|
+
with ThreadingHTTPServer((host, port), handler) as httpd:
|
|
298
|
+
try:
|
|
299
|
+
logger.info("Serving on http://%s:%s", host, port)
|
|
300
|
+
httpd.serve_forever()
|
|
301
|
+
finally:
|
|
302
|
+
logger.info("Closing connection")
|
|
303
|
+
httpd.shutdown()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@click.command()
|
|
307
|
+
@click.option("--host", default="127.0.0.1", help="Ip address to run the server on.")
|
|
308
|
+
@click.option("--port", default=8080, help="Port to run the server on.")
|
|
309
|
+
@click.option(
|
|
310
|
+
"--cache-dir", default="./var/cache", help="Directory to store cached files."
|
|
311
|
+
)
|
|
312
|
+
@click.option("--ttl", default=3600, help="TTL for cache refresh in seconds.")
|
|
313
|
+
def main(host, port, cache_dir, ttl):
|
|
314
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
315
|
+
cached_urls = load_urls_from_cache(cache_dir)
|
|
316
|
+
try:
|
|
317
|
+
for url in cached_urls:
|
|
318
|
+
ensure_refresh_thread(url, cache_dir, ttl)
|
|
319
|
+
|
|
320
|
+
start_server(host, port, cache_dir, ttl)
|
|
321
|
+
except KeyboardInterrupt:
|
|
322
|
+
logger.info("Server stopped.")
|
|
323
|
+
finally:
|
|
324
|
+
logger.info("Closing connection")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|