]> git.gir.st - subscriptionfeed.git/blob - app/frontend.py
use same flash message format as flask-login
[subscriptionfeed.git] / app / frontend.py
1 import re
2 import time
3 import hmac
4 import base64
5 import hashlib
6 import sqlite3
7 import secrets
8 import requests
9 from urllib.parse import parse_qs
10 from werkzeug.security import generate_password_hash, check_password_hash
11 from flask import Flask, render_template, request, redirect, flash, url_for, jsonify, g
12 from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user, login_required
13
14 from common import *
15
16 app = Flask(__name__)
17 if 'secret_key' in cf['frontend']:
18 app.secret_key = base64.b64decode(cf['frontend']['secret_key'])
19 else:
20 app.secret_key = secrets.token_bytes(16)
21 login = LoginManager(app)
22 login.login_view = 'login_form'
23
24 @app.route('/')
25 def index():
26 return redirect(url_for('feed'), code=302)
27
28 @app.route('/feed/subscriptions')
29 @login_required
30 def feed():
31 token = current_user.token # request.args.get('token', 'guest')
32 page = int(request.args.get('page', 0))
33 with sqlite3.connect(cf['global']['database']) as conn:
34 c = conn.cursor()
35 c.execute("""
36 SELECT videos.id, channel_id, name, title, published, flags.display
37 FROM videos
38 JOIN channels ON videos.channel_id = channels.id
39 LEFT JOIN flags ON (videos.id = flags.video_id) AND (flags.user = ?)
40 WHERE channel_id IN
41 (SELECT channel_id FROM subscriptions WHERE user = ?)
42 AND flags.display IS NOT 'hidden'
43 ORDER BY (display = 'pinned') DESC, crawled DESC
44 LIMIT 36
45 OFFSET 36*?""", (token, token, page))
46 rows = [{
47 'video_id': video_id,
48 'channel_id': channel_id,
49 'author': author,
50 'title': title,
51 'published': published,
52 'pinned': display == 'pinned',
53 } for (video_id, channel_id, author, title, published, display) in c.fetchall()]
54 return render_template('index.html.j2', rows=rows, page=page)
55
56 @app.route('/watch')
57 def watch():
58 if not 'v' in request.args:
59 return "missing video id", 400
60
61 plaintextheaders = {
62 'content-type': 'text/plain',
63 'Link': "<data:text/css,body%7Bcolor:%23eee;background:%23333%7D>; rel=stylesheet;"
64 }
65
66 video_id = request.args.get('v')
67 sts, algo = get_cipher()
68 video_url, metadata, error, errdetails = get_video_info(video_id, sts, algo)
69
70 extra = {'geolocked':'local=1', 'livestream':'raw=0'}.get(error,'')
71 invidious_url = f"https://invidio.us/watch?v={video_id}&{extra}&raw=1"
72 errdetails = {
73 'malformed': "Video ID is invalid.",
74 'geolocked': "This video is geolocked.",
75 'livestream': "Livestreams not yet supported.",
76 'exhausted': errdetails or "Couldn't extract video URLs.",
77 'player': errdetails,
78 }.get(error)
79
80 show = request.args.get("show")
81 if show == "raw":
82 if error:
83 msg = errdetails if error=='player' else f"{error.upper()}: {errdetails}"
84 return f"{msg}\n\nRedirecting to Invidious.", 502, {
85 'Refresh': f'2; URL={invidious_url}',
86 **plaintextheaders}
87 return redirect(video_url, code=307)
88 elif show == "json":
89 if error and not metadata:
90 return {'error': True, error: errdetails}, 400 # TODO: better (test _CpR4o81XQc)
91 return jsonify(metadata)
92 else:
93 if error and not metadata: # e.g. malformed, private/deleted video, ...
94 return errdetails,400 # TODO: nicer
95 return render_template('watch.html.j2',
96 video_id=video_id, video_url=video_url,
97 video_error=error, errdetails=errdetails, invidious_url=invidious_url,
98 **prepare_metadata(metadata))
99
100 @app.route('/channel/<channel_id>')
101 def channel(channel_id):
102 if not re.match(r"(UC[A-Za-z0-9_-]{22})", channel_id):
103 return "bad channel id", 400 # todo
104
105 xmlfeed = fetch_xml("channel_id", channel_id)
106 if not xmlfeed:
107 return "not found or something", 404 # XXX
108 title, author, videos = parse_xml(xmlfeed)
109 return render_template('xmlfeed.html.j2', title=author, rows=videos)
110
111 @app.route('/playlist')
112 def playlist():
113 playlist_id = request.args.get('list')
114 if not playlist_id:
115 return "bad list id", 400 # todo
116
117 xmlfeed = fetch_xml("playlist_id", playlist_id)
118 if not xmlfeed:
119 return "not found or something", 404 # XXX
120 title, author, videos = parse_xml(xmlfeed)
121 return render_template('xmlfeed.html.j2', title=f"{title} by {author}", rows=videos)
122
123 @app.route('/subscription_manager')
124 def subscription_manager():
125 token = request.args.get('token', 'guest')
126 with sqlite3.connect(cf['global']['database']) as conn:
127 #with conn.cursor() as c:
128 c = conn.cursor()
129 c.execute("""
130 SELECT subscriptions.channel_id, name,
131 (subscribed_until < datetime('now')) AS obsolete
132 FROM subscriptions
133 left JOIN channels ON channels.id = subscriptions.channel_id
134 left JOIN websub ON channels.id = websub.channel_id
135 WHERE user = ?
136 ORDER BY obsolete=0, name COLLATE NOCASE ASC""", (token,))
137 rows = [{
138 'channel_id': channel_id,
139 'author': author or channel_id,
140 'subscribed_until': subscribed_until
141 } for (channel_id, author, subscribed_until) in c.fetchall()]
142 return render_template('subscription_manager.html.j2', rows=rows)
143
144 @app.route('/feed/subscriptions', methods=['POST'])
145 def feed_post():
146 token = request.args.get('token', 'guest')
147 if token == 'guest': return "guest user is read-only", 403
148 action = next(request.form.keys(), None)
149 if action in ['pin', 'unpin', 'hide']:
150 video_id = request.form.get(action)
151 display = {
152 'pin': 'pinned',
153 'unpin': None,
154 'hide': 'hidden',
155 }[action]
156 with sqlite3.connect(cf['global']['database']) as conn:
157 #with conn.cursor() as c:
158 c = conn.cursor()
159 c.execute("""
160 INSERT OR REPLACE INTO flags (user, video_id, display)
161 VALUES (?, ?, ?)
162 """, (token, video_id, display))
163 else:
164 flash("unsupported action", "error")
165 return redirect(request.url, code=303)
166
167 @app.route('/subscription_manager', methods=['POST'])
168 def manage_subscriptions():
169 token = request.args.get('token', 'guest')
170 if token == 'guest': return "guest user is read-only", 403
171 if 'subscribe' in request.form:
172 channel_id = request.form.get("subscribe")
173 match = re.match(r"(UC[A-Za-z0-9_-]{22})", channel_id)
174 if match:
175 channel_id = match.group(1)
176 else:
177 match = re.match(r"((?:PL|LL|EC|UU|FL|UL|OL)[A-Za-z0-9_-]{10,})", channel_id)
178 if match: # NOTE: PL-playlists are 32chars, others differ in length.
179 flash("playlists not (yet?) supported.", "error")
180 return redirect(request.url, code=303) # TODO: dedup redirection
181 else:
182 flash("not a valid/subscribable URI", "error")
183 return redirect(request.url, code=303) # TODO: dedup redirection
184 with sqlite3.connect(cf['global']['database']) as conn:
185 #with conn.cursor() as c:
186 c = conn.cursor()
187 c.execute("""
188 INSERT OR IGNORE INTO subscriptions (user, channel_id)
189 VALUES (?, ?)
190 """, (token, channel_id))
191 # TODO: sql-error-handling, asynchronically calling update-subs.pl
192
193 elif 'unsubscribe' in request.form:
194 with sqlite3.connect(cf['global']['database']) as conn:
195 #with conn.cursor() as c:
196 c = conn.cursor()
197 c.execute("""
198 DELETE FROM subscriptions
199 WHERE user = ? AND channel_id = ?
200 """, (token, channel_id))
201 # TODO: sql-error-handling, report success
202
203 else:
204 flash("unsupported action", "error")
205
206 return redirect(request.url, code=303)
207
208 @app.route('/r/')
209 def reddit_index():
210 return ""
211 @app.route('/r/<subreddit>')
212 def reddit(subreddit="videos"):
213 count = int(request.args.get('count', 0))
214 before = request.args.get('before')
215 after = request.args.get('after')
216 query = '&'.join([f"{k}={v}" for k,v in [('count',count), ('before',before), ('after',after)] if v])
217 r = requests.get(f"https://old.reddit.com/r/{subreddit}.json?{query}", headers={'User-Agent':'Mozilla/5.0'})
218 if not r.ok or not 'data' in r.json():
219 return r.text+"error retrieving reddit data", 502
220
221 good = [e for e in r.json()['data']['children'] if e['data']['score'] > 1]
222 bad = [e for e in r.json()['data']['children'] if e['data']['score'] <=1]
223 videos = []
224 for entry in (good+bad):
225 e = entry['data']
226 if e['domain'] not in ['youtube.com', 'youtu.be', 'invidio.us']:
227 continue
228 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)
229 if not video_id: continue
230 videos.append({
231 'video_id': video_id,
232 'title': e['title'],
233 'url': e['permalink'],
234 'n_comments': e['num_comments'],
235 'n_karma': e['score'],
236 })
237 before = r.json()['data']['before']
238 after = r.json()['data']['after']
239 return render_template('reddit.html.j2', subreddit=subreddit, rows=videos, before=before, after=after, count=count)
240
241 @app.route('/login')
242 def login_form():
243 if current_user.is_authenticated:
244 return """
245 <body bgcolor="#333" text="#ccc" link="#aaa">
246 <form action="/logout" method=POST>
247 <input type=submit value="log out">
248 </form>
249 """
250 else:
251 return """
252 <body bgcolor="#333" text="#ccc" link="#aaa">
253 <form method=POST>
254 <label>User:<input name=user></label><br>
255 <label>Password:<input name=password></label><br>
256 <button name=action value="login">log in</button>
257 <button name=action value="register">sign up</button>
258 </form>
259 """
260 @app.route('/logout', methods=['POST'])
261 def do_logout():
262 logout_user()
263 return redirect(url_for('index'))
264
265 @app.route('/login', methods=['POST'])
266 def do_login():
267 user = User.from_name(request.form.get('user'))
268 if user and user.check_password(request.form.get('password')):
269 login_user(user, remember=request.form.get('remember',False))
270 return redirect(url_for('index'))
271 flash('wrong username and/or password', 'error')
272 return redirect(url_for('login_form'))
273
274 def get_cipher():
275 # reload cipher from database every 1 hour
276 if 'cipher' not in g or time.time() - g.get('cipher_updated', 0) > 1 * 60 * 60:
277 with sqlite3.connect(cf['global']['database']) as conn:
278 c = conn.cursor()
279 c.execute("SELECT sts, algorithm FROM cipher")
280 g.cipher = c.fetchone()
281 g.cipher_updated = time.time()
282
283 return g.cipher
284
285 class User(UserMixin):
286 def __init__(self, id, name, passwd, token):
287 self.id = id
288 self.name = name
289 self.passwd = passwd
290 self.token = token
291 def set_password(self, passwd):
292 self.passwd = generate_password_hash(passwd)
293 # ^TODO: store changes to database
294 def check_password(self, passwd):
295 return check_password_hash(self.passwd, passwd)
296 @classmethod
297 def from_id(self, id):
298 with sqlite3.connect(cf['global']['database']) as conn:
299 c = conn.cursor()
300 c.execute("SELECT name,password,token FROM users WHERE id = ?", (id,))
301 name, passwd, token = c.fetchone()
302 return User(id, name, passwd, token)
303 # ^TODO: not found
304 @classmethod
305 def from_name(self, name):
306 with sqlite3.connect(cf['global']['database']) as conn:
307 c = conn.cursor()
308 c.execute("SELECT id,password,token FROM users WHERE name=?", (name,))
309 id, passwd, token = c.fetchone()
310 return User(id, name, passwd, token)
311 # ^TODO: not found
312
313 @login.user_loader
314 def load_user(id):
315 return User.from_id(int(id))
316
317 #@app.teardown_appcontext
318 #def teardown_db():
319 # db = g.pop('db', None)
320 #
321 # if db is not None:
322 # db.close()
323
324 # Magic CSRF protection: This modifies outgoing HTML responses and injects a csrf token into all forms.
325 # All post requests are then checked if they contain the valid token.
326 # TODO:
327 # - don't use regex for injecting
328 # - inject a http header into all responses (that could be used by apis)
329 # - allow csrf token to be passed in http header, json, ...
330 # - a decorator on routes to opt out of verification or output munging
331 @app.after_request
332 def add_csrf_protection(response):
333 if response.mimetype == "text/html":
334 token = hmac.new(app.secret_key, request.remote_addr.encode('ascii'), hashlib.sha256).hexdigest() # TODO: will fail behind reverse proxy (remote_addr always localhost)
335 response.set_data( re.sub(
336 rb'''(<[Ff][Oo][Rr][Mm](\s+[a-zA-Z0-9-]+(=(\w*|'[^']*'|"[^"]*"))?)*>)''', # match form tags with any number of attributes and any type of quotes
337 rb'\1<input type="hidden" name="csrf" value="'+token.encode('ascii')+rb'">', # hackily append a hidden input with our csrf protection value
338 response.get_data()))
339 return response
340 @app.before_request
341 def verify_csrf_protection():
342 token = hmac.new(app.secret_key, request.remote_addr.encode('ascii'), hashlib.sha256).hexdigest() # TODO: will fail behind reverse proxy (remote_addr always localhost)
343 if request.method == "POST" and request.form.get('csrf') != token:
344 return "CSRF validation failed!", 400
345 request.form = request.form.copy() # make it mutable
346 request.form.poplist('csrf') # remove our csrf again
347
348 @app.template_filter('format_date')
349 def format_date(s):
350 (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'
351 M = '_ Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split()
352 return f"{d} {M[m]}"
353
354 if __name__ == '__main__':
355 app.run(debug=True)
Imprint / Impressum