server.py 16 KB
Newer Older
1
from flask import Flask, g, request, url_for, redirect, session, render_template, flash, Response
2
from werkzeug.routing import Rule
3
from functools import wraps
Julian Rother's avatar
Julian Rother committed
4
from datetime import date, timedelta, datetime, time, MINYEAR
5
import threading
6
import os
7
import sys
Julian Rother's avatar
Julian Rother committed
8
import hashlib
9
import random
10

11
app = Flask(__name__)
12

Andreas Valder's avatar
Andreas Valder committed
13
14
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
Julian Rother's avatar
Julian Rother committed
15
app.add_template_global(random.randint, name='randint')
16
17
app.add_template_global(datetime, name='datetime')
app.add_template_global(timedelta, name='timedelta')
Andreas Valder's avatar
Andreas Valder committed
18

19
20
21
22
23
24
25
26
27
28
def timer_func():
	with app.test_request_context():
		pass # do something
	timer = threading.Timer(60*60, timer_func)
	timer.start()

timer = threading.Timer(0, timer_func)
timer.daemon = True
timer.start()

29
config = app.config
30
config.from_pyfile('config.py.example', silent=True)
31
32
33
if sys.argv[0].endswith('run.py'): 
	config['SQLITE_INIT_DATA'] = True
	config['DEBUG'] = True
34
config.from_pyfile('config.py', silent=True)
Andreas Valder's avatar
Andreas Valder committed
35
36
if config['DEBUG']:
	app.jinja_env.auto_reload = True
37
38
if not config.get('SECRET_KEY', None):
	config['SECRET_KEY'] = os.urandom(24)
Julian Rother's avatar
Julian Rother committed
39

Julian Rother's avatar
Julian Rother committed
40
from db import query, modify, searchquery, ldapauth, ldapget
Julian Rother's avatar
Julian Rother committed
41

42
mod_endpoints = []
Julian Rother's avatar
Julian Rother committed
43

Julian Rother's avatar
Cleanup    
Julian Rother committed
44
@app.template_global()
45
46
47
48
def ismod(*args):
	return ('user' in session)

def mod_required(func):
49
	mod_endpoints.append(func.__name__)
50
51
	@wraps(func)
	def decorator(*args, **kwargs):
52
		if not ismod():
53
54
55
56
57
58
			flash('Diese Funktion ist nur für Moderatoren verfügbar!')
			return redirect(url_for('login', ref=request.url))
		else:
			return func(*args, **kwargs)
	return decorator

59
app.jinja_env.globals['navbar'] = []
60
61
62
63
64
# iconlib can be 'bootstrap'
# ( see: http://getbootstrap.com/components/#glyphicons )
# or 'fa'
# ( see: http://fontawesome.io/icons/ )
def register_navbar(name, iconlib='bootstrap', icon=None):
65
	def wrapper(func):
66
		endpoint = func.__name__
67
		app.jinja_env.globals['navbar'].append((endpoint, name, iconlib, icon, not endpoint in mod_endpoints))
68
69
70
		return func
	return wrapper

Julian Rother's avatar
Cleanup    
Julian Rother committed
71
72
73
74
def render_endpoint(endpoint, flashtext=None, **kargs):
	if flashtext:
		flash(flashtext)
	# request.endpoint is used for navbar highlighting
75
	request.url_rule = Rule(request.path, endpoint=endpoint)
Julian Rother's avatar
Cleanup    
Julian Rother committed
76
77
	return app.view_functions[endpoint](**kargs)

78
79
80
81
82
83
84
def handle_errors(endpoint, text, code, *errors, **epargs):
	def wrapper(func):
		@wraps(func)
		def decorator(*args, **kwargs):
			try:
				return func(*args, **kwargs)
			except errors:
Julian Rother's avatar
Julian Rother committed
85
86
87
88
				if endpoint:
					return render_endpoint(endpoint, text, **epargs), code
				else:
					return text, code
89
90
91
		return decorator
	return wrapper

Julian Rother's avatar
Cleanup    
Julian Rother committed
92
93
@app.errorhandler(404)
def handle_not_found(e):
94
	return render_endpoint('index', 'Diese Seite existiert nicht!'), 404
