Node.js版だと環境によってインストールに時間がかかるのでPython版に変更しました。内容的には変わっていませんが、こちらのほうがおすすめです。

最初7z版を作っていたんですけれど(エクスポートはZIP)、ZIP版のほうがあとあと便利そうなのと、エクスポートのZIPも-mem=AES256にしているのでZIP版のほうがよいと思います。
LXDコンテナにインストール(ZIP版)
3344番ポートで公開します。Tailscale ServeでHTTPS化しています。
エクスポート前の運用時もZIP形式にしています。こちらのほうが使い勝手が良いかも。
あと、passwords.7zだった部分をspw.zipにしています。
#!/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 renderSidebar(filter = '') {
const list = document.getElementById('category-list');
list.innerHTML = '';
const lf = filter.toLowerCase();
data.categories.forEach(cat => {
const filteredCards = cat.cards.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 : cat.cards).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, '"').replace(/</g, '<').replace(/>/g, '>');
}
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 # ログ確認"
LXDコンテナにインストール(7z版)
一応、前の7z版も残しておきます。エクスポートはZIPですが-mem=AES256されていないのでZIP版のほうがよいでしょう。3344番ポートで公開します。Tailscale ServeでHTTPS化しています。
#!/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_7Z = DATA_DIR / 'passwords.7z'
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'])
# ---------- 7z ----------
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_7Z.exists():
PW_7Z.unlink()
subprocess.run(
['7z', 'a', '-t7z', '-mhe=on', f'-p{password}', str(PW_7Z), str(jf)],
check=True, capture_output=True, timeout=30
)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def load_encrypted(password):
if not PW_7Z.exists():
return {'categories': []}
tmp = Path(tempfile.mkdtemp())
try:
subprocess.run(
['7z', 'x', f'-p{password}', f'-o{tmp}', str(PW_7Z), '-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', 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=".7z,.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 renderSidebar(filter = '') {
const list = document.getElementById('category-list');
list.innerHTML = '';
const lf = filter.toLowerCase();
data.categories.forEach(cat => {
const filteredCards = cat.cards.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 : cat.cards).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, '"').replace(/</g, '<').replace(/>/g, '>');
}
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, '')
: 'passwords.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 "=== アンインストール完了 ==="


