Единый форум поддержки

Информация о пользователе

Привет, Гость! Войдите или зарегистрируйтесь.


Вы здесь » Единый форум поддержки » Сообщения об ошибках » форумы mybb доступны только через VPN


форумы mybb доступны только через VPN

Сообщений 1 страница 6 из 6

Перевести1

собственно, сабж :)

провайдер Ростелеком

Отредактировано Merlin777 (Вс, 21 Дек 2025 15:34:45)

0

Перевести2

Merlin777
Нужны более точные сведения. Будет возможность сделать трассировку при отключеном ВПН?

На компьютере с ОС Windows:

  • На клавиатуре нажать комбинацию клавиш WIN + R

  • В появившемся окне вставить cmd -> нажать Enter

  • Вписать tracert forum.mybb.ru -> нажать Enter

  • Дождаться надписи Трассировка завершена

Сделать скриншот окна и прикрепить его в этой теме.

0

Перевести3

https://upforme.ru/uploads/0000/14/1c/38891/150911.png

0

Перевести4

Merlin777
Со стороны Ростелекома не видится проблем, трассировка успешная до наших IP, значит проблема не в маршрутизации. У меня тоже Ростелеком, проблем с доступом не наблюдаю.
Есть подозрение, что проблему может вызывать бан IP по многократному превышению допустимого числа запросов в секунду. Но для проверки мне понадобится ваш IP (без VPN естественно), актуальный на момент выявленной проблемы (т.е. когда не можете попасть на форумы).
Блокировка автоматически снимается спустя час после вынесения.
У вас на форуме много скриптов, создающих дополнительные запросы, возможно какой-то (какие-то) из них не оптимизирован(ы) и в какие-то моменты начинают ддосить запросами сервер, вследствие чего IP попадает под блокировку.
С VPN проблема может не проявляться или быть менее выраженной ввиду снижения скорости передачи данных (и времени ответа), вследствие чего запросы идут с меньшей частотой и количество их в секунду снижается.

Added after 18 minutes 16 seconds:
Вот этот скрипт у вас множество одновременных запросов генерирует: https://forumstatic.ru/files/0010/54/6f/78002.js?v=26 довольно часто вызывая 503 ошибку, что легко увидеть, взглянув в консоль. Если открыть несколько вкладок или с разных устройств с одним IP, вполне реально словить бан по IP. При этом совершенно непонятно, для чего делать несколько отдельных запросов на каждый пост, запросы вида: https://gribnikikybani.mybb.ru/api.php?method=post.get&post_id=55479&fields=id%2Cusername%2Cmessage%2Cposted%2Cnum_replies%2Csubject&_=1766382026049
когда можно передать post_id массивом или просто через запятую в один-единственный запрос и работать с полученными данными для всех post_id разом.

0

Перевести5

Форум не доступен не со всех VPN, что сразу отменяет вашу теорию про бан  :)  Сейчас я пишу удалённо с физического сервера в стойке в Европе, так сказать, использую служебное подожение в личных целях  :)

Про запросы через скрипт - там НЕТ "многократному превышению допустимого числа запросов в секунду", там задержка по времени везде между запросами.
Сначала запрашивается список тем, потом идут запросы на содержание постов, посты санитизируются и показываются в лайв-боксе - и это при первоначальном наполнении лайв-бокса. Через 7 секунд запрашивается список ТЕМ, и в  ИЗМЕНИВШИХСЯ ПО ВРЕМЕНИ темах  - новые посты запрашиваются по одному. Это основная логика обновления (там ещё есть  :)  ).

Но даже если бы все 10 постов запрашивались каждые 7  секунд, ещё и с задержкой по времени между собой,  это вообще как-то не "тянет" на DDOS атаку  :)
Проблемы с доступом ещё и до лайв-бокса наблюдались, если что  :)

И браузеры в неактивных вкладках блокируют внешние запросы, так что что 100 вкладок открыть с главной страницей, что одну, результат не меняется  :)

Насчёт "можно передать post_id массивом или просто через запятую в один-единственный запрос и работать с полученными данными для всех post_id разом" это идея хорошая, если Вы поможете это сделать, приведя пример рабочего кода  :)

Кроме всего этого, в коде есть поддержка Чёрного списка.

  • Если последнее сообщение в теме написано пользователем из чёрного списка, то оно не должно отображаться.

  • Вместо этого запрашивается последняя страница темы (через post.get с topic_id и limit=30, сортировка по дате в обратном порядке).

  • Среди сообщений на этой странице ищется первое (снизу вверх), автор которого НЕ в чёрном списке.

  • Для этого используется post.get с topic_id, а не с конкретным post_id.

Чтобы исключить точечные  запросы по get для Чёрного списка, Вы бы могли добавить в API опцию исключения сообщений от заданных ников из выдачи, типа  exclude_user_id или exclude_username, это бы радикально решило вопрос  :)

Отредактировано Merlin777 (Пн, 22 Дек 2025 23:33:22)

0

Перевести6

Я код не выкладывал в исходниках по причине того, что нужно было бы объяснять всё, там много неочевидной логики  :)

Но для улучшения запросов к серверу, без кода видимо не обойтись :)

Исходный код JS части лайв-бокса, последняя версия:

Показать код
Код:
// Настройки фильтрации и пагинации
const POSTS_PER_PAGE = 30;
const EXCLUDED_FORUM_IDS = new Set([42, 50]);  // ID разделов, которые не показывать
const EXCLUDED_TOPIC_IDS = new Set([]);        // ID тем, которые не показывать
const Utils = {
  // Загружает список заблокированных пользователей из localStorage
  getBlockedUserIdsSet() {
    try {
      const raw = localStorage.getItem('blockedUsers');
      if (!raw) return new Set();
      const list = JSON.parse(raw);
      const ids = list
        .filter(item => item && typeof item === 'object' && item.id != null)
        .map(item => {
          const id = Number(item.id);
          return isNaN(id) ? null : id;
        })
        .filter(id => id !== null);
      return new Set(ids);
    } catch (e) {
      return new Set();
    }
  },
  // Извлекает "чистое" название темы из формата "color;;bg;;title"
  extractCleanTitle(titleText) {
    if (!titleText || !titleText.includes(';;')) return titleText || '';
    const parts = titleText.split(';;');
    if (parts.length >= 3) {
      return parts.slice(2).join(';;');
    }
    return titleText;
  },
  // Обрезает текст с многоточием
  truncate(text, maxLength = 40) {
    if (!text) return "";
    text = text.trim();
    return text.length > maxLength ? text.slice(0, maxLength) + "…" : text;
  },
  // Генерирует уникальный ID для строки темы
  getTopicId(item) {
    return `topic_${item[4]}`; // item[4] = topic ID
  }
};
const Settings = {
  // Асинхронная загрузка настроек пользователя
  loadUserSettings() {
    return new Promise((resolve) => {
      const defaultSettings = {
        enabled: true,
        showNewestOnTop: false,
        maxTopicLength: 40,
        maxMobileTopicLength: 15,
        maxMessageLength: 60,
        maxMobileAuthorLength: 12,
        coloredTopics: true,
        topicCount: 6,
        rowHeight: 37,
        ignoreBlockedOnLastPage: true,
        openLinksInNewTab: false, // ← ДОБАВЛЕНО
        updatedAt: 0
      };
      try {
        const localSettings = localStorage.getItem('liveBoxUserSettings');
        if (localSettings) {
          const parsed = JSON.parse(localSettings);
          resolve({ ...defaultSettings, ...parsed });
          return;
        }
      } catch (e) { }
      resolve(defaultSettings);
    });
  },
  // Синхронная версия — для частого использования в UI/API
  getLiveSettingsSync() {
    const defaultSettings = {
      enabled: true,
      showNewestOnTop: false,
      maxTopicLength: 40,
      maxMobileTopicLength: 15,
      maxMessageLength: 60,
      maxMobileAuthorLength: 12,
      coloredTopics: true,
      topicCount: 6,
      rowHeight: 33,
      ignoreBlockedOnLastPage: true,
      openLinksInNewTab: false, // ← ДОБАВЛЕНО
      updatedAt: 0
    };
    try {
      const localSettings = localStorage.getItem('liveBoxUserSettings');
      if (localSettings) {
        return { ...defaultSettings, ...JSON.parse(localSettings) };
      }
    } catch (e) { }
    return defaultSettings;
  },
  // Сохранение настроек в localStorage
  saveUserSettings(settings) {
    return new Promise((resolve, reject) => {
      try {
        localStorage.setItem('liveBoxUserSettings', JSON.stringify(settings));
        resolve();
      } catch (error) {
        reject(error);
      }
    });
  },
  // Проверка, включён ли Live-бокс
  shouldShowLiveBox() {
    return this.loadUserSettings().then(settings => settings.enabled);
  },
  // Скрывает стандартный блок "Выбор отображения страниц" в профиле
  hideDisplaySettingsSection() {
    const fieldsets = document.querySelectorAll('fieldset');
    for (const fieldset of fieldsets) {
      const legendSpan = fieldset.querySelector('legend > span');
      if (legendSpan && legendSpan.textContent.trim() === 'Выбор отображения страниц') {
        fieldset.remove();
        break;
      }
    }
  },
    // Добавляет панель настроек Live-бокса в профиль (только на своей странице)
  addProfileSettings() {
    if (document.getElementById('live-settings-accordion')) return;
    const urlParams = new URLSearchParams(window.location.search);
    const profileId = urlParams.get('id');
    if (!profileId) return;
    // Определяем, чей это профиль — свой или чужой
    let myId = null;
    if (typeof pun_user !== 'undefined' && pun_user.id && !pun_user.is_guest) {
      myId = String(pun_user.id);
    }
    if (!myId) {
      const profileLinks = document.querySelectorAll('a[href*="/profile.php?id="]');
      for (const link of profileLinks) {
        try {
          const hrefId = new URL(link.href).searchParams.get('id');
          if (hrefId) {
            myId = hrefId;
            break;
          }
        } catch (e) { }
      }
    }
    if (!myId || myId !== profileId) return;
    const punMain = document.getElementById('pun-main');
    if (!punMain) {
      setTimeout(() => this.addProfileSettings(), 500);
      return;
    }
    this.hideDisplaySettingsSection();
    this.loadUserSettings().then(settings => {
      const isMobile = window.innerWidth <= 768;
      const containerStyle = isMobile ? 'width: 100%;' : 'display: inline-block;';
      // Генерация полей ввода (разные для мобилки и десктопа)
      let textSettingsHtml = '';
      if (isMobile) {
        textSettingsHtml += `
          <div>
            <label>
              Длина темы (моб.):
              <input type="number" id="max-mobile-topic-length" value="${settings.maxMobileTopicLength || 15}" min="1" max="20" />
            </label>
          </div>
          <div>
            <label>
              Длина ника (моб.):
              <input type="number" id="max-mobile-author-length" value="${settings.maxMobileAuthorLength || 12}" min="5" max="20" />
            </label>
          </div>
        `;
      } else {
        textSettingsHtml += `
          <div>
            <label>
              Количество символов в теме (макс. 60):
              <input type="number" id="max-topic-length" value="${settings.maxTopicLength || 40}" min="1" max="60" />
            </label>
          </div>
          <div>
            <label>
              Количество символов в сообщении (макс. 60):
              <input type="number" id="max-message-length" value="${settings.maxMessageLength || 60}" min="1" max="60" />
            </label>
          </div>
        `;
      }
      // Формируем HTML всей панели
      let settingsHtml = `
        <div id="live-settings-accordion" class="live-settings-accordion" style="${containerStyle}">
          <div class="live-settings-header">
            <span>🟢 Настройки Live-бокса</span>
            <span class="live-settings-toggle">▼</span>
          </div>
          <div class="live-settings-content">
            <div class="live-settings-inner">
              <div>
                <label>
                  <input type="checkbox" id="live-enabled" ${settings.enabled ? 'checked' : ''}> 
                  🟢 Включить Live-бокс на главной странице
                </label>
              </div>
              <div>
                <label>
                  <input type="checkbox" id="live-sort-newest-on-top" ${settings.showNewestOnTop ? 'checked' : ''}>
                  📌 Новые темы сверху
                  <small style="display:block;color:#777;font-weight:normal;">(если не отмечено — новые снизу)</small>
                </label>
              </div>
              <div>
                <label>
                  Количество отображаемых тем (3–8):
                  <input type="number" id="live-topic-count" value="${settings.topicCount || 5}" min="3" max="8" />
                </label>
              </div>
              <div>
                <label>
                  Высота строки (px):
                  <input type="number" id="live-row-height" value="${settings.rowHeight || 35}" min="20" max="60" />
                </label>
              </div>
              ${textSettingsHtml}
              <div>
                <label>
                  <input type="checkbox" id="colored-topics-enabled" ${settings.coloredTopics ? 'checked' : ''}> 
                  🎨 Раскрашивать цветные темы
                </label>
              </div>
              <div>
                <label>
                  <input type="checkbox" id="open-links-in-new-tab" ${settings.openLinksInNewTab ? 'checked' : ''}>
                  🔗 Открывать ссылки в новом окне
                </label>
              </div>
              <div>
                <label style="display:block;margin-top:8px;">
                  <input type="checkbox" id="ignore-blocked-on-last-page" ${settings.ignoreBlockedOnLastPage ? 'checked' : ''}>
                  🚫 Игнорировать сообщения от пользователей из чёрного списка
                  <small style="display:block;color:#777;font-weight:normal;">(проверяются только посты на последней странице темы)</small>
                </label>
              </div>
              <button id="save-live-settings" class="button">💾 Сохранить настройки</button>
              <div id="live-settings-status"></div>
            </div>
          </div>
        </div>
      `;
      // Вставка перед формой редактирования профиля или в #pun-main
      const profileForm = document.querySelector('form#edit-profile, form[name="edit_profile"]');
      const targetContainer = profileForm || punMain;
      const tempDiv = document.createElement('div');
      tempDiv.innerHTML = settingsHtml.trim();
      const settingsEl = tempDiv.firstElementChild;
      if (profileForm) {
        profileForm.parentNode.insertBefore(settingsEl, profileForm);
      } else {
        punMain.prepend(settingsEl);
      }
      // Обработчик аккордеона (раскрытие/сворачивание)
      const header = document.querySelector('.live-settings-header');
      if (header) {
        header.addEventListener('click', () => {
          const content = header.nextElementSibling;
          const toggle = header.querySelector('.live-settings-toggle');
          if (!content || !toggle) return;
          const isOpen = content.style.maxHeight && content.style.maxHeight !== '0px';
          if (isOpen) {
            content.style.maxHeight = '0px';
            content.style.padding = '0';
            toggle.style.transform = 'rotate(0deg)';
          } else {
            const inner = content.querySelector('.live-settings-inner');
            if (inner) {
              const innerHeight = inner.scrollHeight;
              content.style.maxHeight = innerHeight + 'px';
              content.style.padding = '20px 16px';
              toggle.style.transform = 'rotate(180deg)';
            }
          }
        });
      }
      // Обработчик сохранения настроек
      const saveBtn = document.getElementById('save-live-settings');
      if (saveBtn) {
        saveBtn.addEventListener('click', e => {
          e.preventDefault();
          // Сбор значений
          const enabled = document.getElementById('live-enabled')?.checked || false;
          const newestOnTop = document.getElementById('live-sort-newest-on-top')?.checked || false;
          const colored = document.getElementById('colored-topics-enabled')?.checked || false;
          const ignoreBlocked = document.getElementById('ignore-blocked-on-last-page')?.checked || false;
          const openLinksInNewTab = document.getElementById('open-links-in-new-tab')?.checked || false; // ← ДОБАВЛЕНО
          const topicCount = parseInt(document.getElementById('live-topic-count')?.value) || 6;
          const rowHeight = parseInt(document.getElementById('live-row-height')?.value) || 33;
          const newSettings = {
            enabled,
            showNewestOnTop: newestOnTop,
            coloredTopics: colored,
            ignoreBlockedOnLastPage: ignoreBlocked,
            openLinksInNewTab, // ← ДОБАВЛЕНО
            topicCount,
            rowHeight,
            updatedAt: Date.now()
          };
          if (isMobile) {
            newSettings.maxMobileTopicLength = parseInt(document.getElementById('max-mobile-topic-length')?.value) || 15;
            newSettings.maxMobileAuthorLength = parseInt(document.getElementById('max-mobile-author-length')?.value) || 12;
          } else {
            newSettings.maxTopicLength = parseInt(document.getElementById('max-topic-length')?.value) || 40;
            newSettings.maxMessageLength = parseInt(document.getElementById('max-message-length')?.value) || 60;
          }
          // Сохранение + обратная связь
          this.saveUserSettings(newSettings).then(() => {
            const statusEl = document.getElementById('live-settings-status');
            if (statusEl) {
              // ✅ Обёрнут в .live-emoji для мгновенного отображения
              statusEl.innerHTML = '<span class="live-emoji">✅</span> Настройки успешно сохранены!';
              statusEl.style.display = 'block';
              setTimeout(() => {
                statusEl.style.display = 'none';
              }, 3000);
            }
          }).catch(error => {
            const statusEl = document.getElementById('live-settings-status');
            if (statusEl) {
              statusEl.textContent = '❌ Ошибка: ' + (error.message || error);
              statusEl.style.display = 'block';
              setTimeout(() => {
                statusEl.style.display = 'none';
              }, 5000);
            }
          });
        });
      }
    });
  }
};
const API = {
  // Получает последние темы через API
  fetchTopics(isInitial = false, currentLimit = null, retryCountAjax = 0, context = {}) {
    const { isFetching, paused, retryDelay = 1500 } = context;
    if (paused || isFetching) return;
    // Параметры для адаптивной загрузки
    const INITIAL_LIMIT = 15;
    const REGULAR_LIMIT = 15;
    const INITIAL_STEP = 10;
    const REGULAR_STEP = 6;
    const INITIAL_MAX = 80;
    const REGULAR_MAX = 40;
    const maxTopics = 8;
    const limit = currentLimit !== null
      ? currentLimit
      : (isInitial ? INITIAL_LIMIT : REGULAR_LIMIT);
    const step = isInitial ? INITIAL_STEP : REGULAR_STEP;
    const maxLimit = isInitial ? INITIAL_MAX : REGULAR_MAX;
    context.isFetching = true;
    const fields = 'id,subject,last_username,last_user_id,last_post_id,last_post_date,forum_id,num_replies';
    const url = `/api.php?method=topic.getRecent&limit=${limit}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`;
    fetch(url)
      .then(response => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json();
      })
      .then(data => {
        context.isFetching = false;
        if (!data?.response || !Array.isArray(data.response)) {
          if (retryCountAjax < 10) setTimeout(() => this.fetchTopics(isInitial, currentLimit, retryCountAjax + 1, context), retryDelay);
          return;
        }
        // Фильтрация и преобразование тем
        let validTopics = data.response
          .filter(topic => {
            if (!topic.last_post_id || !topic.last_post_date || !topic.id) return false;
            if (EXCLUDED_FORUM_IDS.has(Number(topic.forum_id))) return false;
            if (EXCLUDED_TOPIC_IDS.has(Number(topic.id))) return false;
            return true;
          })
          .map(topic => {
            const timestamp = parseInt(topic.last_post_date);
            const author = topic.last_username || 'Гость';
            const pid = topic.last_post_id;
            const url = `/viewtopic.php?pid=${pid}#pid${pid}`;
            const title = topic.subject || '[Без названия]';
            const tid = topic.id;
            const lastUserId = topic.last_user_id ? Number(topic.last_user_id) : 0;
            const numReplies = topic.num_replies ? parseInt(topic.num_replies) : 0;
            return [timestamp, author, url, title, tid, pid, lastUserId, numReplies];
          });
        if (validTopics.length >= maxTopics) {
          UI.processTopics(validTopics.slice(0, maxTopics), context);
          return;
        }
        if (limit < maxLimit) {
          setTimeout(() => this.fetchTopics(isInitial, limit + step, retryCountAjax, context), 100);
        } else {
          UI.processTopics(validTopics, context);
        }
      })
      .catch(error => {
        context.isFetching = false;
        console.error('Ошибка API topic.getRecent:', error);
        if (retryCountAjax < 10) setTimeout(() => this.fetchTopics(isInitial, currentLimit, retryCountAjax + 1, context), retryDelay);
      });
  },
  // Загружает последнее сообщение темы (с учётом чёрного списка)
  fetchPost(item, context) {
    const topicId = item[4];
    const lastPostId = item[5];
    const lastUserId = item[6];
    const numReplies = item[7];
    const totalPosts = numReplies + 1;
    const lastPage = Math.ceil(totalPosts / POSTS_PER_PAGE);
    const rowTopicId = Utils.getTopicId(item);
    const userSettings = Settings.getLiveSettingsSync();
    const shouldFilter = userSettings.ignoreBlockedOnLastPage;
    if (!shouldFilter) {
      return this._loadPostById(lastPostId, item, topicId, lastPage, context);
    }
    const blockedSet = Utils.getBlockedUserIdsSet();
    if (blockedSet.size === 0) {
      return this._loadPostById(lastPostId, item, topicId, lastPage, context);
    }
    if (!blockedSet.has(lastUserId)) {
      return this._loadPostById(lastPostId, item, topicId, lastPage, context);
    }
    // Если автор в чёрном списке — ищем видимый пост на последней странице
    this.findVisiblePostOnLastPage(topicId, blockedSet).then(visiblePost => {
      if (!visiblePost) {
        const row = document.querySelector(`.post-row[data-topic-id="${rowTopicId}"]`);
        if (row) row.remove();
        return;
      }
      const newItem = [...item];
      newItem[0] = Math.floor(new Date(visiblePost.posted * 1000).getTime() / 1000);
      newItem[1] = visiblePost.username;
      newItem[2] = `/viewtopic.php?id=${topicId}&p=${lastPage}#p${visiblePost.id}`;
      newItem[5] = visiblePost.id;
      newItem[6] = visiblePost.user_id;
      UI.handlePostUpdate(
        rowTopicId,
        `post_${visiblePost.id}`,
        newItem,
        visiblePost.message,
        false,
        visiblePost.id,
        context
      );
    }).catch(() => {
      const row = document.querySelector(`.post-row[data-topic-id="${rowTopicId}"]`);
      if (row) {
        const loadingMsg = row.querySelector('.loading-msg');
        if (loadingMsg) loadingMsg.textContent = '[ошибка]';
      }
    });
  },
  // Загружает конкретный пост по ID
  _loadPostById(postId, item, topicId, page, context) {
    const finalMsgId = `post_${postId}`;
    const rowTopicId = Utils.getTopicId(item);
    if (context.seenMessages.has(finalMsgId)) return;
    context.pendingPosts++;
    const fields = 'id,username,message,posted,num_replies,subject';
    const url = `/api.php?method=post.get&post_id=${postId}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`;
    fetch(url)
      .then(response => response.json())
      .then(data => {
        context.pendingPosts--;
        if (!data?.response || !Array.isArray(data.response) || data.response.length === 0) {
          UI.handlePostUpdate(rowTopicId, finalMsgId, item, "[сообщения не найдены]", true, null, context);
          if (context.pendingPosts <= 0 && context.firstLoad) context.firstLoad = false;
          return;
        }
        const post = data.response[0];
        const messageText = post.message || '';
        const colorSubject = post.subject || item[3];
        const newItem = [...item];
        newItem[2] = `/viewtopic.php?id=${topicId}&p=${page}#p${post.id}`;
        newItem[3] = colorSubject;
        UI.handlePostUpdate(rowTopicId, finalMsgId, newItem, messageText, false, post.id, context);
        if (context.pendingPosts <= 0 && context.firstLoad) context.firstLoad = false;
      })
      .catch(() => {
        context.pendingPosts--;
        UI.handlePostUpdate(rowTopicId, finalMsgId, item, "[недоступно]", true, null, context);
        if (context.pendingPosts <= 0 && context.firstLoad) context.firstLoad = false;
      });
  },
  // Ищет первый незаблокированный пост на последней странице темы
  async findVisiblePostOnLastPage(topicId, blockedSet) {
    const fields = 'id,user_id,username,posted,message';
    const url = `/api.php?method=post.get&topic_id=${topicId}&limit=${POSTS_PER_PAGE}&sort_by=posted&sort_dir=desc&fields=${encodeURIComponent(fields)}&_=${Date.now()}`;
    try {
      const response = await fetch(url);
      const data = await response.json();
      if (!data?.response || !Array.isArray(data.response)) return null;
      for (const post of data.response) {
        const userId = Number(post.user_id);
        if (!blockedSet.has(userId)) {
          return {
            id: post.id,
            user_id: userId,
            username: post.username || 'Гость',
            posted: post.posted,
            message: post.message || ''
          };
        }
      }
      return null;
    } catch (error) {
      return null;
    }
  }
};
const UI = {
  bodyGrid: null,
  wrapper: null,
  // Главная функция запуска Live-бокса на главной странице
  runLiveBox() {
    if (window.location.pathname !== "/" && !window.location.pathname.endsWith("index.php")) return;
    const maxTopics = 8;
    let paused = false, activeTab = true, isFetching = false;
    const seenMessages = new Set(), trackedPosts = new Map();
    let inactiveTime = 0, checkInterval = 7000, firstLoad = true, pendingPosts = 0;
    let lastTopicIds = new Set();
    let forceFullReloadNextTime = false;
    let lastShowNewestOnTop = Settings.getLiveSettingsSync().showNewestOnTop;
    // 🔁 Мгновенная пересортировка при изменении настройки
    const instantlyReapplySorting = () => {
      const settings = Settings.getLiveSettingsSync();
      const rows = Array.from(this.bodyGrid.querySelectorAll('.post-row'));
      rows.sort((a, b) => {
        const tsA = Number(a.getAttribute('data-timestamp')) || 0;
        const tsB = Number(b.getAttribute('data-timestamp')) || 0;
        return settings.showNewestOnTop ? tsB - tsA : tsA - tsB;
      });
      rows.forEach(row => this.bodyGrid.appendChild(row));
      if (settings.showNewestOnTop) {
        if (this.wrapper) this.wrapper.scrollTop = 0;
      } else {
        if (this.wrapper) this.wrapper.scrollTop = this.wrapper.scrollHeight;
      }
    };
    // Проверка настроек каждые 300 мс — для мгновенного обновления сортировки
    setInterval(() => {
      const current = Settings.getLiveSettingsSync().showNewestOnTop;
      if (current !== lastShowNewestOnTop) {
        lastShowNewestOnTop = current;
        instantlyReapplySorting();
      }
    }, 300);
    // Пауза при переходе на другую вкладку
    document.addEventListener("visibilitychange", () => {
      activeTab = document.visibilityState === "visible";
      if (activeTab) {
        API.fetchTopics(true, null, 0, {
          isFetching, paused, retryDelay: 1500,
          seenMessages, trackedPosts, firstLoad, pendingPosts,
          lastTopicIds, forceFullReloadNextTime
        });
        inactiveTime = 0;
      }
    });
    // Восстановление состояния паузы
    const storedPaused = localStorage.getItem('livePaused');
    if (storedPaused !== null) paused = storedPaused === 'true';
    // Удаление старого контейнера, если есть
    const existingContainer = document.getElementById('live-posts-container');
    if (existingContainer) existingContainer.remove();
    // Определение места вставки
    let containerParent = null;
    const punDebug = document.getElementById('pun-debug');
    const punUlinks = document.getElementById('pun-ulinks');
    const body = document.body;
    if (punDebug) {
      containerParent = punDebug.parentNode;
      const container = document.createElement('div');
      container.id = 'live-posts-container';
      containerParent.replaceChild(container, punDebug);
    } else if (punUlinks) {
      containerParent = punUlinks.parentNode;
      const container = document.createElement('div');
      container.id = 'live-posts-container';
      containerParent.insertBefore(container, punUlinks.nextSibling);
    } else {
      const container = document.createElement('div');
      container.id = 'live-posts-container';
      body.prepend(container);
    }
    // 💠 Добавление CSS для корректного отображения эмодзи
    {
      const style = document.createElement('style');
      style.textContent = '.live-emoji{font-family:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif !important;}';
      document.head.appendChild(style);
    }
    // Расчёт высоты контейнера
    const userSettingsForHeight = Settings.getLiveSettingsSync();
    const topicCount = Math.max(3, Math.min(8, userSettingsForHeight.topicCount || 6));
    const rowHeight = Math.max(20, Math.min(60, userSettingsForHeight.rowHeight || 33));
    const wrapperHeight = rowHeight * topicCount;
    // HTML-шаблон Live-бокса
    const containerContent = `
      <div class="live-posts-container">
        <div class="live-header-grid">
          <div class="header-cell tcl">
            <div class="left-controls">
              <label class="switch">
                <input type="checkbox" id="pause-updates">
                <span class="slider"></span>
              </label>
              <span class="pause-label">Стоп</span>
            </div>
            <div class="author-label"><span class="live-emoji">🧑💻</span> Автор</div>
          </div>
          <div class="header-cell tc2"><span class="live-emoji">🕒</span> Время</div>
          <div class="header-cell tcr"><span class="live-emoji">📁</span> Тема / <span class="live-emoji">💬</span> Сообщение</div>
        </div>
        <div class="live-body-wrapper" style="max-height: ${wrapperHeight}px;">
          <div class="live-body-grid" id="live-posts-body">
            <div class="post-row"><div class="post-cell">Загрузка сообщений...</div></div>
          </div>
        </div>
      </div>
    `;
    const container = document.getElementById('live-posts-container');
    container.innerHTML = containerContent;
    this.bodyGrid = document.getElementById('live-posts-body');
    this.wrapper = document.querySelector('.live-body-wrapper');
    // Обработчик кнопки паузы
    const pauseCheckbox = document.getElementById('pause-updates');
    if (pauseCheckbox) {
      pauseCheckbox.checked = paused;
      pauseCheckbox.addEventListener('change', () => {
        paused = pauseCheckbox.checked;
        localStorage.setItem('livePaused', paused);
      });
    }
    // Контекст для передачи в API и обработчики
    const context = {
      isFetching, paused, retryDelay: 1500,
      seenMessages, trackedPosts, firstLoad, pendingPosts,
      lastTopicIds, forceFullReloadNextTime,
      inactiveTime, checkInterval, activeTab, maxTopics,
      bodyGrid: this.bodyGrid,
      wrapper: this.wrapper,
      getTopicId: Utils.getTopicId,
      getLiveSettingsSync: Settings.getLiveSettingsSync,
      handlePostUpdate: this.handlePostUpdate.bind(this),
      processTopics: this.processTopics.bind(this),
      instantlyReapplySorting
    };
    // Периодическая проверка, не удалили ли сообщения
    const revalidatePosts = () => {
      if (paused || !activeTab || trackedPosts.size === 0) return;
      const now = Date.now(), maxRecheck = 5, minInterval = 20000;
      const rows = Array.from(this.bodyGrid.querySelectorAll('.post-row')).slice(-maxRecheck);
      rows.forEach(el => {
        const msgId = el.getAttribute('data-msg-id');
        if (!msgId) return;
        const tracked = trackedPosts.get(msgId);
        if (!tracked || !tracked.postId) return;
        if (now - (tracked.lastCheck || 0) < minInterval) return;
        tracked.lastCheck = now;
        const item = tracked.item, topicId = Utils.getTopicId(item), postId = tracked.postId;
        let attempts = 0;
        const load = () => {
          attempts++;
          const fields = 'id,username,message,posted';
          const url = `/api.php?method=post.get&post_id=${postId}&fields=${encodeURIComponent(fields)}&_=${Date.now()}`;
          fetch(url)
            .then(response => response.json())
            .then(data => {
              if (!data?.response || !Array.isArray(data.response) || data.response.length === 0) {
                this.handlePostUpdate(topicId, msgId, item, "[сообщение удалено]", true, null, context);
                trackedPosts.delete(msgId);
                return;
              }
              const post = data.response[0], messageText = post.message || '';
              this.handlePostUpdate(topicId, msgId, item, messageText, false, postId, context);
            })
            .catch(error => {
              if (attempts < 5) setTimeout(load, 1000);
              else {
                this.handlePostUpdate(topicId, msgId, item, "[сообщение недоступно]", true, null, context);
                trackedPosts.delete(msgId);
              }
            });
        };
        load();
      });
    };
    // Отслеживание удаления строк (для очистки кэша)
    const liveObserver = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        mutation.removedNodes.forEach((node) => {
          if (node.nodeType === 1 && node.classList.contains('post-row')) {
            const topicId = node.getAttribute('data-topic-id');
            const msgId = node.getAttribute('data-msg-id');
            if (msgId) {
              seenMessages.delete(msgId);
              trackedPosts.delete(msgId);
            }
          }
        });
      });
    });
    liveObserver.observe(this.bodyGrid, { childList: true, subtree: true });
    // Основной цикл обновления
    setInterval(() => {
      if (!activeTab) {
        inactiveTime += checkInterval;
        let interval = 15000;
        if (inactiveTime >= 180000) interval = 60000;
        else if (inactiveTime >= 60000) interval = 30000;
        if (inactiveTime % interval === 0) {
          API.fetchTopics(true, null, 0, context);
        }
        return;
      }
      inactiveTime = 0;
      const isFullReload = forceFullReloadNextTime;
      if (isFullReload) {
        forceFullReloadNextTime = false;
      }
      API.fetchTopics(isFullReload, null, 0, context);
    }, checkInterval);
    setInterval(revalidatePosts, 20000);
    // Обновление при возврате в онлайн
    window.addEventListener('online', () => {
      seenMessages.clear();
      trackedPosts.clear();
      API.fetchTopics(true, null, 0, context);
    });
    // Первый запуск
    API.fetchTopics(true, null, 0, context);
  },
  // Обработка массива тем — обновление DOM
  processTopics(content, context) {
    const { bodyGrid, getTopicId, getLiveSettingsSync, wrapper } = context;
    const currentSeenTopics = new Set();
    const currentSettings = getLiveSettingsSync();
    const isMobileNow = window.innerWidth <= 768;
    // Обнаружение удалённых тем → триггер полной перезагрузки
    const newTopicIds = new Set();
    content.forEach(item => {
      newTopicIds.add(getTopicId(item));
    });
    if (newTopicIds.size > 0) {
      let hasDisappeared = false;
      context.lastTopicIds.forEach(id => {
        if (!newTopicIds.has(id)) {
          hasDisappeared = true;
        }
      });
      if (hasDisappeared) {
        context.forceFullReloadNextTime = true;
      }
    }
    context.lastTopicIds = newTopicIds;
    // Сортировка данных
    if (currentSettings.showNewestOnTop) {
      content.sort((a, b) => b[0] - a[0]);
    } else {
      content.sort((a, b) => a[0] - b[0]);
    }
    // Очистка "загрузки..."
    const firstRow = bodyGrid.querySelector('.post-row');
    if (firstRow && firstRow.querySelector('.loading-msg')) {
      bodyGrid.innerHTML = '';
    }
    // Обновление или создание строк
    content.forEach(item => {
      const topicId = getTopicId(item);
      const ts = new Date(1000 * item[0]);
      const timeStr = `${String(ts.getDate()).padStart(2,'0')}.${String(ts.getMonth()+1).padStart(2,'0')} ${String(ts.getHours()).padStart(2,'0')}:${String(ts.getMinutes()).padStart(2,'0')}`;
      let existingRow = null;
      bodyGrid.querySelectorAll('.post-row').forEach(row => {
        if (row.getAttribute('data-topic-id') === topicId) {
          existingRow = row;
        }
      });
      if (existingRow) {
        existingRow.setAttribute('data-timestamp', item[0].toString());
        const currentTimeText = existingRow.querySelector('.tc2')?.textContent.trim() || '';
        const newTimeText = `<span class="live-emoji">🕒</span> ${timeStr}`;
        if (currentTimeText !== newTimeText) {
          existingRow.querySelector('.tc2').innerHTML = newTimeText;
        }
        const postId = item[5], finalMsgId = postId ? `post_${postId}` : item[0] + "_api";
        const tracked = context.trackedPosts.get(finalMsgId);
        const currentLastPostId = tracked ? tracked.postId : null;
        if (currentLastPostId !== postId) {
          context.seenMessages.delete(finalMsgId);
          context.trackedPosts.delete(finalMsgId);
          API.fetchPost(item, context);
        }
        currentSeenTopics.add(topicId);
        return;
      }
      // Новая строка
      const author = isMobileNow ? Utils.truncate(item[1], currentSettings.maxMobileAuthorLength || 12) : item[1];
      const topicTitle = item[3];
      const processedTopicTitle = currentSettings.coloredTopics
        ? this.processTopicTitle(topicTitle, isMobileNow ? (currentSettings.maxMobileTopicLength || 15) : (currentSettings.maxTopicLength || 40))
        : Utils.truncate(Utils.extractCleanTitle(topicTitle), isMobileNow ? (currentSettings.maxMobileTopicLength || 15) : (currentSettings.maxTopicLength || 40));
      const row = document.createElement('div');
      row.className = 'post-row';
      row.setAttribute('data-topic-id', topicId);
      row.setAttribute('data-timestamp', item[0].toString());
      row.innerHTML = `
        <div class="post-cell tcl"><span class="live-emoji">🧑💻</span> ${author}</div>
        <div class="post-cell tc2"><span class="live-emoji">🕒</span> ${timeStr}</div>
        <div class="post-cell tcr"><span class="loading-msg">Загрузка...</span></div>
      `.trim();
      bodyGrid.appendChild(row);
      currentSeenTopics.add(topicId);
    });
    // Загрузка постов по очереди с задержкой
    content.forEach((item, i) => {
      setTimeout(() => API.fetchPost(item, context), i * 200);
    });
    if (context.firstLoad) context.firstLoad = false;
    // Удаление устаревших строк
    bodyGrid.querySelectorAll('.post-row').forEach(row => {
      const topicId = row.getAttribute('data-topic-id');
      if (!currentSeenTopics.has(topicId)) {
        row.remove();
        const msgId = row.getAttribute('data-msg-id');
        if (msgId) {
          context.seenMessages.delete(msgId);
          context.trackedPosts.delete(msgId);
        }
      }
    });
    // Принудительная сортировка DOM
    const rows = Array.from(bodyGrid.querySelectorAll('.post-row'));
    rows.sort((a, b) => {
      const tsA = Number(a.getAttribute('data-timestamp')) || 0;
      const tsB = Number(b.getAttribute('data-timestamp')) || 0;
      return currentSettings.showNewestOnTop ? tsB - tsA : tsA - tsB;
    });
    rows.forEach(row => bodyGrid.appendChild(row));
    // Прокрутка после обновления
    if (!context.firstLoad) {
      if (currentSettings.showNewestOnTop) {
        if (wrapper) wrapper.scrollTop = 0;
      } else {
        if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
      }
    }
  },
  // Обновление конкретного сообщения в строке
  handlePostUpdate(topicId, msgId, item, rawContent, isDeleted, postId = null, context) {
    const { bodyGrid, wrapper, getLiveSettingsSync } = context;
    const row = bodyGrid.querySelector(`.post-row[data-topic-id="${topicId}"]`);
    if (!row) return;
    row.setAttribute('data-timestamp', item[0].toString());
    const finalMsgId = postId ? `post_${postId}` : msgId;
    row.setAttribute('data-msg-id', finalMsgId);
    const authorCell = row.querySelector('.tcl');
    const timeCell = row.querySelector('.tc2');
    const messageCell = row.querySelector('.tcr');
    const isMobileNow = window.innerWidth <= 768;
    const userSettings = getLiveSettingsSync();
    const maxMobileAuthorLength = userSettings.maxMobileAuthorLength || 12;
    const author = isMobileNow ? Utils.truncate(item[1], maxMobileAuthorLength) : item[1];
    authorCell.innerHTML = `<span class="live-emoji">🧑‍💻</span> ${author}`;
    const ts = new Date(1000 * item[0]);
    const timeStr = `${String(ts.getDate()).padStart(2, '0')}.${String(ts.getMonth() + 1).padStart(2, '0')} ${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}`;
    timeCell.innerHTML = `<span class="live-emoji">🕒</span> ${timeStr}`;
    const topicTitle = item[3];
    const maxTopicLength = isMobileNow
      ? (userSettings.maxMobileTopicLength || 15)
      : (userSettings.maxTopicLength || 40);
    const processedTopicTitle = userSettings.coloredTopics
      ? this.processTopicTitle(topicTitle, maxTopicLength)
      : Utils.truncate(Utils.extractCleanTitle(topicTitle), maxTopicLength);
    // ← УЧЁТ openLinksInNewTab
    const targetAttr = userSettings.openLinksInNewTab ? ' target="_blank"' : '';
    const topicLink = `<a href="${item[2]}"${targetAttr}>${processedTopicTitle}</a>`;
    let newHtml = topicLink;
    if (!isMobileNow && !isDeleted) {
      let cleanText = this.sanitizePostContent(rawContent);
      cleanText = cleanText.replace(/^\s*[\d,\s]+\s+написал\(а\):\s*/i, '');
      cleanText = cleanText.replace(/[\s\u00A0]+/g, ' ').trim();
      if (cleanText && cleanText !== "[пусто]") {
        const maxMessageLength = userSettings.maxMessageLength || 60;
        cleanText = Utils.truncate(cleanText, maxMessageLength);
        newHtml += `<div>"${cleanText}"</div>`;
      }
    } else if (isDeleted) {
      newHtml += `<div style="color:#999;">${rawContent}</div>`;
    }
    const extractTextFromHtml = (html) => {
      const temp = document.createElement('div');
      temp.innerHTML = html;
      return temp.textContent.trim();
    };
    const currentMessageText = extractTextFromHtml(messageCell.innerHTML);
    const newMessageText = extractTextFromHtml(newHtml);
    if (currentMessageText !== newMessageText) {
      messageCell.innerHTML = newHtml;
      context.seenMessages.add(finalMsgId);
      context.trackedPosts.set(finalMsgId, { item, lastCheck: Date.now(), content: newHtml, msgId, postId });
      if (!context.firstLoad) {
        row.classList.add('edited-highlight');
        setTimeout(() => row.classList.remove('edited-highlight'), 2000);
      }
      if (userSettings.showNewestOnTop) {
        if (wrapper) wrapper.scrollTop = 0;
      } else {
        if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
      }
    }
  },
  // Очистка HTML-контента от потенциально опасных или нежелательных элементов
  sanitizePostContent(htmlString) {
    if (!htmlString || typeof htmlString !== 'string') return "[пусто]";
    let cleaned = htmlString.replace(/\[html\][\s\S]*?\[\/html\]/gi, '[HTML контент]');
    cleaned = cleaned
      .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
      .replace(/<iframe\b[^>]*src\s*=\s*["'][^"']*ads[^"']*["'][^>]*>/gi, '');
    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = cleaned;
    // Замена видео на "[видео]"
    const isVideoIframe = (iframe) => {
      let src = (iframe.getAttribute('src') || '').trim();
      if (!src) return false;
      try {
        src = decodeURIComponent(src);
      } catch (e) { }
      const videoHosts = [
        'youtube.com', 'youtu.be', 'vimeo.com',
        'rutube.ru', 'rutube.com',
        'sendvid.com',
        'vkvideo.ru', 'vkvideo.com'
      ];
      if (videoHosts.some(host => src.includes(host))) return true;
      if (src.startsWith('/embed/') && /^[a-zA-Z0-9_-]{6,}$/.test(src.split('/')[2])) {
        return true;
      }
      return false;
    };
    tempDiv.querySelectorAll('iframe').forEach(iframe => {
      if (isVideoIframe(iframe)) {
        const placeholder = document.createTextNode('[видео]');
        iframe.parentNode.replaceChild(placeholder, iframe);
      }
    });
    tempDiv.querySelectorAll('video, embed[type="application/x-shockwave-flash"]').forEach(el => {
      const placeholder = document.createTextNode('[видео]');
      el.parentNode.replaceChild(placeholder, el);
    });
    tempDiv.querySelectorAll('a[href]').forEach(a => {
      const href = a.getAttribute('href') || '';
      if (/https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be|vimeo\.com|rutube\.(?:ru|com)|sendvid\.com|vkvideo\.(?:ru|com))/i.test(href)) {
        const placeholder = document.createTextNode('[видео]');
        a.parentNode.replaceChild(placeholder, a);
      }
    });
    // Полная очистка недопустимых тегов
    const disallowed = ['script', 'style', 'object', 'embed', '.fs-box'];
    disallowed.forEach(sel => {
      tempDiv.querySelectorAll(sel).forEach(el => el.remove());
    });
    // Замена специфичных блоков на текстовые метки
    const replacements = [
      { selector: '.quote-box', text: '[Цитата]' },
      { selector: '.spoiler-box.media-box', text: '[СпойлерМедиа]' },
      { selector: '.quote-box.hide-box', text: '[Спойлер]' },
      { selector: '.spoiler-box.text-box', text: '[СпойлерТекст]' },
      { selector: '.code-box', text: '[Код]' },
      { selector: '.quote-box.spoiler-box', text: '[СпойлерБокс]' },
      { selector: 'img', text: '[картинка]' }
    ];
    replacements.forEach(({ selector, text }) => {
      tempDiv.querySelectorAll(selector).forEach(el => {
        const placeholder = document.createTextNode(text);
        el.parentNode.replaceChild(placeholder, el);
      });
    });
    // Очистка текстовых узлов от [html]...[/html]
    const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT);
    let node, textNodes = [];
    while (node = walker.nextNode()) textNodes.push(node);
    textNodes.forEach(node => {
      let txt = node.textContent;
      txt = txt.replace(/\[html\][\s\S]*?\[\/html\]/gi, '[HTML контент]');
      node.textContent = txt;
    });
    let finalText = tempDiv.textContent.trim() || "[пусто]";
    finalText = finalText.replace(/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|vimeo\.com\/|rutube\.(?:ru|com)\/|sendvid\.com\/|vkvideo\.(?:ru|com)\/)[a-zA-Z0-9_-]+/gi, '[видео]');
    return finalText;
  },
  // Обработка цветных тем (формат color;;bg;;title)
  processTopicTitle(titleText, maxLength = null) {
    if (!titleText.includes(';;')) {
      return maxLength !== null
        ? (titleText.length > maxLength ? titleText.slice(0, maxLength) + "…" : titleText)
        : titleText;
    }
    const parts = titleText.split(';;');
    if (parts.length < 3) {
      return maxLength !== null
        ? (titleText.length > maxLength ? titleText.slice(0, maxLength) + "…" : titleText)
        : titleText;
    }
    const color = parts[0];
    const bg = parts[1];
    let cleanTitle = parts.slice(2).join(';;');
    if (maxLength !== null) {
      cleanTitle = cleanTitle.length > maxLength
        ? cleanTitle.slice(0, maxLength) + "…"
        : cleanTitle;
    }
    return `<span style="color:${color};background-color:${bg};padding:0 2px;border-radius:2px;">${cleanTitle}</span>`;
  }
};
// Запуск после полной загрузки DOM
document.addEventListener('DOMContentLoaded', () => {
  // Если страница профиля — показываем настройки (только на своей)
  if (
    window.location.pathname.includes('/profile.php') ||
    window.location.pathname.includes('/user/')
  ) {
    if (!document.getElementById('live-settings-accordion')) {
      Settings.addProfileSettings();
    }
    return;
  }
  // Если не главная страница — ничего не делаем
  if (window.location.pathname !== '/' && !window.location.pathname.endsWith('index.php')) {
    return;
  }
  // На главной — запускаем Live-бокс, если включён
  Settings.shouldShowLiveBox().then(shouldShow => {
    if (shouldShow) {
      UI.runLiveBox();
    }
  });
});

Отредактировано Merlin777 (Пн, 22 Дек 2025 18:51:49)

0


Вы здесь » Единый форум поддержки » Сообщения об ошибках » форумы mybb доступны только через VPN