Julian Rother's avatar
Cleanup    
Julian Rother committed
95

Julian Rother's avatar
Julian Rother committed
96
@app.template_filter(name='semester')
97
98
99
100
101
102
103
104
105
106
107
108
109
110
def human_semester(s, long=False):
	if not s or s == 'zeitlos' or len(s) != 6:
		return 'Zeitlos'
	year = s[0:4]
	semester = s[4:6].upper()
	if not year.isdigit() or semester not in ['SS', 'WS']:
		print('Invalid semester string "%s"'%s)
		return '??'
	if not long:
		return semester+year[2:]
	elif semester == 'SS':
		return 'Sommersemester %s'%year
	else:
		return 'Wintersemester %s/%s'%(year, str(int(year)+1)[2:])
Julian Rother's avatar
Julian Rother committed
111
112
113

@app.template_filter(name='date')
def human_date(d):
Andreas Valder's avatar
Andreas Valder committed
114
	return d.strftime('%d.%m.%Y')
Julian Rother's avatar
Julian Rother committed
115

Andreas Valder's avatar
Andreas Valder committed
116
117
118
119
@app.template_filter(name='time')
def human_date(d):
	return d.strftime('%H:%M')

Julian Rother's avatar
Julian Rother committed
120
121
122
123
@app.template_filter()
def rfc3339(d):
	return d.strftime('%Y-%m-%dT%H:%M:%S+02:00')

124
125
@app.template_global()
def get_announcements(minlevel=0):
126
127
	offset = timedelta()
	if ismod():
128
		offset = timedelta(hours=24)
129
	return query('SELECT * FROM announcements WHERE NOT deleted AND (time_expire ISNULL OR time_expire > ?) AND (? OR (visible AND time_publish < ?)) AND level >= ? ORDER BY level DESC', datetime.now()-offset, ismod(), datetime.now(), minlevel)
130

131
132
133
134
135
@app.template_filter()
def fixnl(s):
	# To be remove, as soon as db schema is cleaned-up
	return str(s).replace('\n', '<br>')

136
@app.route('/')
137
@register_navbar('Home', icon='home')
138
def index():
139
140
	start = date.today() - timedelta(days=1)
	end = start + timedelta(days=7)
141
142
	upcomming = query('''
		SELECT lectures.*, "course" AS sep, courses.*
Andreas Valder's avatar
Andreas Valder committed
143
144
		FROM lectures
		JOIN courses ON (lectures.course_id = courses.id)
145
146
		WHERE (time > ?) AND (time < ?) and lectures.visible and courses.visible and courses.listed
		ORDER BY time ASC LIMIT 30''',start,end)
Andreas Valder's avatar
Andreas Valder committed
147
148
149
	for i in upcomming:
		i['date'] = i['time'].date()
	latestvideos=query('''
150
		SELECT lectures.*, "course" AS sep, courses.*
Andreas Valder's avatar
Andreas Valder committed
151
152
153
154
155
		FROM lectures
		LEFT JOIN videos ON (videos.lecture_id = lectures.id)
		LEFT JOIN courses on (courses.id = lectures.course_id)
		WHERE (? OR (courses.visible AND courses.listed AND lectures.visible AND videos.visible))
		GROUP BY videos.lecture_id
156
		ORDER BY MAX(videos.time_updated) DESC
Andreas Valder's avatar
Andreas Valder committed
157
		LIMIT 6	''',ismod())
158
159
	featured = query('SELECT * FROM featured WHERE NOT deleted AND (? OR visible)', ismod())
	return render_template('index.html', latestvideos=latestvideos, upcomming=upcomming, featured=featured)
160

161
@app.route('/course')
162
@register_navbar('Videos', icon='film')
163
def courses():
164
	courses = query('SELECT * FROM courses WHERE (? OR (visible AND listed)) ORDER BY title', ismod())
165
166
167
	for course in courses:
		if course['semester'] == '':
			course['semester'] = 'zeitlos'
