名前順で並び替えするようにしたパスワードマネージャ「SPW」

このツールのことばかりになりますが。そして、このツールに関してはそんなに機能を追加するつもりはなく、極力シンプルな状態のままにするつもりではありますが。アイテムが増えていくならさすがに並び替えされないと不便かなとカテゴリーとアイテムをそれぞれ名前順で並び替えされるようにしました。

LXDコンテナにインストール

#!/bin/bash
set -e

INSTALL_DIR="/opt/lxd-data/spw"
SERVICE_NAME="spw"
PORT="${PORT:-3345}"
TAILSCALE_PORT=3344

echo "=== SPW Password Manager Installer ==="

# Check root
if [ "$EUID" -ne 0 ]; then
  echo "Error: Run as root (sudo bash $0)"
  exit 1
fi

# Python3確認
if ! command -v python3 &>/dev/null; then
  echo "Error: python3 が見つかりません。インストールしてください。"
  exit 1
fi
echo "Python3: $(python3 --version)"

# Install 7zip if missing
if ! command -v 7z &>/dev/null; then
  echo "Installing 7zip..."
  if command -v apt-get &>/dev/null; then
    apt-get update -qq && apt-get install -y -qq p7zip-full
  elif command -v yum &>/dev/null; then
    yum install -y p7zip p7zip-plugins
  elif command -v dnf &>/dev/null; then
    dnf install -y p7zip p7zip-plugins
  elif command -v pacman &>/dev/null; then
    pacman -Sy --noconfirm p7zip
  else
    echo "Error: Cannot install 7zip. Install manually."
    exit 1
  fi
fi
echo "7zip: $(7z --help 2>&1 | head -1)"

# Create directories
mkdir -p "$INSTALL_DIR/public" "$INSTALL_DIR/data/backups"

# server.py
cat > "$INSTALL_DIR/server.py" << 'PYEOF'
#!/usr/bin/env python3
import os, json, hashlib, hmac, secrets, tempfile, shutil, subprocess, threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
from pathlib import Path
from datetime import datetime

PORT      = int(os.environ.get('PORT', 3345))
BASE_DIR  = Path(__file__).parent
DATA_DIR  = BASE_DIR / 'data'
PW_ZIP    = DATA_DIR / 'spw.zip'
CFG_FILE  = DATA_DIR / 'config.json'
BACKUP_DIR= DATA_DIR / 'backups'
PUB_DIR   = BASE_DIR / 'public'

DATA_DIR.mkdir(parents=True, exist_ok=True)
BACKUP_DIR.mkdir(parents=True, exist_ok=True)

sessions = {}  # token -> password
sessions_lock = threading.Lock()

MIME = {
  '.html': 'text/html; charset=utf-8',
  '.css':  'text/css; charset=utf-8',
  '.js':   'application/javascript; charset=utf-8',
  '.json': 'application/json',
  '.ico':  'image/x-icon',
  '.svg':  'image/svg+xml',
}

# ---------- config ----------
def load_config():
  try:
    return json.loads(CFG_FILE.read_text())
  except:
    return {}

def save_config(cfg):
  CFG_FILE.write_text(json.dumps(cfg, indent=2))

# ---------- password hash ----------
def hash_password(password):
  salt = secrets.token_hex(16)
  dk = hashlib.scrypt(password.encode(), salt=salt.encode(), n=16384, r=8, p=1, dklen=64)
  return {'salt': salt, 'hash': dk.hex()}

def verify_password(password, stored):
  dk = hashlib.scrypt(password.encode(), salt=stored['salt'].encode(), n=16384, r=8, p=1, dklen=64)
  return hmac.compare_digest(dk.hex(), stored['hash'])

# ---------- zip ----------
def save_encrypted(data, password):
  tmp = Path(tempfile.mkdtemp())
  try:
    jf = tmp / 'passwords.json'
    jf.write_text(json.dumps(data, indent=2, ensure_ascii=False))
    if PW_ZIP.exists():
      PW_ZIP.unlink()
    subprocess.run(
      ['7z', 'a', '-tzip', '-mem=AES256', f'-p{password}', str(PW_ZIP), str(jf)],
      check=True, capture_output=True, timeout=30
    )
  finally:
    shutil.rmtree(tmp, ignore_errors=True)

def load_encrypted(password):
  if not PW_ZIP.exists():
    return {'categories': []}
  tmp = Path(tempfile.mkdtemp())
  try:
    subprocess.run(
      ['7z', 'x', f'-p{password}', f'-o{tmp}', str(PW_ZIP), '-y'],
      check=True, capture_output=True, timeout=30
    )
    jf = tmp / 'passwords.json'
    return json.loads(jf.read_text()) if jf.exists() else {'categories': []}
  except:
    return {'categories': []}
  finally:
    shutil.rmtree(tmp, ignore_errors=True)

