diff --git a/ShioriFeed.py b/ShioriFeed.py index 75a6c08..6049b65 100755 --- a/ShioriFeed.py +++ b/ShioriFeed.py @@ -1,38 +1,41 @@ #!/usr/bin/env python3 # *----------------------------------------------------------------------* # -# | [ ShioriFeed 🔖 ] | # +# | [ ShioriFeed 🔖 (OctoSpacc) ] | # # | Simple service for getting an Atom/RSS feed from your Shiori profile | # -# | v. 2023-02-13-r3, OctoSpacc | # +# *----------------------------------------------------------------------* # +Version = '2023-02-15' # *----------------------------------------------------------------------* # -# *---------------------------------* # -# | Configuration | # -# *---------------------------------* # +# *-------------------------------------------* # +# | Configuration | # +# *-------------------------------------------* # Host = ('localhost', 8176) Debug = False -# *---------------------------------* # +UserAgent = f'ShioriFeed v{Version} at {Host[0]}' +# *-------------------------------------------* # # External Requirements: urllib3 # TODO: +# - Cheking if Content mode content is actually present, otherwise fall back to Archive mode or original link (using API data is unreliable it seems) # - Atom feed # - Actually valid RSS -# - Include content of links into XML -# - Include other XML metadata (author) +# - XML stylesheet +# - Filtering (tags, etc.) # - Write privacy policy # - Fix the URL copy thing +# - Minification # *-------------------------------------------------------------------------* # import traceback import json -from base64 import urlsafe_b64decode as b64decode, urlsafe_b64encode as b64encode +from base64 import urlsafe_b64decode as b64UrlDecode, urlsafe_b64encode as b64UrlEncode, standard_b64encode as b64Encode from html import escape as HtmlEscape from http.server import HTTPServer, BaseHTTPRequestHandler from socketserver import ThreadingMixIn from urllib.request import urlopen, Request -from urllib.error import HTTPError, URLError import threading HomeTemplate = '''\ @@ -52,17 +55,26 @@ HomeTemplate = '''\ -

ShioriFeed 🔖

-

- Enter the details of your account on a - Shiori - server to get an Atom/RSS feed link. -

-

- Note: still a work-in-progress! -

-
- -

-

- -
- -
- -
- -
-

-
+
+

ShioriFeed 🔖

+

+ Enter the details of your account on a + Shiori + server to get an Atom/RSS feed link. +

+

+ Note: still a work-in-progress! +

+
+ +

+

+ +
+ +
+ +
+ +
+

+
+

- v. 2023-02-13-r3 +

+ + +

Privacy Policy

+ (applies to ShioriFeed.Octt.eu.org) +
+ + I still have to write this... tough luck. + I'm not yet actively inviting anyone to use this instance right now, + if you're worried about your security then just host the software yourself. + +
+

+

+ v. {{Version}} Source Code

