⬅ חזרה לתיקייה
✏️ עריכת admin_server_backup_20260702_151439.py
C:\TripServer\backups\admin_server\admin_server_backup_20260702_151439.py
# -*- coding: utf-8 -*- from flask import Flask, request, redirect, send_from_directory, make_response, Response from werkzeug.utils import secure_filename import os import time import sys import re import shutil import py_compile import threading import html as html_lib import glob import zipfile import subprocess import ipaddress app = Flask(__name__) ADMIN_PANEL_VERSION = "TripServer Admin v20.0 camera one-time auto-install + audio recording" ADMIN_STARTED_AT = time.strftime("%Y-%m-%d %H:%M:%S") BASE_DIR = r"C:\TripServer" ADMIN_HOST = "0.0.0.0" ADMIN_PORT = 8000 FOLDERS = { "JAPAN": os.path.join(BASE_DIR, "JAPAN"), "USA": os.path.join(BASE_DIR, "USA"), "Moneyapp": os.path.join(BASE_DIR, "Moneyapp"), "MoneyHome": os.path.join(BASE_DIR, "MoneyHome"), "TripForm": os.path.join(BASE_DIR, "TripForm"), } APP_FOLDERS = {"JAPAN", "USA", "Moneyapp", "MoneyHome", "TripForm"} JAPAN_PRESENTATION_FILES = { "flight_intro": "japan-flight-intro.html", "japan_experience": "japan-experience.html", } USA_PRESENTATION_FILES = { "usa_presentation": "usa2027-presentation.html", "usa_presentation_tv": "usa2027-presentation-tv.html", } USA_EXTRA_HTML_FILES = { "usa_trip_tv": "usa2027-tv.html", } USA_MUSIC_FILES = { "usa_music": os.path.join("media", "usa-presentation", "music", "usa-roadtrip-theme.mp3"), "usa_music_part2": os.path.join("media", "usa-presentation", "music", "usa-part2-theme.mp3"), } USA_IMAGE_KEYS = ({"intro", "budget", "roadtrip-map", "summary", "extra-big-sur", "extra-diner", "extra-death-valley", "extra-mono-lake"} | {f"day-{i:02d}" for i in range(1, 37)}) USA_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"} JAPAN_MUSIC_TARGETS = { # The Japan experience presentation contains several fallback paths. Keep all aliases synced. "japan_experience_music": [ os.path.join("media", "japan-experience", "music", "japan-theme.mp3"), os.path.join("media", "music", "japan-theme.mp3"), "japan-theme.mp3", ], "japan_flight_music": [ os.path.join("media", "japan-flight", "music", "flight-intro.mp3"), ], } AUDIO_UPLOAD_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma", ".webm", ".mp4"} JAPAN_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"} JAPAN_IMAGES_ZIP_MAX_BYTES = 130 * 1024 * 1024 JAPAN_IMAGES_ZIP_MAX_FILES = 220 JAPAN_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES = 220 * 1024 * 1024 JAPAN_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES = 18 * 1024 * 1024 TRIP_PACKAGE_ZIP_MAX_BYTES = 180 * 1024 * 1024 TRIP_PACKAGE_ZIP_MAX_FILES = 320 TRIP_PACKAGE_ZIP_MAX_UNCOMPRESSED_BYTES = 260 * 1024 * 1024 TRIP_PACKAGE_ZIP_MAX_SINGLE_FILE_BYTES = 35 * 1024 * 1024 TRIP_PACKAGE_EXTENSIONS = {".html", ".htm", ".jpg", ".jpeg", ".png", ".webp", ".mp3", ".css", ".js", ".json", ".txt", ".svg"} ADMIN_SERVER_PATH = os.path.abspath(__file__) ADMIN_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "admin_server") ADMIN_UPLOAD_TMP_DIR = os.path.join(BASE_DIR, "tmp", "admin_server_uploads") ADMIN_MAX_UPLOAD_BYTES = 2 * 1024 * 1024 USA_IMAGES_ZIP_MAX_BYTES = 80 * 1024 * 1024 USA_IMAGES_ZIP_MAX_FILES = 160 USA_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES = 140 * 1024 * 1024 USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES = 12 * 1024 * 1024 ADMIN_RESTART_CMD_PATH = os.path.join(BASE_DIR, "restart_admin_server.cmd") ADMIN_RESTART_LOG_PATH = os.path.join(BASE_DIR, "admin_restart.log") ADMIN_RESTART_HELPER_PATH = os.path.join(BASE_DIR, "restart_admin_server_helper.py") UPLOAD_BACKUP_DIR = os.path.join(BASE_DIR, "backups", "uploaded_files") # External watchdog. This is intentionally separate from the Flask process: if the admin # server is down, Windows Task Scheduler can still run this script every minute and bring # the admin back without walking to the physical computer. WATCHDOG_TASK_NAME = os.environ.get("TRIPSERVER_WATCHDOG_TASK", "TripServerAdminWatchdog") WATCHDOG_SCRIPT_PATH = os.path.join(BASE_DIR, "tripserver_admin_watchdog.py") WATCHDOG_LOG_PATH = os.path.join(BASE_DIR, "tripserver_watchdog.log") WATCHDOG_STATE_PATH = os.path.join(BASE_DIR, "tripserver_watchdog_state.txt") WATCHDOG_INTERVAL_MINUTES = int(os.environ.get("TRIPSERVER_WATCHDOG_INTERVAL_MINUTES", "1")) WATCHDOG_MIN_RESTART_INTERVAL_SECONDS = int(os.environ.get("TRIPSERVER_WATCHDOG_RESTART_COOLDOWN", "90")) # Admin log viewer. Only these fixed paths are exposed in the UI; user-supplied paths are never accepted. LOG_FILE_REGISTRY = { "restart": {"label": "לוג אתחול פאנל", "path": ADMIN_RESTART_LOG_PATH, "note": "Restart helper, העלאת admin חדש, שחזור גיבוי ופעולות מחשב."}, "watchdog": {"label": "לוג Watchdog", "path": WATCHDOG_LOG_PATH, "note": "בדיקות Health, הפעלה אוטומטית של הפאנל, cooldown ושגיאות Task Scheduler."}, } LOG_TAIL_DEFAULT_LINES = 120 LOG_TAIL_MAX_LINES = 500 # Smart media optimizer settings. # Images uploaded to fixed slide slots are always stored as real .jpg files. # Audio uploaded to fixed music slots is always stored as .mp3 when FFmpeg is available. IMAGE_OPTIMIZER_MAX_LONG_EDGE = int(os.environ.get("TRIPSERVER_IMAGE_MAX_LONG_EDGE", "1920")) IMAGE_OPTIMIZER_TARGET_BYTES = int(os.environ.get("TRIPSERVER_IMAGE_TARGET_BYTES", str(1100 * 1024))) IMAGE_OPTIMIZER_QUALITIES = tuple(int(q) for q in os.environ.get("TRIPSERVER_IMAGE_QUALITIES", "88,86,84,82,80,78").split(",") if q.strip()) AUDIO_UPLOAD_MAX_BYTES = int(os.environ.get("TRIPSERVER_AUDIO_MAX_BYTES", str(80 * 1024 * 1024))) AUDIO_MP3_BITRATE = os.environ.get("TRIPSERVER_AUDIO_MP3_BITRATE", "192k") AUDIO_MP3_SAMPLE_RATE = os.environ.get("TRIPSERVER_AUDIO_MP3_SAMPLE_RATE", "44100") MEDIA_AUDIO_ACCEPT = ".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4" MEDIA_IMAGE_ACCEPT = ".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,image/*" # Power actions are intentionally guarded: POST only, LAN/local client only, typed Hebrew confirmation, and cancel support. POWER_ACTIONS_ENABLED = os.environ.get("TRIPSERVER_ENABLE_POWER_ACTIONS", "1").strip().lower() not in {"0", "false", "no", "off"} POWER_MIN_DELAY_SECONDS = 30 POWER_MAX_DELAY_SECONDS = 3600 POWER_DEFAULT_DELAY_SECONDS = 60 POWER_CONFIRM_TEXT = { "shutdown": "כיבוי", "reboot": "הפעלה מחדש", } POWER_LABELS = { "shutdown": "כיבוי מחשב", "reboot": "ריסטארט למחשב", } # Webcam / room camera control. The camera is never opened automatically on page load. # It starts only when the user presses the camera button, and it is guarded to LAN/Tailscale/private IPs. CAMERA_ENABLED = os.environ.get("TRIPSERVER_ENABLE_WEBCAM", "1").strip().lower() not in {"0", "false", "no", "off"} CAMERA_INDEX = int(os.environ.get("TRIPSERVER_WEBCAM_INDEX", "0")) CAMERA_WIDTH = int(os.environ.get("TRIPSERVER_WEBCAM_WIDTH", "1280")) CAMERA_HEIGHT = int(os.environ.get("TRIPSERVER_WEBCAM_HEIGHT", "720")) CAMERA_FPS = max(1, min(20, int(os.environ.get("TRIPSERVER_WEBCAM_FPS", "8")))) CAMERA_JPEG_QUALITY = max(45, min(92, int(os.environ.get("TRIPSERVER_WEBCAM_JPEG_QUALITY", "78")))) CAMERA_MAX_STREAM_SECONDS = int(os.environ.get("TRIPSERVER_WEBCAM_MAX_STREAM_SECONDS", "1800")) CAMERA_FRAME_INTERVAL_SECONDS = 1.0 / float(CAMERA_FPS) # Optional audio + recording layer. Audio and recording are manual-only and guarded by the same # LAN/Tailscale/private-IP checks as the live camera. FFmpeg is required for audio and MP4 recording. CAMERA_AUDIO_ENABLED = os.environ.get("TRIPSERVER_ENABLE_CAMERA_AUDIO", "1").strip().lower() not in {"0", "false", "no", "off"} CAMERA_RECORDING_ENABLED = os.environ.get("TRIPSERVER_ENABLE_CAMERA_RECORDING", "1").strip().lower() not in {"0", "false", "no", "off"} CAMERA_VIDEO_DEVICE = os.environ.get("TRIPSERVER_FFMPEG_VIDEO_DEVICE", "").strip() CAMERA_AUDIO_DEVICE = os.environ.get("TRIPSERVER_FFMPEG_AUDIO_DEVICE", "").strip() CAMERA_RECORDINGS_DIR = os.environ.get("TRIPSERVER_CAMERA_RECORDINGS_DIR", os.path.join(BASE_DIR, "camera_recordings")) CAMERA_RECORD_FPS = max(1, min(30, int(os.environ.get("TRIPSERVER_CAMERA_RECORD_FPS", "15")))) CAMERA_RECORD_MAX_SECONDS = max(30, min(8 * 3600, int(os.environ.get("TRIPSERVER_CAMERA_RECORD_MAX_SECONDS", "3600")))) CAMERA_AUDIO_BITRATE = os.environ.get("TRIPSERVER_CAMERA_AUDIO_BITRATE", "128k") # Camera component installer. First-run auto installer: if OpenCV is missing, the admin # starts a background pip installation automatically when the Flask process starts. # It uses the same Python executable that runs this admin server and writes a log. # The camera itself still never turns on automatically; only the dependency installer runs. CAMERA_SETUP_LOG_PATH = os.path.join(BASE_DIR, "camera_setup.log") CAMERA_SETUP_CONFIRM_TEXT = "התקן" CAMERA_OPENCV_PACKAGE = os.environ.get("TRIPSERVER_OPENCV_PACKAGE", "opencv-python").strip() or "opencv-python" CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS = int(os.environ.get("TRIPSERVER_OPENCV_INSTALL_TIMEOUT_SECONDS", "900")) CAMERA_AUTO_INSTALL_OPENCV = os.environ.get("TRIPSERVER_CAMERA_AUTO_INSTALL_OPENCV", "1").strip().lower() not in {"0", "false", "no", "off"} CAMERA_AUTO_INSTALL_DELAY_SECONDS = int(os.environ.get("TRIPSERVER_CAMERA_AUTO_INSTALL_DELAY_SECONDS", "4")) CAMERA_SETUP_MARKER_PATH = os.path.join(BASE_DIR, "camera_opencv_installed.ok") CAMERA_SETUP_STATE = {"running": False, "started_at": "", "finished_at": "", "ok": False, "last_message": "", "mode": "idle", "auto_requested": False} CAMERA_SETUP_LOCK = threading.RLock() LOG_FILE_REGISTRY["camera_setup"] = {"label": "לוג התקנת רכיבי מצלמה", "path": CAMERA_SETUP_LOG_PATH, "note": "התקנת OpenCV דרך pip ובדיקת רכיבי מצלמה."} def safe_zip_relpath(raw_name): raw = (raw_name or "").replace("\\", "/").strip("/") if not raw or raw.startswith("__MACOSX/"): return None parts = [p for p in raw.split("/") if p and p not in (".", "..")] if not parts or len(parts) != len(raw.split("/")): return None return "/".join(parts) def file_ext(filename): return os.path.splitext(filename or "")[1].lower() def is_likely_jpeg_file(path): try: with open(path, "rb") as fh: return fh.read(3) == b"\xff\xd8\xff" except Exception: return False def is_likely_mp3_file(path): """Fast MP3 sniffing: ID3 tag or MPEG audio frame sync near the beginning.""" try: with open(path, "rb") as fh: data = fh.read(4096) if data.startswith(b"ID3"): return True for idx in range(0, max(0, len(data) - 1)): if data[idx] == 0xFF and (data[idx + 1] & 0xE0) == 0xE0: return True return False except Exception: return False def find_ffmpeg_executable(): candidates = [ os.environ.get("FFMPEG_PATH"), "ffmpeg", r"C:\\ffmpeg\\bin\\ffmpeg.exe", r"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe", r"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe", ] for candidate in candidates: if not candidate: continue found = shutil.which(candidate) if os.path.basename(candidate) == candidate else (candidate if os.path.exists(candidate) else None) if found: return found return None def media_engine_status(): status = {"pillow": False, "ffmpeg": bool(find_ffmpeg_executable()), "ffmpeg_path": find_ffmpeg_executable() or ""} try: import PIL # noqa: F401 status["pillow"] = True except Exception: status["pillow"] = False return status def format_bytes(num): try: num = int(num) except Exception: return str(num) units = ["B", "KB", "MB", "GB"] value = float(num) for unit in units: if value < 1024 or unit == units[-1]: return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B" value /= 1024 def convert_or_copy_image_to_jpg(source_path, target_path, original_name=""): """Create a real optimized JPG file; never writes a fake .jpg. Smart behavior: - Uses Pillow when installed. - Applies EXIF orientation. - Converts alpha/transparency to a white background. - Downscales only when the long edge is larger than IMAGE_OPTIMIZER_MAX_LONG_EDGE. - Uses progressive/optimized JPEG and a quality loop to reduce weight without aggressive damage. - If Pillow is missing, accepts only true JPEG bytes and copies them safely. """ ensure_parent_dir(target_path) original_size = os.path.getsize(source_path) if os.path.exists(source_path) else 0 try: from PIL import Image, ImageOps with Image.open(source_path) as img: img.verify() with Image.open(source_path) as img: img = ImageOps.exif_transpose(img) original_dimensions = tuple(img.size) if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): rgba = img.convert("RGBA") background = Image.new("RGBA", rgba.size, (255, 255, 255, 255)) background.alpha_composite(rgba) img = background.convert("RGB") elif img.mode != "RGB": img = img.convert("RGB") resized = False max_edge = max(img.size) if img.size else 0 if IMAGE_OPTIMIZER_MAX_LONG_EDGE and max_edge > IMAGE_OPTIMIZER_MAX_LONG_EDGE: scale = IMAGE_OPTIMIZER_MAX_LONG_EDGE / float(max_edge) new_size = (max(1, int(round(img.size[0] * scale))), max(1, int(round(img.size[1] * scale)))) img = img.resize(new_size, Image.Resampling.LANCZOS) resized = True tmp_candidates = [] qualities = IMAGE_OPTIMIZER_QUALITIES or (88, 84, 80) for quality in qualities: tmp_candidate = f"{target_path}.q{quality}.tmp" img.save(tmp_candidate, "JPEG", quality=int(quality), optimize=True, progressive=True) tmp_candidates.append((quality, tmp_candidate, os.path.getsize(tmp_candidate))) if os.path.getsize(tmp_candidate) <= IMAGE_OPTIMIZER_TARGET_BYTES: break # Keep the highest-quality candidate that reaches target size; otherwise keep the smallest candidate. under_target = [item for item in tmp_candidates if item[2] <= IMAGE_OPTIMIZER_TARGET_BYTES] chosen = under_target[0] if under_target else min(tmp_candidates, key=lambda item: item[2]) for quality, path, _size in tmp_candidates: if path != chosen[1]: try: os.remove(path) except Exception: pass os.replace(chosen[1], target_path) final_size = os.path.getsize(target_path) return { "mode": "optimized_jpg", "quality": chosen[0], "original_size": original_size, "final_size": final_size, "original_dimensions": original_dimensions, "final_dimensions": tuple(img.size), "resized": resized, "saved_bytes": max(0, original_size - final_size), } except ImportError: if is_likely_jpeg_file(source_path): tmp_target = target_path + ".tmp" shutil.copy2(source_path, tmp_target) os.replace(tmp_target, target_path) final_size = os.path.getsize(target_path) return { "mode": "copied_real_jpeg_without_pillow", "quality": "source", "original_size": original_size, "final_size": final_size, "original_dimensions": "unknown", "final_dimensions": "unknown", "resized": False, "saved_bytes": max(0, original_size - final_size), } raise ValueError("Pillow is not installed, so non-JPG images cannot be converted. Install Pillow with: py -m pip install pillow") except ValueError: raise except Exception as exc: raise ValueError("Image conversion/compression failed: " + repr(exc)) def convert_or_copy_audio_to_mp3(source_path, target_path, original_name=""): """Store uploaded audio as a real .mp3 file. If FFmpeg exists, transcode supported audio/video containers to MP3. If FFmpeg is missing, accept only files that are already real MP3 bytes, even if their extension is wrong. """ ensure_parent_dir(target_path) original_size = os.path.getsize(source_path) if os.path.exists(source_path) else 0 if original_size <= 0: raise ValueError("קובץ האודיו ריק.") if original_size > AUDIO_UPLOAD_MAX_BYTES: raise ValueError(f"קובץ האודיו גדול מדי: {format_bytes(original_size)}. מגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}") source_is_mp3 = is_likely_mp3_file(source_path) ffmpeg = find_ffmpeg_executable() if ffmpeg: tmp_mp3 = target_path + ".ffmpeg.tmp.mp3" cmd = [ ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", source_path, "-vn", "-codec:a", "libmp3lame", "-b:a", AUDIO_MP3_BITRATE, "-ar", AUDIO_MP3_SAMPLE_RATE, tmp_mp3, ] run = subprocess.run(cmd, capture_output=True, text=True, timeout=180) if run.returncode != 0 or not os.path.exists(tmp_mp3) or os.path.getsize(tmp_mp3) <= 0: try: if os.path.exists(tmp_mp3): os.remove(tmp_mp3) except Exception: pass raise ValueError("FFmpeg failed to convert audio to MP3: " + ((run.stderr or run.stdout or "unknown error")[-1200:])) converted_size = os.path.getsize(tmp_mp3) if source_is_mp3 and converted_size > int(original_size * 1.05): # Avoid bloating an already efficient MP3. Still normalize the target filename to .mp3. try: os.remove(tmp_mp3) except Exception: pass tmp_target = target_path + ".copy.tmp" shutil.copy2(source_path, tmp_target) os.replace(tmp_target, target_path) final_size = os.path.getsize(target_path) return {"mode": "copied_existing_mp3_smaller_than_reencode", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": ffmpeg} os.replace(tmp_mp3, target_path) final_size = os.path.getsize(target_path) return {"mode": "converted_to_mp3_ffmpeg", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": ffmpeg, "bitrate": AUDIO_MP3_BITRATE} if source_is_mp3: tmp_target = target_path + ".copy.tmp" shutil.copy2(source_path, tmp_target) os.replace(tmp_target, target_path) final_size = os.path.getsize(target_path) return {"mode": "copied_real_mp3_without_ffmpeg", "original_size": original_size, "final_size": final_size, "saved_bytes": max(0, original_size - final_size), "ffmpeg": "not_found"} raise ValueError("FFmpeg is not installed, so this audio file cannot be converted to MP3. Install FFmpeg or upload a real MP3 file.") def save_audio_upload_to_target(uploaded_file, target_path, backup_group="audio"): os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") safe_name = secure_filename(uploaded_file.filename or "audio_upload") or "audio_upload" tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_{stamp}_{os.getpid()}_{safe_name}") uploaded_file.save(tmp_path) try: backup_path = backup_existing_file(target_path, backup_group) media = convert_or_copy_audio_to_mp3(tmp_path, target_path, uploaded_file.filename or "") return {"path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path, "media": media} finally: try: if os.path.exists(tmp_path): os.remove(tmp_path) except Exception: pass def save_audio_upload_to_multiple_targets(uploaded_file, base_folder, rel_paths, backup_group): os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") safe_name = secure_filename(uploaded_file.filename or "audio_upload") or "audio_upload" source_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_multi_{stamp}_{os.getpid()}_{safe_name}") converted_tmp = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"audio_multi_{stamp}_{os.getpid()}_converted.mp3") uploaded_file.save(source_tmp) try: media = convert_or_copy_audio_to_mp3(source_tmp, converted_tmp, uploaded_file.filename or "") sha = file_sha256(converted_tmp) results = [] for rel_path in rel_paths: target_path = os.path.join(base_folder, rel_path) ensure_parent_dir(target_path) backup_path = backup_existing_file(target_path, backup_group) tmp_target = target_path + ".tmp" shutil.copy2(converted_tmp, tmp_target) os.replace(tmp_target, target_path) results.append({"rel": rel_path, "path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path}) return {"size": os.path.getsize(converted_tmp), "sha256": sha, "targets": results, "media": media} finally: for p in (source_tmp, converted_tmp): try: if os.path.exists(p): os.remove(p) except Exception: pass def media_details_text(media): if not media: return "" lines = [] if media.get("mode"): lines.append(f"מצב עיבוד: {media.get('mode')}") if media.get("original_size") is not None and media.get("final_size") is not None: lines.append(f"גודל לפני: {format_bytes(media.get('original_size'))}") lines.append(f"גודל אחרי: {format_bytes(media.get('final_size'))}") saved = int(media.get("saved_bytes") or 0) if saved > 0: lines.append(f"חיסכון: {format_bytes(saved)}") if media.get("original_dimensions"): lines.append(f"מידות מקור: {media.get('original_dimensions')}") if media.get("final_dimensions"): lines.append(f"מידות סופיות: {media.get('final_dimensions')}") if media.get("quality"): lines.append(f"איכות JPG: {media.get('quality')}") if media.get("bitrate"): lines.append(f"ביטרייט MP3: {media.get('bitrate')}") if media.get("ffmpeg"): lines.append(f"FFmpeg: {media.get('ffmpeg')}") return "\n".join(lines) def save_usa_presentation_image(image_key, uploaded_file): if image_key not in USA_IMAGE_KEYS: return None original_name = uploaded_file.filename or "" if file_ext(original_name) not in USA_IMAGE_EXTENSIONS: raise ValueError("סיומת תמונה לא נתמכת. העלה JPG/JPEG/PNG/WebP/BMP/TIFF.") image_dir = os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images") os.makedirs(image_dir, exist_ok=True) os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_image_{stamp}_{os.getpid()}_{secure_filename(image_key)}") uploaded_file.save(tmp_path) try: if os.path.getsize(tmp_path) <= 0: raise ValueError("קובץ התמונה ריק.") if os.path.getsize(tmp_path) > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES: raise ValueError("קובץ התמונה גדול מדי.") target_path = os.path.join(image_dir, image_key + ".jpg") backup_path = backup_existing_file(target_path, "USA_images_single") media = convert_or_copy_image_to_jpg(tmp_path, target_path, original_name) for old_ext in USA_IMAGE_EXTENSIONS - {".jpg"}: old_path = os.path.join(image_dir, image_key + old_ext) if os.path.exists(old_path): try: backup_existing_file(old_path, "USA_images_single_old_ext") os.remove(old_path) except Exception: pass return {"rel": os.path.join("media", "usa-presentation", "images", image_key + ".jpg"), "path": target_path, "mode": media.get("mode", "optimized"), "media": media, "backup": backup_path, "sha256": file_sha256(target_path), "size": os.path.getsize(target_path)} finally: try: if os.path.exists(tmp_path): os.remove(tmp_path) except Exception: pass def save_usa_presentation_images_zip(uploaded_file): image_dir = os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images") os.makedirs(image_dir, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_images_{stamp}.zip") tmp_extract = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"usa_images_extract_{stamp}") os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) os.makedirs(tmp_extract, exist_ok=True) uploaded_file.save(tmp_zip) zip_size = os.path.getsize(tmp_zip) if zip_size > USA_IMAGES_ZIP_MAX_BYTES: try: os.remove(tmp_zip) except Exception: pass raise ValueError(f"zip_size_exceeded:{zip_size}") imported = [] skipped = [] seen = set() try: with zipfile.ZipFile(tmp_zip, "r") as zf: infos = [info for info in zf.infolist() if not info.is_dir()] if len(infos) > USA_IMAGES_ZIP_MAX_FILES: raise ValueError(f"too_many_files:{len(infos)}") total_uncompressed = sum(max(0, info.file_size) for info in infos) if total_uncompressed > USA_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES: raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}") for info in infos: raw_name = (info.filename or "").replace("\\", "/") base = os.path.basename(raw_name) if not base or base.startswith(".") or raw_name.startswith("__MACOSX/"): continue if info.file_size > USA_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES: skipped.append(base + " — גדול מדי") continue stem, ext = os.path.splitext(base) stem = stem.strip() ext = ext.lower() if ext not in USA_IMAGE_EXTENSIONS: skipped.append(base + " — סיומת לא נתמכת") continue if stem not in USA_IMAGE_KEYS: skipped.append(base + " — שם לא שייך למצגת") continue if stem in seen: skipped.append(base + " — כפילות ב־ZIP") continue seen.add(stem) source_tmp = os.path.join(tmp_extract, stem + ext) with zf.open(info, "r") as source, open(source_tmp, "wb") as dest: shutil.copyfileobj(source, dest) target = os.path.join(image_dir, stem + ".jpg") try: backup_existing_file(target, "USA_images_zip") media = convert_or_copy_image_to_jpg(source_tmp, target, base) for old_ext in USA_IMAGE_EXTENSIONS - {".jpg"}: old_path = os.path.join(image_dir, stem + old_ext) if os.path.exists(old_path): try: backup_existing_file(old_path, "USA_images_zip_old_ext") os.remove(old_path) except Exception: pass imported.append(stem + ".jpg" + " — " + format_bytes(media.get("original_size", 0)) + " → " + format_bytes(media.get("final_size", 0))) except Exception as exc: skipped.append(base + " — לא הומר/נדחס ל־JPG: " + str(exc)) finally: try: os.remove(tmp_zip) except Exception: pass try: shutil.rmtree(tmp_extract, ignore_errors=True) except Exception: pass imported.sort() skipped.sort() # Missing means absent on disk after the import, not merely absent from this specific ZIP. missing = sorted(key + ".jpg" for key in USA_IMAGE_KEYS if not os.path.exists(os.path.join(image_dir, key + ".jpg"))) return imported, skipped, missing for folder_path in FOLDERS.values(): os.makedirs(folder_path, exist_ok=True) os.makedirs(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music"), exist_ok=True) os.makedirs(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images"), exist_ok=True) os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "images"), exist_ok=True) os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "japan-experience", "music"), exist_ok=True) os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "japan-flight", "music"), exist_ok=True) os.makedirs(os.path.join(FOLDERS["JAPAN"], "media", "music"), exist_ok=True) os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True) os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) def no_cache_response(response): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0, private" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" # Prevent old HTML/images from being reused after upload when the browser sends If-None-Match/If-Modified-Since. response.headers.pop("ETag", None) response.headers.pop("Last-Modified", None) return response @app.after_request def add_global_no_cache_headers(response): return no_cache_response(response) def serve_file_no_cache(directory, file_name): try: response = make_response(send_from_directory(directory, file_name, conditional=False, max_age=0)) except TypeError: # Compatibility fallback for older Flask/Werkzeug versions. response = make_response(send_from_directory(directory, file_name)) return no_cache_response(response) def file_sha256(path): import hashlib h = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def ensure_parent_dir(path): parent = os.path.dirname(path) if parent: os.makedirs(parent, exist_ok=True) def backup_existing_file(target_path, backup_group="uploads"): if not os.path.exists(target_path) or not os.path.isfile(target_path): return None stamp = time.strftime("%Y%m%d_%H%M%S") safe_group = secure_filename(str(backup_group)) or "uploads" safe_name = secure_filename(os.path.basename(target_path)) or "file" backup_dir = os.path.join(UPLOAD_BACKUP_DIR, safe_group) os.makedirs(backup_dir, exist_ok=True) backup_path = os.path.join(backup_dir, f"{stamp}_{safe_name}") shutil.copy2(target_path, backup_path) return backup_path def atomic_save_upload(uploaded_file, target_path, backup_group="uploads"): """Save uploaded file atomically: tmp -> validate size -> backup old -> os.replace.""" ensure_parent_dir(target_path) os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") safe_target = secure_filename(os.path.basename(target_path)) or "upload.bin" tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"tmp_{stamp}_{os.getpid()}_{safe_target}") try: uploaded_file.save(tmp_path) size = os.path.getsize(tmp_path) if size <= 0: raise ValueError("uploaded file is empty") backup_path = backup_existing_file(target_path, backup_group) os.replace(tmp_path, target_path) final_size = os.path.getsize(target_path) if final_size != size: raise IOError(f"size mismatch after save: tmp={size}, final={final_size}") return {"path": target_path, "size": final_size, "backup": backup_path, "sha256": file_sha256(target_path)} finally: try: if os.path.exists(tmp_path): os.remove(tmp_path) except Exception: pass def find_html_file(folder_path): index_path = os.path.join(folder_path, "index.html") if os.path.exists(index_path): return "index.html" html_files = [f for f in os.listdir(folder_path) if f.lower().endswith((".html", ".htm"))] if html_files: return html_files[0] return None def clean_wrong_main_files(folder_path): wrong_names = {"html", "htm", "index", "index.htm", "download", "unknown"} for name in os.listdir(folder_path): full_path = os.path.join(folder_path, name) if not os.path.isfile(full_path): continue if name.lower().strip() in wrong_names: try: os.remove(full_path) except Exception: pass def save_uploaded_file(folder, uploaded_file): folder_path = FOLDERS[folder] os.makedirs(folder_path, exist_ok=True) original_filename = uploaded_file.filename or "" safe_name = secure_filename(original_filename) if folder in APP_FOLDERS: clean_wrong_main_files(folder_path) filename = "index.html" else: filename = safe_name or f"upload_{int(time.time())}" target_path = os.path.join(folder_path, filename) result = atomic_save_upload(uploaded_file, target_path, backup_group=f"{folder}_main") return filename, result def save_japan_presentation(presentation_key, uploaded_file): if presentation_key not in JAPAN_PRESENTATION_FILES: return None japan_folder = FOLDERS["JAPAN"] os.makedirs(japan_folder, exist_ok=True) filename = JAPAN_PRESENTATION_FILES[presentation_key] target_path = os.path.join(japan_folder, filename) result = atomic_save_upload(uploaded_file, target_path, backup_group="JAPAN_presentations") return filename, result def save_usa_presentation(presentation_key, uploaded_file): if presentation_key not in USA_PRESENTATION_FILES: return None usa_folder = FOLDERS["USA"] os.makedirs(usa_folder, exist_ok=True) filename = USA_PRESENTATION_FILES[presentation_key] target_path = os.path.join(usa_folder, filename) result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_presentations") return filename, result def save_usa_music(music_key, uploaded_file): if music_key not in USA_MUSIC_FILES: return None usa_folder = FOLDERS["USA"] rel_path = USA_MUSIC_FILES[music_key] full_path = os.path.join(usa_folder, rel_path) result = save_audio_upload_to_target(uploaded_file, full_path, backup_group="USA_music") return rel_path, result def save_usa_extra_html(extra_key, uploaded_file): if extra_key not in USA_EXTRA_HTML_FILES: return None filename = USA_EXTRA_HTML_FILES[extra_key] target_path = os.path.join(FOLDERS["USA"], filename) result = atomic_save_upload(uploaded_file, target_path, backup_group="USA_extra_html") return filename, result def save_file_to_multiple_targets(uploaded_file, base_folder, rel_paths, backup_group): os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"multi_upload_{stamp}_{os.getpid()}") uploaded_file.save(tmp_path) try: size = os.path.getsize(tmp_path) if size <= 0: raise ValueError("uploaded file is empty") sha = file_sha256(tmp_path) results = [] for rel_path in rel_paths: target_path = os.path.join(base_folder, rel_path) ensure_parent_dir(target_path) backup_path = backup_existing_file(target_path, backup_group) tmp_target = target_path + ".tmp" shutil.copy2(tmp_path, tmp_target) os.replace(tmp_target, target_path) results.append({"rel": rel_path, "path": target_path, "size": os.path.getsize(target_path), "sha256": file_sha256(target_path), "backup": backup_path}) return {"size": size, "sha256": sha, "targets": results} finally: try: if os.path.exists(tmp_path): os.remove(tmp_path) except Exception: pass def save_japan_music(music_key, uploaded_file): if music_key not in JAPAN_MUSIC_TARGETS: return None return save_audio_upload_to_multiple_targets(uploaded_file, FOLDERS["JAPAN"], JAPAN_MUSIC_TARGETS[music_key], "JAPAN_music") def normalize_japan_image_rel(raw_name): safe = safe_zip_relpath(raw_name) if not safe: return None lower = safe.lower() marker = "/media/images/" if marker in lower: idx = lower.index(marker) + len(marker) return safe[idx:] if lower.startswith("media/images/"): return safe[len("media/images/"):] if lower.startswith("images/"): return safe[len("images/"):] marker2 = "/images/" if marker2 in lower: idx = lower.index(marker2) + len(marker2) return safe[idx:] return None def save_japan_images_zip(uploaded_file): image_root = os.path.join(FOLDERS["JAPAN"], "media", "images") os.makedirs(image_root, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"japan_images_{stamp}.zip") os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) uploaded_file.save(tmp_zip) imported, skipped, overwritten = [], [], [] try: if os.path.getsize(tmp_zip) > JAPAN_IMAGES_ZIP_MAX_BYTES: raise ValueError(f"zip_size_exceeded:{os.path.getsize(tmp_zip)}") with zipfile.ZipFile(tmp_zip, "r") as zf: infos = [info for info in zf.infolist() if not info.is_dir()] if len(infos) > JAPAN_IMAGES_ZIP_MAX_FILES: raise ValueError(f"too_many_files:{len(infos)}") total_uncompressed = sum(max(0, info.file_size) for info in infos) if total_uncompressed > JAPAN_IMAGES_ZIP_MAX_UNCOMPRESSED_BYTES: raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}") for info in infos: rel = normalize_japan_image_rel(info.filename) base = os.path.basename((info.filename or "").replace("\\", "/")) if not rel: skipped.append(base + " — לא בתיקיית images/media/images") continue if info.file_size > JAPAN_IMAGES_ZIP_MAX_SINGLE_FILE_BYTES: skipped.append(rel + " — גדול מדי") continue if file_ext(rel) not in JAPAN_IMAGE_EXTENSIONS: skipped.append(rel + " — סיומת לא נתמכת") continue # Prevent traversal after normalization. if not safe_zip_relpath(rel): skipped.append(base + " — נתיב לא בטוח") continue target = os.path.join(image_root, *rel.split("/")) ensure_parent_dir(target) if os.path.exists(target): overwritten.append(rel) backup_existing_file(target, "JAPAN_images_zip") tmp_target = target + ".tmp" with zf.open(info, "r") as source, open(tmp_target, "wb") as dest: shutil.copyfileobj(source, dest) os.replace(tmp_target, target) imported.append(rel) finally: try: os.remove(tmp_zip) except Exception: pass return sorted(imported), sorted(skipped), sorted(overwritten) def normalize_trip_package_rel(trip, raw_name): safe = safe_zip_relpath(raw_name) if not safe: return None lower = safe.lower() if trip == "USA": if lower.startswith("usa/"): return safe[4:] # Also allow a package that contains USA files at root. # Do not accept root index.html from Netlify ZIPs, because it is usually only a root redirect/landing file and would overwrite USA/index.html. if lower in {"usa2027-presentation.html", "usa2027-presentation-tv.html", "usa2027-tv.html"} or lower.startswith("media/usa-presentation/"): return safe return None if trip == "JAPAN": if lower.startswith("japan/"): return safe[6:] if lower in {"index.html", "japan-experience.html", "japan-flight-intro.html", "japan-theme.mp3"} or lower.startswith("media/"): return safe return None return None def save_trip_package_zip(trip, uploaded_file): if trip not in {"USA", "JAPAN"}: return None base_folder = FOLDERS[trip] os.makedirs(base_folder, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") tmp_zip = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"{trip.lower()}_package_{stamp}.zip") os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) uploaded_file.save(tmp_zip) imported, skipped, overwritten = [], [], [] try: if os.path.getsize(tmp_zip) > TRIP_PACKAGE_ZIP_MAX_BYTES: raise ValueError(f"zip_size_exceeded:{os.path.getsize(tmp_zip)}") with zipfile.ZipFile(tmp_zip, "r") as zf: infos = [info for info in zf.infolist() if not info.is_dir()] if len(infos) > TRIP_PACKAGE_ZIP_MAX_FILES: raise ValueError(f"too_many_files:{len(infos)}") total_uncompressed = sum(max(0, info.file_size) for info in infos) if total_uncompressed > TRIP_PACKAGE_ZIP_MAX_UNCOMPRESSED_BYTES: raise ValueError(f"zip_uncompressed_size_exceeded:{total_uncompressed}") for info in infos: rel = normalize_trip_package_rel(trip, info.filename) base = os.path.basename((info.filename or "").replace("\\", "/")) if not rel: skipped.append(base + " — לא שייך לחבילת " + trip) continue if info.file_size > TRIP_PACKAGE_ZIP_MAX_SINGLE_FILE_BYTES: skipped.append(rel + " — גדול מדי") continue ext = file_ext(rel) if ext not in TRIP_PACKAGE_EXTENSIONS: skipped.append(rel + " — סיומת לא נתמכת") continue if not safe_zip_relpath(rel): skipped.append(base + " — נתיב לא בטוח") continue target = os.path.join(base_folder, *rel.split("/")) ensure_parent_dir(target) if os.path.exists(target): overwritten.append(rel) backup_existing_file(target, f"{trip}_package_zip") tmp_target = target + ".tmp" with zf.open(info, "r") as source, open(tmp_target, "wb") as dest: shutil.copyfileobj(source, dest) os.replace(tmp_target, target) imported.append(rel) finally: try: os.remove(tmp_zip) except Exception: pass return sorted(imported), sorted(skipped), sorted(overwritten) def describe_path(path): exists = os.path.exists(path) item = {"path": path, "exists": exists} if exists and os.path.isfile(path): try: item["size"] = os.path.getsize(path) item["mtime"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))) item["sha256"] = file_sha256(path)[:16] except Exception as exc: item["error"] = repr(exc) return item def count_files(root, extensions=None): total = 0 if not os.path.isdir(root): return 0 for dirpath, _, filenames in os.walk(root): for name in filenames: if extensions is None or file_ext(name) in extensions: total += 1 return total def list_relative_files(root, extensions=None): files = [] if not os.path.isdir(root): return files for dirpath, _, filenames in os.walk(root): for name in filenames: if extensions is None or file_ext(name) in extensions: full = os.path.join(dirpath, name) rel = os.path.relpath(full, root).replace("\\", "/") files.append(rel) return sorted(files, key=lambda x: x.lower()) def normalize_web_path(path): value = str(path or "").strip().replace("\\", "/") value = value.split("?", 1)[0].split("#", 1)[0] while value.startswith("./"): value = value[2:] return value def analyze_japan_presentation_images(presentation_path=None): """Count images that Japan experience actually uses, not just files in media/images.""" presentation_path = presentation_path or os.path.join(FOLDERS["JAPAN"], "japan-experience.html") image_root = os.path.join(FOLDERS["JAPAN"], "media", "images") folder_files = list_relative_files(image_root, JAPAN_IMAGE_EXTENSIONS) result = { "presentation_path": presentation_path, "presentation_exists": os.path.isfile(presentation_path), "folder_images": len(folder_files), "image_slides": 0, "external_image_uses": 0, "embedded_image_uses": 0, "remote_image_uses": 0, "other_src_uses": 0, "unique_external_images": 0, "missing_external_images": 0, "unused_folder_images": 0, "chapter_slides": 0, "notes": [], "missing_external_list": [], "unused_folder_list": [], } if not result["presentation_exists"]: result["notes"].append("קובץ המצגת japan-experience.html לא נמצא, לכן נספרו רק קבצים בתיקיית התמונות.") return result try: with open(presentation_path, "r", encoding="utf-8", errors="ignore") as fh: html_text = fh.read() except Exception as exc: result["notes"].append("לא ניתן לקרוא את קובץ מצגת יפן: " + repr(exc)) return result refs = [m.group(2).strip() for m in re.finditer(r'["\']src["\']\s*:\s*(["\'])(.*?)\1', html_text, re.S) if m.group(2).strip()] if not refs: refs = [m.group(2).strip() for m in re.finditer(r'\bsrc\s*:\s*(["\'])(.*?)\1', html_text, re.S) if m.group(2).strip()] external = [] embedded = [] remote = [] other = [] for ref in refs: clean = normalize_web_path(ref) if clean.startswith("media/images/"): external.append(clean[len("media/images/"):]) elif clean.startswith("data:image"): embedded.append(ref) elif clean.startswith("http://") or clean.startswith("https://"): remote.append(ref) else: other.append(ref) unique_external = sorted(set(external), key=lambda x: x.lower()) folder_lower = {f.lower(): f for f in folder_files} used_lower = {f.lower() for f in unique_external} missing = [rel for rel in unique_external if rel.lower() not in folder_lower] unused = [rel for rel in folder_files if rel.lower() not in used_lower] result.update({ "image_slides": len(refs), "external_image_uses": len(external), "embedded_image_uses": len(embedded), "remote_image_uses": len(remote), "other_src_uses": len(other), "unique_external_images": len(unique_external), "missing_external_images": len(missing), "unused_folder_images": len(unused), "chapter_slides": len(re.findall(r'["\']chapter["\']\s*:\s*true', html_text)), "missing_external_list": missing[:40], "unused_folder_list": unused[:40], }) if result["image_slides"] == 0: result["notes"].append("לא נמצאו src בשקפי מצגת יפן. ייתכן שמבנה המצגת השתנה.") if result["missing_external_images"]: result["notes"].append("יש תמונות שהמצגת מבקשת אבל אינן קיימות בתיקיית media/images.") if result["unused_folder_images"]: result["notes"].append("יש תמונות בתיקייה שאינן בשימוש במצגת הפעילה.") return result def compact_japan_usage_for_health(): usage = analyze_japan_presentation_images() return { "image_slides_total": usage.get("image_slides", 0), "external_files_used": usage.get("external_image_uses", 0), "embedded_images": usage.get("embedded_image_uses", 0), "folder_images": usage.get("folder_images", 0), "missing_external_images": usage.get("missing_external_images", 0), "unused_folder_images": usage.get("unused_folder_images", 0), "chapter_slides": usage.get("chapter_slides", 0), "presentation_exists": usage.get("presentation_exists", False), } def render_admin_status(): rows = [] def add(label, path): info = describe_path(path) status = "✅ קיים" if info.get("exists") else "❌ חסר" size = f"{info.get('size', 0):,}" if info.get("exists") and "size" in info else "—" mtime = info.get("mtime", "—") sha = info.get("sha256", "—") rows.append(f"<tr><td>{html_lib.escape(label)}</td><td>{status}</td><td>{size}</td><td>{html_lib.escape(mtime)}</td><td>{html_lib.escape(sha)}</td><td><code>{html_lib.escape(path)}</code></td></tr>") add("פאנל פעיל", ADMIN_SERVER_PATH) add("טיול יפן ראשי", os.path.join(FOLDERS["JAPAN"], "index.html")) add("פתיח טיסה יפן", os.path.join(FOLDERS["JAPAN"], "japan-flight-intro.html")) add("מצגת יפן", os.path.join(FOLDERS["JAPAN"], "japan-experience.html")) add("מוזיקת מצגת יפן", os.path.join(FOLDERS["JAPAN"], "media", "japan-experience", "music", "japan-theme.mp3")) add("מוזיקת פתיח יפן", os.path.join(FOLDERS["JAPAN"], "media", "japan-flight", "music", "flight-intro.mp3")) add("טיול ארה״ב ראשי", os.path.join(FOLDERS["USA"], "index.html")) add("טיול ארה״ב TV", os.path.join(FOLDERS["USA"], "usa2027-tv.html")) add("מצגת ארה״ב", os.path.join(FOLDERS["USA"], "usa2027-presentation.html")) add("מצגת ארה״ב TV", os.path.join(FOLDERS["USA"], "usa2027-presentation-tv.html")) add("שיר ארה״ב חלק 1", os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music", "usa-roadtrip-theme.mp3")) add("שיר ארה״ב חלק 2", os.path.join(FOLDERS["USA"], "media", "usa-presentation", "music", "usa-part2-theme.mp3")) add("תקציב טיולים", os.path.join(FOLDERS["Moneyapp"], "index.html")) add("תקציב משפחתי", os.path.join(FOLDERS["MoneyHome"], "index.html")) add("טופס פתיחת טיול", os.path.join(FOLDERS["TripForm"], "index.html")) usa_img_count = count_files(os.path.join(FOLDERS["USA"], "media", "usa-presentation", "images"), USA_IMAGE_EXTENSIONS) japan_usage = analyze_japan_presentation_images() japan_img_count = japan_usage["folder_images"] japan_usage_ok = japan_usage["presentation_exists"] and japan_usage["missing_external_images"] == 0 japan_usage_badge = "✅ תקין" if japan_usage_ok else "⚠️ לבדיקה" japan_usage_details = ( f"{japan_usage['external_image_uses']} קבצים חיצוניים + " f"{japan_usage['embedded_image_uses']} מוטמעות · " f"בתיקייה: {japan_img_count} · " f"לא בשימוש: {japan_usage['unused_folder_images']} · " f"חסרות: {japan_usage['missing_external_images']}" ) watchdog_info = watchdog_status(include_log=False) watchdog_label = "ON" if watchdog_info.get("task_installed") else "OFF" missing_html = "" if japan_usage["missing_external_images"]: missing_items = "".join(f"<li><code>{html_lib.escape(x)}</code></li>" for x in japan_usage["missing_external_list"][:20]) missing_html = f"<div class='warn'><b>תמונות חסרות למצגת יפן</b><ul>{missing_items}</ul></div>" unused_html = "" if japan_usage["unused_folder_images"]: unused_items = "".join(f"<li><code>{html_lib.escape(x)}</code></li>" for x in japan_usage["unused_folder_list"][:20]) more = "" if japan_usage["unused_folder_images"] > 20: more = f"<p>ועוד {japan_usage['unused_folder_images'] - 20} קבצים שלא מוצגים כאן.</p>" unused_html = f"<details class='details'><summary>תמונות בתיקיית יפן שלא בשימוש במצגת ({japan_usage['unused_folder_images']})</summary><ul>{unused_items}</ul>{more}</details>" return f""" <!DOCTYPE html> <html lang="he" dir="rtl"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>סטטוס TripServer</title> <style> body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:24px}}.wrap{{max-width:1180px;margin:auto}}a{{color:#93c5fd}}table{{width:100%;border-collapse:collapse;background:#1e293b;border-radius:18px;overflow:hidden}}th,td{{border-bottom:1px solid #334155;padding:10px;text-align:right;vertical-align:top}}th{{background:#111827;color:#bfdbfe}}code{{direction:ltr;display:block;white-space:normal;color:#fde68a}}.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:18px 0}}.card{{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:16px}}.num{{font-size:30px;font-weight:900;color:#fde68a}} .small{{color:#cbd5e1;font-size:13px;line-height:1.5;margin-top:6px}}.warn{{background:rgba(127,29,29,.38);border:1px solid rgba(248,113,113,.35);color:#fecaca;border-radius:16px;padding:14px;margin:14px 0}}.details{{background:#111827;border:1px solid #334155;border-radius:16px;padding:14px;margin:14px 0;color:#cbd5e1}}summary{{cursor:pointer;font-weight:900;color:#fde68a}}ul{{margin:10px 0 0}} </style></head><body><div class="wrap"><a href="/admin">⬅ חזרה לפאנל</a><h1>סטטוס קבצים פעילים</h1><p>כאן רואים בפועל מה מותקן כרגע ב־C:\\TripServer, כולל גודל, תאריך שינוי ו־SHA קצר. זה נועד למנוע מצב של “העליתי אבל אני רואה גרסה ישנה”.</p><div class="cards"><div class="card"><div>תמונות מצגת ארה״ב</div><div class="num">{usa_img_count}</div><div class="small">כרטיסיות העלאה מסונכרנות לשקפי ארה״ב.</div></div><div class="card"><div>מצגת יפן — תמונות בשימוש בפועל</div><div class="num">{japan_usage['image_slides']}</div><div class="small">{japan_usage_badge} · {japan_usage_details}</div></div><div class="card"><div>קבצים בתיקיית תמונות יפן</div><div class="num">{japan_img_count}</div><div class="small">זה מספר קבצים בתיקייה, לא מספר התמונות שהמצגת משתמשת בהן.</div></div><div class="card"><div>גרסת פאנל</div><div>{html_lib.escape(ADMIN_PANEL_VERSION)}</div></div><div class="card"><div>Watchdog</div><div class="num">{watchdog_label}</div><div class="small">משימה מתוזמנת חיצונית לשמירת הפאנל באוויר.</div></div></div>{missing_html}{unused_html}<table><thead><tr><th>רכיב</th><th>מצב</th><th>גודל bytes</th><th>עודכן</th><th>SHA16</th><th>נתיב</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div></body></html> """ def validate_admin_server_upload(tmp_path): size = os.path.getsize(tmp_path) if size <= 0: return False, "הקובץ ריק." if size > ADMIN_MAX_UPLOAD_BYTES: return False, f"הקובץ גדול מדי: {size:,} bytes. המגבלה היא {ADMIN_MAX_UPLOAD_BYTES:,} bytes." try: py_compile.compile(tmp_path, doraise=True) except py_compile.PyCompileError as exc: return False, "בדיקת קומפילציה נכשלה:\n" + str(exc) except Exception as exc: return False, "שגיאה בבדיקת הקובץ:\n" + repr(exc) try: with open(tmp_path, "r", encoding="utf-8", errors="ignore") as fh: content = fh.read(400000) except Exception as exc: return False, "לא ניתן לקרוא את הקובץ:\n" + repr(exc) required_tokens = ["Flask", "@app.route", "app.run", "BASE_DIR", "ADMIN_PORT", "restart_process_later", "upload_admin_server", "run_self_test_suite", "build_watchdog_code", "watchdog_status"] missing = [token for token in required_tokens if token not in content] if missing: return False, "הקובץ עבר קומפילציה, אבל לא נראה כמו admin_server.py של TripServer. חסרים סימנים: " + ", ".join(missing) return True, "OK" def admin_status_page(title, message, ok=True, details=""): color = "#16a34a" if ok else "#dc2626" bg = "#052e1b" if ok else "#3f1d1d" safe_title = html_lib.escape(title) safe_message = html_lib.escape(message).replace("\n", "<br>") safe_details = html_lib.escape(details).replace("\n", "<br>") details_html = f"<pre>{safe_details}</pre>" if details else "" return f""" <!DOCTYPE html> <html lang="he" dir="rtl"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{safe_title}</title> <style> body{{margin:0;font-family:Arial;background:#0f172a;color:white;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}} .card{{width:min(780px,94vw);background:#1e293b;border-radius:24px;padding:26px;box-shadow:0 18px 70px rgba(0,0,0,.35)}} .badge{{display:inline-block;background:{bg};border:1px solid {color};color:white;border-radius:999px;padding:8px 14px;font-weight:bold;margin-bottom:12px}} h1{{margin:0 0 12px;font-size:30px}} p{{line-height:1.7;color:#e2e8f0}} pre{{white-space:pre-wrap;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:14px;padding:14px;overflow:auto;max-height:360px}} a,button{{display:inline-block;margin:8px 0 0 8px;background:#2563eb;color:white;border:0;border-radius:12px;padding:12px 16px;text-decoration:none;font-weight:bold;cursor:pointer}} button.restart{{background:#ea580c}} .advanced-note{{margin-top:10px;color:#cbd5e1;font-size:13px;line-height:1.55}} form{{display:inline}} </style> </head> <body> <div class="card"> <div class="badge">TripServer Admin</div> <h1>{safe_title}</h1> <p>{safe_message}</p> {details_html} <a href="/admin">חזרה לפאנל</a> <form action="/restart_admin_server" method="post" onsubmit="return confirm('זו בדיקת אתחול מתקדמת. להפעיל מחדש את הפאנל עכשיו?')"><button class="restart">בדיקת אתחול פאנל — מתקדם</button></form> <div class="advanced-note">לא חובה אחרי התקנת Watchdog. מיועד לבדיקה יזומה או אחרי עדכון/שחזור קובץ אדמין.</div> </div> </body> </html> """ def backup_current_admin_server(prefix="admin_server_backup"): os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") backup_path = os.path.join(ADMIN_BACKUP_DIR, f"{prefix}_{stamp}.py") shutil.copy2(ADMIN_SERVER_PATH, backup_path) return backup_path def latest_admin_backup(): os.makedirs(ADMIN_BACKUP_DIR, exist_ok=True) backups = glob.glob(os.path.join(ADMIN_BACKUP_DIR, "admin_server_backup_*.py")) if not backups: return None return max(backups, key=os.path.getmtime) def log_admin_restart(message): try: os.makedirs(BASE_DIR, exist_ok=True) with open(ADMIN_RESTART_LOG_PATH, "a", encoding="utf-8") as fh: fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(message) + "\n") except Exception: pass def build_restart_helper_code(script_path, python_exe, work_dir, current_pid=None, backup_path=None, expected_sha=None, reason=""): """Build a hard-restart helper that can recover from a stuck old process. v16 change: the helper does not merely start a new python process. It first waits for port 8000 to be released, then force-kills the old/admin PID or any process still listening on the admin port. It then starts the new server, checks /health.json, and can roll back to the previous admin backup if the new server fails to boot. """ return "\n".join([ "# -*- coding: utf-8 -*-", "import os", "import sys", "import subprocess", "import time", "import socket", "import json", "import urllib.request", "import hashlib", "import shutil", "", f"script_path = {script_path!r}", f"python_exe = {python_exe!r}", f"work_dir = {work_dir!r}", f"log_path = {ADMIN_RESTART_LOG_PATH!r}", f"admin_port = {ADMIN_PORT!r}", f"health_url = {'http://127.0.0.1:%s/health.json' % ADMIN_PORT!r}", f"current_pid = {int(current_pid or 0)!r}", f"backup_path = {backup_path!r}", f"expected_sha = {expected_sha!r}", f"reason = {reason!r}", "", "def log(message):", " try:", " os.makedirs(os.path.dirname(log_path), exist_ok=True)", " with open(log_path, 'a', encoding='utf-8') as fh:", " fh.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | hard-helper | ' + str(message) + '\\n')", " except Exception:", " pass", "", "def file_sha256(path):", " h = hashlib.sha256()", " with open(path, 'rb') as fh:", " for chunk in iter(lambda: fh.read(1024 * 1024), b''):", " h.update(chunk)", " return h.hexdigest()", "", "def port_open():", " try:", " with socket.create_connection(('127.0.0.1', int(admin_port)), timeout=1.0):", " return True", " except Exception:", " return False", "", "def wait_port_closed(timeout=20):", " deadline = time.time() + timeout", " while time.time() < deadline:", " if not port_open():", " return True", " time.sleep(0.5)", " return not port_open()", "", "def wait_health(timeout=40):", " deadline = time.time() + timeout", " last = 'not checked'", " while time.time() < deadline:", " try:", " req = urllib.request.Request(health_url, headers={'Accept': 'application/json'})", " with urllib.request.urlopen(req, timeout=3.0) as resp:", " raw = resp.read(12000).decode('utf-8', errors='replace')", " data = json.loads(raw)", " if data.get('ok'):", " return True, data.get('version', 'OK')", " last = raw[:500]", " except Exception as exc:", " last = repr(exc)", " time.sleep(1.0)", " return False, last", "", "def pids_on_port():", " pids = []", " if os.name != 'nt':", " return pids", " try:", " cp = subprocess.run(['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, shell=False, timeout=10)", " out = (cp.stdout or '') + '\\n' + (cp.stderr or '')", " marker1 = ':' + str(admin_port)", " for line in out.splitlines():", " lower = line.lower()", " if marker1 not in line or 'listen' not in lower:", " continue", " parts = line.split()", " if parts:", " try:", " pid = int(parts[-1])", " if pid and pid not in pids:", " pids.append(pid)", " except Exception:", " pass", " except Exception as exc:", " log('netstat failed: ' + repr(exc))", " return pids", "", "def kill_pid_tree(pid):", " try:", " pid = int(pid)", " except Exception:", " return False", " if pid <= 0 or pid == os.getpid():", " return False", " try:", " if os.name == 'nt':", " cp = subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], capture_output=True, text=True, shell=False, timeout=12)", " log('taskkill pid=' + str(pid) + ' rc=' + str(cp.returncode) + ' out=' + ((cp.stdout or cp.stderr or '').strip()[:500]))", " return cp.returncode == 0", " else:", " os.kill(pid, 9)", " return True", " except Exception as exc:", " log('kill pid failed pid=' + str(pid) + ' err=' + repr(exc))", " return False", "", "def hard_release_port():", " if wait_port_closed(8):", " log('port already closed')", " return True", " log('port still open; current_pid=' + str(current_pid) + ' pids=' + str(pids_on_port()))", " if current_pid:", " kill_pid_tree(current_pid)", " time.sleep(1.2)", " for pid in pids_on_port():", " kill_pid_tree(pid)", " ok = wait_port_closed(18)", " log('port release result=' + str(ok) + ' remaining_pids=' + str(pids_on_port()))", " return ok", "", "def start_server(label):", " flags = 0", " if os.name == 'nt':", " flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0)", " env = os.environ.copy()", " env.setdefault('PYTHONIOENCODING', 'utf-8')", " os.makedirs(os.path.dirname(log_path), exist_ok=True)", " with open(log_path, 'a', encoding='utf-8') as log_file:", " log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | hard-helper | starting ' + label + ': ' + script_path + '\\n')", " log_file.flush()", " proc = subprocess.Popen(", " [python_exe, script_path],", " cwd=work_dir,", " stdin=subprocess.DEVNULL,", " stdout=log_file,", " stderr=log_file,", " close_fds=(False if os.name == 'nt' else True),", " creationflags=flags,", " start_new_session=(os.name != 'nt'),", " env=env,", " )", " log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | hard-helper | ' + label + ' pid=' + str(proc.pid) + '\\n')", " log_file.flush()", " return proc.pid", "", "def rollback_to_backup():", " if not backup_path or not os.path.exists(backup_path):", " log('rollback unavailable: no backup_path or backup missing')", " return False", " try:", " failed_path = script_path + '.failed_' + time.strftime('%Y%m%d_%H%M%S') + '.py'", " if os.path.exists(script_path):", " shutil.copy2(script_path, failed_path)", " log('failed admin copied to ' + failed_path)", " shutil.copy2(backup_path, script_path)", " log('backup restored from ' + backup_path)", " return True", " except Exception as exc:", " log('rollback failed: ' + repr(exc))", " return False", "", "def main():", " log('restart orchestration started reason=' + str(reason) + ' script=' + script_path + ' python=' + python_exe + ' expected_sha=' + str(expected_sha))", " time.sleep(3.0)", " if expected_sha:", " try:", " actual = file_sha256(script_path)", " log('active admin sha=' + actual)", " if actual.lower() != str(expected_sha).lower():", " log('WARNING: active sha differs from expected uploaded sha')", " except Exception as exc:", " log('sha check failed: ' + repr(exc))", " hard_release_port()", " start_server('new-admin')", " ok, info = wait_health(45)", " log('new-admin health=' + str(ok) + ' info=' + str(info))", " if ok:", " return 0", " # New version did not boot. Kill anything on the port and restore last backup.", " for pid in pids_on_port():", " kill_pid_tree(pid)", " wait_port_closed(10)", " if rollback_to_backup():", " start_server('rollback-admin')", " ok2, info2 = wait_health(45)", " log('rollback-admin health=' + str(ok2) + ' info=' + str(info2))", " return 0 if ok2 else 20", " return 10", "", "if __name__ == '__main__':", " raise SystemExit(main())", "", ]) + "\n" def validate_restart_helper_source(helper_code): try: compile(helper_code, "<tripserver_restart_helper>", "exec") return True, "OK" except Exception as exc: return False, repr(exc) def write_restart_helper(script_path, python_exe, work_dir, backup_path=None, expected_sha=None, reason=""): helper_code = build_restart_helper_code(script_path, python_exe, work_dir, os.getpid(), backup_path, expected_sha, reason) ok, info = validate_restart_helper_source(helper_code) if not ok: raise RuntimeError("Generated restart helper is invalid: " + info) ensure_parent_dir(ADMIN_RESTART_HELPER_PATH) with open(ADMIN_RESTART_HELPER_PATH, "w", encoding="utf-8") as fh: fh.write(helper_code) py_compile.compile(ADMIN_RESTART_HELPER_PATH, doraise=True) return ADMIN_RESTART_HELPER_PATH def restart_process_later(backup_path=None, expected_sha=None, reason="manual_restart"): """Hard self-restart for Windows/iPhone admin workflow. v16: the helper can force-release a stuck port and roll back to the previous admin backup if the newly uploaded admin does not pass /health.json after boot. """ time.sleep(1.2) script_path = os.path.abspath(ADMIN_SERVER_PATH) python_exe = sys.executable or "python" work_dir = os.path.dirname(script_path) or BASE_DIR try: log_admin_restart(f"hard restart requested | reason={reason} | version={ADMIN_PANEL_VERSION} | script={script_path} | python={python_exe} | pid={os.getpid()} | backup={backup_path} | expected_sha={expected_sha} | argv={sys.argv}") helper_path = write_restart_helper(script_path, python_exe, work_dir, backup_path=backup_path, expected_sha=expected_sha, reason=reason) flags = 0 if os.name == "nt": flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) subprocess.Popen( [python_exe, helper_path], cwd=work_dir, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=(False if os.name == "nt" else True), creationflags=flags, start_new_session=(os.name != "nt"), ) log_admin_restart("hard restart helper launched: " + helper_path) os._exit(0) except Exception as exc: log_admin_restart("hard restart helper failed: " + repr(exc)) try: subprocess.Popen([python_exe, script_path], cwd=work_dir, close_fds=True, start_new_session=(os.name != "nt")) os._exit(0) except Exception as exc2: log_admin_restart("direct restart fallback failed: " + repr(exc2)) try: os.execv(python_exe, [python_exe, script_path]) except Exception as exc3: log_admin_restart("execv fallback failed: " + repr(exc3)) os._exit(1) def build_watchdog_code(script_path, python_exe, work_dir): """Build a one-shot watchdog script for Windows Task Scheduler. v16 change: if the port is open but /health.json fails repeatedly, the watchdog treats it as a stuck admin process, kills the process that owns port 8000, and starts admin_server.py again. This is the case that forced a physical reboot. """ return "\n".join([ "# -*- coding: utf-8 -*-", "import os", "import sys", "import time", "import socket", "import subprocess", "import urllib.request", "import json", "", f"script_path = {script_path!r}", f"python_exe = {python_exe!r}", f"work_dir = {work_dir!r}", f"admin_port = {ADMIN_PORT!r}", f"health_url = {'http://127.0.0.1:%s/health.json' % ADMIN_PORT!r}", f"log_path = {WATCHDOG_LOG_PATH!r}", f"state_path = {WATCHDOG_STATE_PATH!r}", f"cooldown_seconds = {WATCHDOG_MIN_RESTART_INTERVAL_SECONDS!r}", "", "def log(message):", " try:", " os.makedirs(os.path.dirname(log_path), exist_ok=True)", " with open(log_path, 'a', encoding='utf-8') as fh:", " fh.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v16 | ' + str(message) + '\\n')", " except Exception:", " pass", "", "def load_state():", " try:", " raw = open(state_path, 'r', encoding='utf-8').read().strip()", " if not raw:", " return {}", " try:", " data = json.loads(raw)", " return data if isinstance(data, dict) else {}", " except Exception:", " return {'last_start': float(raw)}", " except Exception:", " return {}", "", "def save_state(state):", " try:", " os.makedirs(os.path.dirname(state_path), exist_ok=True)", " with open(state_path, 'w', encoding='utf-8') as fh:", " json.dump(state, fh)", " except Exception:", " pass", "", "def port_open():", " try:", " with socket.create_connection(('127.0.0.1', int(admin_port)), timeout=2.0):", " return True", " except Exception:", " return False", "", "def pids_on_port():", " pids = []", " if os.name != 'nt':", " return pids", " try:", " cp = subprocess.run(['netstat', '-ano', '-p', 'tcp'], capture_output=True, text=True, shell=False, timeout=10)", " out = (cp.stdout or '') + '\\n' + (cp.stderr or '')", " marker = ':' + str(admin_port)", " for line in out.splitlines():", " if marker not in line or 'listen' not in line.lower():", " continue", " parts = line.split()", " if parts:", " try:", " pid = int(parts[-1])", " if pid and pid not in pids:", " pids.append(pid)", " except Exception:", " pass", " except Exception as exc:", " log('netstat failed: ' + repr(exc))", " return pids", "", "def kill_pid_tree(pid):", " try:", " pid = int(pid)", " except Exception:", " return False", " if pid <= 0 or pid == os.getpid():", " return False", " try:", " if os.name == 'nt':", " cp = subprocess.run(['taskkill', '/PID', str(pid), '/T', '/F'], capture_output=True, text=True, shell=False, timeout=12)", " log('taskkill pid=' + str(pid) + ' rc=' + str(cp.returncode) + ' out=' + ((cp.stdout or cp.stderr or '').strip()[:500]))", " return cp.returncode == 0", " os.kill(pid, 9)", " return True", " except Exception as exc:", " log('kill pid failed pid=' + str(pid) + ' err=' + repr(exc))", " return False", "", "def wait_port_closed(timeout=12):", " deadline = time.time() + timeout", " while time.time() < deadline:", " if not port_open():", " return True", " time.sleep(0.6)", " return not port_open()", "", "def health_ok():", " try:", " req = urllib.request.Request(health_url, headers={'Accept': 'application/json'})", " with urllib.request.urlopen(req, timeout=4.0) as resp:", " raw = resp.read(8192).decode('utf-8', errors='replace')", " data = json.loads(raw)", " return bool(data.get('ok')), 'OK' if data.get('ok') else raw[:500]", " except Exception as exc:", " return False, repr(exc)", "", "def recently_started(state):", " try:", " last = float(state.get('last_start') or 0)", " return (time.time() - last) < float(cooldown_seconds)", " except Exception:", " return False", "", "def mark_started(state):", " state['last_start'] = time.time()", " state['failure_count'] = 0", " save_state(state)", "", "def script_compiles():", " try:", " import py_compile", " py_compile.compile(script_path, doraise=True)", " return True, 'OK'", " except Exception as exc:", " return False, repr(exc)", "", "def launch_admin(state):", " flags = 0", " if os.name == 'nt':", " flags = getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0) | getattr(subprocess, 'DETACHED_PROCESS', 0)", " env = os.environ.copy()", " env.setdefault('PYTHONIOENCODING', 'utf-8')", " os.makedirs(os.path.dirname(log_path), exist_ok=True)", " with open(log_path, 'a', encoding='utf-8') as log_file:", " log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v16 | starting admin: ' + script_path + '\\n')", " log_file.flush()", " proc = subprocess.Popen(", " [python_exe, script_path],", " cwd=work_dir,", " stdin=subprocess.DEVNULL,", " stdout=log_file,", " stderr=log_file,", " close_fds=(False if os.name == 'nt' else True),", " creationflags=flags,", " start_new_session=(os.name != 'nt'),", " env=env,", " )", " log_file.write(time.strftime('%Y-%m-%d %H:%M:%S') + ' | watchdog-v16 | started pid=' + str(proc.pid) + '\\n')", " mark_started(state)", "", "def main():", " state = load_state()", " ok, info = health_ok()", " if ok:", " if state.get('failure_count'):", " state['failure_count'] = 0", " save_state(state)", " return 0", " fail_count = int(state.get('failure_count') or 0) + 1", " state['failure_count'] = fail_count", " save_state(state)", " is_port_open = port_open()", " log('health failed count=' + str(fail_count) + ' port_open=' + str(is_port_open) + ' info=' + str(info))", " if recently_started(state):", " log('restart cooldown active; skip this minute')", " return 3", " if is_port_open:", " pids = pids_on_port()", " if fail_count < 2:", " log('port is open but health failed; waiting for second failure before hard kill. pids=' + str(pids))", " return 2", " log('port open + repeated health failure; killing stuck pids=' + str(pids))", " for pid in pids:", " kill_pid_tree(pid)", " wait_port_closed(14)", " if not os.path.exists(script_path):", " log('admin script not found: ' + script_path)", " return 4", " ok_compile, compile_info = script_compiles()", " if not ok_compile:", " log('admin script does not compile; not starting. info=' + str(compile_info))", " return 5", " try:", " launch_admin(state)", " return 10", " except Exception as exc:", " log('failed to launch admin: ' + repr(exc))", " return 6", "", "if __name__ == '__main__':", " raise SystemExit(main())", "", ]) + "\n" def validate_watchdog_source(watchdog_code): try: compile(watchdog_code, "<tripserver_watchdog>", "exec") return True, "OK" except Exception as exc: return False, repr(exc) def write_watchdog_script(): script_path = os.path.abspath(ADMIN_SERVER_PATH) python_exe = sys.executable or "python" work_dir = os.path.dirname(script_path) or BASE_DIR source = build_watchdog_code(script_path, python_exe, work_dir) ok, info = validate_watchdog_source(source) if not ok: raise RuntimeError("Generated watchdog script is invalid: " + info) ensure_parent_dir(WATCHDOG_SCRIPT_PATH) with open(WATCHDOG_SCRIPT_PATH, "w", encoding="utf-8") as fh: fh.write(source) py_compile.compile(WATCHDOG_SCRIPT_PATH, doraise=True) return WATCHDOG_SCRIPT_PATH def read_log_tail(path, max_lines=18): try: with open(path, "r", encoding="utf-8", errors="replace") as fh: lines = fh.readlines() return "".join(lines[-max_lines:]).strip() except Exception: return "" def safe_int(value, default, min_value=None, max_value=None): try: parsed = int(value) except Exception: parsed = default if min_value is not None: parsed = max(min_value, parsed) if max_value is not None: parsed = min(max_value, parsed) return parsed def log_file_summary(log_key, max_lines=LOG_TAIL_DEFAULT_LINES): meta = LOG_FILE_REGISTRY.get(log_key) if not meta: return None path = meta["path"] exists = os.path.exists(path) size = os.path.getsize(path) if exists else 0 updated = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(os.path.getmtime(path))) if exists else "לא קיים עדיין" tail = read_log_tail(path, max_lines=max_lines) if exists else "אין עדיין לוג. הוא יווצר אחרי פעולה ראשונה." return { "key": log_key, "label": meta["label"], "note": meta.get("note", ""), "path": path, "exists": exists, "size": size, "size_label": format_bytes(size), "updated": updated, "tail": tail, } def build_logs_payload(max_lines=LOG_TAIL_DEFAULT_LINES): max_lines = safe_int(max_lines, LOG_TAIL_DEFAULT_LINES, 20, LOG_TAIL_MAX_LINES) items = [] for key in LOG_FILE_REGISTRY: info = log_file_summary(key, max_lines=max_lines) if info: items.append(info) return { "ok": True, "version": ADMIN_PANEL_VERSION, "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"), "lines": max_lines, "logs": items, } def render_admin_logs(): payload = build_logs_payload(request.args.get("lines", LOG_TAIL_DEFAULT_LINES)) cards = [] for info in payload["logs"]: badge_cls = "ok" if info["exists"] else "warn" badge_text = "קיים" if info["exists"] else "לא נוצר עדיין" tail = html_lib.escape(info["tail"] or "אין תוכן להצגה.").replace("\n", "<br>") cards.append(f''' <div class="log-card"> <div class="log-head"> <div><h2>{html_lib.escape(info["label"])}</h2><p>{html_lib.escape(info["note"])}</p></div> <span class="badge {badge_cls}">{badge_text}</span> </div> <div class="meta"><b>נתיב</b><code>{html_lib.escape(info["path"])}</code></div> <div class="stats"><span>גודל: <b>{html_lib.escape(info["size_label"])}</b></span><span>עודכן: <b>{html_lib.escape(info["updated"])}</b></span></div> <pre>{tail}</pre> </div>''') return f''' <!DOCTYPE html> <html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>לוגים TripServer</title> <style> body{{margin:0;background:#07111f;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1120px;margin:auto}}a{{color:#dbeafe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#1e3a8a,#0f172a 62%,#111827);border:1px solid #1d4ed8;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.6}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}}.actions a{{background:#2563eb;color:white;border-radius:999px;padding:10px 14px;font-weight:900}}.actions a.secondary{{background:#1e293b;border:1px solid #334155}}.log-card{{background:#101c31;border:1px solid #253650;border-radius:24px;padding:18px;margin:14px 0;box-shadow:0 18px 55px rgba(0,0,0,.22)}}.log-head{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}}.log-head h2{{margin:0 0 6px;font-size:24px}}.badge{{white-space:nowrap;border-radius:999px;padding:7px 11px;font-weight:900}}.badge.ok{{background:#14532d;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#451a03;color:#fed7aa;border:1px solid #f97316}}.meta{{margin-top:10px;color:#cbd5e1}}code{{direction:ltr;display:block;background:#0b1220;border:1px solid #334155;border-radius:12px;padding:10px;color:#fde68a;margin-top:6px;white-space:normal}}.stats{{display:flex;gap:12px;flex-wrap:wrap;color:#cbd5e1;margin:12px 0}}.stats span{{background:#0b1220;border:1px solid #334155;border-radius:999px;padding:8px 10px}}pre{{direction:ltr;text-align:left;white-space:pre-wrap;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:16px;padding:14px;overflow:auto;max-height:420px;font-size:13px;line-height:1.55}}@media(max-width:700px){{body{{padding:14px}}.hero{{padding:18px;border-radius:22px}}h1{{font-size:28px}}.log-head{{display:block}}}} </style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>📜 מרכז לוגים</h1><p>צפייה נוחה בלוגי האדמין וה־Watchdog בלי לפתוח קבצים ידנית במחשב. המסך לקריאה בלבד ולא מוחק או משנה שום דבר.</p><div class="actions"><a href="/admin/logs">רענן</a><a class="secondary" href="/admin/logs?lines=250">הצג 250 שורות</a><a class="secondary" href="/admin/logs?lines=500">הצג 500 שורות</a><a class="secondary" href="/admin/logs.json" target="_blank">JSON טכני</a><a class="secondary" href="/admin/self-test">בדיקה עצמית</a></div></div>{''.join(cards)}</div></body></html> ''' def query_watchdog_task(): if os.name != "nt": return {"supported": False, "installed": False, "ok": False, "details": "Task Scheduler זמין רק על Windows."} try: completed = subprocess.run(["schtasks", "/Query", "/TN", WATCHDOG_TASK_NAME, "/FO", "LIST"], capture_output=True, text=True, shell=False, timeout=12) output = (completed.stdout or completed.stderr or "").strip() return {"supported": True, "installed": completed.returncode == 0, "ok": completed.returncode == 0, "details": output} except Exception as exc: return {"supported": True, "installed": False, "ok": False, "details": repr(exc)} def watchdog_status(include_log=True, include_task=True): generated = build_watchdog_code(os.path.abspath(ADMIN_SERVER_PATH), sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR) source_ok, source_info = validate_watchdog_source(generated) file_exists = os.path.exists(WATCHDOG_SCRIPT_PATH) file_compiles = False file_info = "לא נכתב עדיין" if file_exists: try: py_compile.compile(WATCHDOG_SCRIPT_PATH, doraise=True) file_compiles = True file_info = "OK" except Exception as exc: file_info = repr(exc) task = query_watchdog_task() if include_task else {"supported": None, "installed": None, "ok": None, "details": "not queried"} return { "source_ok": source_ok, "source_status": source_info, "script_path": WATCHDOG_SCRIPT_PATH, "script_exists": file_exists, "script_compiles": file_compiles, "script_status": file_info, "task_name": WATCHDOG_TASK_NAME, "task_supported": task.get("supported"), "task_installed": task.get("installed"), "task_status": task.get("details", ""), "interval_minutes": WATCHDOG_INTERVAL_MINUTES, "cooldown_seconds": WATCHDOG_MIN_RESTART_INTERVAL_SECONDS, "log_path": WATCHDOG_LOG_PATH, "log_tail": read_log_tail(WATCHDOG_LOG_PATH) if include_log else "", } def install_watchdog_task(): script_path = write_watchdog_script() if os.name != "nt": return False, "קובץ ה־Watchdog נכתב, אבל התקנת Task Scheduler נתמכת רק על Windows.", f"script={script_path}" task_command = f'"{sys.executable or "python"}" "{script_path}"' cmd = ["schtasks", "/Create", "/TN", WATCHDOG_TASK_NAME, "/TR", task_command, "/SC", "MINUTE", "/MO", str(max(1, WATCHDOG_INTERVAL_MINUTES)), "/F"] completed = subprocess.run(cmd, capture_output=True, text=True, shell=False, timeout=20) output = (completed.stdout or completed.stderr or "").strip() if completed.returncode != 0: return False, "קובץ ה־Watchdog נכתב, אבל התקנת המשימה נכשלה.", output or f"return code: {completed.returncode}" return True, "Watchdog הותקן כמשימה מתוזמנת שרצה כל דקה.", output def uninstall_watchdog_task(): if os.name != "nt": return False, "הסרת Task Scheduler נתמכת רק על Windows.", "" completed = subprocess.run(["schtasks", "/Delete", "/TN", WATCHDOG_TASK_NAME, "/F"], capture_output=True, text=True, shell=False, timeout=20) output = (completed.stdout or completed.stderr or "").strip() if completed.returncode != 0: return False, "לא הצלחתי להסיר את משימת ה־Watchdog, או שהיא לא קיימת.", output or f"return code: {completed.returncode}" return True, "משימת ה־Watchdog הוסרה.", output def run_watchdog_once_now(): script_path = write_watchdog_script() completed = subprocess.run([sys.executable or "python", script_path], capture_output=True, text=True, shell=False, timeout=25) output = (completed.stdout or completed.stderr or "").strip() ok = completed.returncode in {0, 2, 3, 10} return ok, f"Watchdog רץ פעם אחת והחזיר קוד {completed.returncode}.", output or read_log_tail(WATCHDOG_LOG_PATH) def render_admin_watchdog(): status = watchdog_status(include_log=True) installed = bool(status.get("task_installed")) script_ok = bool(status.get("script_exists")) and bool(status.get("script_compiles")) source_ok = bool(status.get("source_ok")) state_badge = "מותקן ופעיל" if installed else "לא מותקן עדיין" state_cls = "ok" if installed and source_ok else "warn" task_text = html_lib.escape(status.get("task_status") or "") log_tail = html_lib.escape(status.get("log_tail") or "אין עדיין לוג.").replace("\n", "<br>") script_label = "✅ קיים ותקין" if script_ok else "⚠️ עדיין לא נכתב/לא תקין" task_label = "✅ מותקנת" if installed else "⚠️ לא מותקנת" return f""" <!DOCTYPE html> <html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>TripServer Watchdog</title> <style> body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1050px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#14532d,#0f172a 60%,#111827);border:1px solid #166534;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.7}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#713f12;color:#fde68a;border:1px solid #f59e0b}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.card b{{display:block;font-size:18px;margin-bottom:8px}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap;word-break:break-word}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:14px;padding:13px;max-height:340px;overflow:auto}}button{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer}}button.orange{{background:#ea580c}}button.danger{{background:#dc2626}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}}.actions a{{display:inline-flex;background:#172033;border:1px solid #334155;border-radius:999px;padding:10px 14px;font-weight:900}}.note{{background:#0b1220;border:1px solid #334155;border-radius:18px;padding:14px;margin:14px 0;color:#cbd5e1;line-height:1.7}}@media(max-width:700px){{body{{padding:14px}}h1{{font-size:28px}}}} </style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>🛡️ Watchdog לשמירת פאנל האדמין</h1><p>שכבת ביטחון חיצונית: Windows מריץ סקריפט קטן כל דקה. אם הפאנל לא עונה ב־localhost:{ADMIN_PORT}, הוא מפעיל מחדש את admin_server.py לבד.</p><span class="badge {state_cls}">{state_badge}</span><div class="actions"><a href="/admin/self-test">בדיקה עצמית</a><a href="/admin/health">Health</a><a href="/admin/status">סטטוס קבצים</a></div></div> <div class="grid"><div class="card"><b>סקריפט Watchdog</b><p>{script_label}</p><code>{html_lib.escape(status.get('script_path',''))}</code></div><div class="card"><b>משימה מתוזמנת</b><p>{task_label}</p><p>שם: <code>{html_lib.escape(status.get('task_name',''))}</code></p><p>תדירות: כל {status.get('interval_minutes')} דקה</p></div><div class="card"><b>Cooldown</b><p>{status.get('cooldown_seconds')} שניות בין ניסיונות הפעלה כדי למנוע לופ.</p></div></div> <div class="note"><b>מה זה עושה בפועל?</b><br>ה־Watchdog לא מחליף קבצים, לא מכבה מחשב ולא עושה restart למחשב. הוא רק בודק אם פאנל האדמין חי, ואם לא — מפעיל את קובץ האדמין הפעיל מחדש. זה בדיוק הכיסוי למקרה שבו העלאת admin או restart נפלו ואתה לא רוצה ללכת פיזית למחשב.</div> <div class="card"><b>פעולות</b><form action="/watchdog/install" method="post" onsubmit="return confirm('להתקין Watchdog מתוזמן שרץ כל דקה?')"><button>התקן / עדכן Watchdog</button></form><form action="/watchdog/run-once" method="post" onsubmit="return confirm('להריץ בדיקת Watchdog חד־פעמית עכשיו?')"><button class="orange">הרץ פעם אחת עכשיו</button></form><form action="/watchdog/uninstall" method="post" onsubmit="return confirm('להסיר את משימת ה־Watchdog?')"><button class="danger">הסר Watchdog</button></form></div> <div class="card"><b>סטטוס Task Scheduler</b><pre>{task_text or 'אין מידע.'}</pre></div><div class="card"><b>לוג אחרון</b><pre>{log_tail}</pre></div></div></body></html> """ def get_client_ip(): # Use the direct remote address rather than trusting arbitrary X-Forwarded-For headers. return request.remote_addr or "" def is_private_or_loopback_ip(ip_text): try: ip = ipaddress.ip_address((ip_text or "").split("%", 1)[0]) tailscale_ipv4 = ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10") tailscale_ipv6 = ip.version == 6 and ip in ipaddress.ip_network("fd7a:115c:a1e0::/48") return ip.is_loopback or ip.is_private or ip.is_link_local or tailscale_ipv4 or tailscale_ipv6 except Exception: return False def power_action_allowed(): if not POWER_ACTIONS_ENABLED: return False, "פעולות כיבוי/ריסטארט מחשב כבויות בהגדרת TRIPSERVER_ENABLE_POWER_ACTIONS." ip = get_client_ip() if not is_private_or_loopback_ip(ip): return False, f"פעולת מחשב חסומה מכתובת שאינה מקומית/רשת ביתית: {ip}" if os.name != "nt": return False, "פעולות מחשב נתמכות כאן רק על Windows." return True, "OK" def normalize_power_delay(raw_value): try: delay = int(raw_value) except Exception: delay = POWER_DEFAULT_DELAY_SECONDS return max(POWER_MIN_DELAY_SECONDS, min(POWER_MAX_DELAY_SECONDS, delay)) def run_power_command(action, delay_seconds): if action == "shutdown": mode = "/s" label = "כיבוי מחשב" elif action == "reboot": mode = "/r" label = "ריסטארט למחשב" else: raise ValueError("unknown power action") delay = normalize_power_delay(delay_seconds) comment = f"TripServer Admin scheduled {label}" cmd = ["shutdown", mode, "/t", str(delay), "/c", comment] completed = subprocess.run(cmd, capture_output=True, text=True, shell=False) if completed.returncode != 0: raise RuntimeError((completed.stderr or completed.stdout or "shutdown command failed").strip()) log_admin_restart(f"POWER {action} scheduled in {delay}s by {get_client_ip()}") return label, delay, completed.stdout.strip() or completed.stderr.strip() def cancel_power_command(): completed = subprocess.run(["shutdown", "/a"], capture_output=True, text=True, shell=False) # Windows returns non-zero when there is no pending shutdown. Treat it as useful feedback, not a server crash. log_admin_restart(f"POWER cancel requested by {get_client_ip()} | rc={completed.returncode} | out={completed.stdout.strip()} | err={completed.stderr.strip()}") return completed.returncode, (completed.stdout or completed.stderr or "").strip() def camera_access_allowed(): if not CAMERA_ENABLED: return False, "מצלמת החדר כבויה בהגדרת TRIPSERVER_ENABLE_WEBCAM." ip = get_client_ip() if not is_private_or_loopback_ip(ip): return False, f"גישה למצלמה חסומה מכתובת שאינה מקומית/רשת ביתית/Tailscale: {ip}" return True, "OK" def camera_engine_status(): status = {"enabled": CAMERA_ENABLED, "opencv": False, "opencv_version": "", "camera_index": CAMERA_INDEX} try: import cv2 # noqa: F401 status["opencv"] = True status["opencv_version"] = getattr(cv2, "__version__", "") except Exception as exc: status["opencv"] = False status["error"] = str(exc) return status class WebcamManager: def __init__(self): self.lock = threading.RLock() self.cap = None self.opened_at = 0.0 self.last_frame_at = 0.0 self.frames = 0 self.last_error = "" self.stop_requested = False def _import_cv2(self): import cv2 return cv2 def _open_locked(self): cv2 = self._import_cv2() if self.cap is not None and self.cap.isOpened(): return cv2 self.stop_requested = False backend = getattr(cv2, "CAP_DSHOW", 0) if os.name == "nt" else 0 cap = cv2.VideoCapture(CAMERA_INDEX, backend) try: cap.set(getattr(cv2, "CAP_PROP_FRAME_WIDTH", 3), CAMERA_WIDTH) cap.set(getattr(cv2, "CAP_PROP_FRAME_HEIGHT", 4), CAMERA_HEIGHT) cap.set(getattr(cv2, "CAP_PROP_FPS", 5), CAMERA_FPS) except Exception: pass if not cap or not cap.isOpened(): try: cap.release() except Exception: pass self.last_error = f"לא הצלחתי לפתוח מצלמה index={CAMERA_INDEX}. בדוק הרשאות Windows או מצלמה תפוסה באפליקציה אחרת." raise RuntimeError(self.last_error) self.cap = cap self.opened_at = time.time() self.frames = 0 self.last_error = "" return cv2 def read_jpeg(self): with self.lock: if self.stop_requested: raise RuntimeError("המצלמה כובתה ידנית") cv2 = self._open_locked() ok, frame = self.cap.read() if not ok or frame is None: time.sleep(0.15) ok, frame = self.cap.read() if not ok or frame is None: self.last_error = "לא התקבלה תמונה מהמצלמה." raise RuntimeError(self.last_error) ok, encoded = cv2.imencode(".jpg", frame, [int(getattr(cv2, "IMWRITE_JPEG_QUALITY", 1)), CAMERA_JPEG_QUALITY]) if not ok: self.last_error = "קידוד תמונת המצלמה ל־JPG נכשל." raise RuntimeError(self.last_error) self.frames += 1 self.last_frame_at = time.time() return encoded.tobytes() def stop(self): with self.lock: self.stop_requested = True if self.cap is not None: try: self.cap.release() except Exception: pass self.cap = None self.opened_at = 0.0 def status(self): with self.lock: active = bool(self.cap is not None and self.cap.isOpened()) return { "enabled": CAMERA_ENABLED, "active": active, "camera_index": CAMERA_INDEX, "width": CAMERA_WIDTH, "height": CAMERA_HEIGHT, "fps": CAMERA_FPS, "jpeg_quality": CAMERA_JPEG_QUALITY, "opened_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.opened_at)) if self.opened_at else "", "last_frame_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.last_frame_at)) if self.last_frame_at else "", "frames": self.frames, "last_error": self.last_error, } CAMERA_MANAGER = WebcamManager() def log_camera_event(message): try: os.makedirs(BASE_DIR, exist_ok=True) with open(os.path.join(BASE_DIR, "camera_security.log"), "a", encoding="utf-8") as fh: fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(message) + "\n") except Exception: pass def ffmpeg_dshow_devices(): """Return DirectShow audio/video devices available to FFmpeg on Windows.""" devices = {"video": [], "audio": [], "error": "", "raw": ""} ffmpeg = find_ffmpeg_executable() if not ffmpeg: devices["error"] = "FFmpeg לא נמצא." return devices if os.name != "nt": devices["error"] = "זיהוי התקני מצלמה/מיקרופון דרך DirectShow נתמך כאן בעיקר ב־Windows." return devices try: completed = subprocess.run( [ffmpeg, "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=12, ) raw = (completed.stdout or "") + "\n" + (completed.stderr or "") devices["raw"] = raw[-6000:] for name, kind in re.findall(r'"([^"]+)"\s+\((video|audio)\)', raw, flags=re.I): bucket = "video" if kind.lower() == "video" else "audio" if name not in devices[bucket]: devices[bucket].append(name) except Exception as exc: devices["error"] = str(exc) return devices def selected_ffmpeg_devices(): devices = ffmpeg_dshow_devices() video = CAMERA_VIDEO_DEVICE or (devices.get("video") or [""])[0] audio = CAMERA_AUDIO_DEVICE or (devices.get("audio") or [""])[0] return video, audio, devices def dshow_input_name(video_device="", audio_device=""): parts = [] if video_device: parts.append("video=" + video_device) if audio_device: parts.append("audio=" + audio_device) return ":".join(parts) class CameraAudioStreamManager: def __init__(self): self.lock = threading.RLock() self.proc = None self.started_at = 0.0 self.device = "" self.last_error = "" def start_locked(self): if self.proc is not None and self.proc.poll() is None: return self.proc if not CAMERA_AUDIO_ENABLED: raise RuntimeError("אודיו מצלמה כבוי בהגדרת TRIPSERVER_ENABLE_CAMERA_AUDIO.") ffmpeg = find_ffmpeg_executable() if not ffmpeg: raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg כדי להזרים אודיו חי.") _, audio, devices = selected_ffmpeg_devices() if not audio: raise RuntimeError("לא נמצא התקן מיקרופון ב־FFmpeg. בדוק הרשאות Windows או הגדר TRIPSERVER_FFMPEG_AUDIO_DEVICE.") cmd = [ ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "dshow", "-i", "audio=" + audio, "-vn", "-ac", "1", "-ar", "44100", "-codec:a", "libmp3lame", "-b:a", "96k", "-f", "mp3", "pipe:1", ] self.proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) self.started_at = time.time() self.device = audio self.last_error = "" log_camera_event(f"audio stream started by {get_client_ip()} | device={audio}") return self.proc def stream_chunks(self): try: with self.lock: proc = self.start_locked() while proc is not None and proc.poll() is None: chunk = proc.stdout.read(4096) if proc.stdout else b"" if not chunk: break yield chunk except GeneratorExit: pass except Exception as exc: self.last_error = str(exc) yield b"" finally: self.stop() def stop(self): with self.lock: proc = self.proc self.proc = None if proc is not None and proc.poll() is None: try: proc.terminate() proc.wait(timeout=3) except Exception: try: proc.kill() except Exception: pass self.started_at = 0.0 log_camera_event(f"audio stream stopped by {get_client_ip()}") def status(self): with self.lock: active = bool(self.proc is not None and self.proc.poll() is None) return { "audio_enabled": CAMERA_AUDIO_ENABLED, "active": active, "device": self.device, "started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "", "last_error": self.last_error, } class CameraRecordingManager: def __init__(self): self.lock = threading.RLock() self.proc = None self.started_at = 0.0 self.file_path = "" self.log_path = "" self.last_error = "" self.with_audio = False self.video_device = "" self.audio_device = "" self.log_handle = None def _refresh_locked(self): if self.proc is not None and self.proc.poll() is not None: try: if self.log_handle: self.log_handle.close() except Exception: pass self.log_handle = None self.proc = None self.started_at = 0.0 def start(self, with_audio=True): with self.lock: self._refresh_locked() if self.proc is not None and self.proc.poll() is None: raise RuntimeError("יש כבר הקלטה פעילה. עצור אותה לפני התחלה חדשה.") if not CAMERA_RECORDING_ENABLED: raise RuntimeError("הקלטת מצלמה כבויה בהגדרת TRIPSERVER_ENABLE_CAMERA_RECORDING.") ffmpeg = find_ffmpeg_executable() if not ffmpeg: raise RuntimeError("FFmpeg לא נמצא. נדרש FFmpeg כדי להקליט וידאו/אודיו.") video, audio, devices = selected_ffmpeg_devices() if not video: raise RuntimeError("לא נמצא התקן מצלמה ב־FFmpeg. בדוק הרשאות Windows או הגדר TRIPSERVER_FFMPEG_VIDEO_DEVICE.") if with_audio and not audio: raise RuntimeError("ביקשת הקלטה עם אודיו, אבל לא נמצא התקן מיקרופון ב־FFmpeg.") os.makedirs(CAMERA_RECORDINGS_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") target = os.path.join(CAMERA_RECORDINGS_DIR, f"room_camera_{stamp}.mp4") log_path = os.path.join(CAMERA_RECORDINGS_DIR, f"room_camera_{stamp}.ffmpeg.log") input_name = dshow_input_name(video, audio if with_audio else "") cmd = [ ffmpeg, "-hide_banner", "-y", "-f", "dshow", "-rtbufsize", "512M", "-framerate", str(CAMERA_RECORD_FPS), "-video_size", f"{CAMERA_WIDTH}x{CAMERA_HEIGHT}", "-i", input_name, "-t", str(CAMERA_RECORD_MAX_SECONDS), "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", ] if with_audio: cmd += ["-c:a", "aac", "-b:a", CAMERA_AUDIO_BITRATE] else: cmd += ["-an"] cmd += ["-movflags", "+faststart", target] logfh = open(log_path, "ab") creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=logfh, creationflags=creationflags) self.log_handle = logfh self.started_at = time.time() self.file_path = target self.log_path = log_path self.last_error = "" self.with_audio = bool(with_audio) self.video_device = video self.audio_device = audio if with_audio else "" log_camera_event(f"recording started by {get_client_ip()} | file={target} | video={video} | audio={self.audio_device or 'no-audio'}") return target def stop(self): with self.lock: self._refresh_locked() proc = self.proc if proc is None: return self.file_path or "" try: if proc.stdin: proc.stdin.write(b"q") proc.stdin.flush() except Exception: try: proc.terminate() except Exception: pass try: proc.wait(timeout=10) except Exception: try: proc.kill() except Exception: pass try: if self.log_handle: self.log_handle.close() except Exception: pass self.log_handle = None self.proc = None self.started_at = 0.0 log_camera_event(f"recording stopped by {get_client_ip()} | file={self.file_path}") return self.file_path def status(self): with self.lock: self._refresh_locked() active = bool(self.proc is not None and self.proc.poll() is None) size = os.path.getsize(self.file_path) if self.file_path and os.path.exists(self.file_path) else 0 return { "recording_enabled": CAMERA_RECORDING_ENABLED, "active": active, "file_path": self.file_path, "file_name": os.path.basename(self.file_path) if self.file_path else "", "file_size": size, "started_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.started_at)) if self.started_at else "", "elapsed_seconds": int(time.time() - self.started_at) if active and self.started_at else 0, "with_audio": self.with_audio, "video_device": self.video_device, "audio_device": self.audio_device, "log_path": self.log_path, "last_error": self.last_error, } CAMERA_AUDIO_STREAM = CameraAudioStreamManager() CAMERA_RECORDER = CameraRecordingManager() def list_camera_recordings(limit=30): try: os.makedirs(CAMERA_RECORDINGS_DIR, exist_ok=True) items = [] for path in glob.glob(os.path.join(CAMERA_RECORDINGS_DIR, "*.mp4")): try: items.append({ "name": os.path.basename(path), "path": path, "size": os.path.getsize(path), "mtime": os.path.getmtime(path), }) except Exception: pass items.sort(key=lambda x: x.get("mtime", 0), reverse=True) return items[:limit] except Exception: return [] def append_camera_setup_log(message): os.makedirs(BASE_DIR, exist_ok=True) line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}\n" try: with open(CAMERA_SETUP_LOG_PATH, "a", encoding="utf-8") as fh: fh.write(line) except Exception: pass return line def read_camera_setup_log_tail(lines=80): try: lines = max(20, min(300, int(lines))) except Exception: lines = 80 if not os.path.exists(CAMERA_SETUP_LOG_PATH): return "" try: with open(CAMERA_SETUP_LOG_PATH, "r", encoding="utf-8", errors="replace") as fh: data = fh.readlines() return "".join(data[-lines:]) except Exception as exc: return f"שגיאה בקריאת לוג התקנה: {exc}" def opencv_import_status(): try: import cv2 # noqa: F401 return True, getattr(cv2, "__version__", "") except Exception as exc: return False, str(exc) def camera_setup_status(): ok, info = opencv_import_status() ffmpeg_path = find_ffmpeg_executable() or "" with CAMERA_SETUP_LOCK: state = dict(CAMERA_SETUP_STATE) return { "opencv": ok, "opencv_info": info, "ffmpeg": bool(ffmpeg_path), "ffmpeg_path": ffmpeg_path, "python_executable": sys.executable, "pip_command": f'"{sys.executable}" -m pip install --upgrade {CAMERA_OPENCV_PACKAGE}', "opencv_package": CAMERA_OPENCV_PACKAGE, "setup_log_path": CAMERA_SETUP_LOG_PATH, "marker_path": CAMERA_SETUP_MARKER_PATH, "auto_install_enabled": CAMERA_AUTO_INSTALL_OPENCV, "auto_install_delay_seconds": CAMERA_AUTO_INSTALL_DELAY_SECONDS, "installer": state, } def ensure_pip_available(): """Make pip available in the same Python interpreter when possible.""" check = subprocess.run([sys.executable, "-m", "pip", "--version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, shell=False) if check.returncode == 0: append_camera_setup_log("pip available: " + (check.stdout or check.stderr or "").strip()) return True append_camera_setup_log("pip not available, trying ensurepip --upgrade") try: ep = subprocess.run([sys.executable, "-m", "ensurepip", "--upgrade"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, shell=False) append_camera_setup_log("ensurepip return code: " + str(ep.returncode)) if ep.stdout: append_camera_setup_log("ensurepip stdout:\n" + ep.stdout[-6000:]) if ep.stderr: append_camera_setup_log("ensurepip stderr:\n" + ep.stderr[-6000:]) return ep.returncode == 0 except Exception as exc: append_camera_setup_log("ensurepip exception: " + repr(exc)) return False def write_camera_setup_marker(info): try: os.makedirs(BASE_DIR, exist_ok=True) with open(CAMERA_SETUP_MARKER_PATH, "w", encoding="utf-8") as fh: fh.write(time.strftime("%Y-%m-%d %H:%M:%S") + " | " + str(info) + "\n") except Exception: pass def run_opencv_install_worker(mode="manual"): with CAMERA_SETUP_LOCK: CAMERA_SETUP_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "ok": False, "last_message": "מתקין OpenCV...", "mode": mode}) append_camera_setup_log("OpenCV install requested | mode=" + str(mode)) append_camera_setup_log("Python executable: " + sys.executable) if not ensure_pip_available(): msg = "pip לא זמין וגם ensurepip לא הצליח. אי אפשר להתקין OpenCV אוטומטית." append_camera_setup_log("ERROR: " + msg) with CAMERA_SETUP_LOCK: CAMERA_SETUP_STATE.update({"running": False, "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "ok": False, "last_message": msg, "mode": mode}) return cmd = [sys.executable, "-m", "pip", "install", "--upgrade", CAMERA_OPENCV_PACKAGE] append_camera_setup_log("Command: " + " ".join(cmd)) ok = False msg = "" try: completed = subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS, shell=False, ) append_camera_setup_log("pip return code: " + str(completed.returncode)) if completed.stdout: append_camera_setup_log("pip stdout:\n" + completed.stdout[-12000:]) if completed.stderr: append_camera_setup_log("pip stderr:\n" + completed.stderr[-12000:]) ok, info = opencv_import_status() if completed.returncode == 0 and ok: msg = "OpenCV הותקן וזוהה: " + info append_camera_setup_log("SUCCESS: " + msg) write_camera_setup_marker(msg) elif completed.returncode == 0 and not ok: msg = "pip הסתיים בהצלחה אבל import cv2 עדיין נכשל: " + info append_camera_setup_log("WARNING: " + msg) else: msg = "pip נכשל. בדוק את הלוג במסך ההתקנה." append_camera_setup_log("ERROR: " + msg) ok = False except subprocess.TimeoutExpired: msg = f"התקנת OpenCV עברה את מגבלת הזמן ({CAMERA_OPENCV_INSTALL_TIMEOUT_SECONDS} שניות)." append_camera_setup_log("TIMEOUT: " + msg) except Exception as exc: msg = "שגיאה בהרצת pip: " + repr(exc) append_camera_setup_log("EXCEPTION: " + msg) with CAMERA_SETUP_LOCK: CAMERA_SETUP_STATE.update({"running": False, "finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "ok": bool(ok), "last_message": msg, "mode": mode}) def start_opencv_install_background(mode="manual"): with CAMERA_SETUP_LOCK: if CAMERA_SETUP_STATE.get("running"): return False, "התקנת OpenCV כבר רצה כרגע." CAMERA_SETUP_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "ok": False, "last_message": "מתחיל התקנה...", "mode": mode}) t = threading.Thread(target=run_opencv_install_worker, kwargs={"mode": mode}, name="tripserver-opencv-installer", daemon=True) t.start() return True, "התקנת OpenCV התחילה ברקע. אפשר לרענן את מסך המצלמה/התקנה ולבדוק לוג." def maybe_start_camera_auto_install(reason="startup"): if not CAMERA_AUTO_INSTALL_OPENCV: append_camera_setup_log("Auto install disabled by TRIPSERVER_CAMERA_AUTO_INSTALL_OPENCV") return False, "התקנה אוטומטית כבויה." ok, info = opencv_import_status() if ok: with CAMERA_SETUP_LOCK: CAMERA_SETUP_STATE.update({"ok": True, "last_message": "OpenCV כבר זמין: " + str(info), "mode": "ready"}) write_camera_setup_marker("already available: " + str(info)) return False, "OpenCV כבר מותקן." with CAMERA_SETUP_LOCK: CAMERA_SETUP_STATE["auto_requested"] = True def delayed_auto(): try: if CAMERA_AUTO_INSTALL_DELAY_SECONDS > 0: time.sleep(CAMERA_AUTO_INSTALL_DELAY_SECONDS) ok2, info2 = opencv_import_status() if ok2: write_camera_setup_marker("available before auto install: " + str(info2)) with CAMERA_SETUP_LOCK: CAMERA_SETUP_STATE.update({"ok": True, "last_message": "OpenCV כבר זמין: " + str(info2), "mode": "ready"}) return append_camera_setup_log("Auto install triggered | reason=" + str(reason) + " | cv2 import failed: " + str(info2)) start_opencv_install_background(mode="auto:" + str(reason)) except Exception as exc: append_camera_setup_log("Auto install scheduler exception: " + repr(exc)) t = threading.Thread(target=delayed_auto, name="tripserver-opencv-auto-installer", daemon=True) t.start() return True, "התקנת OpenCV אוטומטית תתחיל ברקע." def render_camera_setup_page(): allowed, reason = camera_access_allowed() status = camera_setup_status() inst = status.get("installer", {}) running = bool(inst.get("running")) opencv_badge = "ok" if status.get("opencv") else "warn" ffmpeg_badge = "ok" if status.get("ffmpeg") else "warn" run_badge = "warn" if running else ("ok" if inst.get("ok") else "idle") install_disabled = "disabled" if (not allowed or running or status.get("opencv")) else "" allowed_block = "" if allowed else f"<div class='warn'>גישה חסומה: {html_lib.escape(reason)}</div>" log_tail = html_lib.escape(read_camera_setup_log_tail(120)) or "אין עדיין לוג התקנה." return f""" <!DOCTYPE html><html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>התקנת רכיבי מצלמה</title> <style> body{{margin:0;background:#07111f;color:white;font-family:Arial,'Segoe UI',sans-serif;padding:18px}}.wrap{{max-width:1050px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#0f766e,#0f172a 62%,#111827);border:1px solid #14b8a6;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.7}}.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 6px 0 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.warn{{background:#7f1d1d;color:#fecaca;border:1px solid #f87171}}.badge.idle{{background:#334155;color:#e2e8f0;border:1px solid #64748b}}button,.btn{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer;display:inline-block}}button:disabled{{background:#475569;cursor:not-allowed;opacity:.55}}.warn{{background:#7f1d1d;border:1px solid #f87171;color:#fecaca;border-radius:16px;padding:13px;line-height:1.65;margin:12px 0}}.good{{background:#052e2b;border:1px solid #14b8a6;border-radius:16px;padding:13px;color:#ccfbf1;line-height:1.65;margin:12px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65;margin:12px 0}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap;word-break:break-word}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:12px;padding:12px;overflow:auto;max-height:440px}}@media(max-width:700px){{body{{padding:13px}}h1{{font-size:28px}}}} </style></head><body><div class="wrap"><div class="hero"><a href="/admin/camera">⬅ חזרה למצלמה</a><h1>🧩 התקנת רכיבי מצלמה</h1><p>OpenCV מותקן אוטומטית חד־פעמית ברקע כאשר פאנל האדמין עולה. FFmpeg לא מותקן כאן; הוא רק מזוהה מהנתיב שכבר קיים אצלך.</p><span class="badge {opencv_badge}">OpenCV: {'מותקן' if status.get('opencv') else 'חסר'}</span><span class="badge {ffmpeg_badge}">FFmpeg: {'זמין' if status.get('ffmpeg') else 'לא נמצא'}</span><span class="badge {run_badge}">Installer: {'רץ עכשיו' if running else ('הצלחה אחרונה' if inst.get('ok') else 'ממתין')}</span></div>{allowed_block} <div class="grid"><div class="card"><h2>התקנה אוטומטית חד־פעמית</h2><p>אם OpenCV חסר, הפאנל מריץ לבד את הפקודה:</p><pre>{html_lib.escape(status.get('pip_command',''))}</pre><div class="note">אין כפתור התקנה נוסף. ההתקנה מופעלת ברקע אחרי עליית הפאנל, כדי שתוכל לעשות את זה מרחוק בלי CMD ידני.</div>{'<div class="good">OpenCV כבר מותקן וזמין. אין צורך להתקין שוב.</div>' if status.get('opencv') else ''}</div> <div class="card"><h2>סטטוס</h2><p><b>Python:</b></p><pre>{html_lib.escape(status.get('python_executable',''))}</pre><p><b>OpenCV:</b> {html_lib.escape(str(status.get('opencv_info','')))}</p><p><b>FFmpeg:</b></p><pre>{html_lib.escape(status.get('ffmpeg_path') or 'לא נמצא')}</pre><p><b>הודעה אחרונה:</b><br>{html_lib.escape(str(inst.get('last_message') or 'אין'))}</p><p><b>התחיל:</b> {html_lib.escape(str(inst.get('started_at') or ''))}<br><b>הסתיים:</b> {html_lib.escape(str(inst.get('finished_at') or ''))}</p><p><a class="btn" href="/camera/setup">רענן</a><a class="btn" href="/camera/setup/status.json" target="_blank">JSON טכני</a></p></div></div> <div class="card"><h2>לוג התקנה</h2><p>נתיב לוג: <code>{html_lib.escape(CAMERA_SETUP_LOG_PATH)}</code></p><pre>{log_tail}</pre></div></div></body></html> """ def camera_av_status(): video, audio, devices = selected_ffmpeg_devices() return { "ffmpeg": bool(find_ffmpeg_executable()), "ffmpeg_path": find_ffmpeg_executable() or "", "video_device": video, "audio_device": audio, "devices": devices, "audio_stream": CAMERA_AUDIO_STREAM.status(), "recording": CAMERA_RECORDER.status(), "recordings_dir": CAMERA_RECORDINGS_DIR, } def camera_stream_generator(): started = time.time() try: while True: if CAMERA_MAX_STREAM_SECONDS > 0 and (time.time() - started) > CAMERA_MAX_STREAM_SECONDS: break frame = CAMERA_MANAGER.read_jpeg() yield b"--frame\r\nContent-Type: image/jpeg\r\nCache-Control: no-cache\r\n\r\n" + frame + b"\r\n" time.sleep(CAMERA_FRAME_INTERVAL_SECONDS) except GeneratorExit: pass except Exception as exc: CAMERA_MANAGER.last_error = str(exc) finally: # Release the webcam when the browser closes/stops the stream so the LED turns off. CAMERA_MANAGER.stop() def render_admin_camera(): allowed, reason = camera_access_allowed() engine = camera_engine_status() av = camera_av_status() cam_status = CAMERA_MANAGER.status() rec_status = av.get("recording", {}) audio_status = av.get("audio_stream", {}) devices = av.get("devices", {}) or {} allowed_block = "" if allowed else f"<div class='warn'>גישה חסומה: {html_lib.escape(reason)}</div>" engine_block = "" if not engine.get("opencv"): engine_block = "<div class='warn'><b>OpenCV לא מותקן.</b><br>אפשר להתקין ישירות מהפאנל דרך הכפתור: <a class='btn' href='/camera/setup'>🧩 התקנת רכיבי מצלמה</a><pre>py -m pip install opencv-python</pre></div>" if not av.get("ffmpeg"): engine_block += "<div class='warn'><b>FFmpeg לא נמצא.</b><br>שידור אודיו והקלטת MP4 עם אודיו דורשים FFmpeg ב־PATH או ב־<code>C:\\ffmpeg\\bin\\ffmpeg.exe</code>.</div>" active_text = "פעילה" if cam_status.get("active") else "כבויה" active_cls = "ok" if cam_status.get("active") else "idle" rec_text = "מקליט" if rec_status.get("active") else "לא מקליט" rec_cls = "rec" if rec_status.get("active") else "idle" audio_text = "פעיל" if audio_status.get("active") else "כבוי" disabled_video = "disabled" if (not allowed or not engine.get("opencv")) else "" disabled_av = "disabled" if (not allowed or not av.get("ffmpeg")) else "" recordings = list_camera_recordings(12) rec_rows = "".join( f"<div class='file-row'><div><b>{html_lib.escape(item['name'])}</b><br><span>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item['mtime']))} · {format_bytes(item['size'])}</span></div><a class='mini' href='/camera/recordings/{html_lib.escape(item['name'])}' target='_blank'>פתח</a></div>" for item in recordings ) or "<div class='muted'>אין עדיין הקלטות בתיקייה.</div>" video_devices = ", ".join(devices.get("video", [])) or "לא זוהו דרך FFmpeg" audio_devices = ", ".join(devices.get("audio", [])) or "לא זוהו דרך FFmpeg" return f""" <!DOCTYPE html> <html lang="he" dir="rtl"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>מצלמת חדר</title> <style> body{{margin:0;background:#07111f;color:white;font-family:Arial,'Segoe UI',sans-serif;padding:18px}}.wrap{{max-width:1120px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#0f766e,#0f172a 62%,#111827);border:1px solid #14b8a6;border-radius:28px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.28)}}h1{{margin:0 0 8px;font-size:34px}}h2{{margin:0 0 10px}}p{{color:#cbd5e1;line-height:1.7}}.grid{{display:grid;grid-template-columns:1.45fr .9fr;gap:16px}}.card{{background:#111827;border:1px solid #334155;border-radius:20px;padding:16px;margin-top:14px}}.cam-box{{background:#020617;border:1px solid #334155;border-radius:22px;overflow:hidden;min-height:280px;display:flex;align-items:center;justify-content:center}}#cameraView{{width:100%;height:auto;display:none;background:#020617}}audio{{width:100%;margin-top:10px}}.placeholder{{color:#94a3b8;text-align:center;padding:28px;line-height:1.7}}button,.btn{{border:0;border-radius:14px;padding:13px 16px;font-weight:950;color:white;background:#2563eb;margin:8px 8px 0 0;cursor:pointer;display:inline-block}}button.stop{{background:#dc2626}}button.gray,.btn.gray{{background:#475569}}button.snap{{background:#0f766e}}button.audio{{background:#7c3aed}}button.record{{background:#ea580c}}button:disabled{{background:#475569;cursor:not-allowed;opacity:.55}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin:8px 6px 0 0}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.idle{{background:#334155;color:#e2e8f0;border:1px solid #64748b}}.badge.rec{{background:#7c2d12;color:#fed7aa;border:1px solid #fb923c}}.warn{{background:#7f1d1d;border:1px solid #f87171;color:#fecaca;border-radius:16px;padding:13px;line-height:1.65;margin:12px 0}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65;margin:12px 0}}.good{{background:#052e2b;border:1px solid #14b8a6;border-radius:16px;padding:13px;color:#ccfbf1;line-height:1.65;margin:12px 0}}code,pre{{direction:ltr;text-align:left;white-space:pre-wrap}}pre{{background:#020617;border:1px solid #334155;color:#fde68a;border-radius:12px;padding:10px;overflow:auto}}.row{{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid #263548;padding:9px 0;color:#cbd5e1}}.row b{{color:white}}.file-row{{display:flex;justify-content:space-between;gap:12px;align-items:center;border-bottom:1px solid #263548;padding:10px 0;color:#cbd5e1}}.file-row span,.muted{{color:#94a3b8}}.mini{{background:#1d4ed8;color:white;border-radius:10px;padding:8px 10px;font-weight:900;white-space:nowrap}}label{{display:block;color:#e2e8f0;margin-top:8px;font-weight:800}}input[type=checkbox]{{transform:scale(1.25);margin-left:8px}}@media(max-width:850px){{.grid{{grid-template-columns:1fr}}body{{padding:13px}}h1{{font-size:28px}}.file-row{{align-items:flex-start}}}} </style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>📷 מצלמת חדר</h1><p>צפייה חיה במצלמת המחשב, שידור אודיו נפרד, צילום רגעי והקלטת MP4 לתיקיית הקלטות מקומית. OpenCV מותקן אוטומטית חד־פעמית; המצלמה עצמה מופעלת רק בלחיצה שלך.</p><p><a class="btn gray" href="/camera/setup">סטטוס רכיבי מצלמה</a></p><span class="badge {active_cls}">וידאו: {active_text}</span><span class="badge {active_cls if audio_status.get('active') else 'idle'}">אודיו: {audio_text}</span><span class="badge {rec_cls}">הקלטה: {rec_text}</span></div>{allowed_block}{engine_block} <div class="grid"><div><div class="card"><div class="cam-box"><img id="cameraView" alt="שידור מצלמה"><div id="placeholder" class="placeholder">המצלמה כבויה.<br>לחץ “הפעל וידאו” כדי להתחיל שידור חי.</div></div><div><button onclick="startCamera()" {disabled_video}>הפעל וידאו</button><button class="stop" onclick="stopCamera()">כבה וידאו</button><button class="snap" onclick="openSnapshot()" {disabled_video}>פתח צילום רגעי</button><button class="gray" onclick="location.reload()">רענן סטטוס</button></div></div> <div class="card"><h2>🔊 אודיו חי</h2><p>האודיו מוזרם כנגן נפרד מהווידאו. בדפדפן בטלפון צריך ללחוץ ידנית על “הפעל אודיו”.</p><audio id="audioPlayer" controls preload="none"></audio><div><button class="audio" onclick="startAudio()" {disabled_av}>הפעל אודיו</button><button class="stop" onclick="stopAudio()">כבה אודיו</button></div></div> <div class="card"><h2>⏺️ הקלטה למחשב</h2><p>הקלטות נשמרות מקומית בתיקייה: <code>{html_lib.escape(CAMERA_RECORDINGS_DIR)}</code></p><form method="post" action="/camera/record/start"><label><input type="checkbox" name="with_audio" value="1" checked>כלול אודיו בהקלטה</label><button class="record" type="submit" {disabled_av}>התחל הקלטה</button></form><form method="post" action="/camera/record/stop"><button class="stop" type="submit">עצור הקלטה</button></form><div class="good">משך הקלטה מקסימלי להגנה מדיסק מלא: {CAMERA_RECORD_MAX_SECONDS//60} דקות. אפשר לשנות דרך <code>TRIPSERVER_CAMERA_RECORD_MAX_SECONDS</code>.</div></div> <div class="card"><h2>🎞️ הקלטות אחרונות</h2>{rec_rows}<p><a class="btn gray" href="/camera/recordings">פתח רשימת הקלטות מלאה</a></p></div></div> <div><div class="card"><h2>סטטוס טכני</h2><div class="row"><b>OpenCV</b><span>{'מותקן' if engine.get('opencv') else 'לא מותקן'}</span></div><div class="row"><b>FFmpeg</b><span>{'זמין' if av.get('ffmpeg') else 'לא נמצא'}</span></div><div class="row"><b>Camera index</b><span>{CAMERA_INDEX}</span></div><div class="row"><b>רזולוציה</b><span>{CAMERA_WIDTH}×{CAMERA_HEIGHT}</span></div><div class="row"><b>FPS שידור</b><span>{CAMERA_FPS}</span></div><div class="row"><b>FPS הקלטה</b><span>{CAMERA_RECORD_FPS}</span></div><div class="row"><b>וידאו FFmpeg</b><span>{html_lib.escape(av.get('video_device') or '')}</span></div><div class="row"><b>אודיו FFmpeg</b><span>{html_lib.escape(av.get('audio_device') or '')}</span></div><div class="row"><b>Frames</b><span>{cam_status.get('frames')}</span></div><div class="row"><b>קובץ הקלטה פעיל</b><span>{html_lib.escape(rec_status.get('file_name') or 'אין')}</span></div><div class="row"><b>גודל הקלטה</b><span>{format_bytes(rec_status.get('file_size') or 0)}</span></div><div class="row"><b>שגיאה אחרונה</b><span>{html_lib.escape(cam_status.get('last_error') or rec_status.get('last_error') or audio_status.get('last_error') or 'אין')}</span></div></div> <div class="card"><h2>התקנים שזוהו</h2><p><b>וידאו:</b><br>{html_lib.escape(video_devices)}</p><p><b>אודיו:</b><br>{html_lib.escape(audio_devices)}</p><p class="muted">אם נבחר התקן לא נכון, אפשר להגדיר במערכת: <code>TRIPSERVER_FFMPEG_VIDEO_DEVICE</code> / <code>TRIPSERVER_FFMPEG_AUDIO_DEVICE</code>.</p></div> <div class="note"><b>הערת שימוש:</b> מאחר שזה כולל אודיו והקלטה, הפאנל משאיר חיווי ברור כשהמצלמה/ההקלטה פעילה. כדאי לוודא שכל מי שנמצא בבית יודע שהמערכת קיימת ומתי היא פעילה.</div></div></div></div> <script> function startCamera(){{ const img=document.getElementById('cameraView'); const ph=document.getElementById('placeholder'); img.style.display='block'; ph.style.display='none'; img.src='/camera/stream?ts='+Date.now(); }} async function stopCamera(){{ const img=document.getElementById('cameraView'); const ph=document.getElementById('placeholder'); img.src=''; img.style.display='none'; ph.style.display='block'; try{{await fetch('/camera/stop',{{method:'POST'}});}}catch(e){{}} }} function startAudio(){{ const a=document.getElementById('audioPlayer'); a.src='/camera/audio-stream?ts='+Date.now(); a.play().catch(()=>{{}}); }} async function stopAudio(){{ const a=document.getElementById('audioPlayer'); a.pause(); a.src=''; try{{await fetch('/camera/audio-stop',{{method:'POST'}});}}catch(e){{}} }} function openSnapshot(){{ window.open('/camera/snapshot.jpg?ts='+Date.now(),'_blank'); }} window.addEventListener('beforeunload',()=>{{try{{navigator.sendBeacon('/camera/stop'); navigator.sendBeacon('/camera/audio-stop');}}catch(e){{}}}}); </script></body></html> """ def serve_app(folder): file_name = find_html_file(FOLDERS[folder]) if not file_name: return f"No HTML file found in {folder} folder", 404 return serve_file_no_cache(FOLDERS[folder], file_name) @app.route("/") def home(): return """ <!DOCTYPE html> <html lang="he" dir="rtl"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>מרכז ניהול</title> <style> body{margin:0;font-family:Arial;background:linear-gradient(135deg,#081229,#102447);color:white;min-height:100vh} .hero{text-align:center;padding:50px 20px 20px} .hero h1{font-size:42px;margin:0} .hero p{color:#d8e6ff;font-size:18px} .grid{width:min(1100px,92%);margin:40px auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:22px} .card{text-decoration:none;color:white;border-radius:28px;padding:28px;min-height:180px;box-shadow:0 10px 30px rgba(0,0,0,.25);transition:.2s transform,.2s box-shadow} .card:hover{transform:translateY(-4px);box-shadow:0 16px 40px rgba(0,0,0,.35)} .card h2{font-size:30px;margin:0 0 12px} .card p{line-height:1.6;margin:0} .icon{font-size:52px;margin-bottom:15px} .admin{background:linear-gradient(135deg,#0f9b0f,#38ef7d)} .money{background:linear-gradient(135deg,#5f2c82,#49a09d)} .homebudget{background:linear-gradient(135deg,#11998e,#38ef7d)} .japan{background:linear-gradient(135deg,#ff416c,#ff4b2b)} .usa{background:linear-gradient(135deg,#0052d4,#4364f7,#6fb1fc)} .footer{text-align:center;color:#b8c8e6;padding:25px} .image-guide{margin:12px 0 16px;background:#0f172a;border:1px solid #334155;border-radius:14px;padding:12px;color:#e2e8f0} .image-guide summary{cursor:pointer;font-weight:bold;color:#bfdbfe;margin-bottom:10px} .image-guide-table{width:100%;border-collapse:collapse;margin-top:10px;font-size:14px} .image-guide-table th,.image-guide-table td{border-bottom:1px solid #334155;padding:9px;text-align:right;vertical-align:top} .image-guide-table th{color:#93c5fd;background:#111827} .image-guide-table td:first-child{white-space:nowrap;color:#fde68a} .image-upload-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px;margin-top:16px} .image-upload-card{margin:0} .image-file-input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none} .image-upload-label{display:block;height:100%;cursor:pointer;background:#0f172a;border:1px solid #334155;border-radius:18px;padding:15px;transition:.18s transform,.18s border-color,.18s background;box-shadow:0 8px 22px rgba(0,0,0,.14)} .image-upload-label:hover{transform:translateY(-2px);border-color:#60a5fa;background:#13213a} .image-key{display:inline-flex;background:#1d4ed8;color:#dbeafe;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:900;margin-bottom:8px} .image-title{display:block;color:#fff;font-size:17px;font-weight:950;margin-bottom:7px;line-height:1.25} .image-desc{display:block;color:#cbd5e1;font-size:14px;line-height:1.45;min-height:42px} .upload-status{display:block;margin-top:12px;color:#fde68a;font-size:13px;font-weight:900;border-top:1px solid #334155;padding-top:10px} @media(max-width:640px){.image-upload-grid{grid-template-columns:1fr}.image-title{font-size:16px}.image-desc{min-height:auto}} .app-hero{background:radial-gradient(circle at top right,rgba(59,130,246,.35),transparent 35%),linear-gradient(135deg,#172554,#0f172a);border:1px solid #334155;border-radius:28px;padding:26px;margin-bottom:18px;box-shadow:0 20px 60px rgba(0,0,0,.25)} .app-hero h1{text-align:right;margin:0 0 8px;font-size:36px;letter-spacing:-.8px}.app-hero p{margin:0;color:#cbd5e1;line-height:1.65}.top-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}.pill-link{display:inline-flex;align-items:center;gap:8px;background:#1d4ed8;color:white!important;text-decoration:none;border-radius:999px;padding:10px 14px;font-weight:900;border:1px solid #60a5fa}.pill-link.secondary{background:#334155;border-color:#475569}.dashboard-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin:18px 0}.dash-card{background:linear-gradient(180deg,#1e293b,#111827);border:1px solid #334155;border-radius:22px;padding:16px;box-shadow:0 10px 32px rgba(0,0,0,.18)}.dash-card b{display:block;font-size:18px;margin-bottom:6px}.dash-card span{color:#cbd5e1;font-size:14px;line-height:1.5}.box{box-shadow:0 16px 44px rgba(0,0,0,.20);border:1px solid #334155}.box h2{font-size:25px;letter-spacing:-.4px}.box h3{color:#bfdbfe;margin-top:20px}.split-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}.mini-panel{background:#0f172a;border:1px solid #334155;border-radius:16px;padding:14px}.mini-panel h3{margin-top:0}.danger-zone{background:linear-gradient(135deg,#451a1a,#1e293b)!important;border-color:#7f1d1d!important}.safe-zone{background:linear-gradient(135deg,#132f25,#1e293b)!important;border-color:#166534!important}.field-label{display:block;color:#cbd5e1;font-size:13px;font-weight:900;margin:12px 0 6px}select,input[type=text]{width:100%;box-sizing:border-box;padding:13px;background:#0f172a;color:white;border-radius:12px;border:1px solid #334155}label.check{display:flex;align-items:center;gap:8px;margin-top:12px;color:#e2e8f0;font-weight:bold}.small-danger{background:#991b1b!important}.cancel{background:#475569!important}.muted{color:#94a3b8;font-size:13px;line-height:1.6}.success-dot{display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;box-shadow:0 0 14px #22c55e;margin-left:8px}.quick-nav{box-shadow:0 12px 32px rgba(0,0,0,.22)}@media(max-width:720px){.wrap{width:min(100%,94%);padding:18px 0}.app-hero{padding:20px}.app-hero h1{font-size:28px}.quick-nav{overflow:auto;flex-wrap:nowrap}.quick-nav a{white-space:nowrap}.dashboard-grid{grid-template-columns:1fr}} </style> </head> <body> <div class="hero"> <h1>מרכז ניהול</h1> <p>מערכת מרכזית לניהול קבצים, תקציבים ולוחות זמנים</p> </div> <div class="grid"> <a class="card admin" href="/admin"><div class="icon">📁</div><h2>ניהול קבצים</h2><p>העלאת קבצים ישירות למחשב.</p></a> <a class="card money" href="/Moneyapp/"><div class="icon">💰</div><h2>תקציב טיולים</h2><p>ניהול תקציב חופשות וסנכרון נתונים.</p></a> <a class="card homebudget" href="/MoneyHome/"><div class="icon">🏠</div><h2>תקציב משפחתי</h2><p>ניהול תקציב חודשי, Limits, הוצאות בפועל וארכיון חודשים.</p></a> <a class="card admin" href="/TripForm/"><div class="icon">🧾</div><h2>טופס פתיחת טיול</h2><p>טופס קליטת נתונים לטיול חדש.</p></a> <a class="card admin" href="/admin/camera"><div class="icon">📷</div><h2>מצלמת חדר</h2><p>צפייה חיה במצלמת המחשב, אודיו והקלטות דרך הפאנל.</p></a> <a class="card japan" href="/JAPAN/"><div class="icon">🇯🇵</div><h2>טיול יפן</h2><p>לוז, ניווטים ומסמכי הטיול.</p></a> <a class="card usa" href="/USA/"><div class="icon">🇺🇸</div><h2>טיול ארה״ב</h2><p>טיול בר מצווה 2027.</p></a> </div> <div class="footer">TripServer</div> </body> </html> """ USA_IMAGE_UI_ITEMS = [('intro', '🇺🇸 ארה״ב 2027 — אמריקה על פול ווליום', 'Road Trip • Orlando • Miami • Cruise / 36 ימים של נופים, פארקים, קניות, קרוז ורגעים שגורמים להגיד: “רגע… לשם אנחנו באמת הולכים?” / מערב ארה״ב: סן פרנסיסקו , כביש 1, LA , קניונים, וגאס ויוסמיטי'), ('roadtrip-map', '🗺️ מסלול הרוד טריפ — הסיפור הגדול, מלמעלה', 'סן פרנסיסקו • כביש 1 • LA • וגאס • יוסמיטי / מפה אחת שמסבירה את כל הרעיון: מתחילים באוקיינוס, מטפסים לקניונים, נוגעים במדבר וחוזרים לטבע. / המסלול נותן את כל המנה: חופים, כבישים יפים, פארקים לאומיים, וגאס ויוסמיטי — בלי לנסות לדחוס כאן את כל הלו״ז.'), ('day-01', 'יום 1 — ✈️ נחיתה ישירה ב סן פרנסיסקו', 'סן פרנסיסקו / נוחתים בעיר שיודעת לפתוח טיול עם סטייל. / נחיתה, התארגנות וטעימה ראשונה מהעיר'), ('day-02', 'יום 2 — 🌉 סן פרנסיסקו + Alcatraz', 'סן פרנסיסקו / Alcatraz / יום של אייקונים: מפרץ, גשרים, אלקטרז ואווירה של סרט. / אלקטרז, תצפיות והעיר הכי פוטוגנית בצפון קליפורניה'), ('day-03', 'יום 3 — 🌊 סן פרנסיסקו → מונטריי', 'מונטריי / 17-Mile Drive / עוברים למוד רוד־טריפ אמיתי: ים, כביש ונופים שמתחילים לעבוד עלינו חזק. / נסיעה דרומה, מונטריי וקטע חוף מהמם'), ('day-04', 'יום 4 — 🐋 Monterey Bay Whale Watch + Big Sur', 'מונטריי / Big Sur / היום שבו האוקיינוס נותן הופעה, ואם לוויתן קופץ — זה כבר בונוס קולנועי. / שייט לווייתנים ואז נופי Big Sur וכביש 1'), ('extra-big-sur', 'Road Trip Bonus — 🌉 Big Sur – Bixby Bridge + McWay Falls', 'כביש 1 / קליפורניה / כאן קליפורניה כבר לא רק יפה — היא מתחילה ממש להשוויץ. / גשר Bixby והחופים הדרמטיים של Big Sur הם מהשיאים המצולמים של כביש 1'), ('day-05', 'יום 5 — 🎡 סנטה מוניקה → לוס אנג׳לס', 'Santa Monica / Los Angeles / נכנסים ל־ LA דרך סנטה מוניקה : חוף, טיילת, גלגל ענק ואווירת קליפורניה קלאסית. / Santa Monica Pier, חוף, טיילת וכניסה לאווירת לוס אנג׳לס'), ('day-06', 'יום 6 — 🎥 Warner Bros + Beverly Hills + Griffith', 'לוס אנג׳לס / LA במצב קלאסי: אולפנים, בוורלי הילס ותצפית של סוף סרט. / Warner Bros , Beverly Hills ו־ Griffith'), ('day-07', 'יום 7 — 🏜️ לוס אנג׳לס → Zion', 'מעבר למדבר / Utah / עוזבים עיר ונכנסים למרחבים. זה יום מעבר, אבל עם סוף ששווה את זה. / נסיעה ארוכה אל אזור Zion'), ('extra-diner', 'Road Trip Bonus — 🍔 Peggy Sue’s 50’s Diner', 'עצירת דרך אמריקאית / מדבר / באמצע כל הנופים הגדולים, מגיע רגע קטן ומושלם של אמריקה קלאסית: ניאון, מילקשייק ואווירת רוד־טריפ אמיתית. / עצירת דיינר שנותנת לטיול את הטעם של אמריקה הישנה והכיפית של הכביש'), ('day-08', 'יום 8 — 🏞️ Zion → Bryce → Page', 'Zion / Bryce / Page / יום טבע חזק במיוחד: צוקים, צבעים ומעברים שנראים לא אמיתיים. / Zion , Bryce והגעה ל־ Page'), ('day-09', 'יום 9 — 🧡 Antelope Canyon + Horseshoe Bend', 'Page Arizona / זה היום של “וואו, לאן אתם הולכים?” — בדיוק זה. / Antelope Canyon ו־ Horseshoe Bend'), ('day-10', 'יום 10 — 🏜️ Page → Grand Canyon South Rim', 'Grand Canyon / היום שבו המילה “גדול” מקבלת קנה מידה חדש. / נסיעה אל South Rim ומפגש עם הגרנד קניון'), ('day-11', 'יום 11 — 🎰 Grand Canyon → Las Vegas', 'לאס וגאס / מהטבע הכי גדול — לעיר הכי מוגזמת. מעבר חד, אבל כיפי. / נסיעה לווגאס וכניסה לאורות של הסטריפ'), ('day-12', 'יום 12 — 🛍️ Vegas Outlets + Sphere', 'לאס וגאס / וגאס יודעת לשלב שופינג, שואו ואורות ברמה לא סבירה. / אאוטלטים, Sphere והסטריפ'), ('day-13', 'יום 13 — ✨ Vegas Extra Day + Strip', 'לאס וגאס / עוד יום בווגאס, כי כנראה יום אחד לא מספיק כדי להגזים כמו שצריך. / קניות, Strip , מלונות ותצוגות ערב'), ('day-14', 'יום 14 — 🏔️ Las Vegas → Mammoth Lakes', 'Mammoth Lakes / נפרדים מהניאון וחוזרים לאוויר הרים כמו אנשים אחראיים. בערך. / נסיעה צפונה אל Mammoth Lakes'), ('extra-death-valley', 'Road Trip Bonus — 🔥 Death Valley', 'בין וגאס ל־Mammoth / זה המעבר הכי חד במסלול: מווגאס המנצנצת אל מדבר עצום, שקט ובלתי נתפס. / עמק המוות מוסיף למסלול מרחבים אינסופיים, תצפיות דרמטיות ותחושת קצה של המערב'), ('extra-mono-lake', 'Road Trip Bonus — 🏜️ Mono Lake / Eastern Sierra', 'בדרך אל יוסמיטי / פה הרוד־טריפ משנה טון שוב: מדבר, הרים, אגם דרמטי ושמיים ענקיים — הכל בפריים אחד. / אזור Eastern Sierra מוסיף תחושת מרחב אדירה לפני הכניסה ליוסמיטי'), ('day-15', 'יום 15 — 🚗 Mammoth → Yosemite Valley', 'יוסמיטי / אחד מימי ה־וואו של הטיול. יוסמיטי נכנסת לפריים. / דרך נופית אל Yosemite Valley'), ('day-16', 'יום 16 — 🌲 Yosemite Highlights + Mariposa Grove', 'יוסמיטי / Mariposa / יום של מפלים, גרניט ועצי סקויה שגורמים לך להרגיש בגודל של סיכה. / יוסמיטי קלאסי + Mariposa Grove'), ('day-17', 'יום 17 — ✈️ Yosemite → SFO → Orlando', 'מעבר לאורלנדו / סוגרים פרק מערב ופותחים פרק פארקים. המזוודות עוד לא יודעות מה מחכה להן. / נסיעה לשדה וטיסה לאורלנדו'), ('day-18', 'יום 18 — 🛍️ התאוששות + Disney Springs', 'אורלנדו / יום רך לפני שמתחילים לצרוח בפארקים. / התאוששות, Disney Springs ואווירה קלילה'), ('day-19', 'יום 19 — 🎢 Islands of Adventure', 'Universal Orlando / כאן מתחיל פרק האדרנלין הרשמי. אקספרס ביד, חיוך בפנים. / צ׳ק־אין Premier ו־ Islands of Adventure'), ('day-20', 'יום 20 — 🎬 Universal Studios Florida + CityWalk', 'Universal Studios / יום Universal קלאסי: מתקנים, אווירה ו־ CityWalk לסיום. / Universal Studios Florida וערב CityWalk'), ('day-21', 'יום 21 — ⛳ מנוחה + Hollywood Drive-In Mini Golf', 'אורלנדו / יום רגוע יותר, כי גם למשפחה עם אנרגיה צריך טעינה. / מנוחה, בריכה/קניות קלות ומיני גולף'), ('day-22', 'יום 22 — 🌌 Epic Universe + Express', 'Epic Universe / אחד הימים הכי מסקרנים בטיול — פארק חדש, גדול ומלא הייפ. / Epic Universe עם Express'), ('day-23', 'יום 23 — 🧗 פארק חבלים + בריכה / I‑Drive', 'אורלנדו / קצת אקשן, קצת בריכה, קצת אורלנדו בלי לחץ. / פארק חבלים, בריכה או International Drive'), ('day-24', 'יום 24 — 🌊 Volcano Bay + Express', 'Volcano Bay / מים, מגלשות ויום רענון שנראה כמו פרסומת לחופשה. / Volcano Bay עם Express'), ('day-25', 'יום 25 — 🎃 רגוע + Halloween Horror Nights', 'Universal HHN / היום מתחיל רגוע ונגמר בפנים לבנות. / מנוחה ביום ו־HHN בערב'), ('day-26', 'יום 26 — 🚗 Orlando → Sawgrass Mills → Miami', 'Sawgrass / Miami / מחליפים פארקים בשופינג, ואז ממשיכים ל מיאמי . / Sawgrass Mills והמשך ל מיאמי'), ('day-27', 'יום 27 — 🌴 Miami Chill Before Cruise', 'מיאמי / מיאמי מורידה הילוך בסטייל לפני הקרוז. / חוף, אווירה, התארגנות ונשימה עמוקה'), ('day-28', 'יום 28 — 🚢 החזרת רכב ועלייה לקרוז', 'נמל מיאמי / הרכב חוזר, האונייה מתחילה. החופשה עוברת למצב צף. / עלייה לקרוז ותחילת פרק הים'), ('day-29', 'יום 29 — 🌊 קרוז – יום ים 1', 'בלב הים / יום ים ראשון: בריכה, אוכל, סיפון ונוף בלי סוף. / יום חופשי על האונייה'), ('day-30', 'יום 30 — 🌊 קרוז – יום ים 2', 'בלב הים / עוד יום ים, כי למה לעצור משהו שעובד? / מתקני האונייה, אוכל ומנוחה'), ('day-31', 'יום 31 — 🏝️ St. Maarten', 'St. Maarten / הקריביים נכנסים לתמונה בצבעים שלא נראים חוקיים. / יום עגינה ב־ St. Maarten'), ('day-32', 'יום 32 — 🐬 St. Thomas + Dolphin Splash', 'St. Thomas / יום שיכול להפוך לזיכרון משפחתי חזק במיוחד. / St. Thomas ואטרקציית דולפינים'), ('day-33', 'יום 33 — 🌅 קרוז – יום ים 3', 'בלב הים / עוד קצת ים כדי לא להיגמל מהר מדי. / יום ים, סיפון, אוכל ונוף'), ('day-34', 'יום 34 — 🏖️ Perfect Day at CocoCay', 'CocoCay / שם מחייב — ובמקרה הזה הוא לגמרי מתכוון לזה. / CocoCay , חוף, מים טורקיז ופארק מים'), ('day-35', 'יום 35 — 🏨 ירידה מהקרוז + לילה ליד השדה', 'מיאמי / החופשה מתחילה להתקפל, אבל לא שוברים בבת אחת. / ירידה מהקרוז, התארגנות ולילה ליד השדה'), ('day-36', 'יום 36 — 🛫 טיסה חזרה לישראל', 'הביתה / היום הפחות אהוב, אבל עם אלבום מטורף בזיכרון. / עלייה לטיסה חזרה לישראל'), ('budget', '💰 תקציב — המספרים, בלי דרמה', 'תמונת מצב פיננסית / זה לא טיול קטן. זה פרויקט משפחתי עם נוף, פארקים, ים, שופינג וקרוז. / עלות כללית מוערכת: $81,297 ≈ ₪243,890'), ('summary', '🥂 סיום — אז לאן אתם באמת הולכים?', 'טיול חלום משפחתי / אם מי שרואה את זה אומר “וואו” — הוא פשוט צודק. / מערב ארה״ב, אורלנדו, מיאמי וקרוז — בטיול אחד')] def render_usa_image_upload_cards(): cards = [] for image_key, title, desc in USA_IMAGE_UI_ITEMS: safe_key = html_lib.escape(image_key, quote=True) safe_title = html_lib.escape(title, quote=True) safe_desc = html_lib.escape(desc, quote=True) search_text = html_lib.escape((image_key + " " + title + " " + desc).lower(), quote=True) cards.append(""" <form class="image-upload-card" data-search="%s" action="/upload_usa_image" method="post" enctype="multipart/form-data"> <input type="hidden" name="image_key" value="%s"> <input class="image-file-input" id="image-file-%s" type="file" name="file" accept=".jpg,.jpeg,.png,.webp,.bmp,.tif,.tiff,image/*" onchange="if(this.files && this.files.length){this.closest('form').querySelector('.upload-status').textContent='מעלה תמונה...';this.closest('form').submit();}"> <label for="image-file-%s" class="image-upload-label"> <span class="image-key">%s</span> <span class="image-title">%s</span> <span class="image-desc">%s</span> <span class="upload-status">לחץ כאן ובחר תמונה מהטלפון</span> </label> </form> """ % (search_text, safe_key, safe_key, safe_key, safe_key, safe_title, safe_desc)) return "\n".join(cards) @app.route("/admin") @app.route("/admin/") def admin(): return """ <!DOCTYPE html> <html lang="he" dir="rtl"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>TripServer Admin</title> <style> :root{--bg:#07111f;--panel:#101c31;--panel2:#14233c;--line:#253650;--text:#f8fafc;--muted:#a8b3c7;--brand:#3b82f6;--brand2:#60a5fa;--good:#16a34a;--warn:#f59e0b;--bad:#dc2626;--glass:rgba(16,28,49,.74)} *{box-sizing:border-box}body{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:radial-gradient(circle at 85% -10%,rgba(59,130,246,.35),transparent 28%),radial-gradient(circle at 10% 10%,rgba(20,184,166,.18),transparent 24%),linear-gradient(180deg,#07111f,#0f172a 52%,#07111f);color:var(--text);min-height:100vh}a{color:inherit}.wrap{width:min(1180px,94%);margin:auto;padding:22px 0 48px}.app-shell{min-height:calc(100vh - 44px)} .hero{display:grid;grid-template-columns:1fr auto;gap:16px;align-items:center;background:linear-gradient(135deg,rgba(30,64,175,.92),rgba(15,23,42,.88));border:1px solid rgba(147,197,253,.25);border-radius:30px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.30);position:relative;overflow:hidden}.hero:after{content:"";position:absolute;inset:auto -60px -90px auto;width:260px;height:260px;border-radius:50%;background:rgba(96,165,250,.16)}.hero h1{margin:0 0 8px;font-size:clamp(28px,5vw,44px);letter-spacing:-1px}.hero p{margin:0;color:#dbeafe;line-height:1.65;max-width:760px}.hero-actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}.btn-link,.back-home{display:inline-flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.65);border-radius:999px;padding:11px 15px;font-weight:900;white-space:nowrap}.btn-link:hover,.back-home:hover{border-color:var(--brand2);background:rgba(59,130,246,.22)} .status-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:16px 0}.status-card{background:var(--glass);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 16px 40px rgba(0,0,0,.18)}.status-card b{display:block;font-size:16px;margin-bottom:5px}.status-card span{color:var(--muted);font-size:13px;line-height:1.45}.dot{display:inline-block;width:10px;height:10px;background:#22c55e;border-radius:50%;box-shadow:0 0 16px #22c55e;margin-left:7px}.main-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:18px}.main-card{margin-top:0;border:1px solid var(--line);background:linear-gradient(180deg,rgba(30,41,59,.94),rgba(15,23,42,.98));border-radius:26px;padding:20px;text-align:right;cursor:pointer;color:white;box-shadow:0 20px 55px rgba(0,0,0,.24);min-height:160px;transition:.18s transform,.18s border-color,.18s background;position:relative;overflow:hidden}.main-card:after{content:"›";position:absolute;left:18px;bottom:10px;font-size:42px;color:rgba(191,219,254,.35);transform:rotate(180deg)}.main-card:hover{transform:translateY(-4px);border-color:var(--brand2);background:linear-gradient(180deg,rgba(37,99,235,.28),rgba(15,23,42,.98))}.main-card .icon{font-size:34px;margin-bottom:10px}.main-card h2{margin:0 0 8px;font-size:24px}.main-card p{margin:0;color:var(--muted);line-height:1.55}.main-card.danger-card{border-color:rgba(248,113,113,.45);background:linear-gradient(180deg,rgba(127,29,29,.65),rgba(15,23,42,.98))}.main-card.admin-card{border-color:rgba(251,191,36,.45)} .screen{display:none;margin-top:18px}.screen.active{display:block}.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:20px 0 14px}.section-title h2{margin:0 0 6px;font-size:32px;letter-spacing:-.5px}.section-title p{margin:0;color:var(--muted);line-height:1.55}.section-actions{display:flex;gap:10px;flex-wrap:wrap}.back-btn{border:1px solid var(--line);background:#1e293b;color:white;border-radius:999px;padding:10px 14px;cursor:pointer;font-weight:900}.back-btn:hover{background:#334155}.sub-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.sub-card{background:linear-gradient(180deg,var(--panel),#0f172a);border:1px solid var(--line);border-radius:22px;padding:18px;cursor:pointer;min-height:126px;transition:.18s transform,.18s border-color}.sub-card:hover{transform:translateY(-3px);border-color:var(--brand2)}.sub-card h3{margin:0 0 8px;font-size:22px}.sub-card p{margin:0;color:var(--muted);line-height:1.55}.tool-panel{display:none;background:rgba(16,28,49,.92);border:1px solid var(--line);border-radius:26px;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.22);margin-top:16px}.tool-panel.active{display:block}.tool-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.tool-head h3{font-size:26px;margin:0 0 6px}.tool-head p{margin:0;color:var(--muted);line-height:1.55}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.upload-box{background:#0b1220;border:1px solid #24344d;border-radius:18px;padding:16px}.upload-box h4{margin:0 0 8px;font-size:18px}.upload-box p{color:var(--muted);line-height:1.55;margin:8px 0 0;font-size:14px}.note{color:var(--muted);font-size:14px;line-height:1.65}.warning{background:rgba(127,29,29,.38);border:1px solid rgba(248,113,113,.35);color:#fecaca;border-radius:16px;padding:13px;line-height:1.6;margin:12px 0}.good{background:rgba(22,101,52,.30);border:1px solid rgba(74,222,128,.35);color:#bbf7d0;border-radius:16px;padding:13px;line-height:1.6;margin:12px 0}.mini-note{background:#0f172a;border:1px solid var(--line);border-radius:14px;padding:12px;color:#cbd5e1;line-height:1.6;font-size:14px}input[type=file],select,input[type=text]{width:100%;padding:14px;background:#07111f;color:white;border-radius:14px;border:1px solid #334155;font-size:15px}input[type=file]::file-selector-button{border:0;border-radius:10px;background:#1d4ed8;color:white;padding:10px 12px;margin-left:10px;font-weight:900}button{border:0;background:#2563eb;color:white;padding:13px 17px;border-radius:14px;font-size:15px;font-weight:950;cursor:pointer;margin-top:12px;box-shadow:0 12px 30px rgba(37,99,235,.20)}button:hover{filter:brightness(1.08)}button.orange{background:#ea580c}button.danger{background:#dc2626}button.cancel{background:#475569}.preview-links{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}.preview-links a{display:inline-flex;text-decoration:none;background:#172033;border:1px solid #334155;border-radius:12px;padding:10px 12px;font-weight:900;color:#dbeafe}.preview-links a:hover{border-color:var(--brand2)}.field-label{display:block;color:#cbd5e1;font-size:13px;font-weight:900;margin:12px 0 6px}label.check{display:flex;align-items:center;gap:8px;margin-top:12px;color:#e2e8f0;font-weight:bold}.danger-zone{background:linear-gradient(135deg,rgba(69,26,26,.72),rgba(16,28,49,.94))!important;border-color:rgba(248,113,113,.35)!important}.split-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.filter-bar{position:sticky;top:0;z-index:8;background:rgba(7,17,31,.92);backdrop-filter:blur(12px);border:1px solid var(--line);border-radius:18px;padding:12px;margin:10px 0 14px;display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center}.jump-days{display:flex;gap:6px;flex-wrap:wrap;max-width:500px;justify-content:flex-end}.jump-days button{margin:0;padding:7px 9px;border-radius:10px;background:#1e293b;box-shadow:none;font-size:12px}.image-upload-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.image-upload-card{margin:0}.image-file-input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}.image-upload-label{display:block;height:100%;cursor:pointer;background:#0b1220;border:1px solid #283950;border-radius:18px;padding:15px;transition:.18s transform,.18s border-color,.18s background;box-shadow:0 8px 22px rgba(0,0,0,.14)}.image-upload-label:hover{transform:translateY(-2px);border-color:#60a5fa;background:#13213a}.image-key{display:inline-flex;background:#1d4ed8;color:#dbeafe;border-radius:999px;padding:4px 9px;font-size:12px;font-weight:900;margin-bottom:8px}.image-title{display:block;color:#fff;font-size:17px;font-weight:950;margin-bottom:7px;line-height:1.25}.image-desc{display:block;color:#cbd5e1;font-size:14px;line-height:1.45;min-height:42px}.upload-status{display:block;margin-top:12px;color:#fde68a;font-size:13px;font-weight:900;border-top:1px solid #334155;padding-top:10px}.breadcrumb{display:flex;gap:8px;align-items:center;color:#bfdbfe;font-size:14px;margin:16px 0 0}.breadcrumb button{margin:0;background:transparent;border:1px solid #334155;box-shadow:none;padding:7px 10px}.mobile-footer{display:none;position:fixed;bottom:12px;right:12px;left:12px;z-index:20}.mobile-footer button{width:100%;box-shadow:0 12px 40px rgba(0,0,0,.35)} @media(max-width:860px){.hero{grid-template-columns:1fr;padding:18px}.hero h1{font-size:30px}.hero p{font-size:14px;line-height:1.55}.hero-actions{justify-content:flex-start}.status-strip{display:none}.main-grid{grid-template-columns:repeat(2,1fr);gap:10px}.main-card{min-height:128px;padding:14px;border-radius:22px}.main-card .icon{font-size:27px;margin-bottom:7px}.main-card h2{font-size:20px;margin-bottom:6px}.main-card p{font-size:12.5px;line-height:1.38}.sub-grid,.form-grid,.split-grid{grid-template-columns:1fr}.section-head,.tool-head{display:block}.section-actions{margin-top:12px}.filter-bar{grid-template-columns:1fr}.jump-days{justify-content:flex-start;max-width:none}.wrap{width:min(100%,94%);padding:14px 0 72px}.mobile-footer{display:none}.image-upload-grid{grid-template-columns:1fr}.image-desc{min-height:auto}}@media(max-width:340px){.main-grid{grid-template-columns:1fr}.main-card{min-height:120px}} </style> </head> <body> <div class="wrap app-shell"> <div class="hero"> <div> <h1><span class="dot"></span>TripServer Admin</h1> <p>מסך ניהול לפי היגיון עבודה: בוחרים תחום, נכנסים לתת־קטגוריה, ואז מעלים רק את מה שצריך. בלי גלילה אינסופית ובלי לחפש ידיים ורגליים.</p> </div> <div class="hero-actions"> <a class="btn-link" href="/admin/status">📊 סטטוס קבצים</a> <a class="btn-link" href="/admin/health">🩺 בדיקת מערכת</a> <a class="btn-link" href="/admin/self-test">🧪 בדיקה עצמית מלאה</a> <a class="btn-link" href="/admin/watchdog">🛡️ Watchdog</a> <a class="btn-link" href="/admin/logs">📜 לוגים</a> <a class="btn-link" href="/">🏠 ראשי</a> </div> </div> <div id="screen-home" class="screen active"> <div class="status-strip"> <div class="status-card"><b>📁 העלאה בטוחה</b><span>שמירה זמנית, גיבוי, החלפה אטומית, גודל ו־SHA אחרי שמירה.</span></div> <div class="status-card"><b>🔁 Restart לאדמין</b><span>עדכון admin מפעיל helper חיצוני, לוג ובדיקת פורט.</span></div> <div class="status-card"><b>🧭 מבנה היררכי</b><span>מסך ראשי → תחום → תת־תחום → פעולה.</span></div> <div class="status-card"><b>🖥️ מחשב</b><span>כיבוי/ריסטארט עם טיימר, אישור כתוב וביטול.</span></div> <div class="status-card"><b>🛡️ Watchdog</b><span>בדיקה חיצונית כל דקה והפעלה אוטומטית אם האדמין נפל.</span></div> <div class="status-card"><b>📜 לוגים</b><span>צפייה בלוגי restart ו־Watchdog מתוך הפאנל.</span></div> </div> <div class="main-grid"> <button class="main-card" onclick="showScreen('japan')"><div class="icon">🇯🇵</div><h2>יפן</h2><p>קובץ טיול, מצגות, תמונות ומוזיקה של יפן.</p></button> <button class="main-card" onclick="showScreen('usa')"><div class="icon">🇺🇸</div><h2>ארצות הברית</h2><p>קובץ טיול, מצגת, TV, תמונות שקפים ומוזיקה.</p></button> <button class="main-card" onclick="showScreen('budgets')"><div class="icon">💰</div><h2>תקציבים</h2><p>אפליקציית תקציב חופשה ואפליקציית תקציב חודשי/משפחתי.</p></button> <button class="main-card" onclick="showScreen('tripform')"><div class="icon">🧾</div><h2>טופס פתיחת טיול</h2><p>טופס קליטת טיול חדש, שמירה כ־TripForm/index.html.</p></button> <button class="main-card danger-card" onclick="showScreen('power')"><div class="icon">🖥️</div><h2>פעולות מחשב</h2><p>כיבוי, ריסטארט, טיימר וביטול פעולה מתוזמנת.</p></button> <button class="main-card" onclick="showScreen('watchdog')"><div class="icon">🛡️</div><h2>Watchdog</h2><p>שמירת הפאנל חי: בדיקה כל דקה, שחרור פורט תקוע והפעלה מחדש אם נפל.</p></button> <a class="main-card" href="/admin/camera" style="text-decoration:none"><div class="icon">📷</div><h2>מצלמת חדר</h2><p>הפעלת מצלמת החדר, אודיו והקלטות — רק בלחיצה ידנית.</p></a> <a class="main-card" href="/admin/logs" style="text-decoration:none"><div class="icon">📜</div><h2>לוגים</h2><p>צפייה בלוגי אתחול פאנל ו־Watchdog מתוך הדפדפן.</p></a> <button class="main-card admin-card" onclick="showScreen('admin-tools')"><div class="icon">⚙️</div><h2>ניהול האדמין</h2><p>העלאת admin.py/admin_server.py, restart ושחזור גיבוי.</p></button> </div> </div> <div id="screen-japan" class="screen"> <div class="section-head"><div class="section-title"><h2>🇯🇵 יפן</h2><p>בחר קודם מה אתה מעדכן: קובץ הטיול, המצגות או המדיה.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/JAPAN/" target="_blank">פתח יפן</a></div></div> <div class="sub-grid"> <div class="sub-card" onclick="showTool('japan','japan-trip')"><h3>📄 קובץ טיול יפן</h3><p>העלאת index.html או חבילת ZIP מלאה.</p></div> <div class="sub-card" onclick="showTool('japan','japan-presentations')"><h3>🎬 מצגות יפן</h3><p>פתיח טיסה ומצגת יפן.</p></div> <div class="sub-card" onclick="showTool('japan','japan-media')"><h3>🖼️🎵 מדיה יפן</h3><p>ZIP תמונות ושירי MP3.</p></div> <div class="sub-card" onclick="window.open('/JAPAN/japan-experience.html','_blank')"><h3>🔎 בדיקה מהירה</h3><p>פתיחת מצגת יפן בחלון חדש.</p></div> </div> <div id="tool-japan-trip" class="tool-panel"> <div class="tool-head"><div><h3>📄 קובץ טיול יפן</h3><p>קובץ יחיד נשמר כ־JAPAN/index.html. ZIP מלא מעדכן גם מצגות ומדיה אם הן קיימות בחבילה.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div> <div class="form-grid"><div class="upload-box"><h4>קובץ HTML ראשי</h4><form action="/upload/JAPAN" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ טיול יפן</button></form><p>יישמר כ־C:\\TripServer\\JAPAN\\index.html</p></div><div class="upload-box"><h4>ZIP מלא יפן</h4><form action="/upload_trip_package/JAPAN" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP מלא של יפן</button></form><p>מתאים לחבילה עם index.html, japan-experience.html, japan-flight-intro.html ותיקיית media.</p></div></div> </div> <div id="tool-japan-presentations" class="tool-panel"> <div class="tool-head"><div><h3>🎬 מצגות יפן</h3><p>שמות היעד קבועים כדי שהקישורים מתוך קובץ הטיול לא יישברו.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div> <div class="form-grid"><div class="upload-box"><h4>✈️ פתיח טיסה</h4><form action="/upload_presentation/flight_intro" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה פתיח טיסה</button></form><p>יישמר כ־japan-flight-intro.html</p></div><div class="upload-box"><h4>🎌 מצגת יפן</h4><form action="/upload_presentation/japan_experience" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת יפן</button></form><p>יישמר כ־japan-experience.html</p></div></div> <div class="preview-links"><a href="/JAPAN/japan-flight-intro.html" target="_blank">בדיקת פתיח</a><a href="/JAPAN/japan-experience.html" target="_blank">בדיקת מצגת</a></div> </div> <div id="tool-japan-media" class="tool-panel"> <div class="tool-head"><div><h3>🖼️🎵 מדיה יפן</h3><p>תמונות ושירים נשמרים לנתיבים שהמצגת מחפשת בפועל. ספירה מדויקת של תמונות בשימוש נמצאת במסך סטטוס קבצים.</p></div><button class="back-btn" onclick="hideTools('japan')">חזרה לתת־קטגוריות</button></div> <div class="form-grid"><div class="upload-box"><h4>ZIP תמונות מצגת יפן</h4><form action="/upload_japan_images_zip" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות יפן</button></form><p>הפאנל מסנן קובצי תמונה נתמכים. הספירה החשובה היא לא כמה קבצים יש בתיקייה, אלא כמה src מופיעים בפועל בתוך japan-experience.html. בחבילות/ZIP מלאים הוא לא משנה שמות קבצים כדי לא לשבור קישורים פנימיים במצגת.</p></div><div class="upload-box"><h4>🎵 שיר מצגת יפן</h4><form action="/upload_japan_music/japan_experience_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה japan-theme.mp3</button></form><p>כל קובץ אודיו נתמך יישמר כ־MP3 אמיתי בכל נתיבי ה־fallback של המצגת. המרה מלאה דורשת FFmpeg.</p></div><div class="upload-box"><h4>✈️ מוזיקת פתיח טיסה</h4><form action="/upload_japan_music/japan_flight_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה flight-intro.mp3</button></form><p>כל קובץ אודיו נתמך יישמר כ־MP3 אמיתי לנתיב מוזיקת פתיח הטיסה. המרה מלאה דורשת FFmpeg.</p></div></div> </div> </div> <div id="screen-usa" class="screen"> <div class="section-head"><div class="section-title"><h2>🇺🇸 ארצות הברית</h2><p>אזור ארה״ב מחולק לקובץ טיול, מצגות, תמונות שקפים ומוזיקה/TV.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/USA/" target="_blank">פתח ארה״ב</a></div></div> <div class="sub-grid"> <div class="sub-card" onclick="showTool('usa','usa-trip')"><h3>📄 קובץ טיול</h3><p>HTML ראשי, ZIP מלא וגרסת טלוויזיה.</p></div> <div class="sub-card" onclick="showTool('usa','usa-presentation')"><h3>🎬 מצגת</h3><p>מצגת עם סכומים וגרסת TV.</p></div> <div class="sub-card" onclick="showTool('usa','usa-images')"><h3>🖼️ תמונות שקפים</h3><p>ZIP מלא או העלאה לפי שקף/יום.</p></div> <div class="sub-card" onclick="showTool('usa','usa-music')"><h3>🎵 מוזיקה</h3><p>שיר חלק 1 ושיר חלק 2 של המצגת.</p></div> </div> <div id="tool-usa-trip" class="tool-panel"> <div class="tool-head"><div><h3>📄 קובץ טיול ארה״ב</h3><p>קובץ הטיול הראשי נשמר כ־USA/index.html. גרסת TV נשמרת בנפרד.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div> <div class="form-grid"><div class="upload-box"><h4>HTML ראשי</h4><form action="/upload/USA" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ טיול ארה״ב</button></form><p>יישמר כ־C:\\TripServer\\USA\\index.html</p></div><div class="upload-box"><h4>ZIP מלא ארה״ב</h4><form action="/upload_trip_package/USA" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP מלא של ארה״ב</button></form><p>מתאים לחבילת Netlify/שרת עם index.html, מצגת, media ותמונות.</p></div><div class="upload-box"><h4>גרסת טלוויזיה</h4><form action="/upload_usa_extra_html/usa_trip_tv" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה קובץ ארה״ב TV</button></form><p>יישמר כ־usa2027-tv.html.</p></div></div> </div> <div id="tool-usa-presentation" class="tool-panel"> <div class="tool-head"><div><h3>🎬 מצגת ארה״ב</h3><p>המצגת הרגילה והמצגת המותאמת למסך טלוויזיה נשמרות בשמות קבועים.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div> <div class="form-grid"><div class="upload-box"><h4>מצגת ארה״ב עם סכומים</h4><form action="/upload_usa_presentation/usa_presentation" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת ארה״ב</button></form><p>יישמר כ־usa2027-presentation.html</p></div><div class="upload-box"><h4>מצגת ארה״ב TV</h4><form action="/upload_usa_presentation/usa_presentation_tv" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה מצגת TV</button></form><p>יישמר כ־usa2027-presentation-tv.html</p></div></div> <div class="preview-links"><a href="/USA/usa2027-presentation.html" target="_blank">בדיקת מצגת</a><a href="/USA/usa2027-presentation-tv.html" target="_blank">בדיקת מצגת TV</a></div> </div> <div id="tool-usa-images" class="tool-panel"> <div class="tool-head"><div><h3>🖼️ תמונות מצגת ארה״ב</h3><p>התיאורים כאן מסונכרנים לסדר השקפים בפועל: Disney Springs, ימי ים בקרוז, St. Maarten, St. Thomas ו־CocoCay.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div> <div class="good">העלאה בודדת ו־ZIP שומרים תמיד JPG אמיתי בשם השקף, עם דחיסה חכמה והקטנה עדינה עד 1920px בצלע הארוכה. העלאת ZIP מקבלת שמות כמו day-01.jpg עד day-36.jpg וגם intro/budget/summary/extra.</div> <div class="form-grid"><div class="upload-box"><h4>ZIP תמונות מלא</h4><form action="/upload_usa_images_zip" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed"><button>העלה ZIP תמונות ארה״ב</button></form><p>שמות נתמכים: intro, roadtrip-map, day-01 עד day-36, budget, summary ותמונות bonus.</p></div><div class="upload-box"><h4>בדיקת מצגת</h4><p>אחרי העלאת תמונות פתח את המצגת כדי לוודא שאין cache ישן.</p><div class="preview-links"><a href="/USA/usa2027-presentation.html?from=admin" target="_blank">פתח מצגת</a><a href="/admin/status" target="_blank">סטטוס תמונות</a></div></div></div> <div class="filter-bar"><input id="imageSearch" type="text" placeholder="חיפוש שקף: יום 18, Disney Springs, קרוז, CocoCay..." oninput="filterImages()"><div class="jump-days"><button onclick="jumpImage('intro')">פתיחה</button><button onclick="jumpImage('day-01')">יום 1</button><button onclick="jumpImage('day-18')">יום 18</button><button onclick="jumpImage('day-28')">קרוז</button><button onclick="jumpImage('budget')">תקציב</button><button onclick="jumpImage('summary')">סיום</button></div></div> <div class="image-upload-grid" id="usaImageGrid"> """ + render_usa_image_upload_cards() + """ </div> </div> <div id="tool-usa-music" class="tool-panel"> <div class="tool-head"><div><h3>🎵 מוזיקת מצגת ארה״ב</h3><p>שני קבצי MP3 אמיתיים בלבד, כדי שהדפדפן ינגן בלי קובץ מזויף.</p></div><button class="back-btn" onclick="hideTools('usa')">חזרה לתת־קטגוריות</button></div> <div class="form-grid"><div class="upload-box"><h4>שיר חלק 1 — Road Trip</h4><form action="/upload_usa_music/usa_music" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה שיר חלק 1</button></form><p>usa-roadtrip-theme.mp3 — ממיר ל־MP3 אם FFmpeg מותקן.</p></div><div class="upload-box"><h4>שיר חלק 2 — Orlando / Miami / Cruise</h4><form action="/upload_usa_music/usa_music_part2" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.wma,.webm,.mp4,audio/*,video/mp4"><button>העלה שיר חלק 2</button></form><p>usa-part2-theme.mp3 — ממיר ל־MP3 אם FFmpeg מותקן.</p></div></div> </div> </div> <div id="screen-budgets" class="screen"> <div class="section-head"><div class="section-title"><h2>💰 תקציבים</h2><p>שתי אפליקציות נפרדות: תקציב חופשה ותקציב חודשי/משפחתי.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div> <div class="form-grid"><div class="upload-box"><h4>💼 אפליקציית ניהול תקציב חופשה</h4><form action="/upload/Moneyapp" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה אפליקציית תקציב חופשה</button></form><p>יישמר כ־Moneyapp/index.html.</p><div class="preview-links"><a href="/Moneyapp/" target="_blank">פתח תקציב חופשה</a></div></div><div class="upload-box"><h4>🏠 אפליקציית תקציב חודשי / משפחתי</h4><form action="/upload/MoneyHome" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה תקציב חודשי</button></form><p>יישמר כ־MoneyHome/index.html.</p><div class="preview-links"><a href="/MoneyHome/" target="_blank">פתח תקציב חודשי</a></div></div></div> </div> <div id="screen-tripform" class="screen"> <div class="section-head"><div class="section-title"><h2>🧾 טופס פתיחת טיול</h2><p>טופס הקליטה ליצירת טיול חדש.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/TripForm/" target="_blank">פתח טופס</a></div></div> <div class="upload-box"><h4>העלאת טופס HTML</h4><form action="/upload/TripForm" method="post" enctype="multipart/form-data"><input type="file" name="file" accept=".html,.htm"><button>העלה טופס פתיחת טיול</button></form><p>יישמר כ־TripForm/index.html.</p></div> </div> <div id="screen-power" class="screen"> <div class="section-head"><div class="section-title"><h2>🖥️ פעולות מחשב</h2><p>פעולות Windows אמיתיות. הן מוגנות באישור כתוב, טיימר וחסימת גישה מחוץ לרשת מקומית.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button></div></div> <div class="tool-panel active danger-zone"><div class="warning"><strong>זהירות:</strong> כיבוי או ריסטארט מנתקים את השרת עד שהמחשב עולה מחדש. השתמש בטיימר ולא ב־0 שניות.</div><div class="split-grid"><div class="upload-box"><h4>⏻ כיבוי מחשב</h4><form action="/power/shutdown" method="post" onsubmit="return confirm('לקבוע כיבוי למחשב? אפשר לבטל עם כפתור ביטול לפני שהטיימר נגמר.')"><label class="field-label">טיימר לפני כיבוי</label><select name="delay"><option value="60">עוד דקה</option><option value="300">עוד 5 דקות</option><option value="900">עוד 15 דקות</option><option value="1800">עוד 30 דקות</option></select><label class="field-label">הקלד בדיוק: כיבוי</label><input type="text" name="confirm_text" autocomplete="off" placeholder="כיבוי"><label class="check"><input type="checkbox" name="ack" value="1"> אני מבין שזה יכבה את המחשב</label><button class="danger">קבע כיבוי מחשב</button></form></div><div class="upload-box"><h4>🔄 ריסטארט למחשב</h4><form action="/power/reboot" method="post" onsubmit="return confirm('לקבוע ריסטארט למחשב? אפשר לבטל עם כפתור ביטול לפני שהטיימר נגמר.')"><label class="field-label">טיימר לפני ריסטארט</label><select name="delay"><option value="60">עוד דקה</option><option value="300">עוד 5 דקות</option><option value="900">עוד 15 דקות</option><option value="1800">עוד 30 דקות</option></select><label class="field-label">הקלד בדיוק: הפעלה מחדש</label><input type="text" name="confirm_text" autocomplete="off" placeholder="הפעלה מחדש"><label class="check"><input type="checkbox" name="ack" value="1"> אני מבין שזה יפעיל את המחשב מחדש</label><button class="orange">קבע ריסטארט למחשב</button></form></div></div><form action="/power/cancel" method="post" onsubmit="return confirm('לבטל כיבוי/ריסטארט מתוזמן של Windows?')"><button class="cancel">בטל כיבוי/ריסטארט מתוזמן</button></form><p class="note">פעולות נרשמות ללוג C:\\TripServer\\admin_restart.log.</p></div> </div> <div id="screen-watchdog" class="screen"> <div class="section-head"><div class="section-title"><h2>🛡️ Watchdog</h2><p>שכבת ביטחון חיצונית שמחזירה את פאנל האדמין לאוויר אם הוא נפל.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/admin/watchdog">מסך Watchdog מלא</a><a class="btn-link" href="/admin/self-test">בדיקה עצמית</a></div></div> <div class="tool-panel active safe-zone"><div class="good"><strong>מטרה:</strong> לא ללכת פיזית למחשב אם השרת נפל. Windows יריץ סקריפט קטן כל דקה, ואם /health.json לא עונה — הוא ישחרר פורט תקוע במידת הצורך ויפעיל מחדש את admin_server.py.</div><div class="form-grid"><div class="upload-box"><h4>התקנה / עדכון</h4><form action="/watchdog/install" method="post" onsubmit="return confirm('להתקין או לעדכן את משימת ה־Watchdog?')"><button>התקן / עדכן Watchdog</button></form><p>נכתב אל C:\\TripServer\\tripserver_admin_watchdog.py ומותקן כ־Task Scheduler בשם TripServerAdminWatchdog.</p></div><div class="upload-box"><h4>בדיקה והרצה</h4><form action="/watchdog/run-once" method="post" onsubmit="return confirm('להריץ Watchdog פעם אחת עכשיו?')"><button class="orange">הרץ Watchdog עכשיו</button></form><div class="preview-links"><a href="/admin/watchdog">פתח סטטוס Watchdog</a></div><p>הרצה חד־פעמית לא מחליפה קבצים. אם השרת חי היא רק תצא תקין.</p></div><div class="upload-box"><h4>הסרה</h4><form action="/watchdog/uninstall" method="post" onsubmit="return confirm('להסיר את משימת ה־Watchdog המתוזמנת?')"><button class="danger">הסר Watchdog</button></form><p>מסיר רק את המשימה המתוזמנת. קובץ הסקריפט והלוג נשארים לצורך בדיקה.</p></div></div></div> </div> <div id="screen-admin-tools" class="screen"> <div class="section-head"><div class="section-title"><h2>⚙️ ניהול אפליקציית האדמין</h2><p>כאן מעדכנים את הקובץ שמפעיל את הפאנל עצמו.</p></div><div class="section-actions"><button class="back-btn" onclick="showScreen('home')">⬅ חזרה למסך הראשי</button><a class="btn-link" href="/admin/status">סטטוס</a><a class="btn-link" href="/admin/health">בדיקת מערכת</a><a class="btn-link" href="/admin/self-test">בדיקה עצמית מלאה</a><a class="btn-link" href="/admin/watchdog">Watchdog</a><a class="btn-link" href="/admin/logs">לוגים</a></div></div> <div class="tool-panel active"><div class="warning"><strong>פעולה רגישה:</strong> עדכון admin_server.py מחליף את המנוע שמריץ את הפאנל. לכן יש בדיקת קומפילציה, גיבוי, החלפה אטומית ו־restart אוטומטי.</div><div class="good">הזרימה: העלאה → בדיקת Python → בדיקת סימני TripServer → גיבוי → החלפה → helper חיצוני → restart → לוג.</div><div class="form-grid"><div class="upload-box"><h4>העלאת admin חדש</h4><form action="/upload_admin_server" method="post" enctype="multipart/form-data" onsubmit="return confirm('להעלות admin_server.py חדש ולהחליף את קובץ הפאנל הפעיל אחרי בדיקה וגיבוי?')"><input type="file" name="file" accept=".py,text/x-python,text/plain"><button class="orange">העלה admin_server.py חדש</button></form><p>אם אצלך הקובץ נקרא admin.py, שמור את הקובץ החדש בשם הזה בהחלפה הידנית הראשונה.</p></div><div class="upload-box"><h4>פעולות תחזוקה</h4><form action="/restart_admin_server" method="post" onsubmit="return confirm('זו בדיקת אתחול מתקדמת. להפעיל מחדש את הפאנל עכשיו?')"><button class="orange">בדיקת אתחול פאנל — מתקדם</button></form><form action="/restore_admin_server_backup" method="post" onsubmit="return confirm('לשחזר את הגיבוי האחרון של admin_server.py?')"><button class="danger">שחזר גיבוי אחרון</button></form><div class="preview-links"><a href="/admin/self-test">🧪 הרץ בדיקה עצמית מלאה</a><a href="/admin/logs">📜 צפה בלוגים</a></div><p><b>הערה:</b> לא חובה אחרי התקנת Watchdog. בדיקת אתחול מיועדת רק לבדיקה יזומה או אחרי עדכון/שחזור אדמין.<br>גיבויים: C:\\TripServer\\backups\\admin_server</p></div></div></div> </div> <div class="mobile-footer"><button onclick="showScreen('home')">🏠 חזרה למסך הראשי</button></div> </div> <script> function showScreen(name){ document.querySelectorAll('.screen').forEach(s=>s.classList.remove('active')); const el=document.getElementById('screen-'+name); if(el){el.classList.add('active'); location.hash=name==='home'?'':name; window.scrollTo({top:0,behavior:'smooth'});} } function hideTools(group){ document.querySelectorAll('#screen-'+group+' .tool-panel').forEach(t=>t.classList.remove('active')); window.scrollTo({top:document.getElementById('screen-'+group).offsetTop-10,behavior:'smooth'}); } function showTool(group, tool){ hideTools(group); const el=document.getElementById('tool-'+tool); if(el){el.classList.add('active'); setTimeout(()=>el.scrollIntoView({behavior:'smooth',block:'start'}),70); location.hash=tool;} } function filterImages(){ const q=(document.getElementById('imageSearch')?.value||'').toLowerCase().trim(); document.querySelectorAll('#usaImageGrid .image-upload-card').forEach(card=>{card.style.display=!q||card.dataset.search.includes(q)?'block':'none';}); } function jumpImage(key){ const card=document.querySelector('#usaImageGrid input[value="'+key+'"]').closest('.image-upload-card'); if(card){card.scrollIntoView({behavior:'smooth',block:'center'}); card.querySelector('.image-upload-label').style.borderColor='#fbbf24'; setTimeout(()=>card.querySelector('.image-upload-label').style.borderColor='',1600);} } window.addEventListener('load',()=>{const h=(location.hash||'').replace('#',''); if(['japan','usa','budgets','tripform','power','watchdog','admin-tools'].includes(h)) showScreen(h); if(h.startsWith('usa-')){showScreen('usa'); setTimeout(()=>showTool('usa',h),80);} if(h.startsWith('japan-')){showScreen('japan'); setTimeout(()=>showTool('japan',h),80);}}); </script> </body> </html> """ def add_self_test_result(results, category, name, ok, details="", severity="critical"): if ok: status = "pass" elif severity == "warning": status = "warn" else: status = "fail" results.append({ "category": category, "name": name, "status": status, "severity": severity, "details": str(details or ""), }) def route_rule_matches_action(rule, action): # Support both exact rules and Flask dynamic rules such as /upload/<folder>. if rule == action: return True rule_parts = [part for part in (rule or "").strip("/").split("/") if part] action_parts = [part for part in (action or "").strip("/").split("/") if part] if len(rule_parts) != len(action_parts): return False for rule_part, action_part in zip(rule_parts, action_parts): if rule_part.startswith("<") and rule_part.endswith(">"): if not action_part: return False continue if rule_part != action_part: return False return True def collect_admin_form_actions(html_text): return sorted(set(re.findall(r"<form[^>]+action=[\"\']([^\"\']+)[\"\']", html_text or ""))) def collect_duplicate_ids(html_text): ids = re.findall(r"\bid=[\"\']([^\"\']+)[\"\']", html_text or "") seen, duplicates = set(), [] for item in ids: if item in seen and item not in duplicates: duplicates.append(item) seen.add(item) return duplicates def check_temp_write_permission(): os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) test_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"self_test_{os.getpid()}_{int(time.time())}.tmp") try: with open(test_path, "w", encoding="utf-8") as fh: fh.write("TripServer self test") with open(test_path, "r", encoding="utf-8") as fh: data = fh.read() return data == "TripServer self test", test_path finally: try: if os.path.exists(test_path): os.remove(test_path) except Exception: pass def run_self_test_suite(include_media_conversion=False): """Non-destructive self-test for the admin panel. It checks code validity, routes/forms, folders, media engine availability, presentation image mappings, power guards and restart-helper generation. It intentionally does not upload real files, restart the server, shut down Windows, or modify active trip files. """ started = time.time() results = [] # 1) Python/code validity. try: py_compile.compile(ADMIN_SERVER_PATH, doraise=True) add_self_test_result(results, "קוד", "קומפילציית Python לקובץ האדמין הפעיל", True, ADMIN_SERVER_PATH) except Exception as exc: add_self_test_result(results, "קוד", "קומפילציית Python לקובץ האדמין הפעיל", False, repr(exc)) try: proc = subprocess.run([sys.executable, "-Werror", "-m", "py_compile", ADMIN_SERVER_PATH], capture_output=True, text=True, timeout=40) add_self_test_result(results, "קוד", "קומפילציה עם Werror", proc.returncode == 0, (proc.stderr or proc.stdout or "OK")[-1200:]) except Exception as exc: add_self_test_result(results, "קוד", "קומפילציה עם Werror", False, repr(exc), severity="warning") try: valid, info = validate_admin_server_upload(ADMIN_SERVER_PATH) add_self_test_result(results, "קוד", "בדיקת קובץ אדמין מול מנגנון upload_admin_server", bool(valid), info) except Exception as exc: add_self_test_result(results, "קוד", "בדיקת קובץ אדמין מול מנגנון upload_admin_server", False, repr(exc)) try: helper_source = build_restart_helper_code(os.path.abspath(ADMIN_SERVER_PATH), sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR) ok, info = validate_restart_helper_source(helper_source) add_self_test_result(results, "Restart", "קוד helper חיצוני ל־restart עובר קומפילציה", bool(ok), info) except Exception as exc: add_self_test_result(results, "Restart", "קוד helper חיצוני ל־restart עובר קומפילציה", False, repr(exc)) try: wd_code = build_watchdog_code(ADMIN_SERVER_PATH, sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR) wd_ok, wd_info = validate_watchdog_source(wd_code) add_self_test_result(results, "Watchdog", "קוד Watchdog חיצוני עובר קומפילציה", wd_ok, wd_info) wd_status = watchdog_status(include_log=False) add_self_test_result(results, "Watchdog", "סטטוס Watchdog ניתן לקריאה", bool(wd_status.get("source_ok")), f"script_exists={wd_status.get('script_exists')} task_installed={wd_status.get('task_installed')}") if os.name == "nt": add_self_test_result(results, "Watchdog", "Task Scheduler נתמך במחשב זה", bool(wd_status.get("task_supported")), wd_status.get("task_status", ""), severity="warning") else: add_self_test_result(results, "Watchdog", "Task Scheduler בסביבה הנוכחית", False, "לא Windows — אצלך בשרת Windows זה אמור להיות נתמך.", severity="warning") except Exception as exc: add_self_test_result(results, "Watchdog", "בדיקת Watchdog", False, repr(exc)) # 2) Paths and write access. for key, folder_path in sorted(FOLDERS.items()): add_self_test_result(results, "נתיבים", f"תיקיית {key} קיימת", os.path.isdir(folder_path), folder_path) try: ok, path = check_temp_write_permission() add_self_test_result(results, "נתיבים", "כתיבה/מחיקה בתיקיית tmp של האדמין", ok, path) except Exception as exc: add_self_test_result(results, "נתיבים", "כתיבה/מחיקה בתיקיית tmp של האדמין", False, repr(exc)) for label, path in [ ("תיקיית גיבויי אדמין", ADMIN_BACKUP_DIR), ("תיקיית גיבויי העלאות", UPLOAD_BACKUP_DIR), ]: try: os.makedirs(path, exist_ok=True) add_self_test_result(results, "נתיבים", label, os.path.isdir(path), path) except Exception as exc: add_self_test_result(results, "נתיבים", label, False, repr(exc)) # 3) Flask routes and HTML forms. try: rules = sorted(str(rule.rule) for rule in app.url_map.iter_rules()) expected_routes = [ "/", "/admin", "/admin/status", "/admin/health", "/admin/self-test", "/admin/self-test.json", "/admin/watchdog", "/admin/logs", "/admin/logs.json", "/admin/camera", "/camera/stream", "/camera/snapshot.jpg", "/camera/stop", "/camera/status.json", "/camera/audio-stream", "/camera/audio-stop", "/camera/record/start", "/camera/record/stop", "/camera/recordings", "/health", "/health.json", "/upload_admin_server", "/restart_admin_server", "/restore_admin_server_backup", "/watchdog/install", "/watchdog/uninstall", "/watchdog/run-once", "/upload/<folder>", "/upload_presentation/<presentation_key>", "/upload_usa_presentation/<presentation_key>", "/upload_usa_image", "/upload_usa_images_zip", "/upload_usa_extra_html/<extra_key>", "/upload_japan_music/<music_key>", "/upload_japan_images_zip", "/upload_trip_package/<trip>", "/upload_usa_music/<music_key>", "/power/<action>", "/power/cancel", ] missing_routes = [route for route in expected_routes if route not in rules] add_self_test_result(results, "ממשק", "כל routes המרכזיים קיימים", not missing_routes, ", ".join(missing_routes) if missing_routes else f"{len(rules)} routes") except Exception as exc: rules = [] add_self_test_result(results, "ממשק", "קריאת routes מהאפליקציה", False, repr(exc)) try: admin_html = admin() actions = collect_admin_form_actions(admin_html) missing_actions = [] for action in actions: if not any(route_rule_matches_action(rule, action) for rule in rules): missing_actions.append(action) add_self_test_result(results, "ממשק", "כל טפסי הפאנל מחוברים ל־route קיים", not missing_actions, ", ".join(missing_actions) if missing_actions else f"{len(actions)} טפסים") dup_ids = collect_duplicate_ids(admin_html) add_self_test_result(results, "ממשק", "אין IDs כפולים במסך האדמין", not dup_ids, ", ".join(dup_ids) if dup_ids else "OK") add_self_test_result(results, "ממשק", "כפתור בדיקה עצמית קיים בפאנל", "/admin/self-test" in admin_html, "נבדק href=/admin/self-test") add_self_test_result(results, "ממשק", "כפתור Watchdog קיים בפאנל", "/admin/watchdog" in admin_html and "screen-watchdog" in admin_html, "נבדק href=/admin/watchdog ומסך screen-watchdog") add_self_test_result(results, "ממשק", "כפתור לוגים קיים בפאנל", "/admin/logs" in admin_html, "נבדק href=/admin/logs") add_self_test_result(results, "ממשק", "כפתור מצלמת חדר קיים בפאנל", "/admin/camera" in admin_html and "מצלמת חדר" in admin_html, "נבדק href=/admin/camera") add_self_test_result(results, "ממשק", "כפתור אתחול מתקדם לא מוצג כפעולת חובה", "בדיקת אתחול פאנל — מתקדם" in admin_html and "לא חובה אחרי התקנת Watchdog" in admin_html, "נבדק ניסוח UX") except Exception as exc: add_self_test_result(results, "ממשק", "ניתוח HTML של הפאנל", False, repr(exc)) try: status_html = render_admin_status() add_self_test_result(results, "ממשק", "מסך סטטוס נבנה ללא שגיאה", "סטטוס קבצים פעילים" in status_html, "OK") except Exception as exc: add_self_test_result(results, "ממשק", "מסך סטטוס נבנה ללא שגיאה", False, repr(exc)) try: health_html = render_admin_health() add_self_test_result(results, "ממשק", "מסך Health נבנה ללא שגיאה", "בדיקת מערכת" in health_html, "OK") except Exception as exc: add_self_test_result(results, "ממשק", "מסך Health נבנה ללא שגיאה", False, repr(exc)) try: logs_html = render_admin_logs() add_self_test_result(results, "ממשק", "מסך לוגים נבנה ללא שגיאה", "מרכז לוגים" in logs_html and "לוג Watchdog" in logs_html, "OK") except Exception as exc: add_self_test_result(results, "ממשק", "מסך לוגים נבנה ללא שגיאה", False, repr(exc)) try: camera_html = render_admin_camera() add_self_test_result(results, "מצלמה", "מסך מצלמת חדר נבנה ללא שגיאה", "מצלמת חדר" in camera_html and "/camera/stream" in camera_html, "OK") cam_engine = camera_engine_status() add_self_test_result(results, "מצלמה", "OpenCV זמין להפעלת מצלמה", bool(cam_engine.get("opencv")), "התקנה אוטומטית ברקע פעילה; בדוק /camera/setup" if not cam_engine.get("opencv") else f"OpenCV {cam_engine.get('opencv_version','')}", severity="warning") setup_html = render_camera_setup_page() add_self_test_result(results, "מצלמה", "מסך סטטוס רכיבי מצלמה נבנה תקין", "התקנה אוטומטית" in setup_html and "pip install" in setup_html, "OK") add_self_test_result(results, "מצלמה", "מצלמה מוגנת לרשת פרטית/Tailscale בלבד", is_private_or_loopback_ip("100.64.1.2") and not is_private_or_loopback_ip("8.8.8.8"), "Tailscale מותר, ציבורי חסום") except Exception as exc: add_self_test_result(results, "מצלמה", "בדיקת מסך/מנוע מצלמה", False, repr(exc)) # 4) Media engine. try: media_status = media_engine_status() add_self_test_result(results, "מדיה", "Pillow זמין להמרת/דחיסת תמונות", bool(media_status.get("pillow")), "py -m pip install --upgrade Pillow" if not media_status.get("pillow") else "OK") add_self_test_result(results, "מדיה", "FFmpeg זמין להמרת אודיו ל־MP3", bool(media_status.get("ffmpeg")), media_status.get("ffmpeg_path") or r"שים FFmpeg ב־C:\ffmpeg\bin\ffmpeg.exe") except Exception as exc: add_self_test_result(results, "מדיה", "בדיקת מנוע מדיה", False, repr(exc)) # Optional conversion mini-test: only in temp folder, never touches active media. if include_media_conversion: try: from PIL import Image source = os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.png") target = os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.jpg") Image.new("RGBA", (1600, 900), (30, 80, 150, 255)).save(source, "PNG") media = convert_or_copy_image_to_jpg(source, target, "self_test.png") ok = os.path.exists(target) and is_likely_jpeg_file(target) add_self_test_result(results, "מדיה", "סימולציית PNG → JPG בתיקיית tmp", ok, media_details_text(media)) except Exception as exc: add_self_test_result(results, "מדיה", "סימולציית PNG → JPG בתיקיית tmp", False, repr(exc), severity="warning") finally: for p in (os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.png"), os.path.join(ADMIN_UPLOAD_TMP_DIR, "self_test_image.jpg")): try: if os.path.exists(p): os.remove(p) except Exception: pass # 5) Presentation-specific checks. try: usa_count = len(USA_IMAGE_UI_ITEMS) usa_keys = {item[0] for item in USA_IMAGE_UI_ITEMS} add_self_test_result(results, "ארה״ב", "44 כרטיסיות תמונות ארה״ב קיימות", usa_count == 44, str(usa_count)) add_self_test_result(results, "ארה״ב", "כרטיסיות ארה״ב תואמות למפת שמות התמונות", usa_keys == set(USA_IMAGE_KEYS), f"ui={len(usa_keys)} keys={len(USA_IMAGE_KEYS)}") ui_blob = "\n".join(" ".join(item) for item in USA_IMAGE_UI_ITEMS) forbidden = [word for word in ["Nassau", "נסאו", "פארק דיסני"] if word in ui_blob] add_self_test_result(results, "ארה״ב", "אין תיאורים שגויים ידועים בתמונות ארה״ב", not forbidden, ", ".join(forbidden) if forbidden else "OK") required = ["Disney Springs", "קרוז – יום ים 1", "קרוז – יום ים 2", "קרוז – יום ים 3", "St. Maarten", "St. Thomas", "CocoCay"] missing_words = [word for word in required if word not in ui_blob] add_self_test_result(results, "ארה״ב", "תיאורי קרוז/אורלנדו מסונכרנים", not missing_words, ", ".join(missing_words) if missing_words else "OK") except Exception as exc: add_self_test_result(results, "ארה״ב", "בדיקת כרטיסיות ארה״ב", False, repr(exc)) try: japan_usage = analyze_japan_presentation_images() if not japan_usage.get("presentation_exists"): add_self_test_result(results, "יפן", "מצגת יפן קיימת לניתוח", False, japan_usage.get("presentation_path", ""), severity="warning") else: add_self_test_result(results, "יפן", "מצגת יפן קיימת לניתוח", True, japan_usage.get("presentation_path", "")) add_self_test_result(results, "יפן", "מצגת יפן משתמשת בתמונות בפועל", japan_usage.get("image_slides", 0) > 0, f"{japan_usage.get('image_slides', 0)} תמונות") add_self_test_result(results, "יפן", "אין תמונות חיצוניות חסרות למצגת יפן", japan_usage.get("missing_external_images", 0) == 0, f"חסרות: {japan_usage.get('missing_external_images', 0)}") add_self_test_result(results, "יפן", "ספירת תמונות יפן מוצגת כספירת שימוש ולא כספירת תיקייה", "תמונות בשימוש בפועל" in render_admin_status(), "OK") except Exception as exc: add_self_test_result(results, "יפן", "ניתוח מצגת יפן", False, repr(exc)) # 6) Upload target mapping and safety guards. try: main_targets = {key: os.path.join(path, "index.html") for key, path in FOLDERS.items()} bad_targets = [f"{key}:{target}" for key, target in main_targets.items() if not target.lower().endswith("index.html")] add_self_test_result(results, "העלאות", "קבצים ראשיים נשמרים תמיד כ־index.html", not bad_targets, ", ".join(bad_targets) if bad_targets else "OK") except Exception as exc: add_self_test_result(results, "העלאות", "מפת יעדי העלאה ראשיים", False, repr(exc)) try: safe_examples = ["index.html", "media/images/a.jpg", "USA/index.html"] unsafe_examples = ["../x.html", "a/../../x.html", "__MACOSX/a.jpg"] ok_safe = all(safe_zip_relpath(x) is not None for x in safe_examples) ok_unsafe = all(safe_zip_relpath(x) is None for x in unsafe_examples) add_self_test_result(results, "העלאות", "הגנת ZIP Slip / נתיבים מסוכנים", ok_safe and ok_unsafe, "OK" if ok_safe and ok_unsafe else f"safe={ok_safe} unsafe={ok_unsafe}") except Exception as exc: add_self_test_result(results, "העלאות", "הגנת ZIP Slip / נתיבים מסוכנים", False, repr(exc)) try: add_self_test_result(results, "אבטחה", "זיהוי IP מקומי/בית/Tailscale", is_private_or_loopback_ip("127.0.0.1") and is_private_or_loopback_ip("192.168.1.10") and is_private_or_loopback_ip("100.64.1.2") and is_private_or_loopback_ip("fd7a:115c:a1e0::1"), "OK") add_self_test_result(results, "אבטחה", "כתובת ציבורית נחסמת לפעולות מחשב", not is_private_or_loopback_ip("8.8.8.8"), "OK") add_self_test_result(results, "אבטחה", "טווח טיימר כיבוי/ריסטארט מוגבל", normalize_power_delay("0") == POWER_MIN_DELAY_SECONDS and normalize_power_delay("999999") == POWER_MAX_DELAY_SECONDS, f"{POWER_MIN_DELAY_SECONDS}-{POWER_MAX_DELAY_SECONDS} שניות") except Exception as exc: add_self_test_result(results, "אבטחה", "בדיקות אבטחת פעולות מחשב", False, repr(exc)) elapsed = round(time.time() - started, 3) passed = sum(1 for item in results if item["status"] == "pass") warned = sum(1 for item in results if item["status"] == "warn") failed = sum(1 for item in results if item["status"] == "fail") return { "ok": failed == 0, "passed": passed, "warnings": warned, "failed": failed, "total": len(results), "elapsed_seconds": elapsed, "version": ADMIN_PANEL_VERSION, "started_at": ADMIN_STARTED_AT, "checked_at": time.strftime("%Y-%m-%d %H:%M:%S"), "results": results, } def render_admin_self_test(): include_conversion = (request.args.get("conversion") == "1") payload = run_self_test_suite(include_media_conversion=include_conversion) badge_class = "ok" if payload["ok"] else "bad" badge_text = "הבדיקה עברה" if payload["ok"] else "נמצאו כשלים" grouped = {} for item in payload["results"]: grouped.setdefault(item["category"], []).append(item) def row_html(item): status = item.get("status") if status == "pass": mark = "✅" cls = "pass" label = "תקין" elif status == "warn": mark = "⚠️" cls = "warn" label = "אזהרה" else: mark = "❌" cls = "fail" label = "כשל" details = html_lib.escape(item.get("details") or "").replace("\n", "<br>") return f"<tr class='{cls}'><td>{mark} {label}</td><td>{html_lib.escape(item.get('name',''))}</td><td>{details}</td></tr>" groups_html = [] for category, items in grouped.items(): rows = "".join(row_html(item) for item in items) groups_html.append(f"<section class='group'><h2>{html_lib.escape(category)}</h2><table><thead><tr><th>מצב</th><th>בדיקה</th><th>פירוט</th></tr></thead><tbody>{rows}</tbody></table></section>") return f""" <!DOCTYPE html> <html lang="he" dir="rtl"> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>בדיקה עצמית TripServer</title> <style> body{{margin:0;background:#0f172a;color:white;font-family:Arial;padding:22px}}.wrap{{max-width:1180px;margin:auto}}a{{color:#bfdbfe;text-decoration:none}}.hero{{background:linear-gradient(135deg,#1d4ed8,#0f172a 62%,#111827);border:1px solid #334155;border-radius:26px;padding:22px;margin-bottom:16px;box-shadow:0 20px 70px rgba(0,0,0,.25)}}h1{{margin:0 0 8px;font-size:34px}}p{{color:#cbd5e1;line-height:1.65}}.badge{{display:inline-block;border-radius:999px;padding:8px 14px;font-weight:900;margin-top:8px}}.badge.ok{{background:#064e3b;color:#bbf7d0;border:1px solid #22c55e}}.badge.bad{{background:#7f1d1d;color:#fecaca;border:1px solid #ef4444}}.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:14px 0}}.card{{background:#1e293b;border:1px solid #334155;border-radius:18px;padding:14px}}.num{{font-size:30px;font-weight:950;color:#fde68a}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0}}.actions a{{display:inline-flex;background:#1e293b;border:1px solid #475569;border-radius:999px;padding:10px 13px;font-weight:900}}.group{{background:#111827;border:1px solid #334155;border-radius:22px;padding:16px;margin:14px 0}}.group h2{{margin:0 0 12px;color:#dbeafe}}table{{width:100%;border-collapse:collapse;overflow:hidden;border-radius:14px}}th,td{{padding:10px;border-bottom:1px solid #334155;text-align:right;vertical-align:top}}th{{background:#0b1220;color:#bfdbfe}}td:last-child{{direction:ltr;text-align:left;color:#cbd5e1;word-break:break-word}}tr.pass td:first-child{{color:#bbf7d0;font-weight:900}}tr.warn td:first-child{{color:#fde68a;font-weight:900}}tr.fail td:first-child{{color:#fecaca;font-weight:900}}.note{{background:#0b1220;border:1px solid #334155;border-radius:16px;padding:13px;color:#cbd5e1;line-height:1.65}}@media(max-width:700px){{body{{padding:14px}}h1{{font-size:28px}}table,thead,tbody,tr,th,td{{display:block}}thead{{display:none}}tr{{border:1px solid #334155;border-radius:14px;margin:10px 0;padding:8px}}td{{border:0;padding:7px}}td:last-child{{text-align:right;direction:rtl}}}} </style></head><body><div class="wrap"><div class="hero"><a href="/admin">⬅ חזרה לפאנל</a><h1>🧪 בדיקה עצמית מלאה</h1><p>בדיקה לא־הרסנית של קוד, routes, טפסים, נתיבים, מדיה, מצגות, אבטחה ו־restart helper. היא לא מבצעת העלאות אמיתיות, לא מאתחלת את השרת ולא מכבה את המחשב.</p><span class="badge {badge_class}">{badge_text}</span><div class="actions"><a href="/admin/self-test">הרץ שוב</a><a href="/admin/self-test?conversion=1">בדיקה כולל סימולציית המרת תמונה</a><a href="/admin/self-test.json" target="_blank">JSON טכני</a><a href="/admin/status">סטטוס קבצים</a><a href="/admin/health">Health</a></div></div><div class="summary"><div class="card"><div>סה״כ בדיקות</div><div class="num">{payload['total']}</div></div><div class="card"><div>עברו</div><div class="num">{payload['passed']}</div></div><div class="card"><div>אזהרות</div><div class="num">{payload['warnings']}</div></div><div class="card"><div>כשלים</div><div class="num">{payload['failed']}</div></div><div class="card"><div>משך</div><div class="num">{payload['elapsed_seconds']}s</div></div></div><div class="note">גרסה: {html_lib.escape(payload['version'])}<br>נבדק: {html_lib.escape(payload['checked_at'])}</div>{''.join(groups_html)}</div></body></html> """ def health_payload(): helper_ok, helper_info = validate_restart_helper_source(build_restart_helper_code(ADMIN_SERVER_PATH, sys.executable or "python", os.path.dirname(os.path.abspath(ADMIN_SERVER_PATH)) or BASE_DIR)) return { "ok": True, "service": "TripServer Admin", "version": ADMIN_PANEL_VERSION, "started_at": ADMIN_STARTED_AT, "script": ADMIN_SERVER_PATH, "host": ADMIN_HOST, "port": ADMIN_PORT, "usa_image_keys": len(USA_IMAGE_KEYS), "japan_presentation_images": compact_japan_usage_for_health(), "power_actions_enabled": POWER_ACTIONS_ENABLED, "restart_helper_ok": helper_ok, "restart_helper_status": helper_info, "watchdog": watchdog_status(include_log=False, include_task=False), "logs": {key: {"path": meta["path"], "exists": os.path.exists(meta["path"])} for key, meta in LOG_FILE_REGISTRY.items()}, "media_engine": media_engine_status(), } def render_admin_health(): payload = health_payload() media = payload.get("media_engine", {}) pillow_ok = bool(media.get("pillow")) ffmpeg_ok = bool(media.get("ffmpeg")) ffmpeg_path = media.get("ffmpeg_path") or "לא נמצא" power_ok = bool(payload.get("power_actions_enabled")) japan_usage = payload.get("japan_presentation_images", {}) def badge(ok, text_ok="תקין", text_bad="חסר"): cls = "ok" if ok else "bad" txt = text_ok if ok else text_bad return f'<span class="badge {cls}">{txt}</span>' json_text = html_lib.escape(str(payload)).replace(", '", ",\n '").replace("{", "{\n ").replace("}", "\n}") return f""" <!DOCTYPE html> <html lang="he" dir="rtl"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>בדיקת מערכת TripServer</title> <style> :root{{--bg:#07111f;--panel:#101c31;--line:#253650;--muted:#a8b3c7;--blue:#60a5fa;--green:#22c55e;--red:#ef4444;--yellow:#fbbf24}} *{{box-sizing:border-box}}body{{margin:0;font-family:Arial,'Segoe UI',sans-serif;background:radial-gradient(circle at 80% -10%,rgba(59,130,246,.35),transparent 28%),linear-gradient(180deg,#07111f,#0f172a);color:white;min-height:100vh;padding:20px}} .wrap{{width:min(980px,94vw);margin:auto}}.hero{{background:linear-gradient(135deg,rgba(30,64,175,.9),rgba(15,23,42,.92));border:1px solid rgba(147,197,253,.25);border-radius:28px;padding:22px;box-shadow:0 25px 70px rgba(0,0,0,.32)}} h1{{margin:0 0 8px;font-size:clamp(28px,5vw,42px)}}p{{color:#dbeafe;line-height:1.65;margin:8px 0}}.actions{{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}}a{{display:inline-flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;border:1px solid rgba(148,163,184,.35);background:rgba(15,23,42,.65);border-radius:999px;padding:11px 15px;font-weight:900;color:white}} a:hover{{border-color:var(--blue);background:rgba(59,130,246,.22)}}.grid{{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;margin:18px 0}}.card{{background:rgba(16,28,49,.9);border:1px solid var(--line);border-radius:22px;padding:18px;box-shadow:0 16px 45px rgba(0,0,0,.22)}}.card h2{{margin:0 0 10px;font-size:22px}}.line{{display:flex;justify-content:space-between;gap:12px;border-top:1px solid #263852;padding-top:10px;margin-top:10px;color:#cbd5e1;direction:rtl}}.ltr{{direction:ltr;text-align:left;overflow-wrap:anywhere;color:#fde68a}}.badge{{display:inline-flex;border-radius:999px;padding:6px 10px;font-weight:900;font-size:13px}}.badge.ok{{background:rgba(22,101,52,.45);border:1px solid rgba(74,222,128,.45);color:#bbf7d0}}.badge.bad{{background:rgba(127,29,29,.45);border:1px solid rgba(248,113,113,.45);color:#fecaca}}.note{{background:rgba(15,23,42,.75);border:1px solid #334155;border-radius:18px;padding:14px;color:#cbd5e1;line-height:1.6}}pre{{white-space:pre-wrap;direction:ltr;text-align:left;background:#020617;color:#fde68a;border:1px solid #334155;border-radius:18px;padding:16px;overflow:auto;max-height:360px}} @media(max-width:760px){{body{{padding:14px}}.grid{{grid-template-columns:1fr}}.hero{{padding:18px;border-radius:24px}}.card{{padding:15px}}.line{{display:block}}.ltr{{margin-top:5px}}}} </style> </head> <body><div class="wrap"> <div class="hero"> <h1>🩺 בדיקת מערכת</h1> <p>זה מסך קריא לבני אדם. נקודת הקצה הטכנית עדיין קיימת ב־<span class="ltr">/health?json=1</span> למקרה שצריך בדיקה אוטומטית.</p> <div class="actions"><a href="/admin">⬅ חזרה לפאנל</a><a href="/admin/status">📊 סטטוס קבצים</a><a href="/health?json=1" target="_blank">JSON טכני</a><a href="/admin/self-test">🧪 בדיקה עצמית מלאה</a><a href="/admin/watchdog">🛡️ Watchdog</a><a href="/admin/logs">📜 לוגים</a></div> </div> <div class="grid"> <div class="card"><h2>מצב שרת</h2><div>{badge(True)}</div><div class="line"><b>גרסה</b><span>{html_lib.escape(ADMIN_PANEL_VERSION)}</span></div><div class="line"><b>עלה בתאריך</b><span>{html_lib.escape(ADMIN_STARTED_AT)}</span></div><div class="line"><b>פורט</b><span>{ADMIN_PORT}</span></div><div class="line"><b>Host</b><span>{html_lib.escape(ADMIN_HOST)}</span></div><div class="line"><b>Restart helper</b><span>{badge(payload.get("restart_helper_ok", False), "תקין", "תקול")}</span></div></div> <div class="card"><h2>מנוע מדיה</h2><div>{badge(pillow_ok, "Pillow תקין", "Pillow חסר")} {badge(ffmpeg_ok, "FFmpeg תקין", "FFmpeg חסר")}</div><div class="line"><b>תמונות</b><span>המרה ודחיסה ל־JPG</span></div><div class="line"><b>אודיו</b><span>המרה ל־MP3</span></div><div class="line"><b>FFmpeg</b><span class="ltr">{html_lib.escape(ffmpeg_path)}</span></div></div> <div class="card"><h2>פעולות מחשב</h2><div>{badge(power_ok, "פעיל", "כבוי")}</div><div class="line"><b>כיבוי / ריסטארט</b><span>{"מופעל בפאנל" if power_ok else "מנוטרל בהגדרות"}</span></div><div class="line"><b>הגנות</b><span>POST, רשת מקומית, אישור כתוב, טיימר וביטול</span></div></div> <div class="card"><h2>כיסוי מצגת ארה״ב</h2><div>{badge(len(USA_IMAGE_KEYS) == 44, "44 שקפים", str(len(USA_IMAGE_KEYS)) + " שקפים")}</div><div class="line"><b>תמונות שקפים</b><span>{len(USA_IMAGE_KEYS)}</span></div><div class="line"><b>העלאה</b><span>JPG/PNG/WebP וכו׳ → JPG דחוס</span></div></div> <div class="card"><h2>כיסוי מצגת יפן</h2><div>{badge(japan_usage.get('presentation_exists') and japan_usage.get('missing_external_images', 0) == 0, "תקין", "לבדיקה")}</div><div class="line"><b>תמונות בשימוש בפועל</b><span>{japan_usage.get('image_slides_total', 0)}</span></div><div class="line"><b>קבצים חיצוניים</b><span>{japan_usage.get('external_files_used', 0)}</span></div><div class="line"><b>תמונות מוטמעות</b><span>{japan_usage.get('embedded_images', 0)}</span></div><div class="line"><b>קבצים בתיקייה</b><span>{japan_usage.get('folder_images', 0)}</span></div><div class="line"><b>לא בשימוש / חסרות</b><span>{japan_usage.get('unused_folder_images', 0)} / {japan_usage.get('missing_external_images', 0)}</span></div></div> </div> <div class="note"><b>מה קרה קודם?</b><br>לחצן Health פתח את נקודת הקצה הטכנית והציג JSON שחור. זה לא כשל בשרת — להפך, לפי הנתונים אצלך FFmpeg ו־Pillow זמינים — אבל זה לא מתאים ככפתור במסך ראשי. בגרסה הזו הכפתור מוביל למסך בדיקה קריא, וה־JSON נשאר רק לבדיקה טכנית.</div> <h2>מידע טכני מלא</h2><pre>{json_text}</pre> </div></body></html> """ @app.route("/admin/self-test") def admin_self_test(): return render_admin_self_test() @app.route("/admin/self-test.json") def admin_self_test_json(): include_conversion = (request.args.get("conversion") == "1") return run_self_test_suite(include_media_conversion=include_conversion) @app.route("/health") def health(): accept = request.headers.get("Accept", "") if request.args.get("json") != "1" and "text/html" in accept: return redirect("/admin/health") return health_payload() @app.route("/health.json") def health_json(): return health_payload() @app.route("/admin/health") def admin_health(): return render_admin_health() @app.route("/admin/status") def admin_status(): return render_admin_status() @app.route("/admin/logs") def admin_logs(): return render_admin_logs() @app.route("/admin/logs.json") def admin_logs_json(): return build_logs_payload(request.args.get("lines", LOG_TAIL_DEFAULT_LINES)) @app.route("/camera/setup") def camera_setup_page(): allowed, reason = camera_access_allowed() if not allowed: return admin_status_page("גישה נחסמה", reason, ok=False, back_url="/admin/camera"), 403 return render_camera_setup_page() @app.route("/camera/setup/status.json") def camera_setup_status_json(): allowed, reason = camera_access_allowed() payload = camera_setup_status() payload["access_allowed"] = allowed payload["access_reason"] = reason return payload @app.route("/camera/setup/opencv", methods=["POST"]) def camera_setup_opencv(): allowed, reason = camera_access_allowed() if not allowed: return admin_status_page("התקנת OpenCV נחסמה", reason, ok=False, back_url="/camera/setup"), 403 confirm = (request.form.get("confirm") or "").strip() if confirm != CAMERA_SETUP_CONFIRM_TEXT: return admin_status_page("אישור חסר", f"כדי להתקין OpenCV צריך לכתוב בדיוק: {CAMERA_SETUP_CONFIRM_TEXT}", ok=False, back_url="/camera/setup"), 400 if camera_engine_status().get("opencv"): return admin_status_page("OpenCV כבר מותקן", "אין צורך להריץ התקנה נוספת. אפשר לחזור למסך המצלמה.", ok=True, back_url="/admin/camera") started, message = start_opencv_install_background(mode="manual") return admin_status_page("התקנת OpenCV התחילה" if started else "התקנת OpenCV כבר רצה", message + "\n\nפתח את מסך סטטוס רכיבי מצלמה כדי לעקוב אחרי הלוג.", ok=started, back_url="/camera/setup", details=str(camera_setup_status())) @app.route("/admin/camera") def admin_camera(): return render_admin_camera() @app.route("/camera/status.json") def camera_status_json(): allowed, reason = camera_access_allowed() payload = CAMERA_MANAGER.status() payload.update(camera_engine_status()) payload["access_allowed"] = allowed payload["access_reason"] = reason return payload @app.route("/camera/snapshot.jpg") def camera_snapshot(): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 if not camera_engine_status().get("opencv"): return "OpenCV לא מותקן. הרץ: py -m pip install opencv-python", 503 try: frame = CAMERA_MANAGER.read_jpeg() response = make_response(frame) response.headers["Content-Type"] = "image/jpeg" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" return response except Exception as exc: return f"שגיאת מצלמה: {exc}", 500 @app.route("/camera/stream") def camera_stream(): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 if not camera_engine_status().get("opencv"): return "OpenCV לא מותקן. הרץ: py -m pip install opencv-python", 503 return Response(camera_stream_generator(), mimetype="multipart/x-mixed-replace; boundary=frame", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "X-Accel-Buffering": "no"}) @app.route("/camera/stop", methods=["POST"]) def camera_stop(): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 CAMERA_MANAGER.stop() return action_page("מצלמה כובתה", "המצלמה שוחררה והזרם הופסק. אם נורית המצלמה עדיין דולקת, סגור גם את דף השידור בדפדפן ורענן.", "/admin/camera") @app.route("/camera/audio-stream") def camera_audio_stream(): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 if not find_ffmpeg_executable(): return "FFmpeg לא נמצא. נדרש FFmpeg כדי להזרים אודיו.", 503 return Response(CAMERA_AUDIO_STREAM.stream_chunks(), mimetype="audio/mpeg", headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "X-Accel-Buffering": "no"}) @app.route("/camera/audio-stop", methods=["POST"]) def camera_audio_stop(): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 CAMERA_AUDIO_STREAM.stop() return action_page("אודיו כובה", "שידור האודיו הופסק והמיקרופון שוחרר.", "/admin/camera") @app.route("/camera/record/start", methods=["POST"]) def camera_record_start(): allowed, reason = camera_access_allowed() if not allowed: return admin_status_page("הקלטה נחסמה", reason, ok=False), 403 try: with_audio = request.form.get("with_audio") == "1" target = CAMERA_RECORDER.start(with_audio=with_audio) return admin_status_page("הקלטה התחילה", f"ההקלטה נשמרת אל:\n{target}", ok=True, details=str(CAMERA_RECORDER.status())) except Exception as exc: CAMERA_RECORDER.last_error = str(exc) return admin_status_page("הקלטה לא התחילה", str(exc), ok=False, details=str(camera_av_status())), 500 @app.route("/camera/record/stop", methods=["POST"]) def camera_record_stop(): allowed, reason = camera_access_allowed() if not allowed: return admin_status_page("עצירת הקלטה נחסמה", reason, ok=False), 403 try: target = CAMERA_RECORDER.stop() return admin_status_page("הקלטה נעצרה", f"קובץ ההקלטה:\n{target or 'לא היה קובץ פעיל'}", ok=True, details=str(CAMERA_RECORDER.status()), back_url="/admin/camera") except Exception as exc: return admin_status_page("שגיאה בעצירת הקלטה", str(exc), ok=False, back_url="/admin/camera"), 500 @app.route("/camera/recordings") def camera_recordings_page(): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 rows = "".join( f"<div style='border-bottom:1px solid #334155;padding:12px 0'><b>{html_lib.escape(item['name'])}</b><br><span style='color:#94a3b8'>{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(item['mtime']))} · {format_bytes(item['size'])}</span><br><a href='/camera/recordings/{html_lib.escape(item['name'])}' target='_blank'>פתח/נגן</a></div>" for item in list_camera_recordings(100) ) or "<p>אין הקלטות.</p>" return f"""<!DOCTYPE html><html lang='he' dir='rtl'><head><meta charset='UTF-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>הקלטות מצלמה</title><style>body{{margin:0;background:#07111f;color:white;font-family:Arial;padding:18px}}.wrap{{max-width:900px;margin:auto}}a{{color:#93c5fd}}.card{{background:#111827;border:1px solid #334155;border-radius:22px;padding:18px}}</style></head><body><div class='wrap'><a href='/admin/camera'>⬅ חזרה למצלמה</a><h1>🎞️ הקלטות מצלמה</h1><div class='card'><p>תיקייה: <code>{html_lib.escape(CAMERA_RECORDINGS_DIR)}</code></p>{rows}</div></div></body></html>""" @app.route("/camera/recordings/<path:filename>") def camera_recording_file(filename): allowed, reason = camera_access_allowed() if not allowed: return reason, 403 base = os.path.basename(filename or "") if not base.lower().endswith(".mp4"): return "קובץ לא מורשה", 403 return send_from_directory(CAMERA_RECORDINGS_DIR, base, as_attachment=False) @app.route("/admin/watchdog") def admin_watchdog(): return render_admin_watchdog() @app.route("/watchdog/install", methods=["POST"]) def watchdog_install_route(): if not is_private_or_loopback_ip(get_client_ip()): return admin_status_page("התקנת Watchdog נחסמה", f"הבקשה הגיעה מכתובת לא מקומית/לא Tailscale: {get_client_ip()}", ok=False), 403 try: ok, message, details = install_watchdog_task() return admin_status_page("Watchdog הותקן" if ok else "Watchdog לא הותקן", message, ok=ok, details=details + "\n\n" + str(watchdog_status(include_log=False))) except Exception as exc: return admin_status_page("שגיאה בהתקנת Watchdog", "לא הצלחתי להתקין את שכבת ה־Watchdog.", ok=False, details=repr(exc)), 500 @app.route("/watchdog/uninstall", methods=["POST"]) def watchdog_uninstall_route(): if not is_private_or_loopback_ip(get_client_ip()): return admin_status_page("הסרת Watchdog נחסמה", f"הבקשה הגיעה מכתובת לא מקומית/לא Tailscale: {get_client_ip()}", ok=False), 403 try: ok, message, details = uninstall_watchdog_task() return admin_status_page("Watchdog הוסר" if ok else "Watchdog לא הוסר", message, ok=ok, details=details) except Exception as exc: return admin_status_page("שגיאה בהסרת Watchdog", "לא הצלחתי להסיר את משימת ה־Watchdog.", ok=False, details=repr(exc)), 500 @app.route("/watchdog/run-once", methods=["POST"]) def watchdog_run_once_route(): if not is_private_or_loopback_ip(get_client_ip()): return admin_status_page("הרצת Watchdog נחסמה", f"הבקשה הגיעה מכתובת לא מקומית/לא Tailscale: {get_client_ip()}", ok=False), 403 try: ok, message, details = run_watchdog_once_now() return admin_status_page("Watchdog הורץ" if ok else "Watchdog נכשל", message, ok=ok, details=details) except Exception as exc: return admin_status_page("שגיאה בהרצת Watchdog", "לא הצלחתי להריץ את ה־Watchdog.", ok=False, details=repr(exc)), 500 @app.route("/upload_admin_server", methods=["POST"]) def upload_admin_server(): file = request.files.get("file") if not file or file.filename == "": return admin_status_page("לא נבחר קובץ", "בחר קובץ admin_server.py להעלאה.", ok=False), 400 original_name = file.filename or "" if not original_name.lower().endswith(".py"): return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ Python עם סיומת .py", ok=False), 400 if request.content_length and request.content_length > ADMIN_MAX_UPLOAD_BYTES + 250000: return admin_status_page("קובץ גדול מדי", f"הקובץ גדול מהמותר. המגבלה היא בערך {ADMIN_MAX_UPLOAD_BYTES:,} bytes.", ok=False), 413 os.makedirs(ADMIN_UPLOAD_TMP_DIR, exist_ok=True) stamp = time.strftime("%Y%m%d_%H%M%S") tmp_path = os.path.join(ADMIN_UPLOAD_TMP_DIR, f"admin_server_upload_{stamp}.py") try: file.save(tmp_path) valid, details = validate_admin_server_upload(tmp_path) if not valid: try: os.remove(tmp_path) except Exception: pass return admin_status_page("העלאת admin_server.py נדחתה", "הקובץ הקיים לא הוחלף. נמצאה בעיה בקובץ שהעלית.", ok=False, details=details), 400 backup_path = backup_current_admin_server("admin_server_backup") os.replace(tmp_path, ADMIN_SERVER_PATH) new_sha = file_sha256(ADMIN_SERVER_PATH) message = ( "הקובץ החדש עבר בדיקת קומפילציה והחליף את admin_server.py הפעיל.\n" "נוצר גיבוי לקובץ הקודם.\n\n" "מנגנון אתחול אוטומטי הופעל עכשיו. רענן את הפאנל בעוד כמה שניות." ) details = f"קובץ פעיל:\n{ADMIN_SERVER_PATH}\n\nSHA256 חדש:\n{new_sha}\n\nגיבוי:\n{backup_path}\n\nלוג אתחול:\n{ADMIN_RESTART_LOG_PATH}" threading.Thread(target=restart_process_later, args=(backup_path, new_sha, "admin_upload"), daemon=True).start() return admin_status_page("admin_server.py עודכן והאתחול הופעל", message, ok=True, details=details) except Exception as exc: try: if os.path.exists(tmp_path): os.remove(tmp_path) except Exception: pass return admin_status_page("שגיאה בעדכון admin_server.py", "הקובץ הקיים לא הוחלף.", ok=False, details=repr(exc)), 500 @app.route("/restart_admin_server", methods=["POST"]) def restart_admin_server(): threading.Thread(target=restart_process_later, args=(None, None, "manual_panel_restart"), daemon=True).start() return admin_status_page( "הפאנל מופעל מחדש", "האיפוס התחיל דרך מנגנון Restart חיצוני. המתן 5–10 שניות ואז רענן את הפאנל. אם לא עולה, בדוק את C:\\TripServer\\admin_restart.log.", ok=True, ) @app.route("/restore_admin_server_backup", methods=["POST"]) def restore_admin_server_backup(): backup_path = latest_admin_backup() if not backup_path: return admin_status_page("אין גיבוי לשחזור", "לא נמצא קובץ גיבוי בתיקיית הגיבויים.", ok=False), 404 try: valid, details = validate_admin_server_upload(backup_path) if not valid: return admin_status_page("הגיבוי האחרון לא תקין", "הגיבוי לא שוחזר כי הוא לא עבר בדיקת תקינות.", ok=False, details=details), 400 safety_backup = backup_current_admin_server("admin_server_before_restore") shutil.copy2(backup_path, ADMIN_SERVER_PATH) msg = "הגיבוי האחרון שוחזר אל admin_server.py. לחץ בדיקת אתחול פאנל — מתקדם כדי להפעיל אותו." det = f"שוחזר מתוך:\n{backup_path}\n\nגיבוי בטיחות לגרסה שהייתה פעילה לפני השחזור:\n{safety_backup}" return admin_status_page("שחזור גיבוי הסתיים", msg, ok=True, details=det) except Exception as exc: return admin_status_page("שגיאה בשחזור גיבוי", "השחזור לא הושלם.", ok=False, details=repr(exc)), 500 @app.route("/upload/<folder>", methods=["POST"]) def upload(folder): if folder not in FOLDERS: return "Folder not found", 404 file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if folder in APP_FOLDERS and file_ext(file.filename) not in {".html", ".htm"}: return admin_status_page("קובץ לא תקין", "לאזור הזה ניתן להעלות רק קובץ HTML.", ok=False), 400 try: filename, result = save_uploaded_file(folder, file) details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}" if result.get("backup"): details += f"\n\nגיבוי קודם:\n{result['backup']}" return admin_status_page("העלאה הושלמה", f"הקובץ נשמר בהצלחה כ־{filename}. אם אתה רואה גרסה ישנה, פתח מחדש עם רענון מלא.", ok=True, details=details) except Exception as exc: return admin_status_page("שגיאה בהעלאה", "הקובץ לא נשמר. הקובץ הקודם נשאר במקומו אם היה קיים.", ok=False, details=repr(exc)), 500 @app.route("/upload_presentation/<presentation_key>", methods=["POST"]) def upload_presentation(presentation_key): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if file_ext(file.filename) not in {".html", ".htm"}: return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML של מצגת יפן.", ok=False), 400 saved = save_japan_presentation(presentation_key, file) if not saved: return "Presentation type not found", 404 filename, result = saved details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}" if result.get("backup"): details += f"\n\nגיבוי קודם:\n{result['backup']}" return admin_status_page("מצגת יפן הועלתה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details) @app.route("/upload_usa_presentation/<presentation_key>", methods=["POST"]) def upload_usa_presentation(presentation_key): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if file_ext(file.filename) not in {".html", ".htm"}: return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML של מצגת ארה״ב.", ok=False), 400 saved = save_usa_presentation(presentation_key, file) if not saved: return "USA presentation type not found", 404 filename, result = saved details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}" if result.get("backup"): details += f"\n\nגיבוי קודם:\n{result['backup']}" return admin_status_page("מצגת ארה״ב הועלתה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details) @app.route("/upload_usa_image", methods=["POST"]) def upload_usa_image(): image_key = request.form.get("image_key", "").strip() file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 try: result = save_usa_presentation_image(image_key, file) if not result: return "USA image key not found", 404 details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}\n\nמצב שמירה:\n{result['mode']}" if result.get("backup"): details += f"\n\nגיבוי קודם:\n{result['backup']}" details += "\n\n" + media_details_text(result.get("media")) return admin_status_page("תמונת מצגת ארה״ב הועלתה", f"התמונה נשמרה בשם הקבוע: {result['rel']}", ok=True, details=details) except Exception as exc: return admin_status_page("שגיאה בהעלאת תמונה", "התמונה לא נשמרה. להמרה ודחיסה של PNG/WebP/BMP/TIFF צריך Pillow בשרת. JPG אמיתי יתקבל גם בלי Pillow.", ok=False, details=repr(exc)), 400 @app.route("/upload_usa_images_zip", methods=["POST"]) def upload_usa_images_zip(): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 original_name = file.filename or "" if not original_name.lower().endswith(".zip"): return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ ZIP של תמונות מצגת.", ok=False), 400 if request.content_length and request.content_length > USA_IMAGES_ZIP_MAX_BYTES + 1024 * 1024: return admin_status_page("קובץ גדול מדי", f"קובץ התמונות גדול מהמגבלה: {USA_IMAGES_ZIP_MAX_BYTES:,} bytes.", ok=False), 413 try: imported, skipped, missing = save_usa_presentation_images_zip(file) details = "נשמרו בתיקיית התמונות:\n" + ("\n".join(imported) if imported else "לא נשמרו תמונות.") if skipped: details += "\n\nדולגו:\n" + "\n".join(skipped[:120]) if missing: details += "\n\nלא נמצאו ב־ZIP, ולכן לא הוחלפו:\n" + "\n".join(missing[:120]) return admin_status_page( "ZIP תמונות ארה״ב נטען", f"נשמרו {len(imported)} תמונות למצגת. קבצים קיימים באותו שם הוחלפו.", ok=True, details=details, ) except zipfile.BadZipFile: return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400 except ValueError as exc: return admin_status_page("ZIP לא תקין", "הקובץ לא עומד במגבלות העלאה של הפאנל.", ok=False, details=str(exc)), 400 except Exception as exc: return admin_status_page("שגיאה בהעלאת ZIP", "לא ניתן היה לעבד את קובץ התמונות.", ok=False, details=repr(exc)), 500 @app.route("/upload_usa_extra_html/<extra_key>", methods=["POST"]) def upload_usa_extra_html(extra_key): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if file_ext(file.filename) not in {".html", ".htm"}: return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק קובץ HTML.", ok=False), 400 saved = save_usa_extra_html(extra_key, file) if not saved: return "USA extra HTML type not found", 404 filename, result = saved details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}" if result.get("backup"): details += f"\n\nגיבוי קודם:\n{result['backup']}" return admin_status_page("קובץ ארה״ב נוסף הועלה", f"הקובץ נשמר בשם הקבוע: {filename}", ok=True, details=details) @app.route("/upload_japan_music/<music_key>", methods=["POST"]) def upload_japan_music(music_key): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024: return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413 result = save_japan_music(music_key, file) if not result: return "Japan music type not found", 404 details = "נשמר אל:\n" + "\n".join([item["path"] for item in result["targets"]]) + f"\n\nSHA256 MP3 סופי:\n{result['sha256']}" details += "\n\n" + media_details_text(result.get("media")) return admin_status_page("מוזיקת יפן הועלתה", "הקובץ נשמר בכל הנתיבים שהמצגת מחפשת כ־MP3 אמיתי.", ok=True, details=details) @app.route("/upload_japan_images_zip", methods=["POST"]) def upload_japan_images_zip(): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if not (file.filename or "").lower().endswith(".zip"): return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP תמונות יפן.", ok=False), 400 if request.content_length and request.content_length > JAPAN_IMAGES_ZIP_MAX_BYTES + 1024 * 1024: return admin_status_page("קובץ גדול מדי", f"קובץ התמונות גדול מהמגבלה: {JAPAN_IMAGES_ZIP_MAX_BYTES:,} bytes.", ok=False), 413 try: imported, skipped, overwritten = save_japan_images_zip(file) details = "יובאו:\n" + ("\n".join(imported[:180]) if imported else "לא יובאו תמונות.") if overwritten: details += "\n\nהוחלפו קבצים קיימים:\n" + "\n".join(overwritten[:120]) if skipped: details += "\n\nדולגו:\n" + "\n".join(skipped[:120]) return admin_status_page("ZIP תמונות יפן נטען", f"נשמרו {len(imported)} תמונות תחת JAPAN/media/images.", ok=True, details=details) except zipfile.BadZipFile: return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400 except Exception as exc: return admin_status_page("שגיאה בהעלאת ZIP יפן", "לא ניתן היה לעבד את קובץ התמונות.", ok=False, details=repr(exc)), 500 @app.route("/upload_trip_package/<trip>", methods=["POST"]) def upload_trip_package(trip): trip = (trip or "").upper() file = request.files.get("file") if trip not in {"USA", "JAPAN"}: return "Trip package type not found", 404 if not file or file.filename == "": return "No file selected", 400 if not (file.filename or "").lower().endswith(".zip"): return admin_status_page("קובץ לא תקין", "ניתן להעלות כאן רק ZIP מלא של הטיול.", ok=False), 400 if request.content_length and request.content_length > TRIP_PACKAGE_ZIP_MAX_BYTES + 1024 * 1024: return admin_status_page("קובץ גדול מדי", f"ה־ZIP גדול מהמגבלה: {TRIP_PACKAGE_ZIP_MAX_BYTES:,} bytes.", ok=False), 413 try: imported, skipped, overwritten = save_trip_package_zip(trip, file) details = "יובאו:\n" + ("\n".join(imported[:200]) if imported else "לא יובאו קבצים.") if overwritten: details += "\n\nהוחלפו קבצים קיימים:\n" + "\n".join(overwritten[:140]) if skipped: details += "\n\nדולגו:\n" + "\n".join(skipped[:140]) return admin_status_page(f"ZIP מלא של {trip} נטען", f"נשמרו {len(imported)} קבצים בתיקיית {trip}.", ok=True, details=details) except zipfile.BadZipFile: return admin_status_page("ZIP לא תקין", "הקובץ שהועלה אינו ZIP תקין או שלא ניתן לפתוח אותו.", ok=False), 400 except Exception as exc: return admin_status_page("שגיאה בהעלאת ZIP מלא", "לא ניתן היה לעבד את חבילת הטיול.", ok=False, details=repr(exc)), 500 @app.route("/upload_usa_music/<music_key>", methods=["POST"]) def upload_usa_music(music_key): file = request.files.get("file") if not file or file.filename == "": return "No file selected", 400 if request.content_length and request.content_length > AUDIO_UPLOAD_MAX_BYTES + 1024 * 1024: return admin_status_page("קובץ מוזיקה גדול מדי", f"קובץ האודיו גדול מהמגבלה: {format_bytes(AUDIO_UPLOAD_MAX_BYTES)}.", ok=False), 413 saved = save_usa_music(music_key, file) if not saved: return "USA music type not found", 404 filename, result = saved details = f"יעד:\n{result['path']}\n\nגודל:\n{result['size']:,} bytes\n\nSHA256:\n{result['sha256']}" if result.get("backup"): details += f"\n\nגיבוי קודם:\n{result['backup']}" details += "\n\n" + media_details_text(result.get("media")) return admin_status_page("מוזיקת ארה״ב הועלתה", f"הקובץ נשמר בנתיב הקבוע: {filename}", ok=True, details=details) @app.route("/power/<action>", methods=["POST"]) def power_action(action): action = (action or "").strip().lower() if action not in POWER_CONFIRM_TEXT: return admin_status_page("פעולת מחשב לא מוכרת", "הפעולה המבוקשת אינה קיימת.", ok=False), 404 allowed, reason = power_action_allowed() if not allowed: return admin_status_page("פעולת מחשב נחסמה", reason, ok=False), 403 if request.form.get("ack") != "1": return admin_status_page("חסר אישור פעולה", "כדי למנוע לחיצה בטעות, צריך לסמן שאתה מבין את המשמעות של הפעולה.", ok=False), 400 expected = POWER_CONFIRM_TEXT[action] confirm_text = (request.form.get("confirm_text") or "").strip() if confirm_text != expected: return admin_status_page("מילת האישור לא נכונה", f"כדי לבצע את הפעולה צריך להקליד בדיוק: {expected}", ok=False), 400 delay = normalize_power_delay(request.form.get("delay")) try: label, final_delay, output = run_power_command(action, delay) details = f"פעולה:\n{label}\n\nתבוצע בעוד:\n{final_delay} שניות\n\nכתובת מבקשת:\n{get_client_ip()}\n\nפלט Windows:\n{output or 'OK'}" return admin_status_page("פעולת מחשב נקבעה", f"{label} נקבע בהצלחה. אפשר לבטל לפני סוף הטיימר דרך כפתור הביטול בפאנל.", ok=True, details=details) except Exception as exc: return admin_status_page("שגיאה בהפעלת פקודת מחשב", "Windows לא קיבל את פקודת הכיבוי/ריסטארט.", ok=False, details=repr(exc)), 500 @app.route("/power/cancel", methods=["POST"]) def power_cancel(): allowed, reason = power_action_allowed() if not allowed: return admin_status_page("ביטול פעולת מחשב נחסם", reason, ok=False), 403 rc, output = cancel_power_command() ok = (rc == 0) title = "פעולת מחשב מתוזמנת בוטלה" if ok else "לא נמצאה פעולה מתוזמנת לביטול" msg = "אם היה כיבוי/ריסטארט בהמתנה — הוא בוטל." if ok else "Windows החזיר שאין כרגע כיבוי/ריסטארט בהמתנה, או שלא ניתן לבטל." return admin_status_page(title, msg, ok=ok, details=output or f"return code: {rc}") @app.route("/JAPAN") @app.route("/JAPAN/") def japan(): return serve_app("JAPAN") @app.route("/USA") @app.route("/USA/") def usa(): return serve_app("USA") @app.route("/Moneyapp") @app.route("/Moneyapp/") def moneyapp(): return serve_app("Moneyapp") @app.route("/MoneyHome") @app.route("/MoneyHome/") def moneyhome(): return serve_app("MoneyHome") @app.route("/JAPAN/<path:path>") def japan_static(path): return serve_file_no_cache(FOLDERS["JAPAN"], path) @app.route("/USA/<path:path>") def usa_static(path): return serve_file_no_cache(FOLDERS["USA"], path) @app.route("/Moneyapp/<path:path>") def money_static(path): return serve_file_no_cache(FOLDERS["Moneyapp"], path) @app.route("/TripForm") @app.route("/TripForm/") def tripform(): return serve_app("TripForm") @app.route("/MoneyHome/<path:path>") def moneyhome_static(path): return serve_file_no_cache(FOLDERS["MoneyHome"], path) @app.route("/TripForm/<path:path>") def tripform_static(path): return serve_file_no_cache(FOLDERS["TripForm"], path) if __name__ == "__main__": maybe_start_camera_auto_install("admin-startup") app.run(host=ADMIN_HOST, port=ADMIN_PORT, threaded=True, use_reloader=False)
שמור קובץ