いりますか??
画像 1
画像 2
画像 3
画像 4
画像 5
画像 6
画像 7
画像 8
画像 9
画像 10
画像 11
画像 12
画像 13
画像 14
画像 15
画像 16
📝 #マークダウン.md
#マークダウン
##マークダウン
###これで表示かな?
📝 Untitled-2.txt
/**
 * 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&#10;迷惑な相手@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) {}
}
プライバシーポリシー

当サイトでは、お問い合わせ時に入力されたお名前・メールアドレス等の情報を、お問い合わせへの返信および必要な連絡のために利用します。取得した個人情報は、法令に基づく場合を除き、本人の同意なく第三者に提供しません。

横読み開始
右へスワイプで次のページ 画面右タップ・矢印キーでも進めます