Andreas Valder's avatar
Andreas Valder committed
168
	groupedby = request.args.get('groupedby')
Julian Rother's avatar
Cleanup    
Julian Rother committed
169
	if groupedby not in ['title', 'semester', 'organizer']:
Andreas Valder's avatar
Andreas Valder committed
170
		groupedby = 'semester'
171
	return render_template('courses.html', courses=courses, groupedby=groupedby)
Andreas Valder's avatar
Andreas Valder committed
172

173
174
@app.route('/course/<handle>')
@app.route('/course/<int:id>')
175
@handle_errors('courses', 'Diese Veranstaltung existiert nicht!', 404, IndexError)
176
177
def course(id=None, handle=None):
	if id:
178
		course = query('SELECT * FROM courses WHERE id = ? AND (? OR visible)', id, ismod())[0]
179
	else:
180
		course = query('SELECT * FROM courses WHERE handle = ? AND (? OR visible)', handle, ismod())[0]
181
182
183
	course['auth'] = query('SELECT * FROM auth WHERE course_id = ? ORDER BY auth_type', course['id'])
	auths = query('SELECT auth.* FROM auth JOIN lectures ON (auth.lecture_id = lectures.id) WHERE lectures.course_id = ? ORDER BY auth.auth_type', course['id'])
	lectures = query('SELECT * FROM lectures WHERE course_id = ? AND (? OR visible) ORDER BY time, duration DESC', course['id'], ismod())
184
185
186
187
188
	for lecture in lectures:
		lecture['auth'] = []
		for auth in auths:
			if auth['lecture_id'] == lecture['id']:
				lecture['auth'].append(auth)
Andreas Valder's avatar
Andreas Valder committed
189
	videos = query('''
190
			SELECT videos.*, (videos.downloadable AND courses.downloadable) as downloadable, formats.description AS format_description, formats.player_prio, formats.prio
Andreas Valder's avatar
Andreas Valder committed
191
192
193
194
195
196
			FROM videos
			JOIN lectures ON (videos.lecture_id = lectures.id)
			JOIN formats ON (videos.video_format = formats.id)
			JOIN courses ON (lectures.course_id = courses.id)
			WHERE lectures.course_id= ? AND (? OR videos.visible)
			ORDER BY lectures.time, formats.prio DESC
197
198
			''', course['id'], ismod())
	return render_template('course.html', course=course, lectures=lectures, videos=videos)
Andreas Valder's avatar
Andreas Valder committed
199

Andreas Valder's avatar
Andreas Valder committed
200
@app.route('/faq')
201
@register_navbar('FAQ', icon='question-sign')
Andreas Valder's avatar
Andreas Valder committed
202
def faq():
203
	return render_template('faq.html')
Andreas Valder's avatar
Andreas Valder committed
204

205
@app.route('/play/<int:id>')
Andreas Valder's avatar
Andreas Valder committed
206
@app.route('/embed/<int:id>', endpoint='embed')
207
@handle_errors('course', 'Diese Vorlesung existiert nicht!', 404, IndexError)
208
def lecture(id):
209
	lectures = query('SELECT * FROM lectures WHERE id = ? AND (? OR visible)', id, ismod())
210
	videos = query('SELECT videos.*, formats.description AS format_description, formats.prio, formats.player_prio FROM videos JOIN formats ON (videos.video_format = formats.id) WHERE lecture_id = ? AND (? OR visible)', id, ismod())
211
212
213
214
	if not videos:
		flash('Zu dieser Vorlesung wurden noch keine Videos veröffentlicht!')
	courses = query('SELECT * FROM courses WHERE id = ? AND (? OR (visible AND listed))', lectures[0]['course_id'], ismod())
	if not courses:
215
		return render_endpoint('course', 'Diese Veranstaltung existiert nicht!'), 404
216
	chapters = query('SELECT * FROM chapters WHERE lecture_id = ? AND NOT deleted AND (? OR visible) ORDER BY time ASC', id, ismod())
Andreas Valder's avatar
Andreas Valder committed
217
218
	return render_template('embed.html' if request.endpoint == 'embed' else 'lecture.html', course=courses[0], lecture=lectures[0], videos=videos, chapters=chapters)

