#!/usr/bin/env python3
"""Authenticated Synology portal and direct-to-NAS download controller."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass
from datetime import datetime
from email.message import Message
from email.utils import parsedate_to_datetime
from http import HTTPStatus
from http.client import HTTPSConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from http.cookies import SimpleCookie
from http.cookiejar import CookieJar
import hashlib
import html
import ipaddress
import json
import logging
from logging.handlers import RotatingFileHandler
import mimetypes
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import signal
import shlex
import shutil
import socket
import stat
import subprocess
import sys
import tarfile
import threading
import time
import unicodedata
import zipfile
from urllib.error import HTTPError
from urllib.parse import parse_qs, unquote, urlencode, urljoin, urlparse, urlsplit
from urllib.request import HTTPRedirectHandler, HTTPSHandler, HTTPCookieProcessor, ProxyHandler, Request, build_opener
from transfer_parts import commit_fragment, segment_count, segment_chunk, initial_transfer_mode
ROOT = Path(__file__).resolve().parent
STATE_DIR = Path(os.environ.get("NAS_PORTAL_STATE_DIR", str(ROOT / "runtime"))).resolve()
STATE_FILE = STATE_DIR / "jobs.json"
AUTH_FILE = STATE_DIR / "credentials.json"
CONFIG_FILE = STATE_DIR / "config.json"
GOFILE_COOLDOWN_FILE = STATE_DIR / "gofile_cooldown.json"
SECRET_DIR = STATE_DIR / "job-secrets"
def load_config() -> dict[str, object]:
if not CONFIG_FILE.exists():
return {}
try:
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"설정 파일을 읽을 수 없습니다: {CONFIG_FILE}") from exc
if not isinstance(data, dict):
raise RuntimeError(f"설정 파일은 JSON 객체여야 합니다: {CONFIG_FILE}")
return data
CONFIG = load_config()
def setting(name: str, default: str = "") -> str:
value = os.environ.get(name)
if value is None:
value = CONFIG.get(name, default)
return str(value).strip()
LISTEN_HOST = os.environ.get("NAS_PORTAL_LISTEN_HOST", "127.0.0.1")
LISTEN_PORT = int(setting("NAS_PORTAL_LISTEN_PORT", "8791"))
NAS_TARGET = setting("NAS_PORTAL_NAS_TARGET")
STATIC_DIR = Path(setting("NAS_PORTAL_STATIC_DIR", str(ROOT / "synology" / "web"))).resolve()
LAUNCHER_FILE_SETTING = setting("NAS_PORTAL_LAUNCHER_FILE")
LAUNCHER_FILE = Path(LAUNCHER_FILE_SETTING).resolve() if LAUNCHER_FILE_SETTING else None
PACKAGE_VERSION = setting("NAS_PORTAL_VERSION", "0.9.29")
SEVEN_ZIP = Path(setting("NAS_PORTAL_7ZZ", str(ROOT / "bin" / "7zz"))).resolve()
MAX_FILE_BYTES = 300 * 1024**3
MAX_ARCHIVE_ENTRIES = 100_000
MAX_EXTRACTED_BYTES = 1024**4
MAX_PARALLEL_DOWNLOADS = 3
BATCH_QUEUE_STAGGER_SECONDS = 20
GOFILE_MIN_REQUEST_INTERVAL_SECONDS = 2.0
GOFILE_RATE_LIMIT_COOLDOWN_SECONDS = 30 * 60
GOFILE_NETWORK_COOLDOWN_SECONDS = 5 * 60
GOFILE_MAX_COOLDOWN_SECONDS = 6 * 60 * 60
GIGAFILE_HOST = re.compile(r"^[a-z0-9-]+\.gigafile\.nu$", re.I)
BUZZHEAVIER_DOWNLOAD_HOST = re.compile(r"^[a-z0-9-]+\.buzzheavier\.com$", re.I)
BUZZHEAVIER_TOKEN = re.compile(r"^[A-Za-z0-9_-]{16,4096}$")
ONEFICHIER_FILE_ID = re.compile(r"^[a-z0-9]{5,32}$", re.I)
ONEFICHIER_DIRECT_HOST = re.compile(r"^[a-z0-9-]+\.(?:1fichier|desfichiers)\.com$", re.I)
SAFE_SERVICE_ID = re.compile(r"^[A-Za-z0-9._~-]{1,256}$")
GOFILE_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140 Safari/537.36"
LOG_MAX_BYTES = 1024 * 1024
LOG_BACKUP_COUNT = 2
PASSWORD_HASH_ITERATIONS = 600_000
SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
MAX_SESSION_ENTRIES = 256
MAX_INSPECTION_CACHE_ENTRIES = 64
LOGIN_FAILURE_LIMIT = 5
LOGIN_BLOCK_SECONDS = 15 * 60
LOGIN_FAILURE_RETENTION_SECONDS = 15 * 60
MAX_LOGIN_FAILURE_ENTRIES = 4096
GLOBAL_LOGIN_FAILURE_WINDOW_SECONDS = 60
GLOBAL_LOGIN_FAILURE_LIMIT = 30
GLOBAL_LOGIN_COOLDOWN_SECONDS = 5
REQUEST_BODY_LIMIT = 16_384
REQUEST_TIMEOUT_SECONDS = 30
ARCHIVE_EXTRACT_TIMEOUT_SECONDS = 6 * 60 * 60
CURL_HTTPS_ONLY = "--proto '=https' --proto-redir '=https'"
CURL_NO_REDIRECTS = "--location --max-redirs 0"
CURL_PAGE_TIMEOUT = "--connect-timeout 15 --max-time 60"
CURL_PROBE_TIMEOUT = "--connect-timeout 10 --max-time 30"
CURL_STALL_GUARD = "--connect-timeout 15 --speed-limit 1024 --speed-time 120"
GIGAFILE_NAME_PROBE_TIMEOUT_SECONDS = 30
GIGAFILE_NAME_PROBE_WORKERS = 3
LOGGER = logging.getLogger("nasdrop")
LOGGER.addHandler(logging.NullHandler())
JOB_SECRET_LOCK = threading.RLock()
CONFIG_LOCK = threading.RLock()
FORWARDED_HEADER_LOCK = threading.Lock()
SHUTDOWN_EVENT = threading.Event()
UNTRUSTED_FORWARDED_HEADER_SEEN = False
UNTRUSTED_FORWARDED_HEADER_WARNED = False
def rotating_log_handler(path: str, max_bytes: int = LOG_MAX_BYTES, backup_count: int = LOG_BACKUP_COUNT):
target = Path(path).resolve()
target.parent.mkdir(parents=True, exist_ok=True)
return RotatingFileHandler(
target, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8", delay=True,
)
def configure_logging() -> None:
for handler in list(LOGGER.handlers):
LOGGER.removeHandler(handler)
handler.close()
log_path = setting("NAS_PORTAL_LOG_FILE")
handler = rotating_log_handler(log_path) if log_path else logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", "%Y-%m-%dT%H:%M:%S%z"))
LOGGER.addHandler(handler)
LOGGER.setLevel(logging.INFO)
LOGGER.propagate = False
def bool_setting(name: str, default: bool = False) -> bool:
return setting(name, "1" if default else "0").lower() in {"1", "true", "yes", "on"}
def parallel_limit_setting() -> int:
try:
value = int(setting("NAS_PORTAL_SAME_PROVIDER_LIMIT", "2"))
except ValueError:
value = 2
return min(3, max(2, value))
def normalize_launcher_port(value: object) -> int:
try:
port = int(str(value).strip())
except (TypeError, ValueError) as exc:
raise ValueError("아이콘 외부 포트는 1~65535 사이의 숫자여야 합니다.") from exc
if not 1 <= port <= 65535:
raise ValueError("아이콘 외부 포트는 1~65535 사이의 숫자여야 합니다.")
return port
def launcher_port_setting() -> int:
try:
return normalize_launcher_port(setting("NAS_PORTAL_LAUNCHER_PORT", str(LISTEN_PORT)))
except ValueError:
return LISTEN_PORT
def normalize_launcher_public_url(value: object) -> str:
raw = str(value or "").strip()
if not raw:
return ""
if len(raw) > 2048 or any(char.isspace() or ord(char) < 32 for char in raw) or "\\" in raw:
raise ValueError("DSM 아이콘 사용자 지정 주소가 올바르지 않습니다.")
try:
parsed = urlsplit(raw)
port = parsed.port
except ValueError as exc:
raise ValueError("DSM 아이콘 사용자 지정 주소가 올바르지 않습니다.") from exc
if (
parsed.scheme.lower() != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError("DSM 아이콘 사용자 지정 주소는 쿼리나 인증 정보가 없는 HTTPS 주소여야 합니다.")
try:
hostname = parsed.hostname.encode("idna").decode("ascii").lower()
except UnicodeError as exc:
raise ValueError("DSM 아이콘 사용자 지정 주소의 호스트 이름이 올바르지 않습니다.") from exc
if ":" in hostname:
try:
hostname = str(ipaddress.IPv6Address(hostname))
except ValueError as exc:
raise ValueError("DSM 아이콘 사용자 지정 주소의 호스트 이름이 올바르지 않습니다.") from exc
authority = f"[{hostname}]"
else:
hostname = hostname.rstrip(".")
labels = hostname.split(".")
if (
not hostname
or len(hostname) > 253
or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in labels)
):
raise ValueError("DSM 아이콘 사용자 지정 주소의 호스트 이름이 올바르지 않습니다.")
authority = hostname
if port is not None:
authority += f":{port}"
path = parsed.path or "/"
if not path.startswith("/"):
raise ValueError("DSM 아이콘 사용자 지정 주소가 올바르지 않습니다.")
if not path.endswith("/"):
path += "/"
return f"https://{authority}{path}"
def launcher_public_url_setting() -> str:
try:
return normalize_launcher_public_url(setting("NAS_PORTAL_LAUNCHER_PUBLIC_URL"))
except ValueError:
return ""
def normalize_download_mode(value: object) -> str:
mode = str(value).strip().lower()
if mode not in {"segmented", "single"}:
raise ValueError("다운로드 방식은 분할 또는 단일 연결이어야 합니다.")
return mode
def download_mode_setting() -> str:
try:
return normalize_download_mode(setting("NAS_PORTAL_DOWNLOAD_MODE", "segmented"))
except ValueError:
return "segmented"
ALLOW_SAME_PROVIDER_PARALLEL = bool_setting("NAS_PORTAL_ALLOW_SAME_PROVIDER_PARALLEL")
SAME_PROVIDER_LIMIT = parallel_limit_setting()
LAUNCHER_PORT = launcher_port_setting()
LAUNCHER_PUBLIC_URL = launcher_public_url_setting()
DOWNLOAD_MODE = download_mode_setting()
AUTO_EXTRACT_ARCHIVES = bool_setting("NAS_PORTAL_AUTO_EXTRACT_ARCHIVES", True)
DISK_PROTECTION = bool_setting("NAS_PORTAL_DISK_PROTECTION", True)
TRUST_FORWARDED_FOR = bool_setting("NAS_PORTAL_TRUST_FORWARDED_FOR", False)
def public_error_code(message: object) -> str:
"""Return a stable, non-sensitive category for API and persisted job errors."""
value = str(message)
rules = (
(("로그인이 필요",), "auth_required"),
(("ID 또는 비밀번호",), "invalid_credentials"),
(("ID는 영문, 숫자",), "invalid_username"),
(("비밀번호는 10~128자", "비밀번호에는 제어 문자"), "invalid_password"),
(("로그인 시도가 너무 많",), "too_many_attempts"),
(("계정이 아직 설정",), "account_not_configured"),
(("현재 비밀번호",), "current_password_invalid"),
(("DSM 아이콘 연결",), "launcher_expired"),
(("권한", "쓰기 권한", "볼 권한"), "permission_denied"),
(("GigaFile 다운로드 키",), "download_key_required"),
(("암호가 필요", "암호를 입력", "Wrong password", "password"), "password_required"),
(("Gofile 요청이 몰려", "429", "제한되었"), "rate_limited"),
(("연결하지 못", "HTTP ", "응답을 반환", "응답이 일정 시간 멈춰"), "network_error"),
(("만료", "링크를 다시", "Copy download link"), "link_expired"),
(("무결성", "손상", "크기가 예상값"), "integrity_failed"),
(("분할 압축의 나머지 조각", "분할 압축의 다음 파일"), "archive_parts_waiting"),
(("압축", "7-Zip", "archive"), "archive_error"),
(("지원 서비스", "정식 GigaFile", "정식 Gofile", "정식 Pixeldrain", "Buzzheavier"), "invalid_link"),
(("작업", "파일 정보가 변경", "파일이 없습니다"), "invalid_job_state"),
(("설정", "선택값", "선택해 주세요", "올바르지 않습니다"), "invalid_request"),
(("찾을 수 없습니다",), "not_found"),
(("재시작",), "service_restarted"),
(("내부 처리",), "internal_error"),
)
for fragments, code in rules:
if any(fragment.casefold() in value.casefold() for fragment in fragments):
return code
return "generic_error"
def storage_roots_setting() -> tuple[Path, ...]:
configured = setting("NAS_PORTAL_STORAGE_ROOTS")
candidates = [item.strip() for item in configured.split(",") if item.strip()] if configured else [
str(path) for path in Path("/").iterdir() if re.fullmatch(r"volume[0-9]+", path.name) and path.is_dir()
]
roots = []
for value in candidates:
root = Path(value).resolve()
if not root.is_absolute() or root == Path(root.anchor) or not root.is_dir():
continue
if root not in roots:
roots.append(root)
return tuple(roots)
STORAGE_ROOTS = storage_roots_setting()
def now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def normalize_username(value: object) -> str:
username = str(value).strip()
if not re.fullmatch(r"[A-Za-z0-9._-]{3,32}", username):
raise ValueError("ID는 영문, 숫자, 마침표, 밑줄, 하이픈을 사용해 3~32자로 입력하세요.")
return username
def validate_password(value: object) -> str:
if not isinstance(value, str) or not 10 <= len(value) <= 128:
raise ValueError("비밀번호는 10~128자로 입력하세요.")
if any(ord(character) < 32 or ord(character) == 127 for character in value):
raise ValueError("비밀번호에는 제어 문자를 사용할 수 없습니다.")
return value
def load_credentials() -> dict[str, object]:
if not AUTH_FILE.exists():
return {}
try:
data = json.loads(AUTH_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict) or data.get("algorithm") != "pbkdf2_sha256":
return {}
if not re.fullmatch(r"[A-Za-z0-9._-]{3,32}", str(data.get("username", ""))):
return {}
if not re.fullmatch(r"[0-9a-f]{32}", str(data.get("salt", ""))):
return {}
if not re.fullmatch(r"[0-9a-f]{64}", str(data.get("password_hash", ""))):
return {}
if "must_change_password" in data and not isinstance(data["must_change_password"], bool):
return {}
return data
CREDENTIALS = load_credentials()
SESSIONS: dict[str, tuple[str, float, str, float]] = {}
SESSIONS_LOCK = threading.Lock()
LOGIN_FAILURES: dict[str, tuple[int, float, float]] = {}
LOGIN_FAILURES_LOCK = threading.Lock()
GLOBAL_LOGIN_FAILURES: list[float] = []
GLOBAL_LOGIN_BLOCKED_UNTIL = 0.0
GLOBAL_LOGIN_FAILURES_LOCK = threading.Lock()
def credentials_configured() -> bool:
return bool(CREDENTIALS)
def password_change_required() -> bool:
return CREDENTIALS.get("must_change_password") is True
def password_hash(password: str, salt: bytes, iterations: int = PASSWORD_HASH_ITERATIONS) -> str:
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations).hex()
def create_docker_bootstrap_credentials() -> bool:
"""Create the one-time NASDrop bootstrap login without weakening password validation."""
global CREDENTIALS
STATE_DIR.mkdir(parents=True, exist_ok=True)
salt = secrets.token_bytes(16)
updated = {
"algorithm": "pbkdf2_sha256",
"iterations": PASSWORD_HASH_ITERATIONS,
"username": "nasdrop",
"salt": salt.hex(),
"password_hash": password_hash("nasdrop", salt),
"must_change_password": True,
}
encoded = (json.dumps(updated, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
try:
descriptor = os.open(AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
return False
try:
with os.fdopen(descriptor, "wb") as output:
output.write(encoded)
output.flush()
os.fsync(output.fileno())
except BaseException:
try:
AUTH_FILE.unlink()
except OSError:
pass
raise
CREDENTIALS = updated
return True
def enforce_docker_default_password_change() -> bool:
"""Keep the default bootstrap login confined until it is replaced."""
global CREDENTIALS
if (
not CREDENTIALS
or password_change_required()
or str(CREDENTIALS.get("username", "")).casefold() != "nasdrop"
or not verify_credentials("nasdrop", "nasdrop")
):
return False
updated = dict(CREDENTIALS)
updated["must_change_password"] = True
STATE_DIR.mkdir(parents=True, exist_ok=True)
temporary = AUTH_FILE.with_suffix(".tmp")
temporary.write_text(json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(AUTH_FILE)
CREDENTIALS = updated
with SESSIONS_LOCK:
SESSIONS.clear()
return True
def verify_credentials(username: object, password: object) -> bool:
if not CREDENTIALS or not isinstance(username, str) or not isinstance(password, str):
return False
stored_username = str(CREDENTIALS.get("username", ""))
username_matches = secrets.compare_digest(
username.strip().casefold().encode("utf-8"), stored_username.casefold().encode("utf-8"),
)
try:
salt = bytes.fromhex(str(CREDENTIALS["salt"]))
iterations = int(CREDENTIALS.get("iterations", PASSWORD_HASH_ITERATIONS))
candidate = password_hash(password, salt, iterations)
except (KeyError, TypeError, ValueError):
return False
hash_matches = secrets.compare_digest(candidate, str(CREDENTIALS.get("password_hash", "")))
return username_matches and hash_matches
def create_session(username: str, kind: str = "session", ttl: int = SESSION_TTL_SECONDS) -> str:
token = secrets.token_urlsafe(32)
with SESSIONS_LOCK:
current = time.time()
expired = [value for value, (_, expiry, _, _) in SESSIONS.items() if expiry <= current]
for value in expired:
SESSIONS.pop(value, None)
overflow = len(SESSIONS) - MAX_SESSION_ENTRIES + 1
if overflow > 0:
oldest = sorted(SESSIONS, key=lambda value: SESSIONS[value][3])[:overflow]
for value in oldest:
SESSIONS.pop(value, None)
SESSIONS[token] = (username, current + ttl, kind, current)
return token
def session_username(token: str) -> str:
with SESSIONS_LOCK:
session = SESSIONS.get(token)
if not session:
return ""
username, expiry, _, _ = session
if expiry <= time.time():
SESSIONS.pop(token, None)
return ""
return username
def session_kind(token: str) -> str:
with SESSIONS_LOCK:
session = SESSIONS.get(token)
if not session:
return ""
_, expiry, kind, _ = session
if expiry <= time.time():
SESSIONS.pop(token, None)
return ""
return kind
def revoke_session(token: str) -> None:
with SESSIONS_LOCK:
SESSIONS.pop(token, None)
def replace_credentials(username: object, password: object) -> str:
global CREDENTIALS
normalized_username = normalize_username(username)
normalized_password = validate_password(password)
if password_change_required() and normalized_username.casefold() == "nasdrop":
raise ValueError("초기 ID nasdrop도 새 ID로 변경해야 합니다.")
salt = secrets.token_bytes(16)
updated = {
"algorithm": "pbkdf2_sha256",
"iterations": PASSWORD_HASH_ITERATIONS,
"username": normalized_username,
"salt": salt.hex(),
"password_hash": password_hash(normalized_password, salt),
}
STATE_DIR.mkdir(parents=True, exist_ok=True)
temporary = AUTH_FILE.with_suffix(".tmp")
temporary.write_text(json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(AUTH_FILE)
CREDENTIALS = updated
with SESSIONS_LOCK:
SESSIONS.clear()
return normalized_username
def _prune_login_failures(current: float) -> None:
expired = [
client_ip for client_ip, (_, blocked_until, last_failure) in LOGIN_FAILURES.items()
if blocked_until <= current and current - last_failure >= LOGIN_FAILURE_RETENTION_SECONDS
]
for client_ip in expired:
LOGIN_FAILURES.pop(client_ip, None)
overflow = len(LOGIN_FAILURES) - MAX_LOGIN_FAILURE_ENTRIES
if overflow > 0:
oldest = sorted(LOGIN_FAILURES, key=lambda value: LOGIN_FAILURES[value][2])[:overflow]
for client_ip in oldest:
LOGIN_FAILURES.pop(client_ip, None)
def login_block_remaining(client_ip: str) -> int:
with LOGIN_FAILURES_LOCK:
current = time.time()
_prune_login_failures(current)
failures, blocked_until, _ = LOGIN_FAILURES.get(client_ip, (0, 0, 0))
if not failures or not blocked_until:
return 0
if blocked_until <= current:
LOGIN_FAILURES.pop(client_ip, None)
return 0
return max(1, int(blocked_until - current))
def record_login_result(client_ip: str, success: bool) -> None:
with LOGIN_FAILURES_LOCK:
current = time.time()
_prune_login_failures(current)
if success:
LOGIN_FAILURES.pop(client_ip, None)
return
failures, blocked_until, _ = LOGIN_FAILURES.get(client_ip, (0, 0, 0))
if blocked_until > current:
return
failures += 1
LOGIN_FAILURES[client_ip] = (
failures,
current + LOGIN_BLOCK_SECONDS if failures >= LOGIN_FAILURE_LIMIT else 0,
current,
)
_prune_login_failures(current)
def global_login_retry_after() -> int:
with GLOBAL_LOGIN_FAILURES_LOCK:
current = time.time()
GLOBAL_LOGIN_FAILURES[:] = [
value for value in GLOBAL_LOGIN_FAILURES
if current - value < GLOBAL_LOGIN_FAILURE_WINDOW_SECONDS
]
if GLOBAL_LOGIN_BLOCKED_UNTIL <= current:
return 0
return max(1, int(GLOBAL_LOGIN_BLOCKED_UNTIL - current + 0.999))
def record_global_login_failure() -> None:
global GLOBAL_LOGIN_BLOCKED_UNTIL
with GLOBAL_LOGIN_FAILURES_LOCK:
current = time.time()
GLOBAL_LOGIN_FAILURES[:] = [
value for value in GLOBAL_LOGIN_FAILURES
if current - value < GLOBAL_LOGIN_FAILURE_WINDOW_SECONDS
]
GLOBAL_LOGIN_FAILURES.append(current)
if len(GLOBAL_LOGIN_FAILURES) >= GLOBAL_LOGIN_FAILURE_LIMIT:
GLOBAL_LOGIN_BLOCKED_UNTIL = current + GLOBAL_LOGIN_COOLDOWN_SECONDS
GLOBAL_LOGIN_FAILURES.clear()
def trusted_client_ip(peer_ip: str, forwarded_for: str = "", trust_forwarded: bool | None = None) -> str:
"""Use the rightmost forwarded IP only after explicitly trusting a local reverse proxy."""
try:
peer = ipaddress.ip_address(peer_ip)
except ValueError:
return peer_ip
trust = TRUST_FORWARDED_FOR if trust_forwarded is None else trust_forwarded
if not trust or not peer.is_loopback or not forwarded_for:
return peer_ip
candidate = forwarded_for.rsplit(",", 1)[-1].strip()
try:
return str(ipaddress.ip_address(candidate))
except ValueError:
return peer_ip
def note_forwarded_header(peer_ip: str, forwarded_for: str) -> None:
"""Warn once when a local reverse proxy is present but proxy mode is disabled."""
global UNTRUSTED_FORWARDED_HEADER_SEEN, UNTRUSTED_FORWARDED_HEADER_WARNED
if TRUST_FORWARDED_FOR or not forwarded_for:
return
try:
if not ipaddress.ip_address(peer_ip).is_loopback:
return
except ValueError:
return
with FORWARDED_HEADER_LOCK:
UNTRUSTED_FORWARDED_HEADER_SEEN = True
if UNTRUSTED_FORWARDED_HEADER_WARNED:
return
UNTRUSTED_FORWARDED_HEADER_WARNED = True
LOGGER.warning(
"reverse proxy header detected from loopback while NAS_PORTAL_TRUST_FORWARDED_FOR is disabled; "
"clients share one login-throttle bucket"
)
def render_launcher_html(public_port: int, public_url: str = "") -> str:
custom_target = json.dumps(normalize_launcher_public_url(public_url), ensure_ascii=True).replace("<", "\\u003c")
return f'''
NASDrop
'''
def write_launcher_file(public_port: int | None = None, public_url: str | None = None) -> None:
if LAUNCHER_FILE is None:
return
port = LAUNCHER_PORT if public_port is None else normalize_launcher_port(public_port)
url = LAUNCHER_PUBLIC_URL if public_url is None else normalize_launcher_public_url(public_url)
temporary = LAUNCHER_FILE.with_suffix(".tmp")
temporary.write_text(render_launcher_html(port, url), encoding="utf-8")
temporary.chmod(0o644)
temporary.replace(LAUNCHER_FILE)
def refresh_launcher_safely() -> None:
try:
write_launcher_file()
except OSError:
LOGGER.exception("DSM launcher file could not be refreshed")
INSPECTION_CACHE: dict[str, tuple[float, dict]] = {}
INSPECTION_CACHE_LOCK = threading.Lock()
INSPECTION_TTL_SECONDS = 300
GOFILE_SESSION_LOCK = threading.Lock()
GOFILE_SESSION: tuple[float, str, str] | None = None
GOFILE_REQUEST_LOCK = threading.Lock()
GOFILE_LAST_REQUEST = 0.0
GOFILE_COOLDOWN_LOCK = threading.Lock()
class GofileCooldownError(ValueError):
"""Raised without making a request while GoFile is cooling down."""
class PasswordRequiredError(ValueError):
"""Raised when an encrypted archive needs a new password without redownloading."""
class MissingArchiveVolumeError(ValueError):
"""Raised when 7-Zip needs another split-archive volume."""
class GigaFileDownloadKeyRequiredError(ValueError):
"""Raised when GigaFile requires a source-side download key."""
class GigaFileDownloadKeyInvalidError(GigaFileDownloadKeyRequiredError):
"""Raised only after the user supplied a key and GigaFile rejected it."""
def _load_gofile_cooldown() -> tuple[float, str]:
if not GOFILE_COOLDOWN_FILE.exists():
return 0.0, ""
try:
data = json.loads(GOFILE_COOLDOWN_FILE.read_text(encoding="utf-8"))
return float(data.get("until", 0)), str(data.get("reason", ""))
except (OSError, TypeError, ValueError, json.JSONDecodeError):
return 0.0, ""
GOFILE_COOLDOWN_UNTIL, GOFILE_COOLDOWN_REASON = _load_gofile_cooldown()
def _save_gofile_cooldown(until: float, reason: str) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
temp = GOFILE_COOLDOWN_FILE.with_suffix(".tmp")
temp.write_text(json.dumps({"until": until, "reason": reason}, ensure_ascii=False), encoding="utf-8")
os.replace(temp, GOFILE_COOLDOWN_FILE)
def _gofile_cooldown_status() -> dict[str, object]:
with GOFILE_COOLDOWN_LOCK:
remaining = max(0, int(GOFILE_COOLDOWN_UNTIL - time.time()))
return {
"active": remaining > 0,
"until": GOFILE_COOLDOWN_UNTIL if remaining > 0 else 0,
"remaining_seconds": remaining,
"reason": GOFILE_COOLDOWN_REASON if remaining > 0 else "",
}
def _gofile_guard() -> None:
status = _gofile_cooldown_status()
if not status["active"]:
return
until = datetime.fromtimestamp(float(status["until"])).astimezone().strftime("%H:%M")
raise GofileCooldownError(f"GoFile 요청이 일시 중단되었습니다. {until} 이후 다시 시도해 주세요.")
def _gofile_retry_after(exc: HTTPError, fallback: float) -> float:
try:
value = exc.headers.get("Retry-After", "")
except AttributeError:
value = ""
try:
return max(fallback, float(value))
except (TypeError, ValueError):
try:
retry_at = parsedate_to_datetime(str(value))
return max(fallback, retry_at.timestamp() - time.time())
except (TypeError, ValueError, OverflowError):
return fallback
def _trip_gofile_cooldown(seconds: float, reason: str) -> GofileCooldownError:
global GOFILE_COOLDOWN_UNTIL, GOFILE_COOLDOWN_REASON
duration = min(GOFILE_MAX_COOLDOWN_SECONDS, max(60.0, seconds))
with GOFILE_COOLDOWN_LOCK:
GOFILE_COOLDOWN_UNTIL = max(GOFILE_COOLDOWN_UNTIL, time.time() + duration)
GOFILE_COOLDOWN_REASON = reason
_save_gofile_cooldown(GOFILE_COOLDOWN_UNTIL, reason)
until = datetime.fromtimestamp(GOFILE_COOLDOWN_UNTIL).astimezone().strftime("%H:%M")
return GofileCooldownError(f"{reason} GoFile 요청을 {until}까지 자동 중단합니다.")
def _validate_job_password(value: object) -> str:
password = str(value or "")
if len(password) > 256 or any(character in password for character in {"\x00", "\r", "\n"}):
raise ValueError("압축 암호는 줄바꿈 없이 256자 이내로 입력해 주세요.")
return password
def _validate_gigafile_download_key(value: object) -> str:
key = str(value or "")
if len(key) > 4 or any(ord(character) < 0x20 or ord(character) == 0x7f for character in key):
raise ValueError("GigaFile 다운로드 키는 제어 문자 없이 4자 이내로 입력해 주세요.")
return key
def _validate_1fichier_password(value: object) -> str:
password = str(value or "")
if len(password) > 256 or any(ord(character) < 0x20 or ord(character) == 0x7f for character in password):
raise ValueError("1fichier 파일 암호는 제어 문자 없이 256자 이내로 입력해 주세요.")
return password
def _job_secret_path(job_id: str) -> Path:
if not re.fullmatch(r"[a-f0-9]{12}", job_id):
raise ValueError("작업 ID가 올바르지 않습니다.")
return SECRET_DIR / f"{job_id}.json"
def _load_job_secrets(job_id: str) -> dict[str, str]:
path = _job_secret_path(job_id)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except (OSError, json.JSONDecodeError, TypeError):
return {}
if not isinstance(data, dict):
return {}
return {str(key): str(value) for key, value in data.items() if isinstance(value, str)}
def _write_job_secrets(job_id: str, data: dict[str, str]) -> None:
path = _job_secret_path(job_id)
cleaned = {str(key): str(value) for key, value in data.items() if str(value)}
if not cleaned:
path.unlink(missing_ok=True)
return
SECRET_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(cleaned, ensure_ascii=False), encoding="utf-8")
temp.chmod(0o600)
temp.replace(path)
def save_job_password(job_id: str, password: object) -> None:
normalized = _validate_job_password(password)
with JOB_SECRET_LOCK:
data = _load_job_secrets(job_id)
if normalized:
data["password"] = normalized
else:
data.pop("password", None)
_write_job_secrets(job_id, data)
def load_job_password(job_id: str) -> str:
with JOB_SECRET_LOCK:
try:
return _validate_job_password(_load_job_secrets(job_id).get("password", ""))
except ValueError:
return ""
def delete_job_password(job_id: str) -> None:
save_job_password(job_id, "")
def save_job_download_key(job_id: str, download_key: object) -> None:
normalized = _validate_gigafile_download_key(download_key)
with JOB_SECRET_LOCK:
data = _load_job_secrets(job_id)
if normalized:
data["download_key"] = normalized
else:
data.pop("download_key", None)
_write_job_secrets(job_id, data)
def load_job_download_key(job_id: str) -> str:
with JOB_SECRET_LOCK:
try:
return _validate_gigafile_download_key(_load_job_secrets(job_id).get("download_key", ""))
except ValueError:
return ""
def delete_job_download_key(job_id: str) -> None:
save_job_download_key(job_id, "")
def save_job_source_password(job_id: str, password: object) -> None:
normalized = _validate_1fichier_password(password)
with JOB_SECRET_LOCK:
data = _load_job_secrets(job_id)
if normalized:
data["source_password"] = normalized
else:
data.pop("source_password", None)
_write_job_secrets(job_id, data)
def load_job_source_password(job_id: str) -> str:
with JOB_SECRET_LOCK:
try:
return _validate_1fichier_password(_load_job_secrets(job_id).get("source_password", ""))
except ValueError:
return ""
def save_job_download_url(job_id: str, download_url: str) -> None:
normalized = str(download_url).strip()
with JOB_SECRET_LOCK:
data = _load_job_secrets(job_id)
if normalized:
data["download_url"] = normalized
else:
data.pop("download_url", None)
_write_job_secrets(job_id, data)
def load_job_download_url(job_id: str) -> str:
with JOB_SECRET_LOCK:
return _load_job_secrets(job_id).get("download_url", "")
def delete_job_secrets(job_id: str) -> None:
with JOB_SECRET_LOCK:
_job_secret_path(job_id).unlink(missing_ok=True)
@dataclass
class Job:
id: str
name: str
source: str
size: int
downloaded: int
status: str
created_at: str
target: str = ""
error: str = ""
sha256: str = ""
not_before: float = 0
output: str = ""
extracted: bool = False
extract: bool = True
transfer_mode: str = ""
inspection_pending: bool = False
delete_requested: bool = False
multipart_group: str = ""
multipart_entry: bool = False
multipart_name: str = ""
ARCHIVE_SUFFIXES = (
(".tar.gz", "tar"), (".tar.bz2", "tar"), (".tar.xz", "tar"),
(".tgz", "tar"), (".tbz2", "tar"), (".txz", "tar"),
(".zip", "zip"), (".tar", "tar"), (".7z", "7zip"), (".rar", "7zip"),
)
def split_archive_groups(names: list[str], destinations: list[str]) -> list[dict | None]:
"""Identify complete, contiguous multi-volume archive sets in one inspected batch."""
result: list[dict | None] = [None] * len(names)
consumed: set[int] = set()
def assign(indices: list[int], entry: int, output_name: str) -> None:
if len(indices) < 2 or any(index in consumed for index in indices):
return
for index in indices:
result[index] = {"entry": index == entry, "name": output_name}
consumed.add(index)
by_destination: dict[str, list[int]] = {}
for index, destination in enumerate(destinations):
by_destination.setdefault(destination, []).append(index)
for indices in by_destination.values():
# 7-Zip style volumes: name.7z.001, name.zip.001, name.rar.001.
numbered: dict[tuple[str, str], list[tuple[int, int]]] = {}
for index in indices:
match = re.fullmatch(r"(.+)\.(7z|zip|rar)\.(\d{3,})", names[index], re.I)
if match:
numbered.setdefault((match.group(1).casefold(), match.group(2).casefold()), []).append((int(match.group(3)), index))
for (base_key, _kind), parts in numbered.items():
parts.sort()
numbers = [number for number, _ in parts]
if numbers == list(range(1, len(parts) + 1)):
assign([index for _, index in parts], parts[0][1], names[parts[0][1]].rsplit(".", 2)[0])
# Modern RAR volumes: name.part1.rar, name.part01.rar, ...
rar_parts: dict[str, list[tuple[int, int]]] = {}
for index in indices:
match = re.fullmatch(r"(.+)\.part(\d+)\.rar", names[index], re.I)
if match:
rar_parts.setdefault(match.group(1).casefold(), []).append((int(match.group(2)), index))
for _base_key, parts in rar_parts.items():
parts.sort()
numbers = [number for number, _ in parts]
if numbers == list(range(1, len(parts) + 1)):
assign([index for _, index in parts], parts[0][1], re.sub(r"\.part\d+\.rar$", "", names[parts[0][1]], flags=re.I))
# Classic split ZIP: name.z01, name.z02, ... plus name.zip.
zip_parts: dict[str, list[tuple[int, int]]] = {}
zip_entries: dict[str, int] = {}
for index in indices:
match = re.fullmatch(r"(.+)\.z(\d{2,})", names[index], re.I)
if match:
zip_parts.setdefault(match.group(1).casefold(), []).append((int(match.group(2)), index))
elif names[index].lower().endswith(".zip"):
zip_entries[names[index][:-4].casefold()] = index
for base_key, parts in zip_parts.items():
parts.sort()
entry = zip_entries.get(base_key)
if entry is not None and [number for number, _ in parts] == list(range(1, len(parts) + 1)):
assign([index for _, index in parts] + [entry], entry, names[entry][:-4])
# Legacy RAR: name.rar plus name.r00, name.r01, ...
old_rar_parts: dict[str, list[tuple[int, int]]] = {}
rar_entries: dict[str, int] = {}
for index in indices:
match = re.fullmatch(r"(.+)\.r(\d{2,})", names[index], re.I)
if match:
old_rar_parts.setdefault(match.group(1).casefold(), []).append((int(match.group(2)), index))
elif names[index].lower().endswith(".rar") and not re.search(r"\.part\d+\.rar$", names[index], re.I):
rar_entries[names[index][:-4].casefold()] = index
for base_key, parts in old_rar_parts.items():
parts.sort()
entry = rar_entries.get(base_key)
if entry is not None and [number for number, _ in parts] == list(range(0, len(parts))):
assign([entry] + [index for _, index in parts], entry, names[entry][:-4])
return result
def split_archive_member(name: str) -> dict | None:
"""Describe an unmistakable numbered archive volume, including incomplete sets."""
match = re.fullmatch(r"(.+)\.(7z|zip|rar)\.(\d{3,})", name, re.I)
if match:
return {
"family": f"numbered:{match.group(2).casefold()}:{match.group(1).casefold()}",
"number": int(match.group(3)), "entry": int(match.group(3)) == 1,
"name": match.group(1), "entry_name": "",
}
match = re.fullmatch(r"(.+)\.part(\d+)\.rar", name, re.I)
if match:
return {
"family": f"part-rar:{match.group(1).casefold()}",
"number": int(match.group(2)), "entry": int(match.group(2)) == 1,
"name": match.group(1), "entry_name": "",
}
match = re.fullmatch(r"(.+)\.z(\d{2,})", name, re.I)
if match:
return {
"family": f"split-zip:{match.group(1).casefold()}",
"number": int(match.group(2)), "entry": False,
"name": match.group(1), "entry_name": f"{match.group(1)}.zip",
}
match = re.fullmatch(r"(.+)\.r(\d{2,})", name, re.I)
if match:
return {
"family": f"legacy-rar:{match.group(1).casefold()}",
"number": int(match.group(2)), "entry": False,
"name": match.group(1), "entry_name": f"{match.group(1)}.rar",
}
return None
def archive_kind(name: str) -> str:
lowered = name.lower()
if re.search(r"\.part\d+\.rar$", lowered):
return ""
return next((kind for suffix, kind in ARCHIVE_SUFFIXES if lowered.endswith(suffix)), "")
def archive_output_name(name: str) -> str:
lowered = name.lower()
for suffix, _ in ARCHIVE_SUFFIXES:
if lowered.endswith(suffix):
return name[:-len(suffix)] or "extracted"
return Path(name).stem or "extracted"
def _archive_relative_path(raw_name: str) -> PurePosixPath | None:
normalized = raw_name.replace("\\", "/")
path = PurePosixPath(normalized)
parts = [part for part in path.parts if part not in {"", "."}]
if not parts:
return None
if path.is_absolute():
raise ValueError("압축 파일에 절대 경로가 포함되어 있습니다.")
if any(part == ".." or "\x00" in part for part in parts):
raise ValueError("압축 파일에 저장 폴더를 벗어나는 경로가 포함되어 있습니다.")
return PurePosixPath(*parts)
def _checked_archive_totals(entries: int, total_size: int) -> None:
if entries > MAX_ARCHIVE_ENTRIES:
raise ValueError(f"압축 항목이 너무 많습니다(최대 {MAX_ARCHIVE_ENTRIES:,}개).")
if total_size > MAX_EXTRACTED_BYTES:
raise ValueError("압축을 풀었을 때의 전체 크기가 안전 한도를 초과합니다.")
def _password_failure(message: str) -> bool:
lowered = message.lower()
return any(fragment in lowered for fragment in (
"wrong password", "password is incorrect", "password required", "enter password",
"can not open encrypted archive", "data error in encrypted file",
))
def _missing_archive_volume_failure(message: str) -> bool:
lowered = message.lower()
return any(fragment in lowered for fragment in (
"missing volume", "can't open volume", "cannot open volume",
"unexpected end of archive", "unexpected end of data",
))
def _seven_zip_records(output: str) -> list[dict[str, str]]:
records: list[dict[str, str]] = []
current: dict[str, str] = {}
for line in output.splitlines() + [""]:
if not line.strip():
if current:
records.append(current)
current = {}
continue
if " = " in line:
key, value = line.split(" = ", 1)
current[key.strip()] = value.strip()
return records
def _curl_config_value(value: str) -> str:
if any(ord(character) < 32 or ord(character) == 127 for character in value):
raise ValueError("다운로드 요청 정보에 제어 문자를 사용할 수 없습니다.")
return value.replace("\\", "\\\\").replace('"', '\\"')
def _run_seven_zip(arguments: list[str], password: str, timeout: float | None = None, cancelled=None, process_callback=None) -> subprocess.CompletedProcess[str]:
if not SEVEN_ZIP.is_file():
raise ValueError("패키지의 7-Zip 압축 해제 엔진을 찾을 수 없습니다.")
environment = dict(os.environ)
environment.update({"LANG": "C", "LC_ALL": "C"})
command = [str(SEVEN_ZIP), *arguments]
run_options: dict[str, object] = {
"capture_output": True, "text": True, "errors": "replace", "env": environment, "timeout": timeout,
}
if password:
# A bare -p selects an empty password in 7-Zip. Omit it so the
# password prompt reads stdin; never expose the password in argv.
run_options["input"] = password + "\n"
else:
run_options["stdin"] = subprocess.DEVNULL
result = _run_interruptible(command, cancelled=cancelled, process_callback=process_callback, **run_options)
message = "\n".join((result.stdout, result.stderr)).strip()
if password:
message = message.replace(password, "***")
if result.returncode != 0:
if _password_failure(message) or not password and "encrypted" in message.lower():
raise PasswordRequiredError("압축 암호가 필요하거나 입력한 암호가 올바르지 않습니다.")
if _missing_archive_volume_failure(message):
raise MissingArchiveVolumeError("분할 압축의 다음 파일을 기다리고 있습니다.")
raise ValueError((message or "7-Zip 압축 해제 엔진이 작업을 완료하지 못했습니다.")[-400:])
return result
def _run_interruptible(command, *, input=None, timeout=None, capture_output=True, cancelled=None, process_callback=None, **options):
options.pop("stdin", None)
process = subprocess.Popen(command, stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, **options)
deadline = time.monotonic() + timeout if timeout else float("inf")
try:
if process_callback:
process_callback(process)
while True:
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("서비스 중지로 압축 해제를 일시정지했습니다.")
if time.monotonic() >= deadline:
raise TimeoutError("압축 해제 제한시간을 초과했습니다.")
try:
out, err = process.communicate(input=input, timeout=0.25)
return subprocess.CompletedProcess(command, process.returncode, out, err)
except subprocess.TimeoutExpired:
input = None
finally:
if process.poll() is None:
Controller._stop_process(process)
if process_callback:
process_callback(None)
def _copy_checked(source, output, length, cancelled=None):
while True:
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("서비스 중지로 후처리를 일시정지했습니다.")
block = source.read(length)
if not block:
return
output.write(block)
def _validate_seven_zip_listing(archive: Path, password: str, cancelled=None, process_callback=None) -> None:
result = _run_seven_zip(["l", "-slt", "-ba", "-sccUTF-8", str(archive)], password, timeout=120, cancelled=cancelled, process_callback=process_callback)
records = _seven_zip_records(result.stdout)
total_size = 0
entries = 0
for record in records:
raw_path = record.get("Path", "")
if not raw_path:
continue
_archive_relative_path(raw_path)
if record.get("Symbolic Link") or record.get("Hard Link"):
raise ValueError("압축 파일의 링크는 안전을 위해 해제하지 않습니다.")
attributes = record.get("Attributes", "")
if "L" in attributes[:5]:
raise ValueError("압축 파일의 심볼릭 링크는 안전을 위해 해제하지 않습니다.")
try:
total_size += max(0, int(record.get("Size", "0") or "0"))
except ValueError as exc:
raise ValueError("압축 항목의 크기 정보가 올바르지 않습니다.") from exc
entries += 1
_checked_archive_totals(entries, total_size)
def _validate_extracted_tree(destination: Path) -> None:
entries = 0
total_size = 0
for root, directories, files in os.walk(destination, followlinks=False):
root_path = Path(root)
for name in [*directories, *files]:
path = root_path / name
entries += 1
if path.is_symlink():
raise ValueError("압축 파일의 링크는 안전을 위해 해제하지 않습니다.")
resolved = path.resolve()
resolved.relative_to(destination.resolve())
if path.is_file():
total_size += path.stat().st_size
_checked_archive_totals(entries, total_size)
_ZIP_LEGACY_ENCODINGS = ("utf-8", "cp949", "shift_jis", "gb18030")
def _zip_name_score(value: str, encoding: str) -> int:
score = 3 if encoding == "utf-8" else 0
hangul = 0
kana = 0
cjk = 0
for character in value:
codepoint = ord(character)
category = unicodedata.category(character)
if character in "/\\._- ()[]{}" or character.isascii() and character.isalnum():
score += 1
elif 0xAC00 <= codepoint <= 0xD7A3 or 0x1100 <= codepoint <= 0x11FF:
hangul += 1
score += 3
elif 0x3040 <= codepoint <= 0x30FF:
kana += 1
score += 3
elif 0x3400 <= codepoint <= 0x9FFF:
cjk += 1
score += 2
elif 0x2500 <= codepoint <= 0x259F:
score -= 8
elif category.startswith(("L", "N", "P", "Z")):
score += 1
elif category.startswith("C"):
score -= 8
if encoding == "cp949":
score += hangul * 4
elif encoding == "shift_jis":
score += kana * 4
elif encoding == "gb18030":
score += cjk * 2
return score
def _zip_legacy_encoding(infos: list[zipfile.ZipInfo]) -> str | None:
raw_names: list[bytes] = []
current_score = 0
for info in infos:
if info.flag_bits & 0x800:
continue
try:
raw = info.filename.encode("cp437")
except UnicodeEncodeError:
continue
if not any(byte >= 0x80 for byte in raw):
continue
raw_names.append(raw)
current_score += _zip_name_score(info.filename, "cp437")
if not raw_names:
return None
best_encoding: str | None = None
best_score = current_score
for encoding in _ZIP_LEGACY_ENCODINGS:
try:
decoded = [raw.decode(encoding, errors="strict") for raw in raw_names]
except (UnicodeDecodeError, LookupError):
continue
candidate_score = sum(_zip_name_score(name, encoding) for name in decoded)
if candidate_score > best_score + 3:
best_encoding = encoding
best_score = candidate_score
return best_encoding
def _zip_entry_name(info: zipfile.ZipInfo, legacy_encoding: str | None) -> str:
if not legacy_encoding or info.flag_bits & 0x800:
return info.filename
try:
return info.filename.encode("cp437").decode(legacy_encoding, errors="strict")
except (UnicodeEncodeError, UnicodeDecodeError, LookupError):
return info.filename
def _extract_with_seven_zip(archive: Path, destination: Path, password: str, cancelled=None, process_callback=None) -> None:
_validate_seven_zip_listing(archive, password, cancelled=cancelled, process_callback=process_callback)
_run_seven_zip(
["x", "-y", "-bd", "-bb0", "-sccUTF-8", f"-o{destination}", str(archive)],
password,
timeout=ARCHIVE_EXTRACT_TIMEOUT_SECONDS,
cancelled=cancelled,
process_callback=process_callback,
)
_validate_extracted_tree(destination)
def extract_archive_safely(archive: Path, destination: Path, password: str = "", cancelled=None, process_callback=None) -> None:
kind = archive_kind(archive.name)
password = _validate_job_password(password)
if kind == "7zip" or password:
_extract_with_seven_zip(archive, destination, password, cancelled=cancelled, process_callback=process_callback)
return
if kind == "zip":
try:
with zipfile.ZipFile(archive) as source:
infos = source.infolist()
legacy_encoding = _zip_legacy_encoding(infos)
_checked_archive_totals(len(infos), sum(max(0, info.file_size) for info in infos))
for info in infos:
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("Archive processing stopped")
relative = _archive_relative_path(_zip_entry_name(info, legacy_encoding))
if relative is None:
continue
mode = (info.external_attr >> 16) & 0xFFFF
if stat.S_ISLNK(mode):
raise ValueError("압축 파일의 심볼릭 링크는 안전을 위해 해제하지 않습니다.")
output = destination.joinpath(*relative.parts)
if info.is_dir():
output.mkdir(parents=True, exist_ok=True)
continue
output.parent.mkdir(parents=True, exist_ok=True)
with source.open(info) as reader, output.open("xb") as writer:
_copy_checked(reader, writer, length=1024 * 1024, cancelled=cancelled)
except (NotImplementedError, RuntimeError) as exc:
shutil.rmtree(destination, ignore_errors=True)
destination.mkdir(mode=0o700)
if "password" in str(exc).lower():
raise PasswordRequiredError("압축 암호가 필요합니다.") from exc
_extract_with_seven_zip(archive, destination, password, cancelled=cancelled, process_callback=process_callback)
return
if kind == "tar":
with tarfile.open(archive, mode="r:*") as source:
members = source.getmembers()
_checked_archive_totals(len(members), sum(max(0, member.size) for member in members if member.isfile()))
for member in members:
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("Archive processing stopped")
relative = _archive_relative_path(member.name)
if relative is None:
continue
if not (member.isdir() or member.isfile()):
raise ValueError("압축 파일의 링크 또는 특수 파일은 안전을 위해 해제하지 않습니다.")
output = destination.joinpath(*relative.parts)
if member.isdir():
output.mkdir(parents=True, exist_ok=True)
continue
output.parent.mkdir(parents=True, exist_ok=True)
reader = source.extractfile(member)
if reader is None:
raise ValueError("압축 항목을 읽을 수 없습니다.")
with reader, output.open("xb") as writer:
_copy_checked(reader, writer, length=1024 * 1024, cancelled=cancelled)
return
raise ValueError("자동 압축 해제를 지원하지 않는 형식입니다.")
def fit_download_name(name: str, limit: int = 240) -> str:
"""Bound a filename component in UTF-8 bytes, retaining a useful suffix."""
if len(name.encode("utf-8")) <= limit:
return name
suffix = next((s for s, _ in ARCHIVE_SUFFIXES if name.lower().endswith(s)), "")
if not suffix:
candidate = Path(name).suffix
suffix = candidate if re.fullmatch(r"\.[A-Za-z0-9]{1,16}", candidate) else ""
if suffix:
suffix = name[-len(suffix):]
tag = "~" + hashlib.sha256(name.encode("utf-8")).hexdigest()[:10]
budget = limit - len((tag + suffix).encode("utf-8"))
stem = name[:-len(suffix)] if suffix else name
return stem.encode("utf-8")[:budget].decode("utf-8", "ignore").rstrip(". ") + tag + suffix
def unique_destination(path: Path) -> Path:
path = path.with_name(fit_download_name(path.name))
if not path.exists():
return path
for number in range(1, 10_000):
candidate = path.with_name(f"{path.name} ({number})") if path.is_dir() or not path.suffix else path.with_name(f"{path.stem} ({number}){path.suffix}")
candidate = candidate.with_name(fit_download_name(candidate.name))
if not candidate.exists():
return candidate
raise ValueError("같은 이름의 결과가 너무 많아 저장할 수 없습니다.")
def job_workspace(target_dir: str, job_id: str) -> Path:
if not re.fullmatch(r"[a-f0-9]{12}", job_id):
raise ValueError("작업 ID가 올바르지 않습니다.")
target = Path(target_dir).resolve()
workspace = (target / ".nasdrop-tmp" / job_id).resolve()
workspace.relative_to(target)
return workspace
def migrate_legacy_workspace(target_dir: str, name: str, job_id: str, workspace: Path) -> int:
target = Path(target_dir).resolve()
prefix = f".{name}.{job_id}."
moved = 0
for source in list(target.iterdir()) + list(workspace.iterdir()):
if not source.is_file() or not source.name.startswith(prefix):
continue
remainder = source.name[len(prefix):]
if remainder != "assembling" and not re.fullmatch(r"segment\.[0-7](?:\.(?:more|headers))?", remainder):
continue
destination = workspace / f".{job_id}.{remainder}"
if destination.exists():
raise ValueError("이전 다운로드 조각과 새 조각이 충돌합니다. 기존 데이터를 보존했습니다.")
else:
source.rename(destination)
moved += 1
return moved
def promote_download(artifact: Path, target_dir: str, auto_extract: bool, password: str = "", *, cancelled=None, publication_lock=None, process_callback=None) -> tuple[Path, bool]:
target = Path(target_dir).resolve()
if not artifact.is_file():
raise ValueError("완성된 임시 파일을 찾을 수 없습니다.")
kind = archive_kind(artifact.name) if auto_extract else ""
if kind:
extracted = artifact.parent / "extracted"
if extracted.exists():
shutil.rmtree(extracted)
extracted.mkdir(mode=0o700)
extract_archive_safely(artifact, extracted, password, cancelled=cancelled, process_callback=process_callback)
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("서비스 중지로 후처리를 일시정지했습니다.")
base_name = archive_output_name(artifact.name)
children = list(extracted.iterdir())
with publication_lock or threading.RLock():
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("Publication stopped")
output = unique_destination(target / base_name)
if len(children) == 1 and children[0].is_dir() and children[0].name.casefold() == base_name.casefold():
children[0].rename(output)
extracted.rmdir()
else:
extracted.rename(output)
return output, True
with publication_lock or threading.RLock():
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("Publication stopped")
output = unique_destination(target / artifact.name)
artifact.rename(output)
return output, False
def publish_extracted(extracted: Path, target_dir: str, base_name: str, *, cancelled=None, publication_lock=None) -> Path:
"""Publish one already validated extraction tree with the normal collision rules."""
target = Path(target_dir).resolve()
children = list(extracted.iterdir())
with publication_lock or threading.RLock():
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("Publication stopped")
output = unique_destination(target / fit_download_name(base_name))
if len(children) == 1 and children[0].is_dir() and children[0].name.casefold() == base_name.casefold():
children[0].rename(output)
extracted.rmdir()
else:
extracted.rename(output)
return output
STATE_SAVE_RETRY_DELAYS = (0.05, 0.2, 1.0, 2.0, 5.0)
def _write_state_snapshot(path: Path, payload: str) -> None:
"""Atomically persist state and recover from a transient missing state directory."""
for attempt in range(len(STATE_SAVE_RETRY_DELAYS) + 1):
temporary = path.with_name(
f".{path.name}.{os.getpid()}.{threading.get_ident()}.{secrets.token_hex(4)}.tmp"
)
try:
path.parent.mkdir(parents=True, exist_ok=True)
with temporary.open("x", encoding="utf-8") as output:
output.write(payload)
output.flush()
os.fsync(output.fileno())
temporary.chmod(0o600)
os.replace(temporary, path)
return
except FileNotFoundError:
temporary.unlink(missing_ok=True)
if attempt >= len(STATE_SAVE_RETRY_DELAYS):
raise
time.sleep(STATE_SAVE_RETRY_DELAYS[attempt])
except BaseException:
temporary.unlink(missing_ok=True)
raise
class Controller:
def __init__(self) -> None:
self.stopping = False
self.lock = threading.RLock()
self.condition = threading.Condition(self.lock)
self.jobs: dict[str, Job] = {}
self.processes: dict[str, subprocess.Popen[str]] = {}
self.private_downloads: dict[str, dict[str, str]] = {}
self.running_providers: dict[str, set[str]] = {}
self.postprocess_waiting: list[str] = []
self.processing_job: str | None = None
self.load()
threading.Thread(target=self._dispatcher, name="nasdrop-dispatcher", daemon=True).start()
def load(self) -> None:
if not STATE_FILE.exists():
return
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
for item in data:
if item.get("delete_requested"):
# Do not replay a destructive request automatically after a crash.
item["delete_requested"] = False
item["status"] = "stopping"
if item.get("status") in {"inspecting", "downloading", "waiting_processing", "verifying", "extracting", "publishing", "ready", "stopping"}:
item["status"] = "paused"
item["error"] = "서비스가 재시작되어 작업을 일시정지했습니다. 다시 시작할 수 있습니다."
job = Job(**item)
self.jobs[job.id] = job
except Exception:
self.jobs = {}
def save(self) -> None:
payload = json.dumps([asdict(x) for x in self.jobs.values()], ensure_ascii=False, indent=2)
_write_state_snapshot(STATE_FILE, payload)
def public_jobs(self) -> list[dict]:
with self.lock:
jobs = []
onefichier_head = self._onefichier_queue_head()
for job in reversed(list(self.jobs.values())):
item = asdict(job)
if (self._provider_for_job(job) == "1fichier" and job.status == "queued"
and onefichier_head and job.id != onefichier_head):
item["not_before"] = 0
item["error"] = "1fichier는 목록 아래 작업이 끝난 뒤 순차적으로 시작합니다."
item["error_code"] = public_error_code(item["error"]) if item["error"] else ""
jobs.append(item)
return jobs
def active(self) -> bool:
return any(job.status in {"inspecting", "ready", "downloading", "waiting_processing", "verifying", "extracting", "publishing"} for job in self.jobs.values())
def start(self, file: dict, target: str = "", extract: bool | None = None, password: str = "") -> Job:
return self.start_many([file], target, extract, password)[0]
def enqueue_link(self, url: str, target: str = "", extract: bool | None = None, password: str = "", download_key: str = "") -> Job:
parsed = urlparse(url.strip())
host = (parsed.hostname or "").lower()
try:
port = parsed.port
except ValueError as exc:
raise ValueError("다운로드 링크의 포트가 올바르지 않습니다.") from exc
if parsed.scheme != "https" or parsed.username or parsed.password or port not in {None, 443}:
raise ValueError("비동기 등록을 지원하는 정식 HTTPS 링크가 아닙니다.")
if GIGAFILE_HOST.fullmatch(host):
provider = "gigafile"
file_id = service_path_id(parsed)
canonical = f"https://{host}/{file_id}"
placeholder_name = f"GigaFile {file_id}"
elif host in {"gofile.io", "www.gofile.io"}:
if parsed.query or parsed.fragment:
raise ValueError("정식 GoFile HTTPS 공유 링크가 아닙니다.")
provider = "gofile"
file_id = service_path_id(parsed, "d")
canonical = f"https://gofile.io/d/{file_id}"
placeholder_name = f"GoFile {file_id}"
else:
raise ValueError("비동기 등록을 지원하는 다운로드 링크가 아닙니다.")
if extract is not None and not isinstance(extract, bool):
raise ValueError("압축 해제 선택값이 올바르지 않습니다.")
secret = _validate_job_password(password)
source_key = _validate_gigafile_download_key(download_key)
if provider != "gigafile" and source_key:
raise ValueError("GigaFile 다운로드 키는 GigaFile 링크에만 사용할 수 있습니다.")
destination = normalize_target(target or NAS_TARGET)
with self.condition:
if self.stopping:
raise ValueError("서비스가 종료 중입니다.")
if sum(j.inspection_pending for j in self.jobs.values()) >= 100:
raise ValueError("정보 확인 대기 작업이 너무 많습니다. 잠시 후 다시 시도해 주세요.")
job = Job(secrets.token_hex(6), placeholder_name, canonical,
0, 0, "inspecting", now(), target=destination,
extract=AUTO_EXTRACT_ARCHIVES if extract is None else extract, inspection_pending=True)
if job.extract and secret:
save_job_password(job.id, secret)
if source_key:
save_job_download_key(job.id, source_key)
self.jobs[job.id] = job
try:
self.save()
except Exception:
self.jobs.pop(job.id, None)
delete_job_secrets(job.id)
raise
self.condition.notify_all()
return job
def enqueue_gigafile(self, url: str, target: str = "", extract: bool | None = None, password: str = "", download_key: str = "") -> Job:
parsed = urlparse(url.strip())
if not GIGAFILE_HOST.fullmatch((parsed.hostname or "").lower()):
raise ValueError("정식 GigaFile HTTPS 링크가 아닙니다.")
return self.enqueue_link(url, target, extract, password, download_key)
def _resolve_queued_link(self, job_id: str) -> None:
with self.lock:
job = self.jobs[job_id]
source = job.source
provider = self._provider_for_job(job)
download_key = load_job_download_key(job_id) if provider == "gigafile" else ""
if provider == "gigafile":
inspected = inspect_gigafile(source, download_key) if download_key else inspect_gigafile(source)
elif provider == "gofile":
inspected = inspect_gofile(source)
else:
raise ValueError("비동기 정보 확인을 지원하지 않는 서비스입니다.")
files = inspected.get("files") if inspected.get("batch") else [inspected]
with self.condition:
if self.stopping or SHUTDOWN_EVENT.is_set() or self.jobs.get(job_id) is not job or job.status != "inspecting":
return
# Replace the placeholder and persist children in one state-file write.
saved_jobs, saved_private = dict(self.jobs), dict(self.private_downloads)
try:
self.start_many(
files, job.target, job.extract, load_job_password(job_id),
download_key=download_key, replace_id=job_id,
)
except Exception:
self.jobs, self.private_downloads = saved_jobs, saved_private
raise
def start_many(self, files: list[dict], target: str = "", extract: bool | None = None, password: str = "", *, download_key: str = "", source_password: str = "", replace_id: str | None = None) -> list[Job]:
if not files:
raise ValueError("다운로드할 파일이 없습니다.")
if extract is not None and not isinstance(extract, bool):
raise ValueError("압축 해제 선택값이 올바르지 않습니다.")
should_extract = AUTO_EXTRACT_ARCHIVES if extract is None else extract
normalized_password = _validate_job_password(password)
normalized_download_key = _validate_gigafile_download_key(download_key)
normalized_source_password = _validate_1fichier_password(source_password)
base_destination = normalize_target(target or NAS_TARGET)
destinations = [prepare_batch_target(base_destination, str(file.get("relative_path", ""))) for file in files]
multipart = split_archive_groups([str(file.get("name", "")) for file in files], destinations)
jobs = []
provider_positions: dict[str, int] = {}
queued_at = time.time()
with self.condition:
for file, destination in zip(files, destinations):
provider = str(file.get("provider", "gigafile"))
position = provider_positions.get(provider, 0)
provider_positions[provider] = position + 1
job = Job(
id=secrets.token_hex(6), name=file["name"], source=file["url"],
size=int(file["size"]), downloaded=0, status="queued", created_at=now(), target=destination,
not_before=queued_at + position * BATCH_QUEUE_STAGGER_SECONDS if len(files) > 1 else 0,
extract=should_extract,
)
self.jobs[job.id] = job
self.private_downloads[job.id] = {
"provider": provider,
"download_url": str(file.get("download_url", "")),
"download_token": str(file.get("download_token", "")),
"download_mode": str(file.get("download_mode", "")),
"expected_sha256": str(file.get("expected_sha256", "")),
"target": destination,
}
if provider in {"buzzheavier", "akirabox", "vikingfile", "sendnow", "xshare"}:
save_job_download_url(job.id, str(file.get("download_url", "")))
if should_extract and normalized_password:
save_job_password(job.id, normalized_password)
if provider == "gigafile" and normalized_download_key:
save_job_download_key(job.id, normalized_download_key)
if provider == "1fichier" and normalized_source_password:
save_job_source_password(job.id, normalized_source_password)
jobs.append(job)
grouped: dict[tuple[str, str], list[int]] = {}
for index, descriptor in enumerate(multipart):
if descriptor:
grouped.setdefault((destinations[index], str(descriptor["name"]).casefold()), []).append(index)
for member_indices in grouped.values():
group_id = jobs[member_indices[0]].id
for index in member_indices:
descriptor = multipart[index]
jobs[index].multipart_group = group_id
jobs[index].multipart_entry = bool(descriptor and descriptor["entry"])
jobs[index].multipart_name = str(descriptor["name"]) if descriptor else ""
multipart_before = {
candidate.id: (candidate.multipart_group, candidate.multipart_entry, candidate.multipart_name)
for candidate in self.jobs.values()
}
self._reconcile_split_archive_jobs()
replaced = self.jobs.pop(replace_id, None) if replace_id else None
try:
self.save()
except Exception:
for added in jobs:
self.jobs.pop(added.id, None)
self.private_downloads.pop(added.id, None)
if replaced:
self.jobs[replaced.id] = replaced
for candidate in self.jobs.values():
previous = multipart_before.get(candidate.id)
if previous:
candidate.multipart_group, candidate.multipart_entry, candidate.multipart_name = previous
raise
if replaced:
try:
delete_job_secrets(replaced.id)
except OSError:
LOGGER.warning("Could not remove retired inspection secret")
self.condition.notify_all()
return jobs
def _provider_for_job(self, job: Job) -> str:
private = self.private_downloads.get(job.id, {})
return private.get("provider") or provider_for_url(job.source)
def _reconcile_split_archive_jobs(self) -> bool:
"""Join numbered volumes from separate registrations without publishing early parts."""
eligible = {
"inspecting", "queued", "ready", "downloading", "waiting_processing",
"verifying", "paused", "extracting",
}
candidates = [
job for job in self.jobs.values()
if job.extract and not job.output and job.status in eligible
]
plain: dict[tuple[str, str, str], list[Job]] = {}
families: dict[tuple[str, str, str], list[tuple[Job, dict]]] = {}
for candidate in candidates:
provider = self._provider_for_job(candidate)
target = candidate.target or NAS_TARGET
plain.setdefault((provider, target, candidate.name.casefold()), []).append(candidate)
descriptor = split_archive_member(candidate.name)
if descriptor:
families.setdefault((provider, target, str(descriptor["family"])), []).append((candidate, descriptor))
changed = False
for (provider, target, _family), described in families.items():
number_counts: dict[int, int] = {}
for _candidate, descriptor in described:
number = int(descriptor["number"])
number_counts[number] = number_counts.get(number, 0) + 1
if any(count > 1 for count in number_counts.values()):
# Ambiguous duplicate volume numbers must never be combined.
for candidate, descriptor in described:
values = (candidate.id, bool(descriptor["entry"]), str(descriptor["name"]))
if (candidate.multipart_group, candidate.multipart_entry, candidate.multipart_name) != values:
candidate.multipart_group, candidate.multipart_entry, candidate.multipart_name = values
changed = True
continue
members = list(described)
entry_name = str(described[0][1].get("entry_name", ""))
if entry_name:
entries = plain.get((provider, target, entry_name.casefold()), [])
if len(entries) == 1:
members.append((entries[0], {
"entry": True, "name": described[0][1]["name"],
}))
existing_groups = [candidate.multipart_group for candidate, _descriptor in members if candidate.multipart_group]
group_id = existing_groups[0] if existing_groups else members[0][0].id
output_name = str(described[0][1]["name"])
for candidate, descriptor in members:
values = (group_id, bool(descriptor.get("entry")), output_name)
if (candidate.multipart_group, candidate.multipart_entry, candidate.multipart_name) != values:
candidate.multipart_group, candidate.multipart_entry, candidate.multipart_name = values
changed = True
return changed
def _provider_limit(self) -> int:
return SAME_PROVIDER_LIMIT if ALLOW_SAME_PROVIDER_PARALLEL else 1
def _onefichier_queue_head(self) -> str:
# public_jobs() renders newest first, so the first persisted job appears
# at the bottom of the visible list. Keep that bottom job as the sole
# 1fichier owner until it reaches a terminal or user-paused state.
active = {"inspecting", "queued", "ready", "downloading", "waiting_processing",
"verifying", "extracting", "publishing", "stopping"}
for candidate in self.jobs.values():
if candidate.status in active and self._provider_for_job(candidate) == "1fichier":
return candidate.id
return ""
def _can_start(self, job: Job) -> bool:
if getattr(self, "stopping", False) or any(job.id in ids for ids in self.running_providers.values()):
return False
if DISK_PROTECTION and (self.postprocess_waiting or self.processing_job):
return False
provider = self._provider_for_job(job)
if job.inspection_pending and any(
running_job and running_job.inspection_pending and self._provider_for_job(running_job) == provider
for ids in self.running_providers.values() for running_id in ids
for running_job in (self.jobs.get(running_id),)
):
return False
if provider == "gofile" and _gofile_cooldown_status()["active"]:
return False
if provider == "1fichier":
if self.running_providers.get(provider):
return False
if self._onefichier_queue_head() != job.id:
return False
running_total = sum(len(job_ids) for job_ids in self.running_providers.values())
return running_total < MAX_PARALLEL_DOWNLOADS and len(self.running_providers.get(provider, set())) < self._provider_limit()
def settings_changed(self) -> None:
with self.condition:
self.condition.notify_all()
def _dispatcher(self) -> None:
while True:
with self.condition:
if getattr(self, "stopping", False):
return
current = time.time()
queued = next(
(
job for job in self.jobs.values()
if job.status in {"queued", "inspecting"} and job.not_before <= current and self._can_start(job)
),
None,
)
if queued is None:
delays = [
job.not_before - current for job in self.jobs.values()
if job.status == "queued" and job.not_before > current
]
self.condition.wait(timeout=max(0.1, min([*delays, 1.0])))
continue
provider = self._provider_for_job(queued)
queued.status = "inspecting" if queued.inspection_pending else "ready"
queued.error = ""
self.running_providers.setdefault(provider, set()).add(queued.id)
self.save()
job_id = queued.id
threading.Thread(
target=self._run_guarded,
args=(job_id, provider),
name=f"nasdrop-{provider}-{job_id}",
daemon=True,
).start()
def _run_guarded(self, job_id: str, provider: str) -> None:
try:
if self.jobs[job_id].inspection_pending:
self._resolve_queued_link(job_id)
else:
self._run(job_id)
except Exception as exc:
process = self.processes.get(job_id)
if process is not None:
self._stop_process(process)
with self.lock:
job = self.jobs.get(job_id)
if job and job.status not in {"paused", "cancelled", "stopping"}:
if isinstance(exc, GofileCooldownError):
job.status = "queued"
job.not_before = _gofile_cooldown_status()["until"]
elif isinstance(exc, OneFichierWaitError):
job.status = "queued"
job.not_before = time.time() + exc.seconds
job.downloaded = 0
try:
workspace = job_workspace(job.target or NAS_TARGET, job.id)
for suffix in ("segment.0", "segment.0.more", "segment.0.headers"):
(workspace / f".{job.id}.{suffix}").unlink(missing_ok=True)
(workspace / ".response-headers").unlink(missing_ok=True)
except (OSError, ValueError):
LOGGER.warning("Could not remove stale 1fichier partial data")
elif isinstance(exc, GigaFileDownloadKeyRequiredError):
if isinstance(exc, GigaFileDownloadKeyInvalidError):
delete_job_download_key(job_id)
job.status = "download_key_required"
job.not_before = 0
else:
job.status = "paused" if SHUTDOWN_EVENT.is_set() else "failed"
job.error = (str(exc) or "다운로드 준비 중 오류가 발생했습니다.")[-400:]
self.processes.pop(job_id, None)
self.private_downloads.pop(job_id, None)
self.save()
finally:
# Keep ownership if termination cannot be confirmed. Deletion must not
# race a surviving transfer/extraction process even on an error path.
process = self.processes.get(job_id)
if process is not None:
try:
self._stop_process(process)
except Exception:
LOGGER.warning("Worker process stop unconfirmed; preserving job and workspace")
return
self.processes.pop(job_id, None)
with self.condition:
running = self.running_providers.get(provider)
if running:
running.discard(job_id)
if not running:
self.running_providers.pop(provider, None)
job = self.jobs.get(job_id)
if job and job.status == "stopping" and not job.delete_requested:
job.status = "paused"
self.save()
self.condition.notify_all()
def _local_size(self, prefix: str, size: int, mode: str) -> int:
prefix_path = Path(prefix)
total = 0
chunk = segment_chunk(size, mode)
def length(path):
try:
return path.stat().st_size if path.is_file() else 0
except OSError:
return 0
for index in range(segment_count(size, mode)):
part = prefix_path.with_name(prefix_path.name + str(index))
start, end = index * chunk, min(size - 1, (index + 1) * chunk - 1)
existing = length(part)
covered = existing
try:
with part.with_name(part.name + '.headers').open('rb') as stream:
raw = stream.read(65536)
blocks = [b for b in re.split(rb'\r?\n\r?\n', raw) if re.match(rb'HTTP/\S+ \d{3}', b)]
block = blocks[-1] if blocks else b''
status = int(block.split(None, 2)[1]) if block else 0
more = length(part.with_name(part.name + '.more'))
match = re.search(rb'(?im)^content-range:\s*bytes (\d+)-(\d+)/(\d+)\s*$', block)
if status == 206 and match:
first, last, response_size = map(int, match.groups())
offset = first - start
if response_size == size and last == end and 0 <= offset <= existing and more <= end - first + 1:
covered = max(existing, offset + more)
elif status == 200 and start == 0 and end == size - 1:
covered = max(existing, min(more, size))
except (OSError, ValueError, IndexError):
pass
# The merger may have removed .more/headers since the first stat.
total += min(end - start + 1, max(covered, length(part)))
return total
@staticmethod
def _file_sha256(path: Path, cancelled=None) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(4 * 1024 * 1024), b""):
if SHUTDOWN_EVENT.is_set() or (cancelled and cancelled()):
raise InterruptedError("서비스 중지로 후처리를 일시정지했습니다.")
digest.update(block)
return digest.hexdigest()
def _assemble_artifact(self, job: Job, workspace: Path, private: dict[str, str]) -> tuple[Path, str]:
cancelled = lambda: job.status in {"paused", "cancelled", "stopping"}
artifact = workspace / job.name
if artifact.is_file() and job.sha256:
return artifact, job.sha256
count = segment_count(job.size, "single" if private.get("download_mode") == "gigafile_zip" else private.get("transfer_mode") or job.transfer_mode or "segmented")
parts = [workspace / f".{job.id}.segment.{index}" for index in range(count)]
if any(not part.is_file() for part in parts):
raise ValueError("다운로드 조각이 모두 준비되지 않았습니다.")
assembling = workspace / f".{job.id}.assembling"
if count == 1:
assembling = parts[0]
else:
with assembling.open("wb") as output:
for part in parts:
with part.open("rb") as source:
_copy_checked(source, output, length=4 * 1024 * 1024, cancelled=cancelled)
actual_size = assembling.stat().st_size
size_valid = actual_size >= job.size if private.get("download_mode") == "gigafile_zip" else actual_size == job.size
if not size_valid:
assembling.unlink(missing_ok=True)
raise ValueError("결합된 파일 크기가 예상값과 일치하지 않습니다.")
digest = self._file_sha256(assembling, cancelled=cancelled)
expected_sha256 = private.get("expected_sha256", "").lower()
if re.fullmatch(r"[0-9a-f]{64}", expected_sha256) and digest != expected_sha256:
assembling.unlink(missing_ok=True)
for part in parts:
part.unlink(missing_ok=True)
raise ValueError("무결성 검사에 실패하여 손상된 다운로드 조각을 삭제했습니다.")
assembling.replace(artifact)
for part in parts:
part.unlink(missing_ok=True)
part.with_name(part.name + ".more").unlink(missing_ok=True)
return artifact, digest
def _apply_response_filename(self, job: Job, workspace: Path, artifact: Path, private: dict[str, str]) -> Path:
headers_path = workspace / ".response-headers"
try:
if private.get("provider") not in {"gigafile", "buzzheavier", "akirabox", "vikingfile", "sendnow", "xshare", "1fichier"}:
return artifact
actual_name = response_download_name(headers_path)
if not actual_name or actual_name == job.name:
return artifact
destination = workspace / actual_name
if destination.exists() and destination != artifact:
destination = unique_destination(destination)
artifact.replace(destination)
with self.lock:
job.name = destination.name
self.save()
return destination
finally:
headers_path.unlink(missing_ok=True)
def _enter_postprocessing(self, job_id: str) -> bool:
with self.condition:
self.processes.pop(job_id, None)
if job_id not in self.postprocess_waiting:
self.postprocess_waiting.append(job_id)
job = self.jobs[job_id]
if job.status in {"paused", "cancelled", "stopping"}:
self.postprocess_waiting.remove(job_id)
return False
job.status = "waiting_processing"
job.error = ""
self.save()
self.condition.notify_all()
while True:
if job.status in {"paused", "cancelled", "stopping"} or SHUTDOWN_EVENT.is_set():
if job_id in self.postprocess_waiting:
self.postprocess_waiting.remove(job_id)
self.condition.notify_all()
return False
first = bool(self.postprocess_waiting) and self.postprocess_waiting[0] == job_id
downloads_running = any(process.poll() is None for process in self.processes.values())
disk_ready = not DISK_PROTECTION or not downloads_running
if first and self.processing_job is None and disk_ready:
self.postprocess_waiting.pop(0)
self.processing_job = job_id
self.condition.notify_all()
return True
self.condition.wait(timeout=1)
def _leave_postprocessing(self, job_id: str) -> None:
with self.condition:
if self.processing_job == job_id:
self.processing_job = None
if job_id in self.postprocess_waiting:
self.postprocess_waiting.remove(job_id)
self.condition.notify_all()
def _track_processing_process(self, job_id: str, process) -> None:
with self.condition:
if process is None:
self.processes.pop(job_id, None)
else:
self.processes[job_id] = process
self.condition.notify_all()
def _multipart_members(self, group_id: str) -> list[Job]:
return [candidate for candidate in self.jobs.values() if candidate.multipart_group == group_id]
def _multipart_artifact(self, job: Job) -> Path:
return job_workspace(job.target or NAS_TARGET, job.id) / job.name
def _postprocess_multipart(self, job: Job, workspace: Path) -> bool:
"""Stage and extract a complete split archive. Return True when handled."""
if not job.extract or not job.multipart_group:
return False
with self.lock:
members = self._multipart_members(job.multipart_group)
structure = split_archive_groups(
[member.name for member in members],
[member.target or NAS_TARGET for member in members],
)
complete_set = len(members) >= 2 and all(structure)
ready = complete_set and all(
member.sha256 and self._multipart_artifact(member).is_file() for member in members
)
if not ready:
job.status = "waiting_processing"
job.error = "분할 압축의 나머지 조각을 기다리고 있습니다."
self.save()
return True
entry = next((member for member in members if member.multipart_entry), None)
if entry is None:
raise ValueError("분할 압축의 시작 파일을 찾을 수 없습니다.")
for member in members:
member.status = "extracting"
member.error = ""
self.save()
input_dir = workspace / "multipart-input"
extracted = workspace / "multipart-extracted"
shutil.rmtree(input_dir, ignore_errors=True)
shutil.rmtree(extracted, ignore_errors=True)
input_dir.mkdir(mode=0o700)
extracted.mkdir(mode=0o700)
try:
entry_path = None
for member in members:
source = self._multipart_artifact(member)
destination = input_dir / member.name
if destination.exists():
raise ValueError("분할 압축 조각 이름이 중복됩니다.")
try:
os.link(source, destination)
except OSError:
with source.open("rb") as reader, destination.open("xb") as writer:
_copy_checked(reader, writer, length=4 * 1024 * 1024,
cancelled=lambda: any(item.status in {"paused", "cancelled", "stopping"} for item in members))
if member.id == entry.id:
entry_path = destination
if entry_path is None:
raise ValueError("분할 압축의 시작 파일을 준비하지 못했습니다.")
_extract_with_seven_zip(
entry_path, extracted, load_job_password(entry.id),
cancelled=lambda: any(item.delete_requested or item.status in {"paused", "cancelled", "stopping"} for item in members),
process_callback=lambda process: self._track_processing_process(job.id, process),
)
output = publish_extracted(
extracted, job.target or NAS_TARGET, entry.multipart_name or archive_output_name(entry.name),
cancelled=lambda: any(item.delete_requested or item.status in {"paused", "cancelled", "stopping"} for item in members),
publication_lock=self.lock,
)
except PasswordRequiredError as exc:
for member in members:
delete_job_password(member.id)
with self.lock:
for member in members:
member.status = "password_required" if member.id == entry.id else "waiting_processing"
member.error = str(exc) if member.id == entry.id else "분할 압축 암호 입력을 기다리고 있습니다."
self.save()
return True
except MissingArchiveVolumeError as exc:
with self.lock:
for member in members:
member.status = "waiting_processing"
member.error = str(exc)
self.save()
return True
except Exception as exc:
with self.lock:
for member in members:
if member.status not in {"paused", "cancelled", "stopping"}:
member.status = "failed"
member.error = (str(exc) or "분할 압축을 처리하지 못했습니다.")[-400:]
self.save()
raise
finally:
shutil.rmtree(input_dir, ignore_errors=True)
shutil.rmtree(extracted, ignore_errors=True)
for member in members:
shutil.rmtree(job_workspace(member.target or NAS_TARGET, member.id), ignore_errors=True)
delete_job_secrets(member.id)
with self.lock:
for member in members:
member.status = "stopping" if member.delete_requested else "completed"
member.output = str(output)
member.extracted = True
member.error = ""
self.private_downloads.pop(member.id, None)
self.save()
return True
def _postprocess(self, job_id: str, workspace: Path, artifact: Path, target_dir: str, verify_artifact: bool = False) -> None:
if not self._enter_postprocessing(job_id):
return
try:
with self.lock:
job = self.jobs[job_id]
private = self.private_downloads.get(job_id, {})
if job.status in {"paused", "cancelled", "stopping"} or SHUTDOWN_EVENT.is_set():
return
job.status = "verifying"
self.save()
if not artifact.is_file():
artifact, digest = self._assemble_artifact(job, workspace, private)
with self.lock:
job.sha256 = digest
self.save()
elif verify_artifact and job.sha256 and self._file_sha256(artifact, cancelled=lambda: job.status in {"paused", "cancelled", "stopping"}) != job.sha256:
with self.lock:
artifact.unlink(missing_ok=True)
shutil.rmtree(workspace / "extracted", ignore_errors=True)
job.sha256 = ""
raise ValueError("임시 완성 파일이 손상되어 삭제했습니다. 작업을 재개하면 처음부터 다시 다운로드합니다.")
artifact = self._apply_response_filename(job, workspace, artifact, private)
with self.lock:
if self._reconcile_split_archive_jobs():
self.save()
# The provider key is no longer needed once the complete artifact is
# verified locally. Do not retain it during extraction or publishing.
delete_job_download_key(job_id)
if self._postprocess_multipart(job, workspace):
return
with self.lock:
if job.status in {"paused", "cancelled", "stopping"}:
return
job.status = "extracting" if job.extract and archive_kind(job.name) else "publishing"
self.save()
try:
output, extracted = promote_download(
artifact, target_dir, job.extract, load_job_password(job_id),
cancelled=lambda: job.delete_requested or job.status in {"paused", "cancelled", "stopping"},
publication_lock=self.lock,
process_callback=lambda process: self._track_processing_process(job_id, process),
)
except PasswordRequiredError as exc:
delete_job_password(job_id)
with self.lock:
job = self.jobs[job_id]
job.status = "stopping" if job.delete_requested else "password_required"
job.error = str(exc)
self.private_downloads.pop(job_id, None)
self.save()
return
except MissingArchiveVolumeError as exc:
with self.lock:
job = self.jobs[job_id]
job.multipart_group = job.multipart_group or job.id
job.multipart_entry = True
job.multipart_name = job.multipart_name or archive_output_name(job.name)
job.status = "waiting_processing"
job.error = str(exc)
self.save()
return
shutil.rmtree(workspace, ignore_errors=True)
try:
workspace.parent.rmdir()
except OSError:
pass
delete_job_secrets(job_id)
with self.lock:
job = self.jobs[job_id]
job.status = "stopping" if job.delete_requested else "completed"
job.output = str(output)
job.extracted = extracted
job.error = ""
self.private_downloads.pop(job_id, None)
self.save()
finally:
self._leave_postprocessing(job_id)
def _run(self, job_id: str) -> None:
with self.lock:
job = self.jobs[job_id]
if job.status in {"paused", "cancelled", "stopping"}:
return
job.status = "downloading"
self.save()
private = self.private_downloads.get(job_id, {})
private.pop("rate_limited", None)
target_dir = private.get("target") or job.target or NAS_TARGET
safe_name = job.name
workspace = job_workspace(target_dir, job.id)
workspace.mkdir(parents=True, exist_ok=True, mode=0o700)
migrate_legacy_workspace(target_dir, safe_name, job.id, workspace)
safe_name = _clean_download_name(safe_name)
if safe_name != job.name:
# Enumerate instead of stat-ing a too-long legacy name.
for entry in workspace.iterdir():
if entry.name == job.name:
destination = workspace / safe_name
if destination.exists():
raise ValueError("파일명 변경 대상이 이미 존재합니다. 기존 파일을 보존했습니다.")
entry.rename(destination)
with self.lock:
job.name = safe_name
self.save()
artifact = workspace / safe_name
if artifact.is_file() and job.sha256:
self._postprocess(job_id, workspace, artifact, target_dir, verify_artifact=True)
return
parsed = urlparse(job.source)
provider = private.get("provider") or provider_for_url(job.source)
if provider == "gigafile" and not private.get("download_url"):
download_key = load_job_download_key(job.id)
refreshed = inspect_gigafile(job.source, download_key) if download_key else inspect_gigafile(job.source)
name_changed = refreshed["name"] != job.name
if int(refreshed["size"]) != job.size or (
name_changed and not is_gigafile_fallback_name(job.name, job.source)
):
raise ValueError("대기 중 파일 정보가 변경되었습니다. 링크를 다시 등록해 주세요.")
private = {
"provider": "gigafile",
"download_url": str(refreshed.get("download_url", "")),
"download_mode": str(refreshed.get("download_mode", "gigafile_file")),
"target": target_dir,
}
self.private_downloads[job_id] = private
elif provider == "gofile" and not (private.get("download_url") and private.get("download_token")):
refreshed = inspect_gofile(job.source)
if refreshed["name"] != job.name or int(refreshed["size"]) != job.size:
raise ValueError("대기 중 파일 정보가 변경되었습니다. 링크를 다시 등록해 주세요.")
private = {
"provider": "gofile",
"download_url": str(refreshed["download_url"]),
"download_token": str(refreshed["download_token"]),
"target": target_dir,
}
self.private_downloads[job_id] = private
elif provider == "pixeldrain" and not private.get("download_url"):
refreshed = inspect_pixeldrain(job.source)
if refreshed["name"] != job.name or int(refreshed["size"]) != job.size:
raise ValueError("대기 중 파일 정보가 변경되었습니다. 링크를 다시 등록해 주세요.")
private = {
"provider": "pixeldrain",
"download_url": str(refreshed["download_url"]),
"expected_sha256": str(refreshed.get("expected_sha256", "")),
"target": target_dir,
}
self.private_downloads[job_id] = private
elif provider == "buzzheavier" and not private.get("download_url"):
saved_url = load_job_download_url(job.id)
if not saved_url:
raise ValueError("Buzzheavier 직접 링크 정보가 없습니다. Copy download link를 다시 등록해 주세요.")
direct_url, _file_id, _host = _validate_buzzheavier_download_url(saved_url)
private = {
"provider": "buzzheavier",
"download_url": direct_url,
"target": target_dir,
}
self.private_downloads[job_id] = private
if provider in {"akirabox", "vikingfile", "sendnow", "xshare"}:
signed = private.get("download_url") or load_job_download_url(job.id)
validator = {"akirabox": _validate_akira_url, "vikingfile": _validate_viking_url, "sendnow": _validate_sendnow_url, "xshare": _validate_xshare_url}[provider]
validator(job.source, direct=False)
_validate_handoff_transfer_url(signed, provider)
if provider == "xshare" and urlparse(job.source).path.rsplit("/", 1)[-1] != urlparse(signed).path.rsplit("/", 1)[-1]:
raise HandoffError("X-Share 공유 파일과 다운로드 주소가 일치하지 않습니다.")
private.update(provider=provider, download_url=signed, target=target_dir)
if provider in {"sendnow", "xshare"}:
resolve_host = (urlparse(signed).hostname or "").lower()
resolved = _resolve_public_addresses(resolve_host)
if not resolved:
raise ValueError("브라우저가 전달한 다운로드 서버를 안전하게 확인하지 못했습니다.")
private.update(resolve_host=resolve_host, resolve_address=resolved[0])
# Persist layout so a later settings change cannot reinterpret fragments.
if not job.transfer_mode:
has_legacy_parts = any(workspace.glob(f".{job.id}.segment.*"))
job.transfer_mode = "single" if provider in {"akirabox", "vikingfile", "sendnow", "xshare", "1fichier"} else ("segmented" if has_legacy_parts else initial_transfer_mode(provider, DOWNLOAD_MODE))
self.save()
download_mode = job.transfer_mode
private["transfer_mode"] = download_mode
self.private_downloads[job_id] = private
workspace_dir = str(workspace)
prefix = f"{workspace_dir}/.{job.id}.segment."
if provider == "gigafile" and private.get("download_mode") == "gigafile_zip":
script = self._download_script_gigafile_zip(
private.get("download_url", ""), job.source, safe_name, job.id, job.size, workspace_dir,
)
elif provider == "gofile":
script = self._download_script_gofile(
private.get("download_url", ""), private.get("download_token", ""),
job.source, safe_name, job.id, job.size, workspace_dir, mode=download_mode,
)
elif provider == "pixeldrain":
script = self._download_script_direct(
private.get("download_url", ""), job.source, safe_name, job.id, job.size, workspace_dir,
expected_sha256=private.get("expected_sha256", ""), mode=download_mode,
)
elif provider == "1fichier":
direct_url = resolve_1fichier_direct_url(job.source, load_job_source_password(job.id),
cancelled=lambda: SHUTDOWN_EVENT.is_set() or job.status in {"paused", "cancelled", "stopping"})
direct_host = (urlparse(direct_url).hostname or "").lower()
addresses = _resolve_public_addresses(direct_host)
if not addresses:
raise ValueError("1fichier 다운로드 서버의 공개 주소를 안전하게 확인하지 못했습니다.")
script = self._download_script_1fichier(direct_url, job.source, job.id, job.size, workspace_dir,
direct_host, addresses[0])
elif provider == "xshare":
script = self._download_script_xshare(private.get("download_url", ""), job.id, job.size,
workspace_dir, private["resolve_host"], private["resolve_address"])
elif provider in {"buzzheavier", "akirabox", "vikingfile", "sendnow"}:
script = self._download_script_direct(
private.get("download_url", ""), job.source, safe_name, job.id, job.size, workspace_dir,
mode=download_mode, capture_headers=True, transient_retries=3 if provider == "akirabox" else 0,
resolve_host=private.get("resolve_host", ""), resolve_address=private.get("resolve_address", ""),
user_agent=GOFILE_USER_AGENT if provider == "akirabox" else "",
)
else:
file_id = parsed.path.strip("/")
host = parsed.hostname or ""
script = self._download_script(
host, file_id, safe_name, job.id, job.size, workspace_dir,
mode=download_mode, download_url=private.get("download_url", ""),
)
command = ["sh", "-s"]
with self.condition:
if SHUTDOWN_EVENT.is_set() or job.status in {"paused", "cancelled", "stopping"}:
return
process = subprocess.Popen(
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, start_new_session=True,
)
self.processes[job_id] = process
self.condition.notify_all()
assert process.stdin is not None
process.stdin.write(script)
process.stdin.close()
while process.poll() is None:
time.sleep(2.5)
if provider == "gofile" and (workspace / ".rate-limit").exists():
with self.condition:
self._defer_gofile(job, workspace, active=True)
try:
current = self._local_size(prefix, job.size, "single" if private.get("download_mode") == "gigafile_zip" else download_mode)
except Exception:
current = 0
with self.lock:
if self.jobs[job_id].status in {"paused", "cancelled", "stopping"}:
self._stop_process(process)
break
self.jobs[job_id].downloaded = min(job.size, current)
if download_mode == "segmented" and current >= job.size:
self.jobs[job_id].status = "verifying"
self.save()
stdout = process.stdout.read() if process.stdout else ""
stderr = process.stderr.read() if process.stderr else ""
process.wait()
if provider == "xshare":
# A provider-issued key may be one-use. Never replay it after an
# attempted transfer, including a user pause or service shutdown.
save_job_download_url(job_id, "")
self._recover_fragments(job, workspace, download_mode, file_response=provider == "xshare")
with self.lock:
current_job = self.jobs[job_id]
if current_job.status in {"paused", "cancelled", "stopping"}:
self.processes.pop(job_id, None)
self.private_downloads.pop(job_id, None)
self.save()
return
if process.returncode != 0:
current_job.status = "failed"
if provider == "gofile" and ((workspace / ".rate-limit").exists() or private.get("rate_limited")):
self._defer_gofile(job, workspace)
elif provider == "1fichier":
current_job.error = "1fichier 다운로드가 중단됐습니다. 무료 연결은 이어받기를 지원하지 않아 재개 시 처음부터 다시 받습니다."
elif provider == "xshare":
current_job.error = "X-Share 다운로드가 중단됐습니다. 브라우저에서 보안 검증을 마친 뒤 새 다운로드 버튼으로 다시 등록해 주세요."
elif provider in {"akirabox", "vikingfile", "sendnow"}:
current_job.error = handoff_transfer_error(stderr)
elif provider == "buzzheavier" and re.search(r"(?:error:\s*)?(?:401|403|404)\b", stderr, re.I):
current_job.error = "Buzzheavier 직접 링크가 만료됐거나 사용할 수 없습니다. Copy download link를 다시 받아 새 작업으로 등록해 주세요."
elif re.search(r"maximum \(0\) redirects|too many redirects", stderr, re.I):
current_job.error = "다운로드 서버가 다른 주소로 이동을 요청해 보안을 위해 중단했습니다. 링크를 다시 확인해 주세요."
elif re.search(r"operation timed out|speed below|timed out", stderr, re.I):
current_job.error = "다운로드 서버 응답이 일정 시간 멈춰 작업을 중단했습니다. 다시 시작하면 이어받기를 시도합니다."
else:
current_job.error = (stderr.strip() or "NAS 다운로드가 중단됐습니다.")[-400:]
self.processes.pop(job_id, None)
self.private_downloads.pop(job_id, None)
self.save()
return
match = re.search(r"SHA256=([0-9a-f]{64})", stdout)
current_job.downloaded = current_job.size
current_job.status = "waiting_processing"
current_job.sha256 = match.group(1) if match else ""
self.save()
self._postprocess(job_id, workspace, artifact, target_dir)
def _recover_fragments(self, job: Job, workspace: Path, mode: str, *, file_response: bool = False) -> None:
chunk = segment_chunk(job.size, mode)
for i in range(segment_count(job.size, mode)):
part = workspace / f".{job.id}.segment.{i}"
if part.with_name(part.name + ".headers").exists():
commit_fragment(part, i * chunk, min(job.size - 1, (i + 1) * chunk - 1), job.size, file_response=file_response)
for name in (".cookies", ".page", ".curl.conf"):
(workspace / name).unlink(missing_ok=True)
def _defer_gofile(self, job: Job, workspace: Path, active: bool = False) -> None:
status = _gofile_cooldown_status()
if not status["active"]:
seconds = GOFILE_RATE_LIMIT_COOLDOWN_SECONDS
try:
raw = json.loads((workspace / ".rate-limit").read_text(encoding="utf-8")).get("retry_after", "")
seconds = float(raw) if str(raw).isdigit() else parsedate_to_datetime(raw).timestamp() - time.time()
except (OSError, ValueError, TypeError, OverflowError):
pass
_trip_gofile_cooldown(max(5, min(seconds, GOFILE_MAX_COOLDOWN_SECONDS)), "GoFile 요청 제한(HTTP 429)이 감지되었습니다.")
until = _gofile_cooldown_status()["until"]
(workspace / ".rate-limit").unlink(missing_ok=True)
for other in self.jobs.values():
if self._provider_for_job(other) != "gofile":
continue
if other.status in {"queued", "ready", "downloading"}:
other.not_before = until
self.private_downloads.setdefault(other.id, {})["rate_limited"] = "1"
process = self.processes.get(other.id)
if process:
self._terminate(process)
if not active:
job.status = "queued"
job.not_before = until
job.error = "GoFile 요청 제한으로 잠시 대기합니다."
def shutdown(self, timeout: float = 10) -> None:
SHUTDOWN_EVENT.set()
with self.condition:
self.stopping = True
for job in self.jobs.values():
if job.status in {"inspecting", "ready", "downloading", "waiting_processing", "verifying"}:
job.status = "stopping"
processes = list(self.processes.values())
self.condition.notify_all()
for process in processes:
self._terminate(process)
deadline = time.monotonic() + timeout
with self.condition:
while self.running_providers and time.monotonic() < deadline:
self.condition.wait(timeout=0.1)
remaining = list(self.processes.values())
for process in remaining:
self._stop_process(process)
with self.condition:
for job in self.jobs.values():
if job.status in {"stopping", "extracting", "publishing"}:
job.status = "paused"
self.save()
@staticmethod
def _transfer_loop(prefix: str, total: int, mode: str, curl: str, max_parallel: int = 8, transient_retries: int = 0) -> str:
count = segment_count(total, mode)
chunk = segment_chunk(total, mode)
merger = f"{shlex.quote(sys.executable)} {shlex.quote(str(ROOT / 'transfer_parts.py'))}"
return f'''TOTAL={total}
COUNT={count}
CHUNK={chunk}
PREFIX={shlex.quote(prefix)}
pids=""
i=0
while [ "$i" -lt "$COUNT" ]; do
start=$(( i * CHUNK )); end=$(( start + CHUNK - 1 ))
[ "$end" -lt "$TOTAL" ] || end=$(( TOTAL - 1 ))
part="$PREFIX.$i"
(
expected=$(( end - start + 1 ))
if [ -f "$part.more" ]; then
{merger} "$part" "$start" "$end" "$TOTAL" || true
fi
existing=0; [ ! -f "$part" ] || existing=$(wc -c < "$part" | tr -d ' ')
[ "$existing" -le "$expected" ] || {{ rm -f "$part"; existing=0; }}
attempt=0
while [ "$existing" -lt "$expected" ]; do
from=$(( start + existing )); more="$part.more"
rc=0
{curl} --retry 0 -r "$from-$end" --dump-header "$part.headers" -o "$more" || rc=$?
merged=0
{merger} "$part" "$start" "$end" "$TOTAL" || merged=$?
if [ "$rc" -eq 0 ] && [ "$merged" -eq 0 ]; then break; fi
printf 'NASDROP_TRANSFER curl=%s merge=%s attempt=%s\\n' "$rc" "$merged" "$attempt" >&2
# Retry only validated partial responses after transient transport failures.
# Never retry HTTP rejection, redirect, invalid range or local write failure.
[ "$merged" -eq 0 ] && [ "$attempt" -lt {transient_retries} ] || exit 1
case "$rc" in 18|28|52|56|92) ;; *) exit 1 ;; esac
existing=$(wc -c < "$part" | tr -d ' ')
[ "$existing" -lt "$expected" ] || break
attempt=$(( attempt + 1 ))
sleep $(( attempt * 10 ))
done
actual=$(wc -c < "$part" | tr -d ' ')
[ "$actual" -eq "$expected" ]
) &
pids="$pids $!"
i=$(( i + 1 ))
if [ $(( i % {max_parallel} )) -eq 0 ]; then
failed=0
for child in $pids; do wait "$child" || failed=1; done
[ "$failed" -eq 0 ] || exit 1
pids=""
fi
done
failed=0
for child in $pids; do wait "$child" || failed=1; done
[ "$failed" -eq 0 ] || exit 1
printf 'SEGMENTS_READY=%s\\n' "$COUNT"
'''
def _download_script(self, host: str, file_id: str, name: str, job_id: str, total: int, target_dir: str, mode: str = "segmented", download_url: str = "") -> str:
page = f"https://{host}/{file_id}"
download = download_url or f"https://{host}/download.php?file={file_id}"
parsed_download = urlparse(download)
query = parse_qs(parsed_download.query, keep_blank_values=True)
if (
parsed_download.scheme != "https" or (parsed_download.hostname or "").lower() != host.lower()
or parsed_download.path != "/download.php" or query.get("file") != [file_id]
or any(key not in {"file", "dlkey"} for key in query)
):
raise ValueError("GigaFile 다운로드 주소가 올바르지 않습니다.")
prefix = f"{target_dir}/.{job_id}.segment"
cookie = f"{target_dir}/.cookies"
page_copy = f"{target_dir}/.page"
curl_config = f"{target_dir}/.curl.conf"
config_body = "\n".join((
f'url = "{_curl_config_value(download)}"',
f'referer = "{_curl_config_value(page)}"',
'proto = "=https"', 'proto-redir = "=https"',
))
curl = f'curl --config "$CURL_CONFIG" {CURL_HTTPS_ONLY} {CURL_STALL_GUARD} {CURL_NO_REDIRECTS} --fail --silent --show-error -b "$COOKIE"'
setup = f'''#!/bin/sh
set -eu
umask 077
COOKIE={shlex.quote(cookie)}
PAGE_COPY={shlex.quote(page_copy)}
CURL_CONFIG={shlex.quote(curl_config)}
cat > "$CURL_CONFIG" <<'NASDROP_CURL_CONFIG'
{config_body}
NASDROP_CURL_CONFIG
cleanup() {{ rm -f "$COOKIE" "$PAGE_COPY" "$CURL_CONFIG"; }}
trap cleanup EXIT
trap 'exit 143' HUP INT TERM
curl {CURL_HTTPS_ONLY} {CURL_PAGE_TIMEOUT} {CURL_NO_REDIRECTS} --fail --silent --show-error -c "$COOKIE" {shlex.quote(page)} -o "$PAGE_COPY"
'''
# Names are probed during inspection. The actual GET headers are the
# authoritative fallback, captured by transfer_parts in the workspace.
return setup + self._transfer_loop(prefix, total, mode, curl)
def _download_script_gofile(self, download: str, token: str, page: str, name: str, job_id: str, total: int, target_dir: str, mode: str = "segmented2") -> str:
if not download.startswith("https://") or not token:
raise ValueError("Gofile 다운로드 인증 정보가 없습니다.")
return self._download_script_direct(download, page, name, job_id, total, target_dir, cookie=f"accountToken={token}", mode=mode, max_parallel=2)
def _download_script_direct(self, download: str, page: str, name: str, job_id: str, total: int, target_dir: str, cookie: str = "", expected_sha256: str = "", mode: str = "segmented", capture_headers: bool = False, max_parallel: int = 8, transient_retries: int = 0, resolve_host: str = "", resolve_address: str = "", user_agent: str = "") -> str:
if not download.startswith("https://"):
raise ValueError("직접 다운로드 주소가 올바르지 않습니다.")
config_lines = [
f'url = "{_curl_config_value(download)}"',
f'referer = "{_curl_config_value(page)}"',
'proto = "=https"', 'proto-redir = "=https"',
]
if user_agent:
config_lines.append(f'user-agent = "{_curl_config_value(user_agent)}"')
if cookie:
config_lines.append(f'header = "{_curl_config_value("Cookie: " + cookie)}"')
if resolve_host or resolve_address:
parsed_host = (urlparse(download).hostname or "").lower()
if parsed_host != resolve_host or not _resolve_public_addresses(resolve_address):
raise ValueError("고정할 다운로드 서버 주소가 올바르지 않습니다.")
pinned = f"[{resolve_address}]" if ":" in resolve_address else resolve_address
config_lines.append(f'resolve = "{_curl_config_value(resolve_host + ":443:" + pinned)}"')
config_body = "\n".join(config_lines)
setup = f'''#!/bin/sh
set -eu
umask 077
CURL_CONFIG={shlex.quote(f"{target_dir}/.curl.conf")}
cat > "$CURL_CONFIG" <<'NASDROP_CURL_CONFIG'
{config_body}
NASDROP_CURL_CONFIG
cleanup() {{ rm -f "$CURL_CONFIG"; }}
trap cleanup EXIT
trap 'exit 143' HUP INT TERM
'''
curl = f'curl --config "$CURL_CONFIG" {CURL_STALL_GUARD} {CURL_NO_REDIRECTS} --fail --silent --show-error'
return setup + self._transfer_loop(f"{target_dir}/.{job_id}.segment", total, mode, curl, max_parallel=max_parallel, transient_retries=transient_retries)
def _download_script_1fichier(self, download: str, page: str, job_id: str, total: int,
target_dir: str, host: str, address: str) -> str:
_validate_1fichier_direct_url(download)
if (urlparse(download).hostname or "").lower() != host or address not in _resolve_public_addresses(host):
raise ValueError("1fichier 다운로드 서버 주소가 올바르지 않습니다.")
pinned = f"[{address}]" if ":" in address else address
part = f"{target_dir}/.{job_id}.segment.0"
config = f"{target_dir}/.curl.conf"
config_body = "\n".join([
f'url = "{_curl_config_value(download)}"',
f'referer = "{_curl_config_value(page)}"',
'proto = "=https"', 'proto-redir = "=https"',
f'resolve = "{_curl_config_value(host + ":443:" + pinned)}"',
])
merger = f"{shlex.quote(sys.executable)} {shlex.quote(str(ROOT / 'transfer_parts.py'))}"
return f'''#!/bin/sh
set -eu
umask 077
CURL_CONFIG={shlex.quote(config)}
PART={shlex.quote(part)}
cat > "$CURL_CONFIG" <<'NASDROP_CURL_CONFIG'
{config_body}
NASDROP_CURL_CONFIG
cleanup() {{ rm -f "$CURL_CONFIG"; }}
trap cleanup EXIT
trap 'exit 143' HUP INT TERM
# Free 1fichier transfers do not support Range resume. Never reuse a partial body.
rm -f "$PART" "$PART.more" "$PART.headers"
curl --config "$CURL_CONFIG" {CURL_STALL_GUARD} {CURL_NO_REDIRECTS} --fail --silent --show-error --dump-header "$PART.headers" -o "$PART.more"
actual=$(wc -c < "$PART.more" | tr -d ' ')
[ "$actual" -eq {total} ] || exit 1
{merger} "$PART" 0 {total - 1} {total}
printf 'SEGMENTS_READY=1\n'
'''
def _download_script_xshare(self, download: str, job_id: str, total: int,
target_dir: str, host: str, address: str) -> str:
_validate_xshare_url(download, direct=True)
if host != "x-share.net" or address not in _resolve_public_addresses(host):
raise HandoffError("X-Share 다운로드 서버의 공개 주소를 안전하게 확인하지 못했습니다.")
pinned = f"[{address}]" if ":" in address else address
part = f"{target_dir}/.{job_id}.segment.0"
config = f"{target_dir}/.curl.conf"
config_body = "\n".join([
f'url = "{_curl_config_value(download)}"',
'proto = "=https"', 'proto-redir = "=https"',
f'resolve = "{_curl_config_value(host + ":443:" + pinned)}"',
f'user-agent = "{_curl_config_value(f"NASDrop/{PACKAGE_VERSION}")}"',
'header = "Accept: application/octet-stream"',
'header = "Accept-Encoding: identity"',
'proxy = ""',
])
merger = f"{shlex.quote(sys.executable)} {shlex.quote(str(ROOT / 'transfer_parts.py'))}"
return f'''#!/bin/sh
set -eu
umask 077
CURL_CONFIG={shlex.quote(config)}
PART={shlex.quote(part)}
cat > "$CURL_CONFIG" <<'NASDROP_CURL_CONFIG'
{config_body}
NASDROP_CURL_CONFIG
cleanup() {{ rm -f "$CURL_CONFIG"; }}
trap cleanup EXIT
trap 'exit 143' HUP INT TERM
# Do not preflight, range, retry or replay an opaque provider-issued key.
rm -f "$PART" "$PART.more" "$PART.headers"
curl --config "$CURL_CONFIG" {CURL_STALL_GUARD} {CURL_NO_REDIRECTS} --fail --silent --show-error --dump-header "$PART.headers" -o "$PART.more"
actual=$(wc -c < "$PART.more" | tr -d ' ')
[ "$actual" -eq {total} ] || exit 1
{merger} "$PART" 0 {total - 1} {total} --file-response
printf 'SEGMENTS_READY=1\n'
'''
def _download_script_gigafile_zip(self, download: str, page: str, name: str, job_id: str, total: int, target_dir: str, verify: bool = True) -> str:
parsed_download = urlparse(download)
parsed_page = urlparse(page)
if (
parsed_download.scheme != "https"
or parsed_download.hostname != parsed_page.hostname
or not GIGAFILE_HOST.fullmatch(parsed_download.hostname or "")
or parsed_download.path != "/dl_zip.php"
):
raise ValueError("GigaFile 묶음 다운로드 주소가 올바르지 않습니다.")
part = f"{target_dir}/.{job_id}.segment.0"
cookie = f"/tmp/nas_download_{job_id}.cookies"
page_copy = f"/tmp/nas_download_{job_id}.page"
return f"""#!/bin/sh
set -eu
TOTAL={total}
COOKIE={shlex.quote(cookie)}
PAGE_COPY={shlex.quote(page_copy)}
PART={shlex.quote(part)}
cleanup() {{ rm -f "$COOKIE" "$PAGE_COPY"; }}
trap cleanup EXIT HUP INT TERM
curl {CURL_HTTPS_ONLY} {CURL_PAGE_TIMEOUT} {CURL_NO_REDIRECTS} --fail --silent --show-error -c "$COOKIE" {shlex.quote(page)} -o "$PAGE_COPY"
actual=0; [ -f "$PART" ] && actual=$(wc -c < "$PART" | tr -d ' ')
if [ "$actual" -lt "$TOTAL" ]; then
rm -f "$PART"
curl {CURL_HTTPS_ONLY} {CURL_STALL_GUARD} {CURL_NO_REDIRECTS} --fail --silent --show-error --retry 8 --retry-delay 5 -b "$COOKIE" -e {shlex.quote(page)} -o "$PART" {shlex.quote(download)}
fi
actual=$(wc -c < "$PART" | tr -d ' '); [ "$actual" -ge "$TOTAL" ]
trap - EXIT HUP INT TERM
cleanup
printf 'SEGMENTS_READY=1\\n'
"""
@staticmethod
def _terminate(process: subprocess.Popen[str]) -> None:
try:
os.killpg(process.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError, AttributeError):
if process.poll() is None:
process.terminate()
@classmethod
def _stop_process(cls, process: subprocess.Popen[str]) -> None:
cls._terminate(process)
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
# The shell may have exited while one of its curl children still lives.
try:
os.killpg(process.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError, AttributeError):
if process.poll() is None:
process.kill()
process.wait(timeout=2)
def pause(self, job_id: str) -> None:
with self.condition:
if job_id not in self.jobs:
raise KeyError(job_id)
job = self.jobs[job_id]
if job.status not in {"inspecting", "queued", "ready", "downloading", "waiting_processing", "verifying"}:
raise ValueError("중지할 수 있는 작업이 아닙니다.")
running = any(job_id in ids for ids in self.running_providers.values())
job.status = "stopping" if running or self.processing_job == job_id else "paused"
job.error = ""
process = self.processes.get(job_id)
if process:
self._terminate(process)
self.save()
self.condition.notify_all()
def resume(self, job_id: str) -> None:
with self.condition:
if job_id not in self.jobs:
raise KeyError(job_id)
job = self.jobs[job_id]
if job.delete_requested or any(job_id in ids for ids in self.running_providers.values()) or job.status not in {"paused", "failed", "cancelled"}:
raise ValueError("다시 시작할 수 있는 작업이 아닙니다.")
job.status = "inspecting" if job.inspection_pending else "queued"
job.error = ""
job.output = ""
job.extracted = False
job.not_before = 0
self.save()
self.condition.notify_all()
def submit_password(self, job_id: str, password: object) -> None:
normalized = _validate_job_password(password)
if not normalized:
raise ValueError("압축 암호를 입력해 주세요.")
with self.condition:
job = self.jobs.get(job_id)
if not job:
raise KeyError(job_id)
if job.delete_requested or job.status != "password_required":
raise ValueError("현재 암호 입력이 필요한 작업이 아닙니다.")
save_job_password(job_id, normalized)
job.status = "queued"
job.error = ""
job.not_before = 0
self.save()
self.condition.notify_all()
def submit_download_key(self, job_id: str, download_key: object) -> None:
normalized = _validate_gigafile_download_key(download_key)
if not normalized:
raise ValueError("GigaFile 다운로드 키를 입력해 주세요.")
with self.condition:
job = self.jobs.get(job_id)
if not job:
raise KeyError(job_id)
if job.delete_requested or job.status != "download_key_required" or self._provider_for_job(job) != "gigafile":
raise ValueError("현재 GigaFile 다운로드 키 입력이 필요한 작업이 아닙니다.")
save_job_download_key(job_id, normalized)
job.status = "inspecting" if job.inspection_pending else "queued"
job.error = ""
job.not_before = 0
self.save()
self.condition.notify_all()
def update_processing(self, job_id: str, extract: object, password: object = None) -> dict:
if not isinstance(extract, bool):
raise ValueError("압축 해제 선택값이 올바르지 않습니다.")
if password is not None and not isinstance(password, str):
raise ValueError("압축 암호 형식이 올바르지 않습니다.")
normalized = _validate_job_password(password)
with self.condition:
job = self.jobs.get(job_id)
if job is None:
raise KeyError(job_id)
allowed = {"queued", "ready", "inspecting", "paused", "downloading", "password_required"}
running = any(job_id in ids for ids in self.running_providers.values())
if self.stopping or job.delete_requested or job.status not in allowed or self.processing_job == job_id or (job.status in {"paused", "password_required"} and running):
raise ValueError("현재 상태에서는 압축 해제 설정을 변경할 수 없습니다. 작업을 일시정지한 뒤 다시 시도해 주세요.")
previous_password = load_job_password(job_id)
if job.status == "password_required":
if extract and not (normalized or previous_password):
raise ValueError("압축 암호를 입력해 주세요.")
if not extract:
artifact = job_workspace(job.target or NAS_TARGET, job.id) / _clean_download_name(job.name)
if not job.sha256 or not artifact.is_file():
raise ValueError("보존된 원본 파일이 없어 압축 해제를 건너뛸 수 없습니다.")
previous = (job.extract, job.status, job.error, job.not_before)
try:
if not extract:
delete_job_password(job_id)
elif normalized:
save_job_password(job_id, normalized)
job.extract = extract
if job.status == "password_required":
job.status, job.error, job.not_before = "queued", "", 0
self.save()
except Exception:
job.extract, job.status, job.error, job.not_before = previous
save_job_password(job_id, previous_password)
raise
self.condition.notify_all()
return asdict(job)
def request_delete(self, job_ids: list[str]) -> dict:
"""Opt-in stop-and-delete; never remove data while a worker owns the job."""
with self.condition:
if self.stopping:
raise ValueError("서비스가 종료 중입니다.")
selected_groups = {self.jobs[job_id].multipart_group for job_id in job_ids
if job_id in self.jobs and self.jobs[job_id].multipart_group}
ids = list(dict.fromkeys([*job_ids, *[
candidate.id for candidate in self.jobs.values() if candidate.multipart_group in selected_groups
]]))
for job_id in ids:
if job_id not in self.jobs:
raise KeyError(job_id)
busy = any(self.jobs[i].delete_requested or i in self.processes or self.processing_job == i
or any(i in running for running in self.running_providers.values()) for i in ids)
if not busy:
previous = {i: self.jobs[i].status for i in ids}
for i in ids:
if self.jobs[i].status != "completed":
self.jobs[i].status = "paused"
try:
return {"ok": True, "deleted": self.delete(ids), "pending": []}
except Exception:
for i, status in previous.items():
if i in self.jobs:
self.jobs[i].status = status
raise
pending, scheduled, previous = [], [], {}
for job_id in ids:
job = self.jobs[job_id]
if job.delete_requested:
pending.append(job_id)
continue
previous[job_id] = (job.status, job.error)
job.delete_requested = True
job.status, job.error = "stopping", ""
pending.append(job_id)
scheduled.append(job_id)
try:
self.save()
except Exception:
for job_id in scheduled:
self.jobs[job_id].delete_requested = False
self.jobs[job_id].status, self.jobs[job_id].error = previous[job_id]
self.condition.notify_all()
raise
for job_id in scheduled:
threading.Thread(target=self._finish_delete, args=(job_id,),
name=f"nasdrop-delete-{job_id}", daemon=True).start()
self.condition.notify_all()
return {"ok": True, "deleted": 0, "pending": pending}
def _finish_delete(self, job_id: str) -> None:
with self.condition:
if self.stopping or SHUTDOWN_EVENT.is_set():
return
original = self.jobs.get(job_id)
group_ids = {candidate.id for candidate in self.jobs.values()
if original and original.multipart_group and candidate.multipart_group == original.multipart_group} or {job_id}
while (any(group_ids.intersection(ids) for ids in self.running_providers.values())
or self.processing_job in group_ids):
if self.stopping or SHUTDOWN_EVENT.is_set():
return # Restart preserves the record and requires a fresh request.
self.condition.wait(timeout=0.25)
job = self.jobs.get(job_id)
if not job or not job.delete_requested:
return
try:
process = self.processes.get(job_id)
if process is not None:
self._stop_process(process)
self.processes.pop(job_id, None)
job.status = "paused"
self.delete([job_id], _pending=True)
except Exception:
for member_id in group_ids:
member = self.jobs.get(member_id)
if member:
member.delete_requested = False
member.status = "failed"
member.error = "임시 다운로드 파일을 삭제하지 못해 작업 기록을 보존했습니다. 폴더 권한을 확인한 뒤 다시 삭제해 주세요."
self.save()
finally:
self.condition.notify_all()
def delete(self, job_ids: list[str], *, _pending: bool = False) -> int:
with self.lock:
selected_groups = {self.jobs[job_id].multipart_group for job_id in job_ids
if job_id in self.jobs and self.jobs[job_id].multipart_group}
job_ids = list(dict.fromkeys([*job_ids, *[
candidate.id for candidate in self.jobs.values() if candidate.multipart_group in selected_groups
]]))
for job_id in job_ids:
job = self.jobs.get(job_id)
if not job:
raise KeyError(job_id)
if job.delete_requested and not _pending:
raise ValueError("삭제를 위해 작업을 중지 중입니다.")
if job_id in getattr(self, "processes", {}) or getattr(self, "processing_job", None) == job_id:
raise ValueError("실행 중인 작업은 먼저 멈춰 주세요.")
if job.status in {"inspecting", "queued", "ready", "downloading", "waiting_processing", "verifying", "extracting", "publishing", "stopping"} or any(job_id in ids for ids in self.running_providers.values()):
raise ValueError("실행 중인 작업은 먼저 멈춰 주세요.")
# Delete only the exact private workspace, never published output or symlink targets.
# Keep every record if any workspace cannot be cleaned up.
for job_id in job_ids:
job = self.jobs[job_id]
try:
target = Path(job.target or NAS_TARGET).resolve()
expected = target / ".nasdrop-tmp" / job.id
if expected.parent.is_symlink() or expected.is_symlink() or job_workspace(str(target), job.id) != expected:
raise ValueError("Unsafe workspace")
if expected.exists():
shutil.rmtree(expected)
if expected.exists():
raise OSError("Workspace remains")
except (OSError, ValueError):
raise ValueError("임시 다운로드 파일을 삭제하지 못해 작업 기록을 보존했습니다. 폴더 권한을 확인한 뒤 다시 삭제해 주세요.") from None
for job_id in job_ids:
delete_job_secrets(job_id)
saved_jobs = self.jobs.copy()
saved_private = self.private_downloads.copy()
for job_id in job_ids:
self.jobs.pop(job_id, None)
self.private_downloads.pop(job_id, None)
try:
self.save()
except Exception:
self.jobs, self.private_downloads = saved_jobs, saved_private
raise
return len(job_ids)
def clear_completed(self) -> int:
with self.lock:
completed = [job_id for job_id, job in self.jobs.items() if job.status == "completed" and not job.delete_requested
and not any(job_id in ids for ids in self.running_providers.values())]
for job_id in completed:
self.jobs.pop(job_id, None)
delete_job_secrets(job_id)
self.save()
return len(completed)
def cancel(self, job_id: str) -> None:
self.pause(job_id)
def _clean_download_name(value: str) -> str:
name = html.unescape(re.sub(r"<[^>]+>", "", value)).strip()
return fit_download_name(re.sub(r"[\\/\x00-\x1f:]", "_", name))
def _validate_1fichier_share_url(raw_url: str) -> tuple[str, str]:
parsed = urlparse(str(raw_url).strip())
file_id = parsed.query
if (parsed.scheme != "https" or (parsed.hostname or "").lower() not in {"1fichier.com", "www.1fichier.com"}
or parsed.port not in {None, 443} or parsed.username or parsed.password or parsed.path not in {"", "/"}
or parsed.fragment or not ONEFICHIER_FILE_ID.fullmatch(file_id)):
raise ValueError("정식 1fichier HTTPS 공유 링크가 아닙니다.")
return f"https://1fichier.com/?{file_id}", file_id
def _validate_1fichier_direct_url(raw_url: str) -> str:
parsed = urlparse(html.unescape(raw_url).strip())
host = (parsed.hostname or "").lower()
if (parsed.scheme != "https" or not ONEFICHIER_DIRECT_HOST.fullmatch(host)
or host.split(".", 1)[0] in {"img", "www", "api", "status"}
or parsed.port not in {None, 443} or parsed.username or parsed.password or parsed.fragment
or not (parsed.path.strip("/") or parsed.query)):
raise ValueError("1fichier가 허용되지 않은 다운로드 주소를 반환했습니다.")
return parsed.geturl()
class OneFichierNoRedirect(HTTPRedirectHandler):
def redirect_request(self, request, file_pointer, code, message, headers, new_url):
return None
def _onefichier_opener():
addresses = _resolve_public_addresses("1fichier.com")
if not addresses:
raise ValueError("1fichier 서버의 공개 주소를 안전하게 확인하지 못했습니다.")
return build_opener(ProxyHandler({}), OneFichierNoRedirect(),
HTTPCookieProcessor(CookieJar()), PinnedHTTPSHandler("1fichier.com", addresses[0]))
def inspect_1fichier(raw_url: str) -> dict:
canonical, _file_id = _validate_1fichier_share_url(raw_url)
request = Request("https://1fichier.com/check_links.pl", data=urlencode({"links[]": canonical}).encode(),
headers={"User-Agent": f"NASDrop/{PACKAGE_VERSION}",
"Content-Type": "application/x-www-form-urlencoded", "Accept": "text/plain"})
with _onefichier_opener().open(request, timeout=20) as response:
line = response.read(16_384).decode("utf-8", "replace").strip().splitlines()
if len(line) != 1:
raise ValueError("1fichier 파일 정보를 확인하지 못했습니다. 링크를 확인해 주세요.")
parts = line[0].rsplit(";", 1)
fields = parts[0].split(";", 1) + parts[1:] if len(parts) == 2 else []
if len(fields) != 3 or fields[0] != canonical:
raise ValueError("1fichier 파일 정보 응답이 올바르지 않습니다.")
name = _clean_download_name(fields[1])
try:
size = int(fields[2])
except ValueError as exc:
raise ValueError("1fichier 파일 크기를 확인하지 못했습니다.") from exc
if not name or name in {".", ".."} or size <= 0 or size > MAX_FILE_BYTES:
raise ValueError("1fichier 링크가 만료됐거나 파일 정보가 올바르지 않습니다.")
return {"url": canonical, "name": name, "size": size, "expires": "", "provider": "1fichier"}
def _onefichier_page_direct_url(source: str, canonical: str) -> str:
for raw in re.findall(r'(?:href|location(?:\.href)?)\s*=\s*["\'](https://[^"\']+)["\']', source, re.I):
try:
candidate = _validate_1fichier_direct_url(raw)
except ValueError:
continue
if candidate != canonical:
return candidate
return ""
class OneFichierWaitError(ValueError):
def __init__(self, seconds: int):
self.seconds = max(5, min(24 * 3600, seconds))
super().__init__(f"1fichier 대기 중입니다. 약 {(self.seconds + 59) // 60}분 후 자동으로 다시 시도합니다.")
def _onefichier_wait_seconds(page: str) -> int:
minutes = re.search(r'(?:You must wait\s*(?:(?:at least|up to)\s*)?|Vous devez attendre encore\s*)(\d{1,4})\s*minutes', page, re.I)
if minutes:
return min(24 * 3600, int(minutes.group(1)) * 60 + 5)
if re.search(r"all free guest slots are currently in use|temporarily limited due to high demand", page, re.I):
return 300
# 1fichier sometimes replaces the download form with a daily free-usage
# notice. Treat this as a provider wait instead of a broken/expired link.
# A full-day delay avoids repeatedly probing a limit whose reset time is
# not supplied by the page.
if re.search(r"already downloaded for free more than\s+\d+\s+files today", page, re.I):
return 24 * 3600
return 0
def resolve_1fichier_direct_url(raw_url: str, password: str = "", cancelled=None) -> str:
canonical, _file_id = _validate_1fichier_share_url(raw_url)
source_password = _validate_1fichier_password(password)
opener = _onefichier_opener()
headers = {"User-Agent": "Mozilla/5.0 NASDrop", "Referer": canonical}
with opener.open(Request(canonical, headers=headers), timeout=20) as response:
page = response.read(1_000_001).decode("utf-8", "replace")
if len(page) > 1_000_000:
raise ValueError("1fichier 응답이 너무 큽니다.")
for _stage in range(3):
wait = _onefichier_wait_seconds(page)
if wait:
raise OneFichierWaitError(wait)
if re.search(r'<(?:input|textarea)\b[^>]*\bname\s*=\s*["\'](?:captcha|g-recaptcha-response|cf-turnstile-response)["\']|<(?:div|form)\b[^>]*\bclass\s*=\s*["\'][^"\']*\b(?:g-recaptcha|h-captcha|cf-turnstile)\b', page, re.I):
raise ValueError("1fichier에서 사람 확인을 요구합니다. 사이트에서 직접 확인해 주세요.")
direct = _onefichier_page_direct_url(page, canonical)
if direct:
return direct
password_field = re.search(r']*name\s*=\s*["\']?(?:pass|password|passwd)\b', page, re.I)
if password_field and not source_password:
raise ValueError("1fichier 파일 암호가 필요합니다. 링크를 다시 등록하며 파일 암호를 입력해 주세요.")
form = re.search(r'