# ---------- HTTP handler ----------
class Handler(BaseHTTPRequestHandler):
  def log_message(self, fmt, *args):
    pass  # アクセスログ抑制

  def send_json(self, code, obj):
    body = json.dumps(obj, ensure_ascii=False).encode()
    self.send_response(code)
    self.send_header('Content-Type', 'application/json')
    self.send_header('Content-Length', str(len(body)))
    self.end_headers()
    self.wfile.write(body)

  def send_bytes(self, code, body, content_type, filename=None):
    self.send_response(code)
    self.send_header('Content-Type', content_type)
    self.send_header('Content-Length', str(len(body)))
    if filename:
      self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
    self.end_headers()
    self.wfile.write(body)

  def auth_token(self):
    return self.headers.get('x-auth-token', '')

  def get_session_password(self):
    token = self.auth_token()
    with sessions_lock:
      s = sessions.get(token)
    return s['password'] if s else None

  def read_json(self):
    length = int(self.headers.get('Content-Length', 0))
    return json.loads(self.rfile.read(length)) if length else {}

  def serve_static(self, path):
    p = PUB_DIR / path.lstrip('/')
    if not p.exists() or not p.is_file():
      p = PUB_DIR / 'index.html'
    ext = p.suffix.lower()
    mime = MIME.get(ext, 'application/octet-stream')
    body = p.read_bytes()
    self.send_bytes(200, body, mime)

  def do_GET(self):
    parsed = urlparse(self.path)
    path = parsed.path

    if path == '/api/auth/status':
      cfg = load_config()
      self.send_json(200, {'hasPassword': bool(cfg.get('passwordHash'))})

    elif path == '/api/passwords':
      pw = self.get_session_password()
      if pw is None:
        self.send_json(401, {'error': 'Unauthorized'})
        return
      self.send_json(200, load_encrypted(pw))

    else:
      self.serve_static(path if path != '/' else '/index.html')

  def do_POST(self):
    parsed = urlparse(self.path)
    path = parsed.path

    if path == '/api/auth/setup':
      cfg = load_config()
      if cfg.get('passwordHash'):
        self.send_json(400, {'error': 'Password already set'}); return
      body = self.read_json()
      pw = body.get('password', '')
      if len(pw) < 4:
        self.send_json(400, {'error': 'Password must be at least 4 characters'}); return
      cfg['passwordHash'] = hash_password(pw)
      save_config(cfg)
      token = secrets.token_hex(32)
      with sessions_lock:
        sessions[token] = {'password': pw}
      self.send_json(200, {'success': True, 'token': token})

    elif path == '/api/auth/login':
      cfg = load_config()
      if not cfg.get('passwordHash'):
        self.send_json(400, {'error': 'No password set'}); return
      body = self.read_json()
      pw = body.get('password', '')
      if not pw:
        self.send_json(400, {'error': 'Password required'}); return
      if not verify_password(pw, cfg['passwordHash']):
        self.send_json(401, {'error': 'Wrong password'}); return
      token = secrets.token_hex(32)
      with sessions_lock:
        sessions[token] = {'password': pw}
      self.send_json(200, {'success': True, 'token': token})

    elif path == '/api/auth/logout':
      token = self.auth_token()
      with sessions_lock:
        sessions.pop(token, None)
      self.send_json(200, {'success': True})

    elif path == '/api/auth/change-password':
      pw = self.get_session_password()
      if pw is None:
        self.send_json(401, {'error': 'Unauthorized'}); return
      body = self.read_json()
      cur = body.get('currentPassword', '')
      new = body.get('newPassword', '')
      cfg = load_config()
      if not verify_password(cur, cfg['passwordHash']):
        self.send_json(401, {'error': 'Current password is wrong'}); return
      if len(new) < 4:
        self.send_json(400, {'error': 'New password must be at least 4 characters'}); return
      data = load_encrypted(pw)
      cfg['passwordHash'] = hash_password(new)
      save_config(cfg)
      save_encrypted(data, new)
      token = self.auth_token()
      with sessions_lock:
        if token in sessions:
          sessions[token]['password'] = new
      self.send_json(200, {'success': True})

    elif path == '/api/passwords':
      pw = self.get_session_password()
      if pw is None:
        self.send_json(401, {'error': 'Unauthorized'}); return
      body = self.read_json()
      save_encrypted(body, pw)
      self.send_json(200, {'success': True})

    elif path == '/api/export':
      pw = self.get_session_password()
      if pw is None:
        self.send_json(401, {'error': 'Unauthorized'}); return
      data = load_encrypted(pw)
      tmp = Path(tempfile.mkdtemp())
      try:
        jf = tmp / 'passwords.json'
        jf.write_text(json.dumps(data, indent=2, ensure_ascii=False))
        ts = datetime.now().strftime('%Y-%m-%dT%H-%M-%S')
        filename = f'passwords-{ts}.zip'
        zippath = tmp / filename
        subprocess.run(
          ['7z', 'a', '-tzip', '-mem=AES256', f'-p{pw}', str(zippath), str(jf)],
          check=True, capture_output=True, timeout=30
        )
        body = zippath.read_bytes()
        self.send_bytes(200, body, 'application/zip', filename)
      except Exception as e:
        self.send_json(500, {'error': str(e)})
      finally:
        shutil.rmtree(tmp, ignore_errors=True)

    elif path == '/api/import':
      pw = self.get_session_password()
      if pw is None:
        self.send_json(401, {'error': 'Unauthorized'}); return
      body = self.read_json()
      file_pw = body.get('password', '')
      file_data = body.get('fileData', '')
      if not file_pw or not file_data:
        self.send_json(400, {'error': 'Password and file required'}); return
      import base64
      tmp = Path(tempfile.mkdtemp())
      try:
        arc = tmp / 'import.zip'
        arc.write_bytes(base64.b64decode(file_data))
        out = tmp / 'out'
        out.mkdir()
        subprocess.run(
          ['7z', 'x', f'-p{file_pw}', f'-o{out}', str(arc), '-y'],
          check=True, capture_output=True, timeout=30
        )
        jf = out / 'passwords.json'
        if not jf.exists():
          self.send_json(400, {'error': 'passwords.json not found in archive'}); return
        imported = json.loads(jf.read_text())
        save_encrypted(imported, pw)
        self.send_json(200, {'success': True})
      except Exception:
        self.send_json(500, {'error': 'Wrong password or corrupt file'})
      finally:
        shutil.rmtree(tmp, ignore_errors=True)

    else:
      self.send_json(404, {'error': 'Not found'})

