2 Commits

6 changed files with 111 additions and 45 deletions
+7 -1
View File
@@ -62,6 +62,9 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
# App runtime logs
app/logs/
# Flask stuff:
instance/
.webassets-cache
@@ -180,4 +183,7 @@ cython_debug/
# data
data/*
playlists/*
docker-compose.yml
docker-compose.yml
# Local dev config may contain Plex token
app/config.json
-29
View File
@@ -1,29 +0,0 @@
{
"theme": "auto",
"token": "",
"server_url": "",
"server_scheme": "http",
"server_port": "32400",
"timeout": 9,
"library_name": "",
"sync_mode": "local_force",
"path_rules": [],
"path_mapping": {
"mode": "SIMPLE",
"simple": [],
"regex": {
"local_pre": [],
"local_post": [],
"remote_pre": [],
"remote_post": []
}
},
"schedule_mode": "DISABLED",
"schedule_cron": "",
"schedule_daily_time": "00:00",
"schedule_weekly_days": [0],
"schedule_weekly_time": "00:00",
"schedule_auto_watch": false,
"backup_enabled": false,
"backup_retention_count": 5
}
+26 -2
View File
@@ -1,5 +1,7 @@
import os
import zipfile
import hashlib
import re
from datetime import datetime
from typing import List
from app.utils.logger import logger
@@ -19,6 +21,28 @@ BACKUP_DIR = os.path.abspath(
)
def _safe_zip_entry_name(name: str, extension: str = ".m3u8") -> str:
"""Return a safe zip entry filename.
Prevents zip-slip style paths and avoids problematic characters.
"""
original = (name or "").strip()
base = os.path.basename(original)
base = re.sub(r"[\x00-\x1f\x7f]", "_", base)
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in base).strip().strip(". ")
if not cleaned:
cleaned = "playlist"
cleaned = cleaned[:160].rstrip().strip(". ")
if cleaned != original:
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
cleaned = f"{cleaned}__{digest}"
return f"{cleaned}{extension}"
def ensure_backup_dir():
"""Ensure the backup directory exists."""
if not os.path.exists(BACKUP_DIR):
@@ -118,7 +142,7 @@ def backup_local_playlists(local_path: str) -> str | None:
# Get the playlist name without extension and add .m3u8 extension
playlist_name = os.path.splitext(entry.name)[0]
archive_name = f"{playlist_name}.m3u8"
archive_name = _safe_zip_entry_name(playlist_name)
# Write to zip
zipf.writestr(archive_name, content)
@@ -217,7 +241,7 @@ def backup_cloud_playlists(library_name: str) -> str | None:
if len(lines) > 1: # More than just #EXTM3U
content = "\n".join(lines)
archive_name = f"{playlist.title}.m3u8"
archive_name = _safe_zip_entry_name(getattr(playlist, "title", "playlist"))
zipf.writestr(archive_name, content)
playlist_count += 1
+22 -3
View File
@@ -2,6 +2,25 @@ import json
import os
from app.utils.logger import logger
def _redact_for_log(value: object) -> object:
if not isinstance(value, dict):
return value
redacted = dict(value)
for key in ("token", "password"):
if key in redacted and redacted.get(key):
redacted[key] = "***"
return redacted
def _redact_server_config_dict(state: dict) -> dict:
if not isinstance(state, dict):
return {}
redacted = dict(state)
if redacted.get("token"):
redacted["token"] = "***"
return redacted
DEFAULT_SYNC_MODE = "merge_local_primary"
LOCAL_PLAYLISTS_FOLDER = "playlists"
DEFAULT_PATH_MAPPING = {
@@ -62,7 +81,7 @@ class ServerConfig:
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
logger.debug(f"Loaded server config: {config}")
logger.debug(f"Loaded server config: {_redact_for_log(config)}")
except FileNotFoundError:
# 如果配置文件不存在,使用默认值
self.save()
@@ -110,7 +129,7 @@ class ServerConfig:
self.backup_enabled = config.get("backup_enabled", False)
self.backup_retention_count = config.get("backup_retention_count", 5)
logger.info(f"Server config loaded.")
logger.debug(f"Current server config: {self.__dict__}")
logger.debug(f"Current server config: {_redact_server_config_dict(self.__dict__)}")
def save(self):
_ensure_parent_dir(CONFIG_PATH)
@@ -137,7 +156,7 @@ class ServerConfig:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=4, ensure_ascii=False)
logger.info(f"Server config saved.")
logger.debug(f"Saved server config: {config}")
logger.debug(f"Saved server config: {_redact_for_log(config)}")
def set_url(self, url: str) -> None:
self.url = url
+3 -7
View File
@@ -79,9 +79,7 @@ class PlexClient:
# Update the base URL and connection status
self.base_url = build_plex_url(scheme, url, port)
self.connected = True
logger.info(
f"Connected to Plex server at {self.base_url} with token: {self.token}"
)
logger.info(f"Connected to Plex server at {self.base_url}.")
return self.server, self.token
except Exception as e:
logger.warning(f"Failed to connect to Plex server: {str(e)}")
@@ -106,9 +104,7 @@ class PlexClient:
self.token = account.authenticationToken
self.server = PlexServer(self.base_url, self.token, timeout=timeout)
logger.debug(
f"Connected to Plex server with username: {username}, token: {self.token}"
)
logger.debug(f"Connected to Plex server with username: {username}.")
return self.server, self.token
def _connect_with_token(
@@ -124,7 +120,7 @@ class PlexClient:
self.base_url = build_plex_url(scheme, url, port)
self.server = PlexServer(self.base_url, token, timeout=timeout)
logger.debug(f"Connected to Plex server with token: {token}")
logger.debug("Connected to Plex server with token.")
return self.server, token
def _connect_check(self):
+53 -3
View File
@@ -2,6 +2,8 @@ import threading
import asyncio
import json
import os
import hashlib
import re
from datetime import datetime
from app.utils.logger import logger
from app.utils.playlist_merge import sync_all_playlists, SyncMode
@@ -139,7 +141,7 @@ class SyncManager:
local_result_path = os.path.join(output_dir, "outputs", "local_result.m3u8")
if os.path.exists(local_result_path):
tracks = load_local_playlist(local_result_path)
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
# Ensure directory exists
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
write_local_playlist(dest_path, tracks)
@@ -156,10 +158,10 @@ class SyncManager:
elif action == "deleted":
# Delete Local
dest_path = os.path.join(server_config.local_path, f"{playlist_name}.m3u8")
dest_path = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u8")
delete_local_playlist(dest_path)
# Also check for .m3u
dest_path_m3u = os.path.join(server_config.local_path, f"{playlist_name}.m3u")
dest_path_m3u = self._safe_local_playlist_path(server_config.local_path, playlist_name, ".m3u")
delete_local_playlist(dest_path_m3u)
# Delete Remote
@@ -167,6 +169,54 @@ class SyncManager:
except Exception as e:
logger.error(f"Error applying sync result for playlist {playlist_name}: {e}")
@staticmethod
def _safe_local_playlist_path(local_dir: str, playlist_name: str, extension: str) -> str:
base_dir = os.path.abspath(local_dir or "")
if not base_dir:
raise ValueError("Local playlist directory is not configured")
original = (playlist_name or "").strip()
# Drop any path components.
name = os.path.basename(original)
# Remove control chars.
name = re.sub(r"[\x00-\x1f\x7f]", "_", name)
# Replace path separators and Windows-invalid characters.
invalid = set('<>:"/\\|?*')
cleaned = "".join(("_" if ch in invalid else ch) for ch in name).strip().strip(". ")
windows_reserved = {
"CON", "PRN", "AUX", "NUL",
*(f"COM{i}" for i in range(1, 10)),
*(f"LPT{i}" for i in range(1, 10)),
}
needs_hash = False
if not cleaned:
cleaned = "playlist"
needs_hash = True
if cleaned.upper() in windows_reserved:
needs_hash = True
if cleaned != original:
needs_hash = True
cleaned = cleaned[:160].rstrip().strip(". ")
if not cleaned:
cleaned = "playlist"
needs_hash = True
if needs_hash:
digest = hashlib.sha1(original.encode("utf-8", errors="ignore")).hexdigest()[:8]
cleaned = f"{cleaned}__{digest}"
filename = f"{cleaned}{extension}"
candidate = os.path.abspath(os.path.join(base_dir, filename))
# Ensure the final path stays within base_dir.
if os.path.commonpath([base_dir, candidate]) != base_dir:
raise ValueError("Refusing to write outside local playlist directory")
return candidate
def _complete_sync(self, status, error=None):
with self._lock:
self._last_status = status