diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2a3bfd1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +.pytest_cache +database/backups +database/.pipeline.lock +runtime-data +output_arena +*.db.bak +.env + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..49e7343 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +SECRET_KEY=replace-with-a-long-random-secret +ADMIN_TOKEN=replace-with-a-strong-admin-token + +# public: everyone can read; private: VIEWER_TOKEN required +SITE_VISIBILITY=private +VIEWER_TOKEN=replace-with-a-viewer-password + +YRTV_PORT=5001 +YRTV_DATA_PATH=./runtime-data + +BRAND_PRIMARY=Superjacky6 +BRAND_ALIASES=jacky,jk,yr,jacky0987 + +WEB_CONCURRENCY=1 +WEB_THREADS=2 +WEB_TIMEOUT=120 +SLOW_QUERY_THRESHOLD_SECONDS=0.25 + diff --git a/.gitignore b/.gitignore index a655ac1..5f7b557 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ output/ output_arena/ database/backups/ database/.pipeline.lock +runtime-data/ arena/ scripts/ experiment diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..28f8ded --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +LABEL org.opencontainers.image.title="YRTV" +LABEL org.opencontainers.image.description="Private CS2 team analytics by Superjacky6" +LABEL org.opencontainers.image.authors="Superjacky6, jacky, jk, yr, jacky0987" +LABEL org.opencontainers.image.version="2.0.0-beta" + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV YRTV_DATA_DIR=/data + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN chmod +x /app/docker/entrypoint.sh \ + && mkdir -p /data + +VOLUME ["/data"] +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=3)" + +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3df4f24 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +SPDX-License-Identifier: AGPL-3.0-or-later + +YRTV is free software licensed under the GNU Affero General Public License, +version 3 or (at your option) any later version. + +You may copy, modify and redistribute this software under the terms of that +license. If you modify YRTV and make it available over a network, you must make +the complete corresponding source code of your modified version available to +its users under the same license. + +The full license text is available at: +https://www.gnu.org/licenses/agpl-3.0.html + +Copyright (C) 2026 SuperJacky6 + +Project attribution is described in NOTICE.md. + diff --git a/Makefile b/Makefile index 47dd256..476d1fe 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,14 @@ PYTHON := .venv/bin/python -.PHONY: install run test check l1 l2 l3 l3-all pipeline +.PHONY: install bootstrap run test check l1 l2 l3 l3-all pipeline prepare-data docker-up docker-down docker-logs install: python3 -m venv .venv $(PYTHON) -m pip install -r requirements.txt +bootstrap: + $(PYTHON) -m database.bootstrap + run: $(PYTHON) -m web.app @@ -30,3 +33,15 @@ l3-all: pipeline: $(PYTHON) -c "from web.app import create_app; create_app(); from database.job_store import JobStore; from database.pipeline import run_pipeline; job_id = JobStore().create_job('manual_pipeline', created_by='cli'); print('job_id=', job_id); raise SystemExit(0 if run_pipeline(job_id) else 1)" + +prepare-data: + $(PYTHON) -m database.migrate_data --target runtime-data --include-imports + +docker-up: + docker compose up -d --build + +docker-down: + docker compose down + +docker-logs: + docker compose logs -f yrtv diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..b022684 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,19 @@ +# YRTV Attribution Notice + +YRTV was created and maintained by **Superjacky6**. + +Recognized project signatures: + +- Superjacky6 +- jacky +- jk +- yr +- jacky0987 + +Public deployments and redistributed builds must retain the visible YRTV and +Superjacky6 attribution in the application footer, metadata and documentation. + +The application does not claim ownership of imported match data, platform +content, player identities or third-party assets. Operators are responsible for +obtaining appropriate permission before publishing team or player data. + diff --git a/README.md b/README.md index aea5c0f..18f3074 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# YRTV 2.0.0 Beta +# YRTV 2.0.0 Beta · Superjacky6 Self-hosted Edition + +> Created by **Superjacky6** · jacky / jk / yr / jacky0987 YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。 @@ -27,7 +29,7 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 - 111 个日/周/月/季/年度奖项 - 19 条正面/负面/趣味数据发现 - 36 枚地图与 ELO 分段勋章 -- 36 项自动化测试通过 +- 40 项自动化测试通过 - 43 项数据完整性检查通过 数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。 @@ -102,7 +104,40 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 - L1/L2/L3/Web 文件状态和回滚快照可视化 - 后台写操作使用 CSRF 防护 -## 快速开始 +## Docker 自托管 + +推荐给其他战队使用 Docker Compose: + +```bash +cp .env.example .env +# 修改 SECRET_KEY、ADMIN_TOKEN、VIEWER_TOKEN + +docker compose up -d --build +``` + +默认访问 `http://127.0.0.1:5001`。首次空安装: + +1. 使用 `.env` 中的 `ADMIN_TOKEN` 登录 `/admin/`。 +2. 首次启动向导创建战队和 roster。 +3. 从 `/admin/import-match` 上传自己的 `iframe_network.json`。 + +运行数据保存在 `YRTV_DATA_PATH`,默认是 Git 忽略的 +`./runtime-data`。`git pull` 或重建镜像不会覆盖该目录。 + +### 迁移当前数据 + +当前仓库内已有数据迁移到外置目录: + +```bash +make prepare-data +cp .env.example .env +docker compose up -d --build +``` + +迁移使用 SQLite Backup API,不修改源数据库,并生成 +`runtime-data/migration-manifest.json`、SHA256 和 `quick_check` 结果。 + +## Python 开发模式 环境要求: @@ -140,6 +175,10 @@ make run make run # 启动 Flask make check # 编译检查 + 自动化测试 make pipeline # 备份后执行完整 L1 -> L2 -> L3 +make bootstrap # 初始化空运行时数据库 +make prepare-data # 复制现有数据到 runtime-data +make docker-up # 构建并启动容器 +make docker-logs # 查看容器日志 make l1 # 仅构建 L1 make l2 # 仅构建 L2 @@ -242,6 +281,8 @@ Flask services and player profiles ## 数据库治理 - 所有运行路径集中定义在 `database/paths.py` +- `YRTV_DATA_DIR` 可将数据库、备份、锁和导入文件移出代码仓库 +- 未设置 `YRTV_DATA_DIR` 时继续兼容原 `database/` 数据路径 - 完整编排入口为 `database/pipeline.py` - 同一时间只允许一个 pipeline - Pipeline 运行前备份 L1/L2/L3 @@ -252,6 +293,24 @@ Flask services and player profiles - 高频玩家历史、Party、事件和经济查询具有专用索引 - 数据库和目录规则详见 `database/README.md` +## 访问模式 + +- `SITE_VISIBILITY=public`:前台公开,Admin 仍需令牌。 +- `SITE_VISIBILITY=private`:前台需要 `VIEWER_TOKEN`。 +- `/healthz` 始终开放给 Docker 和反向代理健康检查。 +- 后台写操作具有 CSRF 防护。 + +Docker 示例默认使用 `private`。 + +## 品牌与许可证 + +- 主品牌:`Superjacky6` +- 项目签名:`jacky / jk / yr / jacky0987` +- 页面、报告、后台、元数据、CLI 和容器镜像保留品牌署名 +- 比赛事实和统计指标中不写入水印 +- 许可证:AGPL-3.0-or-later +- 署名规则:`NOTICE.md` + ## 数据质量 Admin 数据完整性中心检查: @@ -289,6 +348,8 @@ yrtv/ │ ├── maintenance.py # 备份、恢复、健康检查 │ ├── job_store.py # ETL 作业状态 │ └── pipeline.py # 完整流水线 +├── docker/ +│ └── entrypoint.sh ├── tests/ # 自动化测试 ├── utils/ # JSON 结构分析工具 ├── web/ @@ -297,6 +358,9 @@ yrtv/ │ ├── templates/ │ └── static/ ├── Makefile +├── Dockerfile +├── docker-compose.yml +├── .env.example ├── requirements.txt └── wsgi.py ``` @@ -305,7 +369,7 @@ yrtv/ - 当前主要数据源为 5E iframe 网络响应。 - 不包含自动网页下载器和 Demo parser。 -- 认证仍是单一 Admin Token,适合私人部署,不适合开放注册。 +- 支持独立 Viewer Token 和 Admin Token,但不提供开放注册或多用户权限系统。 - SQLite 适合当前单战队规模,不面向高并发多租户。 - 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A`。 - `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务。 diff --git a/database/README.md b/database/README.md index 1a7024d..b135a0c 100644 --- a/database/README.md +++ b/database/README.md @@ -22,6 +22,9 @@ This directory separates four different responsibilities: 8. Missing metrics are stored as `NULL`, not fabricated zero values. 9. `Admin -> Data Integrity` is the operational source of truth. 10. Web schema changes increment `Config.WEB_SCHEMA_VERSION`. +11. `YRTV_DATA_DIR` owns runtime databases; code schemas always stay in Git. +12. Container and production deployments must keep runtime data outside the + image and source checkout. ## Entry Points @@ -31,11 +34,16 @@ make l2 # Rebuild normalized facts make l3 # Rebuild active-roster features make pipeline # Run L1 -> L2 -> L3 with backup and validation make check # Compile and run tests +make bootstrap # Initialize an empty runtime data root +make prepare-data # Copy legacy data into runtime-data ``` ## Directory Policy -- `L1/`, `L2/`, `L3/`, `Web/`: active code, schema and database. +- Without `YRTV_DATA_DIR`, `L1/`, `L2/`, `L3/`, `Web/` remain the compatible + development runtime. +- With `YRTV_DATA_DIR`, those folders provide code and schemas while databases + live under the configured external directory. - `backups/`: generated rollback snapshots; ignored by Git. - `schema_bkp/`: historical schema research only; not used at runtime. - `L1B/`: reserved demo-parser integration; not used at runtime. diff --git a/database/bootstrap.py b/database/bootstrap.py new file mode 100644 index 0000000..162fb9b --- /dev/null +++ b/database/bootstrap.py @@ -0,0 +1,66 @@ +import sqlite3 + +from database.paths import ( + L1_DB, + L2_DB, + L2_SCHEMA, + L3_DB, + L3_SCHEMA, + RUNTIME_DATA_ROOT, + ensure_runtime_directories, +) + + +def _apply_schema(database_path, schema_path): + db = sqlite3.connect(str(database_path)) + try: + db.execute('PRAGMA foreign_keys = ON') + db.executescript(schema_path.read_text(encoding='utf-8')) + result = db.execute('PRAGMA quick_check').fetchone()[0] + if result != 'ok': + raise RuntimeError( + f'{database_path.name} quick_check failed: {result}' + ) + db.commit() + finally: + db.close() + + +def bootstrap_runtime(): + ensure_runtime_directories() + + l1 = sqlite3.connect(str(L1_DB)) + try: + l1.execute( + """ + CREATE TABLE IF NOT EXISTS raw_iframe_network ( + match_id TEXT PRIMARY KEY, + content TEXT NOT NULL, + processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + l1.commit() + finally: + l1.close() + + _apply_schema(L2_DB, L2_SCHEMA) + _apply_schema(L3_DB, L3_SCHEMA) + + from web.database import initialize_web_db + initialize_web_db() + + return { + 'data_root': str(RUNTIME_DATA_ROOT), + 'l1': str(L1_DB), + 'l2': str(L2_DB), + 'l3': str(L3_DB), + } + + +if __name__ == '__main__': + result = bootstrap_runtime() + print('YRTV runtime initialized') + for key, value in result.items(): + print(f' {key}: {value}') + diff --git a/database/migrate_data.py b/database/migrate_data.py new file mode 100644 index 0000000..3925c40 --- /dev/null +++ b/database/migrate_data.py @@ -0,0 +1,107 @@ +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import shutil +import sqlite3 + +from database.paths import DATABASE_CODE_ROOT, PROJECT_ROOT + + +DATABASE_LAYOUT = { + 'L1': ('L1', 'L1.db'), + 'L2': ('L2', 'L2.db'), + 'L3': ('L3', 'L3.db'), + 'Web': ('Web', 'Web_App.sqlite'), +} + + +def _sha256(path): + digest = hashlib.sha256() + with path.open('rb') as file: + for chunk in iter(lambda: file.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def _sqlite_copy(source, target): + target.parent.mkdir(parents=True, exist_ok=True) + source_db = sqlite3.connect(str(source)) + target_db = sqlite3.connect(str(target)) + try: + source_db.backup(target_db) + result = target_db.execute('PRAGMA quick_check').fetchone()[0] + if result != 'ok': + raise RuntimeError(f'{target.name} quick_check failed: {result}') + finally: + source_db.close() + target_db.close() + + +def migrate_data(source_root, target_root, include_imports=False): + source_root = Path(source_root).expanduser().resolve() + target_root = Path(target_root).expanduser().resolve() + if source_root == target_root: + raise ValueError('Source and target data roots must differ') + + target_root.mkdir(parents=True, exist_ok=True) + manifest = { + 'created_at': datetime.now(timezone.utc).isoformat(), + 'source_root': str(source_root), + 'target_root': str(target_root), + 'databases': {}, + } + for name, (directory, filename) in DATABASE_LAYOUT.items(): + source = source_root / directory / filename + target = target_root / directory / filename + if not source.exists(): + raise FileNotFoundError(f'Missing source database: {source}') + _sqlite_copy(source, target) + manifest['databases'][name] = { + 'path': str(target), + 'size_bytes': target.stat().st_size, + 'sha256': _sha256(target), + 'quick_check': 'ok', + } + + if include_imports: + legacy_imports = PROJECT_ROOT / 'output_arena' + if legacy_imports.exists(): + destination = target_root / 'imports' + shutil.copytree( + legacy_imports, + destination, + dirs_exist_ok=True, + ) + manifest['imports'] = str(destination) + + manifest_path = target_root / 'migration-manifest.json' + manifest_path.write_text( + json.dumps(manifest, ensure_ascii=True, indent=2), + encoding='utf-8', + ) + return manifest_path + + +def _parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument( + '--source', + default=str(DATABASE_CODE_ROOT), + help='Existing runtime data root', + ) + parser.add_argument('--target', required=True) + parser.add_argument('--include-imports', action='store_true') + return parser.parse_args() + + +if __name__ == '__main__': + args = _parse_args() + path = migrate_data( + args.source, + args.target, + include_imports=args.include_imports, + ) + print(f'Migration completed: {path}') + diff --git a/database/paths.py b/database/paths.py index 45188d6..cfb8d25 100644 --- a/database/paths.py +++ b/database/paths.py @@ -1,26 +1,43 @@ +import os from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent -DATABASE_ROOT = PROJECT_ROOT / 'database' +DATABASE_CODE_ROOT = PROJECT_ROOT / 'database' -L1_DIR = DATABASE_ROOT / 'L1' -L2_DIR = DATABASE_ROOT / 'L2' -L3_DIR = DATABASE_ROOT / 'L3' -WEB_DIR = DATABASE_ROOT / 'Web' +_runtime_override = os.environ.get('YRTV_DATA_DIR') +RUNTIME_DATA_ROOT = ( + Path(_runtime_override).expanduser().resolve() + if _runtime_override + else DATABASE_CODE_ROOT +) + +L1_DIR = RUNTIME_DATA_ROOT / 'L1' +L2_DIR = RUNTIME_DATA_ROOT / 'L2' +L3_DIR = RUNTIME_DATA_ROOT / 'L3' +WEB_DIR = RUNTIME_DATA_ROOT / 'Web' L1_DB = L1_DIR / 'L1.db' L2_DB = L2_DIR / 'L2.db' L3_DB = L3_DIR / 'L3.db' WEB_DB = WEB_DIR / 'Web_App.sqlite' -L2_SCHEMA = L2_DIR / 'schema.sql' -L3_SCHEMA = L3_DIR / 'schema.sql' -WEB_SCHEMA = WEB_DIR / 'schema.sql' +L2_SCHEMA = DATABASE_CODE_ROOT / 'L2' / 'schema.sql' +L3_SCHEMA = DATABASE_CODE_ROOT / 'L3' / 'schema.sql' +WEB_SCHEMA = DATABASE_CODE_ROOT / 'Web' / 'schema.sql' -OUTPUT_ARENA = PROJECT_ROOT / 'output_arena' -BACKUP_ROOT = DATABASE_ROOT / 'backups' -PIPELINE_LOCK = DATABASE_ROOT / '.pipeline.lock' +_import_override = os.environ.get('YRTV_IMPORT_DIR') +OUTPUT_ARENA = ( + Path(_import_override).expanduser().resolve() + if _import_override + else ( + RUNTIME_DATA_ROOT / 'imports' + if _runtime_override + else PROJECT_ROOT / 'output_arena' + ) +) +BACKUP_ROOT = RUNTIME_DATA_ROOT / 'backups' +PIPELINE_LOCK = RUNTIME_DATA_ROOT / '.pipeline.lock' def ensure_runtime_directories(): @@ -33,4 +50,3 @@ def ensure_runtime_directories(): BACKUP_ROOT, ): path.mkdir(parents=True, exist_ok=True) - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0926782 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + yrtv: + build: + context: . + dockerfile: Dockerfile + image: superjacky6/yrtv:2.0.0-beta + container_name: yrtv + restart: unless-stopped + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + env_file: + - .env + environment: + YRTV_DATA_DIR: /data + PORT: 5000 + ports: + - "${YRTV_PORT:-5001}:5000" + volumes: + - "${YRTV_DATA_PATH:-./runtime-data}:/data" + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=3)" + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..f7d3fe1 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +echo "YRTV 2.0.0 Beta · Superjacky6 Data Lab" +echo "Aliases: jacky / jk / yr / jacky0987" +echo "Runtime data: ${YRTV_DATA_DIR:-/data}" + +python -m database.bootstrap + +exec gunicorn wsgi:app \ + --bind "0.0.0.0:${PORT:-5000}" \ + --workers "${WEB_CONCURRENCY:-1}" \ + --threads "${WEB_THREADS:-2}" \ + --timeout "${WEB_TIMEOUT:-120}" \ + --access-logfile - \ + --error-logfile - + diff --git a/tests/test_integration.py b/tests/test_integration.py index d9ceb77..62ff45d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,6 +3,8 @@ import io import os import shutil import sqlite3 +import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -60,6 +62,42 @@ class ApplicationIntegrationTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertGreater(len(response.data), 100) + def test_health_endpoint_and_brand_watermark(self): + response = self.client.get('/healthz') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()['brand'], 'Superjacky6') + response = self.client.get('/') + self.assertIn(b'Superjacky6', response.data) + self.assertIn(b'jacky', response.data) + + def test_private_visibility_requires_viewer_token(self): + from web.app import create_app + + class PrivateConfig(Config): + TESTING = True + SECRET_KEY = 'private-test' + SITE_VISIBILITY = 'private' + VIEWER_TOKEN = 'viewer-secret' + + app = create_app(PrivateConfig) + client = app.test_client() + response = client.get('/') + self.assertEqual(response.status_code, 302) + self.assertIn('/access/', response.location) + self.assertEqual(client.get('/healthz').status_code, 200) + + with client.session_transaction() as session: + session['_csrf_token'] = 'viewer-csrf' + response = client.post( + '/access/', + data={ + '_csrf_token': 'viewer-csrf', + 'token': 'viewer-secret', + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(client.get('/').status_code, 200) + def test_discovery_filters_and_player_medals_render(self): response = self.client.get('/discover/') self.assertEqual(response.status_code, 200) @@ -567,6 +605,72 @@ class DatabaseGovernanceTests(unittest.TestCase): self.assertTrue(path.is_absolute()) self.assertTrue(path.exists()) + def test_external_data_root_bootstraps_without_touching_current_data(self): + current_hashes = {} + for path in ( + Path(Config.DB_L2_PATH), + Path(Config.DB_L3_PATH), + Path(Config.DB_WEB_PATH), + ): + current_hashes[str(path)] = path.stat().st_size + + with tempfile.TemporaryDirectory() as temp_dir: + env = os.environ.copy() + env['YRTV_DATA_DIR'] = temp_dir + result = subprocess.run( + [sys.executable, '-m', 'database.bootstrap'], + cwd=Config.BASE_DIR, + env=env, + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stderr) + runtime = Path(temp_dir) + expected_tables = { + runtime / 'L1' / 'L1.db': 'raw_iframe_network', + runtime / 'L2' / 'L2.db': 'fact_matches', + runtime / 'L3' / 'L3.db': 'dm_player_features', + runtime / 'Web' / 'Web_App.sqlite': 'team_lineups', + } + for path, table in expected_tables.items(): + with self.subTest(path=path): + self.assertTrue(path.exists()) + with sqlite3.connect(str(path)) as db: + self.assertEqual( + db.execute('PRAGMA quick_check').fetchone()[0], + 'ok', + ) + self.assertIsNotNone(db.execute( + """ + SELECT 1 FROM sqlite_master + WHERE type = 'table' AND name = ? + """, + [table], + ).fetchone()) + + for path, size in current_hashes.items(): + self.assertEqual(Path(path).stat().st_size, size) + + def test_existing_data_migrates_to_external_root(self): + from database.migrate_data import migrate_data + from database.paths import DATABASE_CODE_ROOT + + with tempfile.TemporaryDirectory() as temp_dir: + manifest_path = migrate_data( + DATABASE_CODE_ROOT, + temp_dir, + include_imports=False, + ) + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + self.assertEqual( + set(manifest['databases']), + {'L1', 'L2', 'L3', 'Web'}, + ) + for item in manifest['databases'].values(): + self.assertEqual(item['quick_check'], 'ok') + self.assertEqual(len(item['sha256']), 64) + def test_valid_capture_is_identified_from_network_urls(self): from database.paths import L1_DB from web.services.import_service import MatchImportService diff --git a/web/app.py b/web/app.py index e845157..c11bbb7 100644 --- a/web/app.py +++ b/web/app.py @@ -3,7 +3,7 @@ import os sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from flask import Flask +from flask import Flask, redirect, request, session, url_for from web.config import Config from web.database import close_dbs, initialize_web_db from web.auth import get_csrf_token @@ -19,8 +19,38 @@ def create_app(config_object=Config): app.teardown_appcontext(close_dbs) app.jinja_env.globals['csrf_token'] = get_csrf_token + @app.context_processor + def inject_brand(): + return { + 'brand': { + 'primary': app.config['BRAND_PRIMARY'], + 'aliases': app.config['BRAND_ALIASES'], + 'signature': app.config['BRAND_SIGNATURE'], + } + } + + @app.before_request + def require_private_access(): + if app.config['SITE_VISIBILITY'] != 'private': + return None + endpoint = request.endpoint or '' + if ( + endpoint == 'static' + or endpoint == 'main.healthz' + or endpoint.startswith('access.') + or endpoint.startswith('admin.') + or session.get('viewer_access') + or session.get('is_admin') + ): + return None + return redirect(url_for( + 'access.login', + next=request.full_path if request.method == 'GET' else None, + )) + from web.routes import ( admin, + access, awards, discover, main, @@ -33,6 +63,7 @@ def create_app(config_object=Config): wiki, ) app.register_blueprint(main.bp) + app.register_blueprint(access.bp) app.register_blueprint(matches.bp) app.register_blueprint(players.bp) app.register_blueprint(teams.bp) diff --git a/web/config.py b/web/config.py index 510dece..ba327f7 100644 --- a/web/config.py +++ b/web/config.py @@ -1,13 +1,37 @@ import os -from database.paths import L2_DB, L3_DB, WEB_DB, WEB_SCHEMA +from database.paths import ( + L2_DB, + L3_DB, + OUTPUT_ARENA, + RUNTIME_DATA_ROOT, + WEB_DB, + WEB_SCHEMA, +) class Config: BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + DATA_ROOT = str(RUNTIME_DATA_ROOT) + IMPORT_DIR = str(OUTPUT_ARENA) SECRET_KEY = os.environ.get('SECRET_KEY', 'yrtv-dev-only-change-me') ADMIN_TOKEN = os.environ.get('ADMIN_TOKEN', 'yrtv-admin-dev') + SITE_VISIBILITY = os.environ.get('SITE_VISIBILITY', 'public').lower() + VIEWER_TOKEN = os.environ.get('VIEWER_TOKEN', '') + + BRAND_PRIMARY = os.environ.get('BRAND_PRIMARY', 'Superjacky6') + BRAND_ALIASES = [ + value.strip() + for value in os.environ.get( + 'BRAND_ALIASES', + 'jacky,jk,yr,jacky0987', + ).split(',') + if value.strip() + ] + BRAND_SIGNATURE = ( + f"{BRAND_PRIMARY} / {' / '.join(BRAND_ALIASES)}" + ) DB_L2_PATH = str(L2_DB) DB_L3_PATH = str(L3_DB) @@ -20,6 +44,11 @@ class Config: SLOW_QUERY_THRESHOLD_SECONDS = float( os.environ.get('SLOW_QUERY_THRESHOLD_SECONDS', '0.25') ) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'Lax' + SESSION_COOKIE_SECURE = ( + os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true' + ) # Pagination ITEMS_PER_PAGE = 20 diff --git a/web/routes/access.py b/web/routes/access.py new file mode 100644 index 0000000..e2c24ad --- /dev/null +++ b/web/routes/access.py @@ -0,0 +1,42 @@ +import hmac + +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) + +from web.auth import validate_csrf +bp = Blueprint('access', __name__, url_prefix='/access') + + +@bp.route('/', methods=['GET', 'POST']) +def login(): + if current_app.config['SITE_VISIBILITY'] != 'private': + return redirect(url_for('main.index')) + if request.method == 'POST': + validate_csrf() + token = request.form.get('token') or '' + viewer_token = current_app.config['VIEWER_TOKEN'] + if viewer_token and hmac.compare_digest( + token, + viewer_token, + ): + session['viewer_access'] = True + target = request.args.get('next') + if not target or not target.startswith('/') or target.startswith('//'): + target = url_for('main.index') + return redirect(target) + flash('访问密码错误', 'error') + return render_template('access/login.html') + + +@bp.route('/logout') +def logout(): + session.pop('viewer_access', None) + return redirect(url_for('access.login')) diff --git a/web/routes/admin.py b/web/routes/admin.py index 25507e7..61468f8 100644 --- a/web/routes/admin.py +++ b/web/routes/admin.py @@ -13,6 +13,9 @@ from web.auth import admin_required, csrf_protected, validate_csrf from web.database import query_db from web.services.admin_service import AdminService from web.services.etl_service import EtlService +from web.services.team_context_service import TeamContextService +from web.services.web_service import WebService +from web.services.roster_version_service import RosterVersionService import hmac import time @@ -38,11 +41,47 @@ def logout(): @bp.route('/') @admin_required def dashboard(): + if not TeamContextService.get_active_roster_ids(): + return redirect(url_for('admin.setup')) return render_template( 'admin/dashboard.html', **AdminService.get_overview(), ) + +@bp.route('/setup', methods=['GET', 'POST']) +@admin_required +def setup(): + if request.method == 'POST': + validate_csrf() + name = (request.form.get('name') or '').strip() + description = (request.form.get('description') or '').strip() + raw_ids = request.form.get('steam_ids') or '' + player_ids = [] + for value in raw_ids.replace(',', '\n').splitlines(): + steam_id = value.strip() + if steam_id and steam_id not in player_ids: + player_ids.append(steam_id) + if not name: + flash('请输入战队名称', 'error') + elif not player_ids: + flash('至少添加一名队员 Steam ID', 'error') + elif any(not value.isdigit() for value in player_ids): + flash('Steam ID 只能包含数字', 'error') + else: + lineup_id = WebService.save_lineup( + name, + description, + player_ids, + ) + RosterVersionService.snapshot_roster( + player_ids, + name=f'{name} Initial Roster', + ) + flash('战队初始化完成', 'success') + return redirect(url_for('admin.dashboard')) + return render_template('admin/setup.html') + @bp.route('/data-integrity') @admin_required def data_integrity(): diff --git a/web/routes/main.py b/web/routes/main.py index 2b79137..2cab8a6 100644 --- a/web/routes/main.py +++ b/web/routes/main.py @@ -1,8 +1,28 @@ from flask import Blueprint, render_template, request, jsonify +from web.database import query_db +from web.config import Config from web.services.stats_service import StatsService bp = Blueprint('main', __name__) + +@bp.route('/healthz') +def healthz(): + try: + for db_name in ('l2', 'l3', 'web'): + query_db(db_name, 'SELECT 1', one=True) + except Exception as exc: + return jsonify({ + 'status': 'unhealthy', + 'error': str(exc), + 'brand': Config.BRAND_PRIMARY, + }), 503 + return jsonify({ + 'status': 'ok', + 'version': '2.0.0-beta', + 'brand': Config.BRAND_PRIMARY, + }) + @bp.route('/') def index(): recent_matches = StatsService.get_recent_matches(limit=5) diff --git a/web/services/admin_service.py b/web/services/admin_service.py index c858d36..2a508de 100644 --- a/web/services/admin_service.py +++ b/web/services/admin_service.py @@ -164,6 +164,10 @@ class AdminService: 'sqlite_timeout': Config.SQLITE_TIMEOUT_SECONDS, 'slow_query_threshold': Config.SLOW_QUERY_THRESHOLD_SECONDS, 'max_upload_mb': Config.MAX_CONTENT_LENGTH / 1024 / 1024, + 'data_root': Config.DATA_ROOT, + 'import_dir': Config.IMPORT_DIR, + 'site_visibility': Config.SITE_VISIBILITY, + 'brand_primary': Config.BRAND_PRIMARY, 'secret_key_configured': ( Config.SECRET_KEY != 'yrtv-dev-only-change-me' ), @@ -172,4 +176,3 @@ class AdminService: ), }, } - diff --git a/web/templates/access/login.html b/web/templates/access/login.html new file mode 100644 index 0000000..b834acc --- /dev/null +++ b/web/templates/access/login.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}Private Access - YRTV{% endblock %} + +{% block content %} +
+
+
+
{{ brand.primary }} Private Analytics
+

