Alexaの認証を延長するもの
This commit is contained in:
35
alexa-api/alexa-cookie-deploy.sh
Normal file
35
alexa-api/alexa-cookie-deploy.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "usage: $0 /tmp/alexa-api.env" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
SRC_ENV="$1"
|
||||||
|
DEST_ENV="/home/claude/alexa-api/.env"
|
||||||
|
DEST_DIR="/home/claude/alexa-api"
|
||||||
|
COMPOSE_FILE="/home/claude/alexa-api/docker-compose.yml"
|
||||||
|
|
||||||
|
if [[ "$SRC_ENV" != /tmp/* ]]; then
|
||||||
|
echo "source env must be under /tmp" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SRC_ENV" ]]; then
|
||||||
|
echo "source env not found: $SRC_ENV" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -q '^ALEXA_COOKIE=' "$SRC_ENV" || {
|
||||||
|
echo "ALEXA_COOKIE entry not found in $SRC_ENV" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
install -d -m 755 "$DEST_DIR"
|
||||||
|
install -m 600 "$SRC_ENV" "$DEST_ENV"
|
||||||
|
|
||||||
|
docker compose --env-file "$DEST_ENV" -f "$COMPOSE_FILE" restart
|
||||||
|
|
||||||
|
rm -f "$SRC_ENV"
|
||||||
|
echo "alexa cookie deployed"
|
||||||
1
alexa-api/alexa-cookie-deploy.sudoers
Normal file
1
alexa-api/alexa-cookie-deploy.sudoers
Normal file
@@ -0,0 +1 @@
|
|||||||
|
akira ALL=(root) NOPASSWD: /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
|
||||||
239
alexa-api/auth4-core.js
Normal file
239
alexa-api/auth4-core.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ALEXA_LOGIN_URL =
|
||||||
|
'https://www.amazon.co.jp/ap/signin?' +
|
||||||
|
new URLSearchParams({
|
||||||
|
'openid.assoc_handle': 'amzn_dp_project_dee_jp',
|
||||||
|
'openid.mode': 'checkid_setup',
|
||||||
|
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||||
|
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||||
|
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||||
|
'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token',
|
||||||
|
'pageId': 'amzn_dp_project_dee_jp',
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
const USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
function createLogger(logger) {
|
||||||
|
if (!logger) return function() {};
|
||||||
|
if (typeof logger === 'function') return logger;
|
||||||
|
if (typeof logger.log === 'function') {
|
||||||
|
return function(message) {
|
||||||
|
logger.log(message);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return function() {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestState() {
|
||||||
|
const cookieJar = {};
|
||||||
|
|
||||||
|
function setCookies(setCookieHeaders) {
|
||||||
|
if (!setCookieHeaders) return;
|
||||||
|
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
|
||||||
|
for (const header of headers) {
|
||||||
|
const parts = header.split(';');
|
||||||
|
const kv = parts[0] || '';
|
||||||
|
const index = kv.indexOf('=');
|
||||||
|
if (index <= 0) continue;
|
||||||
|
const key = kv.slice(0, index).trim();
|
||||||
|
const value = kv.slice(index + 1).trim();
|
||||||
|
cookieJar[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookieHeader() {
|
||||||
|
return Object.entries(cookieJar)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(url, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const reqOpts = {
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
path: parsed.pathname + parsed.search,
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Accept-Language': 'ja-JP,ja;q=0.9',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
|
||||||
|
'Cookie': getCookieHeader(),
|
||||||
|
...(opts.headers || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(reqOpts, (res) => {
|
||||||
|
setCookies(res.headers['set-cookie']);
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({ status: res.statusCode, headers: res.headers, body });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (opts.body) req.write(opts.body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookieJar,
|
||||||
|
getCookieHeader,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractHiddenFields(html) {
|
||||||
|
const fields = {};
|
||||||
|
const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(html)) !== null) {
|
||||||
|
const tag = match[0];
|
||||||
|
const name = (tag.match(/name=["']([^"']+)["']/) || [])[1];
|
||||||
|
const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1];
|
||||||
|
if (name) fields[name] = value;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFormAction(html) {
|
||||||
|
const match = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/);
|
||||||
|
if (match) return match[1].replace(/&/g, '&');
|
||||||
|
const match2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
|
||||||
|
if (match2) return match2[1].replace(/&/g, '&');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailureMessage(current, cookieJar) {
|
||||||
|
const keys = Object.keys(cookieJar).join(', ') || '(none)';
|
||||||
|
const hints = [];
|
||||||
|
if (current.body && (current.body.includes('captcha') || current.body.includes('CAPTCHA'))) {
|
||||||
|
hints.push('CAPTCHA が要求されています。少し待ってから再試行してください。');
|
||||||
|
}
|
||||||
|
if (current.body && current.body.includes('auth-mfa-form')) {
|
||||||
|
hints.push('MFA が要求されています。このスクリプトだけでは完了できません。');
|
||||||
|
}
|
||||||
|
if (current.body && current.body.includes('password') && current.body.includes('error')) {
|
||||||
|
hints.push('パスワードが間違っている可能性があります。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
'認証に失敗しました。取得できた Cookie に認証トークンが含まれていません。',
|
||||||
|
`取得済み Cookie キー: ${keys}`,
|
||||||
|
...hints,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 'ALEXA_AUTH_FAILED';
|
||||||
|
error.hints = hints;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlexaCookie(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const email = opts.email;
|
||||||
|
const password = opts.password;
|
||||||
|
const log = createLogger(opts.logger);
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
const error = new Error('email と password は必須です');
|
||||||
|
error.code = 'MISSING_CREDENTIALS';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = createRequestState();
|
||||||
|
const request = state.request;
|
||||||
|
const cookieJar = state.cookieJar;
|
||||||
|
|
||||||
|
log('[1] ログインページ取得中...');
|
||||||
|
const page1 = await request(ALEXA_LOGIN_URL);
|
||||||
|
log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`);
|
||||||
|
|
||||||
|
if (page1.status !== 200) {
|
||||||
|
const error = new Error(`ログインページ取得失敗: ${page1.status}`);
|
||||||
|
error.code = 'LOGIN_PAGE_FETCH_FAILED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = extractFormAction(page1.body);
|
||||||
|
const hidden = extractHiddenFields(page1.body);
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
const error = new Error('ログインフォームが見つかりません。Amazon のログイン画面仕様が変わった可能性があります。');
|
||||||
|
error.code = 'LOGIN_FORM_NOT_FOUND';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[2] フォーム送信先: ${action}`);
|
||||||
|
log(` Hidden fields: ${Object.keys(hidden).join(', ')}`);
|
||||||
|
|
||||||
|
const formData = new URLSearchParams({
|
||||||
|
...hidden,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
rememberMe: 'true',
|
||||||
|
}).toString();
|
||||||
|
|
||||||
|
log('[3] 認証送信中...');
|
||||||
|
const page2 = await request(action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': ALEXA_LOGIN_URL,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
log(` Status: ${page2.status}`);
|
||||||
|
log(` Location: ${page2.headers.location || '(none)'}`);
|
||||||
|
log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`);
|
||||||
|
|
||||||
|
let current = page2;
|
||||||
|
let redirectCount = 0;
|
||||||
|
while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) {
|
||||||
|
const location = current.headers.location;
|
||||||
|
const nextUrl = location.startsWith('http') ? location : `https://www.amazon.co.jp${location}`;
|
||||||
|
log(`[${4 + redirectCount}] Redirect -> ${nextUrl.substring(0, 80)}...`);
|
||||||
|
current = await request(nextUrl);
|
||||||
|
log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`);
|
||||||
|
redirectCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = state.getCookieHeader();
|
||||||
|
const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token');
|
||||||
|
if (!hasAlexaToken) throw buildFailureMessage(current, cookieJar);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cookie,
|
||||||
|
cookieLength: cookie.length,
|
||||||
|
cookieKeys: Object.keys(cookieJar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCookieToEnv(cookie, envPath) {
|
||||||
|
const targetEnvPath = envPath || path.join(__dirname, '.env');
|
||||||
|
fs.writeFileSync(targetEnvPath, `ALEXA_COOKIE=${cookie}\n`);
|
||||||
|
return targetEnvPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAlexaCookieAndSave(options) {
|
||||||
|
const result = await fetchAlexaCookie(options);
|
||||||
|
const envPath = saveCookieToEnv(result.cookie, options && options.envPath);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
envPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchAlexaCookie,
|
||||||
|
fetchAlexaCookieAndSave,
|
||||||
|
saveCookieToEnv,
|
||||||
|
};
|
||||||
369
alexa-api/auth4-web.js
Normal file
369
alexa-api/auth4-web.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -1,188 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* auth4.js - Amazon Japan OpenID フローを正しく再現するカスタム認証スクリプト
|
* auth4.js - CLI 版の Alexa Cookie 更新
|
||||||
* alexa-cookie2 の古いエンドポイント問題を回避して直接フォームを処理する
|
*/
|
||||||
*/
|
|
||||||
|
const EMAIL = process.env.AMAZON_EMAIL;
|
||||||
const https = require('https');
|
const PASSWORD = process.env.AMAZON_PASSWORD;
|
||||||
const fs = require('fs');
|
const { fetchAlexaCookieAndSave } = require('./auth4-core');
|
||||||
const path = require('path');
|
|
||||||
|
if (!EMAIL || !PASSWORD) {
|
||||||
const EMAIL = process.env.AMAZON_EMAIL;
|
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
|
||||||
const PASSWORD = process.env.AMAZON_PASSWORD;
|
process.exit(1);
|
||||||
|
}
|
||||||
if (!EMAIL || !PASSWORD) {
|
|
||||||
console.error('[ERROR] 環境変数 AMAZON_EMAIL と AMAZON_PASSWORD を設定してください');
|
async function main() {
|
||||||
process.exit(1);
|
const result = await fetchAlexaCookieAndSave({
|
||||||
}
|
email: EMAIL,
|
||||||
|
password: PASSWORD,
|
||||||
const ALEXA_LOGIN_URL =
|
logger: console.log,
|
||||||
'https://www.amazon.co.jp/ap/signin?' +
|
});
|
||||||
new URLSearchParams({
|
|
||||||
'openid.assoc_handle': 'amzn_dp_project_dee_jp',
|
console.log('\n==============================================');
|
||||||
'openid.mode': 'checkid_setup',
|
console.log(' 認証成功!');
|
||||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
console.log('==============================================');
|
||||||
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
console.log(`.env を保存しました: ${result.envPath}`);
|
||||||
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
console.log(`Cookie 長さ: ${result.cookieLength} 文字`);
|
||||||
'openid.return_to': 'https://alexa.amazon.co.jp/api/apps/v1/token',
|
}
|
||||||
'pageId': 'amzn_dp_project_dee_jp',
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
const USER_AGENT =
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36';
|
|
||||||
|
|
||||||
let cookieJar = {};
|
|
||||||
|
|
||||||
function setCookies(setCookieHeaders) {
|
|
||||||
if (!setCookieHeaders) return;
|
|
||||||
const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
|
|
||||||
for (const h of headers) {
|
|
||||||
const [kv] = h.split(';');
|
|
||||||
const [k, v] = kv.trim().split('=');
|
|
||||||
if (k && v !== undefined) cookieJar[k.trim()] = v.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCookieHeader() {
|
|
||||||
return Object.entries(cookieJar)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function request(url, options = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
const reqOpts = {
|
|
||||||
hostname: parsed.hostname,
|
|
||||||
path: parsed.pathname + parsed.search,
|
|
||||||
method: options.method || 'GET',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
'Accept-Language': 'ja-JP,ja;q=0.9',
|
|
||||||
'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
|
|
||||||
'Cookie': getCookieHeader(),
|
|
||||||
...(options.headers || {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = https.request(reqOpts, (res) => {
|
|
||||||
setCookies(res.headers['set-cookie']);
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (d) => (body += d));
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve({ status: res.statusCode, headers: res.headers, body });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
if (options.body) req.write(options.body);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML の hidden フィールドを抽出
|
|
||||||
function extractHiddenFields(html) {
|
|
||||||
const fields = {};
|
|
||||||
const re = /<input[^>]+type=["']?hidden["']?[^>]*>/gi;
|
|
||||||
let match;
|
|
||||||
while ((match = re.exec(html)) !== null) {
|
|
||||||
const tag = match[0];
|
|
||||||
const name = (tag.match(/name=["']([^"']+)["']/) || [])[1];
|
|
||||||
const value = (tag.match(/value=["']([^"']*)["']/) || ['', ''])[1];
|
|
||||||
if (name) fields[name] = value;
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
// フォームの action URL を抽出
|
|
||||||
function extractFormAction(html) {
|
|
||||||
const m = html.match(/id="ap_login_form"[^>]+action="([^"]+)"/);
|
|
||||||
if (m) return m[1].replace(/&/g, '&');
|
|
||||||
const m2 = html.match(/name="signIn"[^>]+action="([^"]+)"/);
|
|
||||||
if (m2) return m2[1].replace(/&/g, '&');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('[1] ログインページ取得中...');
|
|
||||||
const page1 = await request(ALEXA_LOGIN_URL);
|
|
||||||
console.log(` Status: ${page1.status}, Cookies: ${Object.keys(cookieJar).join(', ')}`);
|
|
||||||
|
|
||||||
if (page1.status !== 200) {
|
|
||||||
console.error(`[ERROR] ログインページ取得失敗: ${page1.status}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// フォーム情報を抽出
|
|
||||||
const action = extractFormAction(page1.body);
|
|
||||||
const hidden = extractHiddenFields(page1.body);
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
console.error('[ERROR] ログインフォームが見つかりません。HTMLを確認します:');
|
|
||||||
console.error(page1.body.substring(0, 500));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[2] フォーム送信先: ${action}`);
|
|
||||||
console.log(` Hidden fields: ${Object.keys(hidden).join(', ')}`);
|
|
||||||
|
|
||||||
// フォームデータ構築
|
|
||||||
const formData = new URLSearchParams({
|
|
||||||
...hidden,
|
|
||||||
email: EMAIL,
|
|
||||||
password: PASSWORD,
|
|
||||||
rememberMe: 'true',
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
console.log('[3] 認証送信中...');
|
|
||||||
const page2 = await request(action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'Referer': ALEXA_LOGIN_URL,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
console.log(` Status: ${page2.status}`);
|
|
||||||
console.log(` Location: ${page2.headers.location || '(none)'}`);
|
|
||||||
console.log(` Cookies after login: ${Object.keys(cookieJar).join(', ')}`);
|
|
||||||
|
|
||||||
// リダイレクトをたどる
|
|
||||||
let current = page2;
|
|
||||||
let redirectCount = 0;
|
|
||||||
while (current.status >= 300 && current.status < 400 && current.headers.location && redirectCount < 10) {
|
|
||||||
const loc = current.headers.location;
|
|
||||||
const nextUrl = loc.startsWith('http') ? loc : `https://www.amazon.co.jp${loc}`;
|
|
||||||
console.log(`[${4 + redirectCount}] Redirect → ${nextUrl.substring(0, 80)}...`);
|
|
||||||
current = await request(nextUrl);
|
|
||||||
console.log(` Status: ${current.status}, New Cookies: ${Object.keys(cookieJar).join(', ')}`);
|
|
||||||
redirectCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成功判定: at-acbjp または session-token が含まれているか
|
|
||||||
const cookie = getCookieHeader();
|
|
||||||
const hasAlexaToken = cookie.includes('at-acbjp') || cookie.includes('session-token');
|
|
||||||
|
|
||||||
if (!hasAlexaToken) {
|
|
||||||
console.error('[ERROR] 認証に失敗しました。取得できたCookieに認証トークンが含まれていません。');
|
|
||||||
console.error('取得済みCookieキー:', Object.keys(cookieJar).join(', '));
|
|
||||||
if (current.body.includes('captcha') || current.body.includes('CAPTCHA')) {
|
|
||||||
console.error('※ CAPTCHA が要求されています。しばらく待ってから再試行してください。');
|
|
||||||
}
|
|
||||||
if (current.body.includes('password') && current.body.includes('error')) {
|
|
||||||
console.error('※ パスワードが間違っている可能性があります。');
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// .env に保存
|
|
||||||
const envPath = path.join(__dirname, '.env');
|
|
||||||
fs.writeFileSync(envPath, `ALEXA_COOKIE=${cookie}\n`);
|
|
||||||
|
|
||||||
console.log('\n==============================================');
|
|
||||||
console.log(' 認証成功!');
|
|
||||||
console.log('==============================================');
|
|
||||||
console.log(`.env を保存しました: ${envPath}`);
|
|
||||||
console.log(`Cookie 長さ: ${cookie.length} 文字`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error('[FATAL]', err);
|
console.error('[FATAL]', err);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"description": "Alexa TTS API server for Windmill integration",
|
"description": "Alexa TTS API server for Windmill integration",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js"
|
"start": "node server.js",
|
||||||
|
"auth": "node auth4.js",
|
||||||
|
"auth:web": "node auth4-web.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.0"
|
"express": "^4.18.0"
|
||||||
|
|||||||
@@ -78,11 +78,19 @@ Windmill ワークフロー
|
|||||||
└→ http://alexa_api:3500/speak を呼び出す
|
└→ http://alexa_api:3500/speak を呼び出す
|
||||||
```
|
```
|
||||||
|
|
||||||
### ネットワーク設計のポイント
|
### ネットワーク設計のポイント
|
||||||
|
|
||||||
- `alexa_api` コンテナは外部に公開しない(セキュリティ)
|
- `alexa_api` コンテナは外部に公開しない(セキュリティ)
|
||||||
- Windmill ワーカーと同じ Docker 内部ネットワーク `windmill_windmill-internal` に接続
|
- Windmill ワーカーと同じ Docker 内部ネットワーク `windmill_windmill-internal` に接続
|
||||||
- Windmill から `http://alexa_api:3500` でアクセス可能
|
- Windmill から `http://alexa_api:3500` でアクセス可能
|
||||||
|
|
||||||
|
### Cookie 更新の権限設計
|
||||||
|
|
||||||
|
- `akira` に `windmill` への自由な切り替え権限は与えない
|
||||||
|
- 代わりに `sudoers` で `/usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけを許可する
|
||||||
|
- ローカル GUI は `.env` を `/tmp/alexa-api.env` へ転送したあと、この専用スクリプトだけを `sudo` で実行する
|
||||||
|
- 専用スクリプト自身は root で `.env` 反映と `docker compose restart` を完了する
|
||||||
|
- これにより Cookie 更新だけを安全寄りに GUI 化できる
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -93,11 +101,15 @@ Windmill ワークフロー
|
|||||||
| `server.js` | `alexa-api/` | Express API サーバー本体 | 本番コード。変更したらビルド・再デプロイが必要 |
|
| `server.js` | `alexa-api/` | Express API サーバー本体 | 本番コード。変更したらビルド・再デプロイが必要 |
|
||||||
| `Dockerfile` | `alexa-api/` | Docker イメージ定義 | node:20-alpine ベース |
|
| `Dockerfile` | `alexa-api/` | Docker イメージ定義 | node:20-alpine ベース |
|
||||||
| `docker-compose.yml` | `alexa-api/` | コンテナ起動設定 | windmill_windmill-internal に接続 |
|
| `docker-compose.yml` | `alexa-api/` | コンテナ起動設定 | windmill_windmill-internal に接続 |
|
||||||
| `package.json` | `alexa-api/` | npm 依存関係 | 本番は express のみ |
|
| `package.json` | `alexa-api/` | npm 依存関係 | 本番は express のみ |
|
||||||
| `.env.example` | `alexa-api/` | 環境変数テンプレート | `ALEXA_COOKIE=xxx` の形式 |
|
| `.env.example` | `alexa-api/` | 環境変数テンプレート | `ALEXA_COOKIE=xxx` の形式 |
|
||||||
| `.env` | `alexa-api/`(.gitignore 対象) | 実際の Cookie 保管 | Git にコミットしない |
|
| `.env` | `alexa-api/`(.gitignore 対象) | 実際の Cookie 保管 | Git にコミットしない |
|
||||||
| `auth4.js` | `alexa-api/` | Amazon 認証・Cookie 取得スクリプト | **ローカルのみで実行**(Windowsブラウザ認証が必要) |
|
| `auth4.js` | `alexa-api/` | Amazon 認証・Cookie 取得スクリプト | CLI版。ローカルのみで実行 |
|
||||||
| `auth.js` / `auth2.js` / `auth3.js` | `alexa-api/` | auth4.js の旧バージョン | 参考用。実際は auth4.js を使う |
|
| `auth4-web.js` | `alexa-api/` | Cookie 更新 GUI | ブラウザで認証し、サーバー反映まで実行可能 |
|
||||||
|
| `auth4-core.js` | `alexa-api/` | Cookie 更新の共通ロジック | CLI版とGUI版で共通利用 |
|
||||||
|
| `alexa-cookie-deploy.sh` | `alexa-api/` | サーバー側専用反映スクリプト | `/tmp/alexa-api.env` を本番 `.env` に反映し、root で再起動 |
|
||||||
|
| `alexa-cookie-deploy.sudoers` | `alexa-api/` | sudoers 設定例 | `akira` から専用反映スクリプトだけ実行許可 |
|
||||||
|
| `auth.js` / `auth2.js` / `auth3.js` | `alexa-api/` | auth4.js の旧バージョン | 参考用。実際は auth4.js を使う |
|
||||||
| `test_tts.js` | `alexa-api/` | ローカルテスト用スクリプト | `.env` を読んで直接 alexa.amazon.co.jp を叩く。テスト対象デバイスはシリアル `G0922H08525302K5`(オフィスの右エコー)にハードコード。TABLET は一覧表示から除外。 |
|
| `test_tts.js` | `alexa-api/` | ローカルテスト用スクリプト | `.env` を読んで直接 alexa.amazon.co.jp を叩く。テスト対象デバイスはシリアル `G0922H08525302K5`(オフィスの右エコー)にハードコード。TABLET は一覧表示から除外。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -398,19 +410,42 @@ sudo docker restart traefik
|
|||||||
|
|
||||||
> **⚠️ 重要**: `docker compose restart` はイメージをリビルドしない。コード変更は `build + up -d` が必要。
|
> **⚠️ 重要**: `docker compose restart` はイメージをリビルドしない。コード変更は `build + up -d` が必要。
|
||||||
|
|
||||||
### B. Cookie 更新時のデプロイ(ビルド不要)
|
### B. Cookie 更新時のデプロイ(ビルド不要)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. ローカルで auth4.js を実行して .env を更新
|
# 1. ローカルで GUI を起動
|
||||||
cd alexa-api
|
cd /home/akira/develop/windmill_workflow/alexa-api
|
||||||
AMAZON_EMAIL="xxx" AMAZON_PASSWORD="xxx" node auth4.js
|
npm run auth:web
|
||||||
|
|
||||||
# 2. .env をサーバーに転送
|
# 2. ブラウザで http://127.0.0.1:3678 を開き、Amazon の認証情報を入力
|
||||||
scp alexa-api/.env keinafarm-claude:/home/claude/alexa-api/.env
|
|
||||||
|
# 3. 同じ画面の「サーバーへ反映する」を実行
|
||||||
# 3. コンテナを再起動(restart で OK。Traefik 再起動不要)
|
# デフォルト値:
|
||||||
ssh keinafarm-claude 'sudo docker compose -f /home/claude/alexa-api/docker-compose.yml restart'
|
# SSH 接続先: keinafarm
|
||||||
```
|
# リモート一時アップロード先: /tmp/alexa-api.env
|
||||||
|
# 実行する専用コマンド: sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### B-1. Cookie 更新 GUI の初回セットアップ
|
||||||
|
|
||||||
|
サーバー側で一度だけ以下を実施する:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 専用反映スクリプトを配置
|
||||||
|
scp alexa-api/alexa-cookie-deploy.sh keinafarm:/tmp/alexa-cookie-deploy.sh
|
||||||
|
ssh keinafarm 'sudo install -m 755 /tmp/alexa-cookie-deploy.sh /usr/local/bin/alexa-cookie-deploy.sh'
|
||||||
|
|
||||||
|
# 2. sudoers を配置
|
||||||
|
scp alexa-api/alexa-cookie-deploy.sudoers keinafarm:/tmp/alexa-cookie-deploy.sudoers
|
||||||
|
ssh keinafarm 'sudo install -m 440 /tmp/alexa-cookie-deploy.sudoers /etc/sudoers.d/alexa-cookie-deploy'
|
||||||
|
|
||||||
|
# 3. 動作確認
|
||||||
|
ssh keinafarm 'sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/not-found.env' || true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `akira` から許可するのは `sudo /usr/local/bin/alexa-cookie-deploy.sh /tmp/alexa-api.env` だけ
|
||||||
|
- `windmill` への自由な `su` や広い `sudo` 権限は与えない
|
||||||
|
- 専用スクリプトは `/tmp/alexa-api.env` を `/home/claude/alexa-api/.env` に反映し、root で `docker compose restart` を実行する
|
||||||
|
|
||||||
### Traefik 再起動が必要な理由
|
### Traefik 再起動が必要な理由
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"id": "failure",
|
"id": "failure",
|
||||||
"value": {
|
"value": {
|
||||||
"type": "rawscript",
|
"type": "rawscript",
|
||||||
"content": "import * as wmill from \"windmill-client\";\n\nexport async function main() {\n const token = await wmill.getVariable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\");\n const to = await wmill.getVariable(\"u/admin/LINE_TO\");\n\n const message = [\n \"\\u26a0\\ufe0f \\u9ce9\\u6642\\u8a08\\u30a8\\u30e9\\u30fc\",\n \"\",\n \"Alexa TTS API \\u304c\\u5931\\u6557\\u3057\\u307e\\u3057\\u305f\\u3002\",\n \"Cookie\\u306e\\u671f\\u9650\\u5207\\u308c\\u306e\\u53ef\\u80fd\\u6027\\u304c\\u3042\\u308a\\u307e\\u3059\\u3002\",\n \"\",\n \"\\u5bfe\\u51e6: auth4.js \\u3067 Cookie \\u3092\\u518d\\u53d6\\u5f97\\u3057\\u3066\\u304f\\u3060\\u3055\\u3044\\u3002\"\n ].join(\"\\n\");\n\n const res = await fetch(\"https://api.line.me/v2/bot/message/push\", {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n to: to,\n messages: [{ type: \"text\", text: message }],\n }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`LINE API error ${res.status}: ${body}`);\n }\n\n return { notified: true };\n}\n",
|
"content": "import * as wmill from \"windmill-client\";\n\nexport async function main() {\n const token = await wmill.getVariable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\");\n const to = await wmill.getVariable(\"u/admin/LINE_TO\");\n\n const message = [\n \"\\u26a0\\ufe0f \\u9ce9\\u6642\\u8a08\\u30a8\\u30e9\\u30fc\",\n \"\",\n \"Alexa TTS API \\u304c\\u5931\\u6557\\u3057\\u307e\\u3057\\u305f\\u3002\",\n \"Cookie\\u306e\\u671f\\u9650\\u5207\\u308c\\u306e\\u53ef\\u80fd\\u6027\\u304c\\u3042\\u308a\\u307e\\u3059\\u3002\",\n \"\",\n \"\\u5bfe\\u51e6\\u624b\\u9806:\",\n \"1. \\u30ed\\u30fc\\u30ab\\u30ebPC\\u3067GUI\\u3092\\u8d77\\u52d5\",\n \"cd /home/akira/develop/windmill_workflow/alexa-api\",\n \"npm run auth:web\",\n \"\",\n \"2. \\u30d6\\u30e9\\u30a6\\u30b6\\u3067 http://127.0.0.1:3678 \\u3092\\u958b\\u304f\",\n \"Amazon \\u306e\\u30e1\\u30fc\\u30eb\\u30a2\\u30c9\\u30ec\\u30b9\\u3068\\u30d1\\u30b9\\u30ef\\u30fc\\u30c9\\u3092\\u5165\\u529b\",\n \"\",\n \"3. \\u540c\\u3058GUI\\u306e\\u300c\\u30b5\\u30fc\\u30d0\\u30fc\\u3078\\u53cd\\u6620\\u3059\\u308b\\u300d\\u3092\\u5b9f\\u884c\",\n \"SSH \\u63a5\\u7d9a\\u5148\\u306f keinafarm \\u3067OK\",\n \"\\u521d\\u56de\\u3060\\u3051 /usr/local/bin/alexa-cookie-deploy.sh \\u3068 sudoers \\u8a2d\\u5b9a\\u304c\\u5fc5\\u8981\"\n ].join(\"\\n\");\n\n const res = await fetch(\"https://api.line.me/v2/bot/message/push\", {\n method: \"POST\",\n headers: {\n \"Authorization\": `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify({\n to: to,\n messages: [{ type: \"text\", text: message }],\n }),\n });\n\n if (!res.ok) {\n const body = await res.text();\n throw new Error(`LINE API error ${res.status}: ${body}`);\n }\n\n return { notified: true };\n}\n",
|
||||||
"language": "bun",
|
"language": "bun",
|
||||||
"input_transforms": {}
|
"input_transforms": {}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user