⬅ חזרה לתיקייה
✏️ עריכת admin_server_backup_20260701_165955.py
C:\TripServer\backups\admin_server\admin_server_backup_20260701_165955.py
# -*- coding: utf-8 -*- from flask import Flask, request, redirect, send_from_directory, make_response from werkzeug.utils import secure_filename import os import time import sys 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 v9.0 hierarchical app + smart media optimizer" 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") # 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": "ריסטארט למחשב", } 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 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_img_count = count_files(os.path.join(FOLDERS["JAPAN"], "media", "images"), JAPAN_IMAGE_EXTENSIONS) 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}} </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><div class="card"><div>תמונות מצגת יפן</div><div class="num">{japan_img_count}</div></div><div class="card"><div>גרסת פאנל</div><div>{html_lib.escape(ADMIN_PANEL_VERSION)}</div></div></div><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"] 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}} 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> </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 write_restart_helper(script_path, python_exe, work_dir): helper_code = f"""# -*- coding: utf-8 -*- import os import subprocess import time import socket script_path = {script_path!r} python_exe = {python_exe!r} work_dir = {work_dir!r} log_path = {ADMIN_RESTART_LOG_PATH!r} admin_port = {ADMIN_PORT!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") + " | helper | " + str(message) + " ") except Exception: pass def wait_for_port(port, timeout=14): deadline = time.time() + timeout last_error = None while time.time() < deadline: try: with socket.create_connection(("127.0.0.1", int(port)), timeout=1.0): return True, "OK" except Exception as exc: last_error = repr(exc) time.sleep(0.7) return False, last_error or "timeout" time.sleep(2.5) try: flags = 0 if os.name == "nt": flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "DETACHED_PROCESS", 0) 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") + " | helper | starting new server: " + script_path + " ") 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"), ) log_file.write(time.strftime("%Y-%m-%d %H:%M:%S") + f" | helper | new server pid={{proc.pid}} ") log_file.flush() ok, info = wait_for_port(admin_port) log("health port check " + ("OK" if ok else "FAILED") + ": " + str(info)) except Exception as exc: log("failed to start new server: " + repr(exc)) """ ensure_parent_dir(ADMIN_RESTART_HELPER_PATH) with open(ADMIN_RESTART_HELPER_PATH, "w", encoding="utf-8") as fh: fh.write(helper_code) return ADMIN_RESTART_HELPER_PATH def restart_process_later(): """Reliable self-restart for Windows/iPhone admin workflow.""" 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"restart requested | version={ADMIN_PANEL_VERSION} | script={script_path} | python={python_exe} | argv={sys.argv}") helper_path = write_restart_helper(script_path, python_exe, work_dir) 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("restart helper launched: " + helper_path) os._exit(0) except Exception as exc: log_admin_restart("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 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]) return ip.is_loopback or ip.is_private 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 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 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(4,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="/health" target="_blank">🩺 Health</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> <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 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>הפאנל מסנן קובצי תמונה נתמכים. בחבילות/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-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></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><p>גיבויים: 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','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> """ @app.route("/health") def health(): 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), "power_actions_enabled": POWER_ACTIONS_ENABLED, "media_engine": media_engine_status()} @app.route("/admin/status") def admin_status(): return render_admin_status() @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, 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, 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__": app.run(host=ADMIN_HOST, port=ADMIN_PORT, threaded=True, use_reloader=False)
שמור קובץ