219
220
221
222
223
224
225

@app.route('/search')
def search():
	if 'q' not in request.args:
		return redirect(url_for('index'))
	q = request.args['q']
	courses = searchquery(q, '*', ['title', 'short', 'organizer', 'subject', 'description'],
226
			'courses', 'WHERE (? OR (visible AND listed)) GROUP BY id ORDER BY _score DESC, semester DESC LIMIT 20', ismod())
227
	lectures = searchquery(q, 'lectures.*, courses.visible AS coursevisible, courses.listed, "course" AS sep, courses.*',
228
229
			['lectures.title', 'lectures.comment', 'lectures.speaker', 'courses.short'],
			'lectures LEFT JOIN courses on (courses.id = lectures.course_id)',
230
			'WHERE (? OR (coursevisible AND listed AND visible)) GROUP BY id ORDER BY _score DESC, time DESC LIMIT 30', ismod())
231
	return render_template('search.html', searchtext=request.args['q'], courses=courses, lectures=lectures)
Andreas Valder's avatar
Andreas Valder committed
232

233
234
235
def check_mod(user, groups):
	return user and 'users' in groups

236
@app.route('/login', methods=['GET', 'POST'])
Julian Rother's avatar
Julian Rother committed
237
def login():
238
239
	if request.method == 'GET':
		return render_template('login.html')
Julian Rother's avatar
Julian Rother committed
240
	user, groups = ldapauth(request.form.get('user'), request.form.get('password'))
241
	if not check_mod(user, groups):
242
		flash('Login fehlgeschlagen!')
243
244
245
246
		return render_template('login.html')
	session['user'] = ldapget(user)
	dbuser = query('SELECT * FROM users WHERE name = ?', user)
	if not dbuser:
Julian Rother's avatar
Julian Rother committed
247
		modify('INSERT INTO users (name, realname, fsacc, level, calendar_key, rfc6238) VALUES (?, ?, ?, 1, "", "")', user, session['user']['givenName'], user)
248
249
		dbuser = query('SELECT * FROM users WHERE name = ?', user)
	session['user']['dbid'] = dbuser[0]['id']
Julian Rother's avatar
Julian Rother committed
250
	return redirect(request.values.get('ref', url_for('index')))
Julian Rother's avatar
Julian Rother committed
251

Julian Rother's avatar
Julian Rother committed
252
@app.route('/logout', methods=['GET', 'POST'])
Julian Rother's avatar
Julian Rother committed
253
254
def logout():
	session.pop('user')
Julian Rother's avatar
Julian Rother committed
255
	return redirect(request.values.get('ref', url_for('index')))
Julian Rother's avatar
Julian Rother committed
256

257
258
259
260
261
262
263
264
tabs = {
	'courses': ('courses_data', 'id', ['visible', 'listed', 'title', 'short',
			'handle', 'organizer', 'subject', 'semester', 'downloadable',
			'internal', 'responsible','deleted']),
	'lectures': ('lectures_data', 'id', ['visible', 'title', 'comment',
			'internal', 'speaker', 'place', 'time', 'duration', 'jumplist','deleted']),
	'videos': ('videos_data', 'id', ['visible','deleted']),
	'chapters': ('chapters', 'id', ['time', 'text', 'visible', 'deleted']),
265
	'announcements': ('announcements', 'id', ['text', 'level', 'visible', 'deleted', 'time_publish', 'time_expire']),
266
	'featured': ('featured', 'id', ['title', 'text', 'internal', 'visible', 'deleted'])
267
268
}

269
@app.route('/edit', methods=['GET', 'POST'])
270
@mod_required
271
def edit(prefix="", ignore=[]):
272
	# All editable tables are expected to have a 'time_updated' field
273
	ignore.append('ref')
Julian Rother's avatar
Julian Rother committed
274
	modify('BEGIN')
275
	if request.is_json:
Julian Rother's avatar
Julian Rother committed
276
277
278
		changes = request.get_json().items()
	else:
		changes = request.args.items()
279
	created = {}
