Initial taiko.su easter-egg site

This commit is contained in:
2026-07-28 13:05:53 +08:00
commit ba68c17898
200 changed files with 16098 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"postCreateCommand": ""
}
+14
View File
@@ -0,0 +1,14 @@
/.git
/__pycache__
**/__pycache__
/.venv
/venv
/env
/backups
/.taiko-secret-key
/flask_session
/public/songs
*.pyc
*.pyo
*.log
debug-*.png
+17
View File
@@ -0,0 +1,17 @@
# Auto detect text files and perform LF normalization
* text=auto
# Custom for Visual Studio
*.cs diff=csharp
# Standard to msysgit
*.doc diff=astextplain
*.DOC diff=astextplain
*.docx diff=astextplain
*.DOCX diff=astextplain
*.dot diff=astextplain
*.DOT diff=astextplain
*.pdf diff=astextplain
*.PDF diff=astextplain
*.rtf diff=astextplain
*.RTF diff=astextplain
+3
View File
@@ -0,0 +1,3 @@
<!--下記の問題を説明してください。 スクリーンショットと診断情報を含めてください。-->
<!--Describe the problem you are having below. Please include a screenshot and the diagnostic information.-->
+25
View File
@@ -0,0 +1,25 @@
__pycache__/
*.pyc
.DS_Store
.venv/
.taiko-secret-key
flask_session/
build/
dist/
# build/version artifacts
version.json
# large assets not meant for VCS
public/songs/
public/songs/**
public/preview/
*.log
# local editor project artifacts
taiko-editor/.venv/
taiko-editor/build/
taiko-editor/dist/
taiko-editor/__pycache__/
taiko-editor/build_log.txt
taiko-editor/run_log.txt
@@ -0,0 +1,20 @@
I will optimize the loading process by implementing a **Multi-Threaded Worker Loader**. This involves creating a pool of Web Workers to fetch assets (JavaScript, Audio, Images, Views) in parallel, offloading the network initiation and handling from the main thread.
### Plan:
1. **Create `public/src/js/loader-worker.js`**:
* This worker will handle `fetch` requests for different resource types (`text`, `blob`, `arraybuffer`).
* It will transfer the data back to the main thread (using zero-copy transfer for `ArrayBuffer`).
2. **Modify `public/src/js/loader.js`**:
* **Initialize Worker Pool**: Create a pool of workers (defaulting to 4) in the `Loader` class.
* **Implement `workerFetch(url, type)`**: A method to distribute fetch tasks to the worker pool.
* **Override `ajax(url, ...)`**: Intercept requests for static assets (`src/`, `assets/`, etc.) and route them through `workerFetch`. Keep API calls (`api/`) on the main thread to ensure session stability.
* **Update `loadScript(url)`**: Change it to fetch the script content via `workerFetch` and inject it using a `<script>` tag with inline content. This ensures JS files are also loaded via the "multi-process" mechanism.
* **Update `loadSound` and `RemoteFile` logic**: Since `RemoteFile` uses `loader.ajax`, routing `ajax` to workers will automatically parallelize audio loading.
### Technical Details:
* **Concurrency**: 4 Workers will be used to maximize throughput without overloading the browser's connection limit per domain.
* **Resource Types**:
* **JS/Views**: Fetched as `text`.
* **Images**: Fetched as `blob` -> `URL.createObjectURL`.
* **Audio**: Fetched as `arraybuffer` -> `AudioContext.decodeAudioData`.
@@ -0,0 +1,9 @@
I will implement the requested changes to remove the delete functionality and redirect the upload interface while keeping the upload API intact.
### 1. Frontend: Song Selection Menu (`public/src/js/songselect.js`)
* **Remove Delete Button**: I will remove the "Delete" (削除) button configuration from the `difficultyMenu` buttons array (around lines 313-319). This removes the option from the UI.
* **Redirect Upload Action**: I will modify the handler for the "upload" action (around lines 954-958). Instead of redirecting to the local `/upload/` page, it will redirect to `https://zizhipu.taiko.asia`.
### 2. Backend: API Security (`app.py`)
* **Disable Delete API**: I will modify the `/api/delete` route to return a 403 Forbidden error (or simply pass), ensuring that songs cannot be deleted even if someone calls the API directly.
* **Keep Upload API**: The `/api/upload` route will remain unchanged, preserving the ability to upload songs via API as requested.
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.13.2-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
EXPOSE 80
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app", "--access-logfile", "-"]
+40
View File
@@ -0,0 +1,40 @@
# MongoDB Safety Notes
Default paths:
- App directory: `/srv/taiko-web`
- Persistent data directory: `/srv/taiko-web-data`
- MongoDB data directory: `/srv/taiko-web-data/mongo`
- Redis data directory: `/srv/taiko-web-data/redis`
- Song file directory: `/srv/taiko-web-data/songs`
- MongoDB backup directory: `/srv/taiko-web/backups/mongodb/YYYYMMDD-HHMMSS/`
Safe commands:
```bash
sudo bash setup.sh install
sudo bash setup.sh update
sudo bash setup.sh backup-db
sudo bash setup.sh restore-db /srv/taiko-web/backups/mongodb/YYYYMMDD-HHMMSS/mongodump
sudo bash setup.sh repair
```
`setup.sh update` does not delete MongoDB data. When existing data is detected, it pauses application writers (`taiko-web-app` and/or the `taiko-web` systemd service), keeps MongoDB online, creates a `mongodump` backup, then updates and starts the new app service. If the backup or update fails before the app is replaced, the script attempts to restart the previously running app service.
`setup.sh backup-db` uses the same consistency-first behavior: pause app writes, run `mongodump`, then resume the app service that was running before the backup.
`setup.sh` keeps an existing `.env` file and only appends missing keys. It also excludes `.env` and `backups` from source sync deletion.
Dangerous database reset is explicit only:
```bash
sudo bash setup.sh reset-db
```
The reset command requires typing:
```text
I_UNDERSTAND_THIS_WILL_DELETE_MONGODB_DATA
```
Normal install and update flows must not run `docker compose down -v`, remove Docker volumes, or remove `/srv/taiko-web-data/mongo`.
+68
View File
@@ -0,0 +1,68 @@
# taiko.su
`taiko.su` 是 [taiko.asia](https://taiko.asia) 的彩蛋站点代码仓库。
当前站点提供一个精简的单曲活动入口:页面完成开屏加载和 setup 后,自动进入管理员指定的歌曲;游玩结束后生成并保存彩蛋码,用户领取后返回加载完成页面。
## 功能
- 开屏加载与 setup 完成页
- 管理员指定单首歌曲和固定难度
- 单人游玩与结算
- 彩蛋码生成、持久化及领取
- Token + 彩蛋码双认证 API
- 管理员活动设置与彩蛋码记录
站点版本标识为 `vSU? ??.??.??`
## 运行
项目需要 MongoDB。Redis 为可选依赖,未连接时使用本地文件会话。
```bash
python -m pip install -r requirements.txt
python app.py 34801 -b 127.0.0.1
```
生产环境可使用:
```bash
sudo bash setup.sh
```
首次部署时,脚本会引导创建管理员。管理员入口为 `/1128admin1128`,后台可以选择数据库中已启用的歌曲、固定游玩难度,并设置彩蛋码验证 Token。
## 彩蛋码验证 API
```http
POST /api/easter-eggs/verify
Authorization: Bearer < Token>
Content-Type: application/json
{"code":"AI4-XXXX-XXXX"}
```
也可以通过 `X-Verification-Token` 请求头传递 Token。
- Token 与彩蛋码均正确:返回 HTTP 200 和 `{"success":true}`
- Token 缺失或错误:返回 HTTP 401
- Token 正确但彩蛋码无效:返回 HTTP 200 和 `{"success":false}`
## 数据集合
- 歌曲:`songs`
- 活动设置:`experience_settings`
- 彩蛋码:`easter_egg_codes`
- 管理员:`users` 集合中 `user_level >= 50` 的账号
## 项目范围
本仓库只维护 `taiko.su` 彩蛋站点所需的加载、单曲游玩、彩蛋码和管理员配置功能,不包含歌曲上传、用户中心、排行榜、社交、多人、AI 对战或谱面编辑器。
## 仓库
项目现已迁移至:
```text
https://code.taiko.im/Superories/taiko.su.git
```
+774
View File
@@ -0,0 +1,774 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import re
import secrets
from datetime import datetime
from functools import wraps
import bcrypt
import config
from flask import (
Flask,
abort,
flash,
jsonify,
make_response,
redirect,
render_template,
request,
send_from_directory,
session,
)
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_session import Session
from flask_wtf.csrf import CSRFError, CSRFProtect, generate_csrf
from pymongo import MongoClient
from pymongo.errors import DuplicateKeyError, PyMongoError
from redis import Redis
APP_ROOT = pathlib.Path(__file__).resolve().parent
SONGS_DIR = pathlib.Path(
os.environ.get('TAIKO_WEB_SONGS_DIR', APP_ROOT / 'public' / 'songs')
).resolve()
EXPERIENCE_SETTINGS_ID = 'single-song'
EXPERIENCE_DIFFICULTIES = ('easy', 'normal', 'hard', 'oni', 'ura')
EASTER_EGG_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
EASTER_EGG_RESULT_FIELDS = (
'points',
'good',
'ok',
'bad',
'max_combo',
'drumroll',
'gauge',
)
def take_config(name, default=None, required=False):
if hasattr(config, name):
return getattr(config, name)
if required:
raise ValueError('Missing config option: {}'.format(name))
return default
def load_or_create_secret_key():
configured = os.environ.get('TAIKO_WEB_SECRET_KEY') or take_config('SECRET_KEY')
if isinstance(configured, str) and configured != 'change-me' and len(configured) >= 32:
return configured
secret_path = pathlib.Path(
os.environ.get('TAIKO_WEB_SECRET_KEY_FILE', APP_ROOT / '.taiko-secret-key')
).resolve()
secret_path.parent.mkdir(parents=True, exist_ok=True)
try:
descriptor = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(descriptor, 'w', encoding='ascii') as output:
output.write(secrets.token_hex(32))
output.flush()
os.fsync(output.fileno())
secret = secret_path.read_text(encoding='ascii').strip()
if len(secret) < 32:
raise RuntimeError('TAIKO_WEB_SECRET_KEY must contain at least 32 characters.')
return secret
basedir = take_config('BASEDIR', '/') or '/'
if not basedir.startswith('/'):
basedir = '/' + basedir
if not basedir.endswith('/'):
basedir += '/'
app = Flask(__name__, static_folder=None)
app.secret_key = load_or_create_secret_key()
app.config['WTF_CSRF_CHECK_DEFAULT'] = False
app.jinja_env.globals.setdefault('csrf_token', generate_csrf)
redis_config = dict(take_config('REDIS', {}, required=True))
redis_host = os.environ.get('TAIKO_WEB_REDIS_HOST') or redis_config.get(
'CACHE_REDIS_HOST', '127.0.0.1'
)
redis_client = Redis(
host=redis_host,
port=redis_config.get('CACHE_REDIS_PORT', 6379),
password=redis_config.get('CACHE_REDIS_PASSWORD'),
db=redis_config.get('CACHE_REDIS_DB') or 0,
socket_connect_timeout=1,
socket_timeout=1,
)
try:
redis_client.ping()
app.config.update(
SESSION_TYPE='redis',
SESSION_REDIS=redis_client,
)
except Exception:
app.config.update(
SESSION_TYPE='filesystem',
SESSION_FILE_DIR=str(APP_ROOT / 'flask_session'),
)
Session(app)
csrf = CSRFProtect(app)
limiter = Limiter(
get_remote_address,
app=app,
storage_uri=os.environ.get('REDIS_URI') or 'memory://',
in_memory_fallback_enabled=True,
)
mongo_config = take_config('MONGO', required=True)
client = MongoClient(
host=os.environ.get('TAIKO_WEB_MONGO_HOST') or mongo_config['host']
)
db = client[mongo_config['database']]
def ensure_indexes():
indexes = (
(db.users, 'username', {'unique': True}),
(db.users, 'username_lower', {'unique': True}),
(db.songs, 'id', {'unique': True}),
(db.easter_egg_codes, 'code', {'unique': True}),
(db.easter_egg_codes, [('created_at', -1)], {}),
(db.easter_egg_codes, [('song_id', 1), ('created_at', -1)], {}),
)
for collection, keys, options in indexes:
try:
collection.create_index(keys, **options)
except PyMongoError:
app.logger.exception('Unable to create index on %s', collection.name)
ensure_indexes()
def site_path(path=''):
return basedir + path.lstrip('/')
def safe_int(value, default=None):
if value in (None, ''):
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def safe_float(value, default=None):
if value in (None, ''):
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def route_song_id(value):
value = str(value)
return int(value) if re.fullmatch(r'\d+', value) else value
def safe_lang_map(value):
return value if isinstance(value, dict) else {}
def normalize_courses(value):
value = value if isinstance(value, dict) else {}
output = {}
for difficulty in EXPERIENCE_DIFFICULTIES:
course = value.get(difficulty)
output[difficulty] = {
'stars': safe_int(course.get('stars'), 0),
'branch': bool(course.get('branch')),
} if isinstance(course, dict) else None
return output
def serialize_song(song):
if not song or song.get('id') is None:
return None
output = {
'id': song.get('id'),
'title': song.get('title') or 'Untitled',
'subtitle': song.get('subtitle') or '',
'title_lang': safe_lang_map(song.get('title_lang')),
'subtitle_lang': safe_lang_map(song.get('subtitle_lang')),
'courses': normalize_courses(song.get('courses')),
'type': song.get('type') if song.get('type') in ('tja', 'osu') else 'tja',
'music_type': song.get('music_type') or 'mp3',
'offset': safe_float(song.get('offset'), 0),
'preview': 0,
'volume': safe_float(song.get('volume'), 1),
'hash': song.get('hash') or song.get('title') or str(song.get('id')),
'category_id': song.get('category_id'),
'lyrics': False,
'song_type': song.get('song_type') or '',
}
category = db.categories.find_one({'id': output['category_id']})
output['category'] = category.get('title') if category else None
skin = db.song_skins.find_one({'id': song.get('skin_id')}) if song.get('skin_id') else None
output['song_skin'] = {
key: value
for key, value in (skin or {}).items()
if key not in ('_id', 'id')
}
return output
def normalize_admin_song(song):
output = serialize_song(song)
if not output:
return None
output['enabled'] = bool(song.get('enabled'))
return output
def get_version():
output = {
'commit': None,
'commit_short': '',
'version': None,
'url': take_config('URL', ''),
}
version_path = APP_ROOT / 'version.json'
if version_path.is_file():
try:
stored = json.loads(version_path.read_text(encoding='utf-8'))
except (OSError, ValueError):
stored = {}
for key in output:
if stored.get(key):
output[key] = stored[key]
return output
def absolute_asset_url(filename):
origin = (
os.environ.get('TAIKO_WEB_SITE_URL')
or take_config('SITE_URL')
or request.host_url.rstrip('/')
)
return origin.rstrip('/') + site_path('assets/' + filename)
def index_seo():
canonical = request.host_url.rstrip('/') + basedir
return {
'lang': 'cn',
'html_lang': 'zh-Hans',
'title': 'taiko.su | taiko.asia 彩蛋站点',
'heading': 'taiko.su 单曲活动',
'description': 'taiko.asia 的彩蛋站点。完成管理员指定的歌曲即可领取彩蛋码。',
'keywords': 'taiko.su, taiko.asia, 太鼓, 音乐游戏, 彩蛋码',
'disclaimer': '非官方粉丝制作的网页音乐游戏。',
'image_url': absolute_asset_url('img/favicon-512.png'),
'canonical_url': canonical,
'default_url': canonical,
'og_locale': 'zh_CN',
'alternate_urls': [],
}
def get_config():
songs_baseurl = take_config('SONGS_BASEURL', '/songs/', required=True)
assets_baseurl = take_config('ASSETS_BASEURL', '/assets/', required=True)
if not re.match(r'^https?://', songs_baseurl) and not songs_baseurl.startswith('/'):
songs_baseurl = site_path(songs_baseurl)
if not re.match(r'^https?://', assets_baseurl) and not assets_baseurl.startswith('/'):
assets_baseurl = site_path(assets_baseurl)
return {
'basedir': basedir,
'songs_baseurl': songs_baseurl,
'assets_baseurl': assets_baseurl,
'_version': get_version(),
}
def get_user_level(user):
return safe_int((user or {}).get('user_level'), 0)
def check_password(user, password):
try:
return bool(user) and bcrypt.checkpw(password, user.get('password', b''))
except (TypeError, ValueError):
return False
def ensure_user_session_id(user):
session_id = user.get('session_id') if user else None
if session_id:
return session_id
session_id = secrets.token_hex(24)
db.users.update_one({'_id': user['_id']}, {'$set': {'session_id': session_id}})
return session_id
def current_admin(minimum_level=50):
username = session.get('username')
if not username:
return None
user = db.users.find_one({'username': username})
return user if get_user_level(user) >= minimum_level else None
def admin_required(level=50):
def decorator(function):
@wraps(function)
def wrapper(*args, **kwargs):
if not current_admin(level):
return abort(403)
return function(*args, **kwargs)
return wrapper
return decorator
def get_experience_settings():
return db.experience_settings.find_one({'_id': EXPERIENCE_SETTINGS_ID}) or {
'_id': EXPERIENCE_SETTINGS_ID
}
def available_difficulties(song):
courses = normalize_courses((song or {}).get('courses'))
return [
difficulty
for difficulty in EXPERIENCE_DIFFICULTIES
if courses.get(difficulty)
]
def resolve_difficulty(song, requested=None):
available = available_difficulties(song)
if requested in available:
return requested
for preferred in ('oni', 'hard', 'normal', 'easy', 'ura'):
if preferred in available:
return preferred
return None
def build_experience_payload(issue_ticket=False):
settings_doc = get_experience_settings()
song_id = settings_doc.get('song_id')
song = db.songs.find_one({'id': song_id, 'enabled': True}) if song_id is not None else None
difficulty = resolve_difficulty(song, settings_doc.get('difficulty'))
public_song = serialize_song(song) if song and difficulty else None
output = {
'status': 'ok',
'configured': bool(public_song and difficulty),
'song': public_song,
'difficulty': difficulty,
}
if not output['configured']:
output['message'] = '管理员尚未配置可游玩的歌曲。'
return output
if issue_ticket:
ticket = secrets.token_urlsafe(32)
session.pop('experience_claim', None)
session['experience_ticket'] = ticket
session['experience_song_id'] = public_song['id']
session['experience_difficulty'] = difficulty
output['ticket'] = ticket
return output
def hash_verification_token(token):
return hashlib.sha256(token.encode('utf-8')).hexdigest()
def verification_token_matches(token, settings_doc):
expected = settings_doc.get('verification_token_hash')
return bool(
isinstance(token, str)
and token
and isinstance(expected, str)
and len(expected) == 64
and secrets.compare_digest(hash_verification_token(token), expected)
)
def request_verification_token():
header = request.headers.get('Authorization', '').strip()
if header.lower().startswith('bearer '):
return header[7:].strip()
return request.headers.get('X-Verification-Token', '').strip()
def normalize_easter_egg_code(value):
value = str(value or '').strip().upper()
return value if re.fullmatch(
r'AI4-[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}', value
) else None
def sanitize_result(value):
value = value if isinstance(value, dict) else {}
output = {}
for field in EASTER_EGG_RESULT_FIELDS:
try:
number = int(float(value.get(field)))
except (TypeError, ValueError, OverflowError):
number = 0
output[field] = max(0, min(number, 1000000000))
return output
def create_easter_egg_code(song, difficulty, result):
for _attempt in range(32):
left = ''.join(secrets.choice(EASTER_EGG_CODE_ALPHABET) for _ in range(4))
right = ''.join(secrets.choice(EASTER_EGG_CODE_ALPHABET) for _ in range(4))
document = {
'code': 'AI4-{}-{}'.format(left, right),
'song_id': song['id'],
'song_hash': song.get('hash') or song.get('title'),
'song_title': song.get('title') or 'Untitled',
'difficulty': difficulty,
'result': sanitize_result(result),
'created_at': datetime.utcnow(),
'verified_count': 0,
}
try:
db.easter_egg_codes.insert_one(document)
return document
except DuplicateKeyError:
continue
raise RuntimeError('Unable to allocate an easter egg code.')
@app.before_request
def protect_admin_writes_and_session():
endpoint = request.endpoint or ''
if (
request.method in ('POST', 'PUT', 'PATCH', 'DELETE')
and (endpoint == 'admin_login' or endpoint.startswith('admin_'))
):
csrf.protect()
username = session.get('username')
session_id = session.get('session_id')
if username and session_id and not db.users.find_one({
'username': username,
'session_id': session_id,
}, {'_id': True}):
session.clear()
@app.after_request
def secure_headers(response):
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
if request.path.startswith(site_path('admin')) or request.path == site_path('1128admin1128'):
response.headers['Cache-Control'] = 'private, no-store, max-age=0'
return response
@app.errorhandler(CSRFError)
def csrf_error(_error):
return jsonify({'status': 'error', 'message': 'invalid_csrf'}), 400
@app.errorhandler(429)
def rate_limit_error(_error):
return jsonify({'success': False, 'error': 'rate_limited'}), 429
@app.get(basedir)
def index():
return render_template(
'index.html',
config=get_config(),
version=get_version(),
seo=index_seo(),
)
@app.route(site_path('1128admin1128'), methods=['GET', 'POST'])
@limiter.limit('10 per minute', methods=['POST'])
def admin_login():
if request.method == 'GET' and current_admin():
return redirect(site_path('admin/experience'))
username = ''
if request.method == 'POST':
username = (request.form.get('username') or '').strip()
password = (request.form.get('password') or '').encode('utf-8')
user = db.users.find_one({'username_lower': username.lower()})
if get_user_level(user) >= 50 and check_password(user, password):
session.clear()
session['username'] = user['username']
session['session_id'] = ensure_user_session_id(user)
session.permanent = True
db.users.update_one(
{'_id': user['_id']},
{'$set': {'last_login_at': datetime.utcnow()}},
)
return redirect(site_path('admin/experience'))
flash('管理员账号或密码错误。', 'error')
return render_template(
'admin_login.html',
config=get_config(),
username=username,
)
@app.get(site_path('admin'))
@app.get(site_path('admin/overview'))
@admin_required()
def admin_root():
return redirect(site_path('admin/experience'))
@app.route(site_path('admin/experience'), methods=['GET', 'POST'])
@admin_required()
def admin_experience():
admin = current_admin()
if request.method == 'POST':
song_id = (request.form.get('song_id') or '').strip()
difficulty = (request.form.get('difficulty') or '').strip().lower()
verification_token = request.form.get('verification_token') or ''
clear_token = bool(request.form.get('clear_token'))
updates = {
'updated_at': datetime.utcnow(),
'updated_by': admin['username'],
}
unsets = {}
if song_id:
song = db.songs.find_one({
'id': route_song_id(song_id),
'enabled': True,
})
if not song:
flash('请选择一首已启用的歌曲。', 'error')
return redirect(site_path('admin/experience'))
if difficulty not in available_difficulties(song):
flash('所选歌曲不包含这个难度。', 'error')
return redirect(site_path('admin/experience'))
updates.update(song_id=song['id'], difficulty=difficulty)
else:
unsets.update(song_id='', difficulty='')
if clear_token:
unsets.update(
verification_token_hash='',
verification_token_updated_at='',
)
elif verification_token:
if not 16 <= len(verification_token) <= 512:
flash('验证 Token 长度必须在 16 到 512 个字符之间。', 'error')
return redirect(site_path('admin/experience'))
updates.update(
verification_token_hash=hash_verification_token(verification_token),
verification_token_updated_at=datetime.utcnow(),
)
update = {'$set': updates}
if unsets:
update['$unset'] = unsets
db.experience_settings.update_one(
{'_id': EXPERIENCE_SETTINGS_ID},
update,
upsert=True,
)
flash('单曲活动设置已保存。')
return redirect(site_path('admin/experience'))
settings_doc = get_experience_settings()
songs = [
normalize_admin_song(song)
for song in db.songs.find({'enabled': True}).sort([('title', 1), ('id', 1)])
]
codes = list(db.easter_egg_codes.find({}).sort('created_at', -1).limit(200))
return render_template(
'admin_experience.html',
admin=admin,
config=get_config(),
settings_doc=settings_doc,
songs=songs,
codes=codes,
code_count=db.easter_egg_codes.count_documents({}),
token_configured=bool(settings_doc.get('verification_token_hash')),
)
@app.post(site_path('admin/logout'))
@admin_required()
def admin_logout():
session.clear()
return redirect(site_path('1128admin1128'))
@app.get(site_path('api/config'))
def api_config():
return jsonify(get_config())
@app.get(site_path('api/categories'))
def api_categories():
categories = []
for category in db.categories.find({}, {'_id': False}):
item = dict(category)
categories.append(item)
return jsonify(categories)
@app.get(site_path('api/experience'))
@limiter.limit('60 per minute')
def api_experience():
response = jsonify(build_experience_payload(issue_ticket=True))
response.headers['Cache-Control'] = 'private, no-store, max-age=0'
return response
@app.post(site_path('api/easter-eggs/claim'))
@limiter.limit('30 per minute')
def api_easter_egg_claim():
payload = request.get_json(silent=True) or {}
ticket = payload.get('ticket')
if not isinstance(ticket, str) or not ticket:
return jsonify({'status': 'error', 'message': 'invalid_ticket'}), 400
ticket_hash = hashlib.sha256(ticket.encode('utf-8')).hexdigest()
previous = session.get('experience_claim') or {}
if (
previous.get('ticket_hash') == ticket_hash
and normalize_easter_egg_code(previous.get('code'))
):
return jsonify(status='ok', success=True, code=previous['code'])
expected = session.get('experience_ticket')
if not isinstance(expected, str) or not secrets.compare_digest(ticket, expected):
return jsonify({'status': 'error', 'message': 'invalid_ticket'}), 400
settings_doc = get_experience_settings()
song = db.songs.find_one({
'id': settings_doc.get('song_id'),
'enabled': True,
})
difficulty = resolve_difficulty(song, settings_doc.get('difficulty'))
if (
not song
or not difficulty
or song['id'] != session.get('experience_song_id')
or difficulty != session.get('experience_difficulty')
):
return jsonify({'status': 'error', 'message': 'experience_changed'}), 409
try:
document = create_easter_egg_code(song, difficulty, payload.get('result'))
except (PyMongoError, RuntimeError):
app.logger.exception('Unable to create an easter egg code')
return jsonify({'status': 'error', 'message': 'claim_failed'}), 500
for key in (
'experience_ticket',
'experience_song_id',
'experience_difficulty',
):
session.pop(key, None)
session['experience_claim'] = {
'ticket_hash': ticket_hash,
'code': document['code'],
}
return jsonify(status='ok', success=True, code=document['code'])
@app.route(site_path('api/easter-eggs/verify'), methods=['POST', 'OPTIONS'])
@limiter.limit('120 per minute')
def api_easter_egg_verify():
def response(payload, status=200):
output = jsonify(payload)
output.status_code = status
output.headers['Access-Control-Allow-Origin'] = '*'
output.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
output.headers['Access-Control-Allow-Headers'] = (
'Authorization, Content-Type, X-Verification-Token'
)
output.headers['Cache-Control'] = 'no-store'
return output
if request.method == 'OPTIONS':
return response({}, 204)
settings_doc = get_experience_settings()
if not settings_doc.get('verification_token_hash'):
return response({
'success': False,
'error': 'verification_not_configured',
}, 503)
if not verification_token_matches(request_verification_token(), settings_doc):
return response({
'success': False,
'error': 'invalid_credentials',
}, 401)
payload = request.get_json(silent=True) or {}
code = normalize_easter_egg_code(payload.get('code'))
document = db.easter_egg_codes.find_one({'code': code}) if code else None
if not document:
return response({'success': False})
db.easter_egg_codes.update_one(
{'_id': document['_id']},
{
'$inc': {'verified_count': 1},
'$set': {'last_verified_at': datetime.utcnow()},
},
)
created_at = document.get('created_at')
return response({
'success': True,
'code': document['code'],
'song_id': document.get('song_id'),
'song_title': document.get('song_title'),
'difficulty': document.get('difficulty'),
'claimed_at': (
created_at.isoformat(timespec='seconds') + 'Z'
if isinstance(created_at, datetime)
else None
),
})
def cache_static(response, seconds):
output = make_response(response)
output.headers['Cache-Control'] = 'public, max-age={}'.format(seconds)
return output
@app.get(site_path('src/<path:ref>'))
def source_file(ref):
return cache_static(send_from_directory(APP_ROOT / 'public' / 'src', ref), 3600)
@app.get(site_path('assets/<path:ref>'))
def asset_file(ref):
return cache_static(send_from_directory(APP_ROOT / 'public' / 'assets', ref), 3600)
@app.get(site_path('songs/<path:ref>'))
def song_file(ref):
return cache_static(send_from_directory(SONGS_DIR, ref), 604800)
@app.get(site_path('manifest.json'))
def manifest_file():
return cache_static(
send_from_directory(APP_ROOT / 'public', 'manifest.json'),
3600,
)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description='Run the single-song Taiko event.')
parser.add_argument('port', type=int, nargs='?', default=34801)
parser.add_argument('-b', '--bind-address', default='localhost')
parser.add_argument('-d', '--debug', action='store_true')
arguments = parser.parse_args()
app.run(
host=arguments.bind_address,
port=arguments.port,
debug=arguments.debug,
)
+24
View File
@@ -0,0 +1,24 @@
# Public URL paths. Keep the trailing slash.
BASEDIR = '/'
ASSETS_BASEURL = '/assets/'
SONGS_BASEURL = '/songs/'
# MongoDB stores songs, the activity configuration and easter-egg codes.
MONGO = {
'host': ['127.0.0.1:27017'],
'database': 'taiko',
}
# Redis is used for server-side sessions. The app falls back to local files.
REDIS = {
'CACHE_REDIS_HOST': '127.0.0.1',
'CACHE_REDIS_PORT': 6379,
'CACHE_REDIS_PASSWORD': None,
'CACHE_REDIS_DB': 0,
}
# Replace in production or provide TAIKO_WEB_SECRET_KEY.
SECRET_KEY = 'change-me'
URL = 'https://code.taiko.im/Superories/taiko.su/'
SITE_URL = 'https://taiko.su'
+19
View File
@@ -0,0 +1,19 @@
BASEDIR = '/'
ASSETS_BASEURL = '/assets/'
SONGS_BASEURL = '/songs/'
MONGO = {
'host': ['127.0.0.1:27017'],
'database': 'taiko',
}
REDIS = {
'CACHE_REDIS_HOST': '127.0.0.1',
'CACHE_REDIS_PORT': 6379,
'CACHE_REDIS_PASSWORD': None,
'CACHE_REDIS_DB': 0,
}
SECRET_KEY = 'change-me'
URL = 'https://code.taiko.im/Superories/taiko.su/'
SITE_URL = 'https://taiko.su'
+48
View File
@@ -0,0 +1,48 @@
services:
app:
build:
context: .
container_name: taiko-web-app
depends_on:
mongo:
condition: service_started
redis:
condition: service_healthy
environment:
PYTHONUNBUFFERED: "1"
TAIKO_WEB_MONGO_HOST: mongo:27017
TAIKO_WEB_REDIS_HOST: redis
REDIS_URI: redis://redis:6379/0
TAIKO_WEB_SONGS_DIR: /data/songs
TAIKO_WEB_SECRET_KEY_FILE: /run/secrets/taiko-web-secret-key
ports:
- "80:80"
restart: unless-stopped
volumes:
- ./config.py:/app/config.py:ro
- ./.taiko-secret-key:/run/secrets/taiko-web-secret-key:ro
- ${TAIKO_WEB_DATA_DIR:-/srv/taiko-web-data}/songs:/data/songs
mongo:
container_name: taiko-web-mongo
image: mongo:7.0
restart: unless-stopped
volumes:
- ${TAIKO_WEB_DATA_DIR:-/srv/taiko-web-data}/mongo:/data/db
redis:
command:
- redis-server
- --appendonly
- "yes"
container_name: taiko-web-redis
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
start_period: 5s
volumes:
- ${TAIKO_WEB_DATA_DIR:-/srv/taiko-web-data}/redis:/data
+18
View File
@@ -0,0 +1,18 @@
import multiprocessing
import os
bind = os.getenv("TAIKO_WEB_BIND", "0.0.0.0:80")
worker_class = "gthread"
workers = int(
os.getenv(
"TAIKO_WEB_GUNICORN_WORKERS",
max(1, min(2, multiprocessing.cpu_count())),
)
)
threads = int(os.getenv("TAIKO_WEB_GUNICORN_THREADS", "4"))
timeout = int(os.getenv("TAIKO_WEB_GUNICORN_TIMEOUT", "60"))
graceful_timeout = int(os.getenv("TAIKO_WEB_GUNICORN_GRACEFUL_TIMEOUT", "30"))
keepalive = int(os.getenv("TAIKO_WEB_GUNICORN_KEEPALIVE", "5"))
max_requests = int(os.getenv("TAIKO_WEB_GUNICORN_MAX_REQUESTS", "2000"))
max_requests_jitter = int(os.getenv("TAIKO_WEB_GUNICORN_MAX_REQUESTS_JITTER", "200"))
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More