import os
import subprocess
import shutil
import time
import zipfile
import json
import getpass
import logging
import re
import unicodedata
from typing import List, Tuple, Dict, Optional, Any
from datetime import datetime
from functools import wraps
import socket
import urllib.request

import psutil

from flask import (
    Flask, render_template, request, redirect, url_for,
    flash, send_file, jsonify, session
)

from passlib.apache import HtpasswdFile
from passlib.hash import sha512_crypt

# -----------------------------
# 내부 모듈 (중복 제거)
# -----------------------------
import modules.share as share
import modules.user as user
import modules.backup as backup
from modules import disk, install, nginx_webdav, nginx_user
from modules.disk import get_disks

from modules.share import (
    set_default_share,
    get_default_share,
    get_default_share_path,
    add_share,
    update_share,
    remove_share,
    list_share,
    load_shares,
    update_htpasswd_for_share,
    sync_nginx_conf,
    sync_samba_conf,
)
from modules.user import list_users

# timeshift policy 별칭(원래 코드 유지)
ts_policy = backup

# -----------------------------
# Flask App
# -----------------------------
app = Flask(__name__, static_url_path='/css', static_folder='/var/www/html/css')
app.secret_key = os.environ.get("REALCOM_NAS_UI_SECRET_KEY", "realnas-nas-ui-super-secret-key-0725")

# -----------------------------
# Path / Config
# -----------------------------
# Ctrl+F: BASE_DIR =
BASE_DIR = "/opt/realnas-nas"

# Ctrl+F: SHARES_JSON
SHARES_JSON = os.path.join(BASE_DIR, "nas_shares.json")
USERS_JSON = os.path.join(BASE_DIR, "nas_users.json")

# Ctrl+F: HTPASSWD_FILE =
HTPASSWD_FILE = "/etc/nginx/.htpasswd"

# Ctrl+F: NGINX_CONF_PATH
NGINX_CONF_PATH = os.path.join(BASE_DIR, "nas_shares.conf")

# Ctrl+F: DEFAULT_SHARE_PATH_FILE
DEFAULT_SHARE_PATH_FILE = os.path.join(BASE_DIR, "nas_default_share")

DDNS_CONFIG_PATH = "/etc/nas_ddns.json"

SHARE_OWNER = "realnas"
SHARE_GROUP = "realnas"
PERM_DIR = 0o755
PERM_FILE = 0o644

SAMBA_CONF_PATH = "/etc/samba/nas_smb.conf"
MOUNT_ROOT = "/mnt"


def login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session:
            flash("로그인이 필요합니다.", "danger")
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function


def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session:
            flash("로그인이 필요합니다.", "danger")
            return redirect(url_for('login'))
        if session.get('username') != 'admin':
            flash("관리자만 접근 가능합니다.", "danger")
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function



def list_nginx_users():
    users = []
    if os.path.exists(HTPASSWD_FILE):
        with open(HTPASSWD_FILE, 'r') as f:
            for line in f:
                if ':' in line:
                    users.append(line.split(':')[0])
    return users


import subprocess
import pwd

def ensure_system_user(username):
    try:
        pwd.getpwnam(username)
    except KeyError:
        subprocess.run(['sudo', 'useradd', '-M', username], check=True)

def apply_folder_permissions(folder_path, allowed_users, share_name):
    group_name = f"share_{share_name.lower()}"

    try:
        subprocess.run(['sudo', 'groupadd', group_name], check=False)

        for user in allowed_users:
            ensure_system_user(user)  # 사용자 존재 보장
            subprocess.run(['sudo', 'usermod', '-aG', group_name, user], check=True)

        subprocess.run(['sudo', 'chown', '-R', f':{group_name}', folder_path], check=True)
        subprocess.run(['sudo', 'chmod', '-R', '777', folder_path], check=True)

        print(f"[INFO] 폴더 권한 적용 완료: {folder_path} (그룹: {group_name})")
        return True

    except subprocess.CalledProcessError as e:
        print(f"[ERROR] 폴더 권한 설정 실패: {e}")
        return False

def apply_acl(path, read_users, write_users, guestok):
    try:
        subprocess.run(['setfacl', '-bR', path], check=True)
        subprocess.run(['chown', '-R', f"{SHARE_OWNER}:{SHARE_GROUP}", path], check=True)
        subprocess.run(['chmod', '-R', '777', path], check=True)

        # write_users 권한 먼저 적용 후, read_users는 write_users에 없는 사용자만 적용
        for u in write_users:
            subprocess.run(['setfacl', '-m', f'u:{u}:rwx', path], check=True)
        for u in read_users:
            if u not in write_users:
                subprocess.run(['setfacl', '-m', f'u:{u}:r-x', path], check=True)
        if guestok:
            subprocess.run(['setfacl', '-m', 'o::r-x', path], check=True)
        else:
            subprocess.run(['setfacl', '-m', 'o::-', path], check=True)
    except subprocess.CalledProcessError as e:
        print(f"ACL 설정 중 오류 발생: {e}")
        # 필요하면 예외를 다시 raise 하거나 로그 남기기

def set_default_share_path(path):
    with open(DEFAULT_SHARE_PATH_FILE, 'w', encoding='utf-8') as f:
        f.write(path.strip() if path else "")



def get_default_share_path():
    if os.path.exists(DEFAULT_SHARE_PATH_FILE):
        with open(DEFAULT_SHARE_PATH_FILE, 'r', encoding='utf-8') as f:
            return f.read().strip()
    return None


@app.route('/share/update_permissions', methods=['POST'])
def update_share_permissions():
    try:
        data = request.get_json()
        share_name = data.get('share_name')
        allowed_users = data.get('allowed_users', [])
        # 실제 권한 수정 함수 불러서 처리
        ok, msg = update_share(share_name, allowed_users)
        return jsonify({'status': 'success' if ok else 'fail', 'message': msg})
    except Exception as e:
        print(f"[ERROR] 권한변경 실패: {e}")
        return jsonify({'status': 'fail', 'message': str(e)}), 500


def is_real_data_mount(part):
    # /media/realnas 하위만! + 시스템/loop/ram/snap/efi 등은 무시
    if not part.mountpoint.startswith(MOUNT_ROOT + '/'):
        return False
    # /dev/loop, /dev/ram, /dev/sr, /dev/mapper, /snap 등은 제외
    if re.search(r'/dev/(loop|ram|sr|mapper|zram|dm-)', part.device):
        return False
    # 마운트포인트에 /boot, /efi 등도 제외
    if part.mountpoint in ['/', '/boot', '/boot/efi', '/snap', '/tmp', '/dev', '/run', '/proc', '/sys']:
        return False
    return True

def get_nas_data_mounts():
    seen = set()
    mounts = []
    for part in psutil.disk_partitions(all=True):
        if part.device in seen:
            continue
        seen.add(part.device)
        if is_real_data_mount(part):
            mounts.append({
                "name": part.device.split('/')[-1],
                "device": part.device,
                "mountpoint": part.mountpoint
            })
    return mounts

if __name__ == "__main__":
    for m in get_nas_data_mounts():
        print(m)


@app.route('/crowdsec/unban', methods=['POST'])
def crowdsec_unban():
    ips_to_unban = request.form.getlist('ips_to_unban')  # 리스트로 받음
    if not ips_to_unban:
        flash('차단 해제할 IP가 없습니다.', 'danger')
        return redirect(url_for('crowdsec_blocks'))

    success_count = 0
    for ip in ips_to_unban:
        # 실제 해제 로직, 아래는 예시
        ret = your_unban_function(ip)  # 반드시 구현 필요
        if ret:
            success_count += 1

    if success_count == len(ips_to_unban):
        flash(f'선택한 IP {success_count}개 모두 차단 해제 성공', 'success')
    elif success_count > 0:
        flash(f'선택한 IP 중 일부({success_count}개) 차단 해제 성공', 'warning')
    else:
        flash('차단 해제 실패', 'danger')

    return redirect(url_for('crowdsec_blocks'))

def your_unban_function(ip):
    out = run_cscli_command(["decisions", "delete", "--ip", ip])
    return out is not None


def run_cscli_command(args):
    try:
        result = subprocess.run(['cscli'] + args, capture_output=True, text=True, check=True)
        return result.stdout
    except subprocess.CalledProcessError as e:
        print(f"[ERROR] cscli 명령 실패: {e}")
        return None

# 시나리오 이름별 한글 설명 매핑
SCENARIO_KR_DESCRIPTIONS = {
    "crowdsecurity/CVE-2022-26134": "CVE-2022-26134 취약점 탐지",
    "crowdsecurity/CVE-2022-35914": "CVE-2022-35914 취약점 탐지",
    "crowdsecurity/CVE-2022-37042": "CVE-2022-37042 취약점 탐지",
    "crowdsecurity/CVE-2022-40684": "CVE-2022-40684 취약점 공격 시도 탐지",
    "crowdsecurity/CVE-2022-41082": "CVE-2022-41082 취약점 탐지",
    "crowdsecurity/CVE-2022-41697": "CVE-2022-41697 정보 탐지",
    "crowdsecurity/CVE-2022-42889": "CVE-2022-42889 취약점 탐지 (Text4Shell)",
    "crowdsecurity/CVE-2022-44877": "CVE-2022-44877 취약점 탐지",
    "crowdsecurity/CVE-2022-46169": "CVE-2022-46169 무차별 대입 공격 탐지",
    "crowdsecurity/apache_log4j2_cve-2021-44228": "CVE-2021-44228(Log4j2) 취약점 탐지",
    "crowdsecurity/f5-big-ip-cve-2020-5902": "CVE-2020-5902 취약점 탐지 (F5 Big-IP)",
    "crowdsecurity/fortinet-cve-2018-13379": "CVE-2018-13379 취약점 탐지 (Fortinet)",
    "crowdsecurity/grafana-cve-2021-43798": "CVE-2021-43798 취약점 탐지 (Grafana)",
    "crowdsecurity/http-backdoors-attempts": "공통 백도어 접근 시도 탐지",
    "crowdsecurity/http-bad-user-agent": "비정상 사용자 에이전트 탐지",
    "crowdsecurity/http-crawl-non_statics": "비정상 크롤링 탐지",
    "crowdsecurity/http-cve-2021-41773": "CVE-2021-41773 취약점 탐지",
    "crowdsecurity/http-cve-2021-42013": "CVE-2021-42013 취약점 탐지",
    "crowdsecurity/http-generic-bf": "일반 HTTP 무차별 대입 공격 탐지",
    "crowdsecurity/http-open-proxy": "오픈 프록시 탐지",
    "crowdsecurity/http-path-traversal-probing": "경로 탐색 공격 시도 탐지",
    "crowdsecurity/http-probing": "사이트 스캔 및 탐색 탐지",
    "crowdsecurity/http-sensitive-files": "민감 파일 및 폴더 접근 시도 탐지",
    "crowdsecurity/http-sqli-probing": "SQL 인젝션 공격 탐지",
    "crowdsecurity/http-xss-probing": "XSS 공격 탐지",
    "crowdsecurity/jira_cve-2021-26086": "Jira CVE-2021-26086 취약점 공격 탐지",
    "crowdsecurity/nginx-req-limit-exceeded": "Nginx 요청 제한 위반 탐지",
    "crowdsecurity/pulse-secure-sslvpn-cve-2019-11510": "CVE-2019-11510 취약점 탐지 (Pulse Secure SSLVPN)",
    "crowdsecurity/spring4shell_cve-2022-22965": "CVE-2022-22965 취약점 탐지 (Spring4Shell)",
    "crowdsecurity/ssh-bf": "SSH 무차별 대입 공격 탐지",
    "crowdsecurity/ssh-slow-bf": "SSH 느린 무차별 대입 공격 탐지",
    "crowdsecurity/thinkphp-cve-2018-20062": "CVE-2018-20062 취약점 탐지 (ThinkPHP)",
    "crowdsecurity/vmware-cve-2022-22954": "CVE-2022-22954 취약점 탐지 (VMware)",
    "crowdsecurity/vmware-vcenter-vmsa-2021-0027": "VMSA-2021-0027 취약점 탐지 (VMware vCenter)",
    "ltsich/http-w00tw00t": "w00tw00t 악성 탐지",
}
@app.route('/crowdsec/scenarios', methods=['GET'])
def crowdsec_scenarios():
    installed_scenarios = set()
    try:
        scenarios_json = run_cscli_command(['scenarios', 'list', '-o', 'json'])
        if scenarios_json:
            data = json.loads(scenarios_json)
            for s in data.get("scenarios", []):
                installed_scenarios.add(s.get("name"))
    except Exception as e:
        flash(f"cscli scenarios 조회 실패: {e}")

    scenarios = []
    # SCENARIO_KR_DESCRIPTIONS는 전체 시나리오 한글 설명 딕셔너리
    for name, description in SCENARIO_KR_DESCRIPTIONS.items():
        scenarios.append({
            "name": name,
            "description": description,
            "installed": name in installed_scenarios
        })

    return render_template('crowdsec_scenarios.html',
                           scenarios=scenarios)



@app.route('/crowdsec/scenarios/toggle', methods=['POST'])
def crowdsec_toggle_scenario():
    scenario_name = request.form.get('scenario_name')
    action = request.form.get('action')  # 'install' 또는 'remove'

    if not scenario_name or action not in ['install', 'remove']:
        flash("잘못된 요청입니다.")
        return redirect(url_for('crowdsec_scenarios'))

    try:
        if action == 'install':
            subprocess.run(['cscli', 'scenarios', 'install', scenario_name], check=True)
            flash(f"시나리오 '{scenario_name}' 설치 완료")
        else:
            subprocess.run(['cscli', 'scenarios', 'remove', scenario_name], check=True)
            flash(f"시나리오 '{scenario_name}' 삭제 완료")
    except subprocess.CalledProcessError as e:
        flash(f"시나리오 {action} 실패: {e}")

    # 설치/삭제 후 crowdsec 서비스 재시작 (필요하면)
    try:
        subprocess.run(['sudo', 'systemctl', 'restart', 'crowdsec'], check=True)
    except Exception:
        pass

    return redirect(url_for('crowdsec_scenarios'))