from http.server import ThreadingHTTPServer
print(f'SPW Password Manager running on http://0.0.0.0:{PORT}')
server = ThreadingHTTPServer(('0.0.0.0', PORT), Handler)
server.serve_forever()
PYEOF

# index.html
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SPW - Password Manager</title>
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔐</text></svg>">
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div id="auth-screen">
    <div class="auth-box">
      <h2>SPW</h2>
      <div id="auth-setup">
        <p>初回アクセスです。パスワードを設定してください。</p>
        <input type="password" id="setup-password" placeholder="パスワード">
        <input type="password" id="setup-password-confirm" placeholder="パスワード(確認)">
        <button id="btn-setup" class="btn-primary">設定</button>
      </div>
      <div id="auth-login" style="display:none">
        <p>パスワードを入力してください。</p>
        <input type="password" id="login-password" placeholder="パスワード">
        <button id="btn-login" class="btn-primary">ログイン</button>
      </div>
      <div id="auth-error" class="auth-error" style="display:none"></div>
    </div>
  </div>

  <div id="app" style="display:none">
    <aside id="sidebar">
      <div class="sidebar-header">
        <h2>SPW</h2>
        <button id="btn-export" class="btn-small" title="エクスポート">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
        </button>
        <button id="btn-import" class="btn-small" title="インポート">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
        </button>
        <button id="btn-add-category" class="btn-small" title="カテゴリー追加">
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
        </button>
      </div>
      <div class="search-box">
        <input type="text" id="search-input" placeholder="検索...">
      </div>
      <div id="category-list"></div>
      <div class="sidebar-footer">
        <button id="btn-change-pw" class="btn-small" title="パスワード変更">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
          パスワード変更
        </button>
        <button id="btn-logout" class="btn-small" title="ログアウト">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
          ログアウト
        </button>
      </div>
    </aside>
    <main id="main-content">
      <div id="welcome-screen">
        <p>カテゴリーとカードを選択してください</p>
      </div>
      <div id="card-detail" style="display:none">
        <div class="detail-header">
          <input type="text" id="detail-card-name" class="card-name-input">
          <div class="detail-actions">
            <button id="btn-add-field" class="btn-small">フィールド追加</button>
            <button id="btn-delete-card" class="btn-small btn-danger">カード削除</button>
            <button id="btn-save-card" class="btn-primary">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
              保存
            </button>
          </div>
        </div>
        <div id="detail-fields"></div>
      </div>
    </main>
  </div>

  <div id="export-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>エクスポート</h3>
      <p>暗号化ファイルをダウンロードします</p>
      <div class="modal-actions">
        <button id="btn-export-cancel" class="btn-small">キャンセル</button>
        <button id="btn-export-confirm" class="btn-primary">ダウンロード</button>
      </div>
    </div>
  </div>

  <div id="import-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>インポート</h3>
      <p>パスワードを入力してください</p>
      <input type="password" id="import-password" placeholder="パスワード">
      <input type="file" id="import-file" accept=".zip" style="margin-bottom:12px;color:#e0e0e0">
      <div class="modal-actions">
        <button id="btn-import-cancel" class="btn-small">キャンセル</button>
        <button id="btn-import-confirm" class="btn-primary">インポート</button>
      </div>
    </div>
  </div>

  <div id="change-pw-modal" class="modal" style="display:none">
    <div class="modal-content">
      <h3>パスワード変更</h3>
      <input type="password" id="current-password" placeholder="現在のパスワード">
      <input type="password" id="new-password" placeholder="新しいパスワード">
      <input type="password" id="new-password-confirm" placeholder="新しいパスワード(確認)">
      <div class="modal-actions">
        <button id="btn-change-pw-cancel" class="btn-small">キャンセル</button>
        <button id="btn-change-pw-confirm" class="btn-primary">変更</button>
      </div>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>
HTMLEOF

# style.css
cat > "$INSTALL_DIR/public/style.css" << 'CSSEOF'
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --bg: #f5f5f5;
  --surface: #ffffff;
  --border: #d0d0d0;
  --text: #1a1a1a;
  --text-muted: #666;
  --accent: #0055a5;
  --accent-hover: #003d7a;
  --danger: #c0392b;
  --row-alt: #f9f9f9;
  --row-hover: #e8f0fe;
  --header-bg: #e2e8f0;
  --input-bg: #fff;
  --scrollbar: #c0c0c0;
  --scrollbar-hover: #999;
  --selection: #b3d4fc;
}

