]> git.gir.st - subscriptionfeed.git/blob - app/frontend.py
cleanup a lot, support CREATOR_MERCHANDISE endcard (as website)
[subscriptionfeed.git] / app / frontend.py
1 import re
2 import time
3 import hmac
4 import hashlib
5 import sqlite3
6 import secrets
7 import requests
8 from urllib.parse import parse_qs
9 from flask import Flask, render_template, request, redirect, flash, url_for, jsonify, g
10
11 from common import *
12
13 app = Flask(__name__)
14 app.secret_key = secrets.token_bytes(16) # XXX: generate and hard-code, or cookies and csrf-validation will fail!
15
16 @app.route('/')
17 def index():
18 return redirect(url_for('feed'), code=302)
19
20 @app.route('/feed/subscriptions')
21 def feed():
22 token = request.args.get('token', 'guest')
23 page = int(request.args.get('page', 0))
24 with sqlite3.connect(cf['global']['database']) as conn:
25 c = conn.cursor()
26 c.execute("""
27 SELECT videos.id, channel_id, name, title, published, flags.display
28 FROM videos
29 JOIN channels ON videos.channel_id = channels.id
30 LEFT JOIN flags ON (videos.id = flags.video_id) AND (flags.user = ?)
31 WHERE channel_id IN
32 (SELECT channel_id FROM subscriptions WHERE user = ?)
33 AND flags.display IS NOT 'hidden'
34 ORDER BY (display = 'pinned') DESC, crawled DESC
35 LIMIT 36
36 OFFSET 36*?""", (token, token, page))
37 rows = [{
38 'video_id': video_id,
39 'channel_id': channel_id,
40 'author': author,
41 'title': title,
42 'published': published,
43 'pinned': display == 'pinned',
44 } for (video_id, channel_id, author, title, published, display) in c.fetchall()]
45 return render_template('index.html.j2', rows=rows, page=page)
46
47 @app.route('/watch')
48 def watch():
49 if not 'v' in request.args:
50 return "missing video id", 400
51
52 plaintextheader = {'content-type': 'text/plain',"Link": "<data:text/css,body%7Bcolor:%23eee;background:%23333%7D>; rel=stylesheet;"}
53
54 video_id = request.args.get('v')
55 (sts, algo) = get_cipher()
56 (video_url, metadata, error_type, error) = get_video_info(video_id, sts, algo)
57 if error_type in ['initial', 'player']:
58 return error, 400, plaintextheader
59
60 show = request.args.get("show")
61 if show == "raw":
62 if error:
63 extra = {'geolocked':'local=1', 'livestream':'raw=0'}.get(error,'')
64 # if error==exhausted, metadata.playabilityStatus.reason may contain additional information.
65 return f"{error.upper()}: Redirecting to Invidious.", 502, {'Refresh': f'2; URL=https://invidio.us/watch?v={video_id}&{extra}&raw=1', **plaintextheader}
66 return redirect(video_url, code=307)
67 elif show == "json":
68 return jsonify(metadata)
69 else: # todo: handle geolocked, livesteam and the case when we have an exhausted error with no metadata returned
70 if error:
71 err_desc = {'geolocked': "this video is geolocked", 'livestream': "livestreams not yet supported", 'exhausted': "couldn't extract video urls"}.get(error,'')
72 flash(("error",f"{err_desc}. Watch on <a href='https://invidio.us/watch?v={video_id}'>Invidious</a> or <a href='https://www.youtube.com/watch?v={video_id}'>Youtube</a>")) # todo: cleanup
73 return render_template('watch.html.j2', video_id=video_id, video_url=video_url, **prepare_metadata(metadata))
74
75 @app.route('/channel/<channel_id>')
76 def channel(channel_id):
77 if not re.match(r"(UC[A-Za-z0-9_-]{22})", channel_id):
78 return "bad channel id", 400 # todo
79
80 xmlfeed = fetch_xml("channel_id", channel_id)
81 if not xmlfeed:
82 return "not found or something", 404 # XXX
83 title, author, videos = parse_xml(xmlfeed)
84 return render_template('xmlfeed.html.j2', title=author, rows=videos)
85
86 @app.route('/playlist')
87 def playlist():
88 playlist_id = request.args.get('list')
89 if not playlist_id:
90 return "bad list id", 400 # todo
91
92 xmlfeed = fetch_xml("playlist_id", playlist_id)
93 if not xmlfeed:
94 return "not found or something", 404 # XXX
95 title, author, videos = parse_xml(xmlfeed)
96 return render_template('xmlfeed.html.j2', title=f"{title} by {author}", rows=videos)
97
98 @app.route('/subscription_manager')
99 def subscription_manager():
100 token = request.args.get('token', 'guest')
101 with sqlite3.connect(cf['global']['database']) as conn:
102 #with conn.cursor() as c:
103 c = conn.cursor()
104 c.execute("""
105 SELECT subscriptions.channel_id, name,
106 (subscribed_until < datetime('now')) AS obsolete
107 FROM subscriptions
108 left JOIN channels ON channels.id = subscriptions.channel_id
109 left JOIN websub ON channels.id = websub.channel_id
110 WHERE user = ?
111 ORDER BY obsolete=0, name COLLATE NOCASE ASC""", (token,))
112 rows = [{
113 'channel_id': channel_id,
114 'author': author or channel_id,
115 'subscribed_until': subscribed_until
116 } for (channel_id, author, subscribed_until) in c.fetchall()]
117 return render_template('subscription_manager.html.j2', rows=rows)
118
119 @app.route('/feed/subscriptions', methods=['POST'])
120 def feed_post():
121 token = request.args.get('token', 'guest')
122 if token == 'guest': return "guest user is read-only", 403
123 action = next(request.form.keys(), None)
124 if action in ['pin', 'unpin', 'hide']:
125 video_id = request.form.get(action)
126 display = {
127 'pin': 'pinned',
128 'unpin': None,
129 'hide': 'hidden',
130 }[action]
131 with sqlite3.connect(cf['global']['database']) as conn:
132 #with conn.cursor() as c:
133 c = conn.cursor()
134 c.execute("""
135 INSERT OR REPLACE INTO flags (user, video_id, display)
136 VALUES (?, ?, ?)
137 """, (token, video_id, display))
138 else:
139 flash(("error","unsupported action"))
140 return redirect(request.url, code=303)
141
142 @app.route('/subscription_manager', methods=['POST'])
143 def manage_subscriptions():
144 token = request.args.get('token', 'guest')
145 if token == 'guest': return "guest user is read-only", 403
146 if 'subscribe' in request.form:
147 channel_id = request.form.get("subscribe")
148 match = re.match(r"(UC[A-Za-z0-9_-]{22})", channel_id)
149 if match:
150 channel_id = match.group(1)
151 else:
152 match = re.match(r"((?:PL|LL|EC|UU|FL|UL|OL)[A-Za-z0-9_-]{10,})", channel_id)
153 if match: # NOTE: PL-playlists are 32chars, others differ in length.
154 flash(("error","playlists not (yet?) supported."))
155 return redirect(request.url, code=303) # TODO: dedup redirection
156 else:
157 flash(("error","not a valid/subscribable URI"))
158 return redirect(request.url, code=303) # TODO: dedup redirection
159 with sqlite3.connect(cf['global']['database']) as conn:
160 #with conn.cursor() as c:
161 c = conn.cursor()
162 c.execute("""
163 INSERT OR IGNORE INTO subscriptions (user, channel_id)
164 VALUES (?, ?)
165 """, (token, channel_id))
166 # TODO: sql-error-handling, asynchronically calling update-subs.pl
167
168 elif 'unsubscribe' in request.form:
169 with sqlite3.connect(cf['global']['database']) as conn:
170 #with conn.cursor() as c:
171 c = conn.cursor()
172 c.execute("""
173 DELETE FROM subscriptions
174 WHERE user = ? AND channel_id = ?
175 """, (token, channel_id))
176 # TODO: sql-error-handling, report success
177
178 else:
179 flash(("error","unsupported action"))
180
181 return redirect(request.url, code=303)
182
183 @app.route('/r/')
184 def reddit_index():
185 return ""
186 @app.route('/r/<subreddit>')
187 def reddit(subreddit="videos"):
188 count = int(request.args.get('count', 0))
189 before = request.args.get('before')
190 after = request.args.get('after')
191 query = '&'.join([f"{k}={v}" for k,v in [('count',count), ('before',before), ('after',after)] if v])
192 r = requests.get(f"https://old.reddit.com/r/{subreddit}.json?{query}", headers={'User-Agent':'Mozilla/5.0'})
193 if not r.ok or not 'data' in r.json():
194 return r.text+"error retrieving reddit data", 502
195
196 good = [e for e in r.json()['data']['children'] if e['data']['score'] > 1]
197 bad = [e for e in r.json()['data']['children'] if e['data']['score'] <=1]
198 videos = []
199 for entry in (good+bad):
200 e = entry['data']
201 if e['domain'] not in ['youtube.com', 'youtu.be', 'invidio.us']:
202 continue
203 video_id = re.match(r'^https?://(?:www.|m.)?(?:youtube.com/watch\?(?:.*&amp;)?v=|youtu.be/|youtube.com/embed/)([-_0-9A-Za-z]+)', e['url']).group(1)
204 if not video_id: continue
205 videos.append({
206 'video_id': video_id,
207 'title': e['title'],
208 'url': e['permalink'],
209 'n_comments': e['num_comments'],
210 'n_karma': e['score'],
211 })
212 before = r.json()['data']['before']
213 after = r.json()['data']['after']
214 return render_template('reddit.html.j2', subreddit=subreddit, rows=videos, before=before, after=after, count=count)
215
216 def get_cipher():
217 # reload cipher from database every 1 hour
218 if 'cipher' not in g or time.time() - g.get('cipher_updated', 0) > 1 * 60 * 60:
219 with sqlite3.connect(cf['global']['database']) as conn:
220 c = conn.cursor()
221 c.execute("SELECT sts, algorithm FROM cipher")
222 g.cipher = c.fetchone()
223 g.cipher_updated = time.time()
224
225 return g.cipher
226
227 #@app.teardown_appcontext
228 #def teardown_db():
229 # db = g.pop('db', None)
230 #
231 # if db is not None:
232 # db.close()
233
234 # Magic CSRF protection: This modifies outgoing HTML responses and injects a csrf token into all forms.
235 # All post requests are then checked if they contain the valid token.
236 # TODO:
237 # - don't use regex for injecting
238 # - inject a http header into all responses (that could be used by apis)
239 # - allow csrf token to be passed in http header, json, ...
240 # - a decorator on routes to opt out of verification or output munging
241 @app.after_request
242 def add_csrf_protection(response):
243 if response.mimetype == "text/html":
244 token = hmac.new(app.secret_key, request.remote_addr.encode('ascii'), hashlib.sha256).hexdigest() # TODO: will fail behind reverse proxy (remote_addr always localhost)
245 response.set_data( re.sub(
246 rb'''(<[Ff][Oo][Rr][Mm](\s+[a-zA-Z0-9-]+(=(\w*|'[^']*'|"[^"]*"))?)*>)''', # match form tags with any number of attributes and any type of quotes
247 rb'\1<input type="hidden" name="csrf" value="'+token.encode('ascii')+rb'">', # hackily append a hidden input with our csrf protection value
248 response.get_data()))
249 return response
250 @app.before_request
251 def verify_csrf_protection():
252 token = hmac.new(app.secret_key, request.remote_addr.encode('ascii'), hashlib.sha256).hexdigest() # TODO: will fail behind reverse proxy (remote_addr always localhost)
253 if request.method == "POST" and request.form.get('csrf') != token:
254 return "CSRF validation failed!", 400
255 request.form = request.form.copy() # make it mutable
256 request.form.poplist('csrf') # remove our csrf again
257
258 @app.template_filter('format_date')
259 def format_date(s):
260 (y,m,d) = (int(n) for n in s.split('T')[0].split(' ')[0].split('-')) # iso-dates can seperate date from time with space or 'T'
261 M = '_ Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split()
262 return f"{d} {M[m]}"
263
264 if __name__ == '__main__':
265 app.run(debug=True)
Imprint / Impressum