diff --git a/.7zignore b/.7zignore
index 8935cea92..30d8ea800 100644
--- a/.7zignore
+++ b/.7zignore
@@ -12,4 +12,6 @@ LICENSE
psalm.xml
composer.json
composer.lock
+webpack.config.js
+codecov.yml
.*
diff --git a/.github/actions/create-psc-task/action.yml b/.github/actions/create-psc-task/action.yml
new file mode 100644
index 000000000..0c5ca21c1
--- /dev/null
+++ b/.github/actions/create-psc-task/action.yml
@@ -0,0 +1,42 @@
+name: Create PSC task
+description: Open a doBoard PSC task for the pentest duty shift, or only log that task
+inputs:
+ version:
+ required: true
+ plugin_slug:
+ required: true
+ create_task:
+ required: true
+ default: "false"
+ role_name:
+ required: false
+ default: "Pentesting on Duty"
+ doboard_username:
+ required: true
+ doboard_password:
+ required: true
+ doboard_company_id:
+ required: true
+ default: "1"
+ doboard_project_id:
+ required: true
+ default: "78"
+ doboard_track_id:
+ required: true
+ default: "1537"
+runs:
+ using: composite
+ steps:
+ - name: Resolve PSC task
+ shell: bash
+ env:
+ INPUT_VERSION: ${{ inputs.version }}
+ INPUT_PLUGIN_SLUG: ${{ inputs.plugin_slug }}
+ INPUT_CREATE_TASK: ${{ inputs.create_task }}
+ INPUT_ROLE_NAME: ${{ inputs.role_name }}
+ DOBOARD_USERNAME: ${{ inputs.doboard_username }}
+ DOBOARD_PASSWORD: ${{ inputs.doboard_password }}
+ DOBOARD_COMPANY_ID: ${{ inputs.doboard_company_id }}
+ DOBOARD_PROJECT_ID: ${{ inputs.doboard_project_id }}
+ DOBOARD_TRACK_ID: ${{ inputs.doboard_track_id }}
+ run: python3 ${{ github.action_path }}/psc_task.py
diff --git a/.github/actions/create-psc-task/psc_task.py b/.github/actions/create-psc-task/psc_task.py
new file mode 100644
index 000000000..1c622daf6
--- /dev/null
+++ b/.github/actions/create-psc-task/psc_task.py
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+"""Resolve the pentest duty user and, unless create_task is false, open a PSC task."""
+
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timedelta, timezone
+
+
+ROLE_NAME_DEFAULT = "Pentesting on Duty"
+AUTH_URL = "https://api-ctask.cleantalk.org/user_authorize"
+
+
+class ApiError(Exception):
+ pass
+
+
+def env(name, default=""):
+ return os.environ.get(name, default).strip()
+
+
+def require(name):
+ value = env(name)
+ if not value:
+ raise ApiError(f"Required environment variable is empty: {name}")
+ return value
+
+
+def post(url, fields):
+ data = urllib.parse.urlencode(fields).encode("utf-8")
+ request = urllib.request.Request(
+ url,
+ data=data,
+ method="POST",
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ raw = response.read().decode("utf-8")
+ except urllib.error.HTTPError as exc:
+ body = exc.read().decode("utf-8", errors="replace")
+ raise ApiError(f"HTTP {exc.code} from {url}: {body}") from exc
+ except urllib.error.URLError as exc:
+ raise ApiError(f"Request to {url} failed: {exc.reason}") from exc
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise ApiError(f"Non-JSON response from {url}: {raw}") from exc
+
+
+def authorize():
+ result = post(
+ AUTH_URL,
+ {
+ "email": require("DOBOARD_USERNAME"),
+ "password": require("DOBOARD_PASSWORD"),
+ },
+ )
+ session_id = (result.get("data") or {}).get("session_id")
+ if not session_id:
+ raise ApiError("Auth failed")
+ return session_id
+
+
+def company_url(route):
+ company_id = env("DOBOARD_COMPANY_ID", "1")
+ return f"https://api.doboard.com/{company_id}/{route}"
+
+
+def role_id(session_id, role_name):
+ result = post(company_url("role_get"), {"session_id": session_id})
+ roles = (result.get("data") or {}).get("roles")
+ if not isinstance(roles, list):
+ raise ApiError("Roles not found")
+ for role in roles:
+ if role.get("name") == role_name:
+ return role.get("role_id")
+ raise ApiError(f"Role not found: {role_name}")
+
+
+def duty_user_id(session_id, wanted_role_id):
+ today = datetime.now(timezone.utc).date()
+ result = post(
+ company_url("calendar_events_get"),
+ {
+ "session_id": session_id,
+ "period_begin": today.isoformat(),
+ "period_end": (today + timedelta(days=1)).isoformat(),
+ "role_id": wanted_role_id,
+ },
+ )
+ events = (result.get("data") or {}).get("events")
+ if not isinstance(events, list):
+ raise ApiError("Events not found")
+ for event in events:
+ if str(event.get("role_id")) == str(wanted_role_id):
+ return event.get("user_id")
+ raise ApiError(f"No shift today for role id {wanted_role_id}")
+
+
+def task_title(plugin_slug, version):
+ return f"PSC для {plugin_slug} {version}"
+
+
+def task_comment(plugin_slug, version):
+ repo = env("GITHUB_REPOSITORY")
+ download = f"https://downloads.wordpress.org/plugin/{plugin_slug}.{version}.zip"
+ lines = [
+ f"Релиз {plugin_slug} {version}.",
+ f"Архив: {download}",
+ ]
+ if repo:
+ lines.append(f"Репозиторий: https://github.com/{repo}")
+ return "
".join(lines)
+
+
+def summary(text):
+ print(text)
+ path = env("GITHUB_STEP_SUMMARY")
+ if path:
+ with open(path, "a", encoding="utf-8") as handle:
+ handle.write(text + "\n")
+
+
+def create_task(session_id, user_id, title, comment):
+ project_id = require("DOBOARD_PROJECT_ID")
+ track_id = require("DOBOARD_TRACK_ID")
+ due = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
+ created = post(
+ company_url("task_add"),
+ {
+ "session_id": session_id,
+ "name": title,
+ "user_id": user_id,
+ "project_id": project_id,
+ "track_id": track_id,
+ "due_date": due,
+ },
+ )
+ task_id = (created.get("data") or {}).get("task_id")
+ if not task_id:
+ raise ApiError(f"Task add failed: {json.dumps(created, ensure_ascii=False)}")
+ commented = post(
+ company_url("comment_add"),
+ {
+ "session_id": session_id,
+ "task_id": task_id,
+ "comment": comment,
+ "project_id": project_id,
+ },
+ )
+ if not (commented.get("data") or {}).get("comment_id"):
+ raise ApiError(f"Comment add failed: {json.dumps(commented, ensure_ascii=False)}")
+ return task_id
+
+
+def main():
+ version = require("INPUT_VERSION").lstrip("v")
+ plugin_slug = require("INPUT_PLUGIN_SLUG")
+ role_name = env("INPUT_ROLE_NAME", ROLE_NAME_DEFAULT)
+ create = env("INPUT_CREATE_TASK", "false").lower() == "true"
+ project_id = require("DOBOARD_PROJECT_ID")
+ track_id = require("DOBOARD_TRACK_ID")
+
+ session_id = authorize()
+ wanted_role_id = role_id(session_id, role_name)
+ user_id = duty_user_id(session_id, wanted_role_id)
+ title = task_title(plugin_slug, version)
+ comment = task_comment(plugin_slug, version)
+
+ summary("## PSC task")
+ summary(f"- Role: {role_name} ({wanted_role_id})")
+ summary(f"- Assignee user id: {user_id}")
+ summary(f"- Project: {project_id}")
+ summary(f"- Track: {track_id}")
+ summary(f"- Title: {title}")
+ summary(f"- Create task: {str(create).lower()}")
+
+ if not create:
+ summary("- Task was not created.")
+ return 0
+
+ try:
+ task_id = create_task(session_id, user_id, title, comment)
+ except ApiError:
+ session_id = authorize()
+ task_id = create_task(session_id, user_id, title, comment)
+ company_id = env("DOBOARD_COMPANY_ID", "1")
+ summary(f"- Created task: https://app.doboard.com/{company_id}/task/{task_id}")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except ApiError as exc:
+ print(f"::error::{exc}", file=sys.stderr)
+ raise SystemExit(1)
diff --git a/.github/actions/deploy-wordpress-svn/action.yml b/.github/actions/deploy-wordpress-svn/action.yml
new file mode 100644
index 000000000..e5aa78ea1
--- /dev/null
+++ b/.github/actions/deploy-wordpress-svn/action.yml
@@ -0,0 +1,51 @@
+name: Deploy WordPress SVN
+description: Publish WordPress plugin to SVN from the master checkout
+inputs:
+ version:
+ required: true
+ plugin_slug:
+ description: WordPress.org plugin slug
+ required: true
+ main_file:
+ description: Main plugin file with the Version header, relative to the repository root
+ required: true
+ ignore_file:
+ description: rsync exclude list used to build the release tree
+ required: false
+ default: .7zignore
+ dry_run:
+ required: true
+ svn_username:
+ required: true
+ svn_password:
+ required: true
+ svn_url:
+ required: true
+ matrix_server:
+ required: false
+ matrix_room:
+ required: false
+ matrix_token:
+ required: false
+runs:
+ using: composite
+ steps:
+ - name: Install dependencies
+ shell: bash
+ run: sudo apt-get update && sudo apt-get install -y subversion unzip rsync python3 curl
+
+ - name: Run deploy script
+ shell: bash
+ env:
+ INPUT_VERSION: ${{ inputs.version }}
+ INPUT_PLUGIN_SLUG: ${{ inputs.plugin_slug }}
+ INPUT_MAIN_FILE: ${{ inputs.main_file }}
+ INPUT_IGNORE_FILE: ${{ inputs.ignore_file }}
+ INPUT_DRY_RUN: ${{ inputs.dry_run }}
+ INPUT_SVN_USERNAME: ${{ inputs.svn_username }}
+ INPUT_SVN_PASSWORD: ${{ inputs.svn_password }}
+ INPUT_SVN_URL: ${{ inputs.svn_url }}
+ INPUT_MATRIX_SERVER: ${{ inputs.matrix_server }}
+ INPUT_MATRIX_ROOM: ${{ inputs.matrix_room }}
+ INPUT_MATRIX_TOKEN: ${{ inputs.matrix_token }}
+ run: bash ${{ github.action_path }}/entrypoint.sh
diff --git a/.github/actions/deploy-wordpress-svn/entrypoint.sh b/.github/actions/deploy-wordpress-svn/entrypoint.sh
new file mode 100644
index 000000000..c9cf6d27d
--- /dev/null
+++ b/.github/actions/deploy-wordpress-svn/entrypoint.sh
@@ -0,0 +1,243 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+trap 'echo "::error::Command failed on line ${LINENO}: ${BASH_COMMAND}"' ERR
+
+VERSION="${INPUT_VERSION}"
+SLUG="${INPUT_PLUGIN_SLUG:-}"
+MAIN_FILE="${INPUT_MAIN_FILE:-}"
+IGNORE_NAME="${INPUT_IGNORE_FILE:-.7zignore}"
+DRY_RUN="${INPUT_DRY_RUN}"
+SVN_USERNAME="${INPUT_SVN_USERNAME}"
+SVN_PASSWORD="${INPUT_SVN_PASSWORD}"
+SVN_URL="${INPUT_SVN_URL}"
+MATRIX_SERVER="${INPUT_MATRIX_SERVER:-}"
+MATRIX_ROOM="${INPUT_MATRIX_ROOM:-}"
+MATRIX_TOKEN="${INPUT_MATRIX_TOKEN:-}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+TMP_DIR="$(mktemp -d)"
+SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-${TMP_DIR}/summary.md}"
+MATRIX_LOG_TXT="${TMP_DIR}/matrix.txt"
+MATRIX_LOG_HTML="${TMP_DIR}/matrix.html"
+: > "$MATRIX_LOG_TXT"
+: > "$MATRIX_LOG_HTML"
+trap 'rm -rf "${TMP_DIR}"' EXIT
+
+notice() { echo "::notice::$1"; }
+warn() { echo "::warning::$1"; }
+error_annot() { echo "::error::$1"; }
+append_summary() { printf '%s\n' "$1" >> "$SUMMARY_FILE"; }
+
+escape_html_text() {
+ python3 "$SCRIPT_DIR/utils.py" escape-text "$1"
+}
+
+send_matrix() {
+ local body="$1"
+ local formatted="${2:-}"
+ python3 "$SCRIPT_DIR/matrix_send.py" "$MATRIX_SERVER" "$MATRIX_ROOM" "$MATRIX_TOKEN" "$body" "$formatted"
+}
+
+log_stage() {
+ local stage="$1"
+ local text="$2"
+ notice "$stage: $text"
+ printf '%s: %s\n' "$stage" "$text" >> "$MATRIX_LOG_TXT"
+ printf '
%s: %s\n' "$stage" "$(escape_html_text "$text")" >> "$MATRIX_LOG_HTML"
+}
+
+flush_matrix() {
+ local header="$1"
+ local body_txt formatted
+ body_txt="${header}"$'\n\n'"$(cat "$MATRIX_LOG_TXT")"
+ formatted="${header}
$(cat "$MATRIX_LOG_HTML")
"
+ send_matrix "$body_txt" "$formatted" || true
+}
+
+fail() {
+ local msg="$1"
+ error_annot "$msg"
+ append_summary "## Error"
+ append_summary "- $msg"
+ printf 'ERROR: %s\n' "$msg" >> "$MATRIX_LOG_TXT"
+ printf 'ERROR: %s\n' "$(escape_html_text "$msg")" >> "$MATRIX_LOG_HTML"
+ flush_matrix "SVN publish failed"
+ exit 1
+}
+
+require_var() {
+ [[ -n "${!1:-}" ]] || fail "Required environment variable is empty: $1"
+}
+
+require_var VERSION
+require_var SLUG
+require_var MAIN_FILE
+require_var SVN_USERNAME
+require_var SVN_PASSWORD
+require_var SVN_URL
+[[ -n "${GITHUB_WORKSPACE:-}" ]] || fail "GITHUB_WORKSPACE is empty"
+
+VERSION="${VERSION#v}"
+[[ "$VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]] || fail "Invalid version: $VERSION"
+[[ "$DRY_RUN" == "true" || "$DRY_RUN" == "false" ]] || fail "dry_run must be true or false"
+
+DOWNLOAD_URL="https://downloads.wordpress.org/plugin/${SLUG}.${VERSION}.zip"
+DOWNLOADED_ZIP="${TMP_DIR}/${SLUG}.${VERSION}.zip"
+PREPARED_CONTENT_DIR="${TMP_DIR}/prepared"
+LOCAL_MANIFEST="${TMP_DIR}/prepared.sha256"
+ZIP_MANIFEST="${TMP_DIR}/zip.sha256"
+IGNORE_FILE="${GITHUB_WORKSPACE}/${IGNORE_NAME}"
+
+append_summary "# WordPress SVN publish"
+append_summary ""
+append_summary "## Context"
+append_summary "- Repository: ${GITHUB_REPOSITORY}"
+append_summary "- Plugin slug: ${SLUG}"
+append_summary "- Main file: ${MAIN_FILE}"
+append_summary "- Release payload source: master checkout"
+append_summary "- Ignore file: ${IGNORE_NAME}"
+append_summary "- Final version: ${VERSION}"
+append_summary "- Dry run: ${DRY_RUN}"
+append_summary "- SVN target: trunk and tags/${VERSION}"
+append_summary "- Expected download URL: ${DOWNLOAD_URL}"
+append_summary ""
+
+log_stage "Start" "Workflow started for ${SLUG} final version ${VERSION}. Payload source: master checkout filtered by ${IGNORE_NAME}. Dry run: ${DRY_RUN}."
+
+[[ -f "$IGNORE_FILE" ]] || fail "${IGNORE_NAME} is missing in the repository root"
+mkdir -p "$PREPARED_CONTENT_DIR"
+rsync -a --exclude-from="$IGNORE_FILE" "${GITHUB_WORKSPACE}/" "$PREPARED_CONTENT_DIR/"
+log_stage "Prepare payload" "Copied master checkout to the release tree using ${IGNORE_NAME}."
+
+MAIN_PLUGIN_FILE="${PREPARED_CONTENT_DIR}/${MAIN_FILE}"
+README_FILE="${PREPARED_CONTENT_DIR}/readme.txt"
+[[ -f "$MAIN_PLUGIN_FILE" ]] || fail "Main plugin file not found in the filtered master tree: ${MAIN_FILE}"
+[[ -f "$README_FILE" ]] || fail "readme.txt not found in the filtered master tree"
+
+PLUGIN_HEADER_VERSION="$(grep -m1 -E '^[[:space:]]*Version:[[:space:]]*' "$MAIN_PLUGIN_FILE" | sed -E 's/^[[:space:]]*Version:[[:space:]]*//; s/[[:space:]]+$//')"
+README_STABLE_TAG="$(grep -m1 -E '^Stable tag:[[:space:]]*' "$README_FILE" | sed -E 's/^Stable tag:[[:space:]]*//; s/[[:space:]]+$//')"
+
+[[ "$PLUGIN_HEADER_VERSION" == "$VERSION" ]] || fail "Plugin header Version (${PLUGIN_HEADER_VERSION}) in ${MAIN_FILE} does not match requested version (${VERSION})"
+[[ "$README_STABLE_TAG" == "$VERSION" ]] || fail "readme.txt Stable tag (${README_STABLE_TAG}) does not match requested version (${VERSION})"
+log_stage "Version check" "Plugin header and readme.txt Stable tag both match ${VERSION}."
+
+python3 "$SCRIPT_DIR/manifest_tools.py" dir-manifest "$PREPARED_CONTENT_DIR" "$LOCAL_MANIFEST"
+LOCAL_FILE_COUNT="$(wc -l < "$LOCAL_MANIFEST" | tr -d ' ')"
+log_stage "Manifest" "Prepared release manifest built from filtered master tree with ${LOCAL_FILE_COUNT} files."
+
+svn checkout "$SVN_URL" svn-repo \
+ --depth immediates \
+ --username "$SVN_USERNAME" \
+ --password "$SVN_PASSWORD" \
+ --non-interactive \
+ --trust-server-cert
+svn update svn-repo/trunk \
+ --set-depth infinity \
+ --username "$SVN_USERNAME" \
+ --password "$SVN_PASSWORD" \
+ --non-interactive \
+ --trust-server-cert
+log_stage "Checkout" "SVN trunk checked out successfully (tags left shallow)."
+
+rsync -a --delete "$PREPARED_CONTENT_DIR/" svn-repo/trunk/
+log_stage "Sync" "Filtered master tree synced to svn-repo/trunk."
+
+cd svn-repo
+svn status | awk '/^!/ {sub(/^![[:space:]]+/, ""); print}' | xargs -r -d '\n' -I{} svn rm "{}"
+svn add --force trunk --parents --depth infinity
+STATUS_OUTPUT="$(svn status || true)"
+log_stage "Prepare" "SVN working copy prepared for trunk update."
+
+append_summary "## SVN status"
+append_summary '```text'
+if [[ -n "$STATUS_OUTPUT" ]]; then
+ printf '%s\n' "$STATUS_OUTPUT" >> "$SUMMARY_FILE"
+else
+ append_summary "No changes detected."
+fi
+append_summary '```'
+append_summary ""
+
+if [[ -n "$STATUS_OUTPUT" ]]; then
+ CHANGED_COUNT="$(printf '%s\n' "$STATUS_OUTPUT" | grep -cE '^[AMDR!?]' || true)"
+ log_stage "Diff vs trunk" "${CHANGED_COUNT} file(s) changed relative to trunk."
+ printf 'Changed files vs trunk (%s):\n' "$CHANGED_COUNT" >> "$MATRIX_LOG_TXT"
+ printf '%s\n' "$STATUS_OUTPUT" >> "$MATRIX_LOG_TXT"
+ {
+ printf 'Changed files vs trunk (%s):' "$CHANGED_COUNT"
+ escape_html_text "$STATUS_OUTPUT"
+ printf '
\n'
+ } >> "$MATRIX_LOG_HTML"
+else
+ log_stage "Diff vs trunk" "No files changed relative to trunk."
+fi
+
+if [[ "$DRY_RUN" == "true" ]]; then
+ log_stage "Dry run" "Trunk would be committed from the filtered master tree and tags/${VERSION} would be created from trunk. Expected download URL: ${DOWNLOAD_URL}"
+ append_summary "## Result"
+ append_summary "- Dry run completed."
+ append_summary "- trunk would be updated from the filtered master tree."
+ append_summary "- tags/${VERSION} would be created from trunk."
+ append_summary "- Expected download URL: ${DOWNLOAD_URL}"
+ flush_matrix "SVN publish dry-run completed"
+ exit 0
+fi
+
+svn commit -m "Release ${VERSION}: update trunk from master" \
+ --username $SVN_USERNAME \
+ --password $SVN_PASSWORD \
+ --non-interactive \
+ --trust-server-cert
+log_stage "Commit trunk" "SVN trunk committed for version ${VERSION} from the filtered master tree."
+
+svn copy \
+ "$SVN_URL/trunk" \
+ "$SVN_URL/tags/${VERSION}" \
+ -m "Release ${VERSION}: create tag from trunk" \
+ --username $SVN_USERNAME \
+ --password $SVN_PASSWORD \
+ --non-interactive \
+ --trust-server-cert
+log_stage "Tag" "SVN tag tags/${VERSION} created from trunk."
+
+ATTEMPTS=12
+SLEEP_SECONDS=20
+DOWNLOAD_OK=0
+for attempt in $(seq 1 "$ATTEMPTS"); do
+ if curl -fL --retry 3 --retry-delay 2 --connect-timeout 20 --max-time 300 -o "$DOWNLOADED_ZIP" "$DOWNLOAD_URL"; then
+ DOWNLOAD_OK=1
+ log_stage "Download check" "Archive became available on attempt ${attempt}/${ATTEMPTS}."
+ break
+ fi
+ log_stage "Download check" "Archive not available yet on attempt ${attempt}/${ATTEMPTS}; waiting ${SLEEP_SECONDS}s."
+ sleep "$SLEEP_SECONDS"
+done
+
+[[ "$DOWNLOAD_OK" -eq 1 ]] || fail "Published ZIP did not become available at ${DOWNLOAD_URL} after ${ATTEMPTS} attempts"
+
+DOWNLOADED_SHA256="$(sha256sum "$DOWNLOADED_ZIP" | awk '{print $1}')"
+log_stage "Archive hash" "Downloaded ZIP SHA-256: ${DOWNLOADED_SHA256}."
+
+python3 "$SCRIPT_DIR/manifest_tools.py" zip-manifest "$DOWNLOADED_ZIP" "$ZIP_MANIFEST"
+ZIP_FILE_COUNT="$(wc -l < "$ZIP_MANIFEST" | tr -d ' ')"
+log_stage "ZIP manifest" "Downloaded archive manifest built with ${ZIP_FILE_COUNT} files."
+
+if ! diff -u <(awk '{print $2}' "$LOCAL_MANIFEST") <(awk '{print $2}' "$ZIP_MANIFEST") > "${TMP_DIR}/manifest.diff"; then
+ append_summary "## Manifest diff (file paths)"
+ append_summary '```diff'
+ cat "${TMP_DIR}/manifest.diff" >> "$SUMMARY_FILE"
+ append_summary '```'
+ fail "Downloaded ZIP file list does not match the filtered master tree"
+fi
+
+append_summary "## Result"
+append_summary "- trunk updated successfully from the filtered master tree."
+append_summary "- tags/${VERSION} created successfully."
+append_summary "- Download URL: ${DOWNLOAD_URL}"
+append_summary "- Downloaded ZIP SHA-256: ${DOWNLOADED_SHA256}"
+append_summary "- Prepared file count: ${LOCAL_FILE_COUNT}"
+append_summary "- ZIP file count: ${ZIP_FILE_COUNT}"
+
+log_stage "Validation" "Downloaded ZIP file list matches the filtered master tree."
+log_stage "Success" "Release ${VERSION} published to SVN from the filtered master tree and validated. Download URL: ${DOWNLOAD_URL}"
+flush_matrix "SVN publish completed"
diff --git a/.github/actions/deploy-wordpress-svn/manifest_tools.py b/.github/actions/deploy-wordpress-svn/manifest_tools.py
new file mode 100644
index 000000000..bb0844f60
--- /dev/null
+++ b/.github/actions/deploy-wordpress-svn/manifest_tools.py
@@ -0,0 +1,69 @@
+import hashlib
+import sys
+import zipfile
+from pathlib import Path, PurePosixPath
+
+
+def build_dir_manifest(source_dir: str, output_file: str) -> None:
+ root = Path(source_dir).resolve()
+ out = Path(output_file)
+ entries = []
+ for path in root.rglob('*'):
+ if path.is_file():
+ rel = path.relative_to(root)
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
+ entries.append((str(rel).replace('\\', '/'), digest))
+ entries.sort()
+ out.write_text(''.join(f"{digest} {rel}\n" for rel, digest in entries), encoding='utf-8')
+
+
+def extract_zip_root(zip_file: str) -> str:
+ with zipfile.ZipFile(zip_file) as zf:
+ names = [n for n in zf.namelist() if not n.endswith('/')]
+ if not names:
+ raise SystemExit('ZIP archive is empty')
+ roots = {PurePosixPath(n).parts[0] for n in names if PurePosixPath(n).parts}
+ if len(roots) != 1:
+ raise SystemExit(f'ZIP must contain exactly one top-level directory, found: {sorted(roots)}')
+ return next(iter(roots))
+
+
+def build_zip_manifest(zip_file: str, output_file: str) -> None:
+ out = Path(output_file)
+ entries = []
+ with zipfile.ZipFile(zip_file) as zf:
+ names = [n for n in zf.namelist() if not n.endswith('/')]
+ if not names:
+ raise SystemExit('ZIP archive is empty')
+ roots = {PurePosixPath(n).parts[0] for n in names if PurePosixPath(n).parts}
+ if len(roots) != 1:
+ raise SystemExit(f'ZIP must contain exactly one top-level directory, found: {sorted(roots)}')
+ for name in names:
+ p = PurePosixPath(name)
+ rel = PurePosixPath(*p.parts[1:])
+ if not rel.parts:
+ continue
+ digest = hashlib.sha256(zf.read(name)).hexdigest()
+ entries.append((str(rel), digest))
+ entries.sort()
+ out.write_text(''.join(f"{digest} {rel}\n" for rel, digest in entries), encoding='utf-8')
+
+
+def main() -> int:
+ if len(sys.argv) < 2:
+ raise SystemExit('Usage: manifest_tools.py ...')
+ command = sys.argv[1]
+ if command == 'dir-manifest' and len(sys.argv) == 4:
+ build_dir_manifest(sys.argv[2], sys.argv[3])
+ return 0
+ if command == 'zip-root' and len(sys.argv) == 3:
+ print(extract_zip_root(sys.argv[2]))
+ return 0
+ if command == 'zip-manifest' and len(sys.argv) == 4:
+ build_zip_manifest(sys.argv[2], sys.argv[3])
+ return 0
+ raise SystemExit('Invalid arguments')
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/.github/actions/deploy-wordpress-svn/matrix_send.py b/.github/actions/deploy-wordpress-svn/matrix_send.py
new file mode 100644
index 000000000..34f2ca3a0
--- /dev/null
+++ b/.github/actions/deploy-wordpress-svn/matrix_send.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+
+
+def main() -> int:
+ if len(sys.argv) != 6:
+ raise SystemExit("usage: matrix_send.py ")
+
+ server, room, token, body, formatted = sys.argv[1:6]
+ if not server or not room or not token:
+ return 0
+
+ server = server.rstrip('/')
+ room_q = urllib.parse.quote(room, safe='')
+ txn = urllib.parse.quote(uuid.uuid4().hex, safe='')
+ url = f"{server}/_matrix/client/v3/rooms/{room_q}/send/m.room.message/{txn}"
+
+ payload = {"msgtype": "m.text", "body": body}
+ if formatted:
+ payload["format"] = "org.matrix.custom.html"
+ payload["formatted_body"] = formatted
+
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(payload).encode(),
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}",
+ },
+ method="PUT",
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=30) as response:
+ response.read()
+ except urllib.error.HTTPError as e:
+ detail = e.read().decode("utf-8", errors="replace")
+ raise SystemExit(f"Matrix send failed with HTTP {e.code}: {detail}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/actions/deploy-wordpress-svn/utils.py b/.github/actions/deploy-wordpress-svn/utils.py
new file mode 100644
index 000000000..738c9b3d6
--- /dev/null
+++ b/.github/actions/deploy-wordpress-svn/utils.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+from pathlib import Path
+import argparse
+import html
+import re
+import sys
+
+
+def escape_text(value: str) -> int:
+ print(html.escape(value))
+ return 0
+
+
+def escape_file(path: str) -> int:
+ print(html.escape(Path(path).read_text(encoding="utf-8")))
+ return 0
+
+
+def parse_subject(subject: str) -> int:
+ match = re.match(r"^\{to_release:\s*(\d+)\}\s+(Fix|Upd|New)\.\s*(.+)$", subject)
+ if not match:
+ return 0
+ print("\t".join(match.groups()))
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ parser_escape_text = subparsers.add_parser("escape-text")
+ parser_escape_text.add_argument("value")
+
+ parser_escape_file = subparsers.add_parser("escape-file")
+ parser_escape_file.add_argument("path")
+
+ parser_parse_subject = subparsers.add_parser("parse-subject")
+ parser_parse_subject.add_argument("subject")
+
+ args = parser.parse_args()
+
+ if args.command == "escape-text":
+ return escape_text(args.value)
+ if args.command == "escape-file":
+ return escape_file(args.path)
+ if args.command == "parse-subject":
+ return parse_subject(args.subject)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/actions/make-changelog/action.yml b/.github/actions/make-changelog/action.yml
new file mode 100644
index 000000000..2b2ef78bf
--- /dev/null
+++ b/.github/actions/make-changelog/action.yml
@@ -0,0 +1,66 @@
+name: Make changelog
+description: Rebuild readme and changelog entries for the current plugin version
+
+inputs:
+ plugin_file:
+ description: Path to the main plugin file with Version header
+ required: true
+ readme_file:
+ description: Path to readme.txt with Stable tag and Changelog section
+ required: true
+ changelog_file:
+ description: Path to standalone changelog file
+ required: false
+ default: changelog.txt
+ matrix_server:
+ description: Matrix homeserver URL
+ required: false
+ matrix_room:
+ description: Matrix room id
+ required: false
+ matrix_token:
+ description: Matrix access token
+ required: false
+ target_branch:
+ description: Branch to push the rebuilt changelog commit to
+ required: false
+ default: beta
+ auto_commit_message:
+ description: Optional custom commit message
+ required: false
+ base_ref:
+ description: Released branch; commits in head_ref but not here go to the changelog
+ required: false
+ default: origin/master
+ date_format:
+ description: "`date` format for the version header, e.g. '%b %d %Y' or '%d.%m.%Y'"
+ required: false
+ default: "%b %d %Y"
+ commit_prefixes:
+ description: Regex alternation of accepted commit subject prefixes (without the trailing dot)
+ required: false
+ default: "Fix|Upd|New|Code"
+ task_url_prefix:
+ description: Task link prefix; subjects with such a link are accepted regardless of prefix
+ required: false
+ default: "https://app.doboard.com/1/task/"
+
+runs:
+ using: composite
+ steps:
+ - name: Run changelog rebuild
+ shell: bash
+ run: bash "${{ github.action_path }}/entrypoint.sh"
+ env:
+ INPUT_PLUGIN_FILE: ${{ inputs.plugin_file }}
+ INPUT_README_FILE: ${{ inputs.readme_file }}
+ INPUT_CHANGELOG_FILE: ${{ inputs.changelog_file }}
+ INPUT_MATRIX_SERVER: ${{ inputs.matrix_server }}
+ INPUT_MATRIX_ROOM: ${{ inputs.matrix_room }}
+ INPUT_MATRIX_TOKEN: ${{ inputs.matrix_token }}
+ INPUT_TARGET_BRANCH: ${{ inputs.target_branch }}
+ INPUT_AUTO_COMMIT_MESSAGE: ${{ inputs.auto_commit_message }}
+ INPUT_BASE_REF: ${{ inputs.base_ref }}
+ INPUT_DATE_FORMAT: ${{ inputs.date_format }}
+ COMMIT_PREFIXES: ${{ inputs.commit_prefixes }}
+ TASK_URL_PREFIX: ${{ inputs.task_url_prefix }}
diff --git a/.github/actions/make-changelog/changelog_rewrite.py b/.github/actions/make-changelog/changelog_rewrite.py
new file mode 100644
index 000000000..808056f07
--- /dev/null
+++ b/.github/actions/make-changelog/changelog_rewrite.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import re
+from pathlib import Path
+
+# Date part is format-agnostic: 'Sep 25 2026' and '25.09.2026' are both accepted.
+VERSION_HEADER_RE = re.compile(r'^= (\d+(?:\.\d+)+) (\S.*?) =$')
+
+
+def parse_blocks(text: str, keep_preamble_blank: bool) -> list[tuple[str | None, str]]:
+ lines = text.splitlines(keepends=True)
+ blocks: list[tuple[str | None, str]] = []
+ current_header = None
+ current_lines: list[str] = []
+
+ for raw_line in lines:
+ line = raw_line.rstrip('\n')
+ if VERSION_HEADER_RE.match(line):
+ if current_header is not None:
+ blocks.append((current_header, ''.join(current_lines).rstrip('\n')))
+ current_header = line
+ current_lines = [raw_line]
+ else:
+ if current_header is None:
+ if keep_preamble_blank or raw_line.strip():
+ blocks.append((None, raw_line.rstrip('\n')))
+ else:
+ current_lines.append(raw_line)
+
+ if current_header is not None:
+ blocks.append((current_header, ''.join(current_lines).rstrip('\n')))
+
+ return blocks
+
+
+def merge_blocks(existing_text: str, block_text: str, version: str, keep_preamble_blank: bool) -> str:
+ blocks = parse_blocks(existing_text, keep_preamble_blank=keep_preamble_blank)
+ block = block_text.rstrip('\n')
+ block_lines = block.splitlines()
+ new_entries = block_lines[1:]
+
+ result_parts: list[str] = []
+ inserted = False
+
+ for header, body in blocks:
+ if header is None:
+ if body:
+ result_parts.append(body.rstrip('\n'))
+ continue
+
+ match = VERSION_HEADER_RE.match(header)
+ existing_version = match.group(1) if match else None
+
+ if existing_version == version:
+ rebuilt = '\n'.join([header] + new_entries).rstrip('\n')
+ result_parts.append(rebuilt)
+ inserted = True
+ else:
+ if not inserted:
+ result_parts.append(block)
+ inserted = True
+ result_parts.append(body)
+
+ if not inserted:
+ result_parts.append(block)
+
+ return '\n\n'.join(part.rstrip('\n') for part in result_parts if part != '')
+
+
+def rewrite_readme(target: Path, block: str, version: str) -> None:
+ text = target.read_text(encoding='utf-8')
+ text, stable_replacements = re.subn(
+ r'^Stable tag:\s*.*$',
+ f'Stable tag: {version}',
+ text,
+ count=1,
+ flags=re.MULTILINE,
+ )
+ if stable_replacements == 0:
+ raise SystemExit('Stable tag not found for replacement')
+
+ changelog_header = '== Changelog ==\n\n'
+ header_pos = text.find(changelog_header)
+ if header_pos == -1:
+ raise SystemExit('Changelog section not found')
+
+ content_start = header_pos + len(changelog_header)
+ next_section_match = re.search(r'^== [^=].* ==\s*$', text[content_start:], flags=re.MULTILINE)
+ content_end = content_start + next_section_match.start() if next_section_match else len(text)
+
+ before = text[:content_start]
+ changelog_content = text[content_start:content_end]
+ after = text[content_end:]
+
+ new_content = merge_blocks(changelog_content, block, version, keep_preamble_blank=True)
+ if new_content:
+ new_content += '\n\n'
+
+ target.write_text(before + new_content + after.lstrip('\n'), encoding='utf-8')
+
+
+def rewrite_changelog(target: Path, block: str, version: str) -> None:
+ text = target.read_text(encoding='utf-8')
+ new_text = merge_blocks(text, block, version, keep_preamble_blank=False)
+ if new_text:
+ new_text += '\n'
+ target.write_text(new_text, encoding='utf-8')
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--mode', choices=['readme', 'changelog'], required=True)
+ parser.add_argument('--file', required=True)
+ parser.add_argument('--block-file', required=True)
+ parser.add_argument('--version', required=True)
+ args = parser.parse_args()
+
+ target = Path(args.file)
+ block = Path(args.block_file).read_text(encoding='utf-8')
+
+ if args.mode == 'readme':
+ rewrite_readme(target, block, args.version)
+ else:
+ rewrite_changelog(target, block, args.version)
+ return 0
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/.github/actions/make-changelog/entrypoint.sh b/.github/actions/make-changelog/entrypoint.sh
new file mode 100644
index 000000000..7933597a9
--- /dev/null
+++ b/.github/actions/make-changelog/entrypoint.sh
@@ -0,0 +1,350 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+trap 'echo "::error::Command failed on line ${LINENO}: ${BASH_COMMAND}"' ERR
+
+PLUGIN_FILE="${INPUT_PLUGIN_FILE}"
+README_FILE="${INPUT_README_FILE}"
+CHANGELOG_FILE="${INPUT_CHANGELOG_FILE:-changelog.txt}"
+MATRIX_SERVER="${INPUT_MATRIX_SERVER:-}"
+MATRIX_ROOM="${INPUT_MATRIX_ROOM:-}"
+MATRIX_TOKEN="${INPUT_MATRIX_TOKEN:-}"
+TARGET_BRANCH="${INPUT_TARGET_BRANCH:-beta}"
+AUTO_COMMIT_MESSAGE="${INPUT_AUTO_COMMIT_MESSAGE:-}"
+BASE_REF="${INPUT_BASE_REF:-origin/master}"
+DATE_FORMAT="${INPUT_DATE_FORMAT:-%b %d %Y}"
+export COMMIT_PREFIXES="${COMMIT_PREFIXES:-Fix|Upd|New|Code}"
+export TASK_URL_PREFIX="${TASK_URL_PREFIX:-https://app.doboard.com/1/task/}"
+SCRIPT_DIR="${GITHUB_ACTION_PATH:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
+
+TMP_DIR="$(mktemp -d)"
+SUMMARY_FILE="${GITHUB_STEP_SUMMARY:-${TMP_DIR}/summary.md}"
+ENTRIES_FILE="${TMP_DIR}/entries.txt"
+BLOCK_FILE="${TMP_DIR}/block.txt"
+SKIPPED_FILE="${TMP_DIR}/skipped.txt"
+: > "$SKIPPED_FILE"
+MATRIX_LOG_TXT="${TMP_DIR}/matrix.txt"
+MATRIX_LOG_HTML="${TMP_DIR}/matrix.html"
+: > "$MATRIX_LOG_TXT"
+: > "$MATRIX_LOG_HTML"
+trap 'rm -rf "${TMP_DIR}"' EXIT
+
+notice() { echo "::notice::$1"; }
+warn() { echo "::warning::$1"; }
+error_annot() { echo "::error::$1"; }
+append_summary() { printf '%s\n' "$1" >> "$SUMMARY_FILE"; }
+
+escape_html_file() {
+ python3 "$SCRIPT_DIR/utils.py" escape-file "$1"
+}
+
+escape_html_text() {
+ python3 "$SCRIPT_DIR/utils.py" escape-text "$1"
+}
+
+send_matrix() {
+ local body="$1"
+ local formatted="${2:-}"
+ [[ -n "$MATRIX_SERVER" && -n "$MATRIX_ROOM" && -n "$MATRIX_TOKEN" ]] || return 0
+ python3 "$SCRIPT_DIR/matrix_send.py" "$MATRIX_SERVER" "$MATRIX_ROOM" "$MATRIX_TOKEN" "$body" "$formatted"
+}
+
+flush_matrix() {
+ local header="$1"
+ local body_txt formatted
+ body_txt="${header}"$'\n\n'"$(cat "$MATRIX_LOG_TXT")"
+ formatted="${header}
$(cat "$MATRIX_LOG_HTML")
"
+ send_matrix "$body_txt" "$formatted" || true
+}
+
+fail() {
+ local msg="$1"
+ warn "$msg"
+ append_summary "## Warning"
+ append_summary "- $msg"
+ printf 'SKIP: %s\n' "$msg" >> "$MATRIX_LOG_TXT"
+ printf 'SKIP: %s\n' "$(escape_html_text "$msg")" >> "$MATRIX_LOG_HTML"
+ return 1
+}
+
+require_file() {
+ [[ -f "$1" ]] || { fail "Required file not found: $1"; return 1; }
+}
+
+trim() {
+ local s="$1"
+ s="${s#${s%%[![:space:]]*}}"
+ s="${s%${s##*[![:space:]]}}"
+ printf '%s' "$s"
+}
+
+current_date() {
+ date "+${DATE_FORMAT}"
+}
+
+get_plugin_version() {
+ local version
+ version="$(sed -nE 's/^\s*Version:\s*([0-9]+(\.[0-9]+)*)\s*$/\1/p' "$PLUGIN_FILE" | head -n1)"
+ [[ -n "$version" ]] || { fail "Could not read Version from $PLUGIN_FILE"; return 1; }
+ printf '%s' "$version"
+}
+
+get_stable_tag() {
+ local stable
+ stable="$(sed -nE 's/^Stable tag:[[:space:]]*([0-9]+(\.[0-9]+)*)[[:space:]]*$/\1/p' "$README_FILE" | head -n1)"
+ [[ -n "$stable" ]] || { fail "Could not read Stable tag from $README_FILE"; return 1; }
+ printf '%s' "$stable"
+}
+
+find_version_range() {
+ local current_version="$1"
+
+ mapfile -t commits < <(git log --format='%H' -- "$PLUGIN_FILE")
+ [[ ${#commits[@]} -gt 0 ]] || { fail "No git history found for $PLUGIN_FILE"; return 1; }
+
+ local current_commit=""
+ local previous_version=""
+ local previous_commit=""
+ local commit version
+ local seen_current=0
+ local seen_previous=0
+
+ for commit in "${commits[@]}"; do
+ version="$(git show "${commit}:${PLUGIN_FILE}" 2>/dev/null \
+ | sed -nE 's/^[[:space:]]*Version:[[:space:]]*([0-9]+(\.[0-9]+)*)[[:space:]]*$/\1/p' \
+ | head -n1)"
+
+ [[ -n "$version" ]] || continue
+
+ if [[ "$seen_current" -eq 0 && "$version" == "$current_version" ]]; then
+ current_commit="$commit"
+ seen_current=1
+ continue
+ fi
+
+ if [[ "$seen_current" -eq 1 && -z "$previous_version" && "$version" != "$current_version" ]]; then
+ previous_version="$version"
+ previous_commit="$commit"
+ seen_previous=1
+ continue
+ fi
+
+ if [[ "$seen_previous" -eq 1 && "$version" == "$previous_version" ]]; then
+ previous_commit="$commit"
+ continue
+ fi
+
+ if [[ "$seen_previous" -eq 1 && "$version" != "$previous_version" ]]; then
+ break
+ fi
+ done
+
+ [[ -n "$current_commit" ]] || { fail "Could not find commit for current version $current_version"; return 1; }
+ [[ -n "$previous_version" ]] || { fail "Could not determine previous version before $current_version"; return 1; }
+ [[ -n "$previous_commit" ]] || { fail "Could not find commit for previous version $previous_version"; return 1; }
+
+ printf '%s\t%s\t%s\n' "$previous_commit" "$current_commit" "$previous_version"
+}
+
+collect_release_entries() {
+ local previous_commit="$1"
+
+ : > "$ENTRIES_FILE"
+
+ local found=0
+ local sha subject body parsed task kind text line
+
+ mapfile -t shas < <(git log --no-merges --format='%H' "${BASE_REF}..origin/${TARGET_BRANCH}")
+
+ for sha in "${shas[@]}"; do
+ [[ -n "$sha" ]] || continue
+ subject="$(git log -1 --format='%s' "$sha")"
+ body="$(git log -1 --format='%B' "$sha")"
+
+ parsed="$(printf '%s\x1f%s' "$subject" "$body" | python3 "$SCRIPT_DIR/utils.py" parse-message || true)"
+ if [[ -z "$parsed" ]]; then
+ notice "Skipping commit $sha: subject does not match ${COMMIT_PREFIXES} prefixes or has no task link ($subject)"
+ printf '%s\t%s\n' "$sha" "$subject" >> "$SKIPPED_FILE"
+ continue
+ fi
+
+ IFS=$'\t' read -r kind text task <<< "$parsed"
+ text="$(trim "$text")"
+ line="${text}"
+ printf '%s\n' "$line" >> "$ENTRIES_FILE"
+ found=1
+ done
+
+ [[ "$found" -eq 1 ]] || { fail "No valid release commits found between previous version boundary and HEAD"; return 1; }
+}
+
+build_changelog_block() {
+ local version="$1"
+ local when="$2"
+
+ {
+ printf '= %s %s =\n' "$version" "$when"
+ while IFS= read -r entry; do
+ printf '* %s\n' "$entry"
+ done < "$ENTRIES_FILE"
+ } > "$BLOCK_FILE"
+}
+
+rewrite_readme() {
+ local version="$1"
+ python3 "$SCRIPT_DIR/changelog_rewrite.py" --mode readme --file "$README_FILE" --block-file "$BLOCK_FILE" --version "$version"
+}
+
+rewrite_changelog_file() {
+ local version="$1"
+ python3 "$SCRIPT_DIR/changelog_rewrite.py" --mode changelog --file "$CHANGELOG_FILE" --block-file "$BLOCK_FILE" --version "$version"
+}
+
+append_job_summary() {
+ local version="$1"
+ local stable="$2"
+ local previous_commit="$3"
+ local current_commit="$4"
+ local previous_version="$5"
+
+ append_summary "# Make changelog"
+ append_summary ""
+ append_summary "## Validation"
+ append_summary "- Plugin version: ${version}"
+ append_summary "- Stable tag at workflow start: ${stable}"
+ append_summary "- Previous version: ${previous_version}"
+ append_summary "- Previous version commit: ${previous_commit}"
+ append_summary "- Current version commit: ${current_commit}"
+ append_summary "- Changes committed and pushed to ${TARGET_BRANCH}."
+ append_summary "- Current version commit: ${current_commit}"
+ append_summary ""
+ append_summary "## Entries"
+ while IFS= read -r entry; do
+ append_summary "- ${entry}"
+ done < "$ENTRIES_FILE"
+ append_summary ""
+ append_summary "## Changelog block"
+ append_summary '```text'
+ cat "$BLOCK_FILE" >> "$SUMMARY_FILE"
+ append_summary '```'
+ append_summary ""
+
+ if [[ -s "$SKIPPED_FILE" ]]; then
+ append_summary "## Skipped commits"
+ append_summary "Commits in ${BASE_REF}..origin/${TARGET_BRANCH} that did not match ${COMMIT_PREFIXES} prefixes or a ${TASK_URL_PREFIX} link:"
+ append_summary ""
+ append_summary '| SHA | Subject |'
+ append_summary '|-----|---------|'
+ while IFS=$'\t' read -r sha subj; do
+ subj="${subj//|/\\|}"
+ append_summary "| \`${sha:0:12}\` | ${subj} |"
+ done < "$SKIPPED_FILE"
+ append_summary ""
+ fi
+}
+
+send_stage_notice() {
+ local stage="$1"
+ local text="$2"
+ notice "$stage: $text"
+ printf '%s: %s\n' "$stage" "$text" >> "$MATRIX_LOG_TXT"
+ printf '%s: %s\n' "$stage" "$(escape_html_text "$text")" >> "$MATRIX_LOG_HTML"
+}
+
+append_matrix_block() {
+ local title="$1"
+ local file="$2"
+ printf '%s:\n%s\n' "$title" "$(cat "$file")" >> "$MATRIX_LOG_TXT"
+ printf '%s:%s
\n' "$title" "$(escape_html_file "$file")" >> "$MATRIX_LOG_HTML"
+}
+
+commit_and_push() {
+ local version="$1"
+ local message="${AUTO_COMMIT_MESSAGE:-chore(changelog): rebuild readme for v${version} [skip ci]}"
+
+ if git diff --quiet -- "$README_FILE" "$CHANGELOG_FILE"; then
+ warn "No changelog file changes detected after rebuild"
+ append_summary "## Commit"
+ append_summary "- No changes to commit."
+ return 0
+ fi
+
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ git add "$README_FILE" "$CHANGELOG_FILE"
+ git commit -m "$message"
+ git push origin "HEAD:${TARGET_BRANCH}"
+
+ append_summary "## Commit"
+ append_summary "- Changes committed and pushed to ${TARGET_BRANCH}."
+}
+
+run_changelog() {
+ require_file "$PLUGIN_FILE" || return 1
+ require_file "$README_FILE" || return 1
+ require_file "$CHANGELOG_FILE" || return 1
+
+ local version stable previous_commit current_commit previous_version date_now range_info
+
+ version="$(get_plugin_version)" || return 1
+ stable="$(get_stable_tag)" || return 1
+
+ send_stage_notice "Parsing" "Detected plugin version ${version}."
+
+ if [[ "$stable" == "$version" ]]; then
+ notice "Stable tag already matches plugin version ${version}. Continuing."
+ append_summary "## Stable tag"
+ append_summary "- Stable tag at workflow start: ${stable}."
+ append_summary "- Stable tag already matches plugin version."
+ append_summary ""
+ else
+ warn "Stable tag ${stable} does not match plugin version ${version}. It will be updated."
+ append_summary "## Stable tag"
+ append_summary "- Stable tag at workflow start: ${stable}."
+ append_summary "- Plugin version: ${version}."
+ append_summary "- Stable tag will be updated during readme rewrite."
+ append_summary ""
+ fi
+
+ range_info="$(find_version_range "$version")" || return 1
+ IFS=$'\t' read -r previous_commit current_commit previous_version <<< "$range_info"
+
+ send_stage_notice "Validation" "Using range from version ${previous_version} (${previous_commit}) to HEAD for current version ${version}."
+ collect_release_entries "$previous_commit" || return 1
+
+ date_now="$(current_date)"
+ build_changelog_block "$version" "$date_now"
+ send_stage_notice "Changelog" "Built current version block with $(wc -l < "$ENTRIES_FILE" | tr -d ' ') entries."
+
+ rewrite_readme "$version"
+ send_stage_notice "Readme" "Updated Stable tag and current version changelog block in ${README_FILE}."
+
+ rewrite_changelog_file "$version"
+ send_stage_notice "Changelog file" "Updated current version changelog block in ${CHANGELOG_FILE}."
+
+ append_job_summary "$version" "$stable" "$previous_commit" "$current_commit" "$previous_version"
+
+ append_matrix_block "Make changelog entries for v${version}" "$BLOCK_FILE"
+
+ commit_and_push "$version"
+ send_stage_notice "Success" "Changelog rebuilt successfully for v${version}."
+}
+
+main() {
+ notice "Matrix configured: server=${MATRIX_SERVER:+yes}, room=${MATRIX_ROOM:+yes}, token=${MATRIX_TOKEN:+yes}"
+ send_stage_notice "Start" "Workflow started for branch ${TARGET_BRANCH}, main file is ${PLUGIN_FILE}."
+
+ if run_changelog; then
+ flush_matrix "Make changelog completed"
+ exit 0
+ fi
+
+ warn "Changelog build was skipped due to errors (see summary). Continuing without failing the job."
+ append_summary "## Result"
+ append_summary "- Changelog build was skipped due to errors (see warnings above)."
+ append_summary "- The workflow will continue so that the release asset can still be built."
+ send_stage_notice "Skipped" "Changelog build was skipped due to errors. Downstream jobs will continue."
+ flush_matrix "Make changelog skipped"
+ exit 0
+}
+
+main "$@"
diff --git a/.github/actions/make-changelog/matrix_send.py b/.github/actions/make-changelog/matrix_send.py
new file mode 100644
index 000000000..615260faf
--- /dev/null
+++ b/.github/actions/make-changelog/matrix_send.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import json
+import sys
+import urllib.parse
+import urllib.request
+import uuid
+
+
+def main() -> int:
+ if len(sys.argv) != 6:
+ raise SystemExit("usage: matrix_send.py ")
+
+ server, room, token, body, formatted = sys.argv[1:6]
+ if not server or not room or not token:
+ return 0
+
+ server = server.rstrip('/')
+ room_q = urllib.parse.quote(room, safe='')
+ txn = urllib.parse.quote(uuid.uuid4().hex, safe='')
+ url = f"{server}/_matrix/client/v3/rooms/{room_q}/send/m.room.message/{txn}"
+
+ payload = {"msgtype": "m.text", "body": body}
+ if formatted:
+ payload["format"] = "org.matrix.custom.html"
+ payload["formatted_body"] = formatted
+
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(payload).encode(),
+ headers={
+ "Content-Type": "application/json",
+ "Authorization": f"Bearer {token}",
+ },
+ method="PUT",
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=30) as response:
+ response.read()
+ except urllib.error.HTTPError as e:
+ detail = e.read().decode("utf-8", errors="replace")
+ raise SystemExit(f"Matrix send failed with HTTP {e.code}: {detail}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/actions/make-changelog/utils.py b/.github/actions/make-changelog/utils.py
new file mode 100644
index 000000000..1e0386590
--- /dev/null
+++ b/.github/actions/make-changelog/utils.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+from pathlib import Path
+import argparse
+import html
+import os
+import re
+import sys
+
+
+def escape_text(value: str) -> int:
+ print(html.escape(value))
+ return 0
+
+
+def escape_file(path: str) -> int:
+ print(html.escape(Path(path).read_text(encoding="utf-8")))
+ return 0
+
+
+def parse_subject(subject: str) -> int:
+ match = re.match(r"^\{to_release:\s*(\d+)\}\s+(Fix|Upd|New)\.\s*(.+)$", subject)
+ if not match:
+ return 0
+ print("\t".join(match.groups()))
+ return 0
+
+
+def parse_message() -> int:
+ data = sys.stdin.read()
+ subject, _, body = data.partition("\x1f")
+ subject = subject.strip("\r\n")
+
+ prefixes = os.environ.get("COMMIT_PREFIXES") or "Fix|Upd|New|Code"
+ task_url_prefix = os.environ.get("TASK_URL_PREFIX") or "https://app.doboard.com/1/task/"
+
+ subject_match = re.match(rf"^({prefixes})\.\s*(.+?)\s*$", subject)
+ url_match = re.search(re.escape(task_url_prefix) + r"(\d+)", subject)
+
+ if not subject_match and not url_match:
+ return 0
+
+ kind = subject_match.group(1) if subject_match else "Upd"
+ text = subject.strip()
+ task = url_match.group(1) if url_match else ""
+
+ print("\t".join([kind, text, task]))
+ return 0
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ parser_escape_text = subparsers.add_parser("escape-text")
+ parser_escape_text.add_argument("value")
+
+ parser_escape_file = subparsers.add_parser("escape-file")
+ parser_escape_file.add_argument("path")
+
+ parser_parse_subject = subparsers.add_parser("parse-subject")
+ parser_parse_subject.add_argument("subject")
+
+ subparsers.add_parser("parse-message")
+
+ args = parser.parse_args()
+
+ if args.command == "escape-text":
+ return escape_text(args.value)
+ if args.command == "escape-file":
+ return escape_file(args.path)
+ if args.command == "parse-subject":
+ return parse_subject(args.subject)
+ if args.command == "parse-message":
+ return parse_message()
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/.github/workflows/deploy-wordpress-svn.yml b/.github/workflows/deploy-wordpress-svn.yml
new file mode 100644
index 000000000..a2588234e
--- /dev/null
+++ b/.github/workflows/deploy-wordpress-svn.yml
@@ -0,0 +1,70 @@
+name: Publish to WordPress SVN
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Final release version for SVN tag, for example 6.90"
+ required: true
+ dry_run:
+ description: "Run in dry-run mode"
+ required: true
+ default: "true"
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+env:
+ PLUGIN_SLUG: cleantalk-spam-protect
+ RELEASE_VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.version }}
+
+jobs:
+ svn-release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout master
+ uses: actions/checkout@v4
+ with:
+ ref: master
+ fetch-depth: 0
+
+ - name: Publish to SVN from master
+ uses: ./.github/actions/deploy-wordpress-svn
+ with:
+ version: ${{ env.RELEASE_VERSION }}
+ plugin_slug: ${{ env.PLUGIN_SLUG }}
+ main_file: cleantalk.php
+ ignore_file: .7zignore
+ dry_run: ${{ github.event_name == 'release' && 'false' || inputs.dry_run }}
+ svn_username: ${{ secrets.SVN_USERNAME }}
+ svn_password: ${{ secrets.SVN_PASSWORD }}
+ svn_url: ${{ secrets.SVN_URL }}
+ matrix_server: ${{ secrets.MATRIX_SERVER }}
+ matrix_room: ${{ secrets.MATRIX_GITHUB_WORKFLOW_FEED }}
+ matrix_token: ${{ secrets.MATRIX_USER_TOKEN }}
+
+ psc-task:
+ needs: svn-release
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout master
+ uses: actions/checkout@v4
+ with:
+ ref: master
+
+ - name: Create PSC task
+ uses: ./.github/actions/create-psc-task
+ with:
+ version: ${{ env.RELEASE_VERSION }}
+ plugin_slug: ${{ env.PLUGIN_SLUG }}
+ create_task: ${{ (github.event_name == 'release' || inputs.dry_run == 'false') && 'true' || 'false' }}
+ role_name: Pentesting on Duty
+ doboard_username: ${{ secrets.DOBOARD_USERNAME }}
+ doboard_password: ${{ secrets.DOBOARD_PASSWORD }}
+ doboard_company_id: "1"
+ doboard_project_id: "78"
+ doboard_track_id: "1537"
diff --git a/.github/workflows/dev-fix-zip.yml b/.github/workflows/dev-fix-zip.yml
index bd89ae2b8..4e8826606 100644
--- a/.github/workflows/dev-fix-zip.yml
+++ b/.github/workflows/dev-fix-zip.yml
@@ -2,7 +2,7 @@ name: Auto Release Zip on dev/fix push (Linux)
on:
push:
- branches: [dev, fix, beta]
+ branches: [dev, fix]
permissions:
contents: write
diff --git a/.github/workflows/make_asset_beta_and_changelog.yml b/.github/workflows/make_asset_beta_and_changelog.yml
new file mode 100644
index 000000000..5a0363a30
--- /dev/null
+++ b/.github/workflows/make_asset_beta_and_changelog.yml
@@ -0,0 +1,118 @@
+name: Beta changelog and release zip
+
+on:
+ push:
+ branches:
+ - beta
+
+permissions:
+ contents: write
+
+env:
+ PLUGIN_NAME: cleantalk-spam-protect
+
+jobs:
+ make-changelog:
+ if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ ref: beta
+ fetch-depth: 0
+
+ - name: Build changelog files
+ uses: ./.github/actions/make-changelog
+ with:
+ plugin_file: cleantalk.php
+ readme_file: readme.txt
+ changelog_file: changelog.txt
+ target_branch: beta
+ base_ref: origin/master
+ date_format: "%d.%m.%Y"
+ commit_prefixes: "Fix|Upd|New|Code"
+ task_url_prefix: "https://app.doboard.com/1/task/"
+ matrix_server: ${{ secrets.MATRIX_SERVER }}
+ matrix_room: ${{ secrets.MATRIX_GITHUB_WORKFLOW_FEED }}
+ matrix_token: ${{ secrets.MATRIX_USER_TOKEN }}
+
+ build-release-zip:
+ needs: make-changelog
+ if: ${{ !cancelled() && !contains(github.event.head_commit.message, '[skip ci]') }}
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: beta
+ fetch-depth: 0
+
+ - name: Install dependencies
+ run: sudo apt-get update && sudo apt-get install -y p7zip-full rsync
+
+ - name: Check if .7zignore exists
+ id: check_ignore
+ run: |
+ if [ -f ".7zignore" ]; then
+ echo "Using existing .7zignore file"
+ echo "ignore_exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "::warning::.7zignore file not found, creating default one"
+ echo "ignore_exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Create default .7zignore if missing
+ if: steps.check_ignore.outputs.ignore_exists == 'false'
+ run: |
+ cat > .7zignore << 'EOF'
+ node_modules/
+ vendor/
+ js/prebuild/
+ js/src/
+ css/src/
+ tests/
+ package.json
+ package-lock.json
+ gulpfile.js
+ README.md
+ LICENSE
+ psalm.xml
+ composer.json
+ composer.lock
+ .*
+ EOF
+
+ - name: Prepare plugin directory
+ run: |
+ mkdir "${PLUGIN_NAME}"
+ rsync -a ./ "${PLUGIN_NAME}/" \
+ --exclude-from=.7zignore \
+ --exclude="${PLUGIN_NAME}"
+
+ - name: Create ZIP archive
+ run: 7z a -tzip "${PLUGIN_NAME}.zip" "${PLUGIN_NAME}"
+
+ - name: Create or update GitHub Release and upload asset
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}-version
+ name: ${{ github.ref_name }} version
+ body: "Prebuilt version from ${{ github.ref_name }} branch"
+ draft: false
+ prerelease: true
+ files: |
+ ${{ env.PLUGIN_NAME }}.zip
+ overwrite_files: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Clean up
+ run: |
+ rm -rf "${PLUGIN_NAME}"
+ rm -f "${PLUGIN_NAME}.zip"
+ if [ "${{ steps.check_ignore.outputs.ignore_exists }}" = "false" ]; then
+ rm -f .7zignore
+ fi
diff --git a/changelog.txt b/changelog.txt
index be005c047..866aba6d4 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,14 @@
== Changelog ==
+= 6.90 13.10.2026 =
+* Fix. Comments checker. Feedback success message fixed. (#907)
+* Fix. Force protection. Checking bot detector fixed.
+* Fix. Code. Parsing the response and text output
+* Fix. Code. Replacing the first occurrence of the search only in the element text
+* Fix. ContactEncoder. Edit to the tag attribute definition
+* New. Integrations. BuddyNext plugin registration.
+* Upd. Contacts encoder. Short codes functionality optimized. (#900)
+* Fix. ContactEncoder. Default value for method
= 6.78 29.04.2026 =
* Removing the link to install "Gravity Forms to doBoard" (#784)
@@ -238,7 +247,6 @@ Fix. Exclusion. Newsletter Automation. Skip test newsletter check.
Fix. Integration. btQuoteBooking form params gathering fixed.
Fix. Integration. WP Mailer Lite. Fixed exclusion.
-
= 6.66 16.10.2025 =
Fix. Remote calls. Debug RC now hide sensitive data.
Fix. TRP. Show TRP for admins and editors.
@@ -4305,6 +4313,7 @@ Fix. Bot detector. Including bot detector wrapper script fixed.
* New API key URL.
* Trimmed API key in admin panel.
* Added current options to array sended to CleanTalk servers.
+
= 4.13 2014-12-29 =
* Fixed bug with autimatically aprovement not spam comments. Now this option disabled and do not override local WordPress policy.
@@ -4610,6 +4619,7 @@ Fix. Bot detector. Including bot detector wrapper script fixed.
== Upgrade Notice ==
+
= 5.130 November 14 2019 =
* Fix: JetPack contact form JS check.
* FIx: Iphorm AJAX form.
@@ -4755,7 +4765,6 @@ Fix. Bot detector. Including bot detector wrapper script fixed.
* Mod: Using minified version of JS and CSS.
* Mod: Date format in statistics.
-
= 5.119.1 May 30 2019 =
* Fix: Helper class error.
diff --git a/cleantalk.php b/cleantalk.php
index f2584a7d7..256c45410 100644
--- a/cleantalk.php
+++ b/cleantalk.php
@@ -4,7 +4,7 @@
Plugin Name: Anti-Spam by CleanTalk
Plugin URI: https://cleantalk.org
Description: Max power, all-in-one, no Captcha, premium anti-spam plugin. No comment spam, no registration spam, no contact spam, protects any WordPress forms.
- Version: 6.89.1
+ Version: 6.90
Author: CleanTalk - Anti-Spam Protection
Author URI: https://cleantalk.org
Text Domain: cleantalk-spam-protect
diff --git a/inc/cleantalk-admin.php b/inc/cleantalk-admin.php
index ad98a6cbb..a1a9636c5 100644
--- a/inc/cleantalk-admin.php
+++ b/inc/cleantalk-admin.php
@@ -644,25 +644,15 @@ function apbct_admin__enqueue_scripts($hook)
\Cleantalk\ApbctWP\CleantalkRealPerson::getLocalizingData()
);
ApbctEnqueue::getInstance()->js('cleantalk-comments-editscreen.js', array('cleantalk-admin-js'));
- $link = LinkConstructor::buildCleanTalkLink(
- 'public_comments_page_go_to_cp',
- 'my',
- array(
- 'user_token' => $apbct->user_token,
- 'cp_mode' => 'antispam'
- )
- );
wp_localize_script('cleantalk-comments-editscreen-js', 'ctCommentsScreen', array(
'ct_ajax_nonce' => $apbct->ajax_service->getAdminNonce(),
'spambutton_text' => __("Find spam comments", 'cleantalk-spam-protect'),
'ct_feedback_msg_whitelisted' => __("The sender has been whitelisted.", 'cleantalk-spam-protect'),
'ct_feedback_msg_blacklisted' => __("The sender has been blacklisted.", 'cleantalk-spam-protect'),
- 'ct_feedback_msg' => sprintf(
- __("Feedback has been sent to %sCleanTalk Dashboard%s.", 'cleantalk-spam-protect'),
- $apbct->user_token ? "" : '',
- $apbct->user_token ? "" : ''
- ) . ' ' . esc_html__('The service accepts feedback only for requests made no more than 7 or 45 days
- (if the Extra package is activated) ago.', 'cleantalk-spam-protect'),
+ 'ct_feedback_msg' =>
+ __("Feedback has been sent to CleanTalk Dashboard.", 'cleantalk-spam-protect')
+ . ' '
+ . esc_html__('The service accepts feedback only for requests made no more than 7 or 45 days (if the Extra package is activated) ago.', 'cleantalk-spam-protect'),
'ct_show_check_links' => (bool)$apbct->settings['comments__show_check_links'],
'ct_img_src_new_tab' => plugin_dir_url(__FILE__) . "images/new_window.gif",
));
diff --git a/inc/cleantalk-integrations-by-route.php b/inc/cleantalk-integrations-by-route.php
index 892cd97d8..13a82715d 100644
--- a/inc/cleantalk-integrations-by-route.php
+++ b/inc/cleantalk-integrations-by-route.php
@@ -21,6 +21,11 @@
'setting' => 'forms__registrations_test',
'rest' => true,
),
+ 'BuddyNextRegistration' => array(
+ 'rest_route' => '/buddynext/v1/auth/register',
+ 'setting' => 'forms__registrations_test',
+ 'rest' => true,
+ ),
);
add_filter('rest_pre_dispatch', function ($result, $_, $request) use ($apbct_active_rest_integrations) {
diff --git a/inc/cleantalk-public-integrations.php b/inc/cleantalk-public-integrations.php
index ace1bd880..3368fc2ba 100644
--- a/inc/cleantalk-public-integrations.php
+++ b/inc/cleantalk-public-integrations.php
@@ -459,7 +459,11 @@ function apbct_form__formidable__testSpam($errors, $_form)
'error_message' => '' . $ct_result->comment . '
',
);
- echo json_encode($result, JSON_FORCE_OBJECT);
+ $encoded = wp_json_encode(
+ $result,
+ JSON_FORCE_OBJECT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
+ );
+ echo is_string($encoded) ? $encoded : '{"errors":{"113":"Forbidden"},"content":"","pass":false,"error_message":""}';
die();
}
@@ -475,22 +479,55 @@ function apbct_form__formidable__testSpam($errors, $_form)
*/
function apbct__formidable_get_key_field_for_ajax_response($_form = array())
{
- $key_field = '113';
+ $key_field = '';
$item_meta = Post::get('item_meta');
- if (is_array($item_meta) && count($item_meta) > 1) {
- $keys = array_keys($item_meta);
- $key_field = isset($keys[1]) ? $keys[1] : '113';
- } elseif (is_array($_form) && isset($_form['item_meta'])) {
- foreach ($_form['item_meta'] as $key => $value) {
- if ($value) {
- $key_field = $key;
- break;
- }
+ if ( is_array($item_meta) && count($item_meta) > 1 ) {
+ $key_field = apbct__formidable_pick_field_id($item_meta);
+ } elseif ( is_array($_form) && isset($_form['item_meta']) ) {
+ $key_field = apbct__formidable_pick_field_id($_form['item_meta'], true);
+ }
+
+ return $key_field !== '' ? $key_field : '113';
+}
+
+/**
+ * @param mixed $item_meta
+ * @param bool $require_value
+ *
+ * @return string
+ */
+function apbct__formidable_pick_field_id($item_meta, $require_value = false)
+{
+ if ( ! is_array($item_meta) || ! count($item_meta) ) {
+ return '';
+ }
+
+ $keys = array_keys($item_meta);
+ if ( ! $require_value && isset($keys[1]) && apbct__formidable_is_field_id($keys[1]) ) {
+ return (string) $keys[1];
+ }
+
+ foreach ( $item_meta as $key => $value ) {
+ if ( $require_value && ! $value ) {
+ continue;
+ }
+ if ( apbct__formidable_is_field_id($key) ) {
+ return (string) $key;
}
}
- return $key_field;
+ return '';
+}
+
+/**
+ * @param mixed $key
+ *
+ * @return bool
+ */
+function apbct__formidable_is_field_id($key)
+{
+ return is_scalar($key) && preg_match('/^[0-9]+$/', (string) $key) === 1;
}
/**
@@ -878,10 +915,6 @@ function ct_registration_errors($errors, $sanitized_user_login = null, $user_ema
if ( Post::get('wpmem_reg_page') && apbct_is_plugin_active('wp-members/wp-members.php') ) {
return $errors;
}
- // BuddyBoss login form
- if ( Post::getString('wp-submit') && Post::getString('log') && Post::getString('pwd') ) {
- return $errors;
- }
$facebook = false;
// Facebook registration
diff --git a/inc/cleantalk-public.php b/inc/cleantalk-public.php
index d141014c2..5d2e9c8c1 100644
--- a/inc/cleantalk-public.php
+++ b/inc/cleantalk-public.php
@@ -409,9 +409,28 @@ function apbct_sgo_bypass_query_params($bypass_query_params)
return $bypass_query_params;
}
+/**
+ * Nesting level of the buffer opened by apbct_buffer__start().
+ *
+ * @param int|null $set
+ *
+ * @return int
+ */
+function apbct_buffer__own_level($set = null)
+{
+ static $level = 0;
+
+ if ( $set !== null ) {
+ $level = (int)$set;
+ }
+
+ return $level;
+}
+
function apbct_buffer__start()
{
ob_start();
+ apbct_buffer__own_level(ob_get_level());
}
function apbct_buffer__end()
@@ -421,6 +440,19 @@ function apbct_buffer__end()
}
global $apbct;
+
+ // Buffers opened after ours (WP late-printed styles hoisting, other plugins) must be flushed,
+ // not discarded — otherwise their replacements never reach the output.
+ $own_level = apbct_buffer__own_level();
+ if ( $own_level > 0 ) {
+ while ( ob_get_level() > $own_level ) {
+ $previous_level = ob_get_level();
+ if ( ! @ob_end_flush() || ob_get_level() >= $previous_level ) {
+ break;
+ }
+ }
+ }
+
$apbct->buffer = ob_get_contents();
ob_end_clean();
}
@@ -775,10 +807,6 @@ function apbct_comment__Wordpress__changeMailNotification($notify_message, $_com
__('Activate protection in your Anti-Spam Dashboard: %s.', 'clentalk'),
//HANDLE LINK
'https://cleantalk.org/my/?cp_mode=antispam&utm_source=newsletter&utm_medium=email&utm_campaign=wp_spam_comment_passed'
- . ($apbct->data['user_token']
- ? '&iser_token=' . $apbct->data['user_token']
- : ''
- )
)
. PHP_EOL . '---'
. PHP_EOL
diff --git a/js/apbct-public-bundle.min.js b/js/apbct-public-bundle.min.js
index 56d47fb11..03207ac7e 100644
--- a/js/apbct-public-bundle.min.js
+++ b/js/apbct-public-bundle.min.js
@@ -1 +1 @@
-function ownKeys(t,e){var n,o=Object.keys(t);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(t),e&&(n=n.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),o.push.apply(o,n)),o}function _objectSpread(t){for(var e=1;et||r=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:t};throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(e,t){var n;if(e)return"string"==typeof e?_arrayLikeToArray(e,t):"Map"===(n="Object"===(n={}.toString.call(e).slice(8,-1))&&e.constructor?e.constructor.name:n)||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,o=Array(t);n{var n,e;if(i.data.forEach(function(e){e.encoded_email===r[t].dataset.originalString&&(n=e)}),!1===n.is_allowed)return{v:void 0};if(void 0===r[t].href||0!==r[t].href.indexOf("mailto:")&&0!==r[t].href.indexOf("tel:"))r[t].classList.add("no-blur"),setTimeout(function(){ctProcessDecodedDataResult(n,r[t])},2e3);else{if(0===r[t].href.indexOf("mailto:"))e="mailto:";else{if(0!==r[t].href.indexOf("tel:"))return 0;e="tel:"}var o=r[t].href.replace(e,""),a=r[t].innerHTML;r[t].innerHTML=a.replace(o,n.decoded_email),r[t].href=e+n.decoded_email,r[t].querySelectorAll("span.apbct-email-encoder").forEach(function(t){var n="";i.data.forEach(function(e){e.encoded_email===t.dataset.originalString&&(n=e.decoded_email)}),t.innerHTML=n})}r[t].removeEventListener("click",ctFillDecodedEmailHandler)})(t))&&e)return e.v}else{var n=i.data[0];r.classList.add("no-blur"),setTimeout(function(){ctProcessDecodedDataResult(n,r)},2e3),r.removeEventListener("click",ctFillDecodedEmailHandler)}}function ctProcessDecodedDataResult(e,t){t.setAttribute("title",""),t.removeAttribute("style"),ctFillDecodedEmail(t,e.decoded_email)}function ctFillDecodedEmail(e,t){e.innerHTML=e.innerHTML.replace(/.+?(