body {
  font-family: 'Segoe UI', 'Meiryo', 'Hiragino Sans', sans-serif;
  background: var(--bg);
  color: var(--text);
  height: 100vh;
  overflow: hidden;
  font-size: 13px;
  line-height: 1.4;
}

::selection { background: var(--selection); }

#auth-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background: var(--header-bg);
}

.auth-box {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  padding: 32px;
  width: 340px;
}

.auth-box h2 {
  color: var(--text);
  font-size: 20px;
  font-weight: 700;
  margin-bottom: 4px;
  letter-spacing: 1px;
}

.auth-box > div > p {
  color: var(--text-muted);
  font-size: 12px;
  margin-bottom: 16px;
}

.auth-box input {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 10px;
  outline: none;
}

.auth-box input:focus { border-color: var(--accent); }

.auth-error {
  color: var(--danger);
  font-size: 12px;
  margin-top: 4px;
}

#app { display: flex; height: 100vh; }

#sidebar {
  width: 260px;
  background: var(--surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
}

.sidebar-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  border-bottom: 1px solid var(--border);
  background: var(--header-bg);
  gap: 4px;
}

.sidebar-header h2 {
  font-size: 13px;
  font-weight: 700;
  color: var(--text);
  flex: 1;
  letter-spacing: 0.5px;
  text-transform: uppercase;
}

.search-box {
  padding: 4px 6px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}

.search-box input {
  width: 100%;
  padding: 5px 8px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 12px;
  outline: none;
}

.search-box input:focus { border-color: var(--accent); }

#category-list { flex: 1; overflow-y: auto; }

.sidebar-footer {
  border-top: 1px solid var(--border);
  padding: 4px 6px;
  display: flex;
  gap: 4px;
  background: var(--header-bg);
}

.sidebar-footer .btn-small { flex: 1; font-size: 11px; padding: 4px 6px; }

.category-item { border-bottom: 1px solid #eee; }

.category-header {
  display: flex;
  align-items: center;
  padding: 6px 8px;
  cursor: pointer;
  font-weight: 700;
  font-size: 12px;
  color: var(--text);
  background: var(--row-alt);
  border-bottom: 1px solid var(--border);
}

.category-header:hover { background: var(--row-hover); }

.category-header .cat-name {
  flex: 1;
  text-transform: uppercase;
  letter-spacing: 0.3px;
}

.category-header .cat-actions { display: none; gap: 2px; }
.category-header:hover .cat-actions { display: flex; }

.card-item {
  display: flex;
  align-items: center;
  padding: 5px 8px 5px 20px;
  cursor: pointer;
  font-size: 12px;
  color: var(--text);
  border-bottom: 1px solid #f0f0f0;
}

.card-item:hover { background: var(--row-hover); }
.card-item.active { background: var(--accent); color: #fff; }
.card-item .card-name { flex: 1; }

#main-content {
  flex: 1;
  overflow-y: auto;
  background: var(--bg);
}

#welcome-screen {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  color: var(--text-muted);
  font-size: 13px;
}

.detail-header {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  gap: 8px;
  background: var(--header-bg);
  border-bottom: 1px solid var(--border);
}

.card-name-input {
  flex: 1;
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
  background: var(--input-bg);
  border: 1px solid var(--border);
  padding: 4px 8px;
  outline: none;
}

.card-name-input:focus { border-color: var(--accent); }
.detail-actions { display: flex; gap: 4px; }
#detail-fields { background: var(--surface); }

.field-row {
  display: flex;
  align-items: center;
  border-bottom: 1px solid var(--border);
}

.field-row:nth-child(even) { background: var(--row-alt); }

.field-label-input {
  width: 160px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  flex-shrink: 0;
  background: transparent;
  border: none;
  border-right: 1px solid var(--border);
  padding: 8px 10px;
  outline: none;
}

.field-label-input:focus { background: var(--row-hover); }

.field-input-wrap {
  flex: 1;
  display: flex;
  align-items: center;
}

.field-input-wrap input {
  flex: 1;
  padding: 8px 10px;
  border: none;
  background: transparent;
  color: var(--text);
  font-size: 13px;
  outline: none;
}

.field-input-wrap input:focus { background: var(--row-hover); }

.btn-toggle-pw {
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  display: flex;
  align-items: center;
  height: 100%;
}

.btn-toggle-pw:hover { color: var(--accent); background: var(--row-hover); }

.btn-copy {
  padding: 6px 10px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  font-size: 11px;
  white-space: nowrap;
  height: 100%;
}

