ImportSongs: Implement Google Drive beta testing

Instances that wish to enable Google Drive support should first enable it to only a small subset of users (100 maximum) to allow the OAuth screen to be verified by Google without hitting the user limit. Minimum level in the config can be set to enable beta testing of this feature and then disabled by setting it to None.

- Add user level assignment screen to the administration panel
- Add privacy policy and links to it in various places
- Add switch accounts link near the Google Drive picker
This commit is contained in:
LoveEevee 2020-12-21 16:02:56 +03:00
parent 665fc57929
commit b8e63c650f
14 changed files with 313 additions and 35 deletions

70
app.py
View File

@ -9,9 +9,10 @@ import re
import requests import requests
import schema import schema
import os import os
import time
from functools import wraps 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_caching import Cache
from flask_session import Session from flask_session import Session
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
@ -113,15 +114,27 @@ def before_request_func():
session.clear() session.clear()
def get_config(): def get_config(credentials=False):
config_out = { config_out = {
'songs_baseurl': config.SONGS_BASEURL, 'songs_baseurl': config.SONGS_BASEURL,
'assets_baseurl': config.ASSETS_BASEURL, 'assets_baseurl': config.ASSETS_BASEURL,
'email': config.EMAIL, 'email': config.EMAIL,
'accounts': config.ACCOUNTS, 'accounts': config.ACCOUNTS,
'custom_js': config.CUSTOM_JS, 'custom_js': config.CUSTOM_JS
'google_credentials': config.GOOGLE_CREDENTIALS
} }
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'): if not config_out.get('songs_baseurl'):
config_out['songs_baseurl'] = ''.join([request.host_url, 'songs']) + '/' config_out['songs_baseurl'] = ''.join([request.host_url, 'songs']) + '/'
@ -342,6 +355,43 @@ def route_admin_songs_id_delete(id):
return redirect('/admin/songs') 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.route('/api/preview')
@app.cache.cached(timeout=15, query_string=True) @app.cache.cached(timeout=15, query_string=True)
def route_api_preview(): def route_api_preview():
@ -400,7 +450,7 @@ def route_api_categories():
@app.route('/api/config') @app.route('/api/config')
@app.cache.cached(timeout=15) @app.cache.cached(timeout=15)
def route_api_config(): def route_api_config():
config = get_config() config = get_config(credentials=True)
return jsonify(config) 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}) 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): def make_preview(song_id, song_type, song_ext, preview):
song_path = 'public/songs/%s/main.%s' % (song_id, song_ext) song_path = 'public/songs/%s/main.%s' % (song_id, song_ext)
prev_path = 'public/songs/%s/preview.mp3' % song_id prev_path = 'public/songs/%s/preview.mp3' % song_id

View File

@ -39,5 +39,6 @@ GOOGLE_CREDENTIALS = {
'gdrive_enabled': False, 'gdrive_enabled': False,
'api_key': '', 'api_key': '',
'oauth_client_id': '', 'oauth_client_id': '',
'project_number': '' 'project_number': '',
'min_level': None
} }

View File

