-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathweb_backend.py
More file actions
301 lines (267 loc) · 11.5 KB
/
Copy pathweb_backend.py
File metadata and controls
301 lines (267 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import os
import sqlite3
import logging
import secrets
from fastapi import FastAPI, HTTPException, Query, Depends, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import List, Optional
import uvicorn
from utils import DB_PATH, SAVE_DIR, delete_media_records, get_db_connection
from config import WEB_USERNAME, WEB_PASSWORD
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("web_backend")
app = FastAPI(title="TeleGrabber Web Management")
# --- HTTP Basic Auth ---
# 凭据来自 .env (WEB_USERNAME / WEB_PASSWORD)。
# 若 WEB_PASSWORD 为空,则视为未启用鉴权:只读接口开放,写操作(删除)接口禁用。
security = HTTPBasic(auto_error=False)
AUTH_ENABLED = bool(WEB_PASSWORD)
if not AUTH_ENABLED:
logger.warning("Web 后台未设置 WEB_PASSWORD,删除等写操作接口已禁用。请在 .env 中配置以启用完整功能。")
def require_auth(credentials: Optional[HTTPBasicCredentials] = Depends(security)):
"""校验 Basic Auth 凭据。未配置密码时直接拒绝写操作。"""
if not AUTH_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="写操作已禁用:服务器未配置 WEB_PASSWORD",
)
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="需要登录",
headers={"WWW-Authenticate": "Basic"},
)
# 使用 compare_digest 防止时序攻击
user_ok = secrets.compare_digest(credentials.username, WEB_USERNAME)
pass_ok = secrets.compare_digest(credentials.password, WEB_PASSWORD)
if not (user_ok and pass_ok):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
# 数据模型
class MediaRecord(BaseModel):
id: int
file_unique_id: str
user_id: Optional[int]
user_name: Optional[str]
filename: str
datetime: str
message_time: Optional[str]
media_group_id: Optional[str]
media_type: str
caption: Optional[str]
source_name: Optional[str]
source_username: Optional[str] = None
source_link1: Optional[str] = None
source_link2: Optional[str] = None
source_type: Optional[str]
file_id: Optional[str] = None
source_id: Optional[str] = None
message_id: Optional[int] = None
remark: Optional[str] = None
# 媒体文件映射 (用于预览下载的内容)
if not os.path.exists(SAVE_DIR):
os.makedirs(SAVE_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=SAVE_DIR), name="media")
# 静态资源映射 (html, css, js)
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir, exist_ok=True)
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/")
def read_index():
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"message": "TeleGrabber Dashboard is running. Please ensure 'static' folder exists."}
@app.get("/favicon.ico")
async def favicon():
favicon_path = os.path.join(static_dir, "favicon.png")
if os.path.exists(favicon_path):
return FileResponse(favicon_path)
raise HTTPException(status_code=404)
@app.get("/api/media", response_model=List[MediaRecord])
def get_media(
limit: int = 30,
offset: int = 0,
search: Optional[str] = None,
source_name: Optional[str] = None,
source_type: Optional[str] = None,
media_group_id: Optional[str] = None,
sort: Optional[str] = "message_time",
):
"""获取媒体记录列表,支持按原始消息时间(message_time)或下载时间(datetime)排序"""
try:
conn = get_db_connection()
cursor = conn.cursor()
query = ("SELECT id, user_id, user_name, filename, datetime, message_time, "
"media_group_id, media_type, caption, source_name, source_id, "
"source_username, source_link1, source_link2, source_type, "
"file_unique_id, file_id, message_id, remark "
"FROM media_metadata")
params = []
conditions = []
if search:
conditions.append("(caption LIKE ? OR filename LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
if source_name:
conditions.append("source_name = ?")
params.append(source_name)
if media_group_id:
if media_group_id == "single":
conditions.append("(media_group_id IS NULL OR media_group_id = '' OR media_group_id = 'single')")
else:
conditions.append("media_group_id = ?")
params.append(media_group_id)
if source_type:
conditions.append("source_type = ?")
params.append(source_type)
if conditions:
query += " WHERE " + " AND ".join(conditions)
# 默认 message_time DESC(原始消息时间),可切 datetime DESC(下载时间)
if sort == "datetime":
query += " ORDER BY id DESC" # datetime 和 id DESC 基本等价(下载顺序)
else:
# 按 COALESCE(message_time, datetime) 排序,空的排后面
query += " ORDER BY COALESCE(message_time, datetime) DESC, id DESC"
query += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return [
MediaRecord(
id=row[0], user_id=row[1], user_name=row[2],
filename=row[3], datetime=row[4], message_time=row[5],
media_group_id=row[6], media_type=row[7], caption=row[8], source_name=row[9],
source_id=row[10], source_username=row[11],
source_link1=row[12], source_link2=row[13],
source_type=row[14], file_unique_id=row[15],
file_id=row[16], message_id=row[17], remark=row[18]
) for row in rows
]
except Exception as e:
logger.error(f"获取媒体记录失败: {e}")
raise HTTPException(status_code=500, detail="获取媒体记录失败")
@app.get("/api/media_groups")
def get_media_groups(source_name: Optional[str] = None):
"""获取指定来源下的所有媒体组ID"""
try:
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT DISTINCT media_group_id FROM media_metadata"
params = []
if source_name:
query += " WHERE source_name = ?"
params.append(source_name)
cursor.execute(query, params)
groups = [row[0] for row in cursor.fetchall() if row[0] and row[0] != 'single']
conn.close()
return groups
except Exception as e:
logger.error(f"获取媒体组失败: {e}")
raise HTTPException(status_code=500, detail="获取媒体组失败")
@app.get("/api/sources")
def get_sources(source_type: Optional[str] = None):
"""获取所有来源渠道列表,可选按 source_type 过滤"""
try:
conn = get_db_connection()
cursor = conn.cursor()
query = "SELECT DISTINCT source_name, source_type FROM media_metadata WHERE source_name IS NOT NULL AND source_name != ''"
params = []
if source_type:
query += " AND source_type = ?"
params.append(source_type)
cursor.execute(query, params)
rows = cursor.fetchall()
conn.close()
return [{"source_name": row[0], "source_type": row[1]} for row in rows]
except Exception as e:
logger.error(f"获取来源列表失败: {e}")
raise HTTPException(status_code=500, detail="获取来源列表失败")
@app.get("/api/stats")
def get_stats(
search: Optional[str] = None,
source_name: Optional[str] = None,
source_type: Optional[str] = None,
media_group_id: Optional[str] = None,
):
"""获取媒体统计信息。支持与 /api/media 相同的筛选参数,
返回媒体数和消息数。"""
try:
conn = get_db_connection()
cursor = conn.cursor()
query = ("SELECT COUNT(*), "
"COUNT(DISTINCT CASE WHEN media_group_id IS NOT NULL AND media_group_id != '' AND media_group_id != 'single' "
"THEN media_group_id ELSE 'msg_' || id END) "
"FROM media_metadata")
params = []
conditions = []
if search:
conditions.append("(caption LIKE ? OR filename LIKE ?)")
params.extend([f"%{search}%", f"%{search}%"])
if source_name:
conditions.append("source_name = ?")
params.append(source_name)
if source_type:
conditions.append("source_type = ?")
params.append(source_type)
if media_group_id:
if media_group_id == "single":
conditions.append("(media_group_id IS NULL OR media_group_id = '' OR media_group_id = 'single')")
else:
conditions.append("media_group_id = ?")
params.append(media_group_id)
if conditions:
query += " WHERE " + " AND ".join(conditions)
cursor.execute(query, params)
row = cursor.fetchone()
conn.close()
return {"total_count": row[0], "message_count": row[1], "filtered": bool(conditions), "current_user_id": None}
except Exception as e:
logger.error(f"获取统计信息失败: {e}")
raise HTTPException(status_code=500, detail="获取统计信息失败")
@app.delete("/api/media/{id}")
def delete_media(id: int, _user: str = Depends(require_auth)):
"""删除指定记录及物理文件 (按主键 ID)"""
try:
from utils import delete_media_by_id
deleted_count = delete_media_by_id([id])
if deleted_count > 0:
return {"status": "success", "deleted_count": deleted_count}
else:
raise HTTPException(status_code=404, detail="未找到相关记录或文件已删除")
except HTTPException:
raise
except Exception as e:
logger.error(f"删除媒体失败: {e}")
raise HTTPException(status_code=500, detail="删除媒体失败")
@app.delete("/api/media_group/{media_group_id}")
def delete_media_group(media_group_id: str, _user: str = Depends(require_auth)):
"""删除整个媒体组及其物理文件"""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT file_unique_id FROM media_metadata WHERE media_group_id = ?", (media_group_id,))
unique_ids = [row[0] for row in cursor.fetchall()]
conn.close()
if not unique_ids:
raise HTTPException(status_code=404, detail="未找到该媒体组记录")
deleted_count = delete_media_records(unique_ids)
return {"status": "success", "deleted_count": deleted_count}
except HTTPException:
raise
except Exception as e:
logger.error(f"批量删除媒体组失败: {e}")
raise HTTPException(status_code=500, detail="批量删除媒体组失败")
def run_server(port=5000):
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
if __name__ == "__main__":
run_server()