.btn-copy:hover { color: var(--accent); background: var(--row-hover); }
.btn-copy.copied { color: #fff; background: #2e7d32; }

.btn-delete-field {
  padding: 6px 8px;
  background: transparent;
  border: none;
  border-left: 1px solid var(--border);
  color: var(--text-muted);
  cursor: pointer;
  font-size: 14px;
  height: 100%;
}

.btn-delete-field:hover { color: var(--danger); background: #fce4ec; }

.btn-small {
  padding: 4px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--text);
  cursor: pointer;
  font-size: 11px;
  outline: none;
}

.btn-small:hover { background: var(--row-hover); border-color: var(--accent); }

.btn-primary {
  padding: 6px 20px;
  background: var(--accent);
  border: 1px solid var(--accent-hover);
  color: #fff;
  cursor: pointer;
  font-size: 12px;
  font-weight: 600;
  outline: none;
}

.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: #fce4ec; }

.modal {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.modal-content {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  padding: 20px;
  min-width: 340px;
}

.modal-content h3 {
  margin-bottom: 8px;
  color: var(--text);
  font-size: 14px;
  font-weight: 700;
  border-bottom: 1px solid var(--border);
  padding-bottom: 8px;
}

.modal-content p { margin-bottom: 12px; font-size: 12px; color: var(--text-muted); }

.modal-content input {
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--border);
  background: var(--input-bg);
  color: var(--text);
  font-size: 13px;
  margin-bottom: 10px;
  outline: none;
}

.modal-content input:focus { border-color: var(--accent); }
.modal-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 8px; }

#category-list::-webkit-scrollbar { width: 6px; }
#category-list::-webkit-scrollbar-track { background: transparent; }
#category-list::-webkit-scrollbar-thumb { background: var(--scrollbar); }
#category-list::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); }

.btn-primary.dirty { animation: blink-btn 1s ease-in-out infinite; }

@keyframes blink-btn {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
CSSEOF

# app.js
cat > "$INSTALL_DIR/public/app.js" << 'JSEOF'
let data = { categories: [] };
let selectedCard = null;
let selectedCategoryId = null;
let authToken = localStorage.getItem('spw_token');
let isDirty = false;

function markDirty() {
  if (isDirty) return;
  isDirty = true;
  document.getElementById('btn-save-card').classList.add('dirty');
}

function markClean() {
  isDirty = false;
  document.getElementById('btn-save-card').classList.remove('dirty');
}

async function apiFetch(url, options = {}) {
  const headers = { ...options.headers, 'x-auth-token': authToken };
  return fetch(url, { ...options, headers });
}

async function checkAuth() {
  const res = await fetch('/api/auth/status');
  const { hasPassword } = await res.json();
  if (!hasPassword) {
    document.getElementById('auth-setup').style.display = 'block';
    document.getElementById('auth-login').style.display = 'none';
  } else if (!authToken) {
    document.getElementById('auth-setup').style.display = 'none';
    document.getElementById('auth-login').style.display = 'block';
  } else {
    const checkRes = await apiFetch('/api/passwords');
    if (checkRes.ok) {
      showApp();
    } else {
      authToken = null;
      localStorage.removeItem('spw_token');
      document.getElementById('auth-setup').style.display = 'none';
      document.getElementById('auth-login').style.display = 'block';
    }
  }
}

function showApp() {
  document.getElementById('auth-screen').style.display = 'none';
  document.getElementById('app').style.display = 'flex';
  loadData();
}

function showAuthError(msg) {
  const el = document.getElementById('auth-error');
  el.textContent = msg;
  el.style.display = 'block';
  setTimeout(() => { el.style.display = 'none'; }, 3000);
}

document.getElementById('btn-setup').addEventListener('click', async () => {
  const pw = document.getElementById('setup-password').value;
  const pw2 = document.getElementById('setup-password-confirm').value;
  if (!pw || pw.length < 4) return showAuthError('パスワードは4文字以上');
  if (pw !== pw2) return showAuthError('パスワードが一致しません');
  const res = await fetch('/api/auth/setup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const result = await res.json();
  if (result.success) {
    authToken = result.token;
    localStorage.setItem('spw_token', authToken);
    showApp();
  } else {
    showAuthError(result.error);
  }
});

document.getElementById('btn-login').addEventListener('click', async () => {
  const pw = document.getElementById('login-password').value;
  if (!pw) return showAuthError('パスワードを入力してください');
  const res = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ password: pw })
  });
  const result = await res.json();
  if (result.success) {
    authToken = result.token;
    localStorage.setItem('spw_token', authToken);
    showApp();
  } else {
    showAuthError(result.error);
  }
});

document.getElementById('btn-logout').addEventListener('click', async () => {
  await apiFetch('/api/auth/logout', { method: 'POST' });
  authToken = null;
  localStorage.removeItem('spw_token');
  document.getElementById('app').style.display = 'none';
  document.getElementById('auth-screen').style.display = 'flex';
  document.getElementById('auth-setup').style.display = 'none';
  document.getElementById('auth-login').style.display = 'block';
  document.getElementById('login-password').value = '';
});

['setup-password', 'setup-password-confirm', 'login-password'].forEach(id => {
  document.getElementById(id).addEventListener('keydown', e => {
    if (e.key === 'Enter') {
      if (id.startsWith('setup')) document.getElementById('btn-setup').click();
      else document.getElementById('btn-login').click();
    }
  });
});

async function loadData() {
  const res = await apiFetch('/api/passwords');
  if (!res.ok) {
    authToken = null;
    localStorage.removeItem('spw_token');
    location.reload();
    return;
  }
  data = await res.json();
  renderSidebar();
}

