GASだけで使える、個人事業主向けの本番Liteフォーム生成ページです。設定パネルのURLコピー・連投基準・手動ブロック/許可リストを復活させた版です。
1. スプレッドシートの【拡張機能】>【Apps Script】を開きます。
2. デフォルトのコードを全て削除し、以下のGASコードを丸ごと貼り付けて保存します。
/**
* NioShield Lite 改訂版 v2 v2 マスターハブ(GAS直送信・本番Lite版)
* ─────────────────────────────────────────────
* 【ライセンス表記】
* このコードの著作権は作成者に帰属します。
* 第三者への再配布・転売・譲渡を禁じます。
*
* 【初回セットアップ】
* 1. このコードを全部貼り付けて保存
* 2. デプロイ → ウェブアプリとして公開(アクセスできるユーザー:全員)→ 表示されたURL(.../exec)をコピーしておく
* 3. スプレッドシートのタブをリロード(または再度開く)
* 4. メニュー「NioShield 設定」→「設定パネルを開く」(初回は権限の承認が必要)
* 5. パネルに表示されたsecret_tokenをコピーし、手順2のURLと合わせてフォームコードに貼り付け
*
* 【複数サイトを1つのGASでまとめて管理したい場合】
* 上記4の設定パネルからサイトを追加・編集できます。
* 転送先1・2(任意)に入力すると複数メールアドレスに同時送信できます。
* ─────────────────────────────────────────────
*/
// ▼あなたのフォームジェネレーターページの固定URLをここに設定してください(末尾の / なし)
// 未設定(空欄)の場合、設定パネルの「フォーム作成ページを開く」ボタンは表示されません。
var GENERATOR_URL = "https://tiny-sunset-8f31.afuugii.workers.dev";
var SHEET_NAME = "問い合わせログ";
var ERROR_SHEET_NAME = "エラーログ";
var BLOCK_LOG_SHEET_NAME = "ブロックログ";
var LOCK_TIMEOUT_MS = 30000;
var MIN_SECONDS_TO_SUBMIT = 3;
var MAX_URL_COUNT = 2;
var MIN_MESSAGE_LENGTH = 5;
var RATE_LIMIT_MINUTES = 10;
var EMAIL_LIMIT_COUNT = 3;
var IP_LIMIT_COUNT = 5;
var MESSAGE_LIMIT_COUNT = 2;
var TRUST_CLIENT_IP = false;
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", ".work"];
var BLOCKED_EMAIL_DOMAINS = ["example.com", "test.com", "aaa.com", "mailinator.com", "tempmail.com", "10minutemail.com", "guerrillamail.com"];
var MIN_NAME_LENGTH = 2;
var MAX_HOURS_TO_SUBMIT = 24;
function onOpen() {
getOrCreateSheet(SHEET_NAME, ["日時","サイトID","名前","メール","電話","住所","件名","内容","送信元URL","リンク元URL","IPアドレス","管理者テスト"]);
getOrCreateSheet(ERROR_SHEET_NAME, ["日時","ラベル","エラー内容","コンテキスト"]);
getOrCreateSheet(BLOCK_LOG_SHEET_NAME, ["日時","理由","詳細","サイトID","メール","名前","本文","IPアドレス"]);
SpreadsheetApp.getUi().createMenu("NioShield 設定").addItem("設定パネルを開く", "showSettingsDialog").addToUi();
}
function showSettingsDialog() {
var initData = getDialogInitData();
var html = HtmlService.createHtmlOutput(buildSettingsHtml(initData)).setWidth(440).setHeight(560);
SpreadsheetApp.getUi().showModalDialog(html, "NioShield 設定パネル");
}
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": { "email": Session.getActiveUser().getEmail() } })
});
}
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, generatorUrl: GENERATOR_URL, 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, email, email2, email3, japaneseCheck, ngWordCheck) {
siteId = String(siteId || "").trim(); email = String(email || "").trim();
email2 = String(email2 || "").trim(); email3 = String(email3 || "").trim();
if (!siteId) throw new Error("サイトIDを入力してください。");
if (!isEmail(email)) throw new Error("メール1の形式が正しくありません。");
if (email2 && !isEmail(email2)) throw new Error("転送先1の形式が正しくありません。");
if (email3 && !isEmail(email3)) throw new Error("転送先2の形式が正しくありません。");
addOrUpdateSite(siteId, { email: email, email2: email2, email3: email3, japaneseCheck: !!japaneseCheck, ngWordCheck: !!ngWordCheck });
return "「" + siteId + "」の設定を保存しました。";
}
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:#fff7ed; color:#9a3412; border:1px solid #fed7aa; 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">
Lite版では、このGAS URLとsecret_tokenをフォームHTMLに入れて使います。設置は簡単ですが、強固な秘密鍵保護が必要な場合はStandard版を使ってください。
</div>
<div class="section-title">接続情報(フォーム作成で使う値)</div>
<div class="field">
<label>ウェブアプリ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(フォーム作成で使う値)</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="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="emailInput">送信先メールアドレス(メール1)</label>
<input type="email" id="emailInput" placeholder="example@example.com">
</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">
10分以内に同じメールアドレスから3通目の送信があると、自動的にブロックされます。<br>
また、同じ本文が短時間に繰り返し送られた場合もブロックされます。<br>
Lite版ではIPアドレスはフォーム側の自己申告扱いになるため、IP判定は弱めです。強いIP判定が必要な場合はStandard版を使ってください。<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>
※Lite版では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('emailInput').value = cfg.email || '';
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({ email:'', 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 email = document.getElementById('emailInput').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, email, 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.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 userName = sanitizeForSheet(String(data.name || "未入力").trim());
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("(自己申告/未検証) " + clientIpRaw);
var siteSettings = getSiteSettings(props, siteIdRaw);
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, 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アドレス","管理者テスト"]);
sheet.appendRow([now, siteId, userName, displayEmail, userPhone, userAddress, userSubject, userMessage, locationUrl, referrerUrl, clientIp, isAdmin ? "YES" : ""]);
try { lock.releaseLock(); gotLock = false; } catch (e) {}
var mailTitle = "【Webサイト新着】お問い合わせ(" + userName + "様)";
if (isAdmin) mailTitle = "【管理者テスト】" + mailTitle;
var mailBody = "ウェブサイトからお問い合わせがありました。\n\n" +
"■サイトID: " + siteId + "\n■送信元URL: " + locationUrl + "\n■リンク元URL: " + referrerUrl + "\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 emailNorm = normalizeEmail(info.userEmailRaw);
var nameRaw = String(data.name || "").trim();
var message = String(info.userMessageRaw || "").trim();
var subject = String(data.subject || "").trim();
var joinedText = [nameRaw, subject, message].join("\n");
// JSが動いているフォームから送られているか確認
// 雑なbotはHTMLだけ見て直接POSTするため、ここで落とせることがあります。
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: "日本語なし" };
}
// URL数チェック
var urlCount = countUrls(message);
if (urlCount > MAX_URL_COUNT) {
return { blocked: true, reason: "Too Many URLs", detail: "URL数: " + urlCount };
}
// 短縮URL・怪しいTLD
var urlRisk = checkUrlRisk(joinedText);
if (urlRisk.blocked) {
return urlRisk;
}
// HTML/スクリプト系
var htmlRisk = checkDangerousHtml(joinedText);
if (htmlRisk.blocked) {
return htmlRisk;
}
// 同じ文字・同じ単語の繰り返し
var repeatRisk = checkRepeatedText(message);
if (repeatRisk.blocked) {
return repeatRisk;
}
// NGワード
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アドレス","管理者テスト"]);
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, 12).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("(自己申告/未検証) ", "").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 {
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 = []; if (isEmail(siteSettings.email)) recipients.push(siteSettings.email);
[siteSettings.email2, siteSettings.email3].forEach(function(addr, idx) { if (!addr) return; if (isEmail(addr)) recipients.push(addr); else logError("転送先メール形式不正", new Error("invalid"), { siteId: siteId, field: idx === 0 ? "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 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 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 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|info|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 (e) {} 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アドレス"]); sheet.appendRow([new Date(), reason, detail || "", sanitizeForSheet(data.site_id || ""), sanitizeForSheet(data.email || ""), sanitizeForSheet(data.name || ""), sanitizeForSheet(data.message || ""), sanitizeForSheet(data.client_ip || "")]); } catch (e) {} }
3. 【デプロイ】>【新しいデプロイ】をクリックします。
4. 種類は「ウェブアプリ」、アクセスできるユーザーは「全員」にしてデプロイします。
5. 発行されたウェブアプリURL(.../exec)をコピーしておきます。
1. スプレッドシートのタブをリロードします。
2. 上部メニューの「NioShield 設定」>「設定パネルを開く」を押します。
3. ウェブアプリURLとsecret_tokenを確認します。
4. 送信先メールアドレス、転送先、日本語チェック、NGワード判定などを設定して保存します。
GAS URLとsecret_tokenを入力し、フォームを生成してください。
※URLに ?site_id=xxx がある場合は、その値を優先できます。