10 Commits

10 changed files with 277 additions and 38 deletions

View File

@@ -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.

View File

@@ -33,6 +33,55 @@ http://<服务器IP>/
- 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80` - 创建 `systemd` 服务,使用 `gunicorn` 直接监听 `0.0.0.0:80`
- 开放防火墙 `80/tcp`(如系统启用了 `ufw` - 开放防火墙 `80/tcp`(如系统启用了 `ufw`
## 全命令部署(使用 Docker 部署 MongoDB
适用系统Ubuntu 20.04+/22.04+/24.04(需 rootMongoDB 通过 Docker 启动,其余步骤照常。
1. 安装 Docker 并启动:
```bash
sudo apt update
sudo apt install -y docker.io
sudo systemctl enable --now docker
```
2. 启动 MongoDB 容器(持久化到 `/srv/taiko-web-mongo`,监听 `27017`
```bash
sudo mkdir -p /srv/taiko-web-mongo
sudo docker run -d \
--name taiko-web-mongo \
--restart unless-stopped \
-v /srv/taiko-web-mongo:/data/db \
-p 27017:27017 \
mongo:6
```
如需开启认证,可加上:
```bash
-e MONGO_INITDB_ROOT_USERNAME=<用户名> -e MONGO_INITDB_ROOT_PASSWORD=<强密码>
```
并在应用侧通过环境变量指定 Host
```bash
export TAIKO_WEB_MONGO_HOST=127.0.0.1:27017
```
3. 安装并启动 Redis照常
```bash
sudo apt install -y redis-server
sudo systemctl enable --now redis-server
```
4. 准备项目与虚拟环境(照常):
```bash
sudo mkdir -p /srv/taiko-web
sudo rsync -a --delete --exclude '.git' --exclude '.venv' . /srv/taiko-web/
sudo python3 -m venv /srv/taiko-web/.venv
sudo /srv/taiko-web/.venv/bin/pip install -U pip
sudo /srv/taiko-web/.venv/bin/pip install -r /srv/taiko-web/requirements.txt
sudo cp /srv/taiko-web/config.example.py /srv/taiko-web/config.py
```
5. 赋予 80 端口绑定权限并启动:
```bash
sudo setcap 'cap_net_bind_service=+ep' /srv/taiko-web/.venv/bin/python3
export TAIKO_WEB_MONGO_HOST=${TAIKO_WEB_MONGO_HOST:-127.0.0.1:27017}
sudo /srv/taiko-web/.venv/bin/gunicorn -b 0.0.0.0:80 app:app
```
## 手动部署(可选) ## 手动部署(可选)
1. 安装依赖: 1. 安装依赖:
@@ -94,3 +143,30 @@ docker run --detach \
--- ---
如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。 如需将监听接口改为仅内网或增加并发工作数(例如 `--workers 4`),可在 `setup.sh` 或 `systemd` 服务中调整。
## 歌曲类型Type
- 可选枚举:
- 01 Pop
- 02 Anime
- 03 Vocaloid
- 04 Children and Folk
- 05 Variety
- 06 Classical
- 07 Game Music
- 08 Live Festival Mode
- 09 Namco Original
- 10 Taiko Towers
- 11 Dan Dojo
### 上传要求
- 上传表单新增必填字段 `song_type`,取值为上述枚举之一
- 成功后将写入 MongoDB `songs.song_type`
### API 扩展
- `GET /api/songs?type=<歌曲类型>` 按类型过滤返回启用歌曲
- 示例:`/api/songs?type=02%20Anime`
- 返回项包含 `song_type` 字段
### 前端切换
- 在歌曲选择页顶部显示当前歌曲类型标签
- 使用左右跳转Shift+左右或肩键)自动切换类型并刷新列表

61
app.py
View File

@@ -33,6 +33,7 @@ from flask_session import Session
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
from ffmpy import FFmpeg from ffmpy import FFmpeg
from pymongo import MongoClient from pymongo import MongoClient
from pymongo.errors import DuplicateKeyError
from redis import Redis from redis import Redis
def take_config(name, required=False): def take_config(name, required=False):
@@ -44,6 +45,19 @@ def take_config(name, required=False):
return None return None
app = Flask(__name__) app = Flask(__name__)
SONG_TYPES = [
"01 Pop",
"02 Anime",
"03 Vocaloid",
"04 Children and Folk",
"05 Variety",
"06 Classical",
"07 Game Music",
"08 Live Festival Mode",
"09 Namco Original",
"10 Taiko Towers",
"11 Dan Dojo",
]
def get_remote_address() -> str: def get_remote_address() -> str:
return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1" return flask.request.headers.get("CF-Connecting-IP") or flask.request.headers.get("X-Forwarded-For") or flask.request.remote_addr or "127.0.0.1"
@@ -89,6 +103,7 @@ sess.init_app(app)
db = client[take_config('MONGO', required=True)['database']] db = client[take_config('MONGO', required=True)['database']]
db.users.create_index('username', unique=True) db.users.create_index('username', unique=True)
db.songs.create_index('id', unique=True) db.songs.create_index('id', unique=True)
db.songs.create_index('song_type')
db.scores.create_index('username') db.scores.create_index('username')
@@ -479,7 +494,13 @@ def route_api_preview():
@app.route(basedir + 'api/songs') @app.route(basedir + 'api/songs')
@app.cache.cached(timeout=15) @app.cache.cached(timeout=15)
def route_api_songs(): def route_api_songs():
songs = list(db.songs.find({'enabled': True}, {'_id': False, 'enabled': False})) type_q = flask.request.args.get('type')
query = {'enabled': True}
if type_q:
if type_q not in SONG_TYPES:
return abort(400)
query['song_type'] = type_q
songs = list(db.songs.find(query, {'_id': False, 'enabled': False}))
for song in songs: for song in songs:
if song['maker_id']: if song['maker_id']:
if song['maker_id'] == 0: if song['maker_id'] == 0:
@@ -852,17 +873,32 @@ def upload_file():
db_entry['enabled'] = True db_entry['enabled'] = True
pprint.pprint(db_entry) pprint.pprint(db_entry)
# mongoDBにデータをぶち込む # 必要な歌曲类型
client['taiko']["songs"].insert_one(db_entry) song_type = flask.request.form.get('song_type')
if not song_type or song_type not in SONG_TYPES:
return flask.jsonify({'error': 'invalid_song_type'})
db_entry['song_type'] = song_type
# mongoDBにデータをぶち込む重複IDは部分更新で上書きし、_id を不変に保つ)
coll = client['taiko']["songs"]
try:
coll.insert_one(db_entry)
except DuplicateKeyError:
coll.update_one({"id": db_entry["id"]}, {"$set": db_entry}, upsert=True)
# キャッシュ削除(/api/songs # キャッシュ削除(/api/songs
try: try:
app.cache.delete_memoized(route_api_songs) app.cache.delete_memoized(route_api_songs)
except Exception: except Exception:
pass pass
# ディレクトリを作成 base_env = os.getenv("TAIKO_WEB_SONGS_DIR")
target_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs")) / generated_id if base_env:
target_dir.mkdir(parents=True,exist_ok=True) base_dir = pathlib.Path(base_env)
else:
base_dir = pathlib.Path(__file__).resolve().parent / "public" / "songs"
base_dir.mkdir(parents=True, exist_ok=True)
target_dir = base_dir / generated_id
target_dir.mkdir(parents=True, exist_ok=True)
# TJAを保存 # TJAを保存
(target_dir / "main.tja").write_bytes(tja_data) (target_dir / "main.tja").write_bytes(tja_data)
@@ -875,19 +911,8 @@ def upload_file():
return flask.jsonify({'success': True}) return flask.jsonify({'success': True})
@app.route("/api/delete", methods=["POST"]) @app.route("/api/delete", methods=["POST"])
@limiter.limit("1 per day")
def delete(): def delete():
id = flask.request.get_json().get('id') return flask.jsonify({ "success": False, "reason": "Deletion is disabled" }), 403
client["taiko"]["songs"].delete_one({ "id": id })
parent_dir = pathlib.Path(os.getenv("TAIKO_WEB_SONGS_DIR", "public/songs"))
target_dir = parent_dir / id
if not (target_dir.resolve().parents and parent_dir.resolve() in target_dir.resolve().parents):
return flask.jsonify({ "success": False, "reason": "PARENT IS NOT ALLOWED" })
shutil.rmtree(target_dir)
return "成功しました。"
if __name__ == '__main__': if __name__ == '__main__':
import argparse import argparse

Binary file not shown.

View File

@@ -1,4 +1,4 @@
class ImportSongs{ class ImportSongs{
constructor(...args){ constructor(...args){
this.init(...args) this.init(...args)
} }
@@ -322,6 +322,10 @@
songTitle = songTitle.slice(0, uraPos) songTitle = songTitle.slice(0, uraPos)
} }
} }
if(id === "cn" && !meta["titlecn"] && meta.titlezh){
titleLang.cn = meta.titlezh
titleLangAdded = true
}
if(meta["title" + id]){ if(meta["title" + id]){
titleLang[id] = meta["title" + id] titleLang[id] = meta["title" + id]
titleLangAdded = true titleLangAdded = true
@@ -329,6 +333,10 @@
titleLang[id] = this.songTitle[songTitle][id] + ura titleLang[id] = this.songTitle[songTitle][id] + ura
titleLangAdded = true titleLangAdded = true
} }
if(id === "cn" && !meta["subtitlecn"] && meta.subtitlezh){
subtitleLang.cn = meta.subtitlezh
subtitleLangAdded = true
}
if(meta["subtitle" + id]){ if(meta["subtitle" + id]){
subtitleLang[id] = meta["subtitle" + id] subtitleLang[id] = meta["subtitle" + id]
subtitleLangAdded = true subtitleLangAdded = true

View File

@@ -310,12 +310,6 @@ class SongSelect{
iconName: "download", iconName: "download",
iconFill: "#e7cbe1", iconFill: "#e7cbe1",
letterSpacing: 4 letterSpacing: 4
}, {
text: "削除",
fill: "silver",
iconName: "trash",
iconFill: "#111111",
letterSpacing: 4
}] }]
this.optionsList = [strings.none, strings.auto, strings.netplay] this.optionsList = [strings.none, strings.auto, strings.netplay]
@@ -386,6 +380,32 @@ class SongSelect{
} }
this.songSelect = document.getElementById("song-select") this.songSelect = document.getElementById("song-select")
this.songTypes = [
"01 Pop",
"02 Anime",
"03 Vocaloid",
"04 Children and Folk",
"05 Variety",
"06 Classical",
"07 Game Music",
"08 Live Festival Mode",
"09 Namco Original",
"10 Taiko Towers",
"11 Dan Dojo",
]
this.songTypeIndex = Math.max(0, Math.min(this.songTypes.length - 1, +(localStorage.getItem("songTypeIndex") || 0)))
this.typeLabel = document.createElement("div")
this.typeLabel.style.position = "absolute"
this.typeLabel.style.top = "8px"
this.typeLabel.style.left = "12px"
this.typeLabel.style.padding = "4px 8px"
this.typeLabel.style.background = "rgba(0,0,0,0.5)"
this.typeLabel.style.color = "#fff"
this.typeLabel.style.borderRadius = "6px"
this.typeLabel.style.fontSize = "14px"
this.typeLabel.style.zIndex = "10"
this.songSelect.appendChild(this.typeLabel)
this.updateTypeLabel()
var cat = this.songs[this.selectedSong].originalCategory var cat = this.songs[this.selectedSong].originalCategory
this.drawBackground(cat) this.drawBackground(cat)
@@ -536,24 +556,20 @@ class SongSelect{
this.toSession() this.toSession()
}else if(name === "left"){ }else if(name === "left"){
if(shift){ if(shift){
if(!repeat){ if(!repeat){ this.changeType(-1) }
this.categoryJump(-1)
}
}else{ }else{
this.moveToSong(-1) this.moveToSong(-1)
} }
}else if(name === "right"){ }else if(name === "right"){
if(shift){ if(shift){
if(!repeat){ if(!repeat){ this.changeType(1) }
this.categoryJump(1)
}
}else{ }else{
this.moveToSong(1) this.moveToSong(1)
} }
}else if(name === "jump_left" && !repeat){ }else if(name === "jump_left" && !repeat){
this.categoryJump(-1) this.changeType(-1)
}else if(name === "jump_right" && !repeat){ }else if(name === "jump_right" && !repeat){
this.categoryJump(1) this.changeType(1)
}else if(name === "mute" || name === "ctrlGamepad"){ }else if(name === "mute" || name === "ctrlGamepad"){
this.endPreview(true) this.endPreview(true)
this.playBgm(false) this.playBgm(false)
@@ -598,6 +614,23 @@ class SongSelect{
} }
} }
updateTypeLabel(){
this.setAltText(this.typeLabel, this.songTypes[this.songTypeIndex])
}
changeType(delta){
this.songTypeIndex = (this.songTypeIndex + delta + this.songTypes.length) % this.songTypes.length
localStorage.setItem("songTypeIndex", this.songTypeIndex)
this.updateTypeLabel()
var type = encodeURIComponent(this.songTypes[this.songTypeIndex])
loader.ajax("api/songs?type=" + type).then(resp => {
var songs = JSON.parse(resp)
assets.songsDefault = songs
assets.songs = assets.songsDefault
new SongSelect(false, false, this.touchEnabled)
}).catch(() => {})
}
mouseDown(event){ mouseDown(event){
if(event.target === this.selectable || event.target.parentNode === this.selectable){ if(event.target === this.selectable || event.target.parentNode === this.selectable){
this.selectable.focus() this.selectable.focus()
@@ -915,7 +948,7 @@ class SongSelect{
} else if (currentSong.action === "upload") { } else if (currentSong.action === "upload") {
this.playSound("se_don"); this.playSound("se_don");
setTimeout(() => { setTimeout(() => {
window.location.href = "/upload/"; window.location.href = "https://zizhipu.taiko.asia";
}, 100); }, 100);
} else if (currentSong.action === "keijiban") { } else if (currentSong.action === "keijiban") {
this.playSound("se_don"); this.playSound("se_don");
@@ -2926,6 +2959,12 @@ class SongSelect{
var categoryName = song.category var categoryName = song.category
var originalCategory = song.category var originalCategory = song.category
} }
if(!categoryName){
if(song.song_type){
categoryName = song.song_type
originalCategory = song.song_type
}
}
var addedSong = { var addedSong = {
title: title, title: title,
originalTitle: song.title, originalTitle: song.title,
@@ -3068,6 +3107,15 @@ class SongSelect{
getLocalTitle(title, titleLang){ getLocalTitle(title, titleLang){
if(titleLang){ if(titleLang){
if(strings.id === "cn"){
if(titleLang.cn){
return titleLang.cn
}
if(titleLang.ja){
return titleLang.ja
}
return title
}
for(var id in titleLang){ for(var id in titleLang){
if(id === "en" && strings.preferEn && !(strings.id in titleLang) && titleLang.en || id === strings.id && titleLang[id]){ if(id === "en" && strings.preferEn && !(strings.id in titleLang) && titleLang.en || id === strings.id && titleLang[id]){
return titleLang[id] return titleLang[id]

View File

@@ -16,6 +16,21 @@
<label for="file_music">音楽ファイル:</label> <label for="file_music">音楽ファイル:</label>
<input type="file" name="file_music" accept=".ogg,.mp3,.wav" required> <input type="file" name="file_music" accept=".ogg,.mp3,.wav" required>
<label for="song_type">曲のタイプ:</label>
<select name="song_type" required>
<option value="01 Pop">01 Pop</option>
<option value="02 Anime">02 Anime</option>
<option value="03 Vocaloid">03 Vocaloid</option>
<option value="04 Children and Folk">04 Children and Folk</option>
<option value="05 Variety">05 Variety</option>
<option value="06 Classical">06 Classical</option>
<option value="07 Game Music">07 Game Music</option>
<option value="08 Live Festival Mode">08 Live Festival Mode</option>
<option value="09 Namco Original">09 Namco Original</option>
<option value="10 Taiko Towers">10 Taiko Towers</option>
<option value="11 Dan Dojo">11 Dan Dojo</option>
</select>
</form> </form>
<button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button> <button type="button" onclick="uploadFiles()">今すぐ投稿! (1分ほどかかる場合があります)</button>

View File

@@ -29,9 +29,9 @@
<div id="screen" class="pattern-bg"></div> <div id="screen" class="pattern-bg"></div>
<div data-nosnippet id="version"> <div data-nosnippet id="version">
{% if version.version and version.commit_short and version.commit %} {% if version.version and version.commit_short and version.commit %}
<a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="taiko-web ver.{{version.version}} ({{version.commit_short}})">taiko-web ver.{{version.version}} ({{version.commit_short}})</a> <a href="{{version.url}}commit/{{version.commit}}" target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
{% else %} {% else %}
<a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="taiko-web vRAINBOW-BETA4">taiko-web vRAINBOW-BETA4</a> <a target="_blank" rel="noopener" id="version-link" class="stroke-sub" alt="vLightNova 1.0.0">vLightNova 1.0.0</a>
{% endif %} {% endif %}
</div> </div>
<script src="src/js/browsersupport.js?{{version.commit_short}}"></script> <script src="src/js/browsersupport.js?{{version.commit_short}}"></script>

22
tjaf.py
View File

@@ -7,6 +7,8 @@ class Tja:
self.text = text self.text = text
self.title: Optional[str] = None self.title: Optional[str] = None
self.subtitle: Optional[str] = None self.subtitle: Optional[str] = None
self.title_ja: Optional[str] = None
self.subtitle_ja: Optional[str] = None
self.wave: Optional[str] = None self.wave: Optional[str] = None
self.offset: Optional[float] = None self.offset: Optional[float] = None
self.courses: Dict[str, Dict[str, Optional[int]]] = {} self.courses: Dict[str, Dict[str, Optional[int]]] = {}
@@ -25,8 +27,12 @@ class Tja:
val = v.strip() val = v.strip()
if key == "TITLE": if key == "TITLE":
self.title = val or None self.title = val or None
elif key == "TITLEJA":
self.title_ja = val or None
elif key == "SUBTITLE": elif key == "SUBTITLE":
self.subtitle = val or None self.subtitle = val or None
elif key == "SUBTITLEJA":
self.subtitle_ja = val or None
elif key == "WAVE": elif key == "WAVE":
self.wave = val or None self.wave = val or None
elif key == "OFFSET": elif key == "OFFSET":
@@ -73,8 +79,20 @@ class Tja:
"type": "tja", "type": "tja",
"title": self.title, "title": self.title,
"subtitle": self.subtitle, "subtitle": self.subtitle,
"title_lang": {"ja": self.title, "en": None, "cn": None, "tw": None, "ko": None}, "title_lang": {
"subtitle_lang": {"ja": self.subtitle, "en": None, "cn": None, "tw": None, "ko": None}, "ja": self.title_ja or self.title,
"en": None,
"cn": self.title_ja or None,
"tw": None,
"ko": None,
},
"subtitle_lang": {
"ja": self.subtitle_ja or self.subtitle,
"en": None,
"cn": self.subtitle_ja or None,
"tw": None,
"ko": None,
},
"courses": courses_out, "courses": courses_out,
"enabled": False, "enabled": False,
"category_id": None, "category_id": None,

40
update.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -Eeuo pipefail
if [ "${EUID}" -ne 0 ]; then echo "需要 root 权限"; exit 1; fi
SRC_DIR=$(cd "$(dirname "$0")" && pwd)
DEST_DIR=/srv/taiko-web
SONGS_DIR="$DEST_DIR/public/songs"
BACKUP_DIR="$DEST_DIR/.backup_songs_$(date +%Y%m%d_%H%M%S)"
systemctl stop taiko-web || true
if [ -d "$SONGS_DIR" ]; then
mkdir -p "$BACKUP_DIR"
rsync -a "$SONGS_DIR/" "$BACKUP_DIR/" || cp -a "$SONGS_DIR/." "$BACKUP_DIR/"
fi
mkdir -p "$DEST_DIR"
rsync -a --delete \
--exclude '.git' \
--exclude '.venv' \
--exclude 'public/songs' \
"$SRC_DIR/" "$DEST_DIR/"
if [ -x "$DEST_DIR/.venv/bin/pip" ]; then
"$DEST_DIR/.venv/bin/pip" install -U pip
"$DEST_DIR/.venv/bin/pip" install -r "$DEST_DIR/requirements.txt"
fi
chown -R www-data:www-data "$DEST_DIR"
if [ -d "$BACKUP_DIR" ]; then
mkdir -p "$SONGS_DIR"
rsync -a "$BACKUP_DIR/" "$SONGS_DIR/" || cp -a "$BACKUP_DIR/." "$SONGS_DIR/"
fi
systemctl daemon-reload || true
systemctl restart taiko-web || systemctl start taiko-web || true
systemctl is-active --quiet taiko-web