async function saveData() {
  await apiFetch('/api/passwords', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  markClean();
}

function collectCurrentInputs() {
  if (!selectedCard) return;
  const nameInput = document.getElementById('detail-card-name');
  if (nameInput) selectedCard.name = nameInput.value;
  document.querySelectorAll('#detail-fields .field-row').forEach((row, idx) => {
    if (!selectedCard.fields[idx]) return;
    const keyInput = row.querySelector('.field-label-input');
    const valInput = row.querySelector('.field-value-input');
    if (keyInput) selectedCard.fields[idx].key = keyInput.value;
    if (valInput) selectedCard.fields[idx].value = valInput.value;
  });
}

function sortedByName(arr) {
  return [...arr].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}

function renderSidebar(filter = '') {
  const list = document.getElementById('category-list');
  list.innerHTML = '';
  const lf = filter.toLowerCase();

  sortedByName(data.categories).forEach(cat => {
    const sortedCards = sortedByName(cat.cards);
    const filteredCards = sortedCards.filter(c =>
      !lf || c.name.toLowerCase().includes(lf) ||
      c.fields.some(f => f.value.toLowerCase().includes(lf) || f.key.toLowerCase().includes(lf))
    );
    if (lf && filteredCards.length === 0) return;

    const catEl = document.createElement('div');
    catEl.className = 'category-item';
    catEl.innerHTML = `
      <div class="category-header" data-id="${cat.id}">
        <span class="cat-name">${escHtml(cat.name)}</span>
        <span class="cat-actions">
          <button class="btn-small btn-add-card" data-cat="${cat.id}" title="カード追加">+</button>
          <button class="btn-small btn-rename-cat" data-cat="${cat.id}" title="名前変更">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
          </button>
          <button class="btn-small btn-del-cat" data-cat="${cat.id}" title="削除">×</button>
        </span>
      </div>
    `;
    list.appendChild(catEl);

    (lf ? filteredCards : sortedCards).forEach(card => {
      const cardEl = document.createElement('div');
      cardEl.className = 'card-item' + (selectedCard && selectedCard.id === card.id ? ' active' : '');
      cardEl.dataset.cardId = card.id;
      cardEl.dataset.catId = cat.id;
      cardEl.innerHTML = `<span class="card-name">${escHtml(card.name)}</span>`;
      list.appendChild(cardEl);
    });
  });
}

function isPasswordField(key) {
  const k = key.toLowerCase();
  return k.includes('パスワード') || k.includes('password') || k.includes('pass') || k.includes('secret');
}

function showCardDetail(catId, cardId) {
  collectCurrentInputs();
  const cat = data.categories.find(c => c.id === catId);
  if (!cat) return;
  const card = cat.cards.find(c => c.id === cardId);
  if (!card) return;

  selectedCard = card;
  selectedCategoryId = catId;

  document.getElementById('welcome-screen').style.display = 'none';
  document.getElementById('card-detail').style.display = 'block';
  document.getElementById('detail-card-name').value = card.name;

  const fieldsDiv = document.getElementById('detail-fields');
  fieldsDiv.innerHTML = '';

  card.fields.forEach((field, idx) => {
    const row = document.createElement('div');
    row.className = 'field-row';
    const isPw = isPasswordField(field.key);
    row.innerHTML = `
      <input type="text" class="field-label-input" data-idx="${idx}" value="${escAttr(field.key)}">
      <div class="field-input-wrap">
        <input type="${isPw ? 'password' : 'text'}" class="field-value-input" data-idx="${idx}" value="${escAttr(field.value)}">
        ${isPw ? `<button class="btn-toggle-pw" data-idx="${idx}" title="表示切替"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>` : ''}
        <button class="btn-copy" data-idx="${idx}">コピー</button>
        <button class="btn-delete-field" data-idx="${idx}" title="削除">×</button>
      </div>
    `;
    fieldsDiv.appendChild(row);
  });

  renderSidebar(document.getElementById('search-input').value);
}

function escHtml(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

function escAttr(s) {
  return s.replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function genId() {
  return 'id-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

function fallbackCopy(text) {
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.cssText = 'position:fixed;opacity:0';
  document.body.appendChild(ta);
  ta.select();
  document.execCommand('copy');
  document.body.removeChild(ta);
}

document.getElementById('category-list').addEventListener('click', e => {
  const addCard = e.target.closest('.btn-add-card');
  if (addCard) {
    collectCurrentInputs();
    const catId = addCard.dataset.cat;
    const cat = data.categories.find(c => c.id === catId);
    if (!cat) return;
    const card = { id: genId(), name: '新しいカード', fields: [{ key: 'ID', value: '' }, { key: 'パスワード', value: '' }] };
    cat.cards.push(card);
    saveData();
    showCardDetail(catId, card.id);
    return;
  }

  const renameCat = e.target.closest('.btn-rename-cat');
  if (renameCat) {
    const catId = renameCat.dataset.cat;
    const cat = data.categories.find(c => c.id === catId);
    if (!cat) return;
    const newName = prompt('カテゴリー名を入力:', cat.name);
    if (!newName || newName === cat.name) return;
    cat.name = newName;
    saveData();
    renderSidebar(document.getElementById('search-input').value);
    return;
  }

  const delCat = e.target.closest('.btn-del-cat');
  if (delCat) {
    const catId = delCat.dataset.cat;
    if (!confirm('カテゴリーを削除しますか?')) return;
    data.categories = data.categories.filter(c => c.id !== catId);
    saveData();
    if (selectedCategoryId === catId) {
      selectedCard = null;
      selectedCategoryId = null;
      document.getElementById('card-detail').style.display = 'none';
      document.getElementById('welcome-screen').style.display = 'flex';
    }
    renderSidebar(document.getElementById('search-input').value);
    return;
  }

  const cardEl = e.target.closest('.card-item');
  if (cardEl) showCardDetail(cardEl.dataset.catId, cardEl.dataset.cardId);
});

document.getElementById('search-input').addEventListener('input', e => {
  renderSidebar(e.target.value);
});

document.getElementById('btn-add-category').addEventListener('click', () => {
  const name = prompt('カテゴリー名を入力:');
  if (!name) return;
  const cat = { id: genId(), name, cards: [] };
  data.categories.push(cat);
  saveData();
  renderSidebar();
});

document.getElementById('btn-add-field').addEventListener('click', () => {
  if (!selectedCard) return;
  collectCurrentInputs();
  selectedCard.fields.push({ key: '新しい項目', value: '' });
  markDirty();
  showCardDetail(selectedCategoryId, selectedCard.id);
});

document.getElementById('btn-delete-card').addEventListener('click', () => {
  if (!selectedCard || !selectedCategoryId) return;
  if (!confirm('カードを削除しますか?')) return;
  const cat = data.categories.find(c => c.id === selectedCategoryId);
  if (!cat) return;
  cat.cards = cat.cards.filter(c => c.id !== selectedCard.id);
  saveData();
  selectedCard = null;
  selectedCategoryId = null;
  document.getElementById('card-detail').style.display = 'none';
  document.getElementById('welcome-screen').style.display = 'flex';
  renderSidebar(document.getElementById('search-input').value);
});

document.getElementById('detail-fields').addEventListener('click', e => {
  const toggleBtn = e.target.closest('.btn-toggle-pw');
  if (toggleBtn) {
    const row = toggleBtn.closest('.field-row');
    const input = row.querySelector('.field-value-input');
    if (input.type === 'password') {
      input.type = 'text';
      toggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
    } else {
      input.type = 'password';
      toggleBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
    }
    return;
  }

  const copyBtn = e.target.closest('.btn-copy');
  if (copyBtn) {
    const row = copyBtn.closest('.field-row');
    const input = row.querySelector('.field-value-input');
    const text = input.value;
    function showCopied() {
      copyBtn.textContent = 'コピー済み';
      copyBtn.classList.add('copied');
      setTimeout(() => { copyBtn.textContent = 'コピー'; copyBtn.classList.remove('copied'); }, 1500);
    }
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text).then(showCopied).catch(() => { fallbackCopy(text); showCopied(); });
    } else {
      fallbackCopy(text); showCopied();
    }
    return;
  }

  const delBtn = e.target.closest('.btn-delete-field');
  if (delBtn) {
    const idx = parseInt(delBtn.dataset.idx);
    if (!selectedCard) return;
    collectCurrentInputs();
    selectedCard.fields.splice(idx, 1);
    markDirty();
    showCardDetail(selectedCategoryId, selectedCard.id);
  }
});

document.getElementById('btn-save-card').addEventListener('click', () => {
  if (!selectedCard) return;
  collectCurrentInputs();
  saveData();
  renderSidebar(document.getElementById('search-input').value);
});

document.getElementById('btn-export').addEventListener('click', () => {
  document.getElementById('export-modal').style.display = 'flex';
});

document.getElementById('btn-export-cancel').addEventListener('click', () => {
  document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('btn-export-confirm').addEventListener('click', async () => {
  const res = await apiFetch('/api/export', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({})
  });
  if (!res.ok) { alert('エクスポートに失敗しました'); return; }
  const blob = await res.blob();
  const disposition = res.headers.get('Content-Disposition') || '';
  const filename = disposition.includes('filename=')
    ? disposition.split('filename=')[1].replace(/"/g, '')
    : 'spw.zip';
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename; a.click();
  URL.revokeObjectURL(url);
  document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('export-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) document.getElementById('export-modal').style.display = 'none';
});

document.getElementById('btn-import').addEventListener('click', () => {
  document.getElementById('import-modal').style.display = 'flex';
  document.getElementById('import-password').value = '';
  document.getElementById('import-file').value = '';
  document.getElementById('import-password').focus();
});

document.getElementById('btn-import-cancel').addEventListener('click', () => {
  document.getElementById('import-modal').style.display = 'none';
});

document.getElementById('btn-import-confirm').addEventListener('click', async () => {
  const password = document.getElementById('import-password').value;
  const fileInput = document.getElementById('import-file');
  if (!password) return alert('パスワードを入力してください');
  if (!fileInput.files.length) return alert('ファイルを選択してください');
  const file = fileInput.files[0];
  const reader = new FileReader();
  reader.onload = async () => {
    const base64 = reader.result.split(',')[1];
    const res = await apiFetch('/api/import', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password, fileData: base64 })
    });
    const result = await res.json();
    if (result.success) {
      alert('インポートが完了しました');
      document.getElementById('import-modal').style.display = 'none';
      await loadData();
    } else {
      alert('インポートに失敗しました: ' + (result.error || '不明なエラー'));
    }
  };
  reader.readAsDataURL(file);
});

document.getElementById('import-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) document.getElementById('import-modal').style.display = 'none';
});

document.getElementById('btn-change-pw').addEventListener('click', () => {
  document.getElementById('change-pw-modal').style.display = 'flex';
  document.getElementById('current-password').value = '';
  document.getElementById('new-password').value = '';
  document.getElementById('new-password-confirm').value = '';
  document.getElementById('current-password').focus();
});

document.getElementById('btn-change-pw-cancel').addEventListener('click', () => {
  document.getElementById('change-pw-modal').style.display = 'none';
});

document.getElementById('btn-change-pw-confirm').addEventListener('click', async () => {
  const current = document.getElementById('current-password').value;
  const newPw = document.getElementById('new-password').value;
  const newPw2 = document.getElementById('new-password-confirm').value;
  if (!current) return alert('現在のパスワードを入力してください');
  if (!newPw || newPw.length < 4) return alert('新しいパスワードは4文字以上');
  if (newPw !== newPw2) return alert('パスワードが一致しません');
  const res = await apiFetch('/api/auth/change-password', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ currentPassword: current, newPassword: newPw })
  });
  const result = await res.json();
  if (result.success) {
    alert('パスワードを変更しました');
    document.getElementById('change-pw-modal').style.display = 'none';
  } else {
    alert(result.error || '変更に失敗しました');
  }
});

