370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
const express = require('express');
|
|
const { execFile } = require('child_process');
|
|
const { fetchAlexaCookieAndSave } = require('./auth4-core');
|
|
|
|
const app = express();
|
|
const PORT = process.env.AUTH4_WEB_PORT || 3678;
|
|
const LOCAL_ENV_PATH = __dirname + '/.env';
|
|
const DEFAULT_SSH_TARGET = process.env.ALEXA_DEPLOY_SSH_TARGET || 'keinafarm';
|
|
const DEFAULT_REMOTE_UPLOAD_PATH =
|
|
process.env.ALEXA_DEPLOY_REMOTE_UPLOAD_PATH || '/tmp/alexa-api.env';
|
|
const DEFAULT_DEPLOY_COMMAND =
|
|
process.env.ALEXA_DEPLOY_COMMAND ||
|
|
'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env';
|
|
|
|
app.use(express.urlencoded({ extended: false }));
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderPage(options) {
|
|
const opts = options || {};
|
|
const email = escapeHtml(opts.email || '');
|
|
const logs = Array.isArray(opts.logs) ? opts.logs : [];
|
|
const result = opts.result || null;
|
|
const error = opts.error || '';
|
|
const statusClass = result ? 'ok' : (error ? 'error' : '');
|
|
const deploy = opts.deploy || {};
|
|
const deployTarget = escapeHtml(deploy.target || DEFAULT_SSH_TARGET);
|
|
const deployUploadPath = escapeHtml(deploy.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH);
|
|
const deployCommand = escapeHtml(deploy.deployCommand || DEFAULT_DEPLOY_COMMAND);
|
|
const deployLogs = Array.isArray(deploy.logs) ? deploy.logs : [];
|
|
const deployError = deploy.error || '';
|
|
const deployResult = deploy.result || null;
|
|
const messageHtml = result
|
|
? `<div class="notice ok">Cookie を更新しました。<br>保存先: <code>${escapeHtml(result.envPath)}</code><br>Cookie 長さ: ${escapeHtml(result.cookieLength)}</div>`
|
|
: (error ? `<div class="notice error">${escapeHtml(error).replace(/\n/g, '<br>')}</div>` : '');
|
|
const logHtml = logs.length
|
|
? `<pre>${escapeHtml(logs.join('\n'))}</pre>`
|
|
: '<pre>ここにログが表示されます。</pre>';
|
|
const deployMessageHtml = deployResult
|
|
? `<div class="notice ok">サーバー反映が完了しました。<br>転送先: <code>${deployTarget}:${deployUploadPath}</code></div>`
|
|
: (deployError ? `<div class="notice error">${escapeHtml(deployError).replace(/\n/g, '<br>')}</div>` : '');
|
|
const deployLogHtml = deployLogs.length
|
|
? `<pre>${escapeHtml(deployLogs.join('\n'))}</pre>`
|
|
: '<pre>ここに転送と再起動のログが表示されます。</pre>';
|
|
|
|
return `<!doctype html>
|
|
<html lang="ja">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Alexa Cookie 更新</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f3ede2;
|
|
--card: #fffaf2;
|
|
--ink: #1f2937;
|
|
--muted: #6b7280;
|
|
--line: #d9cdb7;
|
|
--accent: #1d6b57;
|
|
--accent-strong: #114e3f;
|
|
--danger: #a63b2b;
|
|
--danger-bg: #fff1ee;
|
|
--ok-bg: #edf8f3;
|
|
--shadow: 0 20px 60px rgba(76, 56, 31, 0.12);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Hiragino Sans", "Yu Gothic", sans-serif;
|
|
color: var(--ink);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(29, 107, 87, 0.14), transparent 28%),
|
|
radial-gradient(circle at right, rgba(177, 107, 32, 0.12), transparent 24%),
|
|
linear-gradient(180deg, #f8f3ea 0%, var(--bg) 100%);
|
|
min-height: 100vh;
|
|
padding: 32px 16px;
|
|
}
|
|
.wrap {
|
|
max-width: 760px;
|
|
margin: 0 auto;
|
|
}
|
|
.card {
|
|
background: color-mix(in srgb, var(--card) 92%, white 8%);
|
|
border: 1px solid rgba(217, 205, 183, 0.9);
|
|
border-radius: 24px;
|
|
box-shadow: var(--shadow);
|
|
padding: 28px;
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
h1 {
|
|
margin: 0 0 10px;
|
|
font-size: clamp(28px, 4vw, 42px);
|
|
line-height: 1.05;
|
|
letter-spacing: -0.03em;
|
|
}
|
|
p {
|
|
margin: 0 0 20px;
|
|
color: var(--muted);
|
|
line-height: 1.7;
|
|
}
|
|
form {
|
|
display: grid;
|
|
gap: 16px;
|
|
margin-top: 24px;
|
|
}
|
|
.section {
|
|
margin-top: 28px;
|
|
padding-top: 24px;
|
|
border-top: 1px solid rgba(217, 205, 183, 0.9);
|
|
}
|
|
label {
|
|
display: grid;
|
|
gap: 8px;
|
|
font-weight: 700;
|
|
}
|
|
input {
|
|
width: 100%;
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
padding: 14px 15px;
|
|
font: inherit;
|
|
background: #fff;
|
|
}
|
|
input:focus {
|
|
outline: 2px solid rgba(29, 107, 87, 0.18);
|
|
border-color: var(--accent);
|
|
}
|
|
button {
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 14px 22px;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
color: white;
|
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
|
cursor: pointer;
|
|
justify-self: start;
|
|
}
|
|
.notice {
|
|
margin: 20px 0 0;
|
|
padding: 14px 16px;
|
|
border-radius: 14px;
|
|
line-height: 1.6;
|
|
border: 1px solid transparent;
|
|
}
|
|
.notice.ok {
|
|
background: var(--ok-bg);
|
|
border-color: rgba(29, 107, 87, 0.2);
|
|
}
|
|
.notice.error {
|
|
background: var(--danger-bg);
|
|
border-color: rgba(166, 59, 43, 0.2);
|
|
color: var(--danger);
|
|
}
|
|
.meta {
|
|
margin-top: 22px;
|
|
font-size: 14px;
|
|
color: var(--muted);
|
|
}
|
|
pre {
|
|
margin: 14px 0 0;
|
|
padding: 16px;
|
|
border-radius: 16px;
|
|
background: #1d2430;
|
|
color: #eef2f7;
|
|
overflow: auto;
|
|
line-height: 1.55;
|
|
min-height: 160px;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
code {
|
|
font-family: "SFMono-Regular", Consolas, monospace;
|
|
}
|
|
.hint {
|
|
margin-top: 10px;
|
|
font-size: 14px;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<div class="card ${statusClass}">
|
|
<h1>Alexa Cookie 更新</h1>
|
|
<p>メールアドレスとパスワードをその場で入力して、<code>auth4.js</code> と同じ認証フローで <code>.env</code> を更新します。入力値は保存しません。</p>
|
|
<form method="post" action="/login">
|
|
<label>
|
|
Amazon メールアドレス
|
|
<input type="email" name="email" value="${email}" autocomplete="username" required>
|
|
</label>
|
|
<label>
|
|
Amazon パスワード
|
|
<input type="password" name="password" autocomplete="current-password" required>
|
|
</label>
|
|
<button type="submit">Cookie を更新する</button>
|
|
</form>
|
|
${messageHtml}
|
|
<div class="meta">ローカル専用の簡易 GUI です。CAPTCHA や MFA が出た場合は失敗ログを表示します。</div>
|
|
${logHtml}
|
|
|
|
<div class="section">
|
|
<h1>サーバー反映</h1>
|
|
<p>更新済みの <code>.env</code> を一時パスへ転送し、<code>sudoers</code> で許可した専用スクリプトだけを実行します。SSH の接続先は必要に応じて変えてください。</p>
|
|
<form method="post" action="/deploy">
|
|
<label>
|
|
SSH 接続先
|
|
<input type="text" name="target" value="${deployTarget}" required>
|
|
</label>
|
|
<label>
|
|
リモート一時アップロード先
|
|
<input type="text" name="remoteUploadPath" value="${deployUploadPath}" required>
|
|
</label>
|
|
<label>
|
|
実行する専用コマンド
|
|
<input type="text" name="deployCommand" value="${deployCommand}" required>
|
|
</label>
|
|
<button type="submit">サーバーへ反映する</button>
|
|
</form>
|
|
${deployMessageHtml}
|
|
<div class="hint">このPCの SSH 設定では <code>keinafarm</code> が使えます。初回はサーバー側に <code>/usr/local/bin/alexa-cookie-deploy.sh</code> と <code>sudoers</code> 設定が必要です。</div>
|
|
${deployLogHtml}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function runCommand(command, args) {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(command, args, { maxBuffer: 1024 * 1024 }, function(error, stdout, stderr) {
|
|
if (error) {
|
|
error.stdout = stdout || '';
|
|
error.stderr = stderr || '';
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve({
|
|
stdout: stdout || '',
|
|
stderr: stderr || '',
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function buildDeployState(body, extra) {
|
|
const payload = body || {};
|
|
const override = extra || {};
|
|
return {
|
|
target: override.target || payload.target || DEFAULT_SSH_TARGET,
|
|
remoteUploadPath: override.remoteUploadPath || payload.remoteUploadPath || DEFAULT_REMOTE_UPLOAD_PATH,
|
|
deployCommand: override.deployCommand || payload.deployCommand || DEFAULT_DEPLOY_COMMAND,
|
|
logs: override.logs || [],
|
|
error: override.error || '',
|
|
result: override.result || null,
|
|
};
|
|
}
|
|
|
|
app.get('/', function(req, res) {
|
|
res.type('html').send(renderPage({
|
|
deploy: buildDeployState({}),
|
|
}));
|
|
});
|
|
|
|
app.post('/login', async function(req, res) {
|
|
const email = (req.body && req.body.email) || '';
|
|
const password = (req.body && req.body.password) || '';
|
|
const logs = [];
|
|
const logger = function(message) {
|
|
logs.push(message);
|
|
};
|
|
|
|
if (!email || !password) {
|
|
res.status(400).type('html').send(renderPage({
|
|
email,
|
|
error: 'メールアドレスとパスワードは必須です。',
|
|
logs,
|
|
deploy: buildDeployState({}),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await fetchAlexaCookieAndSave({ email, password, logger });
|
|
logs.push('');
|
|
logs.push('==============================================');
|
|
logs.push('認証成功');
|
|
logs.push(`保存先: ${result.envPath}`);
|
|
logs.push(`Cookie 長さ: ${result.cookieLength}`);
|
|
res.type('html').send(renderPage({
|
|
email,
|
|
result,
|
|
logs,
|
|
deploy: buildDeployState({}),
|
|
}));
|
|
} catch (error) {
|
|
logs.push('');
|
|
logs.push('[ERROR] ' + error.message);
|
|
res.status(500).type('html').send(renderPage({
|
|
email,
|
|
error: error.message,
|
|
logs,
|
|
deploy: buildDeployState({}),
|
|
}));
|
|
}
|
|
});
|
|
|
|
app.post('/deploy', async function(req, res) {
|
|
const deploy = buildDeployState(req.body);
|
|
const logs = [];
|
|
|
|
if (!deploy.target || !deploy.remoteUploadPath || !deploy.deployCommand) {
|
|
res.status(400).type('html').send(renderPage({
|
|
deploy: buildDeployState(req.body, {
|
|
logs,
|
|
error: 'SSH 接続先、一時アップロード先、専用コマンドは必須です。',
|
|
}),
|
|
}));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logs.push('[1] .env をサーバーの一時パスへ転送中...');
|
|
logs.push(`scp ${LOCAL_ENV_PATH} ${deploy.target}:${deploy.remoteUploadPath}`);
|
|
const scpResult = await runCommand('scp', [LOCAL_ENV_PATH, `${deploy.target}:${deploy.remoteUploadPath}`]);
|
|
if (scpResult.stdout.trim()) logs.push(scpResult.stdout.trim());
|
|
if (scpResult.stderr.trim()) logs.push(scpResult.stderr.trim());
|
|
|
|
logs.push('');
|
|
logs.push('[2] 専用デプロイスクリプトを実行中...');
|
|
logs.push(`ssh ${deploy.target} ${deploy.deployCommand}`);
|
|
const sshResult = await runCommand('ssh', [deploy.target, deploy.deployCommand]);
|
|
if (sshResult.stdout.trim()) logs.push(sshResult.stdout.trim());
|
|
if (sshResult.stderr.trim()) logs.push(sshResult.stderr.trim());
|
|
|
|
logs.push('');
|
|
logs.push('反映完了');
|
|
res.type('html').send(renderPage({
|
|
deploy: buildDeployState(req.body, {
|
|
logs,
|
|
result: { ok: true },
|
|
}),
|
|
}));
|
|
} catch (error) {
|
|
if (error.stdout && error.stdout.trim()) logs.push(error.stdout.trim());
|
|
if (error.stderr && error.stderr.trim()) logs.push(error.stderr.trim());
|
|
logs.push('');
|
|
logs.push('[ERROR] ' + error.message);
|
|
res.status(500).type('html').send(renderPage({
|
|
deploy: buildDeployState(req.body, {
|
|
logs,
|
|
error: error.stderr || error.message,
|
|
}),
|
|
}));
|
|
}
|
|
});
|
|
|
|
app.listen(PORT, '127.0.0.1', function() {
|
|
console.log('[INFO] auth4-web listening on http://127.0.0.1:' + PORT);
|
|
});
|