@ -112,10 +112,15 @@ kbd{
margin-right: 0.4em; margin-right: 0.4em;
} }
.center-buttons{ .center-buttons{
display: flex;
justify-content: center;
margin: 1.5em 0; 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{ .center-buttons .taibtn{
margin: 0 0.2em; margin: 0 0.2em;
} }

View File

@ -93,6 +93,11 @@ class Account{
this.inputForms.push(this.accountDel.password) this.inputForms.push(this.accountDel.password)
this.accountDelDiv = this.getElement("accountdel-div") 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.logoutButton = this.getElement("logout-btn")
this.setAltText(this.logoutButton, strings.account.logout) this.setAltText(this.logoutButton, strings.account.logout)
pageEvents.add(this.logoutButton, ["mousedown", "touchstart"], this.onLogout.bind(this)) 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)) pageEvents.add(this.registerButton, ["mousedown", "touchstart"], this.onSwitchMode.bind(this))
this.items.push(this.registerButton) 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){ if(!register){
this.items.push(this.loginButton) this.items.push(this.loginButton)
} }
@ -282,11 +293,17 @@ class Account{
this.onSwitchMode() this.onSwitchMode()
}else if(selected === this.loginButton){ }else if(selected === this.loginButton){
this.onLogin() this.onLogin()
}else if(selected === this.linkPrivacy){
assets.sounds["se_don"].play()
this.openPrivacy()
} }
}else if(name === "previous" || name === "next"){ }else if(name === "previous" || name === "next"){
selected.classList.remove("selected") selected.classList.remove("selected")
this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1)) this.selected = this.mod(this.items.length, this.selected + (name === "next" ? 1 : -1))
this.items[this.selected].classList.add("selected") this.items[this.selected].classList.add("selected")
if(this.items[this.selected] === this.linkPrivacy){
this.items[this.selected].scrollIntoView()
}
assets.sounds["se_ka"].play() assets.sounds["se_ka"].play()
}else if(name === "back"){ }else if(name === "back"){
this.onEnd() 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(){ onLogout(){
if(event){ if(event){
if(event.type === "touchstart"){ if(event.type === "touchstart"){
@ -583,6 +613,7 @@ class Account{
pageEvents.remove(this.customdonResetBtn, ["click", "touchstart"]) pageEvents.remove(this.customdonResetBtn, ["click", "touchstart"])
pageEvents.remove(this.accounPassButton, ["click", "touchstart"]) pageEvents.remove(this.accounPassButton, ["click", "touchstart"])
pageEvents.remove(this.accountDelButton, ["click", "touchstart"]) pageEvents.remove(this.accountDelButton, ["click", "touchstart"])
pageEvents.remove(this.linkPrivacy, ["mousedown", "touchstart"])
pageEvents.remove(this.logoutButton, ["mousedown", "touchstart"]) pageEvents.remove(this.logoutButton, ["mousedown", "touchstart"])
pageEvents.remove(this.saveButton, ["mousedown", "touchstart"]) pageEvents.remove(this.saveButton, ["mousedown", "touchstart"])
for(var i = 0; i < this.inputForms.length; i++){ for(var i = 0; i < this.inputForms.length; i++){
@ -602,6 +633,7 @@ class Account{
delete this.accountDelButton delete this.accountDelButton
delete this.accountDel delete this.accountDel
delete this.accountDelDiv delete this.accountDelDiv
delete this.linkPrivacy
delete this.logoutButton delete this.logoutButton
delete this.saveButton delete this.saveButton
delete this.inputForms delete this.inputForms
@ -615,12 +647,14 @@ class Account{
for(var i = 0; i < this.form.length; i++){ for(var i = 0; i < this.form.length; i++){
pageEvents.remove(this.registerButton, ["keydown", "keyup", "keypress"]) pageEvents.remove(this.registerButton, ["keydown", "keyup", "keypress"])
} }
pageEvents.remove(this.linkPrivacy, ["mousedown", "touchstart"])
delete this.errorDiv delete this.errorDiv
delete this.form delete this.form
delete this.password2 delete this.password2
delete this.remember delete this.remember
delete this.loginButton delete this.loginButton
delete this.registerButton delete this.registerButton
delete this.linkPrivacy
} }
pageEvents.remove(this.endButton, ["mousedown", "touchstart"]) pageEvents.remove(this.endButton, ["mousedown", "touchstart"])
delete this.endButton delete this.endButton

View File

@ -36,7 +36,10 @@ class CustomSongs{
this.linkLocalFolder.parentNode.removeChild(this.linkLocalFolder) this.linkLocalFolder.parentNode.removeChild(this.linkLocalFolder)
} }
var groupGdrive = document.getElementById("group-gdrive")
this.linkGdriveFolder = document.getElementById("link-gdrivefolder") this.linkGdriveFolder = document.getElementById("link-gdrivefolder")
this.linkGdriveAccount = document.getElementById("link-gdriveaccount")
this.linkPrivacy = document.getElementById("link-privacy")
if(gameConfig.google_credentials.gdrive_enabled){ if(gameConfig.google_credentials.gdrive_enabled){
this.setAltText(this.linkGdriveFolder, strings.customSongs.gdriveFolder) this.setAltText(this.linkGdriveFolder, strings.customSongs.gdriveFolder)
pageEvents.add(this.linkGdriveFolder, ["mousedown", "touchstart"], this.gdriveFolder.bind(this)) pageEvents.add(this.linkGdriveFolder, ["mousedown", "touchstart"], this.gdriveFolder.bind(this))
@ -45,8 +48,14 @@ class CustomSongs{
this.linkGdriveFolder.classList.add("selected") this.linkGdriveFolder.classList.add("selected")
this.selected = this.items.length - 1 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{ }else{
this.linkGdriveFolder.parentNode.removeChild(this.linkGdriveFolder) groupGdrive.style.display = "none"
} }
this.endButton = this.getElement("view-end-button") this.endButton = this.getElement("view-end-button")
@ -237,13 +246,67 @@ class CustomSongs{
}else if(e !== "cancel"){ }else if(e !== "cancel"){
return Promise.reject(e) 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){ loading(show){
if(show){ if(show){
loader.screen.appendChild(this.loaderDiv) loader.screen.appendChild(this.loaderDiv)
}else{ }else if(this.loaderDiv.parentNode){
loader.screen.removeChild(this.loaderDiv) this.loaderDiv.parentNode.removeChild(this.loaderDiv)
} }
} }
songsLoaded(songs){ songsLoaded(songs){
@ -276,6 +339,12 @@ class CustomSongs{
}else if(selected === this.linkGdriveFolder){ }else if(selected === this.linkGdriveFolder){
assets.sounds["se_don"].play() assets.sounds["se_don"].play()
this.gdriveFolder() 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"){ }else if(name === "previous" || name === "next"){
@ -327,6 +396,8 @@ class CustomSongs{
}, 500) }, 500)
} }
showError(text){ showError(text){
this.locked = false
this.loading(false)
if(this.mode === "error"){ if(this.mode === "error"){
return return
} }
@ -352,6 +423,8 @@ class CustomSongs{
} }
if(gameConfig.google_credentials.gdrive_enabled){ if(gameConfig.google_credentials.gdrive_enabled){
pageEvents.remove(this.linkGdriveFolder, ["mousedown", "touchstart"]) 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.endButton, ["mousedown", "touchstart"])
pageEvents.remove(this.errorDiv, ["mousedown", "touchstart"]) pageEvents.remove(this.errorDiv, ["mousedown", "touchstart"])
@ -363,6 +436,8 @@ class CustomSongs{
delete this.browse delete this.browse
delete this.linkLocalFolder delete this.linkLocalFolder
delete this.linkGdriveFolder delete this.linkGdriveFolder
delete this.linkGdriveAccount
delete this.linkPrivacy
delete this.endButton delete this.endButton
delete this.items delete this.items
delete this.loaderDiv delete this.loaderDiv

View File

@ -131,37 +131,48 @@ class Gpicker{
gapi.client.load("drive", "v3").then(resolve, reject) gapi.client.load("drive", "v3").then(resolve, reject)
)) ))
} }
getToken(lockedCallback=()=>{}, errorCallback=()=>{}){ getAuth(errorCallback=()=>{}){
if(this.oauthToken){
return Promise.resolve()
}
if(!this.auth){ if(!this.auth){
var authPromise = gapi.auth2.init({ return new Promise((resolve, reject) => {
clientId: this.oauthClientId, gapi.auth2.init({
fetch_basic_profile: false, clientId: this.oauthClientId,
scope: this.scope fetch_basic_profile: false,
}).then(() => { scope: this.scope
this.auth = gapi.auth2.getAuthInstance() }).then(() => {
}, e => { this.auth = gapi.auth2.getAuthInstance()
if(e.details){ resolve(this.auth)
errorCallback(strings.gpicker.authError.replace("%s", e.details)) }, e => {
} if(e.details){
return Promise.reject(e) 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{ }else{
var authPromise = Promise.resolve() return Promise.resolve(this.auth)
} }
return authPromise.then(() => { }
var user = this.auth.currentUser.get() getToken(lockedCallback=()=>{}, errorCallback=()=>{}, force){
if(!this.checkScope(user)){ 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) lockedCallback(false)
this.auth.signIn().then(user => { return auth.signIn(force ? {
prompt: "select_account"
} : undefined).then(user => {
if(this.checkScope(user)){ if(this.checkScope(user)){
lockedCallback(true) lockedCallback(true)
}else{ }else{
return Promise.reject("cancel") return Promise.reject("cancel")
} }
}) }, () => Promise.reject("cancel"))
} }
}) })
} }
@ -173,6 +184,9 @@ class Gpicker{
return false return false
} }
} }
switchAccounts(lockedCallback, errorCallback){
return this.loadApi().then(() => this.getToken(lockedCallback, errorCallback, true))
}
displayPicker(callback){ displayPicker(callback){
var picker = gapi.picker.api var picker = gapi.picker.api
new picker.PickerBuilder() new picker.PickerBuilder()

View File

@ -1,6 +1,6 @@
addEventListener("error", function(err){ addEventListener("error", function(err){
var stack var stack
if("error" in err){ if("error" in err && err.error){
stack = err.error.stack stack = err.error.stack
}else{ }else{
stack = err.message + "\n at " + err.filename + ":" + err.lineno + ":" + err.colno stack = err.message + "\n at " + err.filename + ":" + err.lineno + ":" + err.colno

View File

@ -918,6 +918,13 @@ var translations = {
tw: "註冊", tw: "註冊",
ko: "가입하기" ko: "가입하기"
}, },
privacy: {
ja: "プライバシー",
en: "Privacy",
cn: "隐私权",
tw: "隱私權",
ko: "개인정보처리방침"
},
registerAccount: { registerAccount: {
ja: "アカウントを登録", ja: "アカウントを登録",
en: "Register account", en: "Register account",
@ -1135,6 +1142,13 @@ var translations = {
tw: "Google雲端硬碟...", tw: "Google雲端硬碟...",
ko: "구글 드라이브..." ko: "구글 드라이브..."
}, },
gdriveAccount: {
ja: "アカウントの切り替え",
en: "Switch Accounts",
cn: "切换帐户",
tw: "切換帳戶",
ko: "계정 전환"
},
dropzone: { dropzone: {
ja: "ここにファイルをドロップ", ja: "ここにファイルをドロップ",
en: "Drop files here", en: "Drop files here",
@ -1180,6 +1194,9 @@ var translations = {
}, },
authError: { authError: {
en: "Auth error: %s" en: "Auth error: %s"
},
cookieError: {
en: "This function requires third party cookies."
} }
} }
} }

View File

@ -37,6 +37,11 @@
<input type="password" name="password"> <input type="password" name="password">
</div> </div>
</form> </form>
<div class="center-buttons">
<div>
<div class="privacy-btn taibtn stroke-sub link-btn"></div>
</div>
</div>
</div> </div>
<div id="diag-txt"></div> <div id="diag-txt"></div>
<div class="left-buttons"> <div class="left-buttons">

View File

@ -3,8 +3,14 @@
<div class="view-title stroke-sub"></div> <div class="view-title stroke-sub"></div>
<div class="view-content"></div> <div class="view-content"></div>
<div class="center-buttons"> <div class="center-buttons">
<div id="link-localfolder" class="taibtn stroke-sub link-btn"></div> <div>
<div id="link-gdrivefolder" class="taibtn stroke-sub link-btn"></div> <div id="link-localfolder" class="taibtn stroke-sub link-btn"></div>
</div>
<div id="group-gdrive">
<div id="link-gdrivefolder" class="taibtn stroke-sub link-btn"></div>
<div id="link-gdriveaccount" class="taibtn stroke-sub link-btn"></div>
<div id="link-privacy" class="taibtn stroke-sub link-btn"></div>
</div>
</div> </div>
<div class="view-end-button taibtn stroke-sub"></div> <div class="view-end-button taibtn stroke-sub"></div>
<div class="view-outer shadow-outer" id="dropzone"> <div class="view-outer shadow-outer" id="dropzone">

View File

@ -19,6 +19,7 @@
</div> </div>
<div class="left-buttons"> <div class="left-buttons">
<div class="register-btn taibtn stroke-sub link-btn"></div> <div class="register-btn taibtn stroke-sub link-btn"></div>
<div class="privacy-btn taibtn stroke-sub link-btn"></div>
</div> </div>
<div class="view-end-button taibtn stroke-sub selected"></div> <div class="view-end-button taibtn stroke-sub selected"></div>
</div> </div>

View File

@ -13,6 +13,7 @@
<header> <header>
<div class="nav"> <div class="nav">
<a href="/admin/songs">Songs</a> <a href="/admin/songs">Songs</a>
<a href="/admin/users">Users</a>
</div> </div>
</header> </header>

View File

@ -0,0 +1,21 @@
{% extends 'admin.html' %}
{% block content %}
<h1>Users</h1>
{% for message in get_flashed_messages() %}
<div class="message">{{ message }}</div>
{% endfor %}
<div class="song-form">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-field">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{username}}" required>
<label for="level">Level</label>
<input type="number" id="level" name="level" min="0" max="{{max_level}}" value="{{level}}" required>
</div>
<button type="submit" class="save-song">Save</button>
</form>
</div>
{% endblock %}

38
templates/privacy.txt Normal file
View File

@ -0,0 +1,38 @@
Taiko Web is committed to safeguarding and preserving the privacy of our website visitors and players. This Privacy Policy explains what happens to any personal data that you provide to us or that we collect from you while you play our game or visit our site.
1. The Types and Sources of Data We Collect
1.1 Basic Account Data
When setting up an account, we will collect your user name and password.
1.2 Game High Score Data
When setting a high score in-game, we will collect and store your score. This information may become available to other users when you start a multiplayer game with them.
1.3 Cookies
We use "Cookies", which are text files placed on your computer, and similar technologies to improve the services we are offering and to improve website functionality.
2. Who Has Access to Data
Taiko Web does not sell Personal Data. However, we may share or provide access to each of the categories of Personal Data we collect as necessary for the following business purposes.
2.1 Taiko Web includes multiplayer features, where users can play the game with each other. When playing with other users, please be aware that the information is being made available to them; therefore, you are doing so at your own risk.
{% if integration %}
3. Google Drive Integration
You can use the Google Drive integration to let Taiko Web make your Taiko chart files and other related files available in-game.
Applications that integrate with a Google account must declare their intent by requesting permissions. These permissions to your account must be granted in order for Taiko Web to integrate with Google accounts. Below is a list of these permissions and why they are required. At no time will Taiko Web request or have access to your Google account password.
3.1 "Associate you with your personal info on Google" Permission
Required for Google Sign-In to provide Taiko Web with a non-identifiable user authentication token. No other information provided by this permission is used.
3.2 "See and download all your Google Drive files" Permission
When selecting a folder with the Google Drive file picker, Taiko Web instructs your Browser to recursively download all the files of that folder directly into your computer's memory. Limitation of Google Drive's permission model requires us to request access to all your Google Drive files, however, Taiko Web will only access the selected folder and its children, and only when requested. File parsing is handled locally; none of your Google Drive files is ever sent to our servers or third parties.
{% endif %}{% if config.email %}
{% if integration %}4{% else %}3{% endif %}. Contact Info
You can contact the Taiko Web operator at the email address below.
{{config.email}}
{% endif %}
Revision Date: {{last_modified}}