diff --git a/app.py b/app.py index d5213b3..a9a35dc 100644 --- a/app.py +++ b/app.py @@ -9,9 +9,10 @@ import re import requests import schema import os +import time from functools import wraps -from flask import Flask, g, jsonify, render_template, request, abort, redirect, session, flash +from flask import Flask, g, jsonify, render_template, request, abort, redirect, session, flash, make_response from flask_caching import Cache from flask_session import Session from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError @@ -113,15 +114,27 @@ def before_request_func(): session.clear() -def get_config(): +def get_config(credentials=False): config_out = { 'songs_baseurl': config.SONGS_BASEURL, 'assets_baseurl': config.ASSETS_BASEURL, 'email': config.EMAIL, 'accounts': config.ACCOUNTS, - 'custom_js': config.CUSTOM_JS, - 'google_credentials': config.GOOGLE_CREDENTIALS + 'custom_js': config.CUSTOM_JS } + if credentials: + min_level = config.GOOGLE_CREDENTIALS['min_level'] or 0 + if not session.get('username'): + user_level = 0 + else: + user = db.users.find_one({'username': session.get('username')}) + user_level = user['user_level'] + if user_level >= min_level: + config_out['google_credentials'] = config.GOOGLE_CREDENTIALS + else: + config_out['google_credentials'] = { + 'gdrive_enabled': False + } if not config_out.get('songs_baseurl'): config_out['songs_baseurl'] = ''.join([request.host_url, 'songs']) + '/' @@ -342,6 +355,43 @@ def route_admin_songs_id_delete(id): return redirect('/admin/songs') +@app.route('/admin/users') +@admin_required(level=50) +def route_admin_users(): + user = db.users.find_one({'username': session.get('username')}) + max_level = user['user_level'] - 1 + return render_template('admin_users.html', config=get_config(), max_level=max_level, username='', level='') + + +@app.route('/admin/users', methods=['POST']) +@admin_required(level=50) +def route_admin_users_post(): + admin_name = session.get('username') + admin = db.users.find_one({'username': admin_name}) + max_level = admin['user_level'] - 1 + + username = request.form.get('username') + level = int(request.form.get('level')) or 0 + + user = db.users.find_one({'username': username}) + if not user: + flash('Error: User was not found.') + elif admin_name == username: + flash('Error: You cannot modify your own level.') + else: + user_level = user['user_level'] + if level < 0 or level > max_level: + flash('Error: Invalid level.') + elif user_level > max_level: + flash('Error: This user has higher level than you.') + else: + output = {'user_level': level} + db.users.update_one({'username': username}, {'$set': output}) + flash('User updated.') + + return render_template('admin_users.html', config=get_config(), max_level=max_level, username=username, level=level) + + @app.route('/api/preview') @app.cache.cached(timeout=15, query_string=True) def route_api_preview(): @@ -400,7 +450,7 @@ def route_api_categories(): @app.route('/api/config') @app.cache.cached(timeout=15) def route_api_config(): - config = get_config() + config = get_config(credentials=True) return jsonify(config) @@ -611,6 +661,16 @@ def route_api_scores_get(): return jsonify({'status': 'ok', 'scores': scores, 'username': user['username'], 'display_name': user['display_name'], 'don': don}) +@app.route('/privacy') +def route_api_privacy(): + last_modified = time.strftime('%d %B %Y', time.gmtime(os.path.getmtime('templates/privacy.txt'))) + integration = config.GOOGLE_CREDENTIALS['gdrive_enabled'] + + response = make_response(render_template('privacy.txt', last_modified=last_modified, config=get_config(), integration=integration)) + response.headers['Content-type'] = 'text/plain; charset=utf-8' + return response + + def make_preview(song_id, song_type, song_ext, preview): song_path = 'public/songs/%s/main.%s' % (song_id, song_ext) prev_path = 'public/songs/%s/preview.mp3' % song_id diff --git a/config.example.py b/config.example.py index b6c3aa8..f954121 100644 --- a/config.example.py +++ b/config.example.py @@ -1,11 +1,11 @@ # The full URL base asset URL, with trailing slash. -ASSETS_BASEURL = '' +ASSETS_BASEURL = '/assets/' # The full URL base song URL, with trailing slash. -SONGS_BASEURL = '' +SONGS_BASEURL = '/songs/' # The email address to display in the "About Simulator" menu. -EMAIL = 'taiko@example.com' +EMAIL = None # Whether to use the user account system. ACCOUNTS = True @@ -39,5 +39,6 @@ GOOGLE_CREDENTIALS = { 'gdrive_enabled': False, 'api_key': '', 'oauth_client_id': '', - 'project_number': '' + 'project_number': '', + 'min_level': None } diff --git a/public/src/css/view.css b/public/src/css/view.css index cc55123..0324d93 100644 --- a/public/src/css/view.css +++ b/public/src/css/view.css @@ -112,10 +112,15 @@ kbd{ margin-right: 0.4em; } .center-buttons{ - display: flex; - justify-content: center; margin: 1.5em 0; } +.account-view .center-buttons{ + margin: 0.3em 0; +} +.center-buttons>div{ + text-align: center; + margin: 0.2em 0; +} .center-buttons .taibtn{ margin: 0 0.2em; } diff --git a/public/src/js/account.js b/public/src/js/account.js index 7080ec3..f0383bd 100644 --- a/public/src/js/account.js +++ b/public/src/js/account.js @@ -93,6 +93,11 @@ class Account{ this.inputForms.push(this.accountDel.password) this.accountDelDiv = this.getElement("accountdel-div") + this.linkPrivacy = this.getElement("privacy-btn") + this.setAltText(this.linkPrivacy, strings.account.privacy) + pageEvents.add(this.linkPrivacy, ["mousedown", "touchstart"], this.openPrivacy.bind(this)) + this.items.push(this.linkPrivacy) + this.logoutButton = this.getElement("logout-btn") this.setAltText(this.logoutButton, strings.account.logout) pageEvents.add(this.logoutButton, ["mousedown", "touchstart"], this.onLogout.bind(this)) @@ -245,6 +250,12 @@ class Account{ pageEvents.add(this.registerButton, ["mousedown", "touchstart"], this.onSwitchMode.bind(this)) this.items.push(this.registerButton) + + this.linkPrivacy = this.getElement("privacy-btn") + this.setAltText(this.linkPrivacy, strings.account.privacy) + pageEvents.add(this.linkPrivacy, ["mousedown", "touchstart"], this.openPrivacy.bind(this)) + this.items.push(this.linkPrivacy) + if(!register){ this.items.push(this.loginButton) } @@ -282,11 +293,17 @@ class Account{ this.onSwitchMode() }else if(selected === this.loginButton){ this.onLogin() + }else if(selected === this.linkPrivacy){ + assets.sounds["se_don"].play() + this.openPrivacy() } }else if(name === "previous" || name === "next"){ selected.classList.remove("selected") this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) this.items[this.selected].classList.add("selected") + if(this.items[this.selected] === this.linkPrivacy){ + this.items[this.selected].scrollIntoView() + } assets.sounds["se_ka"].play() }else if(name === "back"){ this.onEnd() @@ -380,6 +397,19 @@ class Account{ } }) } + openPrivacy(event){ + if(event){ + if(event.type === "touchstart"){ + event.preventDefault() + }else if(event.which !== 1){ + return + } + } + if(this.locked){ + return + } + open("privacy") + } onLogout(){ if(event){ if(event.type === "touchstart"){ @@ -583,6 +613,7 @@ class Account{ pageEvents.remove(this.customdonResetBtn, ["click", "touchstart"]) pageEvents.remove(this.accounPassButton, ["click", "touchstart"]) pageEvents.remove(this.accountDelButton, ["click", "touchstart"]) + pageEvents.remove(this.linkPrivacy, ["mousedown", "touchstart"]) pageEvents.remove(this.logoutButton, ["mousedown", "touchstart"]) pageEvents.remove(this.saveButton, ["mousedown", "touchstart"]) for(var i = 0; i < this.inputForms.length; i++){ @@ -602,6 +633,7 @@ class Account{ delete this.accountDelButton delete this.accountDel delete this.accountDelDiv + delete this.linkPrivacy delete this.logoutButton delete this.saveButton delete this.inputForms @@ -615,12 +647,14 @@ class Account{ for(var i = 0; i < this.form.length; i++){ pageEvents.remove(this.registerButton, ["keydown", "keyup", "keypress"]) } + pageEvents.remove(this.linkPrivacy, ["mousedown", "touchstart"]) delete this.errorDiv delete this.form delete this.password2 delete this.remember delete this.loginButton delete this.registerButton + delete this.linkPrivacy } pageEvents.remove(this.endButton, ["mousedown", "touchstart"]) delete this.endButton diff --git a/public/src/js/customsongs.js b/public/src/js/customsongs.js index 2d21fa2..6edc60c 100644 --- a/public/src/js/customsongs.js +++ b/public/src/js/customsongs.js @@ -36,7 +36,10 @@ class CustomSongs{ this.linkLocalFolder.parentNode.removeChild(this.linkLocalFolder) } + var groupGdrive = document.getElementById("group-gdrive") this.linkGdriveFolder = document.getElementById("link-gdrivefolder") + this.linkGdriveAccount = document.getElementById("link-gdriveaccount") + this.linkPrivacy = document.getElementById("link-privacy") if(gameConfig.google_credentials.gdrive_enabled){ this.setAltText(this.linkGdriveFolder, strings.customSongs.gdriveFolder) pageEvents.add(this.linkGdriveFolder, ["mousedown", "touchstart"], this.gdriveFolder.bind(this)) @@ -45,8 +48,15 @@ class CustomSongs{ this.linkGdriveFolder.classList.add("selected") this.selected = this.items.length - 1 } + this.setAltText(this.linkGdriveAccount, strings.customSongs.gdriveAccount) + pageEvents.add(this.linkGdriveAccount, ["mousedown", "touchstart"], this.gdriveAccount.bind(this)) + this.items.push(this.linkGdriveAccount) + this.setAltText(this.linkPrivacy, strings.account.privacy) + pageEvents.add(this.linkPrivacy, ["mousedown", "touchstart"], this.openPrivacy.bind(this)) + this.items.push(this.linkPrivacy) }else{ - this.linkGdriveFolder.parentNode.removeChild(this.linkGdriveFolder) + groupGdrive.style.display = "none" + this.linkPrivacy.parentNode.removeChild(this.linkPrivacy) } this.endButton = this.getElement("view-end-button") @@ -237,13 +247,67 @@ class CustomSongs{ }else if(e !== "cancel"){ return Promise.reject(e) } + }).finally(() => { + var addRemove = !gpicker || !gpicker.oauthToken ? "add" : "remove" + this.linkGdriveAccount.classList[addRemove]("hiddenbtn") }) } + gdriveAccount(event){ + if(event){ + if(event.type === "touchstart"){ + event.preventDefault() + }else if(event.which !== 1){ + return + } + } + if(this.locked || this.mode !== "main"){ + return + } + this.changeSelected(this.linkGdriveAccount) + this.locked = true + this.loading(true) + if(!gpicker){ + var gpickerPromise = loader.loadScript("/src/js/gpicker.js").then(() => { + gpicker = new Gpicker() + }) + }else{ + var gpickerPromise = Promise.resolve() + } + gpickerPromise.then(() => { + return gpicker.switchAccounts(locked => { + this.locked = locked + this.loading(locked) + }, error => { + this.showError(error) + }) + }).then(() => { + this.locked = false + this.loading(false) + }).catch(error => { + if(error !== "cancel"){ + this.showError(error) + } + }) + } + openPrivacy(event){ + if(event){ + if(event.type === "touchstart"){ + event.preventDefault() + }else if(event.which !== 1){ + return + } + } + if(this.locked || this.mode !== "main"){ + return + } + this.changeSelected(this.linkPrivacy) + open("privacy") + } loading(show){ if(show){ loader.screen.appendChild(this.loaderDiv) - }else{ - loader.screen.removeChild(this.loaderDiv) + }else if(this.loaderDiv.parentNode){ + this.loaderDiv.parentNode.removeChild(this.loaderDiv) } } songsLoaded(songs){ @@ -276,11 +340,19 @@ class CustomSongs{ }else if(selected === this.linkGdriveFolder){ assets.sounds["se_don"].play() this.gdriveFolder() + }else if(selected === this.linkGdriveAccount){ + assets.sounds["se_don"].play() + this.gdriveAccount() + }else if(selected === this.linkPrivacy){ + assets.sounds["se_don"].play() + this.openPrivacy() } } }else if(name === "previous" || name === "next"){ selected.classList.remove("selected") - this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) + do{ + this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) + }while(this.items[this.selected] === this.linkPrivacy && name !== "previous") this.items[this.selected].classList.add("selected") assets.sounds["se_ka"].play() }else if(name === "back" || name === "backEsc"){ @@ -327,6 +399,8 @@ class CustomSongs{ }, 500) } showError(text){ + this.locked = false + this.loading(false) if(this.mode === "error"){ return } @@ -352,6 +426,8 @@ class CustomSongs{ } if(gameConfig.google_credentials.gdrive_enabled){ pageEvents.remove(this.linkGdriveFolder, ["mousedown", "touchstart"]) + pageEvents.remove(this.linkGdriveAccount, ["mousedown", "touchstart"]) + pageEvents.remove(this.linkPrivacy, ["mousedown", "touchstart"]) } pageEvents.remove(this.endButton, ["mousedown", "touchstart"]) pageEvents.remove(this.errorDiv, ["mousedown", "touchstart"]) @@ -363,6 +439,8 @@ class CustomSongs{ delete this.browse delete this.linkLocalFolder delete this.linkGdriveFolder + delete this.linkGdriveAccount + delete this.linkPrivacy delete this.endButton delete this.items delete this.loaderDiv diff --git a/public/src/js/gpicker.js b/public/src/js/gpicker.js index 776f403..4e36279 100644 --- a/public/src/js/gpicker.js +++ b/public/src/js/gpicker.js @@ -131,37 +131,48 @@ class Gpicker{ gapi.client.load("drive", "v3").then(resolve, reject) )) } - getToken(lockedCallback=()=>{}, errorCallback=()=>{}){ - if(this.oauthToken){ - return Promise.resolve() - } + getAuth(errorCallback=()=>{}){ if(!this.auth){ - var authPromise = gapi.auth2.init({ - clientId: this.oauthClientId, - fetch_basic_profile: false, - scope: this.scope - }).then(() => { - this.auth = gapi.auth2.getAuthInstance() - }, e => { - if(e.details){ - errorCallback(strings.gpicker.authError.replace("%s", e.details)) - } - return Promise.reject(e) + return new Promise((resolve, reject) => { + gapi.auth2.init({ + clientId: this.oauthClientId, + fetch_basic_profile: false, + scope: this.scope + }).then(() => { + this.auth = gapi.auth2.getAuthInstance() + resolve(this.auth) + }, e => { + if(e.details){ + var errorStr = strings.gpicker.authError.replace("%s", e.details) + if(/cookie/i.test(e.details)){ + errorStr += "\n\n" + strings.gpicker.cookieError + } + errorCallback(errorStr) + } + reject(e) + }) }) }else{ - var authPromise = Promise.resolve() + return Promise.resolve(this.auth) } - return authPromise.then(() => { - var user = this.auth.currentUser.get() - if(!this.checkScope(user)){ + } + getToken(lockedCallback=()=>{}, errorCallback=()=>{}, force){ + if(this.oauthToken && !force){ + return Promise.resolve() + } + return this.getAuth(errorCallback).then(auth => { + var user = force || auth.currentUser.get() + if(force || !this.checkScope(user)){ lockedCallback(false) - this.auth.signIn().then(user => { + return auth.signIn(force ? { + prompt: "select_account" + } : undefined).then(user => { if(this.checkScope(user)){ lockedCallback(true) }else{ return Promise.reject("cancel") } - }) + }, () => Promise.reject("cancel")) } }) } @@ -173,6 +184,9 @@ class Gpicker{ return false } } + switchAccounts(lockedCallback, errorCallback){ + return this.loadApi().then(() => this.getToken(lockedCallback, errorCallback, true)) + } displayPicker(callback){ var picker = gapi.picker.api new picker.PickerBuilder() diff --git a/public/src/js/main.js b/public/src/js/main.js index 6e9386d..b314147 100644 --- a/public/src/js/main.js +++ b/public/src/js/main.js @@ -1,6 +1,6 @@ addEventListener("error", function(err){ var stack - if("error" in err){ + if("error" in err && err.error){ stack = err.error.stack }else{ stack = err.message + "\n at " + err.filename + ":" + err.lineno + ":" + err.colno diff --git a/public/src/js/strings.js b/public/src/js/strings.js index 234f046..688b58d 100644 --- a/public/src/js/strings.js +++ b/public/src/js/strings.js @@ -931,6 +931,13 @@ var translations = { tw: "註冊", ko: "가입하기" }, + privacy: { + ja: "プライバシー", + en: "Privacy", + cn: "隐私权", + tw: "隱私權", + ko: "개인정보처리방침" + }, registerAccount: { ja: "アカウントを登録", en: "Register account", @@ -1148,6 +1155,13 @@ var translations = { tw: "Google雲端硬碟...", ko: "구글 드라이브..." }, + gdriveAccount: { + ja: "アカウントの切り替え", + en: "Switch Accounts", + cn: "切换帐户", + tw: "切換帳戶", + ko: "계정 전환" + }, dropzone: { ja: "ここにファイルをドロップ", en: "Drop files here", @@ -1193,6 +1207,9 @@ var translations = { }, authError: { en: "Auth error: %s" + }, + cookieError: { + en: "This function requires third party cookies." } } } diff --git a/public/src/views/account.html b/public/src/views/account.html index e493dbb..03f30ba 100644 --- a/public/src/views/account.html +++ b/public/src/views/account.html @@ -37,6 +37,11 @@ +
diff --git a/requirements.txt b/requirements.txt index 7c24c11..21f57ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ -bcrypt==3.1.7 -ffmpy==0.2.2 -Flask==1.1.1 -Flask-Caching==1.8.0 -Flask-Session==0.3.1 +bcrypt==3.2.0 +ffmpy==0.2.3 +Flask==1.1.2 +Flask-Caching==1.9.0 +Flask-Session==0.3.2 Flask-WTF==0.14.3 gunicorn==20.0.4 jsonschema==3.2.0 -pymongo==3.10.1 -redis==3.4.1 -requests==2.23.0 -websockets==7.0 +pymongo==3.11.2 +redis==3.5.3 +requests==2.25.1 +websockets==8.1 diff --git a/server.py b/server.py index 85ee590..665b391 100644 --- a/server.py +++ b/server.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import asyncio import websockets diff --git a/templates/admin.html b/templates/admin.html index f0a8019..d5d8118 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -13,6 +13,7 @@