@@ -186,42 +214,50 @@ HomeTemplate = '''\ -''' +'''.replace('{{Version}}', Version) + +def RetDebugIf(): + return f'\n\n{traceback.format_exc()}' if Debug else '' def SessionHash(Remote, Username, Password): return f'{hash(Remote)}{hash(Username)}{hash(Password)}' -#def GetContent(Id, Remote, Session): -# try: -# -# except Exception: -# - -def MkFeed(Data, Remote, Username, Password, Type="RSS"): +def MkFeed(Data, Remote, Username, Session, Type='RSS'): Feed = '' Date = Data['bookmarks'][0]['modified'] if Data['bookmarks'] else '' for Mark in Data['bookmarks']: - #if Mark['hasContent']: - Link = f"{Remote}/bookmark/{Mark['id']}/content" - ImgLink = f"{Remote}/bookmark/{Mark['id']}/thumb" - Cover = f']]>' if Mark['imageURL'] else '' - #elif Mark['hasArchive']: - # Link = f"{Remote}/bookmark/{Mark['id']}/archive" - #else: - # Link = Mark['url'] - Feed += f''' + Id = Mark['id'] + Link = f'{Remote}/bookmark/{Id}/content' + # NOTE: when shiori issue #578 is fixed, this should use a thumb URL from the original article HTML to cope with private bookmarks + Cover = f']]>' if Mark['imageURL'] else '' + # Not so sure about this chief, downloading and embedding EVERY cover image into the XML is slow (~8s per 1 req) and traffic-hungry (~10 simultaneous requests are enough to temporarily DoS the Raspi) + #ImgData = GetContent(Remote, f'bookmark/{Id}/thumb', Session) if Mark['imageURL'] else None + #Cover = f'

]]>' if ImgData else '' + Content = f'{HtmlEscape(GetContent(Remote, f"bookmark/{Id}/content", Session)["Body"].decode())}' + if Type == 'Atom': + Feed += f''' + + ''' + elif Type == 'RSS': + Feed += f''' {HtmlEscape(Mark['title'])} {Cover}{HtmlEscape(Mark['excerpt'])} - + {Mark['author']} + {Content} {Link} {Mark['modified']} - {Mark['id']} + {Link} + ''' + if Type == 'Atom': + return f'''\ + ''' - return f'''\ - - + elif Type == 'RSS': + return f'''\ + + ShioriFeed ({HtmlEscape(Username)}) 🔖 {Date} @@ -229,9 +265,9 @@ def MkFeed(Data, Remote, Username, Password, Type="RSS"): {Feed} -''' + ''' -def MkUrl(Post, Type="RSS"): +def MkUrl(Post, Type='RSS'): Args = {} #Args = Post.split('&') for Arg in Post.split('&'): @@ -240,59 +276,60 @@ def MkUrl(Post, Type="RSS"): return f'''\ http[s]://\ /{Args['Remote']}\ -/{b64encode(Args['Username'].encode()).decode()}\ -/{b64encode(Args['Password'].encode()).decode()}\ +/{b64UrlEncode(Args['Username'].encode()).decode()}\ +/{b64UrlEncode(Args['Password'].encode()).decode()}\ /{Type}.xml''' def GetSession(Remote, Username, Password): try: Rq = urlopen(Request(f'{Remote}/api/login', data=json.dumps({'username': Username, 'password': Password, 'remember': True, 'owner': True}).encode(), - headers={'User-Agent': f'ShioriFeed at {Host[0]}'})) + headers={'User-Agent': UserAgent})) if Rq.code == 200: Data = {SessionHash(Remote, Username, Password): json.loads(Rq.read().decode())['session']} Sessions.update(Data) - return { - 'Code': 200, - 'Body': Data} + return {'Code': 200, 'Body': Data} 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 '')} + return {'Code': Rq.code, 'Body': f'[{Rq.code}] External Server Error\n\n{Rq.read().decode()}'} + except Exception: + return {'Code': 500, 'Body': f'[500] Internal Server Error{RetDebugIf()}'} + +def GetContent(Remote, Path, Session): + try: + Rq = urlopen(Request(f'{Remote}/{Path}', headers={'X-Session-Id': Session, 'User-Agent': UserAgent})) + if Rq.code == 200: + return {'Code': 200, 'Body': Rq.read(), 'Content-Type': Rq.headers['Content-Type']} + else: + return {'Code': Rq.code, 'Body': f'[{Rq.code}] External Server Error\n\n{Rq.read().decode()}'.encode()} + except Exception: + return {'Code': 500, 'Body': f'[500] Internal Server Error{RetDebugIf()}'.encode()} def RqHandle(Path, Attempt=0): try: Rs = {} Args = Path.strip().removeprefix('/').removesuffix('/').strip().split('/') if Args[0] == '': - return { - 'Code': 200, - 'Body': HomeTemplate, - 'Content-Type': 'text/html'} + return {'Code': 200, 'Body': HomeTemplate, 'Content-Type': 'text/html'} else: Shift = 1 if Args[-1].lower().startswith(('atom.xml', 'rss.xml')) else 0 Remote = '/'.join(Args[:-(2+Shift)]) - Username = b64decode(Args[-(2+Shift)]).decode() - Password = b64decode(Args[-(1+Shift)]).decode() + Username = b64UrlDecode(Args[-(2+Shift)]).decode() + Password = b64UrlDecode(Args[-(1+Shift)]).decode() if not SessionHash(Remote, Username, Password) in Sessions: TrySession = GetSession(Remote, Username, Password) if TrySession['Code'] != 200: return TrySession + Session = Sessions[SessionHash(Remote, Username, Password)] Rq = urlopen(Request(f'{Remote}/api/bookmarks', headers={ - 'X-Session-Id': Sessions[SessionHash(Remote, Username, Password)], - 'User-Agent': f'ShioriFeed at {Host[0]}'})) + 'X-Session-Id': Session, + 'User-Agent': UserAgent})) Rs['Code'] = Rq.code - # Shiori got us JSON data, parse it and return our result if Rq.code == 200: - Rs['Body'] = MkFeed(json.loads(Rq.read().decode()), Remote, Username, Password) + # Shiori got us JSON data, parse it and return our result + Rs['Body'] = MkFeed(json.loads(Rq.read().decode()), Remote, Username, Session) 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: + # We probably got an expired Session-Id, let's renew it and retry TrySession = GetSession(Remote, Username, Password) if TrySession['Code'] != 200: return TrySession @@ -300,15 +337,8 @@ def RqHandle(Path, Attempt=0): else: Rs['Body'] = f'[{Rq.code}] External Server Error\n\n{Rq.read().decode()}' return Rs - except Exception: #as Ex: #(HTTPError, URLError) as Ex: - #print(traceback.format_exc()) - #Rs['Code'] = 500 - #Rs['Body'] = f'[500] Internal Server Error\n\n{traceback.format_exc()}' - #Rs['Body'] = f'[500] Internal Server Error' - #Rs['Content-Type'] = 'text/plain' - return { - 'Code': 500, - 'Body': '[500] Internal Server Error' + (f'\n\n{traceback.format_exc()}' if Debug else '')} + except Exception: + return {'Code': 500, 'Body': f'[500] Internal Server Error{RetDebugIf()}'} class Handler(BaseHTTPRequestHandler): def do_GET(self): @@ -340,7 +370,7 @@ class Handler(BaseHTTPRequestHandler): self.send_response(500) self.send_header('Content-Type', 'text/plain') self.end_headers() - self.wfile.write(('[500] Internal Server Error' + (f'\n\n{traceback.format_exc()}' if Debug else '')).encode()) + self.wfile.write((f'[500] Internal Server Error{RetDebugIf()}').encode()) # https://stackoverflow.com/a/3389505 def log_message(self, format, *args): return