document.getElementById('change-pw-modal').addEventListener('click', e => {
  if (e.target === e.currentTarget) document.getElementById('change-pw-modal').style.display = 'none';
});

document.getElementById('detail-card-name').addEventListener('input', () => markDirty());

document.getElementById('detail-fields').addEventListener('input', e => {
  if (e.target.classList.contains('field-label-input') || e.target.classList.contains('field-value-input')) {
    markDirty();
  }
});

checkAuth();
JSEOF

# systemd service
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVCEOF
[Unit]
Description=SPW Password Manager
After=network.target

[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/server.py
Restart=on-failure
RestartSec=5
Environment=PORT=${PORT}

[Install]
WantedBy=multi-user.target
SVCEOF

systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}

# Tailscale Serve設定
TAILSCALE_DOMAIN=""
echo ""
echo "Tailscale Serve を設定中..."
if ! command -v tailscale &>/dev/null; then
  echo "Warning: tailscale コマンドが見つかりません。スキップします。"
else
  tailscale serve --bg --https=${TAILSCALE_PORT} http://127.0.0.1:${PORT}
  TAILSCALE_DOMAIN=$(tailscale status --json 2>/dev/null \
    | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Self',{}).get('DNSName','').rstrip('.'))" 2>/dev/null || echo "")
  if [ -n "$TAILSCALE_DOMAIN" ]; then
    echo "Tailscale Serve: https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
  else
    echo "Warning: Tailscale ドメイン取得に失敗しました。"
  fi
fi

echo ""
echo "=== インストール完了 ==="
echo ""
if [ -n "$TAILSCALE_DOMAIN" ]; then
  echo "URL (HTTPS): https://${TAILSCALE_DOMAIN}:${TAILSCALE_PORT}"
fi
echo "URL (ローカル): http://$(hostname -I | awk '{print $1}'):${PORT}"
echo ""
echo "コマンド:"
echo "  systemctl status  ${SERVICE_NAME}   # 状態確認"
echo "  systemctl restart ${SERVICE_NAME}   # 再起動"
echo "  journalctl -u ${SERVICE_NAME} -f    # ログ確認"

アンインストール

#!/bin/bash
set -e

INSTALL_DIR="/opt/lxd-data/spw"
SERVICE_NAME="spw"
TAILSCALE_PORT=3344

echo "=== SPW Password Manager Uninstaller ==="

# Check root
if [ "$EUID" -ne 0 ]; then
  echo "Error: Run as root (sudo bash $0)"
  exit 1
fi

# データ削除確認
echo ""
read -p "パスワードデータも削除しますか? [y/N]: " DEL_DATA

# Tailscale Serve解除
echo ""
echo "Tailscale Serve を解除中..."
if command -v tailscale &>/dev/null; then
  tailscale serve --https=${TAILSCALE_PORT} off 2>/dev/null && echo "解除しました" || echo "設定がないかスキップ"
else
  echo "tailscale が見つかりません。スキップ"
fi

# systemdサービス停止・削除
echo "サービスを停止・削除中..."
systemctl stop ${SERVICE_NAME} 2>/dev/null || true
systemctl disable ${SERVICE_NAME} 2>/dev/null || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload

# ファイル削除
echo "ファイルを削除中..."
if [[ "$DEL_DATA" =~ ^[Yy]$ ]]; then
  rm -rf "$INSTALL_DIR"
  echo "インストールディレクトリ+データを削除しました: $INSTALL_DIR"
else
  rm -f "$INSTALL_DIR/server.py"
  rm -rf "$INSTALL_DIR/public"
  echo "アプリを削除しました(データは残しました: $INSTALL_DIR/data)"
fi

echo ""
echo "=== アンインストール完了 ==="
タイトルとURLをコピーしました