














#マークダウン ##マークダウン ###これで表示かな?
/**
* NioShield Standard 改訂版 v3マスターハブ(Worker + Turnstile対応・本番Standard版)
* ─────────────────────────────────────────────
* 【ライセンス表記】
* このコードの著作権は作成者に帰属します。
* 第三者への再配布・転売・譲渡を禁じます。
*
* 【初回セットアップ】
* 1. このコードを全部貼り付けて保存
* 2. デプロイ → ウェブアプリとして公開(アクセスできるユーザー:全員)→ 表示されたURL(.../exec)をコピーしておく
* 3. スプレッドシートのタブをリロード(または再度開く)
* 4. メニュー「NioShield 設定」→「設定パネルを開く」(初回は権限の承認が必要)
* 5. パネルに表示されたウェブアプリURLとsecret_tokenを、Workerの環境変数(GAS_URL / GAS_SECRET_TOKEN)に設定します
*
* 【Standard版の特徴】
* - フォーム側にsecret_tokenを出しません
* - Cloudflare WorkerでTurnstile認証を行います
* - Worker側で入口の検問、GAS側で内容審査・ログ・メール送信を行います
* - Workerが取得した検証済みIPをGAS側で利用できます
* ─────────────────────────────────────────────
*/
var SHEET_NAME = "問い合わせログ";
var ERROR_SHEET_NAME = "エラーログ";
var BLOCK_LOG_SHEET_NAME = "ブロックログ";
var SITE_MANAGEMENT_SHEET_NAME = "サイト管理";
var LOCK_TIMEOUT_MS = 30000;
var MIN_SECONDS_TO_SUBMIT = 3;
var MAX_HOURS_TO_SUBMIT = 24;
var MAX_URL_COUNT = 2;
var MIN_MESSAGE_LENGTH = 5;
var RATE_LIMIT_MINUTES = 5;
var EMAIL_LIMIT_COUNT = 4;
var IP_LIMIT_COUNT = 4;
var MESSAGE_LIMIT_COUNT = 2;
// Worker版ではclient_ipはCloudflare Workerがサーバー側で取得した値を使うため、IP判定を信頼できます。
var TRUST_CLIENT_IP = true;
var DEFAULT_NG_WORDS = ["casino", "viagra", "porn", "seo service", "backlink", "guest post"];
var BLOCKED_SHORT_URL_DOMAINS = ["bit.ly", "tinyurl.com", "t.co", "goo.gl", "is.gd", "buff.ly", "ow.ly", "rebrand.ly", "cutt.ly", "shorturl.at"];
var BLOCKED_URL_TLDS = [".xyz", ".top", ".monster", ".click", ".cam", ".club"];
var BLOCKED_EMAIL_DOMAINS = ["example.com", "test.com", "aaa.com", "mailinator.com", "tempmail.com", "10minutemail.com", "guerrillamail.com"];
var MIN_NAME_LENGTH = 2;
function onOpen() {
getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","Turnstileホスト","管理者テスト"]);
getOrCreateSheet(ERROR_SHEET_NAME, ["日時","ラベル","エラー内容","コンテキスト"]);
getOrCreateSheet(BLOCK_LOG_SHEET_NAME, ["日時","理由","詳細","サイトID","メール","名前","本文","IPアドレス","Turnstileホスト"]);
getOrCreateSheet(SITE_MANAGEMENT_SHEET_NAME, ["更新日時","サイトID","クライアント名","転送先1","転送先2"]);
SpreadsheetApp.getUi().createMenu("NioShield 設定").addItem("設定パネルを開く", "showSettingsDialog").addToUi();
}
function showSettingsDialog() {
var initData = getDialogInitData();
var html = HtmlService.createHtmlOutput(buildSettingsHtml(initData)).setWidth(460).setHeight(620);
SpreadsheetApp.getUi().showModalDialog(html, "NioShield Standard 設定パネル");
}
function ensureSecrets_() {
var props = PropertiesService.getScriptProperties();
if (!props.getProperty("API_SECRET_TOKEN")) {
props.setProperties({
"API_SECRET_TOKEN": Utilities.getUuid(),
"ADMIN_TEST_TOKEN": Utilities.getUuid(),
"CLIENT_CONFIG_JSON": JSON.stringify({ "default": { "clientName": "未設定", "email": Session.getActiveUser().getEmail(), "email2": Session.getActiveUser().getEmail(), "email3": "" } })
});
}
return props;
}
function getDialogInitData() {
var props = ensureSecrets_();
var siteIds = Object.keys(getClientConfig(props));
var firstSiteId = siteIds.length > 0 ? siteIds[0] : "default";
var webAppUrl = props.getProperty("WEB_APP_URL_MANUAL") || "";
if (!webAppUrl) {
try { webAppUrl = ScriptApp.getService().getUrl(); } catch (e) { webAppUrl = ""; }
}
return {
secretToken: props.getProperty("API_SECRET_TOKEN"),
webAppUrl: webAppUrl,
siteIds: siteIds,
firstSiteId: firstSiteId,
firstSiteConfig: getSiteSettings(props, firstSiteId),
manualLists: getManualLists(props)
};
}
function saveWebAppUrl(url) {
url = String(url || "").trim();
if (!/^https:\/\/script\.google\.com\/macros\/s\/.+\/exec$/.test(url)) {
throw new Error("正しいウェブアプリURL(.../exec)の形式ではありません。");
}
PropertiesService.getScriptProperties().setProperty("WEB_APP_URL_MANUAL", url);
return "ウェブアプリURLを保存しました。";
}
function getManualLists(props) {
var blockJson = props.getProperty("MANUAL_BLOCK_JSON");
var allowJson = props.getProperty("MANUAL_ALLOW_JSON");
return {
block: blockJson ? JSON.parse(blockJson) : { emails: [], ips: [] },
allow: allowJson ? JSON.parse(allowJson) : { emails: [], ips: [] }
};
}
function parseListLines_(text) {
return String(text || "").split(/[\n,]/).map(function(s) { return s.trim(); }).filter(Boolean);
}
function saveManualLists(blockEmailsText, blockIpsText, allowEmailsText, allowIpsText) {
var block = {
emails: parseListLines_(blockEmailsText).map(normalizeEmail),
ips: parseListLines_(blockIpsText)
};
var allow = {
emails: parseListLines_(allowEmailsText).map(normalizeEmail),
ips: parseListLines_(allowIpsText)
};
var props = PropertiesService.getScriptProperties();
props.setProperty("MANUAL_BLOCK_JSON", JSON.stringify(block));
props.setProperty("MANUAL_ALLOW_JSON", JSON.stringify(allow));
return "手動リストを保存しました。";
}
function getSiteConfigForUi(siteId) {
return getSiteSettings(PropertiesService.getScriptProperties(), String(siteId || "default").trim());
}
function saveSiteConfigFromUi(siteId, clientName, email2, email3, japaneseCheck, ngWordCheck) {
siteId = normalizeSiteId(siteId || "default");
clientName = sanitizeForSheet(String(clientName || "").trim() || "未設定");
email2 = String(email2 || "").trim();
email3 = String(email3 || "").trim();
if (!siteId) throw new Error("サイトIDを入力してください。");
if (!clientName) throw new Error("クライアント名を入力してください。");
if (!isEmail(email2)) throw new Error("転送先1の形式が正しくありません。");
if (email3 && !isEmail(email3)) throw new Error("転送先2の形式が正しくありません。");
addOrUpdateSite(siteId, {
clientName: clientName,
email: email2,
email2: email2,
email3: email3,
japaneseCheck: !!japaneseCheck,
ngWordCheck: !!ngWordCheck
});
upsertSiteManagementRow(siteId, clientName, email2, email3);
return "「" + siteId + "」の設定を保存しました。";
}
function upsertSiteManagementRow(siteId, clientName, email2, email3) {
var sheet = getOrCreateSheet(SITE_MANAGEMENT_SHEET_NAME, ["更新日時","サイトID","クライアント名","転送先1","転送先2"]);
var lastRow = sheet.getLastRow();
var now = new Date();
if (lastRow >= 2) {
var values = sheet.getRange(2, 1, lastRow - 1, 5).getValues();
for (var i = 0; i < values.length; i++) {
if (String(values[i][1] || "").trim() === String(siteId || "").trim()) {
sheet.getRange(i + 2, 1, 1, 5).setValues([[now, siteId, clientName, email2, email3]]);
return;
}
}
}
sheet.appendRow([now, siteId, clientName, email2, email3]);
}
function buildSettingsHtml(initData) {
var d = initData || {};
return `
<style>
body { font-family: 'Helvetica Neue', Arial, sans-serif; padding: 14px; font-size: 14px; color:#1e293b; }
.field { margin-bottom:16px; }
label { display:block; font-weight:bold; margin-bottom:4px; font-size:12px; color:#334155; }
select, input[type=text], input[type=email], textarea { width:100%; box-sizing:border-box; padding:8px; border:1px solid #cbd5e1; border-radius:4px; font-size:14px; }
textarea { min-height: 64px; resize: vertical; }
.readonly-row { display:flex; gap:6px; }
.readonly-row input { background:#f1f5f9; color:#475569; }
.btn-copy-mini { background:#e2e8f0; border:none; border-radius:4px; padding:0 12px; font-size:12px; cursor:pointer; flex-shrink:0; }
.btn-copy-mini:hover { background:#cbd5e1; }
.section-title { font-size:11px; font-weight:bold; color:#64748b; margin:18px 0 8px; text-transform:uppercase; letter-spacing:0.03em; }
.toggle-row { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.toggle-label { font-weight:bold; font-size:13px; }
.toggle-desc { font-size:11px; color:#64748b; margin-top:2px; }
.switch { position:relative; display:inline-block; width:44px; height:24px; flex-shrink:0; margin-left:10px; }
.switch input { opacity:0; width:0; height:0; }
.slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#cbd5e1; transition:.2s; border-radius:24px; }
.slider:before { position:absolute; content:""; height:18px; width:18px; left:3px; bottom:3px; background-color:white; transition:.2s; border-radius:50%; }
input:checked + .slider { background-color:#10b981; }
input:checked + .slider:before { transform:translateX(20px); }
.btn-save { width:100%; padding:10px; background:#2563eb; color:white; border:none; border-radius:6px; font-weight:bold; font-size:14px; cursor:pointer; margin-top:6px; }
.btn-save:disabled { background:#cbd5e1; cursor:not-allowed; }
.status { margin-top:10px; font-size:13px; text-align:center; min-height:18px; line-height:1.5; }
.new-site-row { margin-top:8px; }
.hint { font-size:11px; color:#94a3b8; margin-bottom:14px; line-height:1.6; }
.note { background:#eef2ff; color:#3730a3; border:1px solid #c7d2fe; border-radius:8px; padding:10px 12px; font-size:12px; line-height:1.6; margin-bottom:12px; }
.rate-note { background:#f8fafc; color:#64748b; border:1px solid #e2e8f0; border-radius:8px; padding:10px 12px; font-size:12px; line-height:1.7; margin:12px 0 16px; }
.ip-tool { background:#ecfdf5; border:1px solid #86efac; color:#166534; border-radius:8px; padding:10px 12px; font-size:12px; line-height:1.6; margin-bottom:12px; }
.ip-tool button { margin-top:8px; background:#10b981; color:white; border:none; border-radius:6px; padding:7px 10px; font-weight:bold; cursor:pointer; }
.ip-tool button:disabled { background:#94a3b8; cursor:not-allowed; }
</style>
<div class="note">
Standard版では、このGAS URLとsecret_tokenをCloudflare Workerの環境変数に設定します。フォームHTMLにはsecret_tokenを入れません。
</div>
<div class="section-title">接続情報(Worker設定で使う値)</div>
<div class="field">
<label>ウェブアプリURL(GAS_URL / .../exec)</label>
<div class="readonly-row">
<input type="text" id="urlField" value="" placeholder="https://script.google.com/macros/s/.../exec">
<button class="btn-copy-mini" onclick="saveUrlField()" style="background:#6366f1;color:white;">保存</button>
</div>
<div class="hint" style="margin-top:4px;">※【デプロイを管理】画面に表示されている、現在アクティブなURLを貼り付けて「保存」を押してください。</div>
<div id="currentUrlRow" style="font-size:11px; color:#94a3b8; margin-top:6px; display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<span>
現在保存されている値:
<span id="currentUrlText" style="color:#334155;">${d.webAppUrl || '(未設定:先にGASをデプロイし、上に貼り付けてください)'}</span>
</span>
<button class="btn-copy-mini" id="currentUrlCopyBtn" onclick="copyCurrentUrl()" style="padding:1px 10px; ${d.webAppUrl ? '' : 'display:none;'}">コピー</button>
</div>
<div id="urlSaveStatus" style="font-size:11px; margin-top:4px;"></div>
</div>
<div class="field">
<label>secret_token(GAS_SECRET_TOKENに使う値)</label>
<div class="readonly-row">
<input type="text" id="tokenField" readonly value="${d.secretToken || ''}">
<button class="btn-copy-mini" onclick="copyField('tokenField')">コピー</button>
</div>
</div>
<div class="section-title">サイトごとの設定</div>
<div class="hint">保存すると「サイト管理」シートに、サイトID・クライアント名・転送先1・転送先2が記録されます。</div>
<div class="field">
<label for="siteSelect">サイトを選択</label>
<select id="siteSelect"></select>
<div class="new-site-row" id="newSiteRow" style="display:none;">
<input type="text" id="newSiteId" placeholder="新しいサイトID(半角英数字・ハイフン・アンダーバー)">
</div>
</div>
<div class="field">
<label for="clientNameInput">クライアント名</label>
<input type="text" id="clientNameInput" placeholder="〇〇商店 / 山田整体院 など">
</div>
<div class="field">
<label for="emailInput2">転送先1(必須)</label>
<input type="email" id="emailInput2" placeholder="クライアントのアドレスなど">
</div>
<div class="field">
<label for="emailInput3">転送先2(任意)</label>
<input type="email" id="emailInput3" placeholder="管理者用など">
</div>
<div class="toggle-row">
<div>
<div class="toggle-label">日本語必須チェック</div>
<div class="toggle-desc">本文に日本語が1文字もない場合はブロック</div>
</div>
<label class="switch"><input type="checkbox" id="toggleJapanese"><span class="slider"></span></label>
</div>
<div class="toggle-row">
<div>
<div class="toggle-label">NGワード判定</div>
<div class="toggle-desc">スパムによく使われる単語が含まれる場合はブロック</div>
</div>
<label class="switch"><input type="checkbox" id="toggleNgWord"><span class="slider"></span></label>
</div>
<button class="btn-save" id="saveBtn">保存する</button>
<div class="status" id="statusMsg"></div>
<div class="section-title">連投ブロックの基準(共通・自動)</div>
<div class="rate-note">
5分以内に同じメールアドレス、または同じIPアドレスから4通目の送信があると、自動的にブロックされます。<br>
また、同じ本文が短時間に繰り返し送られた場合もブロックされます。<br>
Worker版ではIPアドレスがCloudflareによって検証済み(なりすまし不可)のため、IPアドレスによるブロックも信頼できます。<br>
これはスパムだけでなく、人間による嫌がらせ連投の抑止にも使えます。
</div>
<div class="section-title">手動ブロックリスト</div>
<div class="hint">自動判定には引っかからないが、受け取りたくない相手を指定できます。1行に1件、またはカンマ区切りで入力してください。</div>
<div class="field">
<label>拒否したいメールアドレス</label>
<textarea id="blockEmailsField" rows="3" placeholder="spam@example.com 迷惑な相手@example.com"></textarea>
</div>
<div class="field">
<label>拒否したいIPアドレス</label>
<textarea id="blockIpsField" rows="2" placeholder="123.45.67.89"></textarea>
</div>
<div class="section-title">手動許可リスト(ホワイトリスト)</div>
<div class="hint">誤ってブロックされてしまった相手や、管理者テスト用のメール/IPを指定すると、以後すべてのスパム判定をスキップして必ず受信します。</div>
<div class="field">
<label>常に許可するメールアドレス</label>
<textarea id="allowEmailsField" rows="3" placeholder="important-client@example.com"></textarea>
</div>
<div class="field">
<label>常に許可するIPアドレス</label>
<textarea id="allowIpsField" rows="2" placeholder="123.45.67.89"></textarea>
</div>
<div class="ip-tool">
管理者テストが弾かれる場合は、現在のIPアドレスを許可IPへ追加できます。<br>
※通信環境によってIPは変わることがあります。固定ではありません。<br>
<button type="button" id="addCurrentIpBtn" onclick="addCurrentIpToAllowList()">現在のIPを取得して許可IPへ追加</button>
<div id="currentIpStatus" style="margin-top:6px;"></div>
</div>
<button class="btn-save" id="saveManualListsBtn" style="background:#6366f1;">手動リストを保存する</button>
<div class="status" id="manualListStatusMsg"></div>
<script>
var SITE_NEW_VALUE = "__new__";
function copyField(id) {
document.getElementById(id).select();
document.execCommand('copy');
}
function copyCurrentUrl() {
var el = document.getElementById('currentUrlText');
if (!el) return;
var value = el.textContent.trim();
if (!value || value.indexOf('未設定') !== -1) {
alert('コピーできるURLがまだ保存されていません。');
return;
}
var temp = document.createElement('textarea');
temp.value = value;
temp.style.position = 'fixed';
temp.style.opacity = '0';
document.body.appendChild(temp);
temp.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(temp);
alert('URLをコピーしました。');
}
function saveUrlField() {
var url = document.getElementById('urlField').value.trim();
var statusEl = document.getElementById('urlSaveStatus');
statusEl.style.color = '#64748b';
statusEl.textContent = '保存中...';
google.script.run
.withSuccessHandler(function(msg) {
statusEl.style.color = '#10b981';
statusEl.textContent = '✔ ' + msg + '(次回開いたときも、この値が使われます)';
var textEl = document.getElementById('currentUrlText');
if (textEl) {
textEl.textContent = url;
textEl.style.color = '#334155';
}
var copyBtn = document.getElementById('currentUrlCopyBtn');
if (copyBtn) copyBtn.style.display = '';
})
.withFailureHandler(function(err) {
statusEl.style.color = '#ef4444';
statusEl.textContent = '❌ ' + (err && err.message ? err.message : err);
})
.saveWebAppUrl(url);
}
function appendUniqueLine(textareaId, value) {
var el = document.getElementById(textareaId);
var v = String(value || '').trim();
if (!v) return;
var lines = String(el.value || '').split(/[\\n,]/).map(function(s) { return s.trim(); }).filter(Boolean);
if (lines.indexOf(v) === -1) {
lines.push(v);
}
el.value = lines.join('\\n');
}
function addCurrentIpToAllowList() {
var btn = document.getElementById('addCurrentIpBtn');
var status = document.getElementById('currentIpStatus');
btn.disabled = true;
status.style.color = '#64748b';
status.textContent = 'IPを取得中...';
fetch('https://api.ipify.org?format=json')
.then(function(r) { return r.json(); })
.then(function(d) {
var ip = d && d.ip ? d.ip : '';
if (!ip) throw new Error('IPを取得できませんでした');
appendUniqueLine('allowIpsField', ip);
status.style.color = '#10b981';
status.textContent = '✔ 現在のIPを許可IPに追加しました:' + ip + ' 最後に「手動リストを保存する」を押してください。';
btn.disabled = false;
})
.catch(function(err) {
status.style.color = '#ef4444';
status.textContent = '❌ IPを取得できませんでした。必要なら手入力してください。';
btn.disabled = false;
});
}
function applyInitData(data) {
var select = document.getElementById('siteSelect');
select.innerHTML = '';
data.siteIds.forEach(function(id) {
var opt = document.createElement('option');
opt.value = id;
opt.text = id;
select.appendChild(opt);
});
var newOpt = document.createElement('option');
newOpt.value = SITE_NEW_VALUE;
newOpt.text = '+ 新しいサイトを追加';
select.appendChild(newOpt);
select.value = data.firstSiteId;
fillSiteForm(data.firstSiteConfig);
if (data.manualLists) {
document.getElementById('blockEmailsField').value = (data.manualLists.block.emails || []).join('\\n');
document.getElementById('blockIpsField').value = (data.manualLists.block.ips || []).join('\\n');
document.getElementById('allowEmailsField').value = (data.manualLists.allow.emails || []).join('\\n');
document.getElementById('allowIpsField').value = (data.manualLists.allow.ips || []).join('\\n');
}
}
function fillSiteForm(cfg) {
document.getElementById('clientNameInput').value = cfg.clientName || '';
document.getElementById('emailInput2').value = cfg.email2 || '';
document.getElementById('emailInput3').value = cfg.email3 || '';
document.getElementById('toggleJapanese').checked = !!cfg.japaneseCheck;
document.getElementById('toggleNgWord').checked = !!cfg.ngWordCheck;
}
document.getElementById('siteSelect').addEventListener('change', function() {
var val = this.value;
var newRow = document.getElementById('newSiteRow');
if (val === SITE_NEW_VALUE) {
newRow.style.display = 'block';
document.getElementById('newSiteId').value = '';
fillSiteForm({ clientName:'', email2:'', email3:'', japaneseCheck:true, ngWordCheck:true });
} else {
newRow.style.display = 'none';
google.script.run.withSuccessHandler(fillSiteForm).getSiteConfigForUi(val);
}
});
document.getElementById('saveBtn').addEventListener('click', function() {
var select = document.getElementById('siteSelect');
var siteId = select.value === SITE_NEW_VALUE ? document.getElementById('newSiteId').value.trim() : select.value;
var clientName = document.getElementById('clientNameInput').value.trim();
var email2 = document.getElementById('emailInput2').value.trim();
var email3 = document.getElementById('emailInput3').value.trim();
var japaneseCheck = document.getElementById('toggleJapanese').checked;
var ngWordCheck = document.getElementById('toggleNgWord').checked;
var statusMsg = document.getElementById('statusMsg');
var btn = this;
btn.disabled = true;
statusMsg.style.color = '#64748b';
statusMsg.innerText = '保存中...';
google.script.run
.withSuccessHandler(function(msg) {
statusMsg.style.color = '#10b981';
statusMsg.innerText = msg;
btn.disabled = false;
google.script.run.withSuccessHandler(applyInitData).getDialogInitData();
})
.withFailureHandler(function(err) {
statusMsg.style.color = '#ef4444';
statusMsg.innerText = 'エラー: ' + (err && err.message ? err.message : err);
btn.disabled = false;
})
.saveSiteConfigFromUi(siteId, clientName, email2, email3, japaneseCheck, ngWordCheck);
});
document.getElementById('saveManualListsBtn').addEventListener('click', function() {
var statusMsg = document.getElementById('manualListStatusMsg');
var btn = this;
btn.disabled = true;
statusMsg.style.color = '#64748b';
statusMsg.innerText = '保存中...';
google.script.run
.withSuccessHandler(function(msg) {
statusMsg.style.color = '#10b981';
statusMsg.innerText = msg;
btn.disabled = false;
})
.withFailureHandler(function(err) {
statusMsg.style.color = '#ef4444';
statusMsg.innerText = 'エラー: ' + (err && err.message ? err.message : err);
btn.disabled = false;
})
.saveManualLists(
document.getElementById('blockEmailsField').value,
document.getElementById('blockIpsField').value,
document.getElementById('allowEmailsField').value,
document.getElementById('allowIpsField').value
);
});
applyInitData(${JSON.stringify(d)});
</script>
`;
}
function setup() {
ensureSecrets_();
}
function showMyConfig() {
var props = PropertiesService.getScriptProperties();
console.log("secret_token: " + props.getProperty("API_SECRET_TOKEN"));
console.log("admin_token: " + props.getProperty("ADMIN_TEST_TOKEN"));
console.log(props.getProperty("CLIENT_CONFIG_JSON"));
}
function addOrUpdateSite(siteId, options) {
var props = PropertiesService.getScriptProperties();
var json = props.getProperty("CLIENT_CONFIG_JSON");
var configMap = json ? JSON.parse(json) : { "default": { "email": "" } };
configMap[siteId] = configMap[siteId] || {};
options = options || {};
if (typeof options.clientName === "string") configMap[siteId].clientName = String(options.clientName || "").trim();
if (typeof options.email === "string") configMap[siteId].email = options.email;
if (typeof options.email2 === "string") configMap[siteId].email2 = String(options.email2 || "").trim();
if (typeof options.email3 === "string") configMap[siteId].email3 = String(options.email3 || "").trim();
if (typeof options.japaneseCheck === "boolean") configMap[siteId].japaneseCheck = options.japaneseCheck;
if (typeof options.ngWordCheck === "boolean") configMap[siteId].ngWordCheck = options.ngWordCheck;
if (options.extraNgWords) configMap[siteId].extraNgWords = options.extraNgWords;
if (options.ngWordExceptions) configMap[siteId].ngWordExceptions = options.ngWordExceptions;
props.setProperty("CLIENT_CONFIG_JSON", JSON.stringify(configMap));
}
function doPost(e) {
var lock = LockService.getScriptLock();
var gotLock = false;
try {
if (!e || !e.postData || !e.postData.contents) return textResponse("No data received");
gotLock = lock.tryLock(LOCK_TIMEOUT_MS);
if (!gotLock) return textResponse("Busy");
var data;
try {
data = JSON.parse(e.postData.contents);
} catch (err) {
logBlock("Invalid JSON", {}, "JSON解析失敗");
return textResponse("Invalid data");
}
var props = PropertiesService.getScriptProperties();
var apiSecret = props.getProperty("API_SECRET_TOKEN");
if (!apiSecret || data.secret_token !== apiSecret) {
logBlock("Forbidden", data, "secret_token不一致");
return textResponse("Forbidden");
}
var adminToken = props.getProperty("ADMIN_TEST_TOKEN");
var isAdmin = !!adminToken && data.admin_token === adminToken;
var now = new Date();
var siteIdRaw = normalizeSiteId(data.site_id || "default");
var siteId = sanitizeForSheet(siteIdRaw || "default");
var userNameRaw = String(data.name || "未入力").trim();
var userName = sanitizeForSheet(userNameRaw);
var userPhone = sanitizeForSheet(String(data.phone || "未入力").trim());
var userAddress = sanitizeForSheet(String(data.address || "未入力").trim());
var userSubject = sanitizeForSheet(String(data.subject || "").trim() || "未入力");
var userMessageRaw = String(data.message || "").trim();
var userMessage = sanitizeForSheet(userMessageRaw || "未入力");
var locationUrl = sanitizeForSheet(String(data.location_url || "").trim() || "不明");
var referrerUrl = sanitizeForSheet(String(data.referrer_url || "").trim() || "(直接アクセス/不明)");
var userEmailRaw = String(data.email || "").trim();
var isValidEmail = isEmail(userEmailRaw);
var userEmail = isValidEmail ? userEmailRaw : "";
var displayEmail = sanitizeForSheet(userEmailRaw || "未入力");
var clientIpRaw = String(data.client_ip || "取得失敗").trim();
var clientIp = sanitizeForSheet("(Cloudflareで検証済み) " + clientIpRaw);
var turnstileHostnameRaw = String(data.turnstile_hostname || "").trim();
var turnstileHostname = sanitizeForSheet(turnstileHostnameRaw || "未取得");
var siteSettings = getSiteSettings(props, siteIdRaw);
var clientName = sanitizeForSheet(siteSettings.clientName || "未設定");
var recipients = buildRecipientList(siteSettings, siteId);
if (recipients.length === 0) {
throw new Error("送信先メールが未設定 site_id: " + siteIdRaw);
}
if (!isAdmin) {
var spamResult = checkSpam(
data,
{
siteId: siteId,
userEmailRaw: userEmailRaw,
userEmail: userEmail,
userNameRaw: userNameRaw,
userMessageRaw: userMessageRaw,
clientIpRaw: clientIpRaw,
now: now
},
siteSettings
);
if (spamResult.blocked) {
logBlock(spamResult.reason, data, spamResult.detail);
return textResponse("Success");
}
}
var targetEmail = recipients.join(",");
var sheet = getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","Turnstileホスト","管理者テスト"]);
sheet.appendRow([
now,
siteId,
userName,
displayEmail,
userPhone,
userAddress,
userSubject,
userMessage,
locationUrl,
referrerUrl,
clientIp,
turnstileHostname,
isAdmin ? "YES" : ""
]);
try {
lock.releaseLock();
gotLock = false;
} catch (e1) {}
var mailTitle = "【Webサイト新着】" + clientName + "|お問い合わせ(" + userName + "様)";
if (isAdmin) mailTitle = "【管理者テスト】" + mailTitle;
var mailBody =
"ウェブサイトからお問い合わせがありました。\n\n" +
"■クライアント名: " + clientName + "\n" +
"■サイトID: " + siteId + "\n" +
"■送信元URL: " + locationUrl + "\n" +
"■リンク元URL: " + referrerUrl + "\n" +
"■Turnstile認証ホスト: " + turnstileHostname + "\n" +
"■件名: " + userSubject + "\n" +
"■お名前: " + userName + "\n" +
"■メール: " + displayEmail + "\n" +
"■電話番号: " + userPhone + "\n" +
"■ご住所: " + userAddress + "\n" +
"■内容:\n" + userMessage + "\n\n" +
"■IPアドレス: " + clientIp + "\n\n" +
"※このメールに返信すると、お客様へ直接返信できます。";
if (!isValidEmail) {
mailBody += "\n\n※入力されたメールアドレスの形式が無効なため、返信先は設定されていません。";
}
try {
var mailOptions = {};
if (isValidEmail) mailOptions.replyTo = userEmail;
GmailApp.sendEmail(targetEmail, mailTitle, mailBody, mailOptions);
} catch (err) {
logError("メール送信失敗", err, { siteId: siteId, targetEmail: targetEmail });
return textResponse("Error");
}
return textResponse("Success");
} catch (error) {
logError("doPost エラー", error, {});
return textResponse("Error");
} finally {
if (gotLock) {
try { lock.releaseLock(); } catch (e2) {}
}
}
}
function checkSpam(data, info, siteSettings) {
var manual = getManualLists(PropertiesService.getScriptProperties());
var emailNorm = normalizeEmail(info.userEmailRaw);
var ipRaw = String(info.clientIpRaw || "").trim();
if ((emailNorm && manual.allow.emails.indexOf(emailNorm) !== -1) || (ipRaw && manual.allow.ips.indexOf(ipRaw) !== -1)) {
return { blocked: false, reason: "", detail: "" };
}
if ((emailNorm && manual.block.emails.indexOf(emailNorm) !== -1) || (ipRaw && manual.block.ips.indexOf(ipRaw) !== -1)) {
return { blocked: true, reason: "Manual Block", detail: "手動ブロックリストに一致" };
}
var nameRaw = String(info.userNameRaw || data.name || "").trim();
var message = String(info.userMessageRaw || "").trim();
var subject = String(data.subject || "").trim();
var joinedText = [nameRaw, subject, message].join("\n");
if (String(data.js_enabled || "") !== "yes") {
return { blocked: true, reason: "JS Check Failed", detail: "js_enabledなし" };
}
if (data.website_dummy && String(data.website_dummy).trim() !== "") {
return { blocked: true, reason: "Honeypot", detail: "隠し項目に値あり" };
}
var startedAt = Number(data.form_started_at || 0);
var nowMs = new Date().getTime();
if (startedAt > 0) {
var elapsed = (nowMs - startedAt) / 1000;
if (elapsed >= 0 && elapsed < MIN_SECONDS_TO_SUBMIT) {
return { blocked: true, reason: "Too Fast", detail: elapsed + "秒" };
}
if (elapsed > MAX_HOURS_TO_SUBMIT * 60 * 60) {
return { blocked: true, reason: "Too Old", detail: Math.round(elapsed / 3600) + "時間" };
}
}
var nameResult = checkNameSpam(nameRaw);
if (nameResult.blocked) return nameResult;
var emailResult = checkEmailSpam(info.userEmailRaw);
if (emailResult.blocked) return emailResult;
if (message.length < MIN_MESSAGE_LENGTH) {
return { blocked: true, reason: "Too Short", detail: "本文が短すぎます" };
}
if (siteSettings.japaneseCheck && !/[ぁ-んァ-ン一-龥]/.test(message)) {
return { blocked: true, reason: "No Japanese", detail: "日本語なし" };
}
var urlCount = countUrls(message);
if (urlCount > MAX_URL_COUNT) {
return { blocked: true, reason: "Too Many URLs", detail: "URL数: " + urlCount };
}
var urlRisk = checkUrlRisk(joinedText);
if (urlRisk.blocked) return urlRisk;
var htmlRisk = checkDangerousHtml(joinedText);
if (htmlRisk.blocked) return htmlRisk;
var repeatRisk = checkRepeatedText(message);
if (repeatRisk.blocked) return repeatRisk;
if (siteSettings.ngWordCheck) {
var ngWordList = DEFAULT_NG_WORDS
.filter(function(w) { return siteSettings.ngWordExceptions.indexOf(w) === -1; })
.concat(siteSettings.extraNgWords || []);
var ngResult = containsNgWord(message, ngWordList);
if (ngResult.hit) {
return { blocked: true, reason: "NG Word", detail: ngResult.word };
}
}
var rateResult = checkRateLimit(info);
if (rateResult.blocked) return rateResult;
return { blocked: false, reason: "", detail: "" };
}
function checkRateLimit(info) {
var sheet = getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","Turnstileホスト","管理者テスト"]);
var lastRow = sheet.getLastRow();
if (lastRow <= 1) return { blocked: false };
var now = info.now || new Date();
var since = new Date(now.getTime() - RATE_LIMIT_MINUTES * 60 * 1000);
var startRow = Math.max(2, lastRow - 299);
var numRows = lastRow - startRow + 1;
if (numRows <= 0) return { blocked: false };
var values = sheet.getRange(startRow, 1, numRows, 13).getValues();
var emailCount = 0;
var ipCount = 0;
var msgCount = 0;
var curSiteId = String(info.siteId || "").trim();
var curEmail = normalizeEmail(info.userEmailRaw);
var curIp = String(info.clientIpRaw || "").trim();
var curMsgHash = simpleHash(String(info.userMessageRaw || "").trim());
for (var i = 0; i < values.length; i++) {
var rowDate = values[i][0];
if (!(rowDate instanceof Date) || rowDate < since) continue;
if (String(values[i][1] || "").trim() !== curSiteId) continue;
var rowEmail = normalizeEmail(values[i][3]);
var rowMsg = String(values[i][7] || "").trim();
var rowIp = String(values[i][10] || "").replace("(Cloudflareで検証済み) ", "").trim();
if (curEmail && rowEmail && curEmail === rowEmail) emailCount++;
if (TRUST_CLIENT_IP && curIp && rowIp && curIp === rowIp) ipCount++;
if (curMsgHash && simpleHash(rowMsg) === curMsgHash) msgCount++;
}
if (curEmail && emailCount >= EMAIL_LIMIT_COUNT - 1) return { blocked: true, reason: "Email Rate Limit", detail: "連投" };
if (TRUST_CLIENT_IP && curIp && ipCount >= IP_LIMIT_COUNT - 1) return { blocked: true, reason: "IP Rate Limit", detail: "連投" };
if (curMsgHash && msgCount >= MESSAGE_LIMIT_COUNT - 1) return { blocked: true, reason: "Message Rate Limit", detail: "連投" };
return { blocked: false };
}
function getSiteSettings(props, siteIdRaw) {
var configMap = getClientConfig(props);
var def = configMap["default"] || {};
var site = configMap[siteIdRaw] || {};
return {
clientName: site.clientName || def.clientName || "",
email: site.email || def.email || "",
email2: site.email2 || def.email2 || "",
email3: site.email3 || def.email3 || "",
japaneseCheck: typeof site.japaneseCheck === "boolean" ? site.japaneseCheck : (typeof def.japaneseCheck === "boolean" ? def.japaneseCheck : true),
ngWordCheck: typeof site.ngWordCheck === "boolean" ? site.ngWordCheck : (typeof def.ngWordCheck === "boolean" ? def.ngWordCheck : true),
extraNgWords: site.extraNgWords || def.extraNgWords || [],
ngWordExceptions: site.ngWordExceptions || def.ngWordExceptions || []
};
}
function getClientConfig(props) {
var json = props.getProperty("CLIENT_CONFIG_JSON");
if (!json) return { "default": { "email": "" } };
try {
return JSON.parse(json);
} catch (e) {
return { "default": { "email": "" } };
}
}
function buildRecipientList(siteSettings, siteId) {
var recipients = [];
[siteSettings.email, siteSettings.email2, siteSettings.email3].forEach(function(addr, idx) {
if (!addr) return;
if (isEmail(addr)) {
if (recipients.indexOf(addr) === -1) recipients.push(addr);
} else {
logError("転送先メール形式不正", new Error("invalid"), {
siteId: siteId,
field: idx === 0 ? "email" : (idx === 1 ? "email2" : "email3"),
value: addr
});
}
});
return recipients;
}
function getOrCreateSheet(name, headerRow) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(name);
if (!sheet) {
sheet = ss.insertSheet(name);
sheet.appendRow(headerRow);
}
return sheet;
}
function sanitizeForSheet(value) {
var str = String(value);
if (/^[=+\-@]/.test(str)) return "'" + str;
return str;
}
function isEmail(value) {
var email = String(value || "").trim();
if (!email) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function normalizeEmail(value) {
var email = String(value || "").trim().toLowerCase();
if (email.charAt(0) === "'") email = email.substring(1);
return email;
}
function normalizeSiteId(value) {
var siteId = String(value || "default").trim();
siteId = siteId.replace(/[^a-zA-Z0-9_-]/g, "");
if (!siteId) siteId = "default";
return siteId.slice(0, 50);
}
function countUrls(text) {
var matches = String(text || "").match(/https?:\/\/|www\./gi);
return matches ? matches.length : 0;
}
function containsNgWord(text, ngWords) {
var str = String(text || "");
for (var i = 0; i < ngWords.length; i++) {
var word = String(ngWords[i] || "").trim();
if (!word) continue;
if (new RegExp("\\b" + escapeRegExp(word) + "\\b", "i").test(str)) {
return { hit: true, word: word };
}
}
return { hit: false, word: "" };
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function simpleHash(text) {
var str = String(text || "").trim();
if (!str) return "";
var hash = 0;
for (var i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash = hash & hash;
}
return String(hash);
}
function checkNameSpam(name) {
var n = String(name || "").trim();
if (n.length < MIN_NAME_LENGTH) {
return { blocked: true, reason: "Name Too Short", detail: "名前が短すぎます" };
}
if (/https?:\/\/|www\./i.test(n)) {
return { blocked: true, reason: "Name Contains URL", detail: "名前欄にURL" };
}
if (/^[a-zA-Z0-9_.-]+$/.test(n) && !/[ぁ-んァ-ン一-龥]/.test(n)) {
return { blocked: true, reason: "Name Looks Random", detail: "英数字のみの名前" };
}
if (/(.)\1{5,}/.test(n)) {
return { blocked: true, reason: "Name Repeated Characters", detail: "名前欄に同一文字連続" };
}
return { blocked: false, reason: "", detail: "" };
}
function checkEmailSpam(email) {
var raw = String(email || "").trim().toLowerCase();
if (!raw) return { blocked: true, reason: "Email Missing", detail: "メール未入力" };
if (!isEmail(raw)) return { blocked: true, reason: "Invalid Email", detail: "メール形式不正" };
var parts = raw.split("@");
var local = parts[0] || "";
var domain = parts[1] || "";
if (local.length < 3) {
return { blocked: true, reason: "Email Local Too Short", detail: "メールの@前が短すぎます" };
}
if (BLOCKED_EMAIL_DOMAINS.indexOf(domain) !== -1) {
return { blocked: true, reason: "Blocked Email Domain", detail: domain };
}
if (/^(test|admin|sample|aaa|asdf|qwerty|no-reply|noreply)$/i.test(local)) {
return { blocked: true, reason: "Suspicious Email Local", detail: local };
}
if (/(.)\1{5,}/.test(local)) {
return { blocked: true, reason: "Email Repeated Characters", detail: local };
}
return { blocked: false, reason: "", detail: "" };
}
function checkUrlRisk(text) {
var s = String(text || "").toLowerCase();
for (var i = 0; i < BLOCKED_SHORT_URL_DOMAINS.length; i++) {
var domain = BLOCKED_SHORT_URL_DOMAINS[i];
var re = new RegExp("(^|[^a-z0-9.-])" + escapeRegExp(domain) + "([^a-z0-9.-]|$)", "i");
if (re.test(s)) {
return { blocked: true, reason: "Short URL", detail: domain };
}
}
for (var j = 0; j < BLOCKED_URL_TLDS.length; j++) {
var tld = BLOCKED_URL_TLDS[j];
if (s.indexOf(tld + "/") !== -1 || s.indexOf(tld + " ") !== -1 || s.slice(-tld.length) === tld) {
return { blocked: true, reason: "Suspicious URL TLD", detail: tld };
}
}
return { blocked: false, reason: "", detail: "" };
}
function checkDangerousHtml(text) {
var s = String(text || "").toLowerCase();
var patterns = [
"<script",
"</script",
"<iframe",
"</iframe",
"javascript:",
"onclick=",
"onerror=",
"onload=",
"<a href",
"<img",
"document.cookie"
];
for (var i = 0; i < patterns.length; i++) {
if (s.indexOf(patterns[i]) !== -1) {
return { blocked: true, reason: "Dangerous HTML", detail: patterns[i] };
}
}
return { blocked: false, reason: "", detail: "" };
}
function checkRepeatedText(text) {
var s = String(text || "").trim();
if (!s) return { blocked: false, reason: "", detail: "" };
if (/(.)\1{7,}/.test(s)) {
return { blocked: true, reason: "Repeated Characters", detail: "同一文字が連続" };
}
var normalized = s.replace(/\s+/g, "");
if (normalized.length >= 12) {
var chars = {};
for (var i = 0; i < normalized.length; i++) {
chars[normalized.charAt(i)] = true;
}
var uniqueCount = Object.keys(chars).length;
if (uniqueCount <= 3) {
return { blocked: true, reason: "Low Variety Text", detail: "文字の種類が少なすぎます" };
}
}
var repeatWordMatch = s.match(/(.{2,10})\1{3,}/);
if (repeatWordMatch) {
return { blocked: true, reason: "Repeated Phrase", detail: repeatWordMatch[1] };
}
return { blocked: false, reason: "", detail: "" };
}
function textResponse(text) {
return ContentService.createTextOutput(text).setMimeType(ContentService.MimeType.TEXT);
}
function logError(label, error, context) {
try {
console.error(label, String(error), JSON.stringify(context || {}));
} catch (e1) {}
try {
var sheet = getOrCreateSheet(ERROR_SHEET_NAME, ["日時","ラベル","エラー内容","コンテキスト"]);
sheet.appendRow([new Date(), label, String(error), JSON.stringify(context || {})]);
} catch (e2) {}
}
function logBlock(reason, data, detail) {
try {
var sheet = getOrCreateSheet(BLOCK_LOG_SHEET_NAME, ["日時","理由","詳細","サイトID","メール","名前","本文","IPアドレス","Turnstileホスト"]);
sheet.appendRow([
new Date(),
reason,
detail || "",
sanitizeForSheet(data.site_id || ""),
sanitizeForSheet(data.email || ""),
sanitizeForSheet(data.name || ""),
sanitizeForSheet(data.message || ""),
sanitizeForSheet(data.client_ip || ""),
sanitizeForSheet(data.turnstile_hostname || "")
]);
} catch (e) {}
}
当サイトでは、お問い合わせ時に入力されたお名前・メールアドレス等の情報を、お問い合わせへの返信および必要な連絡のために利用します。取得した個人情報は、法令に基づく場合を除き、本人の同意なく第三者に提供しません。