381 lines
17 KiB
Python
381 lines
17 KiB
Python
"""Authorized PChome sales report providers and file-boundary guards."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import http.client
|
|
import imaplib
|
|
import ipaddress
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import ssl
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from email import policy
|
|
from email.header import decode_header, make_header
|
|
from email.parser import BytesParser
|
|
from pathlib import Path
|
|
from typing import Callable, List, Optional
|
|
from urllib.parse import unquote, urlparse
|
|
|
|
|
|
VALID_EXTENSIONS = {".xlsx", ".xls"}
|
|
XLSX_MAGIC = b"PK\x03\x04"
|
|
XLS_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
|
|
|
|
class ProviderError(RuntimeError):
|
|
def __init__(self, kind: str, public_message: str):
|
|
super().__init__(public_message)
|
|
self.kind = kind
|
|
self.public_message = public_message
|
|
|
|
|
|
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
"""Connect to the validated IP while preserving hostname TLS verification."""
|
|
|
|
def __init__(self, hostname: str, port: int, pinned_ip: str, timeout: int):
|
|
super().__init__(hostname, port=port, timeout=timeout, context=ssl.create_default_context())
|
|
self._pinned_ip = pinned_ip
|
|
|
|
def connect(self):
|
|
self.sock = socket.create_connection(
|
|
(self._pinned_ip, self.port),
|
|
self.timeout,
|
|
self.source_address,
|
|
)
|
|
self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host)
|
|
|
|
|
|
@dataclass
|
|
class AcquisitionCandidate:
|
|
source_type: str
|
|
file_path: str
|
|
file_name: str
|
|
fingerprint: str
|
|
source_ref_hash: str
|
|
finalize_success: Callable[[], None]
|
|
finalize_rejected: Callable[[], None]
|
|
cleanup: Callable[[], None]
|
|
|
|
|
|
def env_bool(name: str, default: bool = False) -> bool:
|
|
fallback = "true" if default else "false"
|
|
return os.getenv(name, fallback).strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def env_int(name: str, default: int, minimum: int = 1, maximum: int = 1000) -> int:
|
|
try:
|
|
value = int(os.getenv(name, str(default)))
|
|
except (TypeError, ValueError):
|
|
value = default
|
|
return max(minimum, min(value, maximum))
|
|
|
|
|
|
def _hash_text(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8", errors="ignore")).hexdigest()
|
|
|
|
|
|
def _sha256_file(file_path: str) -> str:
|
|
digest = hashlib.sha256()
|
|
with open(file_path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _safe_file_name(value: str, default: str = "pchome-sales.xlsx") -> str:
|
|
candidate = os.path.basename(str(value or "").replace("\x00", "")).strip()
|
|
candidate = re.sub(r"[^0-9A-Za-z._()\-\u4e00-\u9fff]+", "_", candidate)
|
|
return (candidate or default)[:180]
|
|
|
|
|
|
def _decode_mail_header(value: Optional[str]) -> str:
|
|
if not value:
|
|
return ""
|
|
try:
|
|
return str(make_header(decode_header(value)))
|
|
except Exception:
|
|
return str(value)
|
|
|
|
|
|
def _make_temp_path(suffix: str) -> str:
|
|
target = Path(os.getenv("PCHOME_SALES_TEMP_DIR", "data/temp/pchome-sales"))
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
fd, file_path = tempfile.mkstemp(prefix="sales-", suffix=suffix, dir=str(target))
|
|
os.close(fd)
|
|
return file_path
|
|
|
|
|
|
def _validate_excel_file(file_path: str, file_name: str) -> None:
|
|
path = Path(file_path)
|
|
max_bytes = env_int("PCHOME_SALES_MAX_FILE_BYTES", 25 * 1024 * 1024, 1024, 200 * 1024 * 1024)
|
|
if not path.is_file() or path.is_symlink():
|
|
raise ProviderError("unsafe_file", "來源檔案不存在或不符合安全檔案規則。")
|
|
if path.stat().st_size <= 0 or path.stat().st_size > max_bytes:
|
|
raise ProviderError("invalid_file_size", "來源檔案大小不符合匯入政策,未執行寫入。")
|
|
suffix = Path(file_name).suffix.lower()
|
|
if suffix not in VALID_EXTENSIONS:
|
|
raise ProviderError("unsupported_file_type", "來源不是允許的 Excel 業績檔,未執行寫入。")
|
|
with path.open("rb") as handle:
|
|
magic = handle.read(8)
|
|
signature_matches = (
|
|
suffix == ".xlsx" and magic.startswith(XLSX_MAGIC)
|
|
) or (
|
|
suffix == ".xls" and magic == XLS_MAGIC
|
|
)
|
|
if not signature_matches:
|
|
raise ProviderError("invalid_excel_signature", "來源檔案內容不是有效的 Excel 格式,未執行寫入。")
|
|
|
|
|
|
def _archive_file(source: Path, folder_name: str, fingerprint: str) -> None:
|
|
if not source.exists() or source.is_symlink():
|
|
return
|
|
target_dir = source.parent / folder_name
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
target = target_dir / source.name
|
|
if target.exists():
|
|
target = target_dir / f"{source.stem}-{fingerprint[:10]}{source.suffix}"
|
|
os.replace(str(source), str(target))
|
|
|
|
|
|
class AuthorizedSalesProviders:
|
|
"""Side-effect-bounded report providers in governed priority order."""
|
|
|
|
def validate_http_url(self, url: str) -> tuple[str, int, str]:
|
|
parsed = urlparse(url)
|
|
if parsed.scheme.lower() != "https" or not parsed.hostname or parsed.username or parsed.password:
|
|
raise ProviderError("http_url_rejected", "授權報表網址必須是無內嵌帳密的 HTTPS 網址。")
|
|
hostname = parsed.hostname.lower().rstrip(".")
|
|
try:
|
|
port = parsed.port or 443
|
|
except ValueError as exc:
|
|
raise ProviderError("http_port_rejected", "授權報表網址的連接埠格式無效。") from exc
|
|
allowed_ports = {
|
|
int(item.strip())
|
|
for item in os.getenv("PCHOME_SALES_HTTP_ALLOWED_PORTS", "443").split(",")
|
|
if item.strip().isdigit()
|
|
}
|
|
if port not in allowed_ports:
|
|
raise ProviderError("http_port_rejected", "授權報表連接埠未列入允許清單,未發出連線。")
|
|
allowed_hosts = {
|
|
item.strip().lower().rstrip(".")
|
|
for item in os.getenv("PCHOME_SALES_HTTP_ALLOWED_HOSTS", "").split(",")
|
|
if item.strip()
|
|
}
|
|
if hostname not in allowed_hosts:
|
|
raise ProviderError("http_host_not_allowed", "授權報表主機未列入允許清單,未發出連線。")
|
|
try:
|
|
addresses = {
|
|
item[4][0]
|
|
for item in socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
|
}
|
|
except OSError as exc:
|
|
raise ProviderError("http_dns_failed", "授權報表主機目前無法解析。") from exc
|
|
if not addresses:
|
|
raise ProviderError("http_dns_failed", "授權報表主機目前沒有可用位址。")
|
|
if not env_bool("PCHOME_SALES_HTTP_ALLOW_PRIVATE"):
|
|
for address in addresses:
|
|
ip = ipaddress.ip_address(address)
|
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
raise ProviderError("http_private_address_rejected", "授權報表主機解析到受限制網段,未發出連線。")
|
|
return hostname, port, sorted(addresses)[0]
|
|
|
|
def http_candidates(self) -> List[AcquisitionCandidate]:
|
|
if not env_bool("PCHOME_SALES_HTTP_ENABLED"):
|
|
return []
|
|
url = os.getenv("PCHOME_SALES_HTTP_URL", "").strip()
|
|
if not url:
|
|
raise ProviderError("http_not_configured", "授權 HTTPS 來源已啟用但網址尚未設定。")
|
|
hostname, port, pinned_ip = self.validate_http_url(url)
|
|
parsed = urlparse(url)
|
|
headers = {"Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"}
|
|
token = os.getenv("PCHOME_SALES_HTTP_BEARER_TOKEN", "").strip()
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
timeout = env_int("PCHOME_SALES_HTTP_TIMEOUT_SECONDS", 30, 3, 120)
|
|
max_bytes = env_int("PCHOME_SALES_MAX_FILE_BYTES", 25 * 1024 * 1024, 1024, 200 * 1024 * 1024)
|
|
connection = _PinnedHTTPSConnection(hostname, port, pinned_ip, timeout)
|
|
path = parsed.path or "/"
|
|
if parsed.query:
|
|
path = f"{path}?{parsed.query}"
|
|
try:
|
|
connection.request("GET", path, headers=headers)
|
|
response = connection.getresponse()
|
|
if response.status != 200:
|
|
raise ProviderError("http_status_rejected", f"授權 HTTPS 報表來源回應 {response.status},未執行匯入。")
|
|
content_length = response.getheader("Content-Length")
|
|
if content_length and int(content_length) > max_bytes:
|
|
raise ProviderError("http_file_too_large", "授權報表超過檔案大小上限,未執行匯入。")
|
|
disposition = response.getheader("Content-Disposition", "")
|
|
match = re.search(r"filename\*?=(?:UTF-8''|\")?([^\";]+)", disposition, re.I)
|
|
raw_name = unquote(match.group(1).strip()) if match else os.path.basename(parsed.path)
|
|
file_name = _safe_file_name(raw_name)
|
|
suffix = Path(file_name).suffix.lower()
|
|
if suffix not in VALID_EXTENSIONS:
|
|
suffix = ".xlsx"
|
|
file_name = f"{Path(file_name).stem or 'pchome-sales'}{suffix}"
|
|
temp_path = _make_temp_path(suffix)
|
|
written = 0
|
|
try:
|
|
with open(temp_path, "wb") as handle:
|
|
while True:
|
|
chunk = response.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
written += len(chunk)
|
|
if written > max_bytes:
|
|
raise ProviderError("http_file_too_large", "授權報表超過檔案大小上限,未執行匯入。")
|
|
handle.write(chunk)
|
|
_validate_excel_file(temp_path, file_name)
|
|
except Exception:
|
|
Path(temp_path).unlink(missing_ok=True)
|
|
raise
|
|
except ProviderError:
|
|
raise
|
|
except (OSError, ssl.SSLError, http.client.HTTPException, ValueError) as exc:
|
|
raise ProviderError("http_fetch_failed", "授權 HTTPS 報表來源目前無法連線。") from exc
|
|
finally:
|
|
connection.close()
|
|
fingerprint = _sha256_file(temp_path)
|
|
return [AcquisitionCandidate(
|
|
source_type="authorized_https",
|
|
file_path=temp_path,
|
|
file_name=file_name,
|
|
fingerprint=fingerprint,
|
|
source_ref_hash=_hash_text(f"https://{hostname}{parsed.path}"),
|
|
finalize_success=lambda: None,
|
|
finalize_rejected=lambda: None,
|
|
cleanup=lambda candidate_path=temp_path: Path(candidate_path).unlink(missing_ok=True),
|
|
)]
|
|
|
|
def _mark_imap_seen(self, uid: bytes) -> None:
|
|
host = os.getenv("PCHOME_SALES_IMAP_HOST", "").strip()
|
|
user = os.getenv("PCHOME_SALES_IMAP_USER", "").strip()
|
|
password = os.getenv("PCHOME_SALES_IMAP_PASSWORD", "")
|
|
port = env_int("PCHOME_SALES_IMAP_PORT", 993, 1, 65535)
|
|
mailbox = os.getenv("PCHOME_SALES_IMAP_MAILBOX", "INBOX").strip() or "INBOX"
|
|
client = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=20)
|
|
try:
|
|
client.login(user, password)
|
|
client.select(mailbox, readonly=False)
|
|
client.uid("store", uid, "+FLAGS", "(\\Seen)")
|
|
finally:
|
|
try:
|
|
client.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
def imap_candidates(self) -> List[AcquisitionCandidate]:
|
|
if not env_bool("PCHOME_SALES_IMAP_ENABLED"):
|
|
return []
|
|
host = os.getenv("PCHOME_SALES_IMAP_HOST", "").strip()
|
|
user = os.getenv("PCHOME_SALES_IMAP_USER", "").strip()
|
|
password = os.getenv("PCHOME_SALES_IMAP_PASSWORD", "")
|
|
if not host or not user or not password:
|
|
raise ProviderError("imap_not_configured", "授權信箱來源已啟用但連線設定不完整。")
|
|
port = env_int("PCHOME_SALES_IMAP_PORT", 993, 1, 65535)
|
|
mailbox = os.getenv("PCHOME_SALES_IMAP_MAILBOX", "INBOX").strip() or "INBOX"
|
|
subject_pattern = os.getenv("PCHOME_SALES_IMAP_SUBJECT", "即時業績").strip()
|
|
max_messages = env_int("PCHOME_SALES_IMAP_MAX_MESSAGES", 20, 1, 100)
|
|
candidates: List[AcquisitionCandidate] = []
|
|
client = None
|
|
try:
|
|
client = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=20)
|
|
client.login(user, password)
|
|
status, _ = client.select(mailbox, readonly=True)
|
|
if status != "OK":
|
|
raise ProviderError("imap_mailbox_failed", "授權信箱資料夾目前無法開啟。")
|
|
status, data = client.uid("search", None, "UNSEEN")
|
|
if status != "OK":
|
|
raise ProviderError("imap_search_failed", "授權信箱目前無法搜尋新報表。")
|
|
uids = list(reversed((data[0] or b"").split()))[:max_messages]
|
|
for uid in uids:
|
|
status, payload = client.uid("fetch", uid, "(RFC822)")
|
|
if status != "OK" or not payload or not isinstance(payload[0], tuple):
|
|
continue
|
|
message = BytesParser(policy=policy.default).parsebytes(payload[0][1])
|
|
if subject_pattern and subject_pattern not in _decode_mail_header(message.get("Subject")):
|
|
continue
|
|
for attachment in message.iter_attachments():
|
|
file_name = _safe_file_name(_decode_mail_header(attachment.get_filename()))
|
|
suffix = Path(file_name).suffix.lower()
|
|
if suffix not in VALID_EXTENSIONS:
|
|
continue
|
|
temp_path = _make_temp_path(suffix)
|
|
try:
|
|
with open(temp_path, "wb") as handle:
|
|
handle.write(attachment.get_payload(decode=True) or b"")
|
|
_validate_excel_file(temp_path, file_name)
|
|
except Exception:
|
|
Path(temp_path).unlink(missing_ok=True)
|
|
raise
|
|
fingerprint = _sha256_file(temp_path)
|
|
candidates.append(AcquisitionCandidate(
|
|
source_type="authorized_imap",
|
|
file_path=temp_path,
|
|
file_name=file_name,
|
|
fingerprint=fingerprint,
|
|
source_ref_hash=_hash_text(f"{host}:{mailbox}:{uid.decode(errors='ignore')}"),
|
|
finalize_success=lambda message_uid=uid: self._mark_imap_seen(message_uid),
|
|
finalize_rejected=lambda message_uid=uid: self._mark_imap_seen(message_uid),
|
|
cleanup=lambda candidate_path=temp_path: Path(candidate_path).unlink(missing_ok=True),
|
|
))
|
|
except ProviderError:
|
|
for candidate in candidates:
|
|
candidate.cleanup()
|
|
raise
|
|
except (imaplib.IMAP4.error, OSError, ssl.SSLError) as exc:
|
|
for candidate in candidates:
|
|
candidate.cleanup()
|
|
raise ProviderError("imap_connection_failed", "授權信箱來源目前無法連線或驗證。") from exc
|
|
finally:
|
|
if client is not None:
|
|
try:
|
|
client.logout()
|
|
except Exception:
|
|
pass
|
|
return candidates
|
|
|
|
def local_candidates(self) -> List[AcquisitionCandidate]:
|
|
configured = os.getenv("PCHOME_SALES_LOCAL_DROP_DIR", "").strip()
|
|
if not configured:
|
|
return []
|
|
root = Path(configured).expanduser().resolve()
|
|
if not root.is_dir():
|
|
raise ProviderError("local_drop_missing", "受控落地目錄不存在,未執行檔案存取。")
|
|
max_files = env_int("PCHOME_SALES_MAX_FILES_PER_RUN", 5, 1, 25)
|
|
sources = sorted(
|
|
(
|
|
path for path in root.iterdir()
|
|
if path.is_file() and not path.is_symlink() and path.suffix.lower() in VALID_EXTENSIONS
|
|
),
|
|
key=lambda path: path.stat().st_mtime,
|
|
reverse=True,
|
|
)[:max_files]
|
|
candidates: List[AcquisitionCandidate] = []
|
|
for source in sources:
|
|
temp_path = _make_temp_path(source.suffix.lower())
|
|
try:
|
|
shutil.copyfile(str(source), temp_path)
|
|
_validate_excel_file(temp_path, source.name)
|
|
except Exception:
|
|
Path(temp_path).unlink(missing_ok=True)
|
|
for candidate in candidates:
|
|
candidate.cleanup()
|
|
raise
|
|
fingerprint = _sha256_file(temp_path)
|
|
candidates.append(AcquisitionCandidate(
|
|
source_type="controlled_local_drop",
|
|
file_path=temp_path,
|
|
file_name=_safe_file_name(source.name),
|
|
fingerprint=fingerprint,
|
|
source_ref_hash=_hash_text(str(source)),
|
|
finalize_success=lambda item=source, fp=fingerprint: _archive_file(item, "archive", fp),
|
|
finalize_rejected=lambda item=source, fp=fingerprint: _archive_file(item, "quarantine", fp),
|
|
cleanup=lambda candidate_path=temp_path: Path(candidate_path).unlink(missing_ok=True),
|
|
))
|
|
return candidates
|