-
Notifications
You must be signed in to change notification settings - Fork 23
/
Copy pathapp.py
561 lines (414 loc) · 18.3 KB
/
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
from flask.wrappers import Response
from flask_login.utils import login_url
from flask_sqlalchemy import SQLAlchemy
from flask_bootstrap import Bootstrap
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from bcrypt import checkpw, hashpw, gensalt
import re
import random
import string
import hashlib
import time
import json
import resources as res
class Resources():
def __init__(self) -> None:
self.LOGO = res.LOGO or 'logo/logo.png'
self.PRIVACY_POLICY = res.PRIVACY_POLICY
self.TERMS_OF_USE = res.TERMS_OF_USE
resources = Resources()
# Application instance
app = Flask(__name__)
# SQL instance
db = SQLAlchemy()
# Login instance
login_manager = LoginManager()
class UserModel(UserMixin):
def __init__(self, id, username, gm_level):
self.id = id
self.username = username
self.gm_level = gm_level
def __repr__(self):
return '<User %r>' % self.username
def get_id(self):
return self.id
def is_authenticated(self):
return True
def is_active(self):
return True
def is_anonymous(self):
return False
# Activity data
class Activity():
def __init__(self, start, end, map_id):
self.start = start
self.end = end
self.map_id = map_id
class CharacterLog():
def __init__(self, character_id):
self.character_id = character_id
self.activity_log = [] # List of activities
self.last_logout = 0
self.last_activity = None
def register_login(self, start, map_id):
self.last_activity = Activity(start, None, map_id)
def register_logout(self, end):
if not self.last_activity:
return
self.last_activity.end = end
self.activity_log.append(self.last_activity)
self.last_activity = None
def get_play_time(self):
total_time = 0
for activity in self.activity_log:
total_time += activity.end - activity.start
return total_time
def get_play_time_during(self, start, end):
total_time = 0
for activity in self.activity_log:
if activity.start >= start and activity.end <= end:
total_time += activity.end - activity.start
return total_time
def get_play_time_during_in_map(self, start, end, map_id):
total_time = 0
for activity in self.activity_log:
if activity.start >= start and activity.end <= end and activity.map_id == map_id:
total_time += activity.end - activity.start
return total_time
def __repr__(self):
return '<CharacterLog %r>' % self.character_id
class Dataset():
def __init__(self, label, data, borderColor, borderWidth):
self.label = label
self.data = data
self.borderColor = borderColor
self.borderWidth = borderWidth
def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__,
sort_keys=True, indent=4)
class ActivityData():
def __init__(self, labels, title, datasets):
self.labels = labels
self.title = title
self.datasets = datasets
def toJSON(self):
return json.dumps(self, default=lambda o: o.__dict__,
sort_keys=True, indent=4)
activitie_data = {}
# Color definitions per map_id, alpha always 1
map_colors = {
# 1000: Gray
1000: 'rgba(200, 200, 200, 1)',
# 1100: Green
1100: 'rgba(0, 255, 0, 1)',
# 1200: Blue
1200: 'rgba(54, 162, 235, 1)',
# 1300: Dark Red
1300: 'rgba(255, 99, 132, 1)',
# 1400: Purple
1400: 'rgba(153, 102, 255, 1)',
# 1600: Orange
1600: 'rgba(255, 204, 0, 1)',
# 1700: Light Blue
1700: 'rgba(75, 192, 192, 1)',
# 1800: Light Red
1800: 'rgba(102, 0, 204, 1)',
# 1900: Yellow
1900: 'rgba(255, 159, 64, 1)',
# 2000: Black
2000: 'rgba(0, 0, 0, 1)',
}
@app.route('/load_activities', methods=['GET', 'POST'])
@login_required
def load_activities():
if current_user.gm_level != 9:
abort(403)
return
global activitie_data
connection = db.engine
epoch_time = int(time.time())
# Calculate the time 7 days ago
last_time = epoch_time - (7 * 24 * 60 * 60)
query = "SELECT character_id, activity, time, map_id FROM activity_log WHERE time > %s"
result = connection.execute(query, (last_time)).all()
# Get all unique characters, create a new CharacterLog for each and put it in a dictionary
characters = {}
for row in result:
if row[0] not in characters:
characters[row[0]] = CharacterLog(row[0])
# Loop through the results, if activity = 0, then it's a login, if activity = 1, then it's a logout
for row in result:
if row[1] == 0:
characters[row[0]].register_login(row[2], row[3])
else:
characters[row[0]].register_logout(row[2])
# Create activity data for the last 7 days
# Titled: Sessions in the last 7 days
# Data: Number of sessions
labels = []
data = []
for i in range(7):
labels.append(time.strftime("%a", time.localtime(epoch_time - (i * 24 * 60 * 60))))
data.append(0)
for character in characters:
character_data = characters[character]
for activity in character_data.activity_log:
index = (activity.start - last_time) // (24 * 60 * 60)
data[index] += 1
# Flip labels
labels.reverse()
# Insert the data into the dictionary
activitie_data["sessions"] = ActivityData(labels, "Sessions in the last 7 days", [Dataset("Sessions", data, "rgba(255, 99, 132, 1)", 3)])
# Create activity data for total play time over the last 7 days
# Titled: Total play time in the last 7 days
# Data: Total play time in seconds
labels = []
data = []
for i in range(7):
labels.append(time.strftime("%a", time.localtime(epoch_time - (i * 24 * 60 * 60))))
data.append(0)
# Get the play time for the current day
for character in characters:
character_data = characters[character]
begin = epoch_time - ((i + 1) * 24 * 60 * 60)
end = begin + (24 * 60 * 60)
play_time = character_data.get_play_time_during(begin, end) / (60 * 60)
data[i] += play_time
# Flip labels and data
labels.reverse()
data.reverse()
# Insert the data into the dictionary
activitie_data["play_time"] = ActivityData(labels, "Total play time in the last 7 days", [Dataset("Play time", data, "rgba(255, 99, 132, 1)", 3)])
# Create activity data for each unique map played in the last 7 days
# Titled: Maps played in the last 7 days
# Data: Number of times played
labels = []
maps = {} # { 'map_id': [...] }
# Get all unique maps
for character in characters:
character_data = characters[character]
for activity in character_data.activity_log:
# If the map_id is not devicible by 100, skip it
if activity.map_id % 100 != 0:
continue
if activity.map_id not in maps:
maps[activity.map_id] = []
# Make the array of length 7
for i in range(7):
maps[activity.map_id].append(0)
for i in range(7):
labels.append(time.strftime("%a", time.localtime(epoch_time - (i * 24 * 60 * 60))))
# Get the total time played in each map over this day
for map_id in maps:
begin = epoch_time - ((i + 1) * 24 * 60 * 60)
end = begin + (24 * 60 * 60)
for character in characters:
character_data = characters[character]
play_time = character_data.get_play_time_during_in_map(begin, end, map_id) / (60 * 60)
maps[map_id][6 - i] += play_time
# Flip labels and data
labels.reverse()
# Insert the data into the dictionary, one dataset per map
datasets = []
for map_id in maps:
color = map_colors[map_id]
datasets.append(Dataset(map_id, maps[map_id], color, 3))
activitie_data["zone_play_time"] = ActivityData(labels, "Maps played in the last 7 days", datasets)
# Return 200
return "OK", 200
@app.route('/activity_data/<name>', methods=['GET', 'POST'])
@login_required
def activity_data(name):
if current_user.gm_level != 9:
abort(403)
return
global activitie_data
# Get the entry from the dictionary
# Convert it to json and return it
# In the format of:
# {
# "labels": [...]
# "title": "..."
# "datasets": [{...}, {...}]
# }
# Cannot use jsonify because it doesn't support the Dataset class
return activitie_data[name].toJSON()
# User loader
@login_manager.user_loader
def load_user(user_id):
connection = db.engine
query = "SELECT name, gm_level FROM accounts WHERE id = %s;"
account_result = connection.execute(query, (user_id)).fetchone()
if account_result is None:
return None
return UserModel(user_id, account_result[0], account_result[1])
# Index route. Allow both GET and POST requests. Render the "account_creation" page.
@app.route('/activate', defaults={'key': None}, methods=['GET', 'POST'])
@app.route('/activate/<key>', methods=['GET', 'POST'])
def account_creation(key):
# If we get a POST request, collect the form data:
# play_key, account_name, account_password, & account_repeat_password
if request.method == 'POST':
play_key = request.form['play_key']
account_name = request.form['account_name']
account_password = request.form['account_password']
account_repeat_password = request.form['account_repeat_password']
agree = request.form['agree'] if 'agree' in request.form else False
# If the user didn't agree to the terms, return an error.
if not agree:
return render_template('public/account_creation.html', error="You must agree to the Privacy Policy and Terms of Service to continue.", resources=resources)
# If the passwords don't match, return an error.
if account_password != account_repeat_password:
return render_template('public/account_creation.html', error="Passwords don't match!", resources=resources)
connection = db.engine
# Check if the play_key is valid.
query = "SELECT id, key_uses, active FROM play_keys WHERE key_string = %s"
key_result = connection.execute(query, (play_key)).fetchone()
# If the play key is not active, or keyUses is 0, return an error.
if not key_result or key_result['active'] == False or key_result['key_uses'] == 0:
return render_template('public/account_creation.html', error="Invalid play key!", resources=resources)
# Check if the username only contains alphanumeric characters, no spaces, or special characters.
if not re.match("^[A-Za-z0-9_-]*$", account_name):
return render_template('public/account_creation.html', error="Username contains invalid characters!", resources=resources)
# Check if the username is no more than 32 characters long.
if len(account_name) > 32:
return render_template('public/account_creation.html', error="Username is too long!", resources=resources)
# Check if the username is already taken.
query = "SELECT COUNT(*) FROM accounts WHERE name = %s;"
username_taken = connection.execute(query, (account_name))
# If the username is taken, return an error.
if username_taken.fetchone()[0] > 0:
return render_template('public/account_creation.html', error="Username already taken!", resources=resources)
# Decrement keyUses from the play_keys table by id.
query = 'UPDATE play_keys SET key_uses = ' + str(key_result['key_uses'] - 1) + ' WHERE id = ' + str(key_result['id']) + ';'
connection.execute(query)
# Hash the password.
password_hash = hashpw(account_password.encode('utf-8'), gensalt(prefix=b"2a"))
# Insert the new account into the database.
query = 'INSERT INTO accounts (name, password, play_key_id) VALUES (%s, %s, ' + str(key_result['id']) + ');'
connection.execute(query, (account_name, password_hash))
# Notify the user of the successful account creation.
return render_template('public/account_creation.html', message="Successfully created account '{}'!".format(account_name), resources=resources)
key = key if key else ""
return render_template('public/account_creation.html', key=key, resources=resources)
@login_manager.unauthorized_handler
def unauthorized_redirect():
return redirect(url_for('login'))
@app.route('/logout', methods=['GET', 'POST'])
def logout():
logout_user()
return redirect(url_for('login'))
@app.route('/', methods=['GET', 'POST'])
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
if current_user.gm_level == 9:
return redirect(url_for('dashboard'))
else:
return redirect(url_for('data_download'))
if request.method == 'POST':
username = request.form['account_name']
password = request.form['account_password']
remember_me = request.form['remember_me'] if 'remember_me' in request.form else False
# Check if the username is valid.
connection = db.engine
query = "SELECT id, password, gm_level FROM accounts WHERE name = %s;"
account_result = connection.execute(query, (username)).fetchone()
# If the username is not valid, return an error.
if not account_result:
return render_template('private/login.html', error="Incorrect username or password!", resources=resources)
password_hash = account_result['password']
gm_level = account_result['gm_level']
# Support the old password style
old_password = hashlib.sha512((password + username).encode('utf-8')).hexdigest()
try:
# If the password doesn't match the account name, return an error.
if old_password != password_hash and not checkpw(password.encode('utf-8'), password_hash.encode('utf-8')):
return render_template('private/login.html', error="Incorrect username or password!", resources=resources)
except:
return render_template('private/login.html', error="Incorrect username or password!", resources=resources)
# Login user
login_user(UserModel(account_result['id'], username, gm_level), remember=remember_me)
# Redirect to the dashboard.
if gm_level == 9:
return redirect(url_for('dashboard'))
else:
return redirect(url_for('data_download'))
return render_template('private/login.html', resources=resources)
# Data download page
@app.route('/data_download', methods=['GET', 'POST'])
@login_required
def data_download():
if request.method == 'POST':
# Get the data from the form.
character_name = request.form['character_name']
# Check if the character exists.
connection = db.engine
query = "SELECT id FROM charinfo WHERE name = %s AND account_id = %s;"
character_result = connection.execute(query, (character_name, current_user.id)).fetchone()
# If the character doesn't exist, return an error.
if not character_result:
return render_template('private/data_download.html', error="Character '{}' does not exist!".format(character_name), resources=resources)
# Get the character's data.
query = "SELECT xml_data FROM charxml WHERE id = %s;"
xml_data = connection.execute(query, (character_result['id'])).fetchone()
# If the character doesn't have any data, return an error.
if not xml_data:
return render_template('private/data_download.html', error="Character '{}' does not have any data!".format(character_name), resources=resources)
# Return the character's data.
return Response(xml_data['xml_data'], mimetype='text/xml', headers={"Content-disposition": "attachment; filename=" + character_name + ".xml"})
return render_template('private/data_download.html', resources=resources)
# Key creation page
@app.route('/dashboard', methods=['GET', 'POST'])
@login_required
def dashboard():
if current_user.gm_level != 9:
return redirect(url_for('data_download'))
if request.method == 'POST':
connection = db.engine
key_count = request.form['key_count']
# Generate keys in the format AAAA-AAAA-AAAA-AAAA. 4 segments of uppercase letters and numbers.
key_list = []
for i in range(int(key_count)):
key = ""
for j in range(4):
key += ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(4)) + '-'
# Remove last dash
key = key[:-1]
key_list.append(key)
# Insert the keys into the database.
for key in key_list:
query = "INSERT INTO play_keys (key_string, active) VALUES (%s, 1);"
connection.execute(query, (key))
# Notify the user of the successful key creation.
return render_template('private/dashboard.html', message="Successfully created {} keys!".format(key_count), keys=key_list, resources=resources)
return render_template('private/dashboard.html', current_user=current_user, resources=resources)
"""
App configuration
Some properties are in env variables.
"""
from os import getenv
def run_app():
secret_key = getenv('SECRET_KEY')
db_url = getenv('DB_URL')
# If either of the env variables are not set, attempt to read them from credentials.py.
if not secret_key or not db_url:
from credentials import SECRET_KEY, DB_URL
secret_key = SECRET_KEY
db_url = DB_URL
app.config['DEBUG'] = True
app.config['SECRET_KEY'] = secret_key
app.config['SQLALCHEMY_DATABASE_URI'] = db_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SQLALCHEMY_ECHO'] = False
app.config['WTF_CSRF_ENABLED'] = False
# Setup SQL
db.init_app(app)
# Setup Login
login_manager.init_app(app)
# Setup Bootstrap
Bootstrap(app)
app.run(host='0.0.0.0', port=5000)
if __name__ == '__main__':
run_app()