def get_crowdsec_metrics():
    metrics = {
        "total_events": 0,
        "active_blocks": 0,
        "scenario_count": 0,
        "alerts_24h": 0,
    }
    try:
        result = subprocess.run(['cscli', 'decisions', 'list', '-o', 'json'], capture_output=True, text=True, check=True)
        decisions_data = json.loads(result.stdout)
        if isinstance(decisions_data, dict):
            decisions = decisions_data.get("decisions", [])
        else:
            decisions = decisions_data
        metrics["total_events"] = len(decisions)
        metrics["active_blocks"] = sum(1 for d in decisions if d.get("type") == "ban")
    except Exception as e:
        print(f"[ERROR] cscli decisions 조회 실패: {e}")

    try:
        result = subprocess.run(['cscli', 'scenarios', 'list', '-o', 'json'], capture_output=True, text=True, check=True)
        scenarios_json = json.loads(result.stdout)
        scenarios = scenarios_json.get("scenarios", [])
        metrics["scenario_count"] = len(scenarios)
    except Exception as e:
        print(f"[ERROR] cscli scenarios 조회 실패: {e}")

    # TODO: alerts_24h 는 로그파일 또는 별도 API 분석 구현 필요
    metrics["alerts_24h"] = 0

    return metrics
@app.route('/crowdsec')
def crowdsec_dashboard():
    metrics_output = run_cscli_command(['metrics'])
    blocks_output = run_cscli_command(['decisions', 'list', '-o', 'json'])
    scenarios_output = run_cscli_command(['scenarios', 'list', '-o', 'json'])

    try:
        metrics = json.loads(metrics_output)
    except Exception:
        metrics = {
            "total_events": 0,
            "active_blocks": 0,
            "scenario_count": 0,
            "alerts_24h": 0,
        }

    try:
        blocks = json.loads(blocks_output) if blocks_output else []
        if blocks is None:
            blocks = []
        if isinstance(blocks, dict) and "decisions" in blocks:
            blocks = blocks["decisions"]
    except Exception as e:
        print(f"[ERROR] 차단 목록 JSON 파싱 실패: {e}")
        blocks = []

    try:
        scenarios = json.loads(scenarios_output).get("scenarios", []) if scenarios_output else []
        for s in scenarios:
            s['enabled'] = (s.get('status', '').lower() == 'enabled')
    except Exception as e:
        print(f"[ERROR] 시나리오 목록 JSON 파싱 실패: {e}")
        scenarios = []

    return render_template('crowdsec.html',
                           metrics=metrics,
                           blocks=blocks,
                           scenarios=scenarios,
                           SCENARIO_KR_DESCRIPTIONS=SCENARIO_KR_DESCRIPTIONS)


@app.route('/crowdsec/blocks')
def crowdsec_blocks():
    blocks = []
    try:
        # cscli decisions list -o json -t ban 실행
        result = subprocess.run(['cscli', 'decisions', 'list', '-o', 'json', '-t', 'ban'],
                                capture_output=True, text=True, check=True)
        data = json.loads(result.stdout)

        # data가 dict 형태면 "decisions" 키 확인
        decisions = data.get("decisions") if isinstance(data, dict) else data

        if decisions and isinstance(decisions, list):
            for d in decisions:
                ip = d.get("value", "N/A")
                reason = d.get("scenario", "N/A")
                start_time = d.get("start_at", d.get("start_time", "N/A"))
                end_time = d.get("stop_at", d.get("end_time", "N/A"))
                blocks.append({
                    "ip": ip,
                    "reason": reason,
                    "start_time": start_time,
                    "end_time": end_time,
                })
        else:
            print("[WARN] cscli decisions 결과에 차단 목록이 없습니다.")

    except Exception as e:
        print(f"[ERROR] cscli 차단 IP 조회 실패: {e}")

    return render_template('crowdsec_blocks.html', blocks=blocks)



@app.route('/crowdsec/logs')
def crowdsec_logs():
    import subprocess, json, datetime
    query = request.args.get('q', '').strip()
    logs = []

    try:
        # 제한은 100개, 검색어(query)가 있으면 grep 같은 필터를 직접 해도 되고,
        # 간단하게 먼저 전체 불러와서 파이썬에서 필터링하는 방법도 있음
        result = subprocess.run(['cscli', 'alerts', 'list', '-o', 'json', '-l', '100'], capture_output=True, text=True, check=True)
        alerts = json.loads(result.stdout)

        for alert in alerts:
            ip = alert.get('source', {}).get('ip', '')
            message = alert.get('scenario', {}).get('name', '')
            time_str = alert.get('time') or alert.get('timestamp') or ''
            if time_str:
                # UTC 문자열일 경우 필요하면 변환 가능
                try:
                    dt = datetime.datetime.fromisoformat(time_str.replace('Z', '+00:00'))
                    time_str = dt.strftime('%Y-%m-%d %H:%M:%S')
                except:
                    pass

            # 검색어가 없거나, IP나 메시지에 포함되면 출력
            if not query or (query.lower() in ip.lower() or query.lower() in message.lower()):
                logs.append({'time': time_str, 'ip': ip, 'message': message})

    except Exception as e:
        print(f"[ERROR] cscli alerts list 실패: {e}")

    return render_template('crowdsec_logs.html', logs=logs)

# 경로/파일: app.py (또는 users 라우트가 있는 파일)
# Ctrl+F: @app.route('/users'

from modules import samba_conf  # ✅ 상단 import 근처에 추가되어 있어야 함

@app.route('/users', methods=['GET', 'POST'])
def users():
    if request.method == 'POST':
        action = request.form.get('action')
        username = (request.form.get('username') or '').strip()

        changed_membership = False  # add/remove 성공 시 True

        if action == 'add':
            password = (request.form.get('password') or '').strip()
            if not username or not password:
                flash('이름과 비밀번호를 모두 입력하세요.', 'danger')
            else:
                success = user.add_user(username, password)
                if success:
                    changed_membership = True
                    flash(f"사용자 '{username}' 추가 완료", 'success')
                else:
                    flash(f"사용자 '{username}' 추가 실패 또는 이미 존재함", 'danger')

        elif action == 'change_password':
            new_password = (request.form.get('new_password') or '').strip()
            if not username or not new_password:
                flash('사용자와 새 비밀번호를 모두 입력하세요.', 'danger')
            else:
                success = user.change_password(username, new_password)
                if success:
                    flash(f"사용자 '{username}' 비밀번호 변경 완료", 'success')
                else:
                    flash(f"사용자 '{username}' 비밀번호 변경 실패", 'danger')

        elif action == 'remove':
            if not username:
                flash('삭제할 사용자를 선택하세요.', 'danger')
            else:
                success = user.remove_user(username)
                if success:
                    changed_membership = True
                    flash(f"사용자 '{username}' 삭제 완료", 'success')
                else:
                    flash(f"사용자 '{username}' 삭제 실패", 'danger')

        # ✅ add/remove가 성공했으면 samba valid users 자동 갱신
        if changed_membership:
            try:
                users_list = user.list_users()

                # 정책: realnas은 항상 포함
                if "realnas" not in users_list:
                    users_list = ["realnas"] + users_list

                ok, msg = samba_conf.update_storage_valid_users(users_list)

                # update 함수 자체가 ok/msg 반환하는 형태가 아니면 아래처럼 처리해도 됨
                # samba_conf.update_storage_valid_users(users_list)
                # ok, msg = True, "ok"

                chk_ok, out = samba_conf.testparm_check()
                if not chk_ok:
                    flash("Samba 설정 반영 실패(testparm 오류)", "danger")
                else:
                    samba_conf.restart_samba()
                    flash("Samba 설정 반영 완료", "success")

            except Exception as e:
                # 여기서 앱이 죽으면 안 됨
                flash(f"Samba 설정 반영 실패: {e}", "danger")

        return redirect(url_for('users'))

    users_list = user.list_users()
    return render_template('users.html', users=users_list)


def save_shares(shares):
    with open(SHARES_JSON, 'w', encoding='utf-8') as f:
        json.dump(shares, f, indent=2, ensure_ascii=False)


def update_share(name, allowed_users):
    shares = load_shares()
    found = False
    for share in shares:
        if share['name'] == name:
            share['allowed_users'] = allowed_users
            found = True
            break
    if not found:
        return False, "공유폴더를 찾을 수 없습니다."
    save_shares(shares)

    update_htpasswd_for_share(name, allowed_users)
    sync_nginx_conf()
    sync_samba_conf()

    return True, f"공유폴더 '{name}' 수정 완료"


        

def get_mounted_paths():
    """실제 디스크 마운트 경로 목록 반환 (/proc/mounts 기준)"""
    paths = []
    try:
        with open('/proc/mounts', 'r') as f:
            for line in f:
                parts = line.split()
                device, mountpoint = parts[0], parts[1]
                if device.startswith('/dev/'):
                    paths.append(mountpoint)
    except Exception as e:
        print(f"[ERROR] /proc/mounts 읽기 실패: {e}")
    return paths

def list_subfolders(path):
    """특정 경로 하위 폴더 목록 반환"""
    try:
        with os.scandir(path) as it:
            return [entry.name for entry in it if entry.is_dir()]
    except Exception:
        return []


import shutil

def get_disk_usage(path):
    usage = shutil.disk_usage(path)
    total_gb = usage.total / (1024 ** 3)
    used_gb = usage.used / (1024 ** 3)
    free_gb = usage.free / (1024 ** 3)
    percent_used = (usage.used / usage.total) * 100
    return {
        "total_gb": total_gb,
        "used_gb": used_gb,
        "free_gb": free_gb,
        "percent_used": percent_used
    }
import shutil

def get_disk_usage(path):
    usage = shutil.disk_usage(path)
    total_gb = usage.total / (1024 ** 3)
    used_gb = usage.used / (1024 ** 3)
    free_gb = usage.free / (1024 ** 3)
    percent_used = (usage.used / usage.total) * 100
    return {
        "total_gb": total_gb,
        "used_gb": used_gb,
        "free_gb": free_gb,
        "percent_used": percent_used
    }


@app.route('/share/get_mounts', methods=['GET'])
def api_get_mounts():
    mounts = get_mounted_paths()
    return jsonify(mounts)


def list_subfolders(base_path):
    try:
        entries = []
        if os.path.exists(base_path) and os.path.isdir(base_path):
            for entry in os.listdir(base_path):
                if entry.startswith('.'):
                    continue
                full_path = os.path.join(base_path, entry)
                if os.path.isdir(full_path):
                    entries.append(entry)
        return entries
    except Exception as e:
        return []

@app.route('/share/list_folders', methods=['GET'])
def api_list_folders():
    base_path = request.args.get('path', BASE_DIR)

    # 중복 슬래시 제거 (//media -> /media)
    while '//' in base_path:
        base_path = base_path.replace('//', '/')

    folders = list_subfolders(base_path)
    return jsonify(folders)


def verify_nginx_password(username, password):
    from passlib.apache import HtpasswdFile
    HTPASSWD_FILE = "/etc/nginx/.htpasswd"
    print(f"[DEBUG] verify_nginx_password 호출됨 - username: {username}, password: {password}")  # 무조건 로그
    if not os.path.exists(HTPASSWD_FILE):
        print(f"[DEBUG] htpasswd 파일이 없습니다: {HTPASSWD_FILE}")
        return False
    ht = HtpasswdFile(HTPASSWD_FILE)
    result = ht.check_password(username, password)
    print(f"[DEBUG] 사용자 인증 결과: username={username}, 성공={result}")
    return result


# 경로/파일: app.py  (login 라우트 교체)