这是一座私人战队数据站

+

输入战队提供的访问密码后,可以查看比赛、职业主页、发现、荣誉和战队履历。

+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} +
+ +
+ + +
+ +
+
Protected by {{ brand.signature }}
+
+
+
+{% endblock %} diff --git a/web/templates/admin/base.html b/web/templates/admin/base.html index e92cfd1..37d5c63 100644 --- a/web/templates/admin/base.html +++ b/web/templates/admin/base.html @@ -19,6 +19,7 @@
YRTV
Operations
+
{{ brand.primary }} Control Plane
Beta @@ -83,4 +84,3 @@ {% endblock %} - diff --git a/web/templates/admin/setup.html b/web/templates/admin/setup.html new file mode 100644 index 0000000..37a2bca --- /dev/null +++ b/web/templates/admin/setup.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}First Setup - YRTV{% endblock %} + +{% block content %} +
+
+
+
{{ brand.primary }} Self-hosted
+

创建你的私人战队 HLTV

+

这一步只建立战队与 roster,不会生成或修改比赛数据。之后从 Admin 上传自己的 iframe_network.json。

+
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} +
+ +
+ + +
+
+ + +
+
+ + +

支持换行或逗号分隔。后续可在 Clubhouse 修改。

+
+ +
+
+
+
+{% endblock %} diff --git a/web/templates/admin/system.html b/web/templates/admin/system.html index 13f3de6..5ae4b48 100644 --- a/web/templates/admin/system.html +++ b/web/templates/admin/system.html @@ -62,11 +62,17 @@ ('Web schema', 'v' + config.web_schema_version|string), ('SQLite timeout', config.sqlite_timeout|string + 's'), ('Slow query', config.slow_query_threshold|string + 's'), - ('Max upload', '%.0f MB'|format(config.max_upload_mb)) + ('Max upload', '%.0f MB'|format(config.max_upload_mb)), + ('Visibility', config.site_visibility), + ('Brand', config.brand_primary) ] %}
{{ label }}
{{ value }}
{% endfor %} +
+
Data root
{{ config.data_root }}
+
Import dir
{{ config.import_dir }}
+
diff --git a/web/templates/awards/index.html b/web/templates/awards/index.html index a2e238d..99dd900 100644 --- a/web/templates/awards/index.html +++ b/web/templates/awards/index.html @@ -5,7 +5,7 @@ {% block content %}
-

YRTV Awards

+

YRTV Awards · {{ brand.primary }}

周期最佳与荣誉榜

完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。

diff --git a/web/templates/base.html b/web/templates/base.html index c585fbc..e33463b 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -3,6 +3,11 @@ + + + + + {% block title %}YRTV - CS2 Data Platform{% endblock %} @@ -40,7 +45,10 @@