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 |