Compare commits
18
Commits
+19
-2
@@ -1,10 +1,10 @@
|
||||
=== WooCommerce Email Discount Manager ===
|
||||
Contributors: wedsmp
|
||||
Contributors: kristianwind
|
||||
Tags: woocommerce, discount, email, shipping, coupon
|
||||
Requires at least: 5.8
|
||||
Tested up to: 7.1
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 1.4.1
|
||||
Stable tag: 1.4.6
|
||||
WC requires at least: 6.0
|
||||
WC tested up to: 11.1.0
|
||||
License: GPLv2 or later
|
||||
@@ -96,6 +96,23 @@ The most specific match wins: exact email, then domain, then partial match.
|
||||
|
||||
== Changelog ==
|
||||
|
||||
= 1.4.6 =
|
||||
* Housekeeping: the plugin's source moved to a shorter address. Updates continue to arrive as before, and nothing needs doing.
|
||||
|
||||
= 1.4.5 =
|
||||
* Fixed: the plugin could offer its own release as an update to a different plugin. Plugins that update from the same place share one check inside WordPress, and this one answered for all of them instead of only for itself. If another such plugin is installed, update it as well.
|
||||
* "Vis detaljer" next to the update notice now opens the plugin's own details, with the release notes as the changelog. It previously showed "Plugin not found", because WordPress was asking wordpress.org about a plugin that is not published there.
|
||||
|
||||
= 1.4.4 =
|
||||
* A new version now shows up within about fifteen minutes instead of being hidden behind a six-hour cache. The plugin was holding on to its own answer for longer than WordPress waits before asking again, so a release could be published and the shop shown nothing.
|
||||
|
||||
= 1.4.3 =
|
||||
* The plugin is now credited to Kristian Wind, with a link to where its source lives. It previously carried a placeholder name.
|
||||
|
||||
= 1.4.2 =
|
||||
* The plugin now has an icon, so it no longer shows up as a generic grey plug under Dashboard → Updates.
|
||||
* "Check again" on the updates screen really does check again. It previously reused a cached answer for up to six hours, which made a brand new version look like it had not been published.
|
||||
|
||||
= 1.4.1 =
|
||||
* The update check now builds the download address itself instead of using the one the repository reports. The repository was reporting a plain-HTTP address on a raw IP, which would have meant downloading plugin code over an unencrypted connection to an address that is free to change. Anyone on 1.4.0 gets this automatically with the next update.
|
||||
|
||||
|
||||
+8
-1
@@ -18,7 +18,14 @@ while IFS= read -r line; do
|
||||
[ -n "$line" ] && patterns+=("${line%/}")
|
||||
done < .distignore
|
||||
|
||||
git ls-files | while IFS= read -r file; do
|
||||
# -z, not plain `git ls-files`: git QUOTES any path containing a non-ASCII
|
||||
# byte, so "TESTMILJØ.md" comes out as the literal 14-character string
|
||||
# "TESTMILJ\303\230.md". That form matches no pattern in .distignore, so the
|
||||
# file is kept — an internal document walking straight into the release zip
|
||||
# with nothing said. (-z also covers paths with spaces.) Found in a sibling
|
||||
# plugin that has exactly such a filename; this repo has none today, which is
|
||||
# precisely why it would have sat here until someone added one.
|
||||
git ls-files -z | while IFS= read -r -d '' file; do
|
||||
keep=1
|
||||
for p in "${patterns[@]}"; do
|
||||
# A bare name excludes the file or directory wherever it appears, which is
|
||||
|
||||
@@ -46,33 +46,51 @@ ci_plugin_header() {
|
||||
"$(ci_repo_root)/$PLUGIN_MAIN" | head -1 | sed -E 's/[[:space:]]+$//'
|
||||
}
|
||||
|
||||
# Run a shell script against a checkout of this repository, inside a container.
|
||||
#
|
||||
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
||||
# Gitea Actions job runs inside a container talking to the HOST's docker
|
||||
# daemon, so a host path here lands on an empty directory nobody created and
|
||||
# the check passes by checking nothing. The symptom reads like a typo in a path.
|
||||
#
|
||||
# Two details are load-bearing. The script is copied in as a FILE rather than
|
||||
# interpolated into `sh -c`, so it can contain $, quotes and newlines without
|
||||
# being mangled — which matters the moment it is more than one command. And
|
||||
# --entrypoint sh is not decoration: some images (composer:2, for one) have an
|
||||
# entrypoint that swallows its arguments.
|
||||
#
|
||||
# Taken from the subscriptions port, which needed it for composer.
|
||||
ci_in_container() { # image script-text
|
||||
local image="$1" script="$2"
|
||||
local dir cid code=0
|
||||
|
||||
dir=$(mktemp -d -t wpci-XXXXXX)
|
||||
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$dir/src.tar" )
|
||||
printf '%s\n' "$script" > "$dir/run.sh"
|
||||
|
||||
cid=$(docker create --entrypoint sh -w /src "$image" \
|
||||
-c 'mkdir -p /src && tar -xf /tmp/src.tar -C /src && exec sh /tmp/run.sh')
|
||||
docker cp "$dir/src.tar" "$cid:/tmp/src.tar" >/dev/null
|
||||
docker cp "$dir/run.sh" "$cid:/tmp/run.sh" >/dev/null
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
rm -rf "$dir"
|
||||
return "$code"
|
||||
}
|
||||
|
||||
# Run one of this repository's PHP scripts.
|
||||
#
|
||||
# Uses the local php when there is one — a laptop, GitHub's hosted runners —
|
||||
# and a container otherwise. The Gitea runner image has no php, and losing a
|
||||
# build to "php: command not found" after everything else passed is a silly way
|
||||
# to spend an afternoon. Measured: it happened on the very first Gitea run.
|
||||
#
|
||||
# The repository goes in over a tarball and `docker cp`, never a bind mount: a
|
||||
# Gitea Actions job runs inside a container talking to the HOST's docker
|
||||
# daemon, so a host path here lands on an empty directory nobody created.
|
||||
ci_php() {
|
||||
if command -v php >/dev/null 2>&1; then
|
||||
php "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
local tarball cid code=0
|
||||
tarball=$(mktemp -t wpci-php-XXXXXX.tar)
|
||||
( cd "$(ci_repo_root)" && git ls-files -z | tar --null -T - -cf "$tarball" )
|
||||
|
||||
cid=$(docker create "php:8.4-cli" sh -c \
|
||||
"mkdir -p /src && tar -xf /tmp/src.tar -C /src && cd /src && exec php $(printf '%q ' "$@")")
|
||||
docker cp "$tarball" "$cid:/tmp/src.tar" >/dev/null
|
||||
docker start -a "$cid" || code=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
rm -f "$tarball"
|
||||
return "$code"
|
||||
ci_in_container "php:8.4-cli" "exec php $(printf '%q ' "$@")"
|
||||
}
|
||||
|
||||
ci_step() { printf '\n\033[1m→ %s\033[0m\n' "$*"; }
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
# Directory name the plugin gets inside wp-content/plugins, and the name of the
|
||||
# built zip. WordPress identifies a plugin by "<slug>/<main file>", so changing
|
||||
# this after release orphans every installed copy.
|
||||
#
|
||||
# 🔴 THIS DELIBERATELY DOES NOT MATCH THE REPOSITORY NAME, and it must not be
|
||||
# "tidied up" to. The repo is woo-discount-manager; the installed directory has
|
||||
# been woocommerce-email-discount-manager since the first release and is on a
|
||||
# live shop. Renaming it would not move that installation — it would put a
|
||||
# SECOND copy beside it and leave the old one active. The repo name is free to
|
||||
# change (Gitea keeps a 301, measured), the installed slug never is.
|
||||
PLUGIN_SLUG=woocommerce-email-discount-manager
|
||||
|
||||
# The file carrying the plugin header. Version: in here is the version.
|
||||
|
||||
+58
-7
@@ -65,6 +65,35 @@ tested_wc=$(ci_plugin_header 'WC tested up to')
|
||||
body=$(printf '%s\n\n---\n\nTested up to: %s\nWC tested up to: %s\n' \
|
||||
"$notes" "${tested_wp:-unknown}" "${tested_wc:-unknown}")
|
||||
|
||||
# Read one top-level field out of a JSON response.
|
||||
#
|
||||
# Not sed. A greedy `.*"id":([0-9]+)` matches the LAST id in the payload, and a
|
||||
# Gitea release carries both an embedded "author" object and, once anything is
|
||||
# attached, an "assets" array — each with an id of its own. Measured on this
|
||||
# repo: the four published releases have ids 416/417/419/421, and the greedy
|
||||
# pattern returns 1/2/3/4, which are the asset ids.
|
||||
#
|
||||
# Those four releases were nonetheless published correctly, and only by luck:
|
||||
# the author id on this instance is **-1**, and the minus sign stops `[0-9]+`
|
||||
# from matching, so on a create response — which has no assets yet — the last
|
||||
# numeric id happened to be the release's. A real author id, or a re-run
|
||||
# against a release that already has its zip, and it picks the wrong number.
|
||||
#
|
||||
# Found by the session porting this into subscriptions, where it picked the
|
||||
# author id and the upload 404'd. python3 is already a required tool here.
|
||||
json_field() { # field (JSON on stdin)
|
||||
python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load( sys.stdin )
|
||||
except Exception:
|
||||
sys.exit( 0 )
|
||||
value = data.get( sys.argv[1] )
|
||||
if value is not None:
|
||||
print( value )
|
||||
' "$1"
|
||||
}
|
||||
|
||||
api() { # method path [data]
|
||||
curl -fsS -X "$1" \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
@@ -94,20 +123,42 @@ print(json.dumps({
|
||||
# A re-run of a release must not fail on "already exists".
|
||||
existing=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/tags/$TAG" 2>/dev/null || true)
|
||||
id=$(printf '%s' "$existing" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
id=$(printf '%s' "$existing" | json_field id)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
created=$(api POST "$GITHUB_API_URL/repos/$owner/$repo/releases" "$payload")
|
||||
id=$(printf '%s' "$created" | sed -n -E 's/.*"id":[[:space:]]*([0-9]+).*/\1/p' | head -1)
|
||||
id=$(printf '%s' "$created" | json_field id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "Could not create or find the release for $TAG." >&2; exit 1; }
|
||||
echo " release id $id"
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-F "attachment=@$zip" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
||||
echo " attached $PLUGIN_SLUG.zip"
|
||||
# Before uploading anything to it, confirm the id really is this tag's. The
|
||||
# failure being guarded against is not a 404 — that is loud — but an id that
|
||||
# exists and belongs to something else, which would attach this plugin's zip
|
||||
# to an unrelated release with every check still green.
|
||||
release=$(curl -fsS -H "Authorization: token $GH_TOKEN" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id")
|
||||
actual=$(printf '%s' "$release" | json_field tag_name)
|
||||
if [ "$actual" != "$TAG" ]; then
|
||||
echo "Release id $id is tagged '$actual', not '$TAG'. Refusing to upload the zip to it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A re-run must not fail on the half that already succeeded: the upload
|
||||
# rejects a duplicate asset name.
|
||||
if printf '%s' "$release" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load( sys.stdin )
|
||||
sys.exit( 0 if any( a.get( "name" ) == sys.argv[1] for a in data.get( "assets", [] ) ) else 1 )
|
||||
' "$PLUGIN_SLUG.zip"; then
|
||||
echo " $PLUGIN_SLUG.zip is already attached — leaving it alone"
|
||||
else
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-F "attachment=@$zip" \
|
||||
"$GITHUB_API_URL/repos/$owner/$repo/releases/$id/assets?name=$PLUGIN_SLUG.zip" >/dev/null
|
||||
echo " attached $PLUGIN_SLUG.zip"
|
||||
fi
|
||||
|
||||
release_api="$GITHUB_API_URL/repos/$owner/$repo/releases/latest"
|
||||
fi
|
||||
|
||||
+45
-2
@@ -76,11 +76,13 @@ before=$fails
|
||||
if git ls-files -z '*.php' | xargs -0 grep -nP '<\?(?!php|=|xml)' ; then
|
||||
note_fail "Use <?php, not short open tags."
|
||||
fi
|
||||
while read -r f; do
|
||||
# -z: git quotes non-ASCII paths, and the quoted form is not a path that
|
||||
# exists, so this loop would silently skip the file it was meant to check.
|
||||
while IFS= read -r -d '' f; do
|
||||
[ -n "$f" ] || continue
|
||||
[ "$(tail -c 3 "$f" | tr -d '[:space:]')" = "?>" ] \
|
||||
&& { ci_annotate error "$f" 1 "Remove the trailing ?> at the end of the file."; fails=$((fails + 1)); }
|
||||
done <<< "$(git ls-files '*.php')"
|
||||
done < <(git ls-files -z '*.php')
|
||||
[ "$fails" = "$before" ] && echo " clean"
|
||||
|
||||
ci_step "text domain"
|
||||
@@ -141,6 +143,47 @@ for readme in readme.txt README.txt; do
|
||||
|| note_fail "$readme has 'Stable tag: $stable' but the plugin header says $version."
|
||||
done
|
||||
|
||||
ci_step "no secrets in tracked files"
|
||||
# Suggested by the AI-manager port, and it costs nothing: a pushed key cannot be
|
||||
# unpushed. It has to be rotated, and the window between the push and noticing
|
||||
# is the whole problem.
|
||||
#
|
||||
# Deliberately narrow. Broad entropy heuristics fire on minified assets, hashes
|
||||
# and base64 images, and a check that cries wolf gets commented out — which is
|
||||
# worse than not having it. These are prefixes that mean one thing only.
|
||||
before=$fails
|
||||
while IFS= read -r -d '' f; do
|
||||
[ -f "$f" ] || continue
|
||||
if LC_ALL=C grep -nE '(sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{50,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)' "$f"; then
|
||||
note_fail "$f looks like it contains a credential. It cannot be unpushed — rotate it."
|
||||
fi
|
||||
done < <(git ls-files -z)
|
||||
[ "$fails" = "$before" ] && echo " nothing that looks like a key or a private key"
|
||||
|
||||
ci_step "no placeholders left in the plugin header"
|
||||
# The header is the plugin's business card: wp-admin shows the author and the
|
||||
# links to whoever installs it, so a placeholder there survives for as long as
|
||||
# nobody happens to look at that screen. This plugin shipped with
|
||||
# "https://example.com/" as its Plugin URI for several releases.
|
||||
#
|
||||
# It catches the generic markers only. A made-up vendor name — this one went
|
||||
# out as "WEDSMP" — looks exactly like a real one to any check, so an empty
|
||||
# field is warned about but a wrong-but-plausible value cannot be.
|
||||
before=$fails
|
||||
for field in 'Plugin Name' 'Description' 'Author' 'Author URI' 'Plugin URI'; do
|
||||
value=$(ci_plugin_header "$field")
|
||||
if [ -z "$value" ]; then
|
||||
echo "::warning::The plugin header has no '$field:'."
|
||||
continue
|
||||
fi
|
||||
case "$value" in
|
||||
*example.com*|*example.org*|*yourname*|*YOUR_*|*TODO*|*FIXME*|*changeme*|*'<'*'>'*)
|
||||
note_fail "$field is still a placeholder: $value"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ "$fails" = "$before" ] && echo " name, description, author and links are all filled in"
|
||||
|
||||
ci_step "the compatibility headers still tell the truth"
|
||||
# Advisory, never fatal. WordPress ships a new version whether or not anything
|
||||
# here is wrong, and a build that goes red on release day for a header nobody
|
||||
|
||||
+31
-5
@@ -92,13 +92,39 @@ echo " status: $status"
|
||||
[ "$now" = "$expected" ] || { echo "Expected $expected after the update." >&2; exit 1; }
|
||||
[ "$status" = active ] || { echo "The plugin is no longer active after updating itself." >&2; exit 1; }
|
||||
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy
|
||||
# beside the first instead of replacing it, and the old one stays active.
|
||||
count=$(docker exec "$id-cli" sh -c 'ls /var/www/html/wp-content/plugins | wc -l')
|
||||
ci_step "one copy of the plugin, not two"
|
||||
# A zip whose top-level directory is not the slug installs a SECOND copy beside
|
||||
# the first instead of replacing it, and the old one stays active. This used to
|
||||
# count the directories into a variable and then never look at it — a check
|
||||
# that could not fail, sitting under a comment explaining exactly what it was
|
||||
# for.
|
||||
docker exec "$id-cli" ls /var/www/html/wp-content/plugins | sed 's/^/ /'
|
||||
copies=$(docker exec "$id-cli" sh -c \
|
||||
"ls -d /var/www/html/wp-content/plugins/$PLUGIN_SLUG* 2>/dev/null | wc -l" | tr -d '[:space:]')
|
||||
echo " directories starting with $PLUGIN_SLUG: $copies"
|
||||
[ "$copies" = 1 ] || {
|
||||
echo "Expected exactly one, got $copies. The zip unpacked to a directory that is not the slug." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ci_step "the code that is now running reports the new version"
|
||||
# Files can be replaced while the running code still reports the old version —
|
||||
# an opcache that was not invalidated, or a constant defined somewhere else.
|
||||
# The constant name is read out of the plugin header rather than hard-coded:
|
||||
# this script is copied between plugins, and a name that does not exist over
|
||||
# there prints an empty value and carries on cheerfully.
|
||||
const=$(sed -n -E "s/^[[:space:]]*define\([[:space:]]*'([A-Z_]*VERSION)'.*/\1/p" "$PLUGIN_MAIN" | head -1)
|
||||
if [ -n "$const" ]; then
|
||||
running=$(wp eval "echo defined( '$const' ) ? $const : '';" | tr -d '[:space:]')
|
||||
echo " $const = ${running:-<undefined>}"
|
||||
[ "$running" = "$expected" ] || {
|
||||
echo "The files say $expected but the running code says '${running:-undefined}'." >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
echo " $PLUGIN_MAIN defines no *_VERSION constant — nothing to cross-check."
|
||||
fi
|
||||
|
||||
ci_step "and it still works afterwards"
|
||||
wp eval 'echo WC_EDM_VERSION;' | sed 's/^/ WC_EDM_VERSION: /'
|
||||
wp plugin list --name="$PLUGIN_SLUG" --field=update | grep -qx none \
|
||||
&& echo " WordPress no longer offers an update — the header and the feed agree."
|
||||
|
||||
|
||||
+116
-5
@@ -71,11 +71,14 @@ Three things in that file are load-bearing and none of them look it:
|
||||
WordPress 5.8. Remove the header and a .org plugin that happens to share the
|
||||
slug could be pushed onto a shop running yours.
|
||||
|
||||
The answer is cached for six hours, which is right for the check WordPress runs
|
||||
twice a day and wrong for the **Check again** link — so `forget_on_force_check()`
|
||||
drops the cache on `force-check`. Without it, someone who has just published a
|
||||
release clicks that link, sees nothing, and concludes the whole mechanism is
|
||||
broken.
|
||||
🔴 **Keep the feed cache short — WordPress is already the rate limiter.** It
|
||||
gates its own check by screen: one minute on Dashboard → Updates, one hour on
|
||||
the Plugins screen, 2–12 hours in the background. This filter only runs when
|
||||
that check runs, so any cache longer than that cadence simply hides new
|
||||
releases underneath it. Ours was six hours "because WordPress checks twice a
|
||||
day anyway", and the symptom was a release going out and the shop being shown
|
||||
nothing. It is fifteen minutes now, and `forget_on_force_check()` drops it
|
||||
entirely on the **Check again** link.
|
||||
|
||||
**6. Make the Gitea repository public**, or point `Update URI` at a separate
|
||||
public releases-only repository. Every shop reads that feed without
|
||||
@@ -105,6 +108,97 @@ it cannot.
|
||||
- `ci/workflows-in-sync.sh` — fails when the two workflow roots drift, which is
|
||||
the only way to notice that a workflow stopped running on Gitea.
|
||||
|
||||
## Running these locally: `git add` first
|
||||
|
||||
Every one of these scripts derives "what ships" from `git ls-files`, which
|
||||
reads the **index**. An uncommitted file is therefore invisible to
|
||||
`ci/dist-files.sh`, to the release zip, and to the copy that goes into the test
|
||||
site.
|
||||
|
||||
That is deliberate — it is what catches a file that was written, referenced and
|
||||
never committed, which is a release that is missing a file while every local
|
||||
test passes because the file is sitting right there in the working tree. But it
|
||||
also means running any of this before `git add` tests a release without your
|
||||
newest file, and the failure is silent: the check does not complain, it just
|
||||
measures something else. It cost two rounds here, once on
|
||||
`includes/class-updater.php` and once on `assets/icon.svg`.
|
||||
|
||||
If a script reports something you just fixed as still missing, `git status`
|
||||
before you debug the script.
|
||||
|
||||
## Make a check fail once before you believe it
|
||||
|
||||
Three checks in this directory turned out to be incapable of failing, and all
|
||||
three sat under a comment explaining exactly what they were for:
|
||||
|
||||
- `tests/smoke.php` printed `FAIL` and exited 0 for four releases.
|
||||
- `ci/static.sh`'s PHP lint passed while linting an empty directory.
|
||||
- `ci/update-smoke.sh` counted the plugin directories into a variable and never
|
||||
looked at it — under a comment about a zip installing a second copy.
|
||||
|
||||
None of them were caught by reading the code, and a green build is exactly what
|
||||
a broken check looks like. So: break it on purpose once, watch it go red, then
|
||||
put it back. It costs one run.
|
||||
|
||||
## The bug you cannot find by reading your own repo
|
||||
|
||||
🔴 **`update_plugins_{$hostname}` is keyed on the hostname, not the
|
||||
repository.** Every plugin whose `Update URI` points at the same host shares one
|
||||
hook, and WordPress runs **every** callback on it for **every** one of those
|
||||
plugins:
|
||||
|
||||
```php
|
||||
// wp-includes/update.php
|
||||
$update = apply_filters( "update_plugins_{$hostname}", false, $plugin_data, $plugin_file, $locales );
|
||||
```
|
||||
|
||||
Answer unconditionally and you offer your release to your neighbour. Measured on
|
||||
a live shop: a subscriptions plugin was shown this one's 1.4.4 and told it was
|
||||
an update for itself. Load order decides who wins, so which plugin gets
|
||||
corrupted is arbitrary — and pointed at one with auto-updates enabled, it
|
||||
installs a stranger's zip over itself, unattended, on a shop taking money.
|
||||
|
||||
```php
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
||||
|
||||
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
|
||||
if ( '' !== $plugin_file && plugin_basename( SELF_FILE ) !== $plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
```
|
||||
|
||||
Both halves. Leave the accepted-argument count at 2 and the third argument never
|
||||
arrives, so the guard compares against an empty string and does nothing — the
|
||||
same shape of bug it exists to fix. `plugins_api` is safe if it guards on
|
||||
`$args->slug`.
|
||||
|
||||
**No integration test can find this.** A site test installs one plugin from that
|
||||
host, so the collision cannot arise there. The assertion is the entire defence:
|
||||
|
||||
```php
|
||||
wc_edm_ok( 'another plugin on the same host is not offered our release',
|
||||
false === $updater->offer_update( false, $data, 'someone-else/someone-else.php' ) );
|
||||
```
|
||||
|
||||
The same shape applies to anything else shared between sibling plugins. A
|
||||
`shared-*.php` file copied byte-identically into five plugins fatals with
|
||||
`Cannot redeclare` on the second activation unless everything in it is wrapped
|
||||
in `function_exists()` / `class_exists()` / `defined()` — and then the copy that
|
||||
loads first wins, which is alphabetical order, which is arbitrary. Freeze the
|
||||
contract rather than versioning the file.
|
||||
|
||||
## The repository name and the installed slug are different things
|
||||
|
||||
The repo may be renamed freely — Gitea keeps a 301 on both the API and the deep
|
||||
release-download paths (measured), and WordPress follows up to 5 redirects by
|
||||
default, so installed copies keep updating through the old `Update URI`.
|
||||
|
||||
The **installed** slug can never change. It is `<slug>/<main file>` in
|
||||
`wp-content/plugins`, and a new one installs a second copy beside the first
|
||||
while the old one stays active. So `PLUGIN_SLUG` in `ci/plugin.conf` is allowed
|
||||
to disagree with the repository name forever, and if it does, say so in the file
|
||||
or somebody will helpfully align them.
|
||||
|
||||
## Two guards worth understanding before you move them
|
||||
|
||||
**`.gitea/workflows/` hides `.github/workflows/` completely.** Gitea uses the
|
||||
@@ -118,6 +212,23 @@ and a release with no zip attached both produce a page that looks perfect to
|
||||
anyone logged in, and an update that no shop is ever offered. A check that
|
||||
sends a token is the false positive that hides exactly that.
|
||||
|
||||
**Do not parse a forge's JSON with `sed`.** A greedy `.*"id":([0-9]+)` matches
|
||||
the *last* id in the payload, and a release object carries an embedded
|
||||
`author` and, once anything is attached, an `assets` array — each with an id of
|
||||
its own. `ci/publish-release.sh` uses a four-line `json_field()` over `python3`
|
||||
instead. Measured here: the greedy pattern returns the asset ids 1/2/3/4 for
|
||||
releases whose real ids are 416/417/419/421.
|
||||
|
||||
Four releases were published correctly anyway, and the reason is **who
|
||||
published them, not which instance**: a release created by the Actions job
|
||||
token has the ghost user as its author (`"id":-1`), and the minus sign stops
|
||||
`[0-9]+` from matching. A release created by a real user — a cross-repo PAT, or
|
||||
one published by hand — has `"id":1` and the greedy match lands on it. Do not
|
||||
read the `-1` as a safety margin; it lasts until the first release somebody
|
||||
publishes by hand. And on a re-run the lookup fetches a response that *has*
|
||||
assets, where the last id is an asset id: positive, on any instance, with any
|
||||
token.
|
||||
|
||||
**A forge's own download URL is not the address your users can reach.**
|
||||
`browser_download_url` is built from the forge's configured root URL;
|
||||
`gitea.nolimit.dk` reports `http://57.129.89.117:25001/...` there. The updater
|
||||
|
||||
@@ -185,11 +185,21 @@ the real list table and asserts the flag rather than trusting the reasoning.
|
||||
|
||||
### "Check again" really checks again
|
||||
|
||||
The feed is cached for six hours. That is right for the check WordPress runs on
|
||||
its own twice a day, and wrong for a link whose entire promise is that it looks
|
||||
*now* — so the cache is dropped when wp-admin is loaded with `force-check`.
|
||||
Without that, publishing a release and then clicking **Check again** shows
|
||||
nothing for up to six hours, which reads exactly like a broken updater.
|
||||
The feed is cached for fifteen minutes, and the cache is dropped entirely when
|
||||
wp-admin is loaded with `force-check`.
|
||||
|
||||
It was six hours, on the reasoning that WordPress checks twice a day anyway.
|
||||
That reasoning was wrong. WordPress gates its own check **by screen**:
|
||||
|
||||
| Screen | How often WordPress re-checks |
|
||||
| --- | --- |
|
||||
| Dashboard → Updates | every minute |
|
||||
| Plugins | every hour |
|
||||
| Background / cron | every 2–12 hours |
|
||||
|
||||
This filter only runs when that check runs, so WordPress is already the rate
|
||||
limiter. A cache longer than its cadence does nothing but hide new releases
|
||||
underneath it — a release published, and a shop shown nothing.
|
||||
|
||||
From the command line, `wp transient delete wc_edm_release_feed` does the same.
|
||||
|
||||
|
||||
@@ -34,10 +34,24 @@ class WC_EDM_Updater {
|
||||
const TRANSIENT = 'wc_edm_release_feed';
|
||||
|
||||
/**
|
||||
* How long that cache lives. WordPress checks for updates twice a day; a
|
||||
* shorter window would only add requests, a longer one would delay a fix.
|
||||
* How long that cache lives.
|
||||
*
|
||||
* Six hours, originally, on the reasoning that WordPress checks twice a
|
||||
* day anyway. That reasoning was wrong, and the symptom was a shop that
|
||||
* had just been told a new version existed and was shown nothing.
|
||||
*
|
||||
* WordPress does not check twice a day when somebody is looking. It gates
|
||||
* its own check by screen — one minute on Dashboard → Updates, one hour on
|
||||
* the Plugins screen, twelve hours in the background (wp_update_plugins(),
|
||||
* switch on current_filter()). This filter only runs when that check runs,
|
||||
* so WordPress is already the rate limiter, and a cache longer than its
|
||||
* cadence does nothing but hide new releases underneath it.
|
||||
*
|
||||
* Fifteen minutes is left only to absorb a burst — several calls in one
|
||||
* request, or other code calling wp_update_plugins() with extra stats,
|
||||
* which bypasses the gate above.
|
||||
*/
|
||||
const TTL = 6 * HOUR_IN_SECONDS;
|
||||
const TTL = 15 * MINUTE_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Hook into the update check for our own host only.
|
||||
@@ -49,7 +63,7 @@ class WC_EDM_Updater {
|
||||
return;
|
||||
}
|
||||
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 2 );
|
||||
add_filter( "update_plugins_{$host}", array( $this, 'offer_update' ), 10, 3 );
|
||||
|
||||
// Without this the old answer is served for up to six hours after an
|
||||
// update, and the plugins screen keeps offering a version already
|
||||
@@ -58,6 +72,9 @@ class WC_EDM_Updater {
|
||||
|
||||
// And "Check again" has to actually check again.
|
||||
add_action( 'admin_init', array( $this, 'forget_on_force_check' ) );
|
||||
|
||||
// "View version details" next to the update notice.
|
||||
add_filter( 'plugins_api', array( $this, 'plugin_details' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,9 +126,26 @@ class WC_EDM_Updater {
|
||||
*
|
||||
* @param array|false $update Update offered so far.
|
||||
* @param array $plugin_data Headers of the installed plugin.
|
||||
* @param string $plugin_file Which plugin WordPress is asking about.
|
||||
* @return array|false
|
||||
*/
|
||||
public function offer_update( $update, $plugin_data ) {
|
||||
public function offer_update( $update, $plugin_data, $plugin_file = '' ) {
|
||||
// The filter name is built from the HOSTNAME, not the repository:
|
||||
// every plugin that updates from gitea.nolimit.dk shares the one hook
|
||||
// `update_plugins_gitea.nolimit.dk`, and WordPress runs every callback
|
||||
// on it for every one of those plugins. Without this check a plugin
|
||||
// answers for its neighbour and offers it its own release — measured
|
||||
// on a live shop, where a subscriptions plugin was offered this one's
|
||||
// 1.4.4 and told it was an update for itself.
|
||||
//
|
||||
// The `10, 3` on the add_filter above is half of this fix. With the
|
||||
// accepted-argument count left at 2 the third argument never arrives,
|
||||
// $plugin_file stays empty, and the guard silently does nothing — the
|
||||
// same shape of bug it exists to fix.
|
||||
if ( '' !== $plugin_file && plugin_basename( WC_EDM_PLUGIN_FILE ) !== $plugin_file ) {
|
||||
return $update;
|
||||
}
|
||||
|
||||
$release = $this->latest_release();
|
||||
|
||||
if ( ! $release ) {
|
||||
@@ -141,6 +175,58 @@ class WC_EDM_Updater {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the "View version details" modal.
|
||||
*
|
||||
* WordPress builds that link as
|
||||
* plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
||||
* plugins_api() — and plugins_api() asks **wordpress.org**. This plugin is
|
||||
* not there, so without this filter the link opens a modal containing an
|
||||
* error, on the same screen that is otherwise telling the shop owner an
|
||||
* update is ready. Nothing warns you: the link looks like every other
|
||||
* plugin's.
|
||||
*
|
||||
* @param false|object|array $result Response so far.
|
||||
* @param string $action Requested action.
|
||||
* @param object $args Request arguments.
|
||||
* @return false|object|array
|
||||
*/
|
||||
public function plugin_details( $result, $action, $args ) {
|
||||
$slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
|
||||
if ( 'plugin_information' !== $action || empty( $args->slug ) || $args->slug !== $slug ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||
$plugin = get_plugin_data( WC_EDM_PLUGIN_FILE, false, false );
|
||||
$release = $this->latest_release();
|
||||
|
||||
$notes = $release && '' !== $release['notes'] ? $release['notes'] : '';
|
||||
|
||||
return (object) array(
|
||||
'name' => $plugin['Name'],
|
||||
'slug' => $slug,
|
||||
'version' => $release ? $release['version'] : $plugin['Version'],
|
||||
'author' => $plugin['Author'],
|
||||
'author_profile'=> $plugin['AuthorURI'],
|
||||
'homepage' => $plugin['PluginURI'],
|
||||
'requires' => $plugin['RequiresWP'],
|
||||
'requires_php' => $plugin['RequiresPHP'],
|
||||
'tested' => $release ? $release['tested'] : '',
|
||||
'download_link' => $release ? $release['package'] : '',
|
||||
'icons' => array( 'svg' => WC_EDM_PLUGIN_URL . 'assets/icon.svg' ),
|
||||
'sections' => array(
|
||||
'description' => wpautop( esc_html( $plugin['Description'] ) ),
|
||||
// Plain text from the release notes, not markdown: rendering it
|
||||
// as HTML would mean trusting whatever is in a release body.
|
||||
'changelog' => '' !== $notes
|
||||
? wpautop( esc_html( $notes ) )
|
||||
: wpautop( esc_html__( 'Release notes are published with each release.', 'wc-email-discount' ) ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached feed.
|
||||
*/
|
||||
@@ -274,6 +360,9 @@ class WC_EDM_Updater {
|
||||
'package' => $base . '/releases/download/' . rawurlencode( $tag ) . '/' . rawurlencode( $zip ),
|
||||
'url' => $base . '/releases/tag/' . rawurlencode( $tag ),
|
||||
'tested' => $tested,
|
||||
// The release notes are the changelog. Kept so the "View version
|
||||
// details" modal has something true to show.
|
||||
'notes' => isset( $body['body'] ) ? (string) $body['body'] : '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+125
-9
@@ -14,7 +14,29 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$wc_edm_failures = 0;
|
||||
/*
|
||||
* The counter lives in $GLOBALS on purpose, and the four lines below prove it
|
||||
* reaches the verdict before anything relies on it.
|
||||
*
|
||||
* `wp eval-file` runs this file through eval() INSIDE A METHOD, so the top
|
||||
* level here is function scope, not global scope. A plain `$failures = 0` at
|
||||
* the top and a `global $failures` in a helper below are therefore two
|
||||
* different variables: the helpers increment the real global, the verdict at
|
||||
* the bottom reads the local, and the local is always zero. Every assertion
|
||||
* fails, every FAIL line prints, the file ends with "All assertions passed"
|
||||
* and exits 0 — and CI goes green. That is not reasoning, it is what this file
|
||||
* did until it was measured.
|
||||
*
|
||||
* This is the one file whose entire job is to be able to go red, so it checks
|
||||
* that it can.
|
||||
*/
|
||||
$GLOBALS['wc_edm_failures'] = 0;
|
||||
( static function () { ++$GLOBALS['wc_edm_failures']; } )();
|
||||
if ( 1 !== $GLOBALS['wc_edm_failures'] ) {
|
||||
echo "The failure counter does not reach the verdict: this file cannot go red.\n";
|
||||
exit( 1 );
|
||||
}
|
||||
$GLOBALS['wc_edm_failures'] = 0;
|
||||
|
||||
/**
|
||||
* Assert a condition and keep going, so one run reports every failure.
|
||||
@@ -24,14 +46,12 @@ $wc_edm_failures = 0;
|
||||
* @param string $detail Shown when it fails.
|
||||
*/
|
||||
function wc_edm_ok( $what, $condition, $detail = '' ) {
|
||||
global $wc_edm_failures;
|
||||
|
||||
if ( $condition ) {
|
||||
printf( " ok %s\n", $what );
|
||||
return;
|
||||
}
|
||||
|
||||
++$wc_edm_failures;
|
||||
++$GLOBALS['wc_edm_failures'];
|
||||
printf( " FAIL %s%s\n", $what, '' !== $detail ? " — {$detail}" : '' );
|
||||
}
|
||||
|
||||
@@ -285,7 +305,7 @@ set_transient(
|
||||
60
|
||||
);
|
||||
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is( 'a newer release is offered as an update', '99.0.0', is_array( $offered ) ? $offered['version'] : null );
|
||||
wc_edm_is(
|
||||
'and it carries the folder name WordPress will unpack into',
|
||||
@@ -324,9 +344,79 @@ set_transient(
|
||||
),
|
||||
60
|
||||
);
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ) );
|
||||
$offered = wc_edm()->updater->offer_update( false, get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ), plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSION, is_array( $offered ) ? $offered['version'] : null );
|
||||
|
||||
// 🔴 The filter name is built from the HOSTNAME, not the repository. Every
|
||||
// plugin updating from gitea.nolimit.dk shares the hook
|
||||
// update_plugins_gitea.nolimit.dk, and WordPress runs every callback on it for
|
||||
// every one of those plugins. Answering unconditionally means offering the
|
||||
// neighbour your own release — which happened on a live shop: a subscriptions
|
||||
// plugin was shown this one's 1.4.4 and told it was an update for itself. Load
|
||||
// order decides who wins, so the direction it breaks in is arbitrary.
|
||||
//
|
||||
// The site test installs exactly one plugin from this host, so the collision
|
||||
// can never occur here. This assertion is the whole defence.
|
||||
wc_edm_ok(
|
||||
'another plugin on the same host is not offered our release',
|
||||
false === wc_edm()->updater->offer_update(
|
||||
false,
|
||||
get_plugin_data( WC_EDM_PLUGIN_FILE, false, false ),
|
||||
'kw-subscriptions/kw-subscriptions.php'
|
||||
),
|
||||
'we answered for a plugin that is not ours'
|
||||
);
|
||||
|
||||
// The "View version details" link next to the update notice. WordPress builds
|
||||
// it as plugin-install.php?tab=plugin-information&plugin=<slug>, which calls
|
||||
// plugins_api() — and plugins_api() asks wordpress.org, where this plugin does
|
||||
// not exist. Unanswered, that link opens a modal reading "Plugin not found."
|
||||
// on the same screen that is telling the shop owner an update is ready.
|
||||
// Measured on a live shop before this was written.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => '99.0.0',
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v99.0.0/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
||||
'tested' => '7.1',
|
||||
'notes' => "v99.0.0 — a headline\n\nA line of release notes.",
|
||||
),
|
||||
600
|
||||
);
|
||||
|
||||
// plugins_api() lives in the admin includes, which a WP-CLI context does not
|
||||
// load. wp-admin has it; this does not.
|
||||
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
|
||||
|
||||
$wc_edm_slug = dirname( plugin_basename( WC_EDM_PLUGIN_FILE ) );
|
||||
$details = plugins_api( 'plugin_information', array( 'slug' => $wc_edm_slug ) );
|
||||
|
||||
wc_edm_ok(
|
||||
'the details modal is answered here, not by wordpress.org',
|
||||
! is_wp_error( $details ),
|
||||
is_wp_error( $details ) ? $details->get_error_message() : ''
|
||||
);
|
||||
wc_edm_is( 'and it is about this plugin', $wc_edm_slug, is_wp_error( $details ) ? null : $details->slug );
|
||||
wc_edm_is( 'it reports the version from the feed', '99.0.0', is_wp_error( $details ) ? null : $details->version );
|
||||
wc_edm_ok(
|
||||
'the changelog is the release notes',
|
||||
! is_wp_error( $details ) && false !== strpos( $details->sections['changelog'], 'A line of release notes' ),
|
||||
'a modal with an empty changelog is barely better than an error'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'and the notes are escaped rather than rendered',
|
||||
! is_wp_error( $details ) && false === strpos( $details->sections['changelog'], '<script' ),
|
||||
'a release body is not a template language'
|
||||
);
|
||||
wc_edm_ok(
|
||||
'another plugin\'s details request is left alone',
|
||||
false === wc_edm()->updater->plugin_details( false, 'plugin_information', (object) array( 'slug' => 'akismet' ) ),
|
||||
'answering for somebody else would replace their modal with ours'
|
||||
);
|
||||
|
||||
delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
|
||||
/*
|
||||
* The "Enable auto-updates" link on the Plugins screen.
|
||||
*
|
||||
@@ -345,7 +435,18 @@ wc_edm_is( 'the current version is answered too, not passed over', WC_EDM_VERSIO
|
||||
* WordPress does with our answer, and a test that needs the internet fails for
|
||||
* reasons that have nothing to do with it.
|
||||
*/
|
||||
// "Check again" must discard the six-hour cache, or the button lies: someone
|
||||
// The cache must not outlive WordPress's own check cadence. It gates by screen
|
||||
// — one minute on Dashboard → Updates, one hour on the Plugins screen — and
|
||||
// this filter only runs when that check runs, so anything longer here hides a
|
||||
// new release underneath a cache the shop owner cannot see. This was six hours
|
||||
// and produced exactly that: a release published, and a shop shown nothing.
|
||||
wc_edm_ok(
|
||||
'the cache is shorter than the interval WordPress re-checks on',
|
||||
WC_EDM_Updater::TTL <= 15 * MINUTE_IN_SECONDS,
|
||||
'TTL is ' . WC_EDM_Updater::TTL . 's; WordPress re-checks every 60s on Dashboard → Updates'
|
||||
);
|
||||
|
||||
// "Check again" must discard the cache, or the button lies: someone
|
||||
// who has just published a release clicks it, sees nothing, and concludes the
|
||||
// whole mechanism is broken.
|
||||
set_transient( WC_EDM_Updater::TRANSIENT, array( 'version' => '1.0.0', 'package' => 'x', 'url' => 'x', 'tested' => '' ), 600 );
|
||||
@@ -368,6 +469,21 @@ delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
// strlen(null) deprecation from class-wp-plugins-list-table.php. It is
|
||||
// WordPress's, not ours, which is why the deprecation check filters on this
|
||||
// plugin's directory rather than failing on everything it sees.
|
||||
// Primed again deliberately. The force-check assertions above deleted it, and
|
||||
// without this offer_update() makes a real HTTP request — which passes only
|
||||
// because this plugin's feed repo happens to be public. That would make the
|
||||
// assertion measure the environment rather than the code under test.
|
||||
set_transient(
|
||||
WC_EDM_Updater::TRANSIENT,
|
||||
array(
|
||||
'version' => WC_EDM_VERSION,
|
||||
'package' => 'https://gitea.nolimit.dk/kw/demo/releases/download/v1/demo.zip',
|
||||
'url' => 'https://gitea.nolimit.dk/kw/demo',
|
||||
'tested' => '7.1',
|
||||
),
|
||||
600
|
||||
);
|
||||
|
||||
delete_site_transient( 'update_plugins' );
|
||||
wp_update_plugins();
|
||||
$transient = get_site_transient( 'update_plugins' );
|
||||
@@ -407,8 +523,8 @@ delete_transient( WC_EDM_Updater::TRANSIENT );
|
||||
/* --------------------------------------------------------------- verdict -- */
|
||||
|
||||
echo "\n";
|
||||
if ( $wc_edm_failures > 0 ) {
|
||||
printf( "%d assertion(s) failed on WordPress %s.\n", $wc_edm_failures, get_bloginfo( 'version' ) );
|
||||
if ( $GLOBALS['wc_edm_failures'] > 0 ) {
|
||||
printf( "%d assertion(s) failed on WordPress %s.\n", $GLOBALS['wc_edm_failures'], get_bloginfo( 'version' ) );
|
||||
exit( 1 );
|
||||
}
|
||||
printf(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: WooCommerce Email Discount Manager
|
||||
* Plugin URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Update URI: https://gitea.nolimit.dk/kw/woocommerce-email-discount-manager
|
||||
* Plugin URI: https://gitea.nolimit.dk/kw/woo-discount-manager
|
||||
* Update URI: https://gitea.nolimit.dk/kw/woo-discount-manager
|
||||
* Description: Apply discounts and show/hide shipping methods based on the customer's email address (full address, domain or partial match).
|
||||
* Version: 1.4.1
|
||||
* Author: WEDSMP
|
||||
* Version: 1.4.6
|
||||
* Author: Kristian Wind
|
||||
* Author URI: https://gitea.nolimit.dk/kw
|
||||
* License: GPL-2.0-or-later
|
||||
* Text Domain: wc-email-discount
|
||||
* Requires at least: 5.8
|
||||
@@ -27,7 +28,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define( 'WC_EDM_VERSION', '1.4.1' );
|
||||
define( 'WC_EDM_VERSION', '1.4.6' );
|
||||
define( 'WC_EDM_PLUGIN_FILE', __FILE__ );
|
||||
define( 'WC_EDM_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
define( 'WC_EDM_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||
|
||||
Reference in New Issue
Block a user