Julian Rother's avatar
Julian Rother committed
280
	for key, val in changes:
281
282
283
		if key in ignore:
			continue
		key = prefix+key
284
		table, id, column = key.split('.', 2)
Julian Rother's avatar
Julian Rother committed
285
286
		assert table in tabs
		assert column in tabs[table][2]
287
288
		modify('INSERT INTO changelog (`table`,id_value, id_key, field, value_new, value_old, `when`, who, executed) VALUES (?,?,?,?,?,(SELECT %s FROM %s WHERE %s = ?),?,?,1)'%(column, tabs[table][0], tabs[table][1]),
				table, id, tabs[table][1], column, val, id, datetime.now(), session['user']['dbid'])
Julian Rother's avatar
Julian Rother committed
289
290
		modify('UPDATE %s SET %s = ?, time_updated = ? WHERE %s = ?'%(tabs[table][0], column, tabs[table][1]), val, datetime.now(), id)
	modify('COMMIT')
291
292
	if 'ref' in request.values:
		return redirect(request.values['ref'])
293
	return "OK", 200
Julian Rother's avatar
Julian Rother committed
294

295
296
297
298
299
300
301
302
303
304
305
@app.route('/new/<table>', methods=['GET', 'POST'])
@mod_required
def create(table):
	assert table in tabs
	id = modify('INSERT INTO %s (created_by, time_created, time_updated) VALUES (?, ?, ?)'%tabs[table][0],
			session['user']['dbid'], datetime.now(), datetime.now())
	edit(prefix=table+'.'+str(id)+'.')
	if 'ref' in request.values:
		return redirect(request.values['ref'])
	return str(id), 200

306
307
@app.route('/newlecture/<courseid>', methods=['GET', 'POST'])
@mod_required
308
def new_lecture(courseid):
Julian Rother's avatar
Julian Rother committed
309
	id = modify('''
310
311
312
313
314
315
316
317
318
319
		INSERT INTO lectures_data
			(course_id, visible, drehplan, title, comment, internal, speaker, place,
				time, time_created, time_updated, jumplist, titlefile)
			VALUES (?, 0, "", "Noch kein Titel", "", "", "", "", ?, ?, ?, "", "")
		''', courseid, datetime.now(), datetime.now(), datetime.now())
	edit(prefix='lectures.'+str(id)+'.', ignore=['ref'])
	if 'ref' in request.values:
		return redirect(request.values['ref'])
	return str(id), 200

320
321
322
323
324
@app.route('/auth')
def auth(): # For use with nginx auth_request
	if 'X-Original-Uri' not in request.headers:
		return 'Internal Server Error', 500
	url = request.headers['X-Original-Uri'].lstrip(config['VIDEOPREFIX'])
325
	ip = request.headers.get('X-Real-IP', '')
326
	if url.endswith('jpg'):
327
		return "OK", 200
328
	videos = query('''SELECT videos.path, videos.id, lectures.id AS lecture_id, courses.id AS course_id, auth.*
329
330
331
      FROM videos
      JOIN lectures ON (videos.lecture_id = lectures.id)
      JOIN courses ON (lectures.course_id = courses.id)
332
			LEFT JOIN auth ON (videos.id = auth.video_id OR lectures.id = auth.lecture_id OR courses.id = auth.course_id)
333
334
      WHERE videos.path = ?
      AND (? OR (courses.visible AND lectures.visible AND videos.visible))
335
			ORDER BY auth.video_id DESC, auth.lecture_id DESC, auth.course_id DESC''',
336
337
			url, ismod())
	if not videos:
338
		return "Not allowed", 403
339
340
341
342
	allowed = False
	types = []
	auth = request.authorization
	for video in videos:
343
344
		if videos[0] and ((videos[0]['video_id'] and not video['video_id']) \
				or (videos[0]['lecture_id'] and not video['lecture_id'])):
345
346
347
348
349
350
351
352
353
			break
		types.append(video['auth_type'])
		if video['auth_type'] == 'public':
			allowed = True
			break
		elif video['auth_type'] == 'password':
			if auth and video['auth_user'] == auth.username and video['auth_passwd'] == auth.password:
				allowed = True
				break
