Initial taiko.su easter-egg site
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"postCreateCommand": ""
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/.git
|
||||
/__pycache__
|
||||
**/__pycache__
|
||||
/.venv
|
||||
/venv
|
||||
/env
|
||||
/backups
|
||||
/.taiko-secret-key
|
||||
/flask_session
|
||||
/public/songs
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
debug-*.png
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
<!--下記の問題を説明してください。 スクリーンショットと診断情報を含めてください。-->
|
||||
<!--Describe the problem you are having below. Please include a screenshot and the diagnostic information.-->
|
||||
|
||||
@@ -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.
|
||||
@@ -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", "-"]
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 11 KiB |