ShioriFeed v2

This commit is contained in:
octospacc 2023-02-13 13:58:37 +01:00
parent 8a2c2ae67d
commit 5b9caf9417
2 changed files with 165 additions and 94 deletions

0
InvidiousFeedProxy.py Normal file → Executable file
View File

259
ShioriFeed.py Normal file → Executable file
View File

@ -1,25 +1,46 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# *----------------------------------------------------------------------* #
# | [ ShioriFeed 🔖 ] | #
# | Simple service for getting an Atom/RSS feed from your Shiori profile | #
# | v. 2023-02-13-r2, OctoSpacc | #
# *----------------------------------------------------------------------* #
# *---------------------------------* #
# | Configuration | #
# *---------------------------------* #
Host = ('localhost', 8176)
Debug = True
# *---------------------------------* #
# External Requirements: urllib3
# *-------------------------------------------------------------------------* #
import traceback import traceback
import json import json
from base64 import urlsafe_b64decode, urlsafe_b64encode from base64 import urlsafe_b64decode, urlsafe_b64encode
from html import escape as HTMLEscape from html import escape as HtmlEscape
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn from socketserver import ThreadingMixIn
from urllib.request import urlopen, Request from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
import threading import threading
# Requirements: urllib3
# Usage: http[s]://<This Server>/http[s]://<Shiori Server>/<Shiori Username (in base64url)>/<Shiori Password (in base64url)> # Usage: http[s]://<This Server>/http[s]://<Shiori Server>/<Shiori Username (in base64url)>/<Shiori Password (in base64url)>
Host = ('localhost', 8176)
HomeTemplate = ''' HomeTemplate = '''\
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>ShioriFeed</title> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>ShioriFeed 🔖</title>
<meta name="description" content="Simple service for getting an Atom/RSS feed from your Shiori profile"/>
<meta property="og:title" content="ShioriFeed 🔖"/>
<meta property="og:description" content="Simple service for getting an Atom/RSS feed from your Shiori profile"/>
<style> <style>
* { box-sizing: border-box; }
body { body {
margin: 0px; margin: 0px;
padding-top: 24px; padding-top: 24px;
@ -27,7 +48,7 @@ HomeTemplate = '''
padding-left: 10%; padding-left: 10%;
padding-right: 10%; padding-right: 10%;
font-family: sans-serif; font-family: sans-serif;
ford-break: break-word; word-break: break-word;
user-select: none; user-select: none;
-ms-user-select: none; -ms-user-select: none;
-moz-user-select: none; -moz-user-select: none;
@ -35,73 +56,71 @@ HomeTemplate = '''
-webkit-user-select: none; -webkit-user-select: none;
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
details { form > label { padding: 8px; }
background: lightgray; form > label > span { padding-bottom: 4px; }
padding: 8px; form > label, form > label > span {
}
label > span {
display: inline-block; display: inline-block;
padding-bottom: 4px; width: 100%;
} }
input { input {
width: 100%; width: 100%;
height: 2em; height: 2em;
} }
input[type="submit"] { input[type="submit"] { font-size: large; }
width: calc(100% + 8px);
font-size: large;
}
textarea { textarea {
width: 100%; width: 100%;
height: 5em; height: 5em;
font-size: large;
resize: none; resize: none;
} }
details {
background: lightgray;
padding: 8px;
}
details > summary > h4 { display: inline; }
/* {{PostCss}} */
</style> </style>
</head> </head>
<body> <body>
<h2>ShioriFeed</h2> <h2>ShioriFeed 🔖</h2>
<p> <p class="PostObscure">
Enter details about your account on a Enter the details of your account on a
<a href="https://github.com/go-shiori/">Shiori</a> <a href="https://github.com/go-shiori/">Shiori</a>
server to get an RSS feed link. server to get an Aotm/RSS feed link.
</p> </p>
<br /> <br />
{{PostResult}} <!-- {{PostResult}} -->
<p> <p class="PostObscure">
<form action="/" method="POST"> <form action="./" method="POST">
<label> <label class="PostObscure">
<span> <span>Server <small>(must start with protocol prefix)</small>:</span>
Server <small>(must start with protocol prefix)</small>: <input type="text" name="Remote" placeholder="http[s]://..."/>
</span> </label>
<br /> <br />
<input type="text" name="Remote" placeholder="http[s]://..."/> <label class="PostObscure">
</label> <span>Username:</span>
<br /> <input type="text" name="Username" placeholder="erre"/>
<label> </label>
<span>
Username:
</span>
<br /> <br />
<input type="text" name="Username" placeholder="erre"/> <label class="PostObscure">
</label> <span>Password:</span>
<br /> <input type="password" name="Password" placeholder="**********"/>
<label> </label>
<span>
Password:
</span>
<br /> <br />
<input type="password" name="Password" placeholder="**********"/> <label class="PostObscure">
</label> <span>&nbsp;</span>
<br /> <input type="submit" value="Submit"/>
<input type="submit" value="Submit"/> </label>
</form> </form>
</p> </p>
<br /> <br />
<!--
<p> <p>
<details> <details>
<summary> <summary>
Privacy Policy <h4>Privacy Policy</h4>
</summary> </summary>
<p>
<ul> <ul>
<li> <li>
@ -109,23 +128,39 @@ HomeTemplate = '''
</ul> </ul>
</details> </details>
</p> </p>
-->
<p> <p>
<span>v. 2023-02-13</span>
<a href="https://gitlab.com/octospacc/Snippets/-/blob/main/ShioriFeed.py">Source Code</a> <a href="https://gitlab.com/octospacc/Snippets/-/blob/main/ShioriFeed.py">Source Code</a>
</p> </p>
<script>
var Box = document.querySelector('textarea');
if (Box) {
Box.value = location.origin + Box.value.substring('http[s]://<THIS SHIORIFEED SERVER ADDRESS>'.length);
};
Box.onclick = function() {
try {
navigator.clipboard.writeText(Box.value);
alert('Copied to clipboard!');
} catch(e) {};
};
</script>
</body> </body>
</html> </html>
''' '''
def SessionHash(Remote, Username, Password):
return f'{hash(Remote)}{hash(Username)}{hash(Password)}'
def MkFeed(Data, Remote, Username): def MkFeed(Data, Remote, Username):
Feed = '' Feed = ''
if not Data['bookmarks']: Date = Data['bookmarks'][0]['modified'] if Data['bookmarks'] else ''
return '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"></rss>'
Date = Data['bookmarks'][0]['modified']
for Mark in Data['bookmarks']: for Mark in Data['bookmarks']:
Feed += f''' Feed += f'''
<item> <item>
<title>{HTMLEscape(Mark['title'])}</title> <title>{HtmlEscape(Mark['title'])}</title>
<description>{HTMLEscape(Mark['excerpt'])}</description> <description>{HtmlEscape(Mark['excerpt'])}</description>
<link>{Remote}/bookmark/{Mark['id']}/content</link> <link>{Remote}/bookmark/{Mark['id']}/content</link>
<pubDate>{Mark['modified']}</pubDate> <pubDate>{Mark['modified']}</pubDate>
<guid isPermaLink="false">{Mark['id']}</guid> <guid isPermaLink="false">{Mark['id']}</guid>
@ -135,7 +170,7 @@ def MkFeed(Data, Remote, Username):
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
<channel> <channel>
<title>ShioriFeed ({HTMLEscape(Username)})</title> <title>ShioriFeed ({HtmlEscape(Username)}) 🔖</title>
<pubDate>{Date}</pubDate> <pubDate>{Date}</pubDate>
<lastBuildDate>{Date}</lastBuildDate> <lastBuildDate>{Date}</lastBuildDate>
{Feed} {Feed}
@ -143,82 +178,118 @@ def MkFeed(Data, Remote, Username):
</rss> </rss>
''' '''
def MkUrl(Post):
Args = {}
#Args = Post.split('&')
for Arg in Post.split('&'):
Arg = Arg.split('=')
Args.update({Arg[0]: Arg[1]})
return f'''\
http[s]://<THIS SHIORIFEED SERVER ADDRESS>\
/{Args['Remote']}\
/{urlsafe_b64encode(Args['Username'].encode()).decode()}\
/{urlsafe_b64encode(Args['Password'].encode()).decode()}/'''
def GetSession(Remote, Username, Password): def GetSession(Remote, Username, Password):
try: try:
Rq = urlopen(Request(f'{Remote}/api/login', Rq = urlopen(Request(f'{Remote}/api/login',
data=json.dumps({'username': Username, 'password': Password, 'remember': True, 'owner': True}).encode(), data=json.dumps({'username': Username, 'password': Password, 'remember': True, 'owner': True}).encode(),
headers={'User-Agent': f'ShioriFeed at {Host[0]}'})) headers={'User-Agent': f'ShioriFeed at {Host[0]}'}))
if Rq.code == 200: if Rq.code == 200:
Data = {f'{Remote}/{Username}/{Password}': json.loads(Rq.read().decode())['session']} Data = {SessionHash(Remote, Username, Password): json.loads(Rq.read().decode())['session']}
Sessions.update(Data) Sessions.update(Data)
return Data return {
except Exception as Ex: #(HTTPError, URLError) as Ex: 'Code': 200,
print(traceback.format_exc()) 'Body': Data}
return False else:
return {
'Code': Rq.code,
'Body': f'[{Rq.code}] External Server Error\n\n{Rq.read().decode()}'}
except Exception: #as Ex: #(HTTPError, URLError) as Ex:
#print(traceback.format_exc())
return {
'Code': 500,
'Body': '[500] Internal Server Error' + (f'\n\n{traceback.format_exc()}' if Debug else '')}
def RqHandle(Path, Attempt=0): def RqHandle(Path, Attempt=0):
Rs = {}
try: try:
RqItems = Path.strip().removeprefix('/').removesuffix('/').strip().split('/') Rs = {}
if RqItems[0] == '': Args = Path.strip().removeprefix('/').removesuffix('/').strip().split('/')
Rs['Code'] = 200 if Args[0] == '':
Rs['Body'] = HomeTemplate.replace('{{PostResult}}', '') return {
Rs['Content-Type'] = 'text/html' 'Code': 200,
'Body': HomeTemplate,
'Content-Type': 'text/html'}
else: else:
Remote = '/'.join(RqItems[:-2]) Remote = '/'.join(Args[:-2])
Username = urlsafe_b64decode(RqItems[-2]).decode() Username = urlsafe_b64decode(Args[-2]).decode()
Password = urlsafe_b64decode(RqItems[-1]).decode() Password = urlsafe_b64decode(Args[-1]).decode()
if not f'{Remote}/{Username}/{Password}' in Sessions: if not SessionHash(Remote, Username, Password) in Sessions:
GetSession(Remote, Username, Password) TrySession = GetSession(Remote, Username, Password)
if TrySession['Code'] != 200:
return TrySession
Rq = urlopen(Request(f'{Remote}/api/bookmarks', headers={ Rq = urlopen(Request(f'{Remote}/api/bookmarks', headers={
'X-Session-Id': Sessions[f'{Remote}/{Username}/{Password}'], 'X-Session-Id': Sessions[SessionHash(Remote, Username, Password)],
'User-Agent': f'ShioriFeed at {Host[0]}'})) 'User-Agent': f'ShioriFeed at {Host[0]}'}))
Rs['Code'] = Rq.code Rs['Code'] = Rq.code
# Shiori got us JSON data, parse it and return our result
if Rq.code == 200: if Rq.code == 200:
Rs['Body'] = MkFeed(json.loads(Rq.read().decode()), Remote, Username) Rs['Body'] = MkFeed(json.loads(Rq.read().decode()), Remote, Username)
Rs['Content-Type'] = 'application/xml' Rs['Content-Type'] = 'application/xml'
# We probably got an expired Session-Id, let's try to renew it
elif Rq.code == 500 and Attempt < 1: elif Rq.code == 500 and Attempt < 1:
GetSession(Remote, Username, Password) TrySession = GetSession(Remote, Username, Password)
if TrySession['Code'] != 200:
return TrySession
return ReqHandle(Path, Attempt+1) return ReqHandle(Path, Attempt+1)
else: else:
Rs['Body'] = f'[{Rq.code}] External Server Error\n\n{Rq.read().decode()}' Rs['Body'] = f'[{Rq.code}] External Server Error\n\n{Rq.read().decode()}'
Rs['Content-Type'] = 'text/plain' return Rs
except Exception as Ex: #(HTTPError, URLError) as Ex: except Exception: #as Ex: #(HTTPError, URLError) as Ex:
print(traceback.format_exc()) #print(traceback.format_exc())
Rs['Code'] = 500 #Rs['Code'] = 500
#Rs['Body'] = f'[500] Internal Server Error\n\n{traceback.format_exc()}' #Rs['Body'] = f'[500] Internal Server Error\n\n{traceback.format_exc()}'
Rs['Body'] = f'[500] Internal Server Error' #Rs['Body'] = f'[500] Internal Server Error'
Rs['Content-Type'] = 'text/plain' #Rs['Content-Type'] = 'text/plain'
return Rs return {
'Code': 500,
'Body': '[500] Internal Server Error' + (f'\n\n{traceback.format_exc()}' if Debug else '')}
# https://stackoverflow.com/a/51559006
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
Rs = RqHandle(self.path) Rs = RqHandle(self.path)
self.send_response(Rs['Code']) self.send_response(Rs['Code'])
self.send_header('Content-Type', Rs['Content-Type']) self.send_header('Content-Type', Rs['Content-Type'] if 'Content-Type' in Rs else 'text/plain')
self.end_headers() self.end_headers()
self.wfile.write(Rs['Body'].encode()) self.wfile.write(Rs['Body'].encode())
def do_POST(self): def do_POST(self):
if self.path == '/': try:
Params = self.rfile.read(int(self.headers['Content-Length'])) if self.path == '/':
self.send_response(200) Body = HomeTemplate.replace('<!-- {{PostResult}} -->', f'''
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(HomeTemplate.replace('{{PostResult}}', f'''
<p> <p>
Here's your RSS feed: Here's your Atom feed:
<textarea readonly="true"> </textarea> <textarea readonly="true">{MkUrl(self.rfile.read(int(self.headers['Content-Length'])).decode())}</textarea>
</p> </p>
<br /> <br />
''').encode()) ''').replace('/* {{PostCss}} */', '.PostObscure { opacity: 0.5; }')
else: self.send_response(200)
self.send_response(400) self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(Body.encode())
else:
self.send_response(400)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(b'[400] Bad Request')
except Exception:
self.send_response(500)
self.send_header('Content-Type', 'text/plain') self.send_header('Content-Type', 'text/plain')
self.end_headers() self.end_headers()
self.wfile.write(b'[400] Bad Request') self.wfile.write(('[500] Internal Server Error' + (f'\n\n{traceback.format_exc()}' if Debug else '')).encode())
def log_request(self, code='-', size='-'): # https://stackoverflow.com/a/3389505
def log_message(self, format, *args):
return return
# https://stackoverflow.com/a/51559006
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
pass pass
def Serve(): def Serve():