シンプルなパスワードマネージャを作ってみました。今時なのでWebでどこからでも読めるように、しかし、いざという時にはツールが無くても読めるように、ということでデータ自体はjsonファイルで管理。7zipによるAES-256で暗号化しています。保存場所さえ注意すれば個人用途ならば。

なお、サーバーサイドでexecFileSync(‘7z’, …)を呼ぶ必要があり、そのためにNode.js + expressを使っているのですが、Node.js + npmインストールが入っていない環境だとインストールに少し時間がかかります。
(Python版を作りました。そちらのほうがおすすめです)
LXDコンテナにインストール
3080番ポートで公開しています。ただ、ツールの用途的に、本格的に使うなら、後半で紹介するTailscale ServeでHTTPS化する方法でインストールしたほうがよいでしょう。
#!/bin/bash
set -e
INSTALL_DIR="/opt/lxd-data/spw"
SERVICE_NAME="spw"
PORT="${PORT:-3080}"
echo "=== SPW Password Manager Installer ==="
# Check root
if [ "$EUID" -ne 0 ]; then
echo "Error: Run as root (sudo bash $0)"
exit 1
fi
# Install Node.js if missing
if ! command -v node &>/dev/null; then
echo "Installing Node.js..."
if command -v apt-get &>/dev/null; then
apt-get update -qq && apt-get install -y -qq nodejs npm
elif command -v yum &>/dev/null; then
yum install -y nodejs npm
elif command -v dnf &>/dev/null; then
dnf install -y nodejs npm
elif command -v pacman &>/dev/null; then
pacman -Sy --noconfirm nodejs npm
else
echo "Error: Cannot install Node.js. Install manually."
exit 1
fi
fi
echo "Node.js: $(node -v)"
# 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 directory
mkdir -p "$INSTALL_DIR/public" "$INSTALL_DIR/data/backups"
# server.js
cat > "$INSTALL_DIR/server.js" << 'SERVEREOF'
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const os = require('os');
const app = express();
const PORT = process.env.PORT || 3080;
const DATA_DIR = path.join(__dirname, 'data');
const PASSWORDS_7Z = path.join(DATA_DIR, 'passwords.7z');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const BACKUP_DIR = path.join(DATA_DIR, 'backups');
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const sessions = new Map();
function loadConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch {
return {};
}
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return { salt, hash };
}
function verifyPassword(password, stored) {
const hash = crypto.scryptSync(password, stored.salt, 64).toString('hex');
return hash === stored.hash;
}
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function authMiddleware(req, res, next) {
const token = req.headers['x-auth-token'];
if (!token || !sessions.has(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.sessionPassword = sessions.get(token).password;
next();
}
function exec7z(args) {
execFileSync('7z', args, { stdio: 'pipe', timeout: 30000 });
}
function savePasswordsEncrypted(data, password) {
const tmpDir = path.join(os.tmpdir(), `pw-save-${Date.now()}`);
const tmpJson = path.join(tmpDir, 'passwords.json');
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
try {
if (fs.existsSync(PASSWORDS_7Z)) fs.unlinkSync(PASSWORDS_7Z);
exec7z(['a', '-t7z', '-mhe=on', `-p${password}`, PASSWORDS_7Z, tmpJson]);
} finally {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
}
function loadPasswordsEncrypted(password) {
if (!fs.existsSync(PASSWORDS_7Z)) return { categories: [] };
const tmpDir = path.join(os.tmpdir(), `pw-load-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
try {
exec7z(['x', `-p${password}`, `-o${tmpDir}`, PASSWORDS_7Z, '-y']);
const jsonFile = path.join(tmpDir, 'passwords.json');
if (!fs.existsSync(jsonFile)) return { categories: [] };
return JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
} catch {
return { categories: [] };
} finally {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
}
app.get('/api/auth/status', (req, res) => {
const config = loadConfig();
res.json({ hasPassword: !!config.passwordHash });
});
app.post('/api/auth/setup', (req, res) => {
const config = loadConfig();
if (config.passwordHash) {
return res.status(400).json({ error: 'Password already set' });
}
const { password } = req.body;
if (!password || password.length < 4) {
return res.status(400).json({ error: 'Password must be at least 4 characters' });
}
config.passwordHash = hashPassword(password);
saveConfig(config);
const token = generateToken();
sessions.set(token, { created: Date.now(), password });
res.json({ success: true, token });
});
app.post('/api/auth/login', (req, res) => {
const config = loadConfig();
if (!config.passwordHash) {
return res.status(400).json({ error: 'No password set' });
}
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Password required' });
}
if (!verifyPassword(password, config.passwordHash)) {
return res.status(401).json({ error: 'Wrong password' });
}
const token = generateToken();
sessions.set(token, { created: Date.now(), password });
res.json({ success: true, token });
});
app.post('/api/auth/logout', (req, res) => {
const token = req.headers['x-auth-token'];
if (token) sessions.delete(token);
res.json({ success: true });
});
app.post('/api/auth/change-password', authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
const config = loadConfig();
if (!config.passwordHash) {
return res.status(400).json({ error: 'No password set' });
}
if (!currentPassword || !verifyPassword(currentPassword, config.passwordHash)) {
return res.status(401).json({ error: 'Current password is wrong' });
}
if (!newPassword || newPassword.length < 4) {
return res.status(400).json({ error: 'New password must be at least 4 characters' });
}
const oldPassword = req.sessionPassword;
const data = loadPasswordsEncrypted(oldPassword);
config.passwordHash = hashPassword(newPassword);
saveConfig(config);
savePasswordsEncrypted(data, newPassword);
const token = req.headers['x-auth-token'];
if (token && sessions.has(token)) {
sessions.get(token).password = newPassword;
}
res.json({ success: true });
});
app.get('/api/passwords', authMiddleware, (req, res) => {
res.json(loadPasswordsEncrypted(req.sessionPassword));
});
app.post('/api/passwords', authMiddleware, (req, res) => {
savePasswordsEncrypted(req.body, req.sessionPassword);
res.json({ success: true });
});
app.post('/api/export', authMiddleware, (req, res) => {
try {
const data = loadPasswordsEncrypted(req.sessionPassword);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `passwords-${timestamp}.zip`;
const filepath = path.join(BACKUP_DIR, filename);
const tmpDir = path.join(os.tmpdir(), `pw-export-${Date.now()}`);
const tmpJson = path.join(tmpDir, 'passwords.json');
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
try {
exec7z(['a', '-tzip', '-p' + req.sessionPassword, filepath, tmpJson]);
res.download(filepath, filename, (err) => {
fs.unlink(filepath, () => {});
});
} finally {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/import', authMiddleware, (req, res) => {
try {
const { password, fileData } = req.body;
if (!password || !fileData) return res.status(400).json({ error: 'Password and file required' });
const tmpFile = path.join(os.tmpdir(), `pw-import-${Date.now()}`);
const tmpDir = path.join(os.tmpdir(), `pw-import-dir-${Date.now()}`);
fs.writeFileSync(tmpFile, Buffer.from(fileData, 'base64'));
fs.mkdirSync(tmpDir, { recursive: true });
try {
exec7z(['x', '-p' + password, '-o' + tmpDir, tmpFile, '-y']);
const jsonFile = path.join(tmpDir, 'passwords.json');
if (!fs.existsSync(jsonFile)) {
return res.status(400).json({ error: 'passwords.json not found in archive' });
}
const imported = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
savePasswordsEncrypted(imported, req.sessionPassword);
res.json({ success: true });
} finally {
try { fs.unlinkSync(tmpFile); } catch {}
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
} catch (e) {
res.status(500).json({ error: 'Wrong password or corrupt file' });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Password Manager running on http://0.0.0.0:${PORT}`);
});
SERVEREOF
# package.json
cat > "$INSTALL_DIR/package.json" << 'PKGEOF'
{
"name": "spw",
"version": "1.0.0",
"description": "Self-hosted password manager",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
PKGEOF
# 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;
--border-strong: #999;
--text: #1a1a1a;
--text-muted: #666;
--accent: #0055a5;
--accent-hover: #003d7a;
--danger: #c0392b;
--danger-hover: #962d22;
--row-alt: #f9f9f9;
--row-hover: #e8f0fe;
--row-active: #d0e0f5;
--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;
padding: 0;
}
.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;
padding: 0;
background: var(--bg);
}
#welcome-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
#card-detail {
padding: 0;
}
.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;
gap: 0;
}
.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;
}
.detail-actions-bottom {
padding: 8px 12px;
background: var(--header-bg);
border-top: 1px solid var(--border);
}
.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;
top: 0;
left: 0;
right: 0;
bottom: 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';
const nameInput = document.getElementById('detail-card-name');
nameInput.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.position = 'fixed';
ta.style.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) {
const err = await res.json();
alert('エクスポートに失敗しました: ' + (err.error || ''));
return;
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition');
const filename = disposition ? 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
# Install npm dependencies
echo "Installing npm dependencies..."
cd "$INSTALL_DIR" && npm install --production 2>&1 | tail -3
# Create 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/node ${INSTALL_DIR}/server.js
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}
echo ""
echo "=== Installation Complete ==="
echo "Access: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "Service: systemctl status ${SERVICE_NAME}"
echo "Logs: journalctl -u ${SERVICE_NAME} -f"
Tailscale ServeでHTTPS化
3344番ポートで公開しています。
#!/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
# Install Node.js if missing
if ! command -v node &>/dev/null; then
echo "Installing Node.js..."
if command -v apt-get &>/dev/null; then
apt-get update -qq && apt-get install -y -qq nodejs npm
elif command -v yum &>/dev/null; then
yum install -y nodejs npm
elif command -v dnf &>/dev/null; then
dnf install -y nodejs npm
elif command -v pacman &>/dev/null; then
pacman -Sy --noconfirm nodejs npm
else
echo "Error: Cannot install Node.js. Install manually."
exit 1
fi
fi
echo "Node.js: $(node -v)"
# 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 directory
mkdir -p "$INSTALL_DIR/public" "$INSTALL_DIR/data/backups"
# server.js
cat > "$INSTALL_DIR/server.js" << 'SERVEREOF'
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const os = require('os');
const app = express();
const PORT = process.env.PORT || 3345;
const DATA_DIR = path.join(__dirname, 'data');
const PASSWORDS_7Z = path.join(DATA_DIR, 'passwords.7z');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const BACKUP_DIR = path.join(DATA_DIR, 'backups');
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
const sessions = new Map();
function loadConfig() {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
} catch {
return {};
}
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return { salt, hash };
}
function verifyPassword(password, stored) {
const hash = crypto.scryptSync(password, stored.salt, 64).toString('hex');
return hash === stored.hash;
}
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function authMiddleware(req, res, next) {
const token = req.headers['x-auth-token'];
if (!token || !sessions.has(token)) {
return res.status(401).json({ error: 'Unauthorized' });
}
req.sessionPassword = sessions.get(token).password;
next();
}
function exec7z(args) {
execFileSync('7z', args, { stdio: 'pipe', timeout: 30000 });
}
function savePasswordsEncrypted(data, password) {
const tmpDir = path.join(os.tmpdir(), `pw-save-${Date.now()}`);
const tmpJson = path.join(tmpDir, 'passwords.json');
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
try {
if (fs.existsSync(PASSWORDS_7Z)) fs.unlinkSync(PASSWORDS_7Z);
exec7z(['a', '-t7z', '-mhe=on', `-p${password}`, PASSWORDS_7Z, tmpJson]);
} finally {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
}
function loadPasswordsEncrypted(password) {
if (!fs.existsSync(PASSWORDS_7Z)) return { categories: [] };
const tmpDir = path.join(os.tmpdir(), `pw-load-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
try {
exec7z(['x', `-p${password}`, `-o${tmpDir}`, PASSWORDS_7Z, '-y']);
const jsonFile = path.join(tmpDir, 'passwords.json');
if (!fs.existsSync(jsonFile)) return { categories: [] };
return JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
} catch {
return { categories: [] };
} finally {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
}
app.get('/api/auth/status', (req, res) => {
const config = loadConfig();
res.json({ hasPassword: !!config.passwordHash });
});
app.post('/api/auth/setup', (req, res) => {
const config = loadConfig();
if (config.passwordHash) {
return res.status(400).json({ error: 'Password already set' });
}
const { password } = req.body;
if (!password || password.length < 4) {
return res.status(400).json({ error: 'Password must be at least 4 characters' });
}
config.passwordHash = hashPassword(password);
saveConfig(config);
const token = generateToken();
sessions.set(token, { created: Date.now(), password });
res.json({ success: true, token });
});
app.post('/api/auth/login', (req, res) => {
const config = loadConfig();
if (!config.passwordHash) {
return res.status(400).json({ error: 'No password set' });
}
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Password required' });
}
if (!verifyPassword(password, config.passwordHash)) {
return res.status(401).json({ error: 'Wrong password' });
}
const token = generateToken();
sessions.set(token, { created: Date.now(), password });
res.json({ success: true, token });
});
app.post('/api/auth/logout', (req, res) => {
const token = req.headers['x-auth-token'];
if (token) sessions.delete(token);
res.json({ success: true });
});
app.post('/api/auth/change-password', authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
const config = loadConfig();
if (!config.passwordHash) {
return res.status(400).json({ error: 'No password set' });
}
if (!currentPassword || !verifyPassword(currentPassword, config.passwordHash)) {
return res.status(401).json({ error: 'Current password is wrong' });
}
if (!newPassword || newPassword.length < 4) {
return res.status(400).json({ error: 'New password must be at least 4 characters' });
}
const oldPassword = req.sessionPassword;
const data = loadPasswordsEncrypted(oldPassword);
config.passwordHash = hashPassword(newPassword);
saveConfig(config);
savePasswordsEncrypted(data, newPassword);
const token = req.headers['x-auth-token'];
if (token && sessions.has(token)) {
sessions.get(token).password = newPassword;
}
res.json({ success: true });
});
app.get('/api/passwords', authMiddleware, (req, res) => {
res.json(loadPasswordsEncrypted(req.sessionPassword));
});
app.post('/api/passwords', authMiddleware, (req, res) => {
savePasswordsEncrypted(req.body, req.sessionPassword);
res.json({ success: true });
});
app.post('/api/export', authMiddleware, (req, res) => {
try {
const data = loadPasswordsEncrypted(req.sessionPassword);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `passwords-${timestamp}.zip`;
const filepath = path.join(BACKUP_DIR, filename);
const tmpDir = path.join(os.tmpdir(), `pw-export-${Date.now()}`);
const tmpJson = path.join(tmpDir, 'passwords.json');
fs.mkdirSync(tmpDir, { recursive: true });
fs.writeFileSync(tmpJson, JSON.stringify(data, null, 2), 'utf8');
try {
exec7z(['a', '-tzip', '-p' + req.sessionPassword, filepath, tmpJson]);
res.download(filepath, filename, (err) => {
fs.unlink(filepath, () => {});
});
} finally {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post('/api/import', authMiddleware, (req, res) => {
try {
const { password, fileData } = req.body;
if (!password || !fileData) return res.status(400).json({ error: 'Password and file required' });
const tmpFile = path.join(os.tmpdir(), `pw-import-${Date.now()}`);
const tmpDir = path.join(os.tmpdir(), `pw-import-dir-${Date.now()}`);
fs.writeFileSync(tmpFile, Buffer.from(fileData, 'base64'));
fs.mkdirSync(tmpDir, { recursive: true });
try {
exec7z(['x', '-p' + password, '-o' + tmpDir, tmpFile, '-y']);
const jsonFile = path.join(tmpDir, 'passwords.json');
if (!fs.existsSync(jsonFile)) {
return res.status(400).json({ error: 'passwords.json not found in archive' });
}
const imported = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
savePasswordsEncrypted(imported, req.sessionPassword);
res.json({ success: true });
} finally {
try { fs.unlinkSync(tmpFile); } catch {}
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
}
} catch (e) {
res.status(500).json({ error: 'Wrong password or corrupt file' });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Password Manager running on http://0.0.0.0:${PORT}`);
});
SERVEREOF
# package.json
cat > "$INSTALL_DIR/package.json" << 'PKGEOF'
{
"name": "spw",
"version": "1.0.0",
"description": "Self-hosted password manager",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
PKGEOF
# 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;
--border-strong: #999;
--text: #1a1a1a;
--text-muted: #666;
--accent: #0055a5;
--accent-hover: #003d7a;
--danger: #c0392b;
--danger-hover: #962d22;
--row-alt: #f9f9f9;
--row-hover: #e8f0fe;
--row-active: #d0e0f5;
--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;
padding: 0;
}
.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;
padding: 0;
background: var(--bg);
}
#welcome-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
}
#card-detail {
padding: 0;
}
.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;
gap: 0;
}
.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;
}
.detail-actions-bottom {
padding: 8px 12px;
background: var(--header-bg);
border-top: 1px solid var(--border);
}
.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;
top: 0;
left: 0;
right: 0;
bottom: 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';
const nameInput = document.getElementById('detail-card-name');
nameInput.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.position = 'fixed';
ta.style.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) {
const err = await res.json();
alert('エクスポートに失敗しました: ' + (err.error || ''));
return;
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition');
const filename = disposition ? 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
# Install npm dependencies
echo "Installing npm dependencies..."
cd "$INSTALL_DIR" && npm install --production 2>&1 | tail -3
# Create 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/node ${INSTALL_DIR}/server.js
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 コマンドが見つかりません。Tailscale HTTPSはスキップします。"
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.js"
rm -f "$INSTALL_DIR/package.json"
rm -f "$INSTALL_DIR/package-lock.json"
rm -rf "$INSTALL_DIR/node_modules"
rm -rf "$INSTALL_DIR/public"
echo "アプリを削除しました(データは残しました: $INSTALL_DIR/data)"
fi
echo ""
echo "=== アンインストール完了 ==="