354
355
	if not types[0] or allowed or ismod() or \
			(auth and check_mod(*ldapauth(auth.username, auth.password))):
356
		return 'OK', 200
Julian Rother's avatar
Julian Rother committed
357
		modify('INSERT INTO log VALUES (?, "", ?, "video", ?, ?)', ip, datetime.now(), videos[0]['id'], url)
358
359
360
	elif 'password' in types:
		return Response("Login required", 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
	return "Not allowed", 403
Andreas Valder's avatar
Andreas Valder committed
361

Andreas Valder's avatar
Andreas Valder committed
362
@app.route('/stats')
Andreas Valder's avatar
Andreas Valder committed
363
@register_navbar('Statistiken', icon='stats')
Andreas Valder's avatar
Andreas Valder committed
364
365
366
@mod_required
def stats():
	return render_template('stats.html')
Andreas Valder's avatar
Andreas Valder committed
367

Andreas Valder's avatar
Andreas Valder committed
368
@app.route('/changelog')
Andreas Valder's avatar
Andreas Valder committed
369
@register_navbar('Changelog', icon='book')
Andreas Valder's avatar
Andreas Valder committed
370
@mod_required
371
def changelog():
372
373
374
	changelog = query('SELECT * FROM changelog LEFT JOIN users ON (changelog.who = users.id) ORDER BY `when` DESC LIMIT 50')
	for entry in changelog:
		entry['path'] = '.'.join([entry['table'], entry['id_value'], entry['field']])
375
	return render_template('changelog.html', changelog=changelog)
Andreas Valder's avatar
Andreas Valder committed
376

Julian Rother's avatar
Julian Rother committed
377
378
379
380
@app.route('/files/<filename>')
def files(filename):
	return redirect(config['VIDEOPREFIX']+'/'+filename)

381
382
383
384
385
386
387
388
389
@app.route('/newchapter/<int:lectureid>', methods=['POST', 'GET'])
def suggest_chapter(lectureid):
	time = request.values['time']
	text = request.values['text']
	assert(time and text)
	time = int(time)
	submitter = None
	if not ismod():
		submitter = request.environ['REMOTE_ADDR']
Julian Rother's avatar
Julian Rother committed
390
	id = modify('INSERT INTO chapters (lecture_id, time, text, time_created, time_updated, created_by, submitted_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
391
392
393
394
395
				lectureid, time, text, datetime.now(), datetime.now(), session.get('user', {'dbid':None})['dbid'], submitter)
	if 'ref' in request.values:
		return redirect(request.values['ref'])
	return 'OK',  200

Andreas Valder's avatar
Andreas Valder committed
396
@app.route('/sitemap.xml')
Andreas Valder's avatar
Andreas Valder committed
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
def sitemap():
	pages=[]
	# static pages
	for rule in app.url_map.iter_rules():
		if 'GET' in rule.methods and len(rule.arguments)==0:
			if rule.endpoint not in mod_endpoints:
				pages.append([rule.rule])
	for i in query('select * from courses where visible and listed'):
		pages.append([url_for('course',handle=i['handle'])])
		for j in query('select * from lectures where (course_id = ? and visible)',i['id']):
			pages.append([url_for('lecture',id=j['id'])])


	return Response(render_template('sitemap.xml', pages=pages), 200, {'Content-Type': 'application/atom+xml'} )


Andreas Valder's avatar
Andreas Valder committed
413
@app.route('/sortlog')
Andreas Valder's avatar
Andreas Valder committed
414
@register_navbar('Sortierlog', icon='sort-by-attributes-alt')
Andreas Valder's avatar
Andreas Valder committed
415
416
417
@mod_required
def sortlog():
	return render_template('sortlog.html')
Andreas Valder's avatar
Andreas Valder committed
418

Julian Rother's avatar
Julian Rother committed
419
import feeds
420
import importer
Andreas Valder's avatar
Andreas Valder committed
421
import schedule
Andreas Valder's avatar
Andreas Valder committed
422
import sorter