]>
git.gir.st - subscriptionfeed.git/blob - app/frontend.py
8 from urllib
. parse
import parse_qs
9 from flask
import Flask
, render_template
, request
, redirect
, flash
, url_for
, jsonify
, g
14 app
. secret_key
= secrets
. token_bytes ( 16 ) # XXX: generate and hard-code, or cookies and csrf-validation will fail!
18 return redirect ( url_for ( 'feed' ), code
= 302 )
20 @app . route ( '/feed/subscriptions' )
22 token
= request
. args
. get ( 'token' , 'guest' )
23 page
= int ( request
. args
. get ( 'page' , 0 ))
24 with sqlite3
. connect ( cf
[ 'global' ][ 'database' ]) as conn
:
27 SELECT videos.id, channel_id, name, title, published, flags.display
29 JOIN channels ON videos.channel_id = channels.id
30 LEFT JOIN flags ON (videos.id = flags.video_id) AND (flags.user = ?)
32 (SELECT channel_id FROM subscriptions WHERE user = ?)
33 AND flags.display IS NOT 'hidden'
34 ORDER BY (display = 'pinned') DESC, crawled DESC
36 OFFSET 36*?""" , ( token
, token
, page
))
39 'channel_id' : channel_id
,
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
)
49 if not 'v' in request
. args
:
50 return "missing video id" , 400
52 plaintextheader
= { 'content-type' : 'text/plain' , "Link" : "<data:text/css,body%7Bcolor:%23eee;background:%23333%7D>; rel=stylesheet;" }
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
60 show
= request
. args
. get ( "show" )
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 )
68 return jsonify ( metadata
)
69 else : # todo: handle geolocked, livesteam and the case when we have an exhausted error with no metadata returned
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
))
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
80 xmlfeed
= fetch_xml ( "channel_id" , channel_id
)
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
)
86 @app . route ( '/playlist' )
88 playlist_id
= request
. args
. get ( 'list' )
90 return "bad list id" , 400 # todo
92 xmlfeed
= fetch_xml ( "playlist_id" , playlist_id
)
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
)
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:
105 SELECT subscriptions.channel_id, name,
106 (subscribed_until < datetime('now')) AS obsolete
108 left JOIN channels ON channels.id = subscriptions.channel_id
109 left JOIN websub ON channels.id = websub.channel_id
111 ORDER BY obsolete=0, name COLLATE NOCASE ASC""" , ( token
,))
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
)
119 @app . route ( '/feed/subscriptions' , methods
=[ '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
)
131 with sqlite3
. connect ( cf
[ 'global' ][ 'database' ]) as conn
:
132 #with conn.cursor() as c:
135 INSERT OR REPLACE INTO flags (user, video_id, display)
137 """ , ( token
, video_id
, display
))
139 flash (( "error" , "unsupported action" ))
140 return redirect ( request
. url
, code
= 303 )
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
)
150 channel_id
= match
. group ( 1 )
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
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:
163 INSERT OR IGNORE INTO subscriptions (user, channel_id)
165 """ , ( token
, channel_id
))
166 # TODO: sql-error-handling, asynchronically calling update-subs.pl
168 elif 'unsubscribe' in request
. form
:
169 with sqlite3
. connect ( cf
[ 'global' ][ 'database' ]) as conn
:
170 #with conn.cursor() as c:
173 DELETE FROM subscriptions
174 WHERE user = ? AND channel_id = ?
175 """ , ( token
, channel_id
))
176 # TODO: sql-error-handling, report success
179 flash (( "error" , "unsupported action" ))
181 return redirect ( request
. url
, code
= 303 )
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
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 ]
199 for entry
in ( good
+ bad
):
201 if e
[ 'domain' ] not in [ 'youtube.com' , 'youtu.be' , 'invidio.us' ]:
203 video_id
= re
. match ( r
'^https?://(?:www.|m.)?(?:youtube.com/watch\?(?:.*&)?v=|youtu.be/|youtube.com/embed/)([-_0-9A-Za-z]+)' , e
[ 'url' ]). group ( 1 )
204 if not video_id
: continue
206 'video_id' : video_id
,
208 'url' : e
[ 'permalink' ],
209 'n_comments' : e
[ 'num_comments' ],
210 'n_karma' : e
[ 'score' ],
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
)
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
:
221 c
. execute ( "SELECT sts, algorithm FROM cipher" )
222 g
. cipher
= c
. fetchone ()
223 g
. cipher_updated
= time
. time ()
227 #@app.teardown_appcontext
229 # db = g.pop('db', None)
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.
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
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 ()))
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
258 @app . template_filter ( 'format_date' )
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 ()
264 if __name__
== '__main__' :