@app.route('/login/', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username', '').strip()
        password = request.form.get('password', '')
        access_type = request.form.get('access_type', '').strip()

        print(f"[DEBUG] 로그인 요청: username={username}, access_type={access_type}")

        # 1) nginx htpasswd 기반 계정 검증
        if not verify_nginx_password(username, password):
            flash('아이디 또는 비밀번호가 올바르지 않습니다.', 'danger')
            return render_template('login.html')

        # 2) 세션 저장
        session['username'] = username
        session['is_admin'] = (username == 'admin')

        # 3) 접속 유형 분기
        if access_type == 'admin_ui':
            if username != 'admin':
                flash('관리자만 접근 가능합니다.', 'danger')
                return render_template('login.html')
            # 관리자 UI
            return redirect(url_for('share_manage'))

        # 나머지는 전부 elFinder 고급 탐색기로 처리
        # (access_type == 'elfinder' 이거나, 뭔가 잘못 들어온 경우 기본)
        return redirect(url_for("elfinder"))


    # GET 요청: 로그인 화면
    return render_template('login.html')


@app.route("/elfinder", strict_slashes=False)
@app.route("/elfinder/", strict_slashes=False)
def elfinder():
    # nginx 쪽 /elfinder/ (PHP elFinder)로 보내기
    # 포인트: 5001로 직접 접속하면 무한루프 날 수 있어서 포트 제거한 호스트로 보냄
    host = request.host.split(":")[0]  # realnas2.iptime.org 또는 127.0.0.1
    scheme = "http"  # 내부망이면 http로 충분
    return redirect(f"{scheme}://{host}/elfinder/")
@app.route('/set_default_share', methods=['POST'])
def set_default_share_route():
    data = request.get_json()
    share_name = data.get('share_name', '').strip()
    try:
        set_default_share(share_name)  # 대표 공유폴더 저장 함수
        print(f"대표 폴더 변경 요청: {share_name}")
        print(f"변경 후 대표 폴더: {get_default_share()}")
        return jsonify({"message": "대표 공유폴더가 지정되었습니다."}), 200
    except Exception as e:
        print(f"대표 공유폴더 지정 오류: {e}")
        return jsonify({"error": "대표 공유폴더 지정에 실패했습니다."}), 500


def get_share(name):
    if not name:
        return None
    shares = load_shares()
    for s in shares:
        if s['name'].strip() == name.strip():
            return s
    return None

import subprocess

def restart_samba_services():
    services = ['smbd', 'nmbd']
    for service in services:
        try:
            subprocess.run(['systemctl', 'restart', service], check=True)
            print(f"[INFO] {service} 서비스 재시작 성공")
        except subprocess.CalledProcessError as e:
            print(f"[ERROR] {service} 서비스 재시작 실패: {e}")



@app.route('/api/apply_permissions', methods=['POST'])
def api_apply_permissions():
    """
    요청 JSON 예시:
    {
      "share_name": "DATA",
      "folder_path": "/home/realnas/DATA",
      "allowed_users": ["admin", "realnas"]
    }
    """
    data = request.get_json()
    share_name = data.get("share_name")
    folder_path = data.get("folder_path")
    allowed_users = data.get("allowed_users", [])

    if not share_name or not folder_path:
        return jsonify({"status": "error", "message": "share_name과 folder_path는 필수입니다."}), 400

    success = share.apply_folder_permissions(folder_path, allowed_users, share_name)

    if success:
        return jsonify({"status": "success"})
    else:
        return jsonify({"status": "error", "message": "권한 적용 중 오류 발생"}), 500

import subprocess

def ensure_linux_user(username):
    try:
        subprocess.run(['id', username], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        print(f"[INFO] 사용자 '{username}' 존재함")
    except subprocess.CalledProcessError:
        print(f"[INFO] 사용자 '{username}'가 없어서 생성 중...")
        subprocess.run(['sudo', 'adduser', '--disabled-password', '--gecos', '""', username], check=True)
        print(f"[INFO] 사용자 '{username}' 생성 완료")

def apply_folder_permissions(folder_path, allowed_users, share_name):
    group_name = f"share_{share_name.lower()}"

    try:
        subprocess.run(['sudo', 'groupadd', group_name], check=False)

        for user in allowed_users:
            ensure_linux_user(user)
            subprocess.run(['sudo', 'usermod', '-aG', group_name, user], check=True)

        subprocess.run(['sudo', 'chown', '-R', f':{group_name}', folder_path], check=True)
        subprocess.run(['sudo', 'chmod', '-R', '777', folder_path], check=True)

        print(f"[INFO] 폴더 권한 적용 완료: {folder_path} (그룹: {group_name})")
        return True

    except subprocess.CalledProcessError as e:
        print(f"[ERROR] 폴더 권한 설정 실패: {e}")
        return False


def set_folder_permissions(path, user, group, allowed_users):
    try:
        subprocess.run(['sudo', 'groupadd', '-f', group], check=True)
        for username in allowed_users:
            ensure_linux_user(username)
            subprocess.run(['sudo', 'usermod', '-aG', group, username], check=True)

        subprocess.run(['sudo', 'chown', f'{user}:{group}', path], check=True)
        subprocess.run(['sudo', 'chmod', '770', path], check=True)
        print(f"[INFO] 권한 설정 완료: {path}")
        return True
    except Exception as e:
        print(f"[ERROR] 폴더 권한 설정 실패: {e}")
        return False


@app.route('/share', methods=['GET', 'POST'])
@login_required
@admin_required
def share_manage():
    edit_share = None
    shares = load_shares()
    users = nginx_user.list_nginx_users()

    partitions = psutil.disk_partitions(all=False)
    mounts = []
    for p in partitions:
        if p.device.startswith('/dev/loop') or p.device.startswith('/dev/nvme'):
            continue
        mounts.append({
            'device': p.device,
            'path': p.mountpoint
        })

    if request.method == 'POST':
        if 'remove' in request.form:
            name = request.form.get('remove')
            success, msg = remove_share(name)
            flash(msg, 'success' if success else 'danger')
            return redirect(url_for('share_manage'))

        action = request.form.get('action')

        if action == 'add':
            allowed_users = request.form.getlist('allowed_users')
            base_path = request.form.get('path', '').strip()
            name = request.form.get('name', '').strip()

            if not base_path:
                flash("기본 경로가 지정되지 않았습니다.", "danger")
                return redirect(url_for('share_manage'))

            if name:
                if not name.isalnum():
                    flash("폴더명은 알파벳과 숫자만 포함할 수 있습니다.", "danger")
                    return redirect(url_for('share_manage'))

                new_folder_path = os.path.join(base_path, name)
                try:
                    if not os.path.exists(new_folder_path):
                        os.makedirs(new_folder_path, exist_ok=True)
                        set_folder_permissions(new_folder_path, user='realnas', group='realnas', allowed_users=allowed_users)
                except Exception as e:
                    flash(f"폴더 생성 중 오류 발생: {e}", "danger")
                    return redirect(url_for('share_manage'))
            else:
                # 폴더명 없으면 기존 경로 그대로 사용
                new_folder_path = base_path
                # 권한 설정은 폴더가 이미 존재한다고 가정하여 생략하거나 필요 시 추가 가능

            share_name = name if name else os.path.basename(new_folder_path.rstrip('/'))

            success, msg = add_share(
                name=share_name,
                path=new_folder_path,
                allowed_users=allowed_users
            )
            flash(msg, 'success' if success else 'danger')
            return redirect(url_for('share_manage'))

        elif action == 'update':
            permissions_json = request.form.get('permissions_json')
            if not permissions_json:
                flash("권한 정보가 없습니다.", "danger")
                return redirect(url_for('share_manage'))

            import json
            try:
                permissions_dict = json.loads(permissions_json)
            except Exception as e:
                flash(f"권한 정보 파싱 실패: {e}", "danger")
                return redirect(url_for('share_manage'))

            updated_any = False
            for share in shares:
                share_name = share['name']
                allowed_users = permissions_dict.get(share_name, [])
                if not isinstance(allowed_users, list):
                    allowed_users = []
                success, msg = update_share(share_name, allowed_users)
                if success:
                    updated_any = True

            if updated_any:
                flash("공유폴더 접근 권한이 모두 반영되었습니다.", 'success')
            else:
                flash("권한 변경에 실패했습니다.", 'danger')
            return redirect(url_for('share_manage'))

        elif action == 'edit':
            name = request.form.get('name', '').strip()
            for s in shares:
                if s['name'] == name:
                    edit_share = s
                    break

    return render_template(
        'share_manage.html',
        shares=shares,
        users=users,
        mounts=mounts,
        edit_share=edit_share,
        candidates=[]
    )

@app.route('/share_access', methods=['GET', 'POST'])

def share_access():
    shares = share.list_share()
    users = share.list_users()
    share_name = request.args.get('share_name', '').strip()


    share_obj = share.get_share(share_name)

    if share_name:
        if not share_obj:
            return "<h2>대표 공유폴더 접근 권한 관리</h2><p style='color:red;'>대표 공유폴더 정보가 없습니다!</p>"
        # 실제 폴더 경로
        folder_path = share_obj.get('path')
        # 폴더 내부 파일/디렉토리 리스트
        file_list = []
        if folder_path and os.path.isdir(folder_path):
            file_list = os.listdir(folder_path)
            file_list = sorted(file_list)  # 보기좋게 정렬
        else:
            return "<h2>대표 공유폴더 접근 권한 관리</h2><p style='color:red;'>실제 폴더가 없습니다!</p>"

        # 파일/폴더 목록을 html로 나열
        file_items = ""
        for f in file_list:
            fullpath = os.path.join(folder_path, f)
            if os.path.isdir(fullpath):
                file_items += f"<li><b>[DIR]</b> {f}</li>"
            else:
                file_items += f"<li>{f}</li>"

        return f"""
        <h2>대표 공유폴더 접근 권한 관리</h2>
        <p>공유폴더({share_name}) 목록:</p>
        <ul>
            {file_items if file_items else "<li>(비어있음)</li>"}
        </ul>
        """

    return render_template('share_manage.html', shares=shares, users=users)



@app.route('/share_access_direct')
def share_access_direct():
    share_name = request.args.get('share_name', '').strip()
    if not share_name:
        return "대표 공유폴더 정보가 없습니다!"
    share_obj = share.get_share(share_name)
    if not share_obj:
        return "대표 공유폴더 정보가 없습니다!"
    return f"공유폴더({share_name}) 접속 성공!"



def hash_password(password):
    return sha512_crypt.hash(password)






def get_folder_tree(path):
    tree = []
    try:
        with os.scandir(path) as it:
            for entry in it:
                if entry.is_dir(follow_symlinks=False):
                    subtree = get_folder_tree(entry.path)
                    tree.append({
                        "text": entry.name,
                        "id": entry.path,
                        "children": subtree,
                        "icon": "jstree-folder"
                    })
    except PermissionError:
        # 권한 없으면 빈 트리 반환
        pass
    return tree

@app.route('/api/folders')
def api_folders():
    # 클라이언트에서 parent 경로 받음, 기본값은 BASE_PATH
    parent = request.args.get('parent') or (get_default_share_path() or "/mnt/storage")
    tree = get_folder_tree(parent)
    return jsonify(tree)

# 경로/파일: /home/realnas/realnas-nas-ui/app.py
# Ctrl+F: @app.route("/api/elfinder/config", methods=["GET"])
# Ctrl+F: @app.route("/api/elfinder/config", methods=["POST"])

import os
import json
from flask import jsonify, request

ELFINDER_CONF = "/etc/realnas/elfinder.json"
ELFINDER_PHP_ROOTS = "/var/www/html/elfinder/php/realnas_roots.php"

def _default_elfinder_conf():
    return {
        "roots": [
            {
                "alias": "DATA",
                "path": "/mnt/storage",
                "url": "/storage",   # 🔥 반드시 필요
                "accessControl": "disable"  # 선택 (권한 꼬임 방지)
            }
        ]
    }


def _load_elfinder_conf():
    if not os.path.exists(ELFINDER_CONF):
        return _default_elfinder_conf()
    try:
        with open(ELFINDER_CONF, "r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict) or "roots" not in data:
            return _default_elfinder_conf()
        if not isinstance(data["roots"], list) or not data["roots"]:
            return _default_elfinder_conf()
        return data
    except Exception:
        return _default_elfinder_conf()

def _save_elfinder_conf(conf: dict):
    os.makedirs(os.path.dirname(ELFINDER_CONF), exist_ok=True)
    tmp = ELFINDER_CONF + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(conf, f, ensure_ascii=False, indent=2)
    os.replace(tmp, ELFINDER_CONF)

def _write_php_roots(conf: dict):
    """
    PHP elFinder connector가 include해서 roots 배열로 쓰게 만드는 파일 생성.
    return array( ... );
    """
    roots = conf.get("roots") or []
    if not roots:
        roots = _default_elfinder_conf()["roots"]

    r0 = roots[0]
    alias = str(r0.get("alias") or "DATA")
    path = str(r0.get("path") or "/mnt/storage")
    url  = str(r0.get("url") or "")  # 비워도 OK

    # PHP 문자열 이스케이프
    def esc(s: str) -> str:
        return s.replace("\\", "\\\\").replace("'", "\\'")

    php = """<?php
// AUTO-GENERATED by Realcom NAS UI
// DO NOT EDIT MANUALLY
return array(
  array(
    'driver' => 'LocalFileSystem',
    'path'   => '%(path)s',
    'alias'  => '%(alias)s',
    'URL'    => '%(url)s'
  )
);
""" % {"path": esc(path.rstrip("/") + "/"), "alias": esc(alias), "url": esc(url)}

    os.makedirs(os.path.dirname(ELFINDER_PHP_ROOTS), exist_ok=True)
    with open(ELFINDER_PHP_ROOTS, "w", encoding="utf-8") as f:
        f.write(php)

    # 권한: nginx/php-fpm(www-data)가 읽어야 함
    try:
        os.chmod(ELFINDER_PHP_ROOTS, 0o644)
    except Exception:
        pass

@app.route("/api/elfinder/config", methods=["GET"])
def api_get_elfinder_config():
    conf = _load_elfinder_conf()
    return jsonify(conf), 200

@app.route("/api/elfinder/config", methods=["POST"])
def api_set_elfinder_config():
    data = request.get_json(silent=True) or {}
    roots = data.get("roots")

    if not isinstance(roots, list) or not roots:
        return jsonify({"error": "roots 형식이 올바르지 않습니다."}), 400

    r0 = roots[0] if isinstance(roots[0], dict) else {}
    alias = (r0.get("alias") or "DATA").strip()
    path  = (r0.get("path") or "").strip()
    url   = (r0.get("url") or "").strip()

    if not path:
        return jsonify({"error": "path가 비어있습니다."}), 400
    if not os.path.isdir(path):
        return jsonify({"error": f"존재하지 않는 폴더입니다: {path}"}), 400

    conf = {"roots": [{"alias": alias, "path": path, "url": url}]}

    try:
        _save_elfinder_conf(conf)
        _write_php_roots(conf)
        return jsonify({"ok": True}), 200
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# 예를 들어, Flask에서 POST 요청으로 비밀번호 받을 때
@app.route('/some_route', methods=['POST'])
def some_route():
    password = request.form.get('password')
    if not password:
        # 비밀번호 없으면 처리
        pass
    hashed_pw = hash_password(password)
    # 이후 hashed_pw 저장 또는 사용
    return "Success"


def add_user(username: str, password: str) -> bool:
    global user_passwords
    if username in user_passwords:
        return False
    try:
        # OS 사용자 존재 여부 확인 및 없으면 생성
        try:
            subprocess.run(['id', username], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        except subprocess.CalledProcessError:
            subprocess.run(['sudo', 'useradd', '-m', username], check=True)

        # 삼바 사용자 추가 및 비밀번호 설정 (pexpect 필요)
        import pexpect
        child = pexpect.spawn(f'smbpasswd -a {username}')
        child.expect('New SMB password:')
        child.sendline(password)
        child.expect('Retype new SMB password:')
        child.sendline(password)
        child.expect(pexpect.EOF)

        # 패스워드 해시 저장
        hashed = sha512_crypt.hash(password)
        user_passwords[username] = hashed
        success = save_users()
        if not success:
            del user_passwords[username]  # 저장 실패 시 메모리 복구
            return False

        return True
    except Exception as e:
        print(f"[ERROR] 사용자 추가 실패: {e}")
        return False


def remove_user(username: str) -> bool:
    global user_passwords
    if username not in user_passwords:
        return False
    # 기존 패스워드 값 임시 저장
    old_password = user_passwords.get(username)
    try:
        # OS 사용자 삭제
        subprocess.run(['sudo', 'userdel', '-r', username], check=True)
        # 삼바 사용자 삭제
        subprocess.run(['sudo', 'smbpasswd', '-x', username], check=True)

        # JSON 사용자 목록에서 제거
        del user_passwords[username]
        success = save_users()
        if not success:
            user_passwords[username] = old_password  # 저장 실패 시 메모리 복구
            return False
        return True
    except Exception as e:
        print(f"[ERROR] 사용자 삭제 실패: {e}")
        return False



# -- nginx 사용자 관리 함수 (중복 제거, modules.nginx_user 함수 재사용 권장) --

def add_nginx_user_local(username: str, password: str) -> (bool, str):
    if not username or not password:
        return False, "사용자명과 비밀번호를 입력하세요."
    if ':' in username:
        return False, "사용자명에 ':' 문자는 사용할 수 없습니다."

    if not os.path.exists(HTPASSWD_FILE):
        with open(HTPASSWD_FILE, 'w'):
            pass

    ht = HtpasswdFile(HTPASSWD_FILE)
    if username in ht.users():
        return False, "사용자가 이미 존재합니다."

    ht.set_password(username, password)
    ht.save(HTPASSWD_FILE)
    return True, "사용자가 성공적으로 추가되었습니다."

def remove_nginx_user_local(username: str) -> (bool, str):
    if not os.path.exists(HTPASSWD_FILE):
        return False, "htpasswd 파일이 존재하지 않습니다."

    ht = HtpasswdFile(HTPASSWD_FILE)
    if username not in ht.users():
        return False, "삭제할 사용자가 존재하지 않습니다."

    ht.delete(username)
    ht.save(HTPASSWD_FILE)
    return True, "사용자가 성공적으로 삭제되었습니다."

def change_nginx_user_password_local(username: str, new_password: str) -> (bool, str):
    if not os.path.exists(HTPASSWD_FILE):
        return False, "htpasswd 파일이 존재하지 않습니다."

    ht = HtpasswdFile(HTPASSWD_FILE)
    if username not in ht.users():
        return False, "비밀번호를 변경할 사용자가 존재하지 않습니다."

    ht.set_password(username, new_password)
    ht.save(HTPASSWD_FILE)
    return True, "비밀번호가 성공적으로 변경되었습니다."


# -- 로그인 데코레이터 --

def set_default_share(share_name):
    try:
        with open(DEFAULT_SHARE_PATH_FILE, 'w', encoding='utf-8') as f:
            f.write(share_name.strip() if share_name else "")
        print(f"[DEBUG] set_default_share() 저장 완료: '{share_name}'")
    except Exception as e:
        print(f"[ERROR] set_default_share() 저장 실패: {e}")




def get_share_path_by_name(share_name):


    if os.path.exists(SHARES_JSON):
        with open(SHARES_JSON, 'r', encoding='utf-8') as f:
            data = json.load(f)

            for s in data:
                if s['name'].strip() == share_name.strip():
                    return s['path']
    return None
# -- 파일명 안전하게 처리 --

def sanitize_filename(filename):
    # 한글, 영어, 숫자, _, -, 공백만 허용
    return re.sub(r'[^가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9_\- ]', '', filename).strip()

def secure_filename_with_korean(filename):
    filename = unicodedata.normalize('NFKC', filename)
    filename = re.sub(r'[^가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9_\-\. ]', '', filename)
    filename = filename.replace(' ', '_')
    return filename



@app.route('/nginx_users')
def nginx_users():
    users = nginx_user.list_nginx_users()
    return render_template('nginx_users.html', users=users)



@app.route('/nginx_users/add', methods=['GET', 'POST'])
def nginx_users_add():
    if request.method == 'POST':
        username = request.form.get('username', '').strip()
        password = request.form.get('password', '')
        success, msg = nginx_user.add_nginx_user(username, password)
        flash(msg, 'success' if success else 'danger')
        return redirect(url_for('nginx_users'))
    return render_template('nginx_users_add.html')

@app.route('/nginx_users/delete/<username>', methods=['POST'])
def nginx_users_delete(username):
    success, msg = nginx_user.remove_nginx_user(username)
    flash(msg, 'success' if success else 'danger')
    return redirect(url_for('nginx_users'))

@app.route('/nginx_users/change_password/<username>', methods=['GET', 'POST'])
def nginx_users_change_password(username):
    if request.method == 'POST':
        password = request.form.get('password', '')
        if not password:
            flash("비밀번호를 입력하세요.", 'warning')
            return redirect(url_for('nginx_users_change_password', username=username))
        success, msg = nginx_user.change_nginx_user_password(username, password)
        flash(msg, 'success' if success else 'danger')
        return redirect(url_for('nginx_users'))
    return render_template('nginx_users_change_password.html', username=username)

import subprocess  # ← 반드시 맨 위에 추가!

def save_shares_to_json(shares=None):
 
    if shares is None:
        shares = list_share()  # 이미 리스트를 얻는 함수가 있다면 활용
    try:
        with open(SHARES_JSON, "w", encoding="utf-8") as f:
            json.dump(shares, f, indent=2, ensure_ascii=False)
        print(f"[DEBUG] 공유폴더 정보를 {SHARES_JSON}에 저장 완료.")
    except Exception as e:
        print(f"[ERROR] 공유폴더 저장 실패: {e}")
import json



def reload_nginx():
    try:
        subprocess.run(['sudo', 'nginx', '-s', 'reload'], check=True)
    except Exception as e:
        print(f"[ERROR] Nginx 설정 동기화 실패: {e}")

def update_nginx_webdav_conf():
    try:
        shares = share.list_share()
        default_share = share.get_default_share()
        nginx_webdav.generate_webdav_conf(shares, default_share=default_share)
    except Exception as e:
        app.logger.error(f"nginx webdav 설정 동기화 실패: {e}")
from flask import (
    Flask, render_template, request, redirect, url_for, flash, session
)
from modules import share, nginx_webdav, nginx_user

from modules import share

@app.route('/sync_nginx')
def sync_nginx_route():
    success = share.sync_nginx_conf()
    if success:
        return "Nginx 설정 동기화 및 리로드 성공"
    else:
        return "Nginx 설정 동기화 실패", 500

@app.route('/set_representative_share', methods=['POST'])
def set_representative_share():
    rep_name = request.form.get('rep_name', '').strip()
    rep_path = request.form.get('rep_path', '').strip()

    if not rep_name or not rep_path:
        flash('대표 폴더 이름과 경로를 모두 입력해야 합니다.', 'danger')
        return redirect(url_for('share_manage'))

    try:
        share.set_default_share(rep_name)
        flash(f'대표 공유폴더가 "{rep_name}"(경로: {rep_path})로 설정되었습니다.', 'success')
    except Exception as e:
        flash(f'대표 공유폴더 설정 중 오류 발생: {e}', 'danger')

    return redirect(url_for('share_manage'))



@app.route('/share/get_existing_folders_v2', methods=['GET'])
def get_existing_folders1():
    default_share_path = share.get_default_share_path()
    if not default_share_path:
        return jsonify({"error": "대표폴더가 설정되어 있지 않습니다."}), 400

    folders = share.get_candidate_paths(default_share_path)
    folder_infos = [{"name": os.path.basename(f), "path": f} for f in folders]
    return jsonify(folder_infos)

def user_has_share_permission(share_name, username):
    if not os.path.exists(SHARES_JSON):
        return False

    try:
        with open(SHARES_JSON, 'r', encoding='utf-8') as f:
            shares = json.load(f)
    except Exception:
        return False

    for s in shares:
        if s.get('name', '').strip() == share_name.strip():
            return username in s.get('allowed_users', [])

    return False



@app.route('/api/default_share_subdirs')
@login_required
def get_default_share_subdirs():
    shares = load_shares()
    default_share = None
    for s in shares:
        if s.get('is_default'):
            default_share = s
            break
    if not default_share:
        return jsonify(success=False, message='대표 공유폴더가 없습니다.')

    base_path = default_share['path']
    try:
        entries = os.listdir(base_path)
        subdirs = [e for e in entries if os.path.isdir(os.path.join(base_path, e))]
        return jsonify(success=True, subdirs=subdirs)
    except Exception as e:
        return jsonify(success=False, message=str(e))

@app.route('/shortcut/<shortcut_name>')
@login_required
def shortcut_access(shortcut_name):
    username = session.get('username')
    shares = load_shares()

    # 대표 공유폴더 경로 찾기 (is_default=True 인 공유폴더)
    default_share_path = None
    for s in shares:
        if s.get('is_default'):
            default_share_path = s['path']
            break
    if not default_share_path:
        abort(404)

    shortcut_path = os.path.join(default_share_path, shortcut_name)

    # 심볼릭 링크가 아니면 404
    if not os.path.islink(shortcut_path):
        abort(404)

    # 심볼릭 링크가 가리키는 실제 원본 경로
    target_path = os.readlink(shortcut_path)
    if not os.path.isabs(target_path):
        # 상대경로이면 절대경로로 변환
        target_path = os.path.join(os.path.dirname(shortcut_path), target_path)
    target_path = os.path.realpath(target_path)

    # 원본 경로가 어느 공유폴더에 속하는지 확인
    target_share = None
    for s in shares:
        base_path = os.path.realpath(s['path'])
        if target_path.startswith(base_path):
            target_share = s
            break

    if not target_share:
        # 원본 경로가 공유폴더에 없으면 접근 불가
        abort(403)

    # 로그인 사용자 권한 확인
    if username not in target_share.get('allowed_users', []):
        flash("이 바로가기에 대한 접근 권한이 없습니다.", "danger")
        # 권한 없는 사용자는 원본 공유폴더 최상위로 리다이렉트하거나 접근 거부 페이지로
        return redirect(url_for('share_browse', share_name=target_share['name']))

    # 권한 통과시 원본 경로 내 상대 경로 계산 (공유폴더 경로 기준)
    rel_path = os.path.relpath(target_path, target_share['path'])

    # 실제 공유폴더 탐색기로 리다이렉트
    return redirect(url_for('share_browse', share_name=target_share['name'], path=rel_path))





# 파일/폴더명 정합성 검사 (간단)
def sanitize_filename(filename):
    return "".join(c for c in filename if c.isalnum() or c in "._- ").strip()

from flask import (
    render_template, request, redirect, url_for, flash, session,
    send_file, jsonify
)

from flask import request, jsonify
import os

@app.route('/api/list_subdirs', methods=['POST'])
def list_subdirs():
    data = request.get_json()
    base_path = data.get('path', '')
    if not base_path or not os.path.exists(base_path):
        return jsonify({'error': '경로가 유효하지 않습니다.'}), 400

    try:
        dirs = [name for name in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, name))]
        return jsonify({'dirs': dirs})
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/share/add_mount', methods=['POST'])
@login_required
@admin_required
def add_mount():
    data = request.get_json()
    device = data.get('device')
    path = data.get('path')
    allowed_users = data.get('allowed_users', [])

    if not device or not path:
        return jsonify(success=False, message="장치명 또는 경로가 누락되었습니다.")

    shares = load_shares()  # 기존 공유폴더 리스트 읽기

    folder_name = device.replace(' ', '_').replace('/', '_')

    for share in shares:
        if share['name'] == folder_name:
            return jsonify(success=False, message=f"'{folder_name}' 공유폴더가 이미 존재합니다.")

    try:
        if not os.path.exists(path):
            os.makedirs(path, exist_ok=True)
            set_folder_permissions(path, user='realnas', group='realnas')
    except Exception as e:
        return jsonify(success=False, message=f"폴더 생성 오류: {e}")

    shares.append({
        "name": folder_name,
        "path": path,
        "allowed_users": allowed_users
    })

    save_shares(shares)

    return jsonify(success=True, message="공유폴더가 정상적으로 추가되었습니다.")


# --- 파일명/폴더명 보안 필터링 ---
def sanitize_filename(name):
    # 보안적으로 허용할 문자만 통과, 필요에 따라 수정
    import re
    return re.sub(r'[^A-Za-z0-9가-힣_\-\. ]+', '', name).strip()




# -----------------------------
# DDNS IP helpers (안정판)
# -----------------------------


def get_internal_ip() -> str:
    """
    서버 내부 IP (LAN IP)
    - VM / 실서버 모두 안정
    """
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        # 실제 연결은 안 나가고, 라우팅 정보만 사용
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        pass

    # fallback
    try:
        return socket.gethostbyname(socket.gethostname())
    except Exception:
        return "error"


def get_public_ip() -> str:
    """
    공인 IP (외부 IP)
    - urllib 대신 curl 사용 (VM/NAT/CA/프록시 이슈 회피)
    """
    services = [
        "https://api.ipify.org",
        "https://icanhazip.com",
        "https://ifconfig.me/ip",
    ]

    for url in services:
        try:
            p = subprocess.run(
                ["curl", "-4", "-s", "--max-time", "4", url],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                text=True,
                check=False,
            )
            ip = (p.stdout or "").strip()
            # IPv4 검증
            if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
                return ip
        except Exception:
            continue

    return "error"

@app.route("/api/ddns/status")
@admin_required
def api_ddns_status():
    return jsonify({
        "public_ip": get_public_ip(),
        "internal_ip": get_internal_ip(),
    })

BASE_SEARCH_ROOT = "/mnt/storage"  # 기본값

def _get_search_root():
    p = get_default_share_path()
    return p or BASE_SEARCH_ROOT

@app.route('/share/get_existing_folders', methods=['GET'])
def get_existing_folders():
    base_dir = _get_search_root()
    folders = share.get_candidate_paths(base_dir)
    folder_list = [{"name": os.path.relpath(p, base_dir), "path": p} for p in folders]
    return jsonify(folder_list)


def get_folder_candidates(base_path):
    """기본 경로 하위 폴더 리스트 (대표폴더 경로 선택 후보)"""
    candidates = []
    if os.path.exists(base_path):
        for entry in os.listdir(base_path):
            full_path = os.path.join(base_path, entry)
            if os.path.isdir(full_path):
                candidates.append(full_path)
    return candidates

@app.route('/share/<share_name>/browse', methods=['GET'])
def browse_share_explorer(share_name):
    shares = share.list_share()
    share_info = next((s for s in shares if s['name'] == share_name), None)
    if not share_info:
        return f"공유폴더 정보 없음: {share_name}", 404
    base_path = share_info['path']
    rel_path = request.args.get('path', '').strip('/')
    abs_path = os.path.join(base_path, rel_path)
    if not os.path.exists(abs_path) or not os.path.isdir(abs_path):
        return f"경로 없음: {abs_path}", 404
    entries = []
    for entry in os.listdir(abs_path):
        if entry.startswith('.'): continue
        full = os.path.join(abs_path, entry)
        entries.append({
            'name': entry,
            'is_dir': os.path.isdir(full)
        })
    # 상위 폴더 이동
    parent_rel_path = os.path.dirname(rel_path)
    if rel_path:
        entries.insert(0, {
            'name': '..',
            'is_dir': True,
            'is_parent': True,
            'parent_path': parent_rel_path
        })
    return render_template('share_explorer.html', share_name=share_name, rel_path=rel_path, entries=entries)

from flask import (
    Flask, render_template, request, redirect, url_for, session,
    send_file, flash, abort
)


ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', 'mp4', 'mp3', 'xlsx', 'docx'])

def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/upload', methods=['POST'])
def upload_file_global():
    # 대표폴더 업로드 처리
    upload_folder = get_default_share_path()
    if not upload_folder:
        return "대표폴더가 설정되어 있지 않습니다.", 400
    if 'file' not in request.files:
        return "업로드할 파일이 없습니다.", 400
    file = request.files['file']
    if file.filename == '':
        return "파일명이 없습니다.", 400
    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        file.save(os.path.join(upload_folder, filename))
        return "업로드 성공", 200
    return "허용되지 않은 파일 형식입니다.", 400



def get_share_path(share):
    from modules import share as share_module
    info = next((s for s in share_module.list_share() if s['name'] == share), None)
    return info['path'] if info else None



from flask import request, render_template, send_file, redirect, url_for, flash
import os, zipfile, shutil, time



def get_share_path(share):
    from modules import share as share_module
    info = next((s for s in share_module.list_share() if s['name'] == share), None)
    return info['path'] if info else None

import os
import time
import shutil
import zipfile
from flask import request, flash, redirect, url_for, render_template, send_file
from werkzeug.utils import secure_filename





def load_ddns_config():
    if os.path.exists(DDNS_CONFIG_PATH):
        try:
            with open(DDNS_CONFIG_PATH, "r") as f:
                return json.load(f)
        except Exception:
            return {}
    return {}

def save_ddns_config(data):
    try:
        with open(DDNS_CONFIG_PATH, "w") as f:
            json.dump(data, f, indent=2)
        return True
    except Exception as e:
        print(f"DDNS 설정 저장 실패: {e}")
        return False

import os
import subprocess

NGINX_CONF_PATH = "/etc/nginx/sites-available/realnas.conf"

def generate_nginx_conf(domain):
    """
    도메인 기반으로 Nginx 기본 서버 설정 파일 생성
    """
    conf_content = f"""server {{
    listen 80;
    server_name {domain};

    location / {{
        proxy_pass http://127.0.0.1:5001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }}
}}
"""
    try:
        with open(NGINX_CONF_PATH, "w", encoding="utf-8") as f:
            f.write(conf_content)
        return True
    except Exception as e:
        print(f"[ERROR] Nginx 설정 생성 실패: {e}")
        return False


def update_ddns():
    """
    DDNS 갱신 스크립트 실행 함수
    """
    ddns_script_path = "/usr/local/bin/ddns_update.sh"
    if not os.path.isfile(ddns_script_path):
        return False, f"DDNS 갱신 스크립트가 없습니다: {ddns_script_path}"

    try:
        result = subprocess.run([ddns_script_path], capture_output=True, text=True, timeout=30)
        if result.returncode == 0:
            return True, "DDNS 갱신 성공"
        else:
            return False, f"DDNS 갱신 실패: {result.stderr.strip()}"
    except Exception as e:
        return False, f"DDNS 갱신 오류: {e}"

@app.route("/ddns", methods=["GET", "POST"])
def ddns_manage():
    config = load_ddns_config()
    current_ip = None
    # (필요하면 현재 IP 조회 함수 호출)

    if request.method == "POST":
        domain = request.form.get("domain", "").strip()
        # provider, ddns_user, ddns_pass 는 더 이상 사용하지 않으므로 삭제

        if not domain:
            flash("도메인 주소를 입력해주세요.", "warning")
            return redirect(url_for("ddns_manage"))

        config.update({
            "domain": domain,
        })

        if save_ddns_config(config):
            success, msg = update_ddns()

            # Nginx 설정 자동 갱신 및 재시작
            try:
                generate_nginx_conf(domain)
                result = subprocess.run(['sudo', 'nginx', '-t'], capture_output=True, text=True)
                if result.returncode == 0:
                    subprocess.run(['sudo', 'nginx', '-s', 'reload'], check=True)
                else:
                    flash(f"Nginx 설정 오류: {result.stderr}", "danger")
            except Exception as e:
                flash(f"Nginx 재시작 실패: {e}", "danger")

            if success:
                flash(msg, "success")
            else:
                flash(msg, "danger")
        else:
            flash("DDNS 설정 저장 실패", "danger")

        return redirect(url_for("ddns_manage"))

    return render_template("ddns.html", config=config, current_ip=current_ip, username="admin")

@app.route("/share_default_login", methods=["GET", "POST"])
def share_default_login():
    default_share = get_default_share()
    if not default_share:
        flash("대표 공유폴더가 설정되어 있지 않습니다.", "danger")
        return redirect(url_for("ddns_manage"))

    if request.method == "POST":
        username = request.form.get("username", "").strip()
        password = request.form.get("password", "")

        if user.verify_password(username, password):
            session["username"] = username
            session["share_name"] = default_share
            return redirect(url_for("access_share", share_name=default_share))
        else:
            flash("아이디 또는 비밀번호가 올바르지 않습니다.", "danger")

    return render_template("share_access.html", share_name=default_share)
import logging
app.logger.setLevel(logging.DEBUG)
@app.route('/share/<share_name>')
def share_access_detail(share_name):
    if "username" not in session:
        return redirect(url_for("share_login", share_name=share_name))

    shares = share.list_share()
    share_info = next((s for s in shares if s['name'] == share_name), None)

    if share_info is None:
        app.logger.debug(f"공유폴더 정보 없음: {share_name}")
        return f"공유폴더 정보 없음: {share_name}", 404

    allowed_users = share_info.get("allowed_users", [])
    username = session.get("username")

    app.logger.debug(f"allowed_users: {allowed_users}")
    app.logger.debug(f"username: {username}")

    if username not in allowed_users:
        flash("이 폴더에 대한 접근 권한이 없습니다.", "danger")
        return redirect(url_for("share_login", share_name=share_name))

    # 권한 있으면 바로 브라우저로
    return redirect(url_for("share_browse", share_name=share_name))



@app.route('/session_test')
def session_test():
    return f"username: {session.get('username')}, share_name: {session.get('share_name')}"

# 실제 사용자 비밀번호 검증
def verify_password(username, password):
    return user.verify_password(username, password) 


@app.route("/")
def index():
    # 로그인 안 했으면 로그인 화면
    if not session.get("username"):
        return redirect(url_for("login"))

    # 로그인 했으면 elFinder로
    return redirect(url_for("elfinder"))

# 로그아웃 라우트 (기존 코드와 동일)
@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('login'))




# 공유폴더별 로그인 화면
@app.route('/share/<share_name>/login', methods=['GET', 'POST'])
def share_login(share_name):
    if request.method == 'POST':
        username = request.form.get('username', '').strip()
        password = request.form.get('password', '')

        if not user.verify_password(username, password):
            flash("로그인 실패! 아이디/비밀번호를 확인하세요.")
            return redirect(url_for('share_login', share_name=share_name))

        session['username'] = username
        session['share_name'] = share_name

        app.logger.debug(f"로그인 성공: username={username}, share_name={session.get('share_name')}")

        return redirect(url_for('share_access', share_name=share_name))

    return render_template('login.html')


# 대표폴더 정보 저장 (간단 예제용 - 실제론 DB 또는 설정파일에 저장 권장)
DEFAULT_SHARE_SETTING = {
    "name": None,
    "auto_subfolder_create": True,
}

def sync_nginx_webdav():
    shares = share.list_share()
    users = [{"username": u, "password": "실제비번"} for u in user.list_users()]
    nginx_webdav.generate_webdav_conf(shares)
    nginx_webdav.make_htpasswd(users)

def check_login(username, password, share_name):
    shares = share.list_share()
    share_item = next((s for s in shares if s['name'] == share_name), None)
    if not share_item:
        return False
    if username in share_item.get('read_users', []):
        return user.verify_password(username, password)
    return False


from flask import session, redirect, url_for




def check_login(username, password, share_name):
    shares = share.list_share()  # 반드시 shares 불러오기
    share_item = next((s for s in shares if s['name'] == share_name), None)
    if not share_item:
        return False
    if username in share_item['read_users']:
        return user.verify_password(username, password)  # 실제 비밀번호 체크
    return False


from flask import (
    Flask, render_template, request, redirect, url_for, flash,
    session, send_file, abort
)
import os
import subprocess
import base64
from modules import share, user, nginx_webdav

import os


def get_default_share_path():
    default_share = get_default_share()
    if not default_share:
        return None
    shares = share.list_share()
    for s in shares:
        if s['name'] == default_share:
            return s['path']
    return None
@app.route('/api/get_default_share')
def api_get_default_share():
    path = get_default_share_path()
    if not path:
        return jsonify({'error': '대표폴더가 설정되어 있지 않습니다.'}), 404
    return jsonify({'default_share_path': path})


def hash_from_share(share_name):
    return "l1_" + share_name

import base64


def share_from_hash(h):
    if h is None:
        return None
    if h.startswith("l1_"):
        rest = h[3:]
        # base64가 맞으면 디코딩해서 공유폴더 경로와 매핑
        if "=" in rest or rest.endswith("=="):
            try:
                decoded = base64.b64decode(rest).decode("utf-8")
                for s in share.list_share():
                    if s["path"].rstrip("/") == decoded.rstrip("/"):
                        return s["name"]
                return None
            except Exception:
                return None
        else:
            return rest
    return None


def get_share_info_by_hash(h):
    name = share_from_hash(h)
    if not name:
        return None
    for s in share.list_share():
        if s["name"] == name:
            return s
    return None

def get_file_path_by_hash(h):
    # "l1_공유폴더" -> /home/realnas/공유폴더
    # "l1_공유폴더_파일명" -> /home/realnas/공유폴더/파일명
    if not h or not h.startswith("l1_"):
        return None
    parts = h[3:].split("_", 1)
    share_name = parts[0]
    s = None
    for share_info in share.list_share():
        if share_info["name"] == share_name:
            s = share_info
            break
    if not s:
        return None
    base = s["path"]
    if len(parts) == 1:
        return base
    else:
        # 언더스코어가 파일명에 포함된 경우 대비
        relpath = h[3+len(share_name)+1:]
        relpath = relpath.replace("___", "_")  # 충돌 방지
        return os.path.join(base, relpath)

def make_hash(share_name, relpath=None):
    if relpath:
        relpath = relpath.replace("_", "___")  # 해시 충돌 방지
        return f"l1_{share_name}_{relpath}"
    else:
        return f"l1_{share_name}"



def run_command(cmd):
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, check=True)
        return {"success": True, "output": result.stdout.strip()}
    except subprocess.CalledProcessError as e:
        return {"success": False, "error": e.stderr.strip() or e.stdout.strip()}


def get_current_crontab():
    try:
        result = subprocess.run(['crontab', '-l'], capture_output=True, text=True)
        if result.returncode != 0:
            return ""
        return result.stdout
    except Exception:
        return ""

def write_crontab(content):
    try:
        p = subprocess.Popen(['crontab', '-'], stdin=subprocess.PIPE, text=True)
        p.communicate(input=content)
        return p.returncode == 0
    except Exception:
        return False

def add_cron_job(policy_id, schedule):
    user = getpass.getuser()
    cmd = f'/usr/bin/python3 {os.path.abspath("modules/run_policy.py")} --id={policy_id}'
    cron_line = f'{schedule} {cmd} # backup_policy_{policy_id}'
    crontab = get_current_crontab()
    if cron_line in crontab:
        return True  # 이미 존재
    new_crontab = crontab.strip() + '\n' + cron_line + '\n'
    return write_crontab(new_crontab)

def remove_cron_job(policy_id):
    crontab = get_current_crontab()
    lines = crontab.splitlines()
    new_lines = [line for line in lines if f'# backup_policy_{policy_id}' not in line]
    new_crontab = "\n".join(new_lines) + "\n"
    return write_crontab(new_crontab)


# --------------------
from flask import jsonify

@app.route("/backup", methods=["GET", "POST"])
def backup_manage():
    if request.method == "POST":
        action = request.form.get("action")
        if action == "add":
            name = request.form.get("name")
            src = request.form.get("src")
            dest = request.form.get("dest")
            preset = request.form.get("preset")
            schedule = None
            schedule_str = ""

            if preset == "everyday":
                h = int(request.form.get("hour", 2))
                m = int(request.form.get("min", 0))
                schedule = f"{m} {h} * * *"
                schedule_str = f"매일 {h}시 {m}분"
            elif preset == "everyweek":
                h = int(request.form.get("hour", 2))
                m = int(request.form.get("min", 0))
                w = request.form.get("weekday", "0")
                schedule = f"{m} {h} * * {w}"
                week_names = ['일', '월', '화', '수', '목', '금', '토']
                schedule_str = f"매주 {week_names[int(w)]}요일 {h}시 {m}분"
            elif preset == "manual":
                schedule = None
                schedule_str = "수동실행"
            elif preset == "custom":
                schedule = request.form.get("schedule")
                schedule_str = schedule
            else:
                schedule = None
                schedule_str = ""

            backup.add_policy(name, src, dest, schedule, schedule_str)

            # 크론탭 업데이트
            policies = backup.list_policies()
            for p in policies:
                backup.add_cron_job(p["id"], p["schedule"])

            flash(f"백업 정책 '{name}' 추가됨")
        elif action == "delete":
            pid = request.form.get("policy_id")
            backup.remove_policy(pid)

            # 크론탭 업데이트
            policies = backup.list_policies()
            for p in policies:
                backup.add_cron_job(p["id"], p["schedule"])

            flash("백업 정책 삭제됨")
        elif action == "run":
            pid = request.form.get("policy_id")
            msg = backup.run_policy(pid)
            flash(msg)

    policies = backup.list_policies()
    logs = {p["id"]: backup.get_last_log(p["id"]) for p in policies}

    cron_jobs = {}
    if hasattr(backup, 'list_cron_jobs'):
        cron_jobs = backup.list_cron_jobs()

    return render_template("backup.html", policies=policies, logs=logs, cron_jobs=cron_jobs)


@app.route("/backup/log/<policy_id>")
def backup_log(policy_id):
    log = backup.get_last_log(policy_id)
    if log is None:
        log = "-"
    return jsonify({"log": log})

# --------------------
# 사용자 권한 ACL 관리 - 추가

def get_acl(filepath):
    """getfacl 명령어 호출해 ACL 정보 가져오기"""
    try:
        result = subprocess.run(["getfacl", "-cp", filepath], capture_output=True, text=True, check=True)
        return result.stdout
    except Exception as e:
        return f"ACL 조회 실패: {e}"

def set_acl(filepath, acl_text):
    """setfacl 명령어로 ACL 설정 적용"""
    try:
        proc = subprocess.run(["setfacl", "--set-file=-", filepath], input=acl_text, text=True, check=True)
        return True
    except Exception as e:
        return False

@app.route("/acl", methods=["GET", "POST"])
def acl_manage():
    if request.method == "POST":
        filepath = request.form.get("filepath")
        acl_text = request.form.get("acl_text")
        if filepath and acl_text:
            success = set_acl(filepath, acl_text)
            if success:
                flash(f"{filepath} ACL 설정 적용 완료")
            else:
                flash(f"{filepath} ACL 설정 실패")
        return redirect(url_for("acl_manage"))

    # GET 요청 처리
    path = request.args.get("path", "/")
    path = os.path.normpath(path)
    if not os.path.exists(path):
        flash("경로가 존재하지 않습니다.")
        path = "/"
    acl_info = get_acl(path)
    return render_template("acl.html", path=path, acl_info=acl_info)

# ------------------------------------------------------------
# Timeshift API (원본 기능 유지)
# ------------------------------------------------------------
TS_WRAPPER = "/usr/local/sbin/realnas-timeshift"
TS_CONFIG = "/etc/timeshift/timeshift.json"

from threading import Lock
TS_LOCK = Lock()


def _run_cmd(cmd: list[str], timeout: int = 30) -> tuple[int, str]:
    try:
        p = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            timeout=timeout,
        )
        return p.returncode, (p.stdout or "")
    except subprocess.TimeoutExpired:
        return 124, "timeout"
    except Exception as e:
        return 1, f"error: {e}"


def _run_ts(args: list[str], timeout: int = 30) -> tuple[int, str]:
    """
    timeshift wrapper 실행을 단일 실행으로 직렬화한다.
    (프론트에서 status/list가 동시에 들어와도 여기서 1개씩만 실행됨)
    """
    cmd = ["sudo", TS_WRAPPER] + (args or [])
    with TS_LOCK:
        return _run_cmd(cmd, timeout=timeout)


def _parse_ts_status_text(out: str) -> str:
    """
    realnas-timeshift status 출력에서 Status를 뽑아 "OK"/"ERROR"/"UNKNOWN" 반환
    """
    try:
        for ln in (out or "").splitlines():
            if "Status" in ln:
                # 예: Status : OK
                parts = ln.split(":")
                if len(parts) >= 2:
                    v = parts[-1].strip().upper()
                    if v:
                        return v
    except Exception:
        pass
    return "UNKNOWN"


def _get_latest_snapshot_name() -> str:
    """
    list 호출 결과에서 가장 최신 스냅샷 이름(YYYY-MM-DD_HH-MM-SS) 하나 뽑기
    """
    rc, out = _run_ts(["list"], timeout=20)
    if rc != 0:
        return ""
    items = _parse_timeshift_list(out)
    return items[0]["name"] if items else ""


def _load_timeshift_config() -> Dict[str, Any]:
    if not os.path.exists(TS_CONFIG):
        return {}
    try:
        with open(TS_CONFIG, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def _extract_snapshot_path(cfg: Dict[str, Any]) -> str:
    for key in ("snapshot_location", "backup_location", "snapshot_dir", "parent_device_mount_point"):
        v = cfg.get(key)
        if isinstance(v, str) and v.strip():
            return v.strip()
    for c in ("/timeshift/snapshots", "/run/timeshift/backup/timeshift/snapshots"):
        if os.path.isdir(c):
            return c
    return "-"


def _extract_mode(cfg: Dict[str, Any]) -> str:
    mode = cfg.get("backup_type") or cfg.get("snapshot_type") or cfg.get("mode")
    if isinstance(mode, str) and mode.strip():
        return mode.strip()
    if cfg.get("btrfs_mode") is True or cfg.get("btrfs") is True:
        return "btrfs"
    return "rsync"


def _extract_schedule(cfg: Dict[str, Any]) -> str:
    # timeshift 자체 스케줄(원래 로직)
    sched_keys = [
        ("hourly", "schedule_hourly"),
        ("daily", "schedule_daily"),
        ("weekly", "schedule_weekly"),
        ("monthly", "schedule_monthly"),
        ("boot", "schedule_boot"),
    ]
    enabled = []
    for label, key in sched_keys:
        v = cfg.get(key)
        if v is True or v == 1:
            enabled.append(label)
    if enabled:
        return "활성화: " + ", ".join(enabled)

    # ✅ 우리 정책(크론) 기반 자동 백업이 있으면 그걸 우선 “자동 백업”으로 보여줌
    # ✅ A안: ts_policy 제거, 정책 모듈은 backup으로 통일
    try:
        if backup and hasattr(backup, "list_policies"):
            pols = backup.list_policies() or []
            if isinstance(pols, list) and pols:
                p0 = pols[0] if isinstance(pols[0], dict) else {}
                sc = (p0.get("schedule") or "").strip()
                nm = (p0.get("name") or "").strip()
                if sc:
                    return f"정책: {nm or '자동'} / {sc}"
    except Exception:
        pass

    se = cfg.get("schedule_enabled")
    if se is True or se == 1:
        return "활성화(세부값 확인 필요)"
    return "비활성화"


def _parse_timeshift_list(output: str) -> List[Dict[str, str]]:
    lines = [ln.strip() for ln in output.splitlines() if ln.strip()]
    items: List[Dict[str, str]] = []
    name_pat = re.compile(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$")

    for ln in lines:
        tokens = re.split(r"\s{2,}|\t+|\s+\|\s+|\s+", ln)
        for t in tokens:
            if name_pat.match(t):
                try:
                    dt = datetime.strptime(t, "%Y-%m-%d_%H-%M-%S")
                    dt_str = dt.strftime("%Y-%m-%d %H:%M:%S")
                except Exception:
                    dt_str = t
                items.append({"name": t, "date": dt_str})
                break

    uniq = {it["name"]: it for it in items}
    items = list(uniq.values())

    def sort_key(x):
        try:
            return datetime.strptime(x["name"], "%Y-%m-%d_%H-%M-%S")
        except Exception:
            return datetime.min

    items.sort(key=sort_key, reverse=True)
    return items


# 경로/파일: realcom-nas-ui/app.py
# Ctrl+F: def api_timeshift_status(

@app.route("/api/timeshift/status", methods=["GET"])
@admin_required
def api_timeshift_status():
    cfg = _load_timeshift_config()
    rc, out = _run_ts(["status"], timeout=10)

    status = "OK" if rc == 0 else "ERROR"
    # status 출력 안에 Status : OK 있으면 그걸 우선
    status2 = _parse_ts_status_text(out)
    if status2 != "UNKNOWN":
        status = status2

    latest = _get_latest_snapshot_name()

    return jsonify({
        "ok": (rc == 0),
        "mode": _extract_mode(cfg),
        "path": _extract_snapshot_path(cfg),
        "schedule": _extract_schedule(cfg),
        "status": status,
        "latest_snapshot": latest,
        "check": out[:5000],
    })


@app.route("/api/timeshift/list", methods=["GET"])
@admin_required
def api_timeshift_list():
    rc, out = _run_ts(["list"], timeout=20)
    if rc != 0:
        return jsonify([])
    return jsonify(_parse_timeshift_list(out))


@app.route("/api/timeshift/create", methods=["POST"])
@admin_required
def api_timeshift_create():
    rc, out = _run_ts(["create"], timeout=600)
    return jsonify({"ok": (rc == 0), "output": out[:8000]})


@app.route("/api/timeshift/delete", methods=["POST"])
@admin_required
def api_timeshift_delete():
    data = request.get_json(silent=True) or {}
    name = (data.get("name") or "").strip()
    if not re.match(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$", name):
        return jsonify({"ok": False, "error": "invalid snapshot name"}), 400
    rc, out = _run_ts(["delete", name], timeout=600)
    return jsonify({"ok": (rc == 0), "output": out[:8000]})


@app.route("/api/timeshift/restore", methods=["POST"])
@admin_required
def api_timeshift_restore():
    data = request.get_json(silent=True) or {}
    name = (data.get("name") or "").strip()
    if not re.match(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$", name):
        return jsonify({"ok": False, "error": "invalid snapshot name"}), 400

    # realnas-timeshift wrapper에 restore 기능이 있어야 함
    # (없으면 wrapper 쪽에 restore 구현 필요)
    rc, out = _run_ts(["restore", name], timeout=3600)
    return jsonify({"ok": (rc == 0), "output": out[:8000]})


# ------------------------------------------------------------
# Timeshift Policy API (cron/systemd)
# ------------------------------------------------------------
CRON_5FIELD_RE = re.compile(r"^\s*([^\s]+\s+){4}[^\s]+\s*$")


# Ctrl+F: def _require_ts_policy(
def _require_ts_policy():
    """
    Timeshift 정책 모듈(=modules.backup)이 준비되어 있는지 확인.
    반환: (ok, resp, http_code)
    - ok=True  : 사용 가능 (resp=None)
    - ok=False : resp(jsonify)와 http_code 반환
    """
    try:
        # ✅ modules.backup import가 실패했으면 기능 미지원(서버 자체는 정상)
        #    UI가 흔들리지 않게 "정책 0개"로 취급하고 싶으면 여기서 ok=True로 바꿔도 됨.
        if backup is None:
            return False, jsonify({"ok": False, "error": "policy module not loaded"}), 503

        # ✅ 필수 함수 최소 체크 (파일 유무와 무관하게 항상 검사)
        need = ("list_policies", "add_policy", "update_policy", "remove_policy", "run_policy")
        missing = [fn for fn in need if not hasattr(backup, fn)]
        if missing:
            return False, jsonify({"ok": False, "error": f"policy module missing: {', '.join(missing)}"}), 501

        # ✅ 정책 파일이 없어도 "정책 0개"로 취급(에러 아님)
        policy_file = getattr(backup, "POLICY_FILE", None)
        if policy_file:
            try:
                if not os.path.exists(policy_file):
                    return True, None, 200
            except Exception:
                # 파일 체크가 실패해도 모듈 자체는 살아있으니 OK로 둠
                return True, None, 200

        return True, None, 200

    except Exception as e:
        return False, jsonify({"ok": False, "error": f"timeshift policy check failed: {e}"}), 500


def _validate_cron_schedule(schedule: str) -> Optional[str]:
    s = (schedule or "").strip()
    if not s:
        return None  # empty 허용(삭제/비활성화 의미)
    if not CRON_5FIELD_RE.match(s):
        return "invalid cron schedule (need 5 fields: m h dom mon dow)"
    # 위험 문자 컷(혹시라도 쉘로 흘릴 때 대비)
    if any(ch in s for ch in [";", "&", "|", ">", "<", "`", "$", "(", ")", "{", "}", "\\"]):
        return "invalid characters in schedule"
    return None


@app.route("/api/timeshift/policies", methods=["GET"])
@admin_required
def api_timeshift_policies():
    ok, resp, code = _require_ts_policy()
    if not ok:
        return resp, code

    try:
        pols = backup.list_policies() or []
        if not isinstance(pols, list):
            return jsonify([]), 200

        out = []
        for p in pols:
            if isinstance(p, dict):
                out.append({
                    "id": str(p.get("id") or ""),
                    "name": str(p.get("name") or ""),
                    "schedule": str(p.get("schedule") or ""),
                    "schedule_str": str(p.get("schedule_str") or ""),
                })
        return jsonify(out), 200
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/timeshift/policies/create", methods=["POST"])
@admin_required
def api_timeshift_policies_create():
    ok, resp, code = _require_ts_policy()
    if not ok:
        return resp, code

    data = request.get_json(silent=True) or {}
    name = (data.get("name") or "").strip()
    schedule = (data.get("schedule") or "").strip()

    if not name:
        return jsonify({"ok": False, "error": "name required"}), 400

    err = _validate_cron_schedule(schedule)
    if err:
        return jsonify({"ok": False, "error": err}), 400

    try:
        # ✅ A안: backup.add_policy(name, schedule, schedule_str=...)
        try:
            backup.add_policy(name=name, schedule=schedule, schedule_str=schedule)
        except TypeError:
            # 혹시 시그니처가 다르면(구버전 잔재) fallback
            backup.add_policy(name, schedule)
        return jsonify({"ok": True}), 200
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/timeshift/policies/update", methods=["POST"])
@admin_required
def api_timeshift_policies_update():
    ok, resp, code = _require_ts_policy()
    if not ok:
        return resp, code

    data = request.get_json(silent=True) or {}
    pid = (data.get("id") or "").strip()
    schedule = (data.get("schedule") or "").strip()

    if not pid:
        return jsonify({"ok": False, "error": "id required"}), 400

    err = _validate_cron_schedule(schedule)
    if err:
        return jsonify({"ok": False, "error": err}), 400

    try:
        # schedule="" 허용(비활성화)
        try:
            ok2 = bool(backup.update_policy(pid, schedule=schedule, schedule_str=schedule))
        except TypeError:
            ok2 = bool(backup.update_policy(pid, schedule=schedule))
        return jsonify({"ok": ok2}), (200 if ok2 else 404)
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/timeshift/policies/delete", methods=["POST"])
@admin_required
def api_timeshift_policies_delete():
    ok, resp, code = _require_ts_policy()
    if not ok:
        return resp, code

    data = request.get_json(silent=True) or {}
    pid = (data.get("id") or "").strip()
    if not pid:
        return jsonify({"ok": False, "error": "id required"}), 400

    try:
        backup.remove_policy(pid)
        return jsonify({"ok": True}), 200
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/timeshift/policies/run", methods=["POST"])
@admin_required
def api_timeshift_policies_run():
    ok, resp, code = _require_ts_policy()
    if not ok:
        return resp, code

    data = request.get_json(silent=True) or {}
    pid = (data.get("id") or "").strip()
    if not pid:
        return jsonify({"ok": False, "error": "id required"}), 400

    try:
        msg = backup.run_policy(pid)
        return jsonify({"ok": True, "msg": str(msg)}), 200
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500



# --------------------
# 접속 및 로그인 기록 추적 추가

def parse_log_file(filepath, keyword=None, tail=100):
    """로그 파일을 tail 수 만큼 읽고 키워드 필터링(선택)"""
    if not os.path.exists(filepath):
        return []
    try:
        with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
            lines = f.readlines()
        lines = lines[-tail:]
        if keyword:
            lines = [line for line in lines if keyword in line]
        return lines
    except Exception:
        return []

@app.route("/logs", methods=["GET"])
def logs_view():
    log_type = request.args.get("type", "auth")
    keyword = request.args.get("keyword", None)
    tail = int(request.args.get("tail", 100))

    if log_type == "samba":
        log_file = "/var/log/samba/log.smbd"
        log_file = "/var/log/syslog"  # WireGuard 로그는 syslog에 기록되는 경우 많음
    else:
        log_file = "/var/log/auth.log"

    logs = parse_log_file(log_file, keyword=keyword, tail=tail)

    return render_template("logs.html", logs=logs, log_type=log_type)

# --------------------
# 네트워크 및 서비스 실시간 모니터링 추가

def get_network_traffic():
    """ifstat 명령으로 네트워크 트래픽 조회"""
    try:
        result = subprocess.run(["ifstat", "-q", "1", "1"], capture_output=True, text=True, timeout=3)
        return result.stdout
    except Exception as e:
        return f"네트워크 트래픽 조회 실패: {e}"

def get_service_status(service_name):
    """systemctl status 서비스 상태 확인"""
    try:
        result = subprocess.run(["systemctl", "is-active", service_name], capture_output=True, text=True, timeout=3)
        return result.stdout.strip()
    except Exception:
        return "unknown"

@app.route("/monitor", methods=["GET"])
def monitor_view():
    network_traffic = get_network_traffic()
    services = {
        "samba": get_service_status("smbd"),
        "wireguard": get_service_status("wg-quick@wg0"),
        "nginx": get_service_status("nginx"),
    }
    return render_template("monitor.html", network_traffic=network_traffic, services=services)

@app.route('/service_control', methods=['POST'])
def service_control():
    service_name = request.form.get('service_name')
    action = request.form.get('action')  # start, stop, restart 등
    result = ""
    try:
        if action == 'start':
            subprocess.run(['sudo', 'systemctl', 'start', service_name], check=True)
            result = f"{service_name} 시작됨"
        elif action == 'stop':
            subprocess.run(['sudo', 'systemctl', 'stop', service_name], check=True)
            result = f"{service_name} 중지됨"
        elif action == 'restart':
            subprocess.run(['sudo', 'systemctl', 'restart', service_name], check=True)
            result = f"{service_name} 재시작됨"
        else:
            result = "알 수 없는 명령"
    except Exception as e:
        result = f"오류 발생: {e}"
    flash(result)
    return redirect(url_for('monitor_view'))



# --------------------
# 패키지 설치 마법사
DESC = {
    "samba": "윈도우 파일 공유 서버(SMB/NAS 기본 공유)",
    "samba-common-bin": "Samba 관리 및 설정 명령어",
    "rsync": "고속 백업 및 동기화 툴",
    "smartmontools": "디스크 상태 진단(SMART, 불량예측)",
    "ntfs-3g": "NTFS 파일시스템 읽기/쓰기 지원",
    "dosfstools": "FAT/ExFAT 파일시스템 관리(USB 등)",
    "xfsprogs": "XFS 파일시스템 관리/포맷",
    "curl": "웹 데이터 다운로드/외부 IP 확인",
    "avahi-daemon": "네트워크 검색/자동인식(mDNS/Bonjour)",
    "cifs-utils": "윈도우/SMB 공유 폴더 마운트(클라이언트)",
    "lsof": "열려있는 파일 및 포트 확인",
    "lsblk": "블록 디바이스(디스크/USB) 정보",
    "udisks2": "디스크 마운트 및 분할 자동화",
    "parted": "파티션 분할 툴",
    "sudo": "관리자 권한 명령어 실행",
    "flask": "웹서버 파이썬 프레임워크",
    "markupsafe": "Flask용 파이썬 라이브러리",
    "werkzeug": "Flask용 WSGI 서버 라이브러리",
    "pillow": "이미지 생성/처리(PIL)"
}
def get_versions():
    vers = {}
    for pkg in DESC.keys():
        if pkg in ["flask", "markupsafe", "werkzeug"]:
            result = subprocess.run(["python3", "-m", "pip", "show", pkg], capture_output=True, text=True)
            for line in result.stdout.splitlines():
                if line.startswith("Version:"):
                    vers[pkg] = line.split(":", 1)[1].strip()
        else:
            result = subprocess.run(["dpkg", "-s", pkg], capture_output=True, text=True)
            for line in result.stdout.splitlines():
                if line.startswith("Version:"):
                    vers[pkg] = line.split(":", 1)[1].strip()
    return vers

@app.route("/install", methods=['GET', 'POST'])
def install_wizard():
    output = None
    status = install.check_all()
    if request.method == 'POST':
        pkg = request.form.get('pkg')
        if pkg == "ALL":
            for p in install.ALL_PKGS:
                install.install_package(p)
            output = "전체 패키지 설치 완료!"
        elif pkg:
            success, out = install.install_package(pkg)
            output = out
        status = install.check_all()
    versions = get_versions()
    return render_template("install.html",
        status=status, output=output, desc=DESC, versions=versions
    )


# 디스크 관리
def force_umount(dev_name):
    """사용 중인 프로세스 강제 종료하고 lazy 언마운트 시도"""
    try:
        proc = subprocess.run(['lsof', '+f', '--', f'/dev/{dev_name}'], capture_output=True, text=True)
        lines = proc.stdout.strip().split('\n')[1:]  # 헤더 제외
        pids = set()
        for line in lines:
            parts = line.split()
            if len(parts) > 1:
                pids.add(parts[1])
        for pid in pids:
            subprocess.run(['sudo', 'kill', '-9', pid])
    except Exception:
        pass
    subprocess.run(['sudo', 'umount', '-l', f'/dev/{dev_name}'], check=False)


# 경로/파일: /home/realnas/realnas-nas-ui/app.py
# Ctrl+F: def _find_mountpoints_by_dev(
# 위치: disk_manage() 위쪽 아무데나 1회 추가

import os
import re
import subprocess

def _run_root(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
    # sudo 비번 요구하면 바로 실패하게 -n
    p = subprocess.run(["sudo", "-n"] + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    if check and p.returncode != 0:
        raise RuntimeError(
            f"CMD FAIL: {' '.join(cmd)}\nSTDOUT:\n{p.stdout}\nSTDERR:\n{p.stderr}"
        )
    return p

def _find_mountpoints_by_dev(dev: str) -> list[str]:
    """
    dev: /dev/sda1 또는 /dev/sda 같은 형태
    반환: 해당 dev가 마운트된 mountpoint 리스트
    """
    mps = []
    try:
        # findmnt가 제일 정확함
        p = subprocess.run(["findmnt", "-rn", "-S", dev, "-o", "TARGET"],
                           stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        if p.returncode == 0:
            for line in p.stdout.splitlines():
                line = line.strip()
                if line:
                    mps.append(line)
    except Exception:
        pass

    # findmnt 실패 시 /proc/mounts fallback
    if not mps:
        try:
            with open("/proc/mounts", "r", encoding="utf-8", errors="ignore") as f:
                for line in f:
                    parts = line.split()
                    if len(parts) >= 2 and parts[0] == dev:
                        mps.append(parts[1])
        except Exception:
            pass

    # 중복 제거
    out = []
    seen = set()
    for mp in mps:
        if mp not in seen:
            out.append(mp)
            seen.add(mp)
    return out

def _kill_busy_processes(mountpoint: str):
    """
    mountpoint를 점유한 프로세스 kill -9
    """
    try:
        p = subprocess.run(["lsof", "+f", "--", mountpoint],
                           stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        lines = p.stdout.splitlines()
        if len(lines) <= 1:
            return
        pids = set()
        for line in lines[1:]:
            cols = line.split()
            if len(cols) >= 2 and cols[1].isdigit():
                pids.add(int(cols[1]))
        for pid in pids:
            subprocess.run(["sudo", "-n", "kill", "-9", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except Exception:
        pass

def _umount_by_dev_if_needed(dev: str):
    """
    dev에 걸린 마운트포인트가 있으면 전부 lazy 언마운트
    """
    mps = _find_mountpoints_by_dev(dev)
    for mp in mps:
        _kill_busy_processes(mp)
        _run_root(["umount", "-l", mp], check=False)

def _mkfs(dev: str, fstype: str):
    fstype = (fstype or "ext4").strip().lower()
    if fstype not in ("ext4", "xfs", "vfat", "ntfs"):
        raise RuntimeError(f"지원하지 않는 파일시스템: {fstype}")

    # 흔적 제거(강추)
    _run_root(["wipefs", "-a", dev], check=False)

    if fstype == "ext4":
        _run_root(["mkfs.ext4", "-F", dev], check=True)
    elif fstype == "xfs":
        _run_root(["mkfs.xfs", "-f", dev], check=True)
    elif fstype == "vfat":
        _run_root(["mkfs.vfat", "-F", "32", dev], check=True)
    elif fstype == "ntfs":
        _run_root(["mkfs.ntfs", "-F", dev], check=True)

def _mount(dev: str, mountpoint: str):
    if not mountpoint:
        raise RuntimeError("mountpoint 비어있음")
    _run_root(["mkdir", "-p", mountpoint], check=True)
    # 이미 마운트 되어있으면 스킵
    mps = _find_mountpoints_by_dev(dev)
    if mountpoint in mps:
        return
    _run_root(["mount", dev, mountpoint], check=True)

def _get_uuid(dev: str) -> str:
    p = subprocess.run(["blkid", "-s", "UUID", "-o", "value", dev],
                       stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
    uuid = p.stdout.strip()
    if not uuid:
        raise RuntimeError(f"UUID를 못 읽음: {dev}\n{p.stderr}")
    return uuid

def _ensure_fstab_uuid(uuid: str, mountpoint: str, fstype: str):
    mountpoint = mountpoint.rstrip("/")
    line = f"UUID={uuid}\t{mountpoint}\t{fstype}\tdefaults,nofail\t0\t2"

    try:
        with open("/etc/fstab", "r", encoding="utf-8", errors="ignore") as f:
            cur = f.read()
    except Exception:
        cur = ""

    # 기존 mountpoint/UUID 라인 제거(대충 정리)
    new_lines = []
    for l in cur.splitlines():
        if not l.strip() or l.strip().startswith("#"):
            new_lines.append(l)
            continue
        if mountpoint in l or f"UUID={uuid}" in l:
            continue
        new_lines.append(l)

    new_lines.append(line)

    tmp = "/tmp/fstab.realnas.tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write("\n".join(new_lines).rstrip() + "\n")
    _run_root(["cp", tmp, "/etc/fstab"], check=True)

# 경로/파일: /home/realnas/realnas-nas-ui/app.py
# Ctrl+F: elif action == "mkfs_mount_elfinder":
# 위치: disk_manage() 안의 POST 분기 구간에 추가
# 경로/파일: /opt/realnas-nas/realnas-nas-ui/app.py
# Ctrl+F: @app.route("/disk"
# 아래 disk_manage() 함수 전체를 이걸로 교체

@app.route("/disk", methods=["GET", "POST"])
def disk_manage():
    disks = get_disks()
    smart_results = {d["name"]: get_smart_auto(d["name"]) for d in disks}

    def _safe_mountpoint(p: str) -> str:
        p = (p or "").strip()
        if not p:
            return ""
        # 절대경로만 허용
        if not p.startswith("/"):
            raise RuntimeError("mountpoint는 절대경로(/로 시작)만 허용")
        # 위험 경로 차단
        blocked = {"/", "/boot", "/boot/efi", "/etc", "/bin", "/sbin", "/usr", "/var", "/proc", "/sys", "/dev", "/run"}
        if p in blocked:
            raise RuntimeError(f"mountpoint로 위험 경로는 금지: {p}")
        # .. 같은 경로 탈출 방지
        p = os.path.normpath(p)
        if p in blocked:
            raise RuntimeError(f"mountpoint로 위험 경로는 금지: {p}")
        return p

    if request.method == "POST":
        action = (request.form.get("action") or "").strip()
        name = (request.form.get("name") or "").strip()
        fs = (request.form.get("fstype") or "ext4").strip().lower()

        if not name:
            flash("대상 디바이스/파티션(name)이 비어있음", "danger")
            return render_template("disk.html", disks=disks, smart_results=smart_results)

        dev = f"/dev/{name}"
        app.logger.debug(f"[DISK] action={action} dev={dev} fstype={fs}")

        try:
            if action == "mount":
                mp = _safe_mountpoint(request.form.get("mountpoint"))
                if not mp:
                    raise RuntimeError("mountpoint가 비어있음")

                # 기존 mount() 대신 우리가 만든 _mount 사용(중복 마운트 체크 포함)
                _mount(dev, mp)
                flash(f"{name} 마운트 완료 → {mp}", "success")

            elif action == "umount":
                _umount_by_dev_if_needed(dev)
                flash(f"{name} 언마운트 완료", "success")

            elif action == "mkfs":
                _umount_by_dev_if_needed(dev)
                _mkfs(dev, fs)
                flash(f"{name} 포맷 성공 ({fs})", "success")

            elif action == "mkfs_mount_elfinder":
                """
                포맷 누르면 자동으로:
                - 포맷
                - 대표폴더(get_default_share_path) 또는 /mnt/storage 마운트
                - fstab UUID 등록
                """
                target_mount = get_default_share_path() or "/mnt/storage"
                target_mount = _safe_mountpoint(target_mount)

                # target_mount가 파일이면 안됨
                if os.path.exists(target_mount) and not os.path.isdir(target_mount):
                    raise RuntimeError(f"마운트 경로가 디렉토리가 아님: {target_mount}")

                _umount_by_dev_if_needed(dev)
                _mkfs(dev, fs)
                _mount(dev, target_mount)

                uuid = _get_uuid(dev)
                _ensure_fstab_uuid(uuid, target_mount, fs)

                # 권한(원하면)
                _run_root(["chown", "-R", "realnas:realnas", target_mount], check=False)
                _run_root(["chmod", "-R", "777", target_mount], check=False)

                flash(f"{name} 포맷+마운트+fstab 등록 완료 → {target_mount}", "success")

            elif action == "smart":
                # 화면 갱신용(이미 위에서 smart_results 채움)
                flash("SMART 조회 갱신", "info")

            else:
                flash(f"알 수 없는 action: {action}", "warning")

        except Exception as e:
            flash(f"{name} 작업 실패: {e}", "danger")
            try:
                with open("/tmp/disk_manage_error.log", "a", encoding="utf-8") as f:
                    f.write(f"{time.ctime()} - {name} action={action} 실패: {e}\n")
            except Exception:
                pass

        # POST 후 새로고침(중복 submit 방지)
        disks = get_disks()
        smart_results = {d["name"]: get_smart_auto(d["name"]) for d in disks}

    return render_template("disk.html", disks=disks, smart_results=smart_results)


def handle_disk_post_action(form):
    """
    기존에 네가 쓰던 /disk POST 핸들링이 있으면,
    그 안에 아래 분기(action == 'mkfs_mount_elfinder')만 추가해서 써도 됨.
    """
    action = (form.get("action") or "").strip()
    name = (form.get("name") or "").strip()   # 예: nvme0n1p2, sda1
    fstype = (form.get("fstype") or "ext4").strip()
    mountpoint = (form.get("mountpoint") or "").strip()
    alias = (form.get("alias") or "DATA").strip()
    web_path = (form.get("web_path") or "data").strip()

    if action != "mkfs_mount_elfinder":
        return False  # 기존 로직으로 넘김

    if not name:
        raise RuntimeError("대상 파티션이 비어있음")

    dev = f"/dev/{name}"

    # 1) 언마운트
    _umount_by_dev_if_needed(dev)

    # 2) 포맷
    _mkfs(dev, fstype)

    # 3) 마운트 + fstab 등록
    if not mountpoint:
        mountpoint = f"/mnt/{name}"
    _mount(dev, mountpoint)

    uuid = _get_uuid(dev)
    _ensure_fstab_uuid(uuid, mountpoint, fstype)

    # 4) elFinder 설정 저장
    _write_elfinder_json(alias, mountpoint, web_path)

    return True
# 디스크 SMART 상태 조회 함수
def get_smart_auto(dev_name):
    device = f"/dev/{dev_name}"
    if dev_name.startswith("nvme"):
        cmd = ["sudo", "smartctl", "-H", "-d", "nvme", "-T", "permissive", device]
    elif dev_name.startswith("sd"):
        cmd = ["sudo", "smartctl", "-H", "-d", "sat", "-T", "permissive", device]
    else:
        cmd = ["sudo", "smartctl", "-H", "-T", "permissive", device]
    try:
        out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
        if ("Unknown USB bridge" in out or "Unsupported USB bridge" in out or
            "Read Device Identity failed" in out or "SMART support is: Ambiguous" in out):
            return "미지원"
        if "SMART overall-health self-assessment test result: PASSED" in out:
            return "정상"
        if "SMART overall-health self-assessment test result: FAILED" in out:
            return "체크"
        if "SMART Health Status: OK" in out:
            return "정상"
        return "알 수 없음"
    except subprocess.CalledProcessError as e:
        if ("SMART support is: Unavailable" in e.output or
            "Unknown device type" in e.output or
            "Unknown USB bridge" in e.output):
            return "미지원"
        return "체크"
    except Exception:
        return "미지원"


def get_disks():
    df_result = subprocess.run(
        ["df", "-h", "--output=source,size,used,avail,pcent,target"],
        capture_output=True, text=True
    )
    df_lines = df_result.stdout.strip().split('\n')[1:]
    df_map = {}
    for line in df_lines:
        cols = line.split()
        if len(cols) == 6:
            dev_short = cols[0].replace('/dev/', '')
            df_map[dev_short] = {
                "size": cols[1],
                "used": cols[2],
                "avail": cols[3],
                "percent": cols[4],
                "mountpoint": cols[5]
            }
    result = subprocess.run(["lsblk", "-J", "-o", "NAME,SIZE,FSTYPE,MOUNTPOINT,LABEL,TYPE"], capture_output=True, text=True)
    data = json.loads(result.stdout)
    disks = []
    for dev in data['blockdevices']:
        if dev['type'] == 'disk' and (dev['name'].startswith('nvme') or dev['name'].startswith('sd')):
            d_df = df_map.get(dev['name'], {})
            temperature = get_disk_temperature(dev["name"])
            d = {
                "name": dev["name"],
                "size": dev["size"],
                "fstype": dev.get("fstype") or "",
                "mountpoint": dev.get("mountpoint") or d_df.get("mountpoint", ""),
                "label": dev.get("label") or "",
                "type": dev["type"],
                "used": d_df.get("used", ""),
                "avail": d_df.get("avail", ""),
                "percent": d_df.get("percent", ""),
                "smart_status": get_smart_auto(dev["name"]),
                "temperature": temperature if temperature is not None else "-",
                "parts": []
            }
            for part in dev.get("children", []):
                p_df = df_map.get(part.get("name", ""), {})
                d["parts"].append({
                    "name": part.get("name"),
                    "size": part.get("size"),
                    "fstype": part.get("fstype") or "",
                    "mountpoint": part.get("mountpoint") or p_df.get("mountpoint", ""),
                    "label": part.get("label") or "",
                    "type": part.get("type"),
                    "used": p_df.get("used", ""),
                    "avail": p_df.get("avail", ""),
                    "percent": p_df.get("percent", "")
                })
            disks.append(d)
    return disks

# API 엔드포인트: 디스크 목록 및 상태
@app.route('/api/disks', methods=['GET'])
def api_disks():
    disks = get_disks()
    return jsonify(disks)

def get_disk_temperature(dev_name):
    device = f"/dev/{dev_name}"
    if dev_name.startswith("nvme"):
        cmd = ["sudo", "smartctl", "-A", "-d", "nvme", "-T", "permissive", device]
    elif dev_name.startswith("sd"):
        cmd = ["sudo", "smartctl", "-A", "-d", "sat", "-T", "permissive", device]
    else:
        cmd = ["sudo", "smartctl", "-A", "-T", "permissive", device]

    try:
        out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
        if dev_name.startswith("nvme"):
            # nvme 온도는 "Temperature:   49 Celsius" 이런 텍스트 줄에서 숫자 파싱
            for line in out.splitlines():
                if "Temperature:" in line and "Celsius" in line:
                    parts = line.split()
                    for part in parts:
                        if part.isdigit():
                            return int(part)
            return None
        else:
            # 기존 SATA/HDD 온도 파싱 (표 형식)
            for line in out.splitlines():
                if "194 Temperature_Celsius" in line or "Temperature_Celsius" in line:
                    parts = line.split()
                    temp_str = parts[-1]
                    if temp_str.isdigit():
                        return int(temp_str)
    except Exception as e:
        print(f"Error getting temperature for {dev_name}: {e}")

    return None

@app.route('/api/mounts')
def api_mounts():
    try:
        result = subprocess.run(['lsblk', '-J', '-o', 'NAME,MOUNTPOINT,TYPE'], capture_output=True, text=True)
        data = json.loads(result.stdout)

        mounts = []
        seen_devices = set()

        # 제외할 마운트포인트(시스템 볼륨 등)
        SYSTEM_MOUNTPOINTS = {
            '/', '/boot', '/boot/efi', '/snap', '/home', '/var', '/tmp', '/srv'
        }
        # 제외할 devname(파티션명, 필요하면 추가)
        SYSTEM_DEVS = {'nvme0n1p2'}

        def walk_devices(devices):
            for dev in devices:
                if dev['type'] in ['loop', 'rom', 'sr']:
                    continue
                devname = dev['name']
                mountpoint = dev.get('mountpoint')
                if mountpoint:
                    # 시스템 경로/디바이스 필터링
                    if (devname in SYSTEM_DEVS) or any(mountpoint == x or mountpoint.startswith(x + '/') for x in SYSTEM_MOUNTPOINTS):
                        continue
                    if devname.startswith(('sd', 'nvme', 'mmcblk')):
                        if devname not in seen_devices:
                            mounts.append({'name': devname, 'mountpoint': mountpoint})
                            seen_devices.add(devname)
                if 'children' in dev:
                    walk_devices(dev['children'])

        walk_devices(data['blockdevices'])
        return jsonify(mounts)
    except Exception as e:
        app.logger.error(f'/api/mounts 오류: {e}')
        return jsonify([]), 500


import os
import subprocess

def create_symlink(target_path, link_path):
    try:
        if os.path.exists(link_path):
            os.remove(link_path)
        subprocess.run(['ln', '-s', target_path, link_path], check=True)
        return True, "바로가기 생성 완료"
    except Exception as e:
        return False, f"바로가기 생성 실패: {e}"

SHORTCUT_BASE = os.path.join(BASE_DIR, "shortcuts")


def set_permissions(path, user='admin', group='admin', mode='750'):
    try:
        subprocess.run(['chown', f'{user}:{group}', path], check=True)
        subprocess.run(['chmod', mode, path], check=True)
        return True
    except Exception as e:
        print(f"권한 설정 실패: {e}")
        return False

@app.route('/create_shortcut', methods=['POST'])
def create_shortcut():
    try:
        data = request.get_json()
        target_real_path = data.get('path')
        shortcut_name = data.get('name')

        if not target_real_path or not shortcut_name:
            return jsonify({'error': '경로 또는 이름이 누락되었습니다.'}), 400

        # 경로 탈출 방지: 이름에 슬래시, .. 등 있으면 안 됨
        if '/' in shortcut_name or '\\' in shortcut_name or '..' in shortcut_name:
            return jsonify({'error': '바로가기 이름에 잘못된 문자가 포함되어 있습니다.'}), 400

        # 대표폴더 절대경로
        default_share_path = get_default_share_path()
        if not default_share_path:
            return jsonify({'error': '대표폴더 경로를 찾을 수 없습니다.'}), 400

        # 대상 경로 존재 여부 확인
        if not os.path.exists(target_real_path):
            return jsonify({'error': '대상 경로가 존재하지 않습니다.'}), 400

        # 바로가기 저장 폴더 (대표폴더 내에 'shortcuts' 폴더)
        shortcut_base_dir = os.path.join(default_share_path, 'shortcuts')
        if not os.path.exists(shortcut_base_dir):
            os.makedirs(shortcut_base_dir)

        # 바로가기 경로 (대표폴더/shortcuts/바로가기명)
        shortcut_path = os.path.join(shortcut_base_dir, shortcut_name)

        if os.path.exists(shortcut_path):
            os.remove(shortcut_path)

        # 심볼릭 링크 생성 : shortcut_path → target_real_path
        os.symlink(target_real_path, shortcut_path)

        # 권한 설정 시도 (필요하다면)
        set_permissions(shortcut_path, user='realnas', group='realnas', mode='750')

        return jsonify({'message': f"'{shortcut_name}' 바로가기가 생성되었습니다."}), 200

    except Exception as e:
        app.logger.error(f'/create_shortcut 오류: {e}')
        return jsonify({'error': '바로가기 생성 실패', 'detail': str(e)}), 500



@app.route('/api/ping')
def ping():
    return jsonify({"status": "ok"})



from flask import request, render_template, flash, redirect, url_for


@app.route("/user", methods=['GET', 'POST'])
def user_manage():
    if request.method == 'POST':
        action = request.form.get('action')
        username = request.form.get('username')
        if action == "add":
            password = request.form.get('password')
            print(f"[DEBUG] 사용자 추가 시도: {username}")
            success = user.add_user(username, password)
            print(f"[DEBUG] 사용자 추가 성공 여부: {success}")
            if success:
                flash(f"{username} 사용자 추가 성공", "success")
            else:
                flash(f"{username} 사용자 추가 실패", "error")

        elif action == "remove":
            print(f"[DEBUG] 사용자 삭제 시도: {username}")
            success = user.remove_user(username)
            print(f"[DEBUG] 사용자 삭제 성공 여부: {success}")
            if success:
                flash(f"{username} 사용자 삭제 성공", "success")
            else:
                flash(f"{username} 사용자 삭제 실패", "error")

        elif action == "change_password":
            new_password = request.form.get('new_password')
            print(f"[DEBUG] 비밀번호 변경 시도: {username}")
            success = user.change_password(username, new_password)
            print(f"[DEBUG] 비밀번호 변경 성공 여부: {success}")
            if success:
                flash(f"{username} 비밀번호 변경 성공", "success")
            else:
                flash(f"{username} 비밀번호 변경 실패", "error")

        return redirect(url_for('user_manage'))

    users = user.list_users()
    return render_template("user.html", users=users)


# --------------------
# 관리자 계정 설정
SUDOERS_PATH = "/etc/sudoers"
SUDOERS_BACKUP = "/etc/sudoers.bak"
SUDOERS_CMDS = "/usr/bin/apt-get, /usr/bin/pip3, /usr/bin/smbpasswd, /usr/sbin/useradd, /usr/sbin/userdel, /usr/bin/rsync, /usr/bin/systemctl"

@app.route("/admin", methods=['GET', 'POST'])
def admin_settings():
    msg = error = None
    sudo_user = ""
    try:
        with open(SUDOERS_PATH, "r") as f:
            content = f.read()
        match = re.search(r"^([a-zA-Z0-9_-]+) ALL=\(ALL\) NOPASSWD: .+$", content, re.MULTILINE)
        if match:
            sudo_user = match.group(1)
    except Exception as e:
        error = f"sudoers 읽기 오류: {e}"
    if request.method == 'POST':
        new_user = request.form.get('sudo_user').strip()
        if new_user:
            try:
                shutil.copy(SUDOERS_PATH, SUDOERS_BACKUP)
                new_content = re.sub(
                    r"^[a-zA-Z0-9_-]+ ALL=\(ALL\) NOPASSWD: .+$",
                    f"{new_user} ALL=(ALL) NOPASSWD: {SUDOERS_CMDS}",
                    content, flags=re.MULTILINE)
                with open(SUDOERS_PATH, "w") as f:
                    f.write(new_content)
                result = subprocess.run(["visudo", "-c"], capture_output=True, text=True)
                if "OK" in result.stdout:
                    msg = f"sudoers 계정명을 '{new_user}'로 변경 완료!"
                    sudo_user = new_user
                else:
                    shutil.copy(SUDOERS_BACKUP, SUDOERS_PATH)
                    error = f"sudoers 문법오류! 변경 취소됨: {result.stdout}"
            except Exception as e:
                error = f"sudoers 수정 오류: {e}"
    current = ""
    try:
        with open(SUDOERS_PATH, "r") as f:
            for line in f:
                if "NOPASSWD" in line:
                    current = line.strip()
    except:
        pass
    return render_template("admin.html", sudo_user=sudo_user, msg=msg, error=error, current=current)

# ------------

if __name__ == "__main__":
    print("=== Flask Registered Routes ===")
    for rule in app.url_map.iter_rules():
        print(f"{rule.endpoint}: {rule}")
    print("===============================")
    app.run(host="0.0.0.0", port=5001, debug=True)
