mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Merge origin/main into the extensions branch
Main moved a long way (RedBean removed, invoices on Doctrine, a PSR-3 Logger, offline-payment approval, RDAP, theme rename), so this is more than a textual merge: several of main's changes had to be re-expressed against the extension layout and contract. Carried over from main's changes to the original adapters - Stripe v22, the PayPal signed-callback / IPN hardening, Stripe ownership and currency validation, Plesk/CWP/Whm/Resellerclub/Namecheap fixes, the RDAP client (now Contract\Registrar\Rdap with AdapterAbstract::getRdap()) and the Registrar\Exception tolerance fix. - php-whois is gone with RDAP, so the Custom and Email registrars no longer need a composer.json. Decisions where the two designs met - Offline approval: main made Custom::approveTransaction() the settlement point and requiresManualApproval() a static on the class. A gateway here has no container and core cannot read a static without loading the class, so the capability is a `manual_approval` flag in extension.json and core settles the approved payment itself, with the same logic and the same concurrency guarantees. The overlapping-approval test is kept and drives the new settlement seam. - Stripe and PayPal declared their settings only in getConfig(), which core no longer reads, so their forms were empty and main's credential masking had nothing to mask. Their manifests now carry the settings, logo and `secret` flags, generated from main's getConfig(). - The ClientBalance gateway stays removed; main's fix to it (#4338) only repaired a gateway-ID comparison that no longer exists. - InvoiceView::$approved becomes $issued, matching main's invoice model. - Box_Log is gone, so PsrLogAdapter and the extension_logger service are too: $di['logger'] is already PSR-3. - SentryHelper's shipped-adapter allowlists now look under src/extensions, so errors from bundled registrars and managers are still reported. - UpdatePatcher: main now holds patches up to 128, so the extensions move and the ClientBalance removal become 129 and 130. Also fixed - Rector now skips extension vendor directories, as PHPStan already did. - labeler.yml and cspell.json pointed at the old adapter paths. - A stray `use GeoIp2\Model\Domain;` in the Namecheap registrar, from the original relocation.
This commit is contained in:
commit
8b786e55e0
979 files changed
+78462
-22374
No files matched your search
@@ -0,0 +1,256 @@
|
||||
---
|
||||
name: security-advisory-fix
|
||||
description: Design, implement, test, and ship the actual code fix for a security advisory that has already been verified as real (typically by security-advisory-triage) against this repo. Use this whenever the user asks to fix, patch, remediate, harden, or "build a comprehensive fix for" a confirmed vulnerability or GHSA, or says something like "now let's fix it" right after a triage/verification step. Don't wait for the user to ask for "the process" by name; if a vulnerability is confirmed and they want it shipped, this skill is almost certainly what they want. Companion to security-advisory-triage, which verifies and writes up the advisory; this one builds and ships the fix.
|
||||
---
|
||||
|
||||
# Security advisory fix
|
||||
|
||||
Companion to `security-advisory-triage`. That skill answers "is this real,
|
||||
and what does the advisory say?" This one starts once the answer is yes and
|
||||
covers everything from there through a merged, CI-green fix: designing the
|
||||
fix, implementing it, and getting it through review.
|
||||
|
||||
Like triage, this is collaborative and staged, not something to run to
|
||||
completion and hand over at the end. The design stage in particular involves
|
||||
real judgment calls (how far to change existing behavior, whether a
|
||||
suggested response masks the bug or just the symptom), and those are worth
|
||||
putting in front of the user as they come up rather than deciding silently
|
||||
and revealing the decision only in the diff.
|
||||
|
||||
## Carry the disclosure caution forward
|
||||
|
||||
If the advisory is still unpublished, the same rule from triage applies to
|
||||
everything you produce here, not just the write-up. No GHSA/CVE ID, and
|
||||
nothing specific enough to reconstruct the exploit, in branch names, commit
|
||||
messages, PR titles or bodies, or CI run titles. If the harness auto-named
|
||||
your branch after the advisory ID (this happens: a task prompt that mentions
|
||||
the GHSA ID can get echoed straight into an auto-generated branch name),
|
||||
rename it before your first commit, not after. Use a plain, symptom-based
|
||||
name instead, matching how this repo's own fix PRs are named
|
||||
(`fix/template-exists`, `fix/client-signup-enumeration`: what broke, not
|
||||
what the finding is called).
|
||||
|
||||
## Stage 1: Design the fix
|
||||
|
||||
Resist writing code before this stage produces an actual decision. The
|
||||
temptation is strong, especially when the vulnerable lines are obvious, but
|
||||
"obvious lines" and "correct fix" are different problems. For anything
|
||||
beyond a one-line validation gap, there's real design space worth surfacing
|
||||
to the user rather than picking silently.
|
||||
|
||||
### If exploration contradicts the report, stop before designing anything
|
||||
|
||||
Triage should have confirmed the report already, but exploring the actual
|
||||
code for a fix sometimes turns up a mismatch triage didn't catch: the
|
||||
described mechanism doesn't exist as written. Don't quietly retarget the fix
|
||||
at whatever you did find and carry on as if that's what was reported. Stop
|
||||
and put the discrepancy in front of the user: here's what the report claims,
|
||||
here's what the code actually does. Two shapes of mismatch are both common
|
||||
enough to expect, not just one:
|
||||
|
||||
- **Something adjacent and real is there instead.** Say what it is, and let
|
||||
the user decide whether that's what you should fix, whether it's worth a
|
||||
separate report, or whether the original claim needs another look.
|
||||
- **Nothing is actually wrong.** The path the report describes is already
|
||||
correctly guarded, sometimes by a fix that landed for an unrelated reason.
|
||||
Trace and quote the exact guard so the user can verify your reading rather
|
||||
than just taking "looks fine" on faith, and treat closing the report as a
|
||||
real outcome, not a failure to find something. It's often still worth
|
||||
asking whether the gap is covered by a regression test, and adding one if
|
||||
not, so this stays provably true rather than just currently true.
|
||||
|
||||
Silently substituting a different fix for a different bug, or silently
|
||||
closing a report as unfounded without showing your work, are both exactly
|
||||
the kind of decision this skill exists to make visible, not the kind to make
|
||||
on your own.
|
||||
|
||||
### Look for the pattern already in the codebase
|
||||
|
||||
Before inventing a fix, check whether this exact class of problem was
|
||||
already solved somewhere else in the app. A codebase this size usually has
|
||||
prior art: a sibling endpoint that handles the same kind of sensitive
|
||||
comparison, timing concern, or disclosure risk correctly, even if the
|
||||
vulnerable code never got the same treatment. Grep for the mechanism, not
|
||||
just the symptom. If the bug is a timing-safe comparison gap, look for other
|
||||
places doing timing-safe comparisons; if it's an enumeration oracle, look
|
||||
for other endpoints that already had to solve enumeration. Reusing an
|
||||
established, already-reviewed pattern is almost always better than
|
||||
designing a new one from scratch, and it gives you a concrete thing to
|
||||
propose instead of an abstract description of what you're about to build.
|
||||
|
||||
### Lay out real options, then ask
|
||||
|
||||
Once you understand the vulnerable mechanism and what prior art exists,
|
||||
identify the genuinely distinct strategies for fixing it. Not a strawman
|
||||
next to the obviously-correct answer, but options that trade real things
|
||||
against each other: how much existing behavior or API contract changes, how
|
||||
completely the underlying issue closes versus just becoming harder to
|
||||
exploit, how much UX or performance cost it adds. Recommend one, but put the
|
||||
real choice to the user with an explicit question (`AskUserQuestion` in
|
||||
Claude Code) rather than deciding and presenting the result as a fait
|
||||
accompli. This matters most exactly when the
|
||||
strongest fix would change public behavior other things might depend on.
|
||||
That's a call the user should get to make deliberately, with the tradeoff
|
||||
stated plainly, not discover after the fact in a diff.
|
||||
|
||||
### Check whether the fix closes the mechanism, or just the reported instance
|
||||
|
||||
The first fix that comes to mind usually addresses exactly the instance the
|
||||
report pointed at, and it's worth asking whether that's the same thing as
|
||||
closing the underlying mechanism. The two come apart in different ways for
|
||||
different bug classes, so what to check depends on what kind of bug this is:
|
||||
|
||||
- For a **disclosure/enumeration oracle**, the report usually names one
|
||||
channel (an error message, say). Walk through the others explicitly: does
|
||||
the response body still differ, does a returned value's type or shape
|
||||
leak something even once the message is gone (a real ID sitting next to a
|
||||
masked branch's placeholder is itself a signal), do HTTP status, headers,
|
||||
cookies/session state, or timing still diverge between the two cases. A
|
||||
fix that closes the loudest channel can leave a quieter one wide open, and
|
||||
because it's quieter it's easy to miss unless you look on purpose.
|
||||
- For a **validation/injection/traversal gap**, the report usually names one
|
||||
call site. Check whether the same unvalidated value reaches the same
|
||||
dangerous sink through any other path, and whether the fix belongs at the
|
||||
point of use (so every current and future caller is covered) rather than
|
||||
only in the one entry point that happened to get reported.
|
||||
- For a **broken access control / ownership-scoping gap** (one account
|
||||
reaching another's data by id), check every sibling endpoint that fetches
|
||||
the same kind of entity by id for the same ownership predicate, not just
|
||||
the reported one. This is a common enough bug class in this app's history
|
||||
to expect it by default, not just when a report happens to name it.
|
||||
- For other bug classes, ask the equivalent question in whatever terms
|
||||
actually apply: what would still let the underlying issue happen even
|
||||
after this specific report's reproduction case stops working?
|
||||
|
||||
This still applies even when Stage 1 concluded there's no code fix to write,
|
||||
only a regression test to add (see above): ask the same question about the
|
||||
test instead. Does it actually prove the general protection holds, or only
|
||||
that this one reported case happens to pass?
|
||||
|
||||
If you find something the obvious fix doesn't cover, treat it as its own
|
||||
decision. Surface it and ask, same as the primary strategy choice, rather
|
||||
than silently folding a second significant design change into the same
|
||||
commit as the first fix.
|
||||
|
||||
## Stage 2: Implement
|
||||
|
||||
Once the design is settled:
|
||||
|
||||
1. **Write the fix**, reusing whatever established pattern Stage 1 found
|
||||
where possible instead of a bespoke implementation.
|
||||
2. **Find every consumer of anything you changed the contract of.** If a
|
||||
return type, response shape, or error behavior changed, grep broadly for
|
||||
callers, not just obvious application code but every test framework this
|
||||
repo uses that might assert on the old contract. That can include
|
||||
PHP/Pest unit tests, PHP E2E tests that hit a live running instance, and
|
||||
Playwright specs, and they don't all share helper code, so a fix in one
|
||||
doesn't mean the equivalent fix landed in the others. Update each one you
|
||||
find.
|
||||
3. **Write a regression test** for the vulnerability itself, not just for
|
||||
the refactor.
|
||||
4. **Run the full local suite before ever pushing**: `composer test`
|
||||
(Unit + Modules), `composer phpstan`, and CS-Fixer. For CS-Fixer, pass
|
||||
`--config=.php-cs-fixer.dist.php` explicitly:
|
||||
`src/vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run
|
||||
--diff <files>`. Without it, CS-Fixer tries to locate the project's config
|
||||
by walking up from a single given path, which is ambiguous once you pass
|
||||
more than one, so it refuses outright ("For multiple paths config
|
||||
parameter is required") rather than guessing which path's config to use.
|
||||
Passing `--config` explicitly sidesteps the ambiguity regardless of how
|
||||
many files follow. This catches most things, but know its ceiling going
|
||||
in: `composer test`'s default suites are Unit and Modules only, so it
|
||||
silently skips anything under `tests/E2E` or `src/modules/*/tests/E2E`,
|
||||
which is exactly where a vulnerability regression test for something
|
||||
network- or live-instance-shaped is likely to live. Those need
|
||||
`APP_URL`/`TEST_API_KEY` (Playwright specs need `PLAYWRIGHT_BASE_URL`,
|
||||
`ADMIN_EMAIL`, `ADMIN_PASSWORD`) or a live instance to run at all, and
|
||||
network-timing dependent behavior (rate limits under real request
|
||||
latency, for example) plain doesn't reproduce in a unit test's mocked DI
|
||||
container no matter how it's stubbed. If your environment actually has
|
||||
those available, run the E2E suite too before pushing
|
||||
(`composer test -- --testsuite E2E`, `npm run pw:run`), not just the
|
||||
default suites; if it doesn't, that's fine, but know that a regression in
|
||||
that territory won't surface until CI actually runs it, so don't be
|
||||
surprised by it later.
|
||||
|
||||
All of the above is PHP-side. If the fix (or its regression test) touches
|
||||
browser/theme code instead, run the frontend equivalent too:
|
||||
`npm run test:js` for the JS unit suite and `npm run build` (which
|
||||
type-checks via `tsc` as part of running) so a broken build doesn't ship
|
||||
alongside a passing PHP suite.
|
||||
|
||||
## Stage 3: Open the PR and handle review
|
||||
|
||||
Open the PR with the same plain, symptom-based naming as the branch. No
|
||||
advisory ID anywhere in the title or body either, even once the fix is
|
||||
written and the temptation is to just describe what was actually wrong.
|
||||
|
||||
Naming carefully doesn't make this disclosure-free: the diff itself, whatever
|
||||
it's titled, is now visible to anyone with read access to the repo, before
|
||||
the advisory is published. This project's accepted practice is to ship the
|
||||
fix that way anyway (a working patch reaching anyone tracking `main`
|
||||
immediately outweighs holding it back for a slower private-disclosure
|
||||
process), and to hold the advisory's own *publication* for a window
|
||||
afterward instead, separately (see Stage 4). That's a real, deliberate
|
||||
tradeoff, not an oversight, so don't second-guess it by default. But it's a
|
||||
tradeoff made for this project's normal case, not a universal constant. If
|
||||
something about this specific advisory argues for more caution than usual
|
||||
(an unusually severe impact, a likely well-resourced attacker actively
|
||||
watching the repo), or if you're applying this skill somewhere that hasn't
|
||||
made this same call, confirm with the user before pushing anything rather
|
||||
than assuming the general practice applies unconditionally.
|
||||
|
||||
### Responding to CI and review comments
|
||||
|
||||
Verify every finding against the code as it currently stands before acting
|
||||
on it. This is the single highest-value habit in this whole loop. Two
|
||||
failure modes show up constantly, and both look identical to a real,
|
||||
actionable finding at a glance:
|
||||
|
||||
- **Stale findings.** A review can run against a commit you've since
|
||||
superseded. Check the finding's `original_commit_id`/timestamp against
|
||||
what's actually on the branch now before assuming it's still true. If a
|
||||
later commit already fixed it, say so in the reply instead of re-doing the
|
||||
work or, worse, reverting a correct fix back to the flagged state.
|
||||
- **Wrong findings.** A suggestion can be plausible-sounding and still be
|
||||
incorrect, including, notably, a suggestion that would silently reopen the
|
||||
exact vulnerability this whole skill exists to close ("just return the
|
||||
real ID here for compatibility," say, when the whole point of the fix was
|
||||
that the ID was a side channel). When that happens, don't comply to be
|
||||
agreeable. Reply with the concrete reason it's wrong and leave the code as
|
||||
it is.
|
||||
|
||||
When CI itself fails, read the actual failure log, not just the check name.
|
||||
The real cause is often several layers removed from the symptom, and the
|
||||
first plausible-looking cause isn't always the right one. Before reaching
|
||||
for a fix, ask whether the thing you're trying to preserve is even used by
|
||||
anything; the simplest correct fix for a broken test fixture is sometimes to
|
||||
stop trying to recover a value nothing downstream actually reads, not to
|
||||
route around whatever broke while getting it.
|
||||
|
||||
Reply to and resolve each review thread you actually acted on (including an
|
||||
"already fixed, here's why" reply for stale ones). Skip threads you didn't
|
||||
act on rather than resolving them as a formality.
|
||||
|
||||
A CI-green PR with every review thread resolved is not the same thing as a
|
||||
mergeable PR. `CONTRIBUTING.md` requires two maintainer reviews before
|
||||
anything merges, a real approval gate separate from CI and separate from
|
||||
automated review tools, and it's not this skill's place to merge around it
|
||||
even if the tooling you're working in happens to have the permission to.
|
||||
Get the fix and its tests in front of maintainers and leave the merge itself
|
||||
to them.
|
||||
|
||||
## Stage 4: After merge
|
||||
|
||||
Confirm CI is actually green on the merge commit on the base branch, not
|
||||
just on the PR branch before merging. A squash-merge or a concurrent change
|
||||
to the base branch can still surface something new. Clean up the now-merged
|
||||
branch and worktree once confirmed.
|
||||
|
||||
Publishing or updating the advisory itself (the patched-version field,
|
||||
whether and when it goes public) is always the maintainer's call, never
|
||||
something to do unprompted just because the fix shipped. Advisories are
|
||||
routinely held back deliberately for a while after the fix lands, to give
|
||||
users an update window before the vulnerability becomes public knowledge.
|
||||
Update your own notes to reflect that timing decision once the user states
|
||||
it, and don't revisit or nudge toward publishing until they raise it again.
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: security-advisory-triage
|
||||
description: Verify a reported GitHub Security Advisory (GHSA) against this repo's actual current code, then refine its title/description/CVSS/CWE metadata into this project's house style and apply it to the live advisory. Use this whenever the user gives you a GHSA ID, a security advisory number, a security report to "check", "verify", "triage", or "confirm", or asks whether a vulnerability report is valid/real/still exploitable on main. Also use it when the user wants an advisory's writeup improved, its CVSS score computed or recalculated, its CWEs corrected, or its title/description brought in line with how this repo normally publishes advisories, even if they only ask for one of those pieces (e.g. "recompute the CVSS for GHSA-xxxx") rather than the full workflow. Don't wait for the user to ask for "the process" by name; if they hand you a GHSA id or a raw vulnerability report against this codebase, this skill is almost certainly what they want.
|
||||
---
|
||||
|
||||
# Security advisory triage
|
||||
|
||||
This is a two-stage workflow: first establish, with evidence, whether a reported
|
||||
vulnerability is real against the code as it stands today; only then move on to
|
||||
making the advisory itself good. Don't skip straight to writing prose about a bug
|
||||
you haven't confirmed exists.
|
||||
|
||||
Both stages are collaborative and iterative, not something to run to completion
|
||||
and dump on the user at the end. The reason is simple: writing a security advisory
|
||||
involves a lot of small judgment calls (how far back does "affected versions" really
|
||||
go, is this workaround actually true, does this deserve its own CWE), and the user
|
||||
catching a wrong assumption early is much cheaper than catching it after you've
|
||||
built five more paragraphs on top of it. Work through it piece by piece, show your
|
||||
reasoning, and let the user correct course before you continue.
|
||||
|
||||
## Stage 1: Verify the report against current code
|
||||
|
||||
Goal: reach a confident, evidence-backed valid/invalid verdict, not "this looks
|
||||
plausible."
|
||||
|
||||
1. **Fetch the advisory.** `gh api /repos/{owner}/{repo}/security-advisories/{ghsa_id}`
|
||||
gives you the reporter's summary, description, severity, CVSS, CWEs, and state
|
||||
(`triage`/`draft`/`published`/`closed`). Read the whole thing before touching code.
|
||||
|
||||
2. **Confirm you're looking at current code, exactly.** Fetch the branch the
|
||||
report claims to target (`git fetch origin main`, or whatever branch is
|
||||
relevant) and require a clean worktree (`git status --porcelain` empty).
|
||||
Then compare revisions exactly: `git rev-parse HEAD` must equal
|
||||
`git rev-parse origin/main`. An ancestor check alone (`git merge-base
|
||||
--is-ancestor origin/main HEAD`) isn't enough. It passes even if
|
||||
`origin/main` is stale relative to the actual remote, or if your checkout
|
||||
has extra local commits the report never saw, and it doesn't catch
|
||||
uncommitted changes either. A "verified" conclusion built on the wrong code
|
||||
state isn't a verification at all, so make the exact check a reflex.
|
||||
|
||||
3. **Trace the full path, not just the quoted lines.** Reporters usually quote two
|
||||
or three lines as evidence. Read those lines for real, but don't stop there.
|
||||
Follow the code end to end: where does the request enter, what's the actual
|
||||
vulnerable logic, and how does the response or exception actually get back to
|
||||
the caller? That last part is often what reports skip, and a bug that looks
|
||||
real at the "sink" can turn out to be redacted, caught, or rate-limited
|
||||
somewhere in the response path the reporter never looked at. In this codebase
|
||||
specifically, API exceptions flow through `Box\Mod\Api\Controller\Client::tryCall()`
|
||||
into `FOSSBilling\Http\ApiResponseFactory`, which is worth checking on any
|
||||
report about information disclosure via error messages. Also check whatever
|
||||
the report claims is *missing* (rate limiting, CAPTCHA, timing normalization,
|
||||
authorization checks) by grepping/reading for it yourself. Absence claims are
|
||||
exactly the kind of thing worth double-checking, not assuming.
|
||||
|
||||
4. **Pull related history for context.** Query every advisory state
|
||||
explicitly rather than trusting an unfiltered call: `gh api --method GET
|
||||
/repos/{owner}/{repo}/security-advisories -f state=<published|draft|triage|closed>
|
||||
--paginate`, once per state. A maintainer-authenticated session may find the
|
||||
unfiltered call already returns everything (it did when this was tested
|
||||
directly against this repo), but that can depend on token permissions, so
|
||||
don't rely on it. `--method GET` matters here: `gh api` defaults to POST
|
||||
whenever you pass `-f` without it, which for this endpoint hits the
|
||||
advisory-*creation* path instead and fails outright, so it's worth getting
|
||||
right the first time rather than debugging a confusing error. Skim titles
|
||||
for anything thematically close to the current report and read the close
|
||||
ones in full. This surfaces real things: a prior closed report about a
|
||||
related bug class, or a PR that hardened sibling code but happened to miss
|
||||
the exact path being reported now. That kind of context turns "here's a
|
||||
bug" into "here's why it slipped through," which is both more convincing
|
||||
and useful for Stage 2's Details section later.
|
||||
|
||||
5. **If unit tests exist for the relevant code, read them.** They're often the
|
||||
fastest, most authoritative way to confirm exact behavior (exception messages,
|
||||
config-flag semantics, order of operations) instead of inferring it from
|
||||
reading application code alone.
|
||||
|
||||
6. **Conclude with an explicit verdict**, laid out so each of the reporter's
|
||||
claims is checked against a specific file:line, not just asserted as "confirmed."
|
||||
A table works well for this. If something in the report doesn't hold up
|
||||
(wrong line numbers, a claimed-missing mitigation that actually exists,
|
||||
a severity that seems off), say so plainly. The point of this stage is an
|
||||
honest verdict, not validating the reporter's framing.
|
||||
|
||||
**Confidentiality note:** if the advisory is unpublished (`published_at: null`,
|
||||
state `triage`/`draft`), treat the GHSA ID and exploit details as sensitive
|
||||
everywhere, not just in committed artifacts. Don't put them in public commit
|
||||
messages, PR titles/bodies, branch names, or CI run titles; this has actually
|
||||
leaked before. The same caution applies to chat, code review comments, and any
|
||||
other tool output: only share specifics with a requester and channel you've
|
||||
actually confirmed are private to this advisory's disclosure. Keep working
|
||||
notes in a scratchpad or a private location instead.
|
||||
|
||||
## Stage 2: Refine the advisory and apply it
|
||||
|
||||
Only start this once Stage 1 has produced a real "yes, this is valid" verdict.
|
||||
|
||||
### Use the house style, and keep it current
|
||||
|
||||
`references/house-style.md` already has the FOSSBilling template (section
|
||||
order, per-section rules, voice, CVSS/CWE/title conventions), derived by
|
||||
sampling this repo's own *published* advisories rather than assuming the
|
||||
reporter's raw submission format is the target. Read it before drafting
|
||||
anything; it holds the detail so this file doesn't have to repeat it.
|
||||
|
||||
It's a living document, not a fixed spec. If a new advisory surfaces
|
||||
something it doesn't cover, or the user corrects an assumption it makes,
|
||||
update it in place and log the change under its Revisions section, the same
|
||||
way it got built in the first place. If you ever suspect it's drifted from
|
||||
reality (a new published advisory breaks one of its rules), re-derive by
|
||||
sampling directly:
|
||||
|
||||
```shell
|
||||
gh api /repos/{owner}/{repo}/security-advisories --paginate
|
||||
# then fetch several state=published ones in full and diff their structure
|
||||
```
|
||||
|
||||
### Work section by section
|
||||
|
||||
Draft one section, show it, take the feedback, apply it, move to the next.
|
||||
Don't write the whole thing and ask for one blanket approval. By the time you've
|
||||
written seven sections, an early wrong assumption has propagated through all of
|
||||
them, and the user has to hold the whole draft in their head to catch it. A
|
||||
tight loop on individual sections is much cheaper to fix and cheaper to review.
|
||||
|
||||
Things worth doing before the user has to point them out, because the pattern
|
||||
already caught real mistakes when it wasn't done:
|
||||
|
||||
- **Verify every factual claim in the writeup against real code or tests**,
|
||||
don't just write what sounds plausible and flag it for the user to check.
|
||||
Config key names, exact admin-UI label text, and whether a suggested
|
||||
workaround genuinely behaves as described are all things you can grep and
|
||||
read yourself. If you catch yourself writing "I believe this is called X,"
|
||||
stop and go check.
|
||||
- **Ground "Affected Versions" in real git history**, not guesswork. Reporters
|
||||
usually only tested the current release. `git fetch --unshallow` first if
|
||||
the checkout is shallow (cheap, do it by default, worth checking with
|
||||
`git rev-parse --is-shallow-repository`), then `git log -S"<distinguishing
|
||||
string>" -- <file>` to find where the vulnerable logic actually appeared.
|
||||
Also check whether a later, unrelated hardening pass should have covered
|
||||
this exact path but missed it. That's both a strong version-range signal
|
||||
and good material for the Details section. If history hits a genuine wall
|
||||
(a rename/rewrite commit that shows the whole file as newly added) and the
|
||||
pattern already existed at that wall, that's a legitimate stopping point:
|
||||
state the range as confirmed rather than manufacturing false precision by
|
||||
digging further with no possible payoff.
|
||||
- **Compute CVSS with an actual tool**, not by hand. CVSS v4 in particular is
|
||||
scored via a nested lookup table (the "macrovector"), not a simple formula,
|
||||
and is easy to get wrong from memory. Python's `cvss` package
|
||||
(`from cvss import CVSS4`) is normally available; use it. Reason through
|
||||
each base metric explicitly before computing (see `references/house-style.md`
|
||||
for FOSSBilling's specific answer on which CVSS version this repo publishes).
|
||||
- **Don't restate CVSS in the description body.** It belongs solely in the
|
||||
advisory's dedicated `cvss_vector_string` field.
|
||||
- **Pick CWEs by mechanism, not vibes.** A message-content oracle and a
|
||||
timing oracle are different CWEs (204 vs. 208) even though they're both
|
||||
"enumeration" bugs in casual conversation. Get the mechanism right. Check
|
||||
whether a similar past advisory in this repo used a particular CWE pairing
|
||||
worth following as precedent.
|
||||
|
||||
### Voice
|
||||
|
||||
Technical but approachable, precise on file paths/method names/code, not
|
||||
written like a jargon-dense internal analyst note. See `references/house-style.md`
|
||||
for the specifics (jargon substitutions, AI-writing tics to avoid, how to open
|
||||
the Impact section). Worth internalizing this one before drafting: it's the
|
||||
kind of thing the user will keep correcting a little at a time if you don't.
|
||||
|
||||
### Apply it, but only once approved
|
||||
|
||||
Applying a refined advisory is normally two separate PATCH calls, and it's
|
||||
easy to only remember the first:
|
||||
|
||||
1. The top-level fields: `summary` (title), `description` (the Markdown
|
||||
sections above), `cwe_ids`, and either `cvss_vector_string` or `severity`
|
||||
(the API rejects a request setting both).
|
||||
2. The separate structured `vulnerabilities` field: an array, not a single
|
||||
object. Don't assume index `[0]` is the whole story; check how many
|
||||
entries actually exist. FOSSBilling is one application rather than a
|
||||
multi-package repo, so one entry is the norm here, but confirm it rather
|
||||
than assume it. See `references/house-style.md` for what belongs in each
|
||||
entry and the ecosystem-enum gotcha that will otherwise produce a
|
||||
confusing 422.
|
||||
|
||||
Treat `gh api --method PATCH .../security-advisories/{ghsa_id}` as a real,
|
||||
external, semi-irreversible action, even while the advisory is still
|
||||
unpublished/triage. Only run it after the user has explicitly said to apply
|
||||
the changes, not proactively once you think it's ready. If they've approved
|
||||
individual sections along the way, that's still not the same as approval to
|
||||
push to the live advisory; ask once the full set of changes is ready to go.
|
||||
@@ -0,0 +1,295 @@
|
||||
# FOSSBilling security advisory house style
|
||||
|
||||
Derived by sampling this repo's own *published* advisories directly (not
|
||||
assumed), originally from 6/6 that were 100% consistent on structure:
|
||||
GHSA-3g7m-ph2r-jhcx, GHSA-rrp7-fvc6-xxrw, GHSA-cqqm-p3x5-9fqg,
|
||||
GHSA-x7p2-xhvc-cfp9, GHSA-q4rq-9844-r9w2, GHSA-5493-9m76-2qrr.
|
||||
|
||||
This is a *living* reference: it should keep accumulating rules and
|
||||
revisions as more advisories get triaged, the same way it was built in the
|
||||
first place. If the user corrects something during a future advisory, update
|
||||
this file, don't just fix that one draft and move on. Log the change under
|
||||
Revisions at the bottom so the reasoning behind a rule stays visible, not
|
||||
just the rule itself.
|
||||
|
||||
This is distinct from the *reporter's* raw submission format (GitHub's
|
||||
default Summary/Details/PoC/Impact template): this file describes the
|
||||
maintainer-authored rewrite that actually gets published.
|
||||
|
||||
## Section order
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
## Impact
|
||||
## Affected Versions
|
||||
## Patched Versions
|
||||
## Details
|
||||
## Workarounds
|
||||
## Acknowledgements
|
||||
```
|
||||
|
||||
### Summary
|
||||
1-3 sentences: what the flaw is, where it lives (endpoint/class/method), and
|
||||
the immediate consequence. State the mechanism, not just the outcome label.
|
||||
|
||||
### Impact
|
||||
Lead with one or two plain-language sentences stating the real-world effect,
|
||||
what a person or the business actually experiences, before any technical
|
||||
framing. Don't open with a CIA-triad label ("Confidentiality: ...") as the
|
||||
first thing the reader sees; save classification framing, if used at all,
|
||||
for after the plain statement. Then list realistic follow-on scenarios in
|
||||
concrete terms (what an attacker would actually do with this), not the
|
||||
abstract class of bug.
|
||||
|
||||
### Affected Versions
|
||||
Keep this to a single brief version-range line, nothing else:
|
||||
- `FOSSBilling version 0.1.0 through 0.7.2 (all releases to date).`
|
||||
- `FOSSBilling versions >= X and <= 0.7.2.`
|
||||
|
||||
Required, not optional: confirm this range to a reasonable degree via code
|
||||
and git history before writing it down. Don't just trust the reporter's
|
||||
guess; reporters usually only tested the current release. In practice:
|
||||
- `git log -S"<distinguishing string/symbol>" -- <file>` to find when the
|
||||
vulnerable logic was introduced.
|
||||
- Unshallow the clone first if needed (`git fetch --unshallow`). A shallow
|
||||
worktree will silently truncate the search at its shallow boundary and
|
||||
give a false "this is where it was introduced" answer. Check with
|
||||
`git rev-parse --is-shallow-repository`. This is cheap (took under a
|
||||
minute the one time it was needed) and worth doing by default.
|
||||
- Check whether a later, unrelated hardening/refactor pass should have
|
||||
covered this code path but didn't. That's a strong signal for the true
|
||||
affected range, and good Details material (see the worked example below).
|
||||
- If history genuinely can't be pinned down past a certain point (e.g. a
|
||||
rename/rewrite commit that shows the whole file as newly added), that's a
|
||||
legitimate place to stop. State the range as bounded by what's confirmed
|
||||
rather than chasing false precision that can't change the answer anyway.
|
||||
|
||||
Put any narrative about *why* that range is what it is in **Details**, not
|
||||
here. This field is a fact, not an explanation.
|
||||
|
||||
### Patched Versions
|
||||
Just the version, or `TBD` if no fix exists yet. Don't write a sentence
|
||||
here either:
|
||||
- `0.8.7`
|
||||
- `TBD`
|
||||
|
||||
### Details
|
||||
Technical root-cause walkthrough:
|
||||
- Exact vulnerable entry point(s), by file/method.
|
||||
- The specific logic responsible (quote it).
|
||||
- Contrast with sibling code that handles the same concern correctly.
|
||||
FOSSBilling's own advisories consistently point out when "the fix pattern
|
||||
already exists elsewhere in the codebase"; it reads as more convincing
|
||||
than describing the bug in isolation. Name the sibling by file and method
|
||||
only, never by another advisory's GHSA tag (short tags like "GHSA-xxxx"
|
||||
are not valid references). If another advisory truly must be named, use
|
||||
its full ID, but prefer leaving the cross-reference out entirely.
|
||||
- History where relevant: which PR/commit introduced the gap, or which
|
||||
later hardening pass should have covered this path but missed it. This
|
||||
turns "here's a bug" into "here's why it slipped through," which reads as
|
||||
far more credible and helps prevent recurrence.
|
||||
- Keep it tight: one code quote is usually enough, and skip the response-code
|
||||
path unless it is load-bearing for the verdict. Compress history to a
|
||||
single closing paragraph and don't re-explain what Summary or Impact
|
||||
already stated. A first draft through this process was cut roughly in half
|
||||
on review for exactly these reasons.
|
||||
|
||||
### Workarounds
|
||||
Practical steps an operator can take before a patch ships. If there is no
|
||||
real workaround, say so plainly ("There is no complete workaround without
|
||||
patching") and then list partial mitigations (WAF/reverse-proxy rate
|
||||
limiting, disabling the affected feature, config hardening).
|
||||
Keep it to two or three sentences: the plain statement, the incident
|
||||
response steps, then partial mitigations. Fold side effects (such as logging
|
||||
out the legitimate user too) into a single clause rather than a separate
|
||||
explanation.
|
||||
|
||||
### Acknowledgements
|
||||
`Thanks to @<reporter> for responsibly reporting this vulnerability.`
|
||||
Exact phrasing used in every sample checked; keep it consistent.
|
||||
Always include the `@` so GitHub links the reporter's account.
|
||||
|
||||
## Voice
|
||||
|
||||
Technical but approachable. Stay precise on file paths, method names, and
|
||||
code (don't soften those), but avoid security-jargon labels and
|
||||
internal-analyst asides where a plain description works ("the existence
|
||||
check" over "sink", "how the message reaches the attacker" over "why the
|
||||
message reaches the attacker verbatim", no "noted in passing"-style
|
||||
meta-commentary). The reader is a technical person, not necessarily a
|
||||
security specialist.
|
||||
|
||||
Avoid em dashes and other tics that read as AI-generated: triads of
|
||||
adjectives, "it's not just X, it's Y" constructions, excessive bolded
|
||||
lead-ins. Rewrite with plain periods/commas or restructure the sentence
|
||||
instead.
|
||||
|
||||
## Severity / CVSS
|
||||
|
||||
This repo scores **CVSS v4 only**. On a GET, every published advisory
|
||||
checked has a null `cvss_severities.cvss_v3` and a populated
|
||||
`cvss_severities.cvss_v4` (GitHub nests both under `cvss_severities` on
|
||||
read); don't carry over a v3.1 vector just because the reporter submitted
|
||||
one. Do NOT restate the vector or score anywhere in the description body.
|
||||
On a PATCH, it's a different shape than the read: the top-level
|
||||
`cvss_vector_string` field, not the nested read path. The API also rejects
|
||||
a request that sets both `severity` and `cvss_vector_string` at once; pick
|
||||
one (this repo's convention is to always send `cvss_vector_string` and let
|
||||
GitHub derive `severity` from it).
|
||||
|
||||
Compute the score with a real tool rather than by hand. CVSS v4's base
|
||||
score comes from a nested lookup table (the "macrovector"), not a formula,
|
||||
and is easy to get wrong from memory. Python's `cvss` package supports it;
|
||||
the vector string below is just an example of the syntax, not a default to
|
||||
reuse:
|
||||
|
||||
```python
|
||||
from cvss import CVSS4
|
||||
c = CVSS4("CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N")
|
||||
c.base_score, c.severity
|
||||
```
|
||||
|
||||
Reason through each metric explicitly for the actual bug in front of you
|
||||
before computing, rather than guessing the vector wholesale:
|
||||
- `AV` (Attack Vector, N/Adjacent/Local/Physical): how does the attacker
|
||||
reach the vulnerable component?
|
||||
- `AC` (Attack Complexity): does the attacker need to actively evade some
|
||||
built-in defense to get a working exploit, or does it just work?
|
||||
- `AT` (Attack Requirements, new in v4, distinct from AC): does exploitation
|
||||
need a specific pre-existing state (a race window, being on-path, prior
|
||||
reconnaissance)? Most straightforward unauthenticated endpoint bugs are
|
||||
`AT:N`.
|
||||
- `PR` (Privileges Required) / `UI` (User Interaction): self-explanatory.
|
||||
- `VC`/`VI`/`VA` (Confidentiality/Integrity/Availability impact to the
|
||||
**Vulnerable** System): what does this specific vulnerability actually
|
||||
expose or corrupt on the system where it lives?
|
||||
- `SC`/`SI`/`SA` (impact to a **Subsequent** System): only non-`N` when the
|
||||
impact genuinely extends to a separate downstream system, not just
|
||||
"an attacker could theoretically use this to attack something else later."
|
||||
|
||||
## CWEs
|
||||
|
||||
Pick by the precise technical mechanism, not the closest-sounding label.
|
||||
CWE-204 (Observable Response Discrepancy, a message/content oracle) and
|
||||
CWE-208 (Observable Timing Discrepancy) are both "enumeration" bugs in
|
||||
casual conversation but are different root causes; don't default to
|
||||
whichever sounds closer. Check whether a similar past advisory in this repo
|
||||
used a particular CWE pairing worth following as precedent (this repo has
|
||||
done that: an oracle-endpoint-plus-weak-rate-limiting bug used CWE-204
|
||||
paired with CWE-307).
|
||||
|
||||
## Title
|
||||
|
||||
Avoid restating a CWE's official name as the title (e.g. don't write "via
|
||||
observable response discrepancy" just because that's literally what
|
||||
CWE-204 is called; it reads as jargon lifted from a taxonomy rather than a
|
||||
plain description of the bug). Match the plain subject/verb/object pattern
|
||||
used throughout this repo's real titles, e.g. "Unauthenticated X via Y
|
||||
endpoint" or "Missing Z allows W". Use the project's own domain vocabulary
|
||||
for entities: this app calls a customer record a "client" (`Box\Mod\Client`)
|
||||
rather than a generic "user" or "account", so prefer that term where it
|
||||
fits naturally.
|
||||
|
||||
## Applying changes to the live advisory
|
||||
|
||||
Two PATCH calls' worth of fields, and it's easy to only remember the prose:
|
||||
|
||||
1. **Top-level fields**: `summary` (title), `description` (the Markdown
|
||||
sections above), `cwe_ids`, and either `cvss_vector_string` or `severity`
|
||||
(the API rejects setting both at once; see Severity / CVSS above).
|
||||
2. A **separate structured field**, `vulnerabilities`: an array, not a
|
||||
single object. Don't assume index `[0]` is the whole story; check how
|
||||
many entries actually exist. FOSSBilling is a single application rather
|
||||
than a multi-package repo, so one entry is the norm, but confirm it. Each
|
||||
entry is shaped `package.ecosystem` / `package.name` /
|
||||
`vulnerable_version_range` / `patched_versions` / `vulnerable_functions`,
|
||||
which is what machine-readable consumers (OSV, Dependabot-style tooling)
|
||||
actually key off. It's easy to leave this silently inheriting whatever
|
||||
the original reporter typed if you only ever edit the prose; explicitly
|
||||
review and PATCH it too, every time, even if the values turn out
|
||||
unchanged from the reporter's submission. That should be a deliberate
|
||||
confirmation, not an accident of omission. When you do PATCH it, send the
|
||||
complete object for each entry, including `vulnerable_functions` even
|
||||
when it's an empty array, not just the one field you're changing; treat
|
||||
it as a full replacement rather than assume it merges, so anything you
|
||||
omit is anything you erase.
|
||||
|
||||
**Ecosystem enum gotcha:** FOSSBilling is an application, not a
|
||||
package-registry library, so the correct `package.ecosystem` value is the
|
||||
literal string `other`. The write API rejects an empty string outright
|
||||
(422: `` `` is not a possible value ``); the valid enum is `rubygems, npm,
|
||||
pip, maven, nuget, composer, go, rust, erlang, actions, pub, other, swift`.
|
||||
`package.name` stays `FOSSBilling` (not blank); every published advisory
|
||||
checked uses that name.
|
||||
|
||||
## Worked example (provenance for the rules above)
|
||||
|
||||
An earlier advisory triaged through this process is where most of these
|
||||
rules were established (kept unnamed here since it was still unpublished at
|
||||
the time of writing; see this repo's private working notes from that triage
|
||||
if you need the specifics):
|
||||
|
||||
- The Affected Versions git-archaeology rule came from tracing a reported
|
||||
bug back through an old rename commit, and separately discovering (via
|
||||
`git log -S` on the relevant symbol) that an unrelated PR had added a
|
||||
mitigation to sibling code in the same class but missed the exact path
|
||||
being reported, which became both the version-range justification and the
|
||||
strongest paragraph in Details.
|
||||
- The "verify every factual claim" rule came from initially writing a
|
||||
Workarounds bullet that described a config toggle's UI label from memory;
|
||||
it was wrong, and grepping the actual Twig template caught it.
|
||||
- The CVSS-v4-only and ecosystem-enum facts above came from directly
|
||||
inspecting other published advisories' API responses rather than assuming.
|
||||
|
||||
## Revisions
|
||||
|
||||
- v1: initial template derived from 6 published advisories.
|
||||
- v1.1: "Affected Versions"/"Patched Versions" corrected to single-line,
|
||||
fact-only fields (no inline narrative; narrative belongs in Details).
|
||||
- v1.2: confirmed CVSS is never restated in the body.
|
||||
- v1.3: added the Voice section after feedback that a draft read as
|
||||
slightly over-technical; reworded jargon labels ("Sink" → "the existence
|
||||
check") and dropped internal-analyst-note phrasing.
|
||||
- v1.4: Impact rewritten to lead with plain-language real-world effect
|
||||
instead of a CIA-triad-labeled bullet list.
|
||||
- v1.5: confirmed git/code-history verification of Affected Versions is
|
||||
required, not optional, to a reasonable (not unlimited) degree.
|
||||
- v1.6: added em-dash/AI-tic avoidance to Voice. Added the structured
|
||||
`vulnerabilities` API field as a required, separate PATCH step from the
|
||||
description prose, plus the `ecosystem: "other"` enum gotcha.
|
||||
- v1.7: caught the skill itself violating its own em-dash/filler-word rule
|
||||
throughout (SKILL.md and this file). Rewritten. Lesson: apply the Voice
|
||||
section to the skill's own prose, not just to advisory drafts it produces.
|
||||
- v1.8: after external review, corrected several claims that hadn't been
|
||||
verified directly. CVSS section now distinguishes the nested read path
|
||||
(`cvss_severities.cvss_v3`/`cvss_v4`) from the flat write field
|
||||
(`cvss_vector_string`), and notes `severity`/`cvss_vector_string` are
|
||||
mutually exclusive on write. The live-advisory PATCH checklist now lists
|
||||
the actual top-level fields (`summary`, `description`, `cwe_ids`,
|
||||
`cvss_vector_string`/`severity`) instead of just "the description body",
|
||||
notes `vulnerabilities` is an array (don't assume `[0]` is everything),
|
||||
and says to send the complete object per entry on write rather than a
|
||||
partial one. Confirmed directly against this repo that an unfiltered
|
||||
`security-advisories` call already returns every state for a
|
||||
maintainer-authenticated token, but the guidance still recommends explicit
|
||||
per-state queries for portability, plus the `gh api -f` without
|
||||
`--method GET` footgun (silently POSTs to the advisory-creation endpoint
|
||||
instead) is now called out explicitly, since it's real and easy to hit.
|
||||
- v1.9: the v1.8 "send the complete object" instruction listed only four
|
||||
`vulnerabilities` entry fields and omitted `vulnerable_functions`, which
|
||||
is a real field on the object (this session's own successful PATCH sent
|
||||
it as `[]`). Following the v1.8 guidance literally would have wiped
|
||||
`vulnerable_functions` on any advisory where it's populated. Added it to
|
||||
the field list.
|
||||
- v1.10: Acknowledgements now require the `@` mention prefix
|
||||
(`Thanks to @<reporter> ...`) per user correction and GitHub
|
||||
user-linking conventions.
|
||||
- v1.11: Details and Workarounds now carry brevity rules after review
|
||||
feedback cut a first draft roughly in half (one code quote, no
|
||||
non-load-bearing response path, history in one closing paragraph;
|
||||
Workarounds in two or three sentences with side effects in a clause).
|
||||
- v1.12: Details must contrast sibling code by file and method, never by
|
||||
another advisory's short GHSA tag (not a valid reference); full ID only
|
||||
if a cross-reference is unavoidable, preferably omitted. Caught during
|
||||
review of a draft that cited "GHSA-xxxx" for the sibling global-counter
|
||||
fix.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -1,7 +1,7 @@
|
||||
name: phpmyadmin
|
||||
repository: ddev/ddev-phpmyadmin
|
||||
version: v1.0.0
|
||||
install_date: "2025-08-24T12:09:35-07:00"
|
||||
version: v1.0.3
|
||||
install_date: "2026-09-11T12:34:11+01:00"
|
||||
project_files:
|
||||
- docker-compose.phpmyadmin.yaml
|
||||
- docker-compose.phpmyadmin_norouter.yaml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
## #ddev-generated: If you want to edit and own this file, remove this line.
|
||||
## Description: Launch a browser with PhpMyAdmin
|
||||
|
||||
+4
-265
@@ -1,12 +1,11 @@
|
||||
name: fossbilling.dev
|
||||
name: fossbilling
|
||||
type: php
|
||||
docroot: src
|
||||
php_version: "8.3"
|
||||
nodejs_version: "24"
|
||||
webserver_type: nginx-fpm
|
||||
xdebug_enabled: false
|
||||
additional_hostnames: []
|
||||
additional_fqdns: []
|
||||
additional_hostnames: ["fossbilling.dev"]
|
||||
database:
|
||||
type: mariadb
|
||||
version: "11.8"
|
||||
@@ -15,131 +14,17 @@ hooks:
|
||||
- exec: .ddev/post-start.sh
|
||||
upload_dirs:
|
||||
- data/uploads
|
||||
- ../node_modules
|
||||
use_dns_when_possible: true
|
||||
composer_version: "2"
|
||||
web_environment:
|
||||
- IS_DDEV=true
|
||||
- APP_ENV=dev
|
||||
corepack_enable: false
|
||||
omit_containers: ["ddev-ssh-agent"]
|
||||
|
||||
# Key features of DDEV's config.yaml:
|
||||
|
||||
# name: <projectname> # Name of the project, automatically provides
|
||||
# http://projectname.ddev.site and https://projectname.ddev.site
|
||||
# If the name is omitted, the project will take the name of the enclosing directory,
|
||||
# which is useful if you want to have a copy of the project side by side with this one.
|
||||
|
||||
# type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
|
||||
# See https://docs.ddev.com/en/stable/users/quickstart/ for more
|
||||
# information on the different project types
|
||||
|
||||
# docroot: <relative_path> # Relative path to the directory containing index.php.
|
||||
|
||||
# php_version: "8.4" # PHP version to use, "5.6" through "8.5"
|
||||
|
||||
# You can explicitly specify the webimage but this
|
||||
# is not recommended, as the images are often closely tied to DDEV's' behavior,
|
||||
# so this can break upgrades.
|
||||
|
||||
# webimage: <docker_image>
|
||||
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
|
||||
# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.*
|
||||
|
||||
# database:
|
||||
# type: <dbtype> # mysql, mariadb, postgres
|
||||
# version: <version> # database version, like "10.11" or "8.0"
|
||||
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8
|
||||
# MySQL versions can be 5.5-8.0, 8.4
|
||||
# PostgreSQL versions can be 9-18
|
||||
|
||||
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
|
||||
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
|
||||
|
||||
# xdebug_enabled: false # Set to true to enable Xdebug and "ddev start" or "ddev restart"
|
||||
# Note that for most people the commands
|
||||
# "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better,
|
||||
# as leaving Xdebug enabled all the time is a big performance hit.
|
||||
|
||||
# xhgui_http_port: "8143"
|
||||
# xhgui_https_port: "8142"
|
||||
# The XHGui ports can be changed from the default 8143 and 8142
|
||||
# Very rarely used
|
||||
|
||||
# host_xhgui_port: "8142"
|
||||
# Can be used to change the host binding port of the XHGui
|
||||
# application. Rarely used; only when port conflict and
|
||||
# bind_all_ports is used (normally with router disabled)
|
||||
|
||||
# xhprof_mode: [prepend|xhgui|global]
|
||||
# Default is "xhgui"
|
||||
|
||||
# webserver_type: nginx-fpm, apache-fpm, generic
|
||||
|
||||
# timezone: Europe/Berlin
|
||||
# If timezone is unset, DDEV will attempt to derive it from the host system timezone
|
||||
# using the $TZ environment variable or the /etc/localtime symlink.
|
||||
# This is the timezone used in the containers and by PHP;
|
||||
# it can be set to any valid timezone,
|
||||
# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
# For example Europe/Dublin or MST7MDT
|
||||
|
||||
# composer_root: <relative_path>
|
||||
# Relative path to the Composer root directory from the project root. This is
|
||||
# the directory which contains the composer.json and where all Composer related
|
||||
# commands are executed.
|
||||
|
||||
# composer_version: "2"
|
||||
# You can set it to "" or "2" (default) for Composer v2
|
||||
# to use the latest major version available at the time your container is built.
|
||||
# It is also possible to use each other Composer version channel. This includes:
|
||||
# - 2.2 (latest Composer LTS version)
|
||||
# - stable
|
||||
# - preview
|
||||
# - snapshot
|
||||
# Alternatively, an explicit Composer version may be specified, for example "2.2.18".
|
||||
# To reinstall Composer after the image was built, run "ddev utility rebuild".
|
||||
|
||||
# nodejs_version: "24"
|
||||
# change from the default system Node.js version to any other version.
|
||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
|
||||
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
|
||||
|
||||
# corepack_enable: false
|
||||
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
|
||||
|
||||
# additional_hostnames:
|
||||
# - somename
|
||||
# - someothername
|
||||
# would provide http and https URLs for "somename.ddev.site"
|
||||
# and "someothername.ddev.site".
|
||||
|
||||
# additional_fqdns:
|
||||
# - example.com
|
||||
# - sub1.example.com
|
||||
# would provide http and https URLs for "example.com" and "sub1.example.com"
|
||||
# Please take care with this because it can cause great confusion.
|
||||
|
||||
# upload_dirs: "custom/upload/dir"
|
||||
#
|
||||
# upload_dirs:
|
||||
# - custom/upload/dir
|
||||
# - ../private
|
||||
#
|
||||
# would set the destination paths for ddev import-files to <docroot>/custom/upload/dir
|
||||
# When Mutagen is enabled this path is bind-mounted so that all the files
|
||||
# in the upload_dirs don't have to be synced into Mutagen.
|
||||
|
||||
# disable_upload_dirs_warning: false
|
||||
# If true, turns off the normal warning that says
|
||||
# "You have Mutagen enabled and your 'php' project type doesn't have upload_dirs set"
|
||||
|
||||
# ddev_version_constraint: ""
|
||||
# Example:
|
||||
# ddev_version_constraint: ">= 1.24.8"
|
||||
# This will enforce that the running ddev version is within this constraint.
|
||||
# See https://github.com/Masterminds/semver#checking-version-constraints for
|
||||
# supported constraint formats
|
||||
|
||||
# working_dir:
|
||||
# web: /var/www/html
|
||||
# db: /home
|
||||
@@ -147,153 +32,7 @@ corepack_enable: false
|
||||
# These values specify the destination directory for ddev ssh and the
|
||||
# directory in which commands passed into ddev exec are run.
|
||||
|
||||
# omit_containers: [db, ddev-ssh-agent]
|
||||
# Currently only these containers are supported. Some containers can also be
|
||||
# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit
|
||||
# the "db" container, several standard features of DDEV that access the
|
||||
# database container will be unusable. In the global configuration it is also
|
||||
# possible to omit ddev-router, but not here.
|
||||
|
||||
# performance_mode: "global"
|
||||
# DDEV offers performance optimization strategies to improve the filesystem
|
||||
# performance depending on your host system. Should be configured globally.
|
||||
#
|
||||
# If set, will override the global config. Possible values are:
|
||||
# - "global": uses the value from the global config.
|
||||
# - "none": disables performance optimization for this project.
|
||||
# - "mutagen": enables Mutagen for this project.
|
||||
#
|
||||
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
|
||||
|
||||
# fail_on_hook_fail: False
|
||||
# Decide whether 'ddev start' should be interrupted by a failing hook
|
||||
|
||||
# host_https_port: "59002"
|
||||
# The host port binding for https can be explicitly specified. It is
|
||||
# dynamic unless otherwise specified.
|
||||
# This is not used by most people, most people use the *router* instead
|
||||
# of the localhost port.
|
||||
|
||||
# host_webserver_port: "59001"
|
||||
# The host port binding for the ddev-webserver can be explicitly specified. It is
|
||||
# dynamic unless otherwise specified.
|
||||
# This is not used by most people, most people use the *router* instead
|
||||
# of the localhost port.
|
||||
|
||||
# host_db_port: "59002"
|
||||
# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic
|
||||
# unless explicitly specified.
|
||||
|
||||
# mailpit_http_port: "8025"
|
||||
# mailpit_https_port: "8026"
|
||||
# The Mailpit ports can be changed from the default 8025 and 8026
|
||||
|
||||
# host_mailpit_port: "8025"
|
||||
# The mailpit port is not normally bound on the host at all, instead being routed
|
||||
# through ddev-router, but it can be bound directly to localhost if specified here.
|
||||
|
||||
# webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
|
||||
# Extra Debian packages that are needed in the webimage can be added here
|
||||
|
||||
# dbimage_extra_packages: [netcat, telnet, sudo]
|
||||
# Extra Debian packages that are needed in the dbimage can be added here
|
||||
|
||||
# use_dns_when_possible: true
|
||||
# If the host has internet access and the domain configured can
|
||||
# successfully be looked up, DNS will be used for hostname resolution
|
||||
# instead of editing /etc/hosts
|
||||
# Defaults to true
|
||||
|
||||
# project_tld: ddev.site
|
||||
# The top-level domain used for project URLs
|
||||
# The default "ddev.site" allows DNS lookup via a wildcard
|
||||
|
||||
# share_default_provider: ngrok
|
||||
# The default share provider to use for "ddev share"
|
||||
# Defaults to global configuration, usually "ngrok"
|
||||
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
|
||||
|
||||
# share_provider_args: --basic-auth username:pass1234
|
||||
# Provide extra flags to the share provider script
|
||||
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
|
||||
|
||||
# disable_settings_management: false
|
||||
# If true, DDEV will not create CMS-specific settings files like
|
||||
# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php
|
||||
# In this case the user must provide all such settings.
|
||||
|
||||
# You can inject environment variables into the web container with:
|
||||
# web_environment:
|
||||
# - SOMEENV=somevalue
|
||||
# - SOMEOTHERENV=someothervalue
|
||||
|
||||
# no_project_mount: false
|
||||
# (Experimental) If true, DDEV will not mount the project into the web container;
|
||||
# the user is responsible for mounting it manually or via a script.
|
||||
# This is to enable experimentation with alternate file mounting strategies.
|
||||
# For advanced users only!
|
||||
|
||||
# bind_all_interfaces: false
|
||||
# If true, host ports will be bound on all network interfaces,
|
||||
# not the localhost interface only. This means that ports
|
||||
# will be available on the local network if the host firewall
|
||||
# allows it.
|
||||
|
||||
# default_container_timeout: 120
|
||||
# The default time that DDEV waits for all containers to become ready can be increased from
|
||||
# the default 120. This helps in importing huge databases, for example.
|
||||
|
||||
#web_extra_exposed_ports:
|
||||
#- name: nodejs
|
||||
# container_port: 3000
|
||||
# http_port: 2999
|
||||
# https_port: 3000
|
||||
#- name: something
|
||||
# container_port: 4000
|
||||
# https_port: 4000
|
||||
# http_port: 3999
|
||||
# Allows a set of extra ports to be exposed via ddev-router
|
||||
# Fill in all three fields even if you don’t intend to use the https_port!
|
||||
# If you don’t add https_port, then it defaults to 0 and ddev-router will fail to start.
|
||||
#
|
||||
# The port behavior on the ddev-webserver must be arranged separately, for example
|
||||
# using web_extra_daemons.
|
||||
# For example, with a web app on port 3000 inside the container, this config would
|
||||
# expose that web app on https://<project>.ddev.site:9999 and http://<project>.ddev.site:9998
|
||||
# web_extra_exposed_ports:
|
||||
# - name: myapp
|
||||
# container_port: 3000
|
||||
# http_port: 9998
|
||||
# https_port: 9999
|
||||
|
||||
#web_extra_daemons:
|
||||
#- name: "http-1"
|
||||
# command: "/var/www/html/node_modules/.bin/http-server -p 3000"
|
||||
# directory: /var/www/html
|
||||
#- name: "http-2"
|
||||
# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000"
|
||||
# directory: /var/www/html
|
||||
|
||||
# override_config: false
|
||||
# By default, config.*.yaml files are *merged* into the configuration
|
||||
# But this means that some things can't be overridden
|
||||
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge
|
||||
# and you can't erase existing hooks or all environment variables.
|
||||
# However, with "override_config: true" in a particular config.*.yaml file,
|
||||
# 'use_dns_when_possible: false' can override the existing values, and
|
||||
# hooks:
|
||||
# post-start: []
|
||||
# or
|
||||
# web_environment: []
|
||||
# or
|
||||
# additional_hostnames: []
|
||||
# can have their intended affect. 'override_config' affects only behavior of the
|
||||
# config.*.yaml file it exists in.
|
||||
|
||||
# Many DDEV commands can be extended to run tasks before or after the
|
||||
# DDEV command is executed, for example "post-start", "post-import-db",
|
||||
# "pre-composer", "post-composer"
|
||||
# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more
|
||||
# information on the commands that can be extended and the tasks you can define
|
||||
# for them. Example:
|
||||
#hooks:
|
||||
@@ -2,7 +2,7 @@
|
||||
services:
|
||||
phpmyadmin:
|
||||
container_name: ddev-${DDEV_SITENAME}-phpmyadmin
|
||||
image: ${PHPMYADMIN_DOCKER_IMAGE:-phpmyadmin:5@sha256:5e2289bcd500868ed4ac9261ddd7cbcc6e20036f83031ade72667b15dca31c60}
|
||||
image: ${PHPMYADMIN_DOCKER_IMAGE:-phpmyadmin:5@sha256:e1d42a6985a9622e1e2461d31d2cea82fb069ee05194dbe2a6c758f86c9ef6b8}
|
||||
working_dir: "/root"
|
||||
restart: "no"
|
||||
labels:
|
||||
@@ -22,9 +22,6 @@ services:
|
||||
- UPLOAD_LIMIT=4000M
|
||||
- HTTP_EXPOSE=8036:80
|
||||
- HTTPS_EXPOSE=8037:80
|
||||
healthcheck:
|
||||
interval: 120s
|
||||
timeout: 2s
|
||||
retries: 1
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -1,16 +1,67 @@
|
||||
# Deny direct access to sensitive project files and directories.
|
||||
location = /config.php {
|
||||
return 403;
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny PHP scripts that are not web entry points (index.php, ipn.php, installer).
|
||||
# Exact matches take precedence over the PHP handler regardless of include order.
|
||||
location = /cron.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /console.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /load.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /di.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /config-sample.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /config.old.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location = /install/session.php {
|
||||
deny all;
|
||||
}
|
||||
|
||||
# Deny code directories. Prefix matches skip the PHP handler.
|
||||
location ^~ /library/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ^~ /modules/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ^~ /locale/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ^~ /install/sql/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ^~ /vendor/ {
|
||||
return 403;
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~* ^/themes/[^/]+/config/ {
|
||||
return 403;
|
||||
location ~* ^/themes/(?:[^/]+/)+config/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ^~ /data/ {
|
||||
return 403;
|
||||
deny all;
|
||||
}
|
||||
|
||||
location ~* (?:\.(?:twig|lock|old|ya?ml|htaccess|htpasswd)|~)$ {
|
||||
deny all;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 443 ssl;
|
||||
sendfile off;
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
# Set your domain name here
|
||||
server_name fossbilling.dev;
|
||||
|
||||
# Document root
|
||||
root /var/www/html/src;
|
||||
|
||||
# SSL Setup
|
||||
ssl_certificate /etc/ssl/certs/master.crt;
|
||||
ssl_certificate_key /etc/ssl/certs/master.key;
|
||||
|
||||
# Security Headers
|
||||
#add_header Strict-Transport-Security "max-age=31536000;" always;
|
||||
#add_header X-Content-Type-Options nosniff;
|
||||
|
||||
# Set the index
|
||||
index index.php;
|
||||
|
||||
# Block access to sensitive files
|
||||
location ~* .(htaccess|htpasswd|bak|conf|inc|ini|lock|log|old|sh|sql|twig|yam)$ {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Block access to hidden files except .well-known
|
||||
location ~ /\.(?!well-known\/) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Block access to theme configuration files
|
||||
location ~* ^/themes/[^/]+/config/ {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Deny access to runtime data.
|
||||
location ^~ /data/ {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
# PHP Handling
|
||||
location ~ ^/(index.php|ipn.php|install/index.php|install/install.php) {
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
|
||||
# On severs with multiple PHP versions the syntax the socket would likely be similar to "unix:/run/php/php8.4-fpm.sock;"
|
||||
fastcgi_pass unix:/run/php-fpm.sock;
|
||||
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_intercept_errors on;
|
||||
}
|
||||
|
||||
# Block PHP files
|
||||
location ~* .(php)$ {
|
||||
return 403;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[PHP]
|
||||
disable_functions = exec,passthru,system,shell_exec,popen,proc_open
|
||||
+5
-3
@@ -7,6 +7,7 @@ nodeBuildTimestamp="node_modules/package.stamp"
|
||||
|
||||
# Composer vars
|
||||
composerVendorPath="src/vendor"
|
||||
composerJson="composer.json"
|
||||
composerLock="composer.lock"
|
||||
customVendorTimestamp="src/vendor/composer.stamp"
|
||||
|
||||
@@ -23,13 +24,14 @@ if [ ! -d "$nodeModulesPath" ] || [ ! -f "$nodeBuildTimestamp" ] || [ "$packageL
|
||||
npm ci --include=dev
|
||||
npmBuildNeeded=1
|
||||
elif [ ! -f "src/public/assets/manifest.json" ] \
|
||||
|| [ ! -f "src/themes/admin_default/assets/build/manifest.json" ] \
|
||||
|| [ ! -f "src/themes/huraga/assets/build/manifest.json" ]; then
|
||||
|| [ ! -f "src/themes/default/admin/assets/build/manifest.json" ] \
|
||||
|| [ ! -f "src/themes/default/client/assets/build/manifest.json" ]; then
|
||||
npmBuildNeeded=1
|
||||
fi
|
||||
|
||||
# If the composer packages aren't installed or are outdated, install the locked versions
|
||||
if [ ! -d "$composerVendorPath" ] || [ ! -f "$customVendorTimestamp" ] || [ "$composerLock" -nt "$customVendorTimestamp" ]; then
|
||||
# composer.json is checked too: autoload-only changes don't touch the lock file.
|
||||
if [ ! -d "$composerVendorPath" ] || [ ! -f "$customVendorTimestamp" ] || [ "$composerJson" -nt "$customVendorTimestamp" ] || [ "$composerLock" -nt "$customVendorTimestamp" ]; then
|
||||
composer install
|
||||
echo "$now" > "$customVendorTimestamp"
|
||||
fi
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
PHP_VERSION="$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;')"
|
||||
FPM_CONF_DIR="/etc/php/${PHP_VERSION}/fpm/conf.d"
|
||||
SECURITY_INI="${FPM_CONF_DIR}/99-fossbilling-security.ini"
|
||||
|
||||
cat > "${SECURITY_INI}" <<'EOF'
|
||||
[PHP]
|
||||
disable_functions = exec,passthru,system,shell_exec,popen,proc_open
|
||||
EOF
|
||||
@@ -13,6 +13,7 @@ node_modules
|
||||
output.log
|
||||
|
||||
src/config.php
|
||||
src/config.old.php
|
||||
src/vendor
|
||||
src/**/node_modules
|
||||
src/public/assets
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: 'PHP Quality Tools Setup'
|
||||
description: 'Installs PHP and Composer dependencies, and restores the Rector/PHP-CS-Fixer caches, ready to run code quality tools.'
|
||||
|
||||
inputs:
|
||||
php-version:
|
||||
description: 'The PHP version to use.'
|
||||
required: false
|
||||
default: '8.3'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: PHP Build
|
||||
uses: FOSSBilling/.workflows/.github/actions/php-build@76b4af76fb2a1a2b553ec1c2839044984299a271
|
||||
with:
|
||||
php-version: ${{ inputs.php-version }}
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: ${{ inputs.php-version }}
|
||||
extensions: intl, xml, dom, curl, iconv, json
|
||||
|
||||
- name: Create Cache Directories
|
||||
shell: bash
|
||||
run: |
|
||||
echo "MONTH=$(date '+%h')" >> "${GITHUB_ENV}"
|
||||
mkdir -p cache/rector
|
||||
|
||||
- name: Restore Rector Cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ./cache/rector
|
||||
key: ${{ env.MONTH }}-rector
|
||||
restore-keys: ${{ env.MONTH }}-rector
|
||||
|
||||
- name: Restore CS-Fixer Cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .php-cs-fixer.cache
|
||||
key: ${{ env.MONTH }}-csfixer
|
||||
restore-keys: ${{ env.MONTH }}-csfixer
|
||||
+5
-5
@@ -12,7 +12,7 @@ installer:
|
||||
library:
|
||||
- any:
|
||||
- changed-files:
|
||||
- all-globs-to-any-file: ["src/library/**/*", "!src/library/Payment/Adapter/*", "!src/library/Registrar/Adapter/*", "!src/library/Server/Manager/*",]
|
||||
- any-glob-to-any-file: ["src/library/**/*"]
|
||||
|
||||
modules:
|
||||
- any:
|
||||
@@ -23,22 +23,22 @@ modules:
|
||||
extensions:
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["src/library/Payment/Adapter/*", "src/library/Registrar/Adapter/*", "src/library/Server/Manager/*",]
|
||||
- any-glob-to-any-file: ["src/extensions/**/*"]
|
||||
|
||||
payment-gateways:
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["src/library/Payment/Adapter/*"]
|
||||
- any-glob-to-any-file: ["src/extensions/gateways/**/*"]
|
||||
|
||||
domain-registrars:
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["src/library/Registrar/Adapter/*"]
|
||||
- any-glob-to-any-file: ["src/extensions/registrars/**/*"]
|
||||
|
||||
server-managers:
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ["src/library/Server/Manager/*"]
|
||||
- any-glob-to-any-file: ["src/extensions/managers/**/*"]
|
||||
|
||||
# Front-end/Themes.
|
||||
client-area:
|
||||
|
||||
+32
-30
@@ -2,43 +2,45 @@ name-template: '$RESOLVED_VERSION'
|
||||
tag-template: '$RESOLVED_VERSION'
|
||||
categories:
|
||||
- title: '⚠️ Potentially Breaking Changes'
|
||||
labels:
|
||||
- 'breaking change'
|
||||
when:
|
||||
label: 'breaking change'
|
||||
- title: '🔐 Security'
|
||||
labels:
|
||||
- 'security'
|
||||
when:
|
||||
label: 'security'
|
||||
- title: '📈 Enhancements'
|
||||
labels:
|
||||
- 'enhancement'
|
||||
when:
|
||||
label: 'enhancement'
|
||||
- title: '➕ New Features'
|
||||
labels:
|
||||
- 'feature'
|
||||
when:
|
||||
label: 'feature'
|
||||
- title: '🐛 Bug Fixes'
|
||||
labels:
|
||||
- 'bug'
|
||||
when:
|
||||
label: 'bug'
|
||||
- title: '📝 Changes'
|
||||
labels:
|
||||
- 'task'
|
||||
- 'tests/CI'
|
||||
when:
|
||||
labels:
|
||||
- 'task'
|
||||
- 'tests/CI'
|
||||
- title: '📦 Dependencies'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'composer'
|
||||
- 'npm'
|
||||
exclude-labels:
|
||||
- 'skip-changelog'
|
||||
when:
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'composer'
|
||||
- 'npm'
|
||||
- type: 'pre-exclude'
|
||||
when:
|
||||
label: 'skip-changelog'
|
||||
- type: 'version-resolver'
|
||||
semver-increment: 'major'
|
||||
when:
|
||||
label: 'major'
|
||||
- type: 'version-resolver'
|
||||
semver-increment: 'minor'
|
||||
when:
|
||||
label: 'minor'
|
||||
- type: 'version-resolver'
|
||||
semver-increment: 'patch'
|
||||
change-template: '- $TITLE (@$AUTHOR, #$NUMBER)'
|
||||
change-title-escapes: '\<*_&'
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- 'major'
|
||||
minor:
|
||||
labels:
|
||||
- 'minor'
|
||||
patch:
|
||||
labels:
|
||||
- 'patch'
|
||||
default: patch
|
||||
template: |
|
||||
$CHANGES
|
||||
Executable
+519
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract gettext msgids from FOSSBilling sources into a messages.pot file.
|
||||
|
||||
Open-source replacement for the manual Poedit Pro step. Scans Twig
|
||||
``|trans`` literals and PHP ``__trans`` / ``__pluralTrans`` / exception
|
||||
keywords under ``src/`` (minus vendor, install, data, load.php, tests and
|
||||
web-asset extensions); obsolete msgids are dropped.
|
||||
|
||||
Usage:
|
||||
extract_pot.py <src_dir> <out.pot>
|
||||
extract_pot.py --diff <old.pot> <new.pot> # msgid-set comparison,
|
||||
# flagging casing-only changes (gettext matches case-sensitively,
|
||||
# so those silently invalidate existing translations)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import NamedTuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration (mirrors the historical Poedit runs + project decisions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EXCLUDED_DIR_NAMES = {"vendor", "install", "data", "tests"}
|
||||
EXCLUDED_FILES = {"load.php"}
|
||||
EXCLUDED_EXTENSIONS = {".js", ".html", ".css", ".scss", ".md"}
|
||||
SCANNED_EXTENSIONS = {".php", ".twig"}
|
||||
|
||||
PHP_KEYWORDS_SINGLE = {
|
||||
"__trans",
|
||||
"Exception",
|
||||
"InformationException",
|
||||
"Server_Exception",
|
||||
"Registrar_Exception",
|
||||
"Payment_Exception",
|
||||
}
|
||||
PHP_KEYWORDS_PLURAL = {"__pluralTrans"}
|
||||
|
||||
HEADER_TEMPLATE = """\
|
||||
# en_US translation of FOSSBilling Application
|
||||
# Copyright 2022-2026 FOSSBilling
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: 4\\n"
|
||||
"POT-Creation-Date: {date}\\n"
|
||||
"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n"
|
||||
"Last-Translator: FOSSBilling TM <info@fossbilling.org>\\n"
|
||||
"Language-Team: FOSSBilling TM <info@fossbilling.org>\\n"
|
||||
"MIME-Version: 1.0\\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\\n"
|
||||
"Content-Transfer-Encoding: 8bit\\n"
|
||||
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n"
|
||||
"X-Generator: FOSSBilling extract_pot.py\\n"
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# String decoding / encoding helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PHP_DOUBLE_ESCAPES = {
|
||||
"n": "\n",
|
||||
"r": "\r",
|
||||
"t": "\t",
|
||||
"v": "\v",
|
||||
"e": "\x1b",
|
||||
"f": "\f",
|
||||
"\\": "\\",
|
||||
"$": "$",
|
||||
'"': '"',
|
||||
}
|
||||
|
||||
|
||||
def _decode_php_double(body: str) -> str:
|
||||
"""Decode a PHP double-quoted string body (no surrounding quotes)."""
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
seq = match.group(1)
|
||||
if seq in _PHP_DOUBLE_ESCAPES:
|
||||
return _PHP_DOUBLE_ESCAPES[seq]
|
||||
if seq.startswith("x"):
|
||||
return chr(int(seq[1:] or "0", 16))
|
||||
if seq[0].isdigit():
|
||||
return chr(int(seq, 8) & 0xFF)
|
||||
return match.group(0)
|
||||
|
||||
return re.sub(r"\\(x[0-9A-Fa-f]{1,2}|[0-7]{1,3}|.)", repl, body, flags=re.DOTALL)
|
||||
|
||||
|
||||
def _decode_single(body: str) -> str:
|
||||
"""Decode a PHP/Twig single-quoted string body (only \\\\ and \\' are special)."""
|
||||
return body.replace("\\\\", "\x00").replace("\\'", "'").replace("\x00", "\\")
|
||||
|
||||
|
||||
def po_escape(text: str) -> str:
|
||||
out = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||
out = out.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment masking (replaced with spaces, newlines preserved -> offsets stable)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PHP_TOKEN = re.compile(
|
||||
r"(?P<heredoc><<<\s*['\"]?(?P<htag>[A-Za-z_][A-Za-z0-9_]*)['\"]?[^\n]*\n)"
|
||||
r"|(?P<dstr>\"(?:\\.|[^\"\\])*\")"
|
||||
r"|(?P<sstr>'(?:\\.|[^'\\])*')"
|
||||
r"|(?P<linec>//[^\n]*|#[^\n]*)"
|
||||
r"|(?P<blockc>/\*.*?\*/)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _mask_php_comments(text: str) -> tuple[str, list[tuple[int, int]]]:
|
||||
"""Blank out PHP comments, honouring strings and heredocs (offsets stable).
|
||||
|
||||
Also returns the spans of ordinary string literals and heredoc bodies so
|
||||
callers can ignore keyword-like text inside string content (which real
|
||||
xgettext never extracts).
|
||||
"""
|
||||
out: list[str] = []
|
||||
spans: list[tuple[int, int]] = []
|
||||
pos = 0
|
||||
for match in _PHP_TOKEN.finditer(text):
|
||||
kind = match.lastgroup
|
||||
if match.start() < pos:
|
||||
# Inside an already-consumed heredoc body; it was copied verbatim.
|
||||
continue
|
||||
if kind == "heredoc":
|
||||
tag = match.group("htag")
|
||||
# Flexible heredoc (PHP 7.3+): indented marker optionally
|
||||
# followed by `;` or `,` when used inside an expression.
|
||||
end = re.search(
|
||||
rf"^[ \t]*{re.escape(tag)}[ \t]*[;,]?[ \t]*$",
|
||||
text[match.end():],
|
||||
re.MULTILINE,
|
||||
)
|
||||
stop = match.end() + end.end() if end else len(text)
|
||||
spans.append((match.start(), stop))
|
||||
out.append(text[pos:stop])
|
||||
pos = stop
|
||||
continue
|
||||
out.append(text[pos : match.start()])
|
||||
token = match.group(0)
|
||||
if kind in ("linec", "blockc"):
|
||||
out.append(re.sub(r"[^\n]", " ", token))
|
||||
else:
|
||||
spans.append((match.start(), match.end()))
|
||||
out.append(token)
|
||||
pos = match.end()
|
||||
out.append(text[pos:])
|
||||
return "".join(out), spans
|
||||
|
||||
|
||||
def _in_spans(spans: list[tuple[int, int]], starts: list[int], offset: int) -> bool:
|
||||
"""Whether offset falls inside a string/heredoc span (spans are sorted)."""
|
||||
idx = bisect.bisect_right(starts, offset) - 1
|
||||
return idx >= 0 and spans[idx][0] <= offset < spans[idx][1]
|
||||
|
||||
|
||||
_TWIG_COMMENT = re.compile(r"\{#.*?#\}", re.DOTALL)
|
||||
|
||||
|
||||
def _mask_twig_comments(text: str) -> str:
|
||||
return _TWIG_COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A quoted literal immediately feeding the |trans filter, e.g.
|
||||
# {{ 'Order status'|trans }} {{ 'Hello'|trans({...}) }} {{ 'Guest' |trans }}
|
||||
_TWIG_TRANS = re.compile(
|
||||
r"""'(?P<sbody>(?:\\.|[^'\\])*)'\s*\|\s*trans(?![A-Za-z0-9_])"""
|
||||
r"""|"(?P<dbody>(?:\\.|[^"\\])*)"\s*\|\s*trans(?![A-Za-z0-9_])"""
|
||||
)
|
||||
|
||||
_PHP_STR = r"""'(?P<sbody>(?:\\.|[^'\\])*)'|"(?P<dbody>(?:\\.|[^"\\])*)\""""
|
||||
|
||||
_KEYWORD_ALTS = "|".join(
|
||||
sorted(PHP_KEYWORDS_SINGLE | PHP_KEYWORDS_PLURAL, key=len, reverse=True)
|
||||
)
|
||||
# Matches bare calls AND `new [Namespace\]Keyword(`. The lookbehind rejects
|
||||
# method/static calls (->, ::), variables and longer identifiers
|
||||
# (e.g. the `Exception` in `InformationException`).
|
||||
_PHP_CALL = re.compile(
|
||||
rf"(?:new\s+)?(?<![\w$>:\\])(?:\\)?(?:[A-Za-z_][\w]*\\)*(?P<kw>{_KEYWORD_ALTS})\s*\(\s*"
|
||||
rf"(?P<arg>{_PHP_STR})?"
|
||||
rf"(?P<rest>\s*,)?",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# xgettext treats a double-quoted PHP literal as non-constant (and skips the
|
||||
# call) when it contains an unescaped `$` starting an interpolation
|
||||
# (`$var`, `${var}`, `{$var}` …). A `$` followed by anything else
|
||||
# (e.g. `$5`) is literal and kept. Single-quoted strings never interpolate.
|
||||
_PHP_DYNAMIC_DQOUTE = re.compile(r"(?<!\\)\$(?=[A-Za-z_{\x80-\xff])")
|
||||
|
||||
class Occurrence(NamedTuple):
|
||||
msgid: str
|
||||
plural: str | None
|
||||
ref: str
|
||||
|
||||
|
||||
def _line_no(text: str, offset: int) -> int:
|
||||
return text.count("\n", 0, offset) + 1
|
||||
|
||||
|
||||
def extract_twig(rel: str, text: str, occurrences: list[Occurrence], stats: dict) -> None:
|
||||
masked = _mask_twig_comments(text)
|
||||
for match in _TWIG_TRANS.finditer(masked):
|
||||
if match.group("sbody") is not None:
|
||||
msgid = _decode_single(match.group("sbody"))
|
||||
else:
|
||||
body = match.group("dbody")
|
||||
if "#{" in body:
|
||||
stats["skipped_dynamic"] += 1
|
||||
stats["skipped"].append(f"{rel}:{_line_no(masked, match.start())} interpolated Twig string")
|
||||
continue
|
||||
msgid = _decode_php_double(body)
|
||||
occurrences.append(Occurrence(msgid, None, f"{rel}:{_line_no(masked, match.start())}"))
|
||||
stats["twig"] += 1
|
||||
|
||||
|
||||
def _read_php_literal(masked: str, start: int) -> tuple[str, int, bool] | None:
|
||||
"""Read one quoted literal at offset.
|
||||
|
||||
Returns (value, end_offset, is_dynamic_double) or None. Double-quoted
|
||||
literals containing an interpolation are flagged dynamic (xgettext
|
||||
treats them as non-constant).
|
||||
"""
|
||||
if start >= len(masked) or masked[start] not in ("'", '"'):
|
||||
return None
|
||||
quote = masked[start]
|
||||
pattern = (
|
||||
r"'(?:\\.|[^'\\])*'" if quote == "'" else r'"(?:\\.|[^"\\])*"'
|
||||
)
|
||||
match = re.compile(pattern, re.DOTALL).match(masked, start)
|
||||
if not match:
|
||||
return None
|
||||
body = match.group(0)[1:-1]
|
||||
if quote == "'":
|
||||
return _decode_single(body), match.end(), False
|
||||
return _decode_php_double(body), match.end(), bool(_PHP_DYNAMIC_DQOUTE.search(body))
|
||||
|
||||
|
||||
def extract_php(rel: str, text: str, occurrences: list[Occurrence], stats: dict) -> None:
|
||||
masked, spans = _mask_php_comments(text)
|
||||
starts = [start for start, _ in spans]
|
||||
for match in _PHP_CALL.finditer(masked):
|
||||
if _in_spans(spans, starts, match.start()):
|
||||
continue
|
||||
keyword = match.group("kw")
|
||||
if re.search(r"\bfunction\s*$", masked[max(0, match.start() - 200) : match.start()]):
|
||||
stats["skipped_defs"] += 1
|
||||
continue
|
||||
if match.group("arg") is None:
|
||||
# Non-literal first argument (variable, constant, expression...).
|
||||
stats["skipped_dynamic"] += 1
|
||||
snippet = masked[match.start() : match.start() + 60].replace("\n", " ")
|
||||
stats["skipped"].append(f"{rel}:{_line_no(masked, match.start())} {snippet}")
|
||||
continue
|
||||
if match.group("sbody") is not None:
|
||||
first = _decode_single(match.group("sbody"))
|
||||
first_dynamic = False
|
||||
else:
|
||||
body = match.group("dbody")
|
||||
first_dynamic = bool(_PHP_DYNAMIC_DQOUTE.search(body))
|
||||
first = _decode_php_double(body)
|
||||
if first_dynamic:
|
||||
stats["skipped_dynamic"] += 1
|
||||
snippet = masked[match.start() : match.start() + 60].replace("\n", " ")
|
||||
stats["skipped"].append(f"{rel}:{_line_no(masked, match.start())} interpolated {snippet}")
|
||||
continue
|
||||
# Rescan the first literal to find where it ends (for the plural arg).
|
||||
arg_start = match.start("arg")
|
||||
parsed = _read_php_literal(masked, arg_start)
|
||||
end = parsed[1] if parsed else match.end()
|
||||
# A concatenated first argument ('a ' . $b) never matches at runtime
|
||||
# as a msgid, so skip it instead of recording the literal prefix.
|
||||
if re.match(r"\s*[),]", masked[end:]) is None:
|
||||
stats["skipped_dynamic"] += 1
|
||||
snippet = masked[match.start() : match.start() + 60].replace("\n", " ")
|
||||
stats["skipped"].append(f"{rel}:{_line_no(masked, match.start())} concat {snippet}")
|
||||
continue
|
||||
plural: str | None = None
|
||||
if keyword in PHP_KEYWORDS_PLURAL:
|
||||
rest = re.match(r"\s*,\s*", masked[end:])
|
||||
second = (
|
||||
_read_php_literal(masked, end + rest.end())
|
||||
if rest
|
||||
else None
|
||||
)
|
||||
# A plural entry is only valid with a complete static second
|
||||
# literal; anything else (missing, dynamic, concatenated)
|
||||
# would mistranslate, so skip the whole call.
|
||||
if (
|
||||
second is None
|
||||
or second[2]
|
||||
or not re.match(r"\s*(?:,|\))", masked[second[1]:])
|
||||
):
|
||||
stats["skipped_dynamic"] += 1
|
||||
snippet = masked[match.start() : match.start() + 60].replace("\n", " ")
|
||||
stats["skipped"].append(f"{rel}:{_line_no(masked, match.start())} {snippet}")
|
||||
continue
|
||||
plural = second[0]
|
||||
occurrences.append(Occurrence(first, plural, f"{rel}:{_line_no(masked, match.start())}"))
|
||||
stats["php"] += 1
|
||||
|
||||
|
||||
def iter_source_files(src_dir: str):
|
||||
for root, dirs, files in os.walk(src_dir):
|
||||
dirs[:] = sorted(d for d in dirs if d not in EXCLUDED_DIR_NAMES)
|
||||
for name in sorted(files):
|
||||
if name in EXCLUDED_FILES:
|
||||
continue
|
||||
if os.path.splitext(name)[1] not in SCANNED_EXTENSIONS:
|
||||
continue
|
||||
full = os.path.join(root, name)
|
||||
yield full, os.path.relpath(full, src_dir).replace(os.sep, "/")
|
||||
|
||||
|
||||
def extract_all(src_dir: str) -> tuple[list[Occurrence], dict]:
|
||||
occurrences: list[Occurrence] = []
|
||||
stats: dict = {"twig": 0, "php": 0, "skipped_dynamic": 0, "skipped_defs": 0, "skipped": [], "files": 0}
|
||||
for full, rel in iter_source_files(src_dir):
|
||||
with open(full, encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
stats["files"] += 1
|
||||
if full.endswith(".twig"):
|
||||
extract_twig(rel, text, occurrences, stats)
|
||||
else:
|
||||
extract_php(rel, text, occurrences, stats)
|
||||
return occurrences, stats
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .pot output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ref_sort_key(ref: str) -> tuple[str, int]:
|
||||
path, _, line = ref.rpartition(":")
|
||||
try:
|
||||
return (path, int(line))
|
||||
except ValueError:
|
||||
return (ref, 0)
|
||||
|
||||
|
||||
def write_pot(occurrences: list[Occurrence], out_path: str) -> int:
|
||||
merged: dict[str, dict] = {}
|
||||
for occ in occurrences:
|
||||
entry = merged.setdefault(occ.msgid, {"refs": [], "plural": None, "plurals_seen": set()})
|
||||
entry["refs"].append(occ.ref)
|
||||
if occ.plural is not None:
|
||||
entry["plurals_seen"].add(occ.plural)
|
||||
if entry["plural"] is None:
|
||||
entry["plural"] = occ.plural
|
||||
elif entry["plural"] != occ.plural:
|
||||
print(
|
||||
f"warning: conflicting plurals for {occ.msgid!r}: "
|
||||
f"{entry['plural']!r} vs {occ.plural!r} (keeping first)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M+0000")
|
||||
with open(out_path, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(HEADER_TEMPLATE.format(date=date))
|
||||
for msgid in sorted(merged):
|
||||
entry = merged[msgid]
|
||||
refs = sorted(set(entry["refs"]), key=_ref_sort_key)
|
||||
handle.write(f"\n#: {' '.join(refs)}\n")
|
||||
handle.write(f'msgid "{po_escape(msgid)}"\n')
|
||||
if entry["plural"] is not None:
|
||||
handle.write(f'msgid_plural "{po_escape(entry["plural"])}"\n')
|
||||
handle.write('msgstr[0] ""\nmsgstr[1] ""\n')
|
||||
else:
|
||||
handle.write('msgstr ""\n')
|
||||
return len(merged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# .pot parsing (for --diff) and comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _po_unescape(quoted: str) -> str:
|
||||
body = quoted[1:-1]
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
simple = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
||||
while i < len(body):
|
||||
char = body[i]
|
||||
if char != "\\" or i + 1 >= len(body):
|
||||
out.append(char)
|
||||
i += 1
|
||||
continue
|
||||
nxt = body[i + 1]
|
||||
out.append(simple.get(nxt, nxt))
|
||||
i += 2
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def parse_pot_entries(path: str) -> dict[str, str | None]:
|
||||
"""Parse a .pot into {msgid: msgid_plural}; handles wrapped lines."""
|
||||
with open(path, encoding="utf-8", errors="replace") as handle:
|
||||
text = handle.read()
|
||||
entries: dict[str, str | None] = {}
|
||||
for block in re.split(r"\n\s*\n", text):
|
||||
msgid_parts: list[str] = []
|
||||
plural_parts: list[str] = []
|
||||
target: list[str] | None = None
|
||||
for line in block.splitlines():
|
||||
if line.startswith("msgid_plural"):
|
||||
target = plural_parts
|
||||
plural_parts.append(_po_unescape(line[len("msgid_plural") :].strip()))
|
||||
elif line.startswith("msgid"):
|
||||
target = msgid_parts
|
||||
msgid_parts.append(_po_unescape(line[len("msgid") :].strip()))
|
||||
elif line.startswith('"') and target is not None:
|
||||
target.append(_po_unescape(line.strip()))
|
||||
else:
|
||||
target = None
|
||||
msgid = "".join(msgid_parts)
|
||||
if msgid:
|
||||
plural = "".join(plural_parts) or None
|
||||
entries[msgid] = plural
|
||||
return entries
|
||||
|
||||
|
||||
def cmd_diff(old_path: str, new_path: str) -> int:
|
||||
old_entries = parse_pot_entries(old_path)
|
||||
new_entries = parse_pot_entries(new_path)
|
||||
old_ids = set(old_entries)
|
||||
new_ids = set(new_entries)
|
||||
added = sorted(new_ids - old_ids)
|
||||
removed = sorted(old_ids - new_ids)
|
||||
|
||||
removed_lookup: dict[str, list[str]] = {}
|
||||
for msgid in removed:
|
||||
removed_lookup.setdefault(msgid.casefold(), []).append(msgid)
|
||||
casing_changes: list[tuple[str, str]] = []
|
||||
real_added: list[str] = []
|
||||
for msgid in added:
|
||||
candidates = removed_lookup.get(msgid.casefold(), [])
|
||||
if candidates:
|
||||
casing_changes.append((candidates.pop(0), msgid))
|
||||
else:
|
||||
real_added.append(msgid)
|
||||
changed_set = {old for old, _ in casing_changes}
|
||||
real_removed = [msgid for msgid in removed if msgid not in changed_set]
|
||||
|
||||
print(f"Added: {len(added)} (new: {len(real_added)}, casing-only: {len(casing_changes)})")
|
||||
print(f"Removed: {len(removed)} (gone: {len(real_removed)}, casing-only: {len(casing_changes)})")
|
||||
if casing_changes:
|
||||
print("\nCasing-only changes (invalidate existing translations!):")
|
||||
for old, new in sorted(casing_changes):
|
||||
print(f" - {old!r} -> {new!r}")
|
||||
if real_added:
|
||||
print("\nNew msgids needing translation:")
|
||||
for msgid in real_added:
|
||||
print(f" + {msgid!r}")
|
||||
if real_removed:
|
||||
print("\nDropped msgids:")
|
||||
for msgid in real_removed:
|
||||
print(f" - {msgid!r}")
|
||||
plural_changed = sorted(
|
||||
msgid
|
||||
for msgid in old_ids & new_ids
|
||||
if old_entries[msgid] != new_entries[msgid]
|
||||
)
|
||||
plural_changed_pairs = sorted(
|
||||
(old, new)
|
||||
for old, new in casing_changes
|
||||
if old_entries[old] != new_entries[new]
|
||||
)
|
||||
if plural_changed or plural_changed_pairs:
|
||||
print("\nChanged plural forms:")
|
||||
for msgid in plural_changed:
|
||||
print(f" ~ {msgid!r}: {old_entries[msgid]!r} -> {new_entries[msgid]!r}")
|
||||
for old, new in plural_changed_pairs:
|
||||
print(f" ~ {old!r} -> {new!r}: {old_entries[old]!r} -> {new_entries[new]!r}")
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract gettext msgids into messages.pot")
|
||||
parser.add_argument("src_dir", nargs="?", help="FOSSBilling src/ directory")
|
||||
parser.add_argument("out_pot", nargs="?", help="Output .pot path")
|
||||
parser.add_argument("--diff", nargs=2, metavar=("OLD_POT", "NEW_POT"), help="Compare two .pot files")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.diff:
|
||||
return cmd_diff(args.diff[0], args.diff[1])
|
||||
if not args.src_dir or not args.out_pot:
|
||||
parser.error("src_dir and out_pot are required (or use --diff)")
|
||||
occurrences, stats = extract_all(args.src_dir)
|
||||
count = write_pot(occurrences, args.out_pot)
|
||||
print(f"files scanned: {stats['files']}", file=sys.stderr)
|
||||
print(f"twig hits: {stats['twig']}, php hits: {stats['php']}", file=sys.stderr)
|
||||
print(f"unique msgids written: {count}", file=sys.stderr)
|
||||
print(f"skipped non-literals: {stats['skipped_dynamic']}, skipped defs: {stats['skipped_defs']}", file=sys.stderr)
|
||||
for item in stats["skipped"]:
|
||||
print(f" skip: {item}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,195 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
image="${1:?Usage: run-docker-cypress-tests.sh <test-image>}"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/../.." && pwd)"
|
||||
compose_file="${repo_root}/.github/docker/live-tests.compose.yml"
|
||||
project="fossbilling-cypress-${GITHUB_RUN_ID:-local}-$$"
|
||||
cypress_image="${CYPRESS_DOCKER_IMAGE:-cypress/included:15.15.0}"
|
||||
cypress_browser="${CYPRESS_BROWSER:-electron}"
|
||||
app_host="fossbilling-app"
|
||||
app_url="http://${app_host}/"
|
||||
install_url="${app_url}install/"
|
||||
|
||||
db_name="fossbilling"
|
||||
db_user="root"
|
||||
db_pass="root"
|
||||
db_port="3306"
|
||||
test_email="email@example.com"
|
||||
test_pass="4WGemqiihh8iM3"
|
||||
test_api_key="AW6qEQCa7U7FG96J9NFIZXNYMJ79M8LH"
|
||||
|
||||
compose() {
|
||||
docker compose --file "${compose_file}" --project-name "${project}" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
|
||||
if [[ -n "${cypress_pull_pid:-}" ]]; then
|
||||
kill "${cypress_pull_pid}" 2>/dev/null || true
|
||||
wait "${cypress_pull_pid}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ $status -ne 0 ]]; then
|
||||
compose exec -T app sh -c '
|
||||
cd /var/www/html
|
||||
|
||||
for file in install/php_error.log data/log/php_error.log; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "===== $file ====="
|
||||
cat "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
find data/log -type f ! -name "*.html" ! -name "php_error.log" -print 2>/dev/null | while read -r file; do
|
||||
echo "===== $file ====="
|
||||
cat "$file"
|
||||
done
|
||||
' || true
|
||||
compose logs --no-color app db || true
|
||||
fi
|
||||
|
||||
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
set_commit_info() {
|
||||
if ! command -v git >/dev/null 2>&1 || ! git -C "${repo_root}" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "${COMMIT_INFO_BRANCH:-}" ]]; then
|
||||
COMMIT_INFO_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}"
|
||||
if [[ -z "${COMMIT_INFO_BRANCH}" ]]; then
|
||||
COMMIT_INFO_BRANCH="$(git -C "${repo_root}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
if [[ "${COMMIT_INFO_BRANCH}" == "HEAD" ]]; then
|
||||
COMMIT_INFO_BRANCH=""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "${COMMIT_INFO_MESSAGE:-}" ]]; then
|
||||
COMMIT_INFO_MESSAGE="$(git -C "${repo_root}" show -s --pretty=%B 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${COMMIT_INFO_EMAIL:-}" ]]; then
|
||||
COMMIT_INFO_EMAIL="$(git -C "${repo_root}" show -s --pretty=%ae 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${COMMIT_INFO_AUTHOR:-}" ]]; then
|
||||
COMMIT_INFO_AUTHOR="$(git -C "${repo_root}" show -s --pretty=%an 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${COMMIT_INFO_SHA:-}" ]]; then
|
||||
COMMIT_INFO_SHA="$(git -C "${repo_root}" rev-parse HEAD 2>/dev/null || true)"
|
||||
fi
|
||||
if [[ -z "${COMMIT_INFO_REMOTE:-}" ]]; then
|
||||
COMMIT_INFO_REMOTE="$(git -C "${repo_root}" config --get remote.origin.url 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
export COMMIT_INFO_BRANCH COMMIT_INFO_MESSAGE COMMIT_INFO_EMAIL COMMIT_INFO_AUTHOR COMMIT_INFO_SHA COMMIT_INFO_REMOTE
|
||||
}
|
||||
|
||||
if ! docker image inspect "${image}" >/dev/null 2>&1; then
|
||||
echo "Docker image '${image}' was not found. Build the test image before running Cypress tests."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set_commit_info
|
||||
|
||||
cypress_args=(run --browser "${cypress_browser}")
|
||||
if [[ -n "${CYPRESS_RECORD_KEY:-}" ]]; then
|
||||
cypress_args+=(--record --group "PHP 8.5 / ${cypress_browser}")
|
||||
if [[ -n "${GITHUB_RUN_ID:-}" ]]; then
|
||||
cypress_args+=(--ci-build-id "${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}")
|
||||
fi
|
||||
else
|
||||
echo "CYPRESS_RECORD_KEY is not set. Running Cypress without Cloud recording."
|
||||
fi
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
docker pull "${cypress_image}" &
|
||||
cypress_pull_pid=$!
|
||||
|
||||
export FOSSBILLING_TEST_IMAGE="${image}"
|
||||
export FOSSBILLING_DB_NAME="${db_name}"
|
||||
export FOSSBILLING_DB_PASS="${db_pass}"
|
||||
|
||||
compose up --detach
|
||||
compose exec -T app rm -f /var/www/html/config.php
|
||||
|
||||
for _ in {1..60}; do
|
||||
if compose exec -T app curl -fsS "${install_url}" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
compose exec -T app curl -fsS "${install_url}" >/dev/null
|
||||
|
||||
install_payload=(
|
||||
-H 'Content-type: multipart/form-data'
|
||||
-F error_reporting=0
|
||||
-F "database_hostname=db"
|
||||
-F "database_port=${db_port}"
|
||||
-F "database_name=${db_name}"
|
||||
-F "database_username=${db_user}"
|
||||
-F "database_password=${db_pass}"
|
||||
-F admin_name=test
|
||||
-F "admin_email=${test_email}"
|
||||
-F "admin_password=${test_pass}"
|
||||
-F currency_code=USD
|
||||
-F 'currency_title=US Dollar'
|
||||
-F "admin_api_token=${test_api_key}"
|
||||
-X POST
|
||||
"${install_url}install.php?a=install"
|
||||
)
|
||||
|
||||
compose exec -T app curl -fsS "${install_payload[@]}" >/dev/null
|
||||
|
||||
compose exec -T app php -r '
|
||||
$configPath = "/var/www/html/config.php";
|
||||
$config = require $configPath;
|
||||
$config["security"]["perform_session_fingerprinting"] = false;
|
||||
file_put_contents($configPath, "<?php\n\nreturn " . var_export($config, true) . ";\n");
|
||||
'
|
||||
|
||||
wait "${cypress_pull_pid}"
|
||||
cypress_pull_pid=""
|
||||
|
||||
# Chromium-based browsers are sensitive to Docker's default 64 MB /dev/shm size.
|
||||
docker run --rm \
|
||||
--network "${project}_default" \
|
||||
--shm-size=2g \
|
||||
--env CI \
|
||||
--env CYPRESS_BASE_URL="${app_url}" \
|
||||
--env CYPRESS_ADMIN_EMAIL="${test_email}" \
|
||||
--env CYPRESS_ADMIN_PASSWORD="${test_pass}" \
|
||||
--env CYPRESS_PROJECT_ID \
|
||||
--env CYPRESS_RECORD_KEY \
|
||||
--env COMMIT_INFO_AUTHOR \
|
||||
--env COMMIT_INFO_BRANCH \
|
||||
--env COMMIT_INFO_EMAIL \
|
||||
--env COMMIT_INFO_MESSAGE \
|
||||
--env COMMIT_INFO_REMOTE \
|
||||
--env COMMIT_INFO_SHA \
|
||||
--env GITHUB_ACTIONS \
|
||||
--env GITHUB_API_URL \
|
||||
--env GITHUB_BASE_REF \
|
||||
--env GITHUB_EVENT_NAME \
|
||||
--env GITHUB_HEAD_REF \
|
||||
--env GITHUB_JOB \
|
||||
--env GITHUB_REF \
|
||||
--env GITHUB_REF_NAME \
|
||||
--env GITHUB_REPOSITORY \
|
||||
--env GITHUB_RUN_ATTEMPT \
|
||||
--env GITHUB_RUN_ID \
|
||||
--env GITHUB_SERVER_URL \
|
||||
--env GITHUB_SHA \
|
||||
--env GITHUB_WORKFLOW \
|
||||
--volume "${repo_root}:/workspace" \
|
||||
--workdir /workspace \
|
||||
--entrypoint cypress \
|
||||
"${cypress_image}" \
|
||||
"${cypress_args[@]}"
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
image="${1:?Usage: run-docker-playwright-tests.sh <test-image>}"
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "${script_dir}/../.." && pwd)"
|
||||
compose_file="${repo_root}/.github/docker/live-tests.compose.yml"
|
||||
project="fossbilling-playwright-${GITHUB_RUN_ID:-local}-$$"
|
||||
playwright_version="1.62.1"
|
||||
playwright_image="${PLAYWRIGHT_DOCKER_IMAGE:-mcr.microsoft.com/playwright:v${playwright_version}-noble}"
|
||||
app_host="fossbilling-app"
|
||||
app_url="http://${app_host}/"
|
||||
install_url="${app_url}install/"
|
||||
|
||||
db_name="fossbilling"
|
||||
db_user="root"
|
||||
db_pass="root"
|
||||
db_port="3306"
|
||||
test_email="email@example.com"
|
||||
test_pass="4WGemqiihh8iM3"
|
||||
test_api_key="AW6qEQCa7U7FG96J9NFIZXNYMJ79M8LH"
|
||||
|
||||
compose() {
|
||||
docker compose --file "${compose_file}" --project-name "${project}" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
|
||||
if [[ -n "${playwright_pull_pid:-}" ]]; then
|
||||
kill "${playwright_pull_pid}" 2>/dev/null || true
|
||||
wait "${playwright_pull_pid}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ $status -ne 0 ]]; then
|
||||
compose exec -T app sh -c '
|
||||
cd /var/www/html
|
||||
|
||||
for file in install/php_error.log data/log/php_error.log; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "===== $file ====="
|
||||
cat "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
find data/log -type f ! -name "*.html" ! -name "php_error.log" -print 2>/dev/null | while read -r file; do
|
||||
echo "===== $file ====="
|
||||
cat "$file"
|
||||
done
|
||||
' || true
|
||||
compose logs --no-color app db || true
|
||||
fi
|
||||
|
||||
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
if ! docker image inspect "${image}" >/dev/null 2>&1; then
|
||||
echo "Docker image '${image}' was not found. Build the test image before running Playwright tests."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
docker pull "${playwright_image}" &
|
||||
playwright_pull_pid=$!
|
||||
|
||||
export FOSSBILLING_TEST_IMAGE="${image}"
|
||||
export FOSSBILLING_DB_NAME="${db_name}"
|
||||
export FOSSBILLING_DB_PASS="${db_pass}"
|
||||
|
||||
compose up --detach
|
||||
compose exec -T app rm -f /var/www/html/config.php
|
||||
|
||||
for _ in {1..60}; do
|
||||
if compose exec -T app curl -fsS "${install_url}" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
compose exec -T app curl -fsS "${install_url}" >/dev/null
|
||||
|
||||
install_payload=(
|
||||
-H 'Content-type: multipart/form-data'
|
||||
-F error_reporting=0
|
||||
-F "database_hostname=db"
|
||||
-F "database_port=${db_port}"
|
||||
-F "database_name=${db_name}"
|
||||
-F "database_username=${db_user}"
|
||||
-F "database_password=${db_pass}"
|
||||
-F admin_name=test
|
||||
-F "admin_email=${test_email}"
|
||||
-F "admin_password=${test_pass}"
|
||||
-F currency_code=USD
|
||||
-F 'currency_title=US Dollar'
|
||||
-F "admin_api_token=${test_api_key}"
|
||||
-X POST
|
||||
"${install_url}install.php?a=install"
|
||||
)
|
||||
|
||||
compose exec -T app curl -fsS "${install_payload[@]}" >/dev/null
|
||||
|
||||
compose exec -T app php -r '
|
||||
$configPath = "/var/www/html/config.php";
|
||||
$config = require $configPath;
|
||||
$config["security"]["perform_session_fingerprinting"] = false;
|
||||
file_put_contents($configPath, "<?php\n\nreturn " . var_export($config, true) . ";\n");
|
||||
'
|
||||
|
||||
wait "${playwright_pull_pid}"
|
||||
playwright_pull_pid=""
|
||||
|
||||
# Chromium-based browsers need more shared memory than Docker's default /dev/shm.
|
||||
# The runner package is installed into a container-local prefix; a tmpfs shadows the
|
||||
# workspace's @playwright directory so specs resolve that exact copy (one package
|
||||
# instance) without touching the host's node_modules.
|
||||
docker run --rm \
|
||||
--network "${project}_default" \
|
||||
--shm-size=2g \
|
||||
--tmpfs /workspace/node_modules/@playwright:rw \
|
||||
--env CI \
|
||||
--env PLAYWRIGHT_BASE_URL="${app_url}" \
|
||||
--env ADMIN_EMAIL="${test_email}" \
|
||||
--env ADMIN_PASSWORD="${test_pass}" \
|
||||
--env GITHUB_ACTIONS \
|
||||
--volume "${repo_root}:/workspace" \
|
||||
--workdir /workspace \
|
||||
"${playwright_image}" \
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
runner=/opt/playwright-runner
|
||||
mkdir -p "$runner" && cd "$runner"
|
||||
npm init -y >/dev/null
|
||||
npm install --no-audit --no-fund "@playwright/test@'"${playwright_version}"'" >/dev/null
|
||||
ln -sn "$runner/node_modules/@playwright/test" /workspace/node_modules/@playwright/test
|
||||
cd /workspace
|
||||
exec "$runner/node_modules/.bin/playwright" test
|
||||
'
|
||||
@@ -11,7 +11,6 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
fix-cs:
|
||||
@@ -23,57 +22,78 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: PHP Build
|
||||
uses: FOSSBilling/.workflows/.github/actions/php-build@76b4af76fb2a1a2b553ec1c2839044984299a271
|
||||
- name: Set Up PHP Quality Tools
|
||||
uses: ./.github/actions/php-quality-setup
|
||||
with:
|
||||
php-version: 8.3
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: 8.3
|
||||
extensions: intl, xml, dom, curl, iconv, json
|
||||
|
||||
- name: Create Cache Directories
|
||||
run: |
|
||||
echo "MONTH=$(date '+%h')" >> "${GITHUB_ENV}"
|
||||
mkdir -p cache/rector
|
||||
|
||||
- name: Restore Rector Cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ./cache/rector
|
||||
key: ${{ env.MONTH }}-rector
|
||||
restore-keys: ${{ env.MONTH }}-rector
|
||||
|
||||
- name: Restore CS-Fixer Cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: .php-cs-fixer.cache
|
||||
key: ${{ env.MONTH }}-csfixer
|
||||
restore-keys: ${{ env.MONTH }}-csfixer
|
||||
|
||||
- name: Run Rector
|
||||
id: rector
|
||||
run: php src/vendor/bin/rector
|
||||
run: composer rector:fix
|
||||
|
||||
- name: Run PHP-CS-Fixer
|
||||
id: php_cs_fixer
|
||||
run: php src/vendor/bin/php-cs-fixer fix
|
||||
run: composer cs:fix
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create_pr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
- name: Configure Git Identity
|
||||
run: |
|
||||
git config user.name "FOSSBilling Bot"
|
||||
git config user.email "fossbilling-bot"
|
||||
|
||||
- name: 'Load Secrets for FOSSBilling Bot'
|
||||
uses: 1password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5.0.1
|
||||
with:
|
||||
title: Apply PHP Code Styling & Quality Changes
|
||||
commit-message: Apply PHP Code Styling & Quality Changes
|
||||
branch: chore/cs-fix
|
||||
body: Automated run of PHP-CS-Fixer and Rector to ensure code styling, quality, and modern practices.
|
||||
delete-branch: true
|
||||
token: ${{ secrets.BOT_TOKEN }}
|
||||
committer: FOSSBilling Bot <fossbilling-bot>
|
||||
author: FOSSBilling Bot <fossbilling-bot>
|
||||
labels: skip-changelog
|
||||
export-env: true
|
||||
version: '2.39.0'
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
# FOSSBilling Bot (GitHub CI/CD) credentials stored in 1Password.
|
||||
GH_TOKEN: op://mkqkefntgcmjlgyxzeaekzuuvq/yyxe5dc4jyw45h2pagqhl7576i/credential
|
||||
|
||||
# Registers `gh` as git's credential helper for github.com, so the
|
||||
# plain `git push` below authenticates using GH_TOKEN above.
|
||||
- name: Authenticate Git via gh
|
||||
run: gh auth setup-git
|
||||
|
||||
- name: Commit and Push Changes
|
||||
id: push_changes
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add -A
|
||||
git commit -m "Apply PHP Code Styling & Quality Changes"
|
||||
|
||||
# chore/cs-fix is fully regenerated every run, so history isn't
|
||||
# preserved between runs — --force-with-lease still guards
|
||||
# against clobbering a remote branch that moved unexpectedly
|
||||
# (e.g. a reviewer pushed changes to the open PR).
|
||||
git fetch origin '+refs/heads/chore/cs-fix:refs/remotes/origin/chore/cs-fix' 2>/dev/null || true
|
||||
git push --force-with-lease origin HEAD:refs/heads/chore/cs-fix
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or Update Pull Request
|
||||
id: create_pr
|
||||
if: steps.push_changes.outputs.changed == 'true'
|
||||
run: |
|
||||
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --head chore/cs-fix --base main --state open --json number -q '.[0].number')
|
||||
|
||||
if [ -n "$existing_pr" ]; then
|
||||
gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url -q '"url=" + .url' >> "$GITHUB_OUTPUT"
|
||||
echo "Pull request #$existing_pr already open; push updated it."
|
||||
else
|
||||
url=$(gh pr create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Apply PHP Code Styling & Quality Changes" \
|
||||
--body "Automated run of PHP-CS-Fixer and Rector to ensure code styling, quality, and modern practices." \
|
||||
--base main \
|
||||
--head chore/cs-fix \
|
||||
--label skip-changelog)
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Write Job Summary
|
||||
if: always()
|
||||
@@ -86,6 +106,6 @@ jobs:
|
||||
| PHP version | `8.3` |
|
||||
| Rector | `${{ steps.rector.outcome }}` |
|
||||
| PHP-CS-Fixer | `${{ steps.php_cs_fixer.outcome }}` |
|
||||
| Pull request action | `${{ steps.create_pr.outcome }}` |
|
||||
| Pull request | `${{ steps.create_pr.outputs.pull-request-url || 'No changes' }}` |
|
||||
| Changes pushed | `${{ steps.push_changes.outputs.changed || 'false' }}` |
|
||||
| Pull request | `${{ steps.create_pr.outputs.url || 'No changes' }}` |
|
||||
EOF
|
||||
+26
-29
@@ -57,7 +57,7 @@ jobs:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7
|
||||
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Perform Spellcheck'
|
||||
uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8
|
||||
uses: streetsidesoftware/cspell-action@6f3c77c1406bc930f944ba97e9801a22e42caf58 # v9
|
||||
with:
|
||||
incremental_files_only: true
|
||||
|
||||
@@ -116,35 +116,27 @@ jobs:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: ./.github/workflows/live-tests.yml
|
||||
|
||||
cypress-tests-pr:
|
||||
name: 'Cypress Tests'
|
||||
playwright-tests-pr:
|
||||
name: 'Playwright Tests'
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [ php-build-test-pr ]
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.pull_request.base.repo.id != github.event.pull_request.head.repo.id }}
|
||||
uses: ./.github/workflows/cypress-tests.yml
|
||||
with:
|
||||
cypress-project-id: ${{ vars.CYPRESS_PROJECT_ID }}
|
||||
secrets:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
uses: ./.github/workflows/playwright-tests.yml
|
||||
|
||||
cypress-tests-push:
|
||||
name: 'Cypress Tests'
|
||||
playwright-tests-push:
|
||||
name: 'Playwright Tests'
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [ php-build-test-push ]
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: ./.github/workflows/cypress-tests.yml
|
||||
with:
|
||||
cypress-project-id: ${{ vars.CYPRESS_PROJECT_ID }}
|
||||
secrets:
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
uses: ./.github/workflows/playwright-tests.yml
|
||||
|
||||
preview-build-pr:
|
||||
name: 'Create Preview Build'
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [ live-tests-pr, cypress-tests-pr ]
|
||||
needs: [ live-tests-pr, playwright-tests-pr ]
|
||||
if: ${{ github.event_name == 'pull_request' && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.pull_request.base.repo.id != github.event.pull_request.head.repo.id }}
|
||||
uses: ./.github/workflows/preview-build.yml
|
||||
with:
|
||||
@@ -155,7 +147,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ live-tests-push, cypress-tests-push ]
|
||||
needs: [ live-tests-push, playwright-tests-push ]
|
||||
if: ${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
uses: ./.github/workflows/preview-build.yml
|
||||
with:
|
||||
@@ -167,11 +159,11 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [ live-tests-push, cypress-tests-push ]
|
||||
needs: [ live-tests-push, playwright-tests-push ]
|
||||
if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }}
|
||||
uses: ./.github/workflows/docker-build.yml
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
with:
|
||||
target: runtime
|
||||
@@ -208,27 +200,31 @@ jobs:
|
||||
rm -f ./src/provenance.json
|
||||
|
||||
- name: 'Create Downloadable Preview'
|
||||
run: zip -qr ../FOSSBilling-preview.zip .
|
||||
run: |
|
||||
GITHUB_SHORT_SHA="$(echo "${GITHUB_SHA}" | cut -c1-7)"
|
||||
echo "GITHUB_SHORT_SHA=${GITHUB_SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
zip -qr ../FOSSBilling-preview-${GITHUB_SHORT_SHA}.zip .
|
||||
echo "FOSSBILLING_PREVIEW_DIGEST=sha256:$(sha256sum ../FOSSBilling-preview-${GITHUB_SHORT_SHA}.zip | awk '{print $1}')" >> "$GITHUB_ENV"
|
||||
working-directory: ./src/
|
||||
|
||||
- name: 'Move Downloadable Preview for Upload'
|
||||
run: |
|
||||
mkdir ./dist
|
||||
mv ./FOSSBilling-preview.zip ./dist
|
||||
cp ./FOSSBilling-preview-${GITHUB_SHORT_SHA}.zip ./dist/FOSSBilling-preview.zip
|
||||
|
||||
- name: 'Upload Downloadable Preview to S3'
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
aws s3 sync "${SOURCE_DIR}" "s3://${AWS_S3_BUCKET}/" \
|
||||
--endpoint-url "${AWS_S3_ENDPOINT}" \
|
||||
--acl public-read \
|
||||
--metadata="digest=${{ env.FOSSBILLING_PREVIEW_DIGEST }},commit-sha=${{ github.sha }}" \
|
||||
--follow-symlinks \
|
||||
--no-progress
|
||||
env:
|
||||
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_S3_ENDPOINT: ${{ secrets.AWS_S3_ENDPOINT }}
|
||||
AWS_S3_BUCKET: ${{ vars.CF_R2_DOWNLOAD_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_S3_ENDPOINT: ${{ vars.CF_R2_ENDPOINT_URL }}
|
||||
AWS_REGION: 'auto'
|
||||
SOURCE_DIR: './dist'
|
||||
|
||||
@@ -236,6 +232,7 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
if-no-files-found: 'error'
|
||||
name: 'FOSSBilling Preview'
|
||||
path: './src/*'
|
||||
name: 'FOSSBilling-preview-${{ env.GITHUB_SHORT_SHA }}.zip'
|
||||
path: './FOSSBilling-preview-${{ env.GITHUB_SHORT_SHA }}.zip'
|
||||
archive: false
|
||||
retention-days: 14
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Code Quality & Styling Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: main
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-cs:
|
||||
name: Rector & PHP-CS-Fixer
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Full history is needed so the merge-base diff below can reach
|
||||
# the PR's base commit — the default shallow clone won't have it.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set Up PHP Quality Tools
|
||||
uses: ./.github/actions/php-quality-setup
|
||||
with:
|
||||
php-version: 8.3
|
||||
|
||||
# The path list is written to a file, not a $GITHUB_OUTPUT string —
|
||||
# ${{ }} is literal text substitution into the run: script before bash
|
||||
# ever parses it, so interpolating a filename there is a script
|
||||
# injection vector (and breaks on spaces regardless). Only the boolean
|
||||
# below crosses the step boundary via outputs; every step that needs
|
||||
# the actual paths reads them back into a real array with mapfile.
|
||||
- name: Determine Changed PHP Files
|
||||
id: changed_files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# Triple-dot diffs against the merge-base, not the base branch's
|
||||
# current tip, so a PR isn't blamed for drift on main it never
|
||||
# touched — matches what GitHub's own "Files changed" tab shows.
|
||||
git diff -z --name-only --diff-filter=ACMR "$BASE_SHA...$HEAD_SHA" -- '*.php' > "$RUNNER_TEMP/changed-php-files.txt"
|
||||
if [ -s "$RUNNER_TEMP/changed-php-files.txt" ]; then
|
||||
echo "has_php_files=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Changed PHP files:"
|
||||
xargs -0 -n1 printf ' %s\n' < "$RUNNER_TEMP/changed-php-files.txt"
|
||||
else
|
||||
echo "has_php_files=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No changed PHP files in this pull request — skipping style/quality checks."
|
||||
fi
|
||||
|
||||
# Scoped to just the files this PR touches, not the whole repo — see
|
||||
# https://cs.symfony.com/doc/usage.html#using-php-cs-fixer-on-ci. This
|
||||
# also means pre-existing drift on main (only ever cleaned up by the
|
||||
# scheduled sweep in auto-cs-fix.yml) can't fail an unrelated PR.
|
||||
- name: Run Rector (dry-run)
|
||||
id: rector
|
||||
if: steps.changed_files.outputs.has_php_files == 'true'
|
||||
run: |
|
||||
mapfile -d '' -t files < "$RUNNER_TEMP/changed-php-files.txt"
|
||||
composer rector:check -- --no-progress-bar -- "${files[@]}"
|
||||
|
||||
# --config is required here: PHP-CS-Fixer only auto-discovers
|
||||
# .php-cs-fixer.dist.php when given a single (or zero) path argument,
|
||||
# and refuses to run against multiple explicit paths without it.
|
||||
- name: Run PHP-CS-Fixer (dry-run)
|
||||
id: php_cs_fixer
|
||||
if: steps.changed_files.outputs.has_php_files == 'true'
|
||||
run: |
|
||||
mapfile -d '' -t files < "$RUNNER_TEMP/changed-php-files.txt"
|
||||
composer cs:check -- --config=.php-cs-fixer.dist.php -- "${files[@]}"
|
||||
|
||||
- name: Write Job Summary
|
||||
if: always()
|
||||
run: |
|
||||
cat >> "${GITHUB_STEP_SUMMARY}" <<'EOF'
|
||||
## Code Quality and Styling Check
|
||||
|
||||
| Item | Value |
|
||||
| --- | --- |
|
||||
| PHP version | `8.3` |
|
||||
| Rector | `${{ steps.rector.outcome }}` |
|
||||
| PHP-CS-Fixer | `${{ steps.php_cs_fixer.outcome }}` |
|
||||
EOF
|
||||
@@ -37,12 +37,12 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -11,9 +11,8 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
target_ref:
|
||||
description: 'Git ref to build and tag'
|
||||
description: 'Git ref to build and tag (target)'
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
draft:
|
||||
description: 'Create as a draft release'
|
||||
@@ -181,11 +180,11 @@ jobs:
|
||||
run: echo "CACHE_IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}-build-cache" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: 'Set up Docker Buildx'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: 'Build Release Artifact'
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: |
|
||||
FOSSBILLING_VERSION=${{ inputs.version }}
|
||||
@@ -203,8 +202,48 @@ jobs:
|
||||
run: |
|
||||
mkdir -p ../dist
|
||||
zip -qr "../dist/FOSSBilling-${RELEASE_VERSION}.zip" .
|
||||
|
||||
echo "FOSSBILLING_RELEASE_DIGEST=sha256:$(sha256sum "../dist/FOSSBilling-${RELEASE_VERSION}.zip" | awk '{print $1}')" >> "$GITHUB_ENV"
|
||||
working-directory: ./release-tree
|
||||
|
||||
- name: 'Validate Release Tag'
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
# An existing tag pins the release commit; refuse a mismatched archive.
|
||||
if tag_commit="$(git rev-parse --verify --quiet "refs/tags/${RELEASE_VERSION}^{commit}")"; then
|
||||
head_commit="$(git rev-parse HEAD)"
|
||||
if [[ "${tag_commit}" != "${head_commit}" ]]; then
|
||||
echo "Tag ${RELEASE_VERSION} points at ${tag_commit}, but this run built from ${head_commit}. Re-run with target_ref set to the tag or the matching commit."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: 'Upload Release Archive to R2'
|
||||
id: r2-upload
|
||||
# Mirrors the release archive to our own IPv6-reachable storage, alongside the
|
||||
# GitHub release asset. github.com and api.github.com have no AAAA record, so
|
||||
# installs on IPv6-only hosts cannot download an update from GitHub at all -
|
||||
# see https://github.com/FOSSBilling/FOSSBilling/issues/2479. This mirror is
|
||||
# what api.fossbilling.net's `download_url` should point update clients to.
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_S3_ENDPOINT: ${{ vars.CF_R2_ENDPOINT_URL }}
|
||||
AWS_S3_BUCKET: ${{ vars.CF_R2_DOWNLOAD_BUCKET }}
|
||||
AWS_REGION: 'auto'
|
||||
run: |
|
||||
asset="./dist/FOSSBilling-${RELEASE_VERSION}.zip"
|
||||
key="releases/${RELEASE_VERSION}/FOSSBilling-${RELEASE_VERSION}.zip"
|
||||
|
||||
aws s3 cp "${asset}" "s3://${AWS_S3_BUCKET}/${key}" \
|
||||
--endpoint-url "${AWS_S3_ENDPOINT}" \
|
||||
--metadata="digest=${FOSSBILLING_RELEASE_DIGEST},version=${RELEASE_VERSION}" \
|
||||
--no-progress
|
||||
|
||||
echo "url=https://download.fossbilling.org/${key}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Create or Update GitHub Release'
|
||||
id: release
|
||||
env:
|
||||
@@ -223,12 +262,18 @@ jobs:
|
||||
--repo "${REPOSITORY}" \
|
||||
--clobber
|
||||
|
||||
edit_args=(
|
||||
"${RELEASE_VERSION}"
|
||||
--repo "${REPOSITORY}"
|
||||
--target "${TARGET_REF}"
|
||||
)
|
||||
|
||||
if [[ "${RELEASE_NOTES}" != "none" ]]; then
|
||||
gh release edit "${RELEASE_VERSION}" \
|
||||
--repo "${REPOSITORY}" \
|
||||
--notes-file release-notes.md
|
||||
edit_args+=(--notes-file release-notes.md)
|
||||
fi
|
||||
|
||||
gh release edit "${edit_args[@]}"
|
||||
|
||||
echo "operation=updated" >> "${GITHUB_OUTPUT}"
|
||||
exit 0
|
||||
fi
|
||||
@@ -273,7 +318,9 @@ jobs:
|
||||
| Release notes | `${{ inputs.release_notes }}` |
|
||||
| Previous tag | `${{ steps.notes-context.outputs.previous_tag || 'n/a' }}` |
|
||||
| Archive | `FOSSBilling-${{ inputs.version }}.zip` |
|
||||
| Digest | `${{ env.FOSSBILLING_RELEASE_DIGEST }}` |
|
||||
| Docker build | `${{ steps.build.outcome }}` |
|
||||
| Archive creation | `${{ steps.archive.outcome }}` |
|
||||
| R2 mirror | `${{ steps.r2-upload.outputs.url }}` |
|
||||
| Release operation | `${{ steps.release.outputs.operation || steps.release.outcome }}` |
|
||||
EOF
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Cypress Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
cypress-project-id:
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
CYPRESS_RECORD_KEY:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
cypress-tests:
|
||||
name: 'Cypress Tests'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CYPRESS_RECORDING_DISABLED: ${{ secrets.CYPRESS_RECORD_KEY == '' }}
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Docker Compose'
|
||||
uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0
|
||||
|
||||
# Reuses the image already built once in the PHP Build and Test (8.5) job.
|
||||
- name: 'Download Test Image Artifact'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: test-image-8.5.tar.zst
|
||||
path: /tmp
|
||||
|
||||
- name: 'Load Test Image'
|
||||
run: zstd --decompress --stdout /tmp/test-image-8.5.tar.zst | docker load
|
||||
|
||||
- name: 'Run Cypress Tests'
|
||||
env:
|
||||
CYPRESS_PROJECT_ID: ${{ inputs.cypress-project-id }}
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
run: bash ./.github/scripts/run-docker-cypress-tests.sh fossbilling:test-8.5
|
||||
|
||||
- name: 'Upload Cypress Artifacts'
|
||||
if: ${{ failure() && env.CYPRESS_RECORDING_DISABLED == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: cypress-artifacts
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
tests/E2E/Cypress/artifacts/screenshots
|
||||
tests/E2E/Cypress/artifacts/videos
|
||||
tests/E2E/Cypress/artifacts/downloads
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 'Set up Docker Buildx'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: 'Docker Metadata'
|
||||
id: meta
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: 'Build and Push by Digest'
|
||||
id: build
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: ${{ inputs.build-args }}
|
||||
cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:${{ inputs.cache-scope }}-${{ matrix.suffix }}
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: 'Extract Release Tree'
|
||||
if: inputs.extract-release-tree && matrix.platform == 'linux/amd64'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: ${{ inputs.build-args }}
|
||||
cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:${{ inputs.cache-scope }}-${{ matrix.suffix }}
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 'Set up Docker Buildx'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: 'Docker Metadata'
|
||||
id: meta
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Translation Source Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**/*.php'
|
||||
- 'src/**/*.twig'
|
||||
- '.github/scripts/extract_pot.py'
|
||||
- '.github/workflows/i18n-pot-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
pot-diff:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: 'Checkout PR head'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Extract msgids (head)'
|
||||
run: python3 .github/scripts/extract_pot.py src /tmp/head.pot
|
||||
|
||||
- name: 'Extract msgids (base)'
|
||||
run: |
|
||||
git worktree add /tmp/base "${{ github.event.pull_request.base.sha }}"
|
||||
python3 .github/scripts/extract_pot.py /tmp/base/src /tmp/base.pot
|
||||
git worktree remove --force /tmp/base
|
||||
|
||||
- name: 'Compare'
|
||||
id: diff
|
||||
run: |
|
||||
python3 .github/scripts/extract_pot.py --diff /tmp/base.pot /tmp/head.pot > /tmp/pot-diff.txt 2>&1 || true
|
||||
{
|
||||
echo 'body<<EOF'
|
||||
cat /tmp/pot-diff.txt
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Comment on PR'
|
||||
# Fork PRs get a read-only token, so commenting may fail there;
|
||||
# the extraction result above is still visible in the job log.
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
POT_DIFF: ${{ steps.diff.outputs.body }}
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- fossbilling-i18n-pot-diff -->';
|
||||
const diff = (process.env.POT_DIFF || '').trim() || '(no output)';
|
||||
const body = `${marker}\n### Translation Source Check\n\n` +
|
||||
`How this PR changes the gettext msgid set ` +
|
||||
`(casing-only changes silently invalidate existing translations):\n\n` +
|
||||
'```\n' + diff + '\n```\n\n' +
|
||||
`_Informational only. The .pot is regenerated by the scheduled job in FOSSBilling/locale._`;
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Docker Compose'
|
||||
uses: docker/setup-compose-action@4eb059ff7f16592f9c84d5ca339c53cb7c5064e2 # v2.3.0
|
||||
uses: docker/setup-compose-action@54042514f505b273907334ae2b9cdbb9a0213c1a # v2.4.0
|
||||
|
||||
# Reuses the image already built once in the PHP Build and Test (8.5) job.
|
||||
- name: 'Download Test Image Artifact'
|
||||
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 'Set up Docker Buildx'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: 'Build Docker Test Image'
|
||||
id: build_read_cache
|
||||
if: ${{ !inputs.write-cache }}
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: |
|
||||
PHP_VERSION=${{ matrix.php }}
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: 'Build Docker Test Image'
|
||||
id: build_write_cache
|
||||
if: inputs.write-cache
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: |
|
||||
PHP_VERSION=${{ matrix.php }}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Playwright Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
playwright-tests:
|
||||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: 'Typecheck Test Suite'
|
||||
run: npm ci --no-audit --no-fund && npm run pw:tsc
|
||||
|
||||
- name: 'Build Admin Widget Assets'
|
||||
run: NODE_ENV=production npm run build-admin_default
|
||||
|
||||
- name: 'Set up Docker Compose'
|
||||
uses: docker/setup-compose-action@54042514f505b273907334ae2b9cdbb9a0213c1a # v2.4.0
|
||||
|
||||
# Reuses the image already built once in the PHP Build and Test (8.5) job.
|
||||
- name: 'Download Test Image Artifact'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: test-image-8.5.tar.zst
|
||||
path: /tmp
|
||||
|
||||
- name: 'Load Test Image'
|
||||
run: zstd --decompress --stdout /tmp/test-image-8.5.tar.zst | docker load
|
||||
|
||||
- name: 'Run Playwright Tests'
|
||||
run: bash ./.github/scripts/run-docker-playwright-tests.sh fossbilling:test-8.5
|
||||
|
||||
- name: 'Upload Playwright Artifacts'
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: playwright-artifacts
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
tests/E2E/Playwright/artifacts
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: 'Set up Docker Buildx'
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4
|
||||
|
||||
- name: 'Build Release Artifact'
|
||||
id: build_read_cache
|
||||
if: ${{ !inputs.write-cache }}
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: |
|
||||
FOSSBILLING_VERSION=${{ github.sha }}
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: 'Build Release Artifact'
|
||||
id: build_write_cache
|
||||
if: inputs.write-cache
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7
|
||||
with:
|
||||
build-args: |
|
||||
FOSSBILLING_VERSION=${{ github.sha }}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Report Release to Sentry
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
report-release:
|
||||
name: 'Report Release to Sentry'
|
||||
permissions:
|
||||
contents: read
|
||||
# getsentry/action-release runs as a docker:// container action on Linux, which ubuntu-slim
|
||||
# can't support (unprivileged, no Docker daemon).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref_name }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Load Sentry Auth Token'
|
||||
uses: 1password/load-secrets-action@70062d7a876d3eb6334754fa26efd2fbd90c32f2 # v5.0.1
|
||||
with:
|
||||
export-env: true
|
||||
version: '2.39.0'
|
||||
env:
|
||||
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
|
||||
# Sentry (GitHub CI/CD) credentials stored in 1Password.
|
||||
SENTRY_AUTH_TOKEN: op://mkqkefntgcmjlgyxzeaekzuuvq/hw6jxytb2lqqjl5zzu4jjqztke/credential
|
||||
|
||||
- name: 'Report Release to Sentry'
|
||||
id: sentry-release
|
||||
uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0
|
||||
env:
|
||||
SENTRY_ORG: fossbilling
|
||||
SENTRY_PROJECT: fossbilling
|
||||
with:
|
||||
# Must match SentryHelper::SENTRY_RELEASE_PACKAGE . '@' . Version::VERSION exactly.
|
||||
release: fossbilling@${{ github.ref_name }}
|
||||
set_commits: auto
|
||||
finalize: true
|
||||
+15
-7
@@ -5,11 +5,12 @@
|
||||
/cache
|
||||
/src/config.php
|
||||
/src/config.old.php
|
||||
/src/data/log/*
|
||||
/src/data/cache/*
|
||||
/src/data/uploads/*
|
||||
/src/data/update-finalization.json
|
||||
/src/data/*
|
||||
!/src/data/.htaccess
|
||||
!/src/data/index.html
|
||||
!/src/data/cache/.gitkeep
|
||||
!/src/data/log/index.html
|
||||
!/src/data/uploads/index.html
|
||||
/src/public/assets
|
||||
/src/vendor/*
|
||||
/src/extensions/*/*/vendor/*
|
||||
@@ -28,8 +29,15 @@ node_modules
|
||||
### friendsofphp/php-cs-fixer ###
|
||||
.php-cs-fixer.cache
|
||||
src/install/php_error.log
|
||||
src/themes/huraga/config/settings_data.json
|
||||
src/themes/default/client/config/settings_data.json
|
||||
output.log
|
||||
|
||||
### Cypress ###
|
||||
tests/E2E/Cypress/artifacts
|
||||
### Playwright ###
|
||||
tests/E2E/Playwright/artifacts
|
||||
|
||||
### Demo seed (generated per-run artifacts) ###
|
||||
tools/demo-seed/manifest*.json
|
||||
.ddev/*.sql.gz
|
||||
|
||||
### Python (extractor script bytecode cache) ###
|
||||
.github/scripts/__pycache__/
|
||||
@@ -23,7 +23,8 @@ FOSSBilling is a free and open-source billing and client management solution des
|
||||
* The FOSSBilling project is in the process of gradually phasing out RedBeanPHP in favor of Doctrine ORM.
|
||||
* When writing new pieces of code, avoid RedBeanPHP.
|
||||
* If you are assisting with the migration from RedBeanPHP to Doctrine, do your best to keep compatibility with the existing table structure.
|
||||
* When refactoring API endpoints, check how the `$di['pager']` works in `src/library/FOSSBilling/Pagination.php`. `paginateDoctrineQuery()` is the replacement for `getPaginatedResultSet()`.
|
||||
* When refactoring API endpoints, check how the `$di['pager']` works in `src/library/FOSSBilling/Pagination.php`. `paginateDoctrineQuery()` replaces `getPaginatedResultSet()` for entities that implement `ApiArrayInterface`; use `paginateMappedQuery()` when the API shape needs service-context mapping (identity/deep flags, cross-module lookups, joined scalars).
|
||||
* Id and foreign-key columns are `Types::BIGINT` almost everywhere (matching `bigint(20)` in `src/install/sql/structure.sql`), typed `?int` on the PHP side, not `int|string`. This is intentional, not an oversight: `Doctrine\DBAL\Types\BigIntType::convertToPHPValue()` only widens to `string` when a value doesn't fit PHP's native `int`, which cannot happen here — MySQL's signed `BIGINT` range matches PHP's native 64-bit `int` range exactly, none of these columns are `UNSIGNED`, and Composer enforces a 64-bit PHP 8.3+ runtime through the `php-64bit` platform requirement. Keep new id/FK columns `Types::BIGINT`/`?int` for consistency, and cross-reference `structure.sql` before ever declaring one `Types::INTEGER` — a narrower id/FK column than the primary key it references is a real bug (see issue #4187), not a cosmetic mismatch.
|
||||
* [Monolog](https://github.com/Seldaek/monolog): Logging framework. Used via `$di['logger']` (`/src/library/FOSSBilling/Monolog.php`).
|
||||
* [dompdf](https://github.com/dompdf/dompdf): PDF generation for invoices and documents
|
||||
* [Pimple](https://github.com/silexphp/Pimple): Dependency injection container, see `src/di.php`.
|
||||
@@ -72,7 +73,9 @@ The application uses a modern PHP architecture with dependency injection, event-
|
||||
* **Composer** for PHP dependency management
|
||||
* **Node.js and npm** for frontend asset management
|
||||
* Docker and DDEV use Node.js 24, and `package.json` requires npm 11 or newer.
|
||||
* **MySQL/MariaDB** database server
|
||||
* A database server: **MySQL/MariaDB**, **PostgreSQL**, or **SQLite**
|
||||
* Fresh installs support all three drivers (`pdo_mysql`, `pdo_pgsql`, `pdo_sqlite`) — see `DriverManagerFactory::SUPPORTED_DRIVERS`. Schema is generated from Doctrine entity metadata (`FOSSBilling\Doctrine\SchemaInstaller`) on every driver, MySQL/MariaDB included; `src/install/sql/structure.sql` is no longer used for a fresh install of any driver, and stays in the repo only as the frozen definition `UpdatePatcher`'s legacy MySQL-only patches still assume for pre-cutover installs upgrading.
|
||||
* Upgrading an *existing* install keeps the database **structure** current automatically, on every driver: `UpdatePatcher::applyCorePatches()` always runs `FOSSBilling\Doctrine\SchemaSynchronizer` after its (MySQL/MariaDB-only) legacy patches, which diffs live schema against current Doctrine entity metadata and applies only additive changes (new tables/columns/indexes) — it never drops or alters existing structure, and never adds a foreign key constraint to a table that already exists (only to a table it's creating fresh), since FOSSBilling's schema has never had real FK constraints and existing rows aren't guaranteed to satisfy one. Anything it doesn't recognize or won't touch is logged, not silently ignored. What it does *not* do: replay the **data transformations** several historical patches perform (splitting/merging tables, rewriting existing rows) — those remain MySQL/MariaDB-only raw SQL forever, so an install can pick up new schema going forward but won't get an old data-migration's effect retroactively on any driver.
|
||||
|
||||
**Important:** If PHP is not installed or configured on the system, try using `ddev` to manage the development environment and run PHP/Composer commands.
|
||||
DDEV is configured with `docroot: src`, Node.js 24, MariaDB 10.11, and `data/uploads` as its upload directory. Its post-start hook installs Composer/npm dependencies and rebuilds frontend assets when needed.
|
||||
@@ -102,8 +105,8 @@ npm run build
|
||||
This command builds assets for:
|
||||
|
||||
* Core public browser assets in `src/public/assets`
|
||||
* `admin_default` theme
|
||||
* `huraga` theme
|
||||
* `admin_default` theme (`src/themes/default/admin`)
|
||||
* `huraga` theme (`src/themes/default/client`)
|
||||
|
||||
The core build script is `frontend/esbuild.mjs`. Theme build scripts are defined in each theme's `package.json` and use local `esbuild.mjs` files for configuration:
|
||||
|
||||
@@ -127,10 +130,10 @@ npm run build-huraga
|
||||
|
||||
```bash
|
||||
# Theme development builds
|
||||
cd src/themes/admin_default && npm run dev
|
||||
cd src/themes/default/admin && npm run dev
|
||||
|
||||
# Watch mode for active Huraga development
|
||||
cd src/themes/huraga && npm run dev
|
||||
cd src/themes/default/client && npm run dev
|
||||
```
|
||||
|
||||
### Testing
|
||||
@@ -143,7 +146,7 @@ composer test
|
||||
|
||||
The default suite covers unit tests in `tests/Unit` and module unit tests in `src/modules/*/tests/Unit`.
|
||||
Live API tests are in `src/modules/*/tests/E2E/` and run through Pest via the "E2E" test suite in `phpunit.xml.dist` when `APP_URL` and `TEST_API_KEY` are available.
|
||||
Cypress end-to-end tests live under `tests/E2E/Cypress`.
|
||||
Playwright end-to-end tests live under `tests/E2E/Playwright`. Run them locally with `npm run pw:open` (UI mode) or `npm run pw:run`; they target the app at `PLAYWRIGHT_BASE_URL` and require `ADMIN_EMAIL` and `ADMIN_PASSWORD` for admin flows.
|
||||
|
||||
### Code Quality Tools
|
||||
|
||||
@@ -186,7 +189,7 @@ src/
|
||||
├── cron.php # Scheduled tasks entry point
|
||||
├── library/ # Core libraries and third-party integrations
|
||||
├── modules/ # Application modules (50+ modules)
|
||||
├── themes/ # UI themes (admin_default, huraga)
|
||||
├── themes/ # UI themes (shipped `default` package: default/admin, default/client, default/shared)
|
||||
├── public/ # Public core assets, branding, and gateway icons
|
||||
├── data/ # Runtime data (cache, logs, uploads)
|
||||
├── install/ # Installation scripts and assets
|
||||
@@ -198,7 +201,7 @@ tests/ # Pest, live API, and end-to-end test structure
|
||||
|
||||
### Front-end Guidelines
|
||||
|
||||
* `admin_default` theme icons are compiled from the `src/themes/admin_default/assets/icons` directory and can be referenced from within the Twig template like so:
|
||||
* `admin_default` theme icons are compiled from the `src/themes/default/admin/custom-icons` directory and can be referenced from within the Twig template like so:
|
||||
|
||||
```html
|
||||
<svg class="icon">
|
||||
@@ -260,5 +263,5 @@ tests/ # Pest, live API, and end-to-end test structure
|
||||
## Important Notes
|
||||
|
||||
* **PHP Version:** Requires PHP 8.3 or higher
|
||||
* **Database:** Requires MySQL/MariaDB database server
|
||||
* **Database:** MySQL, MariaDB, PostgreSQL, or SQLite for fresh installs; upgrades on every driver stay in schema sync automatically, but MySQL/MariaDB-only historical data migrations never replay on any driver (see Prerequisites above)
|
||||
* **License:** Apache License 2.0
|
||||
+43
-1
@@ -60,6 +60,48 @@ If you need to sell a new type of product you will implement a Service type modu
|
||||
|
||||
Other modules extend the whole FOSSBilling API with any functionality needed. Check existing modules to get an idea of what is already shipped with the default structure of FOSSBilling.
|
||||
|
||||
### Typed module events
|
||||
|
||||
FOSSBilling modules use typed events for extension points. Event classes extend `FOSSBilling\Events\Event`, and each module keeps its event classes in `src/modules/<Module>/Event/`. The shared base event is in `src/library/FOSSBilling/Events/Event.php`.
|
||||
|
||||
Define an event in the owning module's `Event/` directory and dispatch it from the code that owns the operation:
|
||||
|
||||
```php
|
||||
namespace Box\Mod\Example\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
final class BeforeExampleOperationEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $entityId)
|
||||
{
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
use Box\Mod\Example\Event\BeforeExampleOperationEvent;
|
||||
|
||||
$this->di['event_dispatcher']->dispatch(new BeforeExampleOperationEvent($entityId));
|
||||
```
|
||||
|
||||
A module can listen from a public instance method on its `Service` class. Add Symfony's `#[AsEventListener]` attribute and type the first parameter as the event class. Listeners on active modules are registered when the dispatcher initializes.
|
||||
|
||||
```php
|
||||
use Box\Mod\Support\Event\AfterTicketOpenedEvent;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
|
||||
#[AsEventListener(priority: 10)]
|
||||
public function notifyTicketOpened(AfterTicketOpenedEvent $event): void
|
||||
{
|
||||
// Handle the opened ticket using the event's documented data.
|
||||
}
|
||||
```
|
||||
|
||||
Higher priority listeners run first. Event propagation follows Symfony's event behavior, including `stopPropagation()`. Listeners run synchronously as part of dispatch.
|
||||
|
||||
Treat each event class as the contract for its extension point. Prefer readonly constructor properties for context listeners may inspect. Keep mutable data private and expose only the getters and setters listeners are meant to use. Do not include credentials or unrelated request data in an event payload. Name event classes for the operation and timing, such as `Before...Event` or `After...Event`.
|
||||
|
||||
## How can I contribute?
|
||||
|
||||
There are a lot of different ways that you can get involved in the FOSSBilling project. Let's take a look at some of the main ones:
|
||||
@@ -135,7 +177,7 @@ The process described here has several goals:
|
||||
|
||||
Please follow these steps to have your contribution considered by the maintainers:
|
||||
|
||||
1. Follow the [style guides](#styleguides)
|
||||
1. Follow the [style guides](#style-guides)
|
||||
2. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing <details><summary>What if the status checks are failing?</summary>If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.</details>
|
||||
|
||||
Before a PR can be merged it must pass all of the automated tests and also be reviewed by two maintainers. All of the above requirements must be met before your pull request will be reviewed. Please be aware that the reviewers may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.
|
||||
|
||||
+18
-12
@@ -1,4 +1,4 @@
|
||||
# syntax=docker/dockerfile:1.26@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
|
||||
# syntax=docker/dockerfile:1.27@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e
|
||||
|
||||
ARG PHP_VERSION=8.5
|
||||
ARG NODE_VERSION=24
|
||||
@@ -47,7 +47,7 @@ FROM php-base AS composer-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=composer:2@sha256:4d71c3c2109c61d5415544264b59ad4087e4c5b7244481723664138fd36d5040 /usr/bin/composer /usr/bin/composer
|
||||
COPY --from=composer:2@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac /usr/bin/composer /usr/bin/composer
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
@@ -61,6 +61,8 @@ COPY src/library ./src/library
|
||||
COPY src/modules ./src/modules
|
||||
|
||||
RUN --mount=type=cache,target=/tmp/composer-cache,id=composer-prod \
|
||||
set -eux; \
|
||||
find ./src/modules -type d -name tests -prune -exec rm -rf {} +; \
|
||||
COMPOSER_CACHE_DIR=/tmp/composer-cache \
|
||||
composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction --no-progress
|
||||
|
||||
@@ -78,14 +80,14 @@ FROM node:${NODE_VERSION}-bookworm-slim AS frontend-assets
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY src/themes/admin_default/package.json src/themes/admin_default/package.json
|
||||
COPY src/themes/huraga/package.json src/themes/huraga/package.json
|
||||
COPY src/themes/default/admin/package.json src/themes/default/admin/package.json
|
||||
COPY src/themes/default/client/package.json src/themes/default/client/package.json
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm CYPRESS_INSTALL_BINARY=0 npm ci
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src/themes/admin_default ./src/themes/admin_default
|
||||
COPY src/themes/huraga ./src/themes/huraga
|
||||
COPY src/themes/default/admin ./src/themes/default/admin
|
||||
COPY src/themes/default/client ./src/themes/default/client
|
||||
COPY src/modules ./src/modules
|
||||
COPY frontend ./frontend
|
||||
|
||||
@@ -102,16 +104,17 @@ ARG INSTALL_TRANSLATIONS=true
|
||||
ARG TRANSLATIONS_URL=https://github.com/FOSSBilling/locale/releases/latest/download/translations.zip
|
||||
ARG TRANSLATIONS_SHA256=
|
||||
|
||||
COPY --from=composer:2@sha256:4d71c3c2109c61d5415544264b59ad4087e4c5b7244481723664138fd36d5040 /usr/bin/composer /usr/bin/composer
|
||||
COPY --from=composer:2@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac /usr/bin/composer /usr/bin/composer
|
||||
COPY composer.json ./composer.json
|
||||
COPY src ./src
|
||||
COPY README.md LICENSE ./src/
|
||||
COPY --from=php-vendor /app/src/vendor ./src/vendor
|
||||
COPY --from=frontend-assets /app/src/public/assets ./src/public/assets
|
||||
COPY --from=frontend-assets /app/src/themes/admin_default/assets/build ./src/themes/admin_default/assets/build
|
||||
COPY --from=frontend-assets /app/src/themes/huraga/assets/build ./src/themes/huraga/assets/build
|
||||
COPY --from=frontend-assets /app/src/themes/default/admin/assets/build ./src/themes/default/admin/assets/build
|
||||
COPY --from=frontend-assets /app/src/themes/default/client/assets/build ./src/themes/default/client/assets/build
|
||||
|
||||
RUN set -eux; \
|
||||
find ./src/modules -type d -name tests -prune -exec rm -rf {} +; \
|
||||
mkdir -p ./src/locale; \
|
||||
if [ "${INSTALL_TRANSLATIONS}" = "true" ]; then \
|
||||
curl -fsSL "${TRANSLATIONS_URL}" -o /tmp/translations.zip; \
|
||||
@@ -125,8 +128,9 @@ RUN set -eux; \
|
||||
FOSSBILLING_VERSION_TRUNCATE="${FOSSBILLING_VERSION_TRUNCATE}" \
|
||||
SENTRY_DSN="${SENTRY_DSN}" \
|
||||
php -r '$version = getenv("FOSSBILLING_VERSION") ?: "0.0.1"; $truncate = (int) (getenv("FOSSBILLING_VERSION_TRUNCATE") ?: 0); if ($truncate > 0) { $version = substr($version, 0, $truncate); } $versionFile = "./src/library/FOSSBilling/Version.php"; $contents = file_get_contents($versionFile); $quote = chr(39); $pattern = "/public const string VERSION = " . $quote . "[^" . $quote . "]+" . $quote . ";/"; $replacement = "public const string VERSION = " . var_export($version, true) . ";"; $contents = preg_replace($pattern, $replacement, $contents, 1, $count); if ($contents === null || $count !== 1) { fwrite(STDERR, "Failed to replace FOSSBilling version.\n"); exit(1); } file_put_contents($versionFile, $contents); $dsn = getenv("SENTRY_DSN"); if ($dsn !== false && $dsn !== "") { $sentryFile = "./src/library/FOSSBilling/SentryHelper.php"; file_put_contents($sentryFile, str_replace("--replace--this--during--release--process--", $dsn, file_get_contents($sentryFile))); }'; \
|
||||
php -r '$composer = json_decode(file_get_contents("./composer.json"), true, 512, JSON_THROW_ON_ERROR); $composer["autoload"]["psr-4"]["Box\\Mod\\"] = "modules/"; $composer["autoload"]["classmap"] = ["library/"]; $composer["config"]["vendor-dir"] = "vendor"; file_put_contents("./src/composer.json", json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);'; \
|
||||
php -r '$composer = json_decode(file_get_contents("./composer.json"), true, 512, JSON_THROW_ON_ERROR); $composer["autoload"]["psr-4"]["FOSSBilling\\"] = "library/FOSSBilling/"; $composer["autoload"]["psr-4"]["Box\\Mod\\"] = "modules/"; $composer["autoload"]["classmap"] = ["library/"]; $composer["config"]["vendor-dir"] = "vendor"; file_put_contents("./src/composer.json", json_encode($composer, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL);'; \
|
||||
composer --no-plugins dump-autoload --working-dir=./src --no-dev --optimize --no-interaction; \
|
||||
php -r '$class = "FOSSBilling\\Cache\\CacheFactory"; require "./src/vendor/autoload.php"; if (!class_exists($class)) { fwrite(STDERR, "Release Composer autoloader is missing {$class}.\n"); exit(1); }'; \
|
||||
rm ./composer.json ./src/composer.json; \
|
||||
chmod -R u=rwX,go=rX ./src
|
||||
|
||||
@@ -155,9 +159,11 @@ FROM runtime AS test
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY --from=release-tree /app/src ./src
|
||||
COPY src/modules ./src/modules
|
||||
COPY --from=php-dev-vendor /app/src/vendor ./src/vendor
|
||||
COPY composer.json composer.lock phpstan.neon phpstan-baseline.neon phpunit.xml.dist ./
|
||||
COPY composer.json composer.lock phpstan.neon phpunit.xml.dist ./
|
||||
COPY tests ./tests
|
||||
COPY tools ./tools
|
||||
|
||||
RUN set -eux; \
|
||||
php -r '$config = require "./src/config-sample.php"; file_put_contents("./src/config.php", "<?php\nreturn " . var_export($config, true) . ";\n");'; \
|
||||
|
||||
@@ -66,9 +66,9 @@ If you prefer to start a conversation first, drop by the [forum](https://forum.f
|
||||
|
||||
<a href="https://www.star-history.com/?repos=FOSSBilling%2FFOSSBilling&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=FOSSBilling/FOSSBilling&type=date&theme=dark&legend=top-left&sealed_token=ioJopLgtNzi8yMGmGszWYUH4Wt8JZOgRfi9GscyJOzAhSUWXjpK_hCml7VrZAOnJkL7sWGtd47X7WWR-a0hnJrYr6v-S6IjBNrQJW36PZPQXZSXcfiTnasf0F_U2DDomBkP_p3hUcPegkId4CIwL5vk4dM1S_K-bjklbRDDqnSJpnZg7Pus4QG8uez3K" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=FOSSBilling/FOSSBilling&type=date&legend=top-left&sealed_token=ioJopLgtNzi8yMGmGszWYUH4Wt8JZOgRfi9GscyJOzAhSUWXjpK_hCml7VrZAOnJkL7sWGtd47X7WWR-a0hnJrYr6v-S6IjBNrQJW36PZPQXZSXcfiTnasf0F_U2DDomBkP_p3hUcPegkId4CIwL5vk4dM1S_K-bjklbRDDqnSJpnZg7Pus4QG8uez3K" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=FOSSBilling/FOSSBilling&type=date&legend=top-left&sealed_token=ioJopLgtNzi8yMGmGszWYUH4Wt8JZOgRfi9GscyJOzAhSUWXjpK_hCml7VrZAOnJkL7sWGtd47X7WWR-a0hnJrYr6v-S6IjBNrQJW36PZPQXZSXcfiTnasf0F_U2DDomBkP_p3hUcPegkId4CIwL5vk4dM1S_K-bjklbRDDqnSJpnZg7Pus4QG8uez3K" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=FOSSBilling/FOSSBilling&type=date&theme=dark&legend=top-left&sealed_token=Imt45TQsmOl2wo97NTmR7vsrH9SbJ1R5OvbXL3zLgFfc6f_rrzAxDDqgxSmD-EowpNbyzjx0vFfBWCU-VN5HNlE67V6MN-Ssc7LrlZRPkdHSbw1V3NeImFQU3carSCrPm1Af3jbXZjqukrKLkqYii4bXvG_9006YdjIjWzbbGByUhXZOXgv7zH_fphhz" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=FOSSBilling/FOSSBilling&type=date&legend=top-left&sealed_token=Imt45TQsmOl2wo97NTmR7vsrH9SbJ1R5OvbXL3zLgFfc6f_rrzAxDDqgxSmD-EowpNbyzjx0vFfBWCU-VN5HNlE67V6MN-Ssc7LrlZRPkdHSbw1V3NeImFQU3carSCrPm1Af3jbXZjqukrKLkqYii4bXvG_9006YdjIjWzbbGByUhXZOXgv7zH_fphhz" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=FOSSBilling/FOSSBilling&type=date&legend=top-left&sealed_token=Imt45TQsmOl2wo97NTmR7vsrH9SbJ1R5OvbXL3zLgFfc6f_rrzAxDDqgxSmD-EowpNbyzjx0vFfBWCU-VN5HNlE67V6MN-Ssc7LrlZRPkdHSbw1V3NeImFQU3carSCrPm1Af3jbXZjqukrKLkqYii4bXvG_9006YdjIjWzbbGByUhXZOXgv7zH_fphhz" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
||||
+5
-5
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"php-64bit": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-mbstring": "*",
|
||||
@@ -49,11 +50,11 @@
|
||||
"composer/composer": "^2.8.12",
|
||||
"dicebear/core": "^10.0",
|
||||
"dicebear/styles": "^10.0",
|
||||
"doctrine/orm": "^3.6",
|
||||
"doctrine/dbal": "^4.5",
|
||||
"doctrine/orm": "^3.7",
|
||||
"dompdf/dompdf": "^3.1.4",
|
||||
"egulias/email-validator": "^4.0.4",
|
||||
"filp/whoops": "^2.18.4",
|
||||
"gabordemooij/redbean": "^5.7.5",
|
||||
"league/commonmark": "^2.8",
|
||||
"league/csv": "^9.28",
|
||||
"liborm85/composer-vendor-cleaner": "^1.7.1",
|
||||
@@ -69,6 +70,7 @@
|
||||
"sentry/sentry": "^4.19.1",
|
||||
"symfony/cache": "^7.4.1",
|
||||
"symfony/console": "^7.4.1",
|
||||
"symfony/event-dispatcher": "^7.4",
|
||||
"symfony/filesystem": "^7.4",
|
||||
"symfony/finder": "^7.4",
|
||||
"symfony/html-sanitizer": "^7.4",
|
||||
@@ -97,6 +99,7 @@
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FOSSBilling\\": "src/library/FOSSBilling/",
|
||||
"Box\\Mod\\": "src/modules/"
|
||||
},
|
||||
"classmap": [
|
||||
@@ -134,9 +137,6 @@
|
||||
".github/",
|
||||
".devcontainer/"
|
||||
],
|
||||
"gabordemooij/redbean": [
|
||||
"testing/"
|
||||
],
|
||||
"mattketmo/email-checker": [
|
||||
"res/"
|
||||
],
|
||||
|
||||
Generated
+566
-666
File diff suppressed because it is too large.
Load diff
+100
-9
@@ -2,6 +2,11 @@
|
||||
"version": "0.2",
|
||||
"language": "en",
|
||||
"words": [
|
||||
"TINYTEXT",
|
||||
"MEDIUMTEXT",
|
||||
"dropup",
|
||||
"focusable",
|
||||
"NOCASE",
|
||||
"FOSS",
|
||||
"huraga",
|
||||
"FOSSBilling",
|
||||
@@ -33,6 +38,7 @@
|
||||
"jambroseclarke",
|
||||
"Egulias",
|
||||
"ICANN",
|
||||
"IRTP",
|
||||
"failovers",
|
||||
"Autosize",
|
||||
"analyse",
|
||||
@@ -54,12 +60,50 @@
|
||||
"Customtest",
|
||||
"mysite",
|
||||
"myhost",
|
||||
"mypackage",
|
||||
"inversed",
|
||||
"Autoresponder",
|
||||
"initialises",
|
||||
"WORKTREE",
|
||||
"previewable",
|
||||
"falsey",
|
||||
"unindexed",
|
||||
"catchable",
|
||||
"stackable",
|
||||
"nelexa",
|
||||
"apisecret",
|
||||
"cutover",
|
||||
"parameterless",
|
||||
"mysqldump",
|
||||
"mysqldump's",
|
||||
"portableize",
|
||||
"setval",
|
||||
"nextval",
|
||||
"repoints",
|
||||
"pgsql",
|
||||
"julianday",
|
||||
"mtimes",
|
||||
"unroutable",
|
||||
"namedlock",
|
||||
"entrancy",
|
||||
"writability",
|
||||
"rediss",
|
||||
"dedup",
|
||||
"GHSA",
|
||||
"macrovector",
|
||||
"rubygems",
|
||||
"Åland",
|
||||
"demoseed",
|
||||
"Novak",
|
||||
"Kowalski",
|
||||
"Rossi",
|
||||
"Tanaka",
|
||||
"Johansson",
|
||||
"Fontaine",
|
||||
"Petrov",
|
||||
"Okafor",
|
||||
"Bakker",
|
||||
"Novakova",
|
||||
"WHMCS",
|
||||
"Blesta",
|
||||
"Nonmerchant",
|
||||
@@ -68,9 +112,9 @@
|
||||
"autoloading",
|
||||
"unconfigured",
|
||||
"Dedup",
|
||||
"dedup",
|
||||
"TOCTOU",
|
||||
"opped"
|
||||
"opped",
|
||||
"NONTRANSITIONAL"
|
||||
],
|
||||
"ignoreWords": [
|
||||
"thead",
|
||||
@@ -143,6 +187,8 @@
|
||||
"sysdate",
|
||||
"promocode",
|
||||
"maxuses",
|
||||
"maxlifetime",
|
||||
"varbinary",
|
||||
"freesetup",
|
||||
"nopadding",
|
||||
"fieldset",
|
||||
@@ -153,12 +199,15 @@
|
||||
"selectbox",
|
||||
"sitekey",
|
||||
"sitekey",
|
||||
"siteverify",
|
||||
"autodark",
|
||||
"noopener",
|
||||
"datepicker",
|
||||
"tablist",
|
||||
"subpages",
|
||||
"subpage",
|
||||
"subpath",
|
||||
"subpaths",
|
||||
"keyup",
|
||||
"aligncenter",
|
||||
"tabpanel",
|
||||
@@ -192,6 +241,7 @@
|
||||
"servicedomain",
|
||||
"markitup",
|
||||
"ckeditor",
|
||||
"contenteditable",
|
||||
"codexplorer",
|
||||
"Xplorer",
|
||||
"POSTFIELDS",
|
||||
@@ -215,16 +265,21 @@
|
||||
"upath",
|
||||
"pathe",
|
||||
"tabler",
|
||||
"tblr",
|
||||
"apexcharts",
|
||||
"topnav",
|
||||
"yourparamvalue",
|
||||
"userid",
|
||||
"customerid",
|
||||
"recsonpage",
|
||||
"subkey",
|
||||
"blockquote",
|
||||
"floatright",
|
||||
"copyfields",
|
||||
"mydomain",
|
||||
"WHOIS",
|
||||
"RDAP",
|
||||
"exämple",
|
||||
"supportticket",
|
||||
"massmailer",
|
||||
"kbcategories",
|
||||
@@ -241,6 +296,8 @@
|
||||
"ພູເກັດ",
|
||||
"ctable",
|
||||
"DATEDIFF",
|
||||
"AUTOINCREMENT",
|
||||
"CLOB",
|
||||
"cbrepo",
|
||||
"OODB",
|
||||
"Inno",
|
||||
@@ -254,6 +311,7 @@
|
||||
"multisite",
|
||||
"mcrypt",
|
||||
"stopforumspam",
|
||||
"yopmail",
|
||||
"Internetbs",
|
||||
"binded",
|
||||
"tada",
|
||||
@@ -374,6 +432,8 @@
|
||||
"FileCacheExcption",
|
||||
"Autoloader",
|
||||
"autoloader",
|
||||
"autoloading",
|
||||
"autoloaded",
|
||||
"qwertyuiopasdfghjklzxccvbnm",
|
||||
"QWERTYUIOPASDFGHJKLZXCVBNM",
|
||||
"subkeys",
|
||||
@@ -386,10 +446,12 @@
|
||||
"ordermodel",
|
||||
"homoglyph",
|
||||
"TINYINT",
|
||||
"ENCAPSED",
|
||||
"homoglyph",
|
||||
"oldconfig",
|
||||
"ordermodel",
|
||||
"TINYINT",
|
||||
"LONGTEXT",
|
||||
"hostings",
|
||||
"rulesets",
|
||||
"describedby",
|
||||
@@ -438,7 +500,7 @@
|
||||
"Prins",
|
||||
"iplookup",
|
||||
"DDEV",
|
||||
"Cypress",
|
||||
"Playwright",
|
||||
"CSRF",
|
||||
"undismissed",
|
||||
"unmigrated",
|
||||
@@ -547,11 +609,40 @@
|
||||
"Brien",
|
||||
"whsec",
|
||||
"IDNA",
|
||||
"Namingo"
|
||||
"Namingo",
|
||||
"htype",
|
||||
"webspace",
|
||||
"webspaces",
|
||||
"serviceexternal",
|
||||
"aftp",
|
||||
"anonftp",
|
||||
"dnscontrol",
|
||||
"maillists",
|
||||
"nemailf",
|
||||
"nemailml",
|
||||
"nemailr",
|
||||
"spamfilter",
|
||||
"sysinfo",
|
||||
"unemailml",
|
||||
"listpkgs",
|
||||
"addpkg",
|
||||
"createacct",
|
||||
"setupreseller",
|
||||
"setacls",
|
||||
"acllist",
|
||||
"creationtime",
|
||||
"domsecret",
|
||||
"isprivacyprotected",
|
||||
"admincontact",
|
||||
"contactid",
|
||||
"emailaddr",
|
||||
"telno",
|
||||
"telnocc",
|
||||
"actionstatus"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"node_modules/**",
|
||||
"src/themes/*/assets/**",
|
||||
"src/themes/**/assets/**",
|
||||
"src/install/assets/**",
|
||||
"src/install/sql/**",
|
||||
"src/modules/*/assets/**",
|
||||
@@ -579,15 +670,15 @@
|
||||
"src/modules/Invoice/templates/pdf/fonts/OFL.txt",
|
||||
"src/modules/System/templates/admin/mod_system_settings.html.twig",
|
||||
"src/vendor",
|
||||
"src/themes/*/build",
|
||||
"src/themes/*/node_modules",
|
||||
"src/themes/**/build",
|
||||
"src/themes/**/node_modules",
|
||||
"phpstan.neon",
|
||||
"*.config.js",
|
||||
".github/**",
|
||||
".gitignore",
|
||||
"src/.htaccess",
|
||||
"phpstan-baseline.neon",
|
||||
"Dockerfile",
|
||||
".ddev/**"
|
||||
".ddev/**",
|
||||
".agents/skills/**"
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
projectId: 'zap1kb',
|
||||
e2e: {
|
||||
baseUrl: process.env.CYPRESS_BASE_URL || 'http://localhost',
|
||||
specPattern: 'tests/E2E/Cypress/specs/**/*.cy.js',
|
||||
supportFile: 'tests/E2E/Cypress/support/e2e.js',
|
||||
},
|
||||
defaultCommandTimeout: 10000,
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
env: {
|
||||
adminEmail: process.env.CYPRESS_ADMIN_EMAIL || '',
|
||||
adminPassword: process.env.CYPRESS_ADMIN_PASSWORD || '',
|
||||
},
|
||||
video: false,
|
||||
screenshotOnRunFailure: true,
|
||||
screenshotsFolder: 'tests/E2E/Cypress/artifacts/screenshots',
|
||||
videosFolder: 'tests/E2E/Cypress/artifacts/videos',
|
||||
downloadsFolder: 'tests/E2E/Cypress/artifacts/downloads',
|
||||
};
|
||||
+26
-6
@@ -9,18 +9,34 @@ FOSSBilling builds shared browser assets from `frontend/` and theme assets from
|
||||
- `npm run check` runs lightweight JavaScript syntax checks and validates theme icon manifests.
|
||||
- `npm run build-admin_default` and `npm run build-huraga` build a single theme.
|
||||
|
||||
Generated assets are written to `src/public/assets` and `src/themes/*/assets/build`. These paths are ignored by Git and are copied into release artifacts by the Docker build.
|
||||
Generated assets are written to `src/public/assets` and `src/themes/**/assets/build`. These paths are ignored by Git and are copied into release artifacts by the Docker build.
|
||||
|
||||
Admin widget regression tests use isolated browser fixtures and require no application database or admin credentials. Build production assets first so the tests cover purged CSS:
|
||||
|
||||
```sh
|
||||
npm run build:production
|
||||
npx playwright test tests/E2E/Playwright/specs/admin/tabler-widgets.spec.ts
|
||||
```
|
||||
|
||||
## Theme Dependencies
|
||||
|
||||
Themes own the UI packages they consume. Shared tooling in `frontend/tools` can help compile assets, generate sprites, and post-process CSS, but it should not make a theme depend on a specific UI package.
|
||||
|
||||
Examples:
|
||||
Examples (the shipped `default` theme package bundles both — `src/themes/default/admin` and `src/themes/default/client`; their npm workspace package names, `admin_default` and `huraga`, are unrelated to their directory paths):
|
||||
|
||||
- `admin_default` owns Tabler, Coloris, Chart.js, Litepicker, Tom Select, Flag Icons, and its Tabler icon package.
|
||||
- `huraga` owns Bootstrap, Tom Select, Flag Icons, and its Tabler icon package.
|
||||
- `admin_default` (`src/themes/default/admin`) owns Tabler, Coloris, Chart.js, Vanilla Calendar Pro, Tom Select, Flag Icons, and its Tabler icon package.
|
||||
- `huraga` (`src/themes/default/client`) owns Bootstrap, Tom Select, Flag Icons, and its Tabler icon package.
|
||||
- `svg-sprite` is a root development dependency because the shared sprite helper imports it.
|
||||
|
||||
The admin theme uses Tabler's component bundle and `data-bs-*` APIs. Do not load Bootstrap's JavaScript alongside it. The `tabler` global exposes the bundle; `bootstrap` remains a compatibility alias for extensions.
|
||||
|
||||
When using admin components:
|
||||
|
||||
- Keep `.datepicker`, `YYYY-MM-DD`, and `data-name-from` / `data-name-to` for API date fields. The datepicker adapter loads Vanilla Calendar Pro and synchronizes range values within each form.
|
||||
- Use the clipboard adapter to retain copy support on HTTP and when Clipboard API access is denied.
|
||||
- Use `partial_password_strength.html.twig` for translated password feedback; server-side validation remains authoritative.
|
||||
- Use `data-bs-toggle="autosize"` for plain-text notes, not CKEditor fields. Growth is capped at 24rem.
|
||||
|
||||
## Icons
|
||||
|
||||
Theme icon sprites are generated from each theme's `icon-manifest.json`.
|
||||
@@ -34,6 +50,10 @@ Run `npm run check-icons` before changing icon usage.
|
||||
|
||||
## CSS
|
||||
|
||||
Theme vendor CSS belongs in `src/themes/*/assets/css/vendor.css`. Theme-specific styles belong in the theme Sass entrypoint.
|
||||
Theme vendor CSS belongs in `src/themes/**/assets/css/vendor.css`. Theme-specific styles belong in the theme Sass entrypoint. Rules that are genuinely identical across the default admin and client themes belong in `frontend/styles/_shared-theme.scss` instead of being duplicated.
|
||||
|
||||
PurgeCSS runs only for production builds. A PurgeCSS failure fails production builds so release CSS problems are caught early.
|
||||
Load Tom Select's base preset before `tabler-vendors.css` so Tabler's sizing, validation, focus, and theme styles take precedence. Avoid fixed input heights.
|
||||
|
||||
Use `form-text` for admin form help text, preserve visible keyboard focus indicators, and use color tokens with `color-mix()` instead of deprecated RGB tokens.
|
||||
|
||||
PurgeCSS runs only for production builds. A PurgeCSS failure fails production builds so release CSS problems are caught early. Preserve selectors generated by runtime components, including the calendar's `data-vc-*` states.
|
||||
@@ -0,0 +1,25 @@
|
||||
// Rules genuinely identical between the default admin and client themes.
|
||||
// Deliberately small and conservative: most component styling is
|
||||
// framework-specific (Tabler vs Bootstrap custom properties) and stays
|
||||
// duplicated on purpose rather than forcing a shared abstraction here.
|
||||
|
||||
.fb-phone-country-code {
|
||||
width: 5.5rem;
|
||||
flex: 0 0 5.5rem;
|
||||
max-width: 5.5rem;
|
||||
}
|
||||
|
||||
// Every property here was identical between the two themes except
|
||||
// background-color, which is genuinely framework-specific (Tabler's
|
||||
// --tblr-bg-surface vs Bootstrap's --bs-secondary-bg) - each theme's own
|
||||
// stylesheet still sets that one property itself, after @use-ing this file.
|
||||
.db-avatar {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
background-size: 100% 100%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -9,23 +9,23 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const themes = [
|
||||
{
|
||||
code: 'huraga',
|
||||
code: 'default/client',
|
||||
area: 'client',
|
||||
themePath: resolve(rootDir, 'src/themes/huraga'),
|
||||
themePath: resolve(rootDir, 'src/themes/default/client'),
|
||||
scanPaths: [
|
||||
resolve(rootDir, 'src/themes/huraga/html'),
|
||||
resolve(rootDir, 'src/themes/huraga/assets'),
|
||||
resolve(rootDir, 'src/themes/default/client/html'),
|
||||
resolve(rootDir, 'src/themes/default/client/assets'),
|
||||
resolve(rootDir, 'src/modules'),
|
||||
],
|
||||
disallowFilled: true,
|
||||
},
|
||||
{
|
||||
code: 'admin_default',
|
||||
code: 'default/admin',
|
||||
area: 'admin',
|
||||
themePath: resolve(rootDir, 'src/themes/admin_default'),
|
||||
themePath: resolve(rootDir, 'src/themes/default/admin'),
|
||||
scanPaths: [
|
||||
resolve(rootDir, 'src/themes/admin_default/html'),
|
||||
resolve(rootDir, 'src/themes/admin_default/assets'),
|
||||
resolve(rootDir, 'src/themes/default/admin/html'),
|
||||
resolve(rootDir, 'src/themes/default/admin/assets'),
|
||||
resolve(rootDir, 'src/modules'),
|
||||
],
|
||||
dynamicNavigation: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import postcss from 'postcss';
|
||||
import * as sass from 'sass';
|
||||
import { PurgeCSS } from 'purgecss';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises';
|
||||
import * as esbuild from 'esbuild';
|
||||
import type { BuildOptions, Drop, Loader, Plugin } from 'esbuild';
|
||||
@@ -21,6 +22,7 @@ interface PurgeOptions {
|
||||
enabled?: boolean;
|
||||
area?: 'admin' | 'client';
|
||||
additionalStandardSafelist?: RegExp[];
|
||||
additionalGreedySafelist?: RegExp[];
|
||||
}
|
||||
|
||||
interface CssBuildOptions {
|
||||
@@ -145,6 +147,7 @@ export async function purgeCssFile(cssFilePath: string, options: PurgeOptions) {
|
||||
enabled = false,
|
||||
area = 'admin',
|
||||
additionalStandardSafelist = [],
|
||||
additionalGreedySafelist = [],
|
||||
} = options;
|
||||
|
||||
if (!enabled) {
|
||||
@@ -157,7 +160,7 @@ export async function purgeCssFile(cssFilePath: string, options: PurgeOptions) {
|
||||
|
||||
try {
|
||||
const css = await readFile(cssFilePath, 'utf8');
|
||||
const modulesPath = resolve(themePath, '../../modules');
|
||||
const modulesPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../src/modules');
|
||||
const moduleArea = area === 'client' ? 'client' : 'admin';
|
||||
|
||||
const purgeCSSResult = await new PurgeCSS().purge({
|
||||
@@ -176,11 +179,14 @@ export async function purgeCssFile(cssFilePath: string, options: PurgeOptions) {
|
||||
/^collapse/,
|
||||
/^alert/,
|
||||
/^spinner/,
|
||||
/^tooltip/,
|
||||
/^popover/,
|
||||
/^active$/,
|
||||
/^show$/,
|
||||
/^fade$/,
|
||||
/^nav-/,
|
||||
/^data-bs-/,
|
||||
/^data-tblr-/,
|
||||
/^btn-/,
|
||||
/^card-/,
|
||||
/^badge-/,
|
||||
@@ -202,7 +208,7 @@ export async function purgeCssFile(cssFilePath: string, options: PurgeOptions) {
|
||||
...additionalStandardSafelist,
|
||||
],
|
||||
deep: [/tom-select/, /ts-/],
|
||||
greedy: [/^theme-/],
|
||||
greedy: [/^theme-/, ...additionalGreedySafelist],
|
||||
},
|
||||
defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || [],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, test } from 'node:test';
|
||||
|
||||
import { purgeCssFile } from '../esbuild-helpers.mts';
|
||||
|
||||
describe('purgeCssFile', () => {
|
||||
test('keeps rules for classes that only module templates use', async () => {
|
||||
const themePath = await mkdtemp(join(tmpdir(), 'fb-theme-'));
|
||||
await mkdir(join(themePath, 'html'), { recursive: true });
|
||||
await writeFile(join(themePath, 'html', 'layout.twig'), '<div class="page"></div>');
|
||||
|
||||
const cssPath = join(themePath, 'vendor.css');
|
||||
await writeFile(
|
||||
cssPath,
|
||||
[
|
||||
'.page{margin:0}',
|
||||
'.tab-content>.tab-pane{display:none}',
|
||||
'.never-referenced{color:red}',
|
||||
].join(''),
|
||||
);
|
||||
|
||||
await purgeCssFile(cssPath, { themePath, enabled: true, area: 'client' });
|
||||
|
||||
const purged = await readFile(cssPath, 'utf8');
|
||||
assert.match(purged, /\.page\{/);
|
||||
assert.match(purged, /\.tab-content>\.tab-pane/);
|
||||
assert.doesNotMatch(purged, /never-referenced/);
|
||||
});
|
||||
|
||||
test('keeps runtime calendar attribute states with a theme-specific safelist', async () => {
|
||||
const themePath = await mkdtemp(join(tmpdir(), 'fb-calendar-'));
|
||||
await mkdir(join(themePath, 'html'), { recursive: true });
|
||||
await writeFile(join(themePath, 'html', 'layout.twig'), '<input class="datepicker">');
|
||||
const cssPath = join(themePath, 'vendor.css');
|
||||
await writeFile(cssPath, '[data-vc="calendar"][data-vc-calendar-hidden]{display:none}.unused{color:red}');
|
||||
|
||||
await purgeCssFile(cssPath, {
|
||||
themePath,
|
||||
enabled: true,
|
||||
area: 'admin',
|
||||
additionalGreedySafelist: [/data-vc/],
|
||||
});
|
||||
|
||||
const purged = await readFile(cssPath, 'utf8');
|
||||
assert.match(purged, /data-vc-calendar-hidden/);
|
||||
assert.doesNotMatch(purged, /\.unused/);
|
||||
});
|
||||
|
||||
});
|
||||
Vendored
+2
@@ -125,6 +125,7 @@ interface BootstrapRuntime {
|
||||
|
||||
declare global {
|
||||
const bootstrap: BootstrapRuntime;
|
||||
const tabler: BootstrapRuntime;
|
||||
const FOSSBilling: FOSSBillingRuntime;
|
||||
const Modals: ModalsRuntime | undefined;
|
||||
|
||||
@@ -139,6 +140,7 @@ declare global {
|
||||
}
|
||||
|
||||
var bootstrap: BootstrapRuntime;
|
||||
var tabler: BootstrapRuntime;
|
||||
var flashMessage: ((options: { message?: string; reload?: boolean | string; type?: string }) => void) | undefined;
|
||||
var FOSSBilling: FOSSBillingRuntime;
|
||||
var Modals: ModalsRuntime | undefined;
|
||||
|
||||
Generated
+922
-838
File diff suppressed because it is too large.
Load diff
+17
-12
@@ -12,11 +12,11 @@
|
||||
"build-admin_default": "npm run build -w admin_default",
|
||||
"check": "npm run check-types && npm run check-icons",
|
||||
"check-types": "tsc --noEmit",
|
||||
"pw:tsc": "tsc --noEmit -p tests/E2E/Playwright",
|
||||
"check-icons": "node ./frontend/tools/check-icons.mts",
|
||||
"test:js": "node --test 'frontend/core/tests/**/*.test.mts' 'frontend/tools/tests/**/*.test.mts'",
|
||||
"cy:open": "npm exec --yes cypress@15.15.0 -- open",
|
||||
"cy:run": "npm exec --yes cypress@15.15.0 -- run",
|
||||
"cy:run:record": "npm exec --yes cypress@15.15.0 -- run --record"
|
||||
"test:js": "node --test 'frontend/core/tests/**/*.test.mts' 'frontend/tools/tests/**/*.test.mts' 'src/themes/default/admin/assets/js/**/*.test.mts'",
|
||||
"pw:open": "playwright test --ui",
|
||||
"pw:run": "playwright test"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -26,18 +26,19 @@
|
||||
"url": "https://github.com/FOSSBilling/FOSSBilling/issues"
|
||||
},
|
||||
"workspaces": [
|
||||
"src/themes/huraga",
|
||||
"src/themes/admin_default"
|
||||
"src/themes/default/client",
|
||||
"src/themes/default/admin"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"autoprefixer": "10.5.4",
|
||||
"ckeditor5": "48.4.0",
|
||||
"esbuild": "0.28.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@types/node": "24.19.0",
|
||||
"autoprefixer": "10.6.1",
|
||||
"ckeditor5": "48.5.2",
|
||||
"esbuild": "0.28.2",
|
||||
"github-markdown-css": "5.9.0",
|
||||
"postcss": "8.5.25",
|
||||
"postcss": "8.5.28",
|
||||
"purgecss": "8.0.0",
|
||||
"sass": "1.102.0",
|
||||
"sass": "1.105.1",
|
||||
"svg-sprite": "2.0.4",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
@@ -49,5 +50,9 @@
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=11"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@parcel/watcher": true,
|
||||
"esbuild": true
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: '#^Call to an undefined method Closure\:\:resolveClass\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/di.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasAnonymousFtp\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasCatchAll\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasCgi\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasCron\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasPhp\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasShell\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasSpamFilter\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasSsl\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getMaxEmailAutoresponders\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getMaxEmailForwarders\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getMaxEmailLists\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: src/extensions/managers/Directadmin/Directadmin.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasAnonymousFtp\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasCron\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasShell\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getHasSpamFilter\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method FOSSBilling\\Extension\\Contract\\Server\\Package\:\:getMaxEmailLists\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method PleskX\\Api\\Operator\\Customer\:\:setProperties\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method PleskX\\Api\\Operator\\Session\:\:create\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/extensions/managers/Plesk/Plesk.php
|
||||
|
||||
-
|
||||
message: '#^Method FOSSBilling\\Extension\\Registrar\\Namecheap\\Namecheap\:\:getPrivacyInfo\(\) should return array\<string\> but returns array\<string, bool\|string\>\.$#'
|
||||
identifier: return.type
|
||||
count: 1
|
||||
path: src/extensions/registrars/Namecheap/Namecheap.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$report of method FOSSBilling\\Extension\\DependencyBootstrap\:\:installMissing\(\) expects \(callable\(string\)\: void\)\|null, Closure\(string\)\: 1 given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: src/install-extension-dependencies.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Monolog\\Handler\\HandlerInterface\:\:setFormatter\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: src/library/FOSSBilling/Monolog.php
|
||||
@@ -1,6 +1,3 @@
|
||||
includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
parameters:
|
||||
level: 5
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
@@ -15,8 +12,6 @@ parameters:
|
||||
analyse:
|
||||
- src/vendor
|
||||
- src/extensions/*/*/vendor/*
|
||||
- src/library/Model/Admin.php
|
||||
- src/library/Model/Client.php
|
||||
- src/extensions/managers/Custom/Custom.php
|
||||
- src/extensions/managers/Whm/Whm.php
|
||||
- src/data/cache (?)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/E2E/Playwright/specs',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
['github'],
|
||||
['html', { outputFolder: 'tests/E2E/Playwright/artifacts/report', open: 'never' }],
|
||||
]
|
||||
: [
|
||||
['list'],
|
||||
['html', { outputFolder: 'tests/E2E/Playwright/artifacts/report', open: 'on-failure' }],
|
||||
],
|
||||
outputDir: 'tests/E2E/Playwright/artifacts/results',
|
||||
use: {
|
||||
baseURL,
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 30_000,
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
+22
-1
@@ -2,8 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Caching\ValueObject\Storage\FileCacheStorage;
|
||||
use Rector\CodingStyle\Rector\ArrowFunction\ArrowFunctionDelegatingCallToFirstClassCallableRector;
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
@@ -12,7 +14,26 @@ return RectorConfig::configure()
|
||||
])
|
||||
->withSkip([
|
||||
__DIR__ . '/src/vendor',
|
||||
__DIR__ . '/src/extensions/*/*/vendor',
|
||||
__DIR__ . '/src/data/cache',
|
||||
// Both rules only misbehave against Pest's more dynamic patterns, which
|
||||
// exist in tests/ but not src/ — kept active there.
|
||||
//
|
||||
// Rector infers this from Pest's IDE-only @mixin stub (Pest\Mixins\Expectation),
|
||||
// which doesn't match the real runtime class (Pest\Expectation) — declaring it
|
||||
// throws a TypeError the moment the closure runs. Only unsafe for closures whose
|
||||
// own body resolves to an expect() chain.
|
||||
AddArrowFunctionReturnTypeRector::class => [
|
||||
__DIR__ . '/tests',
|
||||
__DIR__ . '/src/modules/*/tests/*',
|
||||
],
|
||||
// Pest's ->with() dataset closures get Closure::bindTo() called on them for
|
||||
// lazy evaluation, which throws for a closure derived from a first-class
|
||||
// callable reference to a plain function (no scope to rebind).
|
||||
ArrowFunctionDelegatingCallToFirstClassCallableRector::class => [
|
||||
__DIR__ . '/tests',
|
||||
__DIR__ . '/src/modules/*/tests/*',
|
||||
],
|
||||
])
|
||||
->withAttributesSets()
|
||||
->withPhpSets()
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ Options -Indexes +SymLinksIfOwnerMatch
|
||||
RewriteRule ^ - [L]
|
||||
|
||||
# Block direct access to sensitive files and file types.
|
||||
RewriteCond %{REQUEST_URI} /themes/[^/]+/config/ [NC]
|
||||
RewriteCond %{REQUEST_URI} ^/themes/(?:[^/]+/)+config/ [NC]
|
||||
RewriteRule ^(.*)$ index.php?_url=/$1&_errcode=404 [L]
|
||||
|
||||
RewriteCond %{REQUEST_URI} /?.+\.(htaccess|htpasswd|bak|conf|inc|ini|lock|log|old|sh|sql|twig|yaml|php) [NC,OR]
|
||||
|
||||
+81
-9
@@ -4,12 +4,9 @@ declare(strict_types=1);
|
||||
/**
|
||||
* FOSSBilling configuration file example.
|
||||
*
|
||||
* If you are not using the web installer, you can rename this file
|
||||
* to "config.php" and fill in the values.
|
||||
* Import /install/sql/structure.sql to your database
|
||||
* Import /install/sql/content.sql to your database
|
||||
* Open browser https://www.yourdomain.com/admin to create a new admin account.
|
||||
* Remove /install directory
|
||||
* Fresh installations must use the web installer, which creates the database
|
||||
* schema and seeds its initial data. Do not import /install/sql/content.sql
|
||||
* directly, as it does not contain the database schema.
|
||||
*
|
||||
* For more information, see the documentation: https://docs.fossbilling.org/customizing-fossbilling/config/
|
||||
*/
|
||||
@@ -134,7 +131,10 @@ return [
|
||||
|
||||
'db' => [
|
||||
/*
|
||||
* Database type. Don't change this if in doubt.
|
||||
* Database driver. Don't change this if in doubt.
|
||||
* Supported: 'pdo_mysql' (MySQL/MariaDB), 'pdo_pgsql' (PostgreSQL), 'pdo_sqlite' (SQLite).
|
||||
* pdo_sqlite ignores 'host'/'port'/'user'/'password' below and instead reads a 'path'
|
||||
* (filesystem path to the database file) or 'memory' (bool, for an in-memory database).
|
||||
*/
|
||||
'driver' => 'pdo_mysql',
|
||||
|
||||
@@ -162,6 +162,71 @@ return [
|
||||
* Database Port.
|
||||
*/
|
||||
'port' => getenv('DB_PORT') ?: '3306',
|
||||
|
||||
// MySQL/MariaDB connections default to utf8mb4. An explicit legacy charset
|
||||
// overrides that default; changing it does not convert existing columns.
|
||||
// 'charset' => 'utf8mb4',
|
||||
|
||||
/*
|
||||
* Optional session timeouts (seconds) applied to database connections.
|
||||
* When unset, the MySQL server defaults are preserved.
|
||||
*/
|
||||
// 'interactive_timeout' => 28800,
|
||||
// 'wait_timeout' => 28800,
|
||||
],
|
||||
|
||||
/*
|
||||
* Cache backend used for the general application cache, the rate limiter, and the
|
||||
* Doctrine ORM metadata/query/result cache. This is unrelated to the 'twig' cache below.
|
||||
*/
|
||||
'cache' => [
|
||||
/*
|
||||
* Supported values: 'filesystem' (default), 'redis', 'memcached'.
|
||||
* 'redis' requires the PHP redis (or relay) extension, and 'memcached' requires the
|
||||
* PHP memcached extension. Also configurable from the admin area under System > Settings > Cache.
|
||||
*/
|
||||
'driver' => 'filesystem',
|
||||
|
||||
/*
|
||||
* Used when 'driver' is set to 'redis'.
|
||||
*/
|
||||
'redis' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 6379,
|
||||
'password' => null,
|
||||
'database' => 0,
|
||||
|
||||
/*
|
||||
* TLS ('rediss://') for the connection to the Redis server. Required whenever
|
||||
* 'password' is set and 'host' isn't a loopback address (127.0.0.1, ::1, localhost) -
|
||||
* see CacheFactory::assertRedisTransportIsSafe() for why. Also configurable from the
|
||||
* admin area under System > Settings > Cache.
|
||||
*/
|
||||
'tls' => [
|
||||
'enabled' => false,
|
||||
|
||||
// Verify the server's certificate, and that its certificate name matches 'host'.
|
||||
// Leave both enabled unless you have a specific reason not to.
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
|
||||
// Path to a CA bundle to trust, if the Redis server's certificate isn't signed by
|
||||
// a CA your system already trusts.
|
||||
'cafile' => null,
|
||||
|
||||
// Only for a self-signed certificate in a development/testing environment - never
|
||||
// enable this in production.
|
||||
'allow_self_signed' => false,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* Used when 'driver' is set to 'memcached'.
|
||||
*/
|
||||
'memcached' => [
|
||||
'host' => '127.0.0.1',
|
||||
'port' => 11211,
|
||||
],
|
||||
],
|
||||
|
||||
'twig' => [
|
||||
@@ -186,13 +251,20 @@ return [
|
||||
'rate_limiter' => [
|
||||
'enabled' => true,
|
||||
|
||||
/**
|
||||
/*
|
||||
* Development environments (APP_ENV=dev) bypass rate limiting so local
|
||||
* development and E2E runs are never throttled. Set this to true to
|
||||
* test throttling behavior on a development box.
|
||||
*/
|
||||
// 'enforce_in_development' => true,
|
||||
|
||||
/*
|
||||
* Any IP address within this list will not be put through the rate-limiter system.
|
||||
* This is useful if you have an application with a static IP address that needs to make frequent API requests to FOSSBilling.
|
||||
*/
|
||||
'whitelist_ips' => [],
|
||||
|
||||
/**
|
||||
/*
|
||||
* Override individual rate limiter policies here.
|
||||
* Defaults are defined in FOSSBilling\Security\RateLimiter::getDefaultConfig().
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,10 @@ declare(strict_types=1);
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
if (php_sapi_name() !== 'cli') {
|
||||
exit('This script can only be run from the command line.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . DIRECTORY_SEPARATOR . 'load.php';
|
||||
|
||||
use FOSSBilling\Environment;
|
||||
|
||||
+107
-150
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use FOSSBilling\Cache\CacheFactory;
|
||||
use FOSSBilling\Config;
|
||||
use FOSSBilling\Doctrine\DriverManagerFactory;
|
||||
use FOSSBilling\Doctrine\EntityManagerFactory;
|
||||
@@ -19,11 +20,13 @@ use FOSSBilling\Http\RequestFactory;
|
||||
use FOSSBilling\Security\AuthenticationRequiredException;
|
||||
use FOSSBilling\Security\EmailValidationRequiredException;
|
||||
use FOSSBilling\Version;
|
||||
use RedBeanPHP\Facade;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session as SymfonySession;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
$di = new Pimple\Container();
|
||||
@@ -39,27 +42,32 @@ if (!$request instanceof Request) {
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return Box_Log A new logger instance
|
||||
* @return FOSSBilling\Logger A new logger instance
|
||||
*/
|
||||
$di['logger'] = function () use ($di) {
|
||||
$log = new Box_Log();
|
||||
$log = new FOSSBilling\Logger();
|
||||
$log->setDi($di);
|
||||
|
||||
$activity_service = $di['mod_service']('activity');
|
||||
$dbWriter = new Box_LogDb($activity_service);
|
||||
$dbWriter = new FOSSBilling\Logging\DatabaseWriter($activity_service);
|
||||
$log->addWriter($dbWriter);
|
||||
|
||||
$context = [];
|
||||
if ($di['auth']->isAdminLoggedIn()) {
|
||||
$admin = $di['loggedin_admin'];
|
||||
$log->setEventItem('admin_id', $admin->id);
|
||||
$context['admin_id'] = $admin->getId();
|
||||
} elseif ($di['auth']->isClientLoggedIn()) {
|
||||
$client = $di['loggedin_client'];
|
||||
$log->setEventItem('client_id', $client->id);
|
||||
$context['client_id'] = $client->getId();
|
||||
}
|
||||
|
||||
$monolog = new FOSSBilling\Monolog();
|
||||
$log->addWriter($monolog);
|
||||
|
||||
if ($context !== []) {
|
||||
$log = $log->withContext($context);
|
||||
}
|
||||
|
||||
return $log;
|
||||
};
|
||||
|
||||
@@ -67,15 +75,36 @@ $di['logger'] = function () use ($di) {
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return \Box_Crypt
|
||||
* @return FOSSBilling\Crypt
|
||||
*/
|
||||
$di['crypt'] = function () use ($di) {
|
||||
$crypt = new Box_Crypt();
|
||||
$crypt = new FOSSBilling\Crypt();
|
||||
$crypt->setDi($di);
|
||||
|
||||
return $crypt;
|
||||
};
|
||||
|
||||
/*
|
||||
* Creates and returns a Doctrine ORM EntityManager instance.
|
||||
*
|
||||
* This is the anchor for the shared database connection: the PDO and DBAL
|
||||
* services below reuse its connection so that all three participate in the
|
||||
* same transaction scope.
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return EntityManager The Doctrine ORM EntityManager instance.
|
||||
*/
|
||||
$di['em'] = (fn (): EntityManager => EntityManagerFactory::create());
|
||||
|
||||
/*
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return Connection The shared Doctrine DBAL connection instance.
|
||||
*/
|
||||
$di['dbal'] = (fn (): Connection => DriverManagerFactory::getSharedConnection());
|
||||
|
||||
/*
|
||||
* Creates a new PDO object for database connections
|
||||
*
|
||||
@@ -84,88 +113,18 @@ $di['crypt'] = function () use ($di) {
|
||||
* @return PDO The PDO object used for database connections
|
||||
*/
|
||||
$di['pdo'] = function () {
|
||||
$debugConfig = Config::getProperty('debug_and_monitoring', []);
|
||||
$dbConfig = DriverManagerFactory::getDatabaseConfig();
|
||||
$driverOptions = [
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
];
|
||||
$debug = (bool) Config::getProperty('debug_and_monitoring.debug', false);
|
||||
|
||||
$connection = DriverManagerFactory::getConnection($driverOptions);
|
||||
/** @var PDO $pdo */
|
||||
$pdo = $connection->getNativeConnection();
|
||||
|
||||
if (isset($debugConfig['debug']) && $debugConfig['debug']) {
|
||||
$pdo->setAttribute(PDO::ATTR_STATEMENT_CLASS, ['Box_DbLoggedPDOStatement']);
|
||||
}
|
||||
|
||||
if ($dbConfig['driver'] === 'pdo_mysql') {
|
||||
// Set server default charset for newly created tables. Connection charset is handled by DBAL via DSN.
|
||||
$pdo->exec('SET character_set_server = utf8');
|
||||
$pdo->exec('SET SESSION interactive_timeout = 28800');
|
||||
$pdo->exec('SET SESSION wait_timeout = 28800');
|
||||
|
||||
// Get the timezone offset in the PDO format
|
||||
$datetime = new DateTime('now');
|
||||
$offset = $datetime->format('P');
|
||||
$pdo->exec("SET time_zone = '{$offset}'");
|
||||
}
|
||||
|
||||
return new DebugBar\DataCollector\PDO\TraceablePDO($pdo);
|
||||
};
|
||||
|
||||
/*
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return \Box_Database The new Box_Database object that was just created.
|
||||
*/
|
||||
$di['db'] = function () use ($di) {
|
||||
$pdo = $di->offsetGet('pdo');
|
||||
$pdo = DriverManagerFactory::getSharedConnection()->getNativeConnection();
|
||||
if (!$pdo instanceof PDO) {
|
||||
throw new RuntimeException('PDO service must resolve to a PDO instance');
|
||||
}
|
||||
|
||||
RedBeanPHP\R::setup($pdo);
|
||||
RedBeanPHP\Util\DispenseHelper::setEnforceNamingPolicy(false);
|
||||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
|
||||
// SECURITY: bind string literals as PARAM_STR, not PARAM_INT. Without
|
||||
// this, ?hash=107 in /api/guest/invoice/get resolves to the invoice
|
||||
// whose hash starts with '107' because MySQL coerces VARCHAR against
|
||||
// int to a leading-digits match. Do not remove; see RedBeanBindingTest.
|
||||
/* @phpstan-ignore-next-line Adapter::getDatabase() returns the abstract Driver; the concrete RPDO implements setUseStringOnlyBinding. */
|
||||
Facade::getDatabaseAdapter()->getDatabase()->setUseStringOnlyBinding(true);
|
||||
|
||||
$helper = new Box_BeanHelper();
|
||||
$helper->setDi($di);
|
||||
|
||||
$mapper = new Facade();
|
||||
$mapper->getRedBean()->setBeanHelper($helper);
|
||||
$freeze = Config::getProperty('db.freeze', true);
|
||||
$mapper->freeze($freeze);
|
||||
|
||||
$db = new Box_Database();
|
||||
$db->setDi($di);
|
||||
$db->setDataMapper($mapper);
|
||||
|
||||
return $db;
|
||||
return $debug ? new DebugBar\DataCollector\PDO\TraceablePDO($pdo) : $pdo;
|
||||
};
|
||||
|
||||
/*
|
||||
* Creates and returns a Doctrine DBAL connection instance.
|
||||
*
|
||||
* @return Connection The Doctrine DBAL connection instance.
|
||||
*/
|
||||
$di['dbal'] = (fn (): Connection => DriverManagerFactory::getConnection());
|
||||
|
||||
/*
|
||||
* Creates and returns a Doctrine ORM EntityManager instance.
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return EntityManager The Doctrine ORM EntityManager instance.
|
||||
*/
|
||||
$di['em'] = (fn (): EntityManager => EntityManagerFactory::create());
|
||||
|
||||
/*
|
||||
*
|
||||
* @param void
|
||||
@@ -183,10 +142,10 @@ $di['pager'] = function () use ($di) {
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return Box_Url
|
||||
* @return FOSSBilling\Url
|
||||
*/
|
||||
$di['url'] = function () use ($di) {
|
||||
$url = new Box_Url();
|
||||
$url = new FOSSBilling\Url();
|
||||
$url->setDi($di);
|
||||
$url->setBaseUri(SYSTEM_URL);
|
||||
|
||||
@@ -225,18 +184,11 @@ $di['mod_config'] = $di->protect(fn ($name) => $di['mod']($name)->getConfig());
|
||||
|
||||
$di['cookie_queue'] = fn (): FOSSBilling\Http\CookieQueue => new FOSSBilling\Http\CookieQueue();
|
||||
|
||||
/*
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return \Box_EventManager
|
||||
*/
|
||||
$di['events_manager'] = function () use ($di) {
|
||||
$service = new Box_EventManager();
|
||||
$service->setDi($di);
|
||||
|
||||
return $service;
|
||||
};
|
||||
$di['event_dispatcher'] = fn (): FOSSBilling\Events\EventDispatcher => new FOSSBilling\Events\EventDispatcher(
|
||||
fn (): array => $di['mod_service']('extension')->getCoreAndActiveModules(),
|
||||
fn (string $module): object => $di['mod_service']($module),
|
||||
$di['logger']->withChannel('event'),
|
||||
);
|
||||
|
||||
/*
|
||||
* Creates a new session, applying specified security rules depending on the config.php settings.
|
||||
@@ -253,8 +205,39 @@ $di['session'] = function () use ($di) {
|
||||
throw new RuntimeException('PDO service must resolve to a PDO instance');
|
||||
}
|
||||
|
||||
$handler = new PdoSessionHandler($pdo);
|
||||
$session = new FOSSBilling\Session($handler);
|
||||
$sessionLifetime = max(1, (int) Config::getProperty('security.session_lifespan', 7200));
|
||||
$handler = new PdoSessionHandler($pdo, [
|
||||
'db_table' => 'session',
|
||||
'db_id_col' => 'id',
|
||||
'db_data_col' => 'content',
|
||||
'db_lifetime_col' => 'lifetime',
|
||||
'db_time_col' => 'modified_at',
|
||||
// The application uses the same connection for its business queries;
|
||||
// advisory locking avoids holding a transaction open for the request.
|
||||
'lock_mode' => PdoSessionHandler::LOCK_ADVISORY,
|
||||
]);
|
||||
$currentCookieParams = session_get_cookie_params();
|
||||
$cookieParams = [
|
||||
'path' => $currentCookieParams['path'],
|
||||
'domain' => $currentCookieParams['domain'],
|
||||
'secure' => Config::getProperty('security.force_https', true) || $di['request']->isSecure(),
|
||||
'httponly' => true,
|
||||
'samesite' => Config::getProperty('security.mode', 'strict') === 'strict'
|
||||
? 'Strict'
|
||||
: $currentCookieParams['samesite'],
|
||||
];
|
||||
$storage = new NativeSessionStorage([
|
||||
'cache_limiter' => '',
|
||||
'cookie_lifetime' => 0,
|
||||
'gc_maxlifetime' => $sessionLifetime,
|
||||
'cookie_path' => $cookieParams['path'],
|
||||
'cookie_domain' => $cookieParams['domain'],
|
||||
'cookie_secure' => $cookieParams['secure'],
|
||||
'cookie_httponly' => $cookieParams['httponly'],
|
||||
'cookie_samesite' => $cookieParams['samesite'],
|
||||
'serialize_handler' => 'php',
|
||||
], $handler);
|
||||
$session = new FOSSBilling\Session(new SymfonySession($storage), $cookieParams);
|
||||
$session->setDi($di);
|
||||
$session->setupSession();
|
||||
|
||||
@@ -273,15 +256,18 @@ $di['session'] = function () use ($di) {
|
||||
$di['request'] = $request;
|
||||
|
||||
/*
|
||||
* The general-purpose application cache. Backed by the filesystem by default; can be configured
|
||||
* to use Redis or Memcached instead via the `cache` block in the FOSSBilling configuration file.
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @link https://symfony.com/doc/current/components/cache/adapters/filesystem_adapter.html
|
||||
* @link https://symfony.com/doc/current/components/cache.html
|
||||
*
|
||||
* @return FilesystemAdapter
|
||||
* @return CacheItemPoolInterface
|
||||
*/
|
||||
$di['cache'] = fn (): FilesystemAdapter => new FilesystemAdapter('sf_cache', 24 * 60 * 60, PATH_CACHE);
|
||||
$di['cache'] = fn (): CacheItemPoolInterface => CacheFactory::create(CacheFactory::NAMESPACE_APP, 24 * 60 * 60);
|
||||
|
||||
$di['rate_limit_cache'] = fn (): FilesystemAdapter => new FilesystemAdapter('rate_limit', 24 * 60 * 60, PATH_CACHE);
|
||||
$di['rate_limit_cache'] = fn (): CacheItemPoolInterface => CacheFactory::create(CacheFactory::NAMESPACE_RATE_LIMIT, 24 * 60 * 60);
|
||||
|
||||
$di['http_client'] = fn (): HttpClientInterface => HttpClient::create([
|
||||
'bindto' => BIND_TO,
|
||||
@@ -337,7 +323,7 @@ $di['is_client_logged'] = function () use ($di) {
|
||||
$di['is_client_email_validated'] = $di->protect(function ($model) use ($di) {
|
||||
$config = $di['mod_config']('client');
|
||||
if (isset($config['require_email_confirmation']) && (bool) $config['require_email_confirmation']) {
|
||||
return (bool) $model->email_approved;
|
||||
return (bool) $model->getEmailApproved();
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -368,7 +354,7 @@ $di['is_admin_logged'] = function () use ($di) {
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return \Model_Client The existing logged-in client model object.
|
||||
* @return \Box\Mod\Client\Entity\Client The existing logged-in client entity.
|
||||
*/
|
||||
$di['loggedin_client'] = function () use ($di) {
|
||||
$di['is_client_logged'];
|
||||
@@ -377,8 +363,8 @@ $di['loggedin_client'] = function () use ($di) {
|
||||
$client_id = $session->get('client_id');
|
||||
|
||||
try {
|
||||
$client = $di['db']->getExistingModelById('Client', $client_id);
|
||||
if ($client->status !== Model_Client::ACTIVE) {
|
||||
$client = $di['em']->getRepository(Box\Mod\Client\Entity\Client::class)->find($client_id);
|
||||
if (!$client instanceof Box\Mod\Client\Entity\Client || $client->getStatus() !== Box\Mod\Client\Entity\Client::ACTIVE) {
|
||||
throw new Exception('Client account is not active');
|
||||
}
|
||||
|
||||
@@ -399,11 +385,11 @@ $di['loggedin_client'] = function () use ($di) {
|
||||
$di['is_cron'] = false;
|
||||
|
||||
/*
|
||||
* Returns an existing logged-in admin model object.
|
||||
* Returns an existing logged-in admin entity.
|
||||
*
|
||||
* @param void
|
||||
*
|
||||
* @return \Model_Admin|null The existing logged-in admin model object, or null if no admin is logged in.
|
||||
* @return \Box\Mod\Staff\Entity\Admin|null The existing logged-in admin entity, or null if no admin is logged in.
|
||||
*
|
||||
* @throws \FOSSBilling\Exception If the script is running in CLI or CGI mode and there is no cron admin available.
|
||||
*/
|
||||
@@ -418,8 +404,8 @@ $di['loggedin_admin'] = function () use ($di) {
|
||||
$admin = $session->get('admin');
|
||||
|
||||
try {
|
||||
$model = $di['db']->getExistingModelById('Admin', $admin['id']);
|
||||
if ($model->status !== Model_Admin::STATUS_ACTIVE) {
|
||||
$model = $di['em']->getRepository(Box\Mod\Staff\Entity\Admin::class)->find($admin['id']);
|
||||
if (!$model instanceof Box\Mod\Staff\Entity\Admin || $model->getStatus() !== Box\Mod\Staff\Entity\Admin::STATUS_ACTIVE) {
|
||||
throw new Exception('Admin account is not active');
|
||||
}
|
||||
|
||||
@@ -462,7 +448,7 @@ $di['set_return_uri'] = function () use ($di): void {
|
||||
*/
|
||||
$di['api_identity'] = $di->protect(function ($role) use ($di) {
|
||||
$identity = match ($role) {
|
||||
'guest' => new Model_Guest(),
|
||||
'guest' => new FOSSBilling\Identity\Guest(),
|
||||
'client' => $di['loggedin_client'],
|
||||
'admin' => $di['loggedin_admin'],
|
||||
default => throw new Exception('Unrecognized Handler type: ' . $role),
|
||||
@@ -620,12 +606,6 @@ $di['extension_locator'] = fn (): FOSSBilling\Extension\ExtensionLocator => new
|
||||
*/
|
||||
$di['extension_dependencies'] = fn (): FOSSBilling\Extension\DependencyInstaller => new FOSSBilling\Extension\DependencyInstaller($di['filesystem']);
|
||||
|
||||
/*
|
||||
* The logger handed to extensions. PSR-3, so that the extension API does not
|
||||
* expose Box_Log, while the writers configured on it still receive everything.
|
||||
*/
|
||||
$di['extension_logger'] = fn (): Psr\Log\LoggerInterface => new FOSSBilling\PsrLogAdapter($di['logger']);
|
||||
|
||||
/*
|
||||
* Creates a new server manager object and returns it.
|
||||
*
|
||||
@@ -635,22 +615,22 @@ $di['extension_logger'] = fn (): Psr\Log\LoggerInterface => new FOSSBilling\PsrL
|
||||
* @return \FOSSBilling\Extension\Contract\Server\Manager The new server manager object that was just created.
|
||||
*/
|
||||
$di['server_manager'] = $di->protect(function ($manager, $config) use ($di) {
|
||||
$class = $di['extension_locator']->resolveClass(FOSSBilling\Extension\ExtensionType::Manager, ucfirst((string) $manager));
|
||||
$class = $di->offsetGet('extension_locator')->resolveClass(FOSSBilling\Extension\ExtensionType::Manager, ucfirst((string) $manager));
|
||||
|
||||
$s = new $class($config);
|
||||
$s->setLog($di['extension_logger']);
|
||||
$s->setLog($di['logger']);
|
||||
|
||||
return $s;
|
||||
});
|
||||
|
||||
/*
|
||||
* Creates a new Box_Period object using the provided period code and returns it.
|
||||
* Creates a new FOSSBilling\Period object using the provided period code and returns it.
|
||||
*
|
||||
* @param string $code The two character period code to create the period object with.
|
||||
*
|
||||
* @return \Box_Period The new period object that was just created.
|
||||
* @return FOSSBilling\Period The new period object that was just created.
|
||||
*/
|
||||
$di['period'] = $di->protect(fn ($code): Box_Period => new Box_Period($code));
|
||||
$di['period'] = $di->protect(fn (string $code): FOSSBilling\Period => new FOSSBilling\Period($code));
|
||||
|
||||
/*
|
||||
* Gets the current client area theme.
|
||||
@@ -678,22 +658,6 @@ $di['cart'] = function () use ($di) {
|
||||
return $service->getSessionCart();
|
||||
};
|
||||
|
||||
/*
|
||||
* Creates a new table object and returns it.
|
||||
*
|
||||
* @param string $name The name of the table to create.
|
||||
*
|
||||
* @return \Box_Table The new table object that was just created.
|
||||
*/
|
||||
$di['table'] = $di->protect(function ($name) use ($di) {
|
||||
$tools = new FOSSBilling\Tools();
|
||||
$tools->setDi($di);
|
||||
$table = $tools->getTable($name);
|
||||
$table->setDi($di);
|
||||
|
||||
return $table;
|
||||
});
|
||||
|
||||
/*
|
||||
* @param void
|
||||
*
|
||||
@@ -726,14 +690,14 @@ $di['geoip'] = function () use ($di) {
|
||||
$di['password'] = fn (): FOSSBilling\PasswordManager => new FOSSBilling\PasswordManager();
|
||||
|
||||
/*
|
||||
* Creates a new Box_Translate object and sets the specified text domain, locale, and other options.
|
||||
* Creates a new FOSSBilling\Translate object and sets the specified text domain, locale, and other options.
|
||||
*
|
||||
* @param string $textDomain The text domain to create the translation object with.
|
||||
*
|
||||
* @return \Box_Translate The new translation object that was just created.
|
||||
* @return FOSSBilling\Translate The new translation object that was just created.
|
||||
*/
|
||||
$di['translate'] = $di->protect(function ($textDomain = '') use ($di) {
|
||||
$tr = new Box_Translate();
|
||||
$tr = new FOSSBilling\Translate();
|
||||
|
||||
if (!empty($textDomain)) {
|
||||
$tr->setDomain($textDomain);
|
||||
@@ -747,14 +711,7 @@ $di['translate'] = $di->protect(function ($textDomain = '') use ($di) {
|
||||
return $tr;
|
||||
});
|
||||
|
||||
$di['csv_response_factory'] = function () use ($di): FOSSBilling\Http\CsvResponseFactory {
|
||||
$database = $di->offsetGet('db');
|
||||
if (!$database instanceof Box_Database) {
|
||||
throw new RuntimeException('Database service must resolve to a Box_Database instance');
|
||||
}
|
||||
|
||||
return new FOSSBilling\Http\CsvResponseFactory($database);
|
||||
};
|
||||
$di['csv_response_factory'] = fn (): FOSSBilling\Http\CsvResponseFactory => new FOSSBilling\Http\CsvResponseFactory($di['em']->getConnection());
|
||||
|
||||
$di['twig_factory'] = fn (): FOSSBilling\Twig\TwigFactory => new FOSSBilling\Twig\TwigFactory($di);
|
||||
|
||||
|
||||
@@ -5,10 +5,25 @@
|
||||
"description": "A manually configured gateway for payment methods FOSSBilling has no integration for.",
|
||||
"version": "1.0.0",
|
||||
"api": 1,
|
||||
"logo": { "file": "custom.png", "width": "50px", "height": "50px" },
|
||||
"logo": {
|
||||
"file": "custom.png",
|
||||
"width": "50px",
|
||||
"height": "50px"
|
||||
},
|
||||
"embeddable": true,
|
||||
"settings": [
|
||||
{ "name": "single", "type": "textarea", "label": "Enter Your Text for Single Payment Information", "required": false },
|
||||
{ "name": "recurrent", "type": "textarea", "label": "Enter Your Text for Subscription Information", "required": false }
|
||||
]
|
||||
{
|
||||
"name": "single",
|
||||
"type": "textarea",
|
||||
"label": "Enter Your Text for Single Payment Information",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "recurrent",
|
||||
"type": "textarea",
|
||||
"label": "Enter Your Text for Subscription Information",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"manual_approval": true
|
||||
}
|
||||
@@ -65,7 +65,7 @@ function customInvoiceRequest(bool $subscription = false): CheckoutRequest
|
||||
gatewayId: 3,
|
||||
currency: 'USD',
|
||||
hash: 'hashString',
|
||||
approved: true,
|
||||
issued: true,
|
||||
serie: 'INV-',
|
||||
nr: 1001,
|
||||
total: 42.5,
|
||||
|
||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace FOSSBilling\Extension\Gateway\PayPalEmail;
|
||||
|
||||
use Box\Mod\Invoice\Entity\Invoice;
|
||||
use FOSSBilling\Environment;
|
||||
use Pimple\Container;
|
||||
|
||||
@@ -60,7 +61,11 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
|
||||
public function getHtml($api_admin, $invoice_id, $subscription): string
|
||||
{
|
||||
$invoiceModel = $this->di['db']->load('Invoice', $invoice_id);
|
||||
$invoiceModel = $this->di['em']->getRepository(Invoice::class)->find($invoice_id);
|
||||
if (!$invoiceModel instanceof Invoice) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('Invoice not found');
|
||||
}
|
||||
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
$invoice = $invoiceService->toApiArray($invoiceModel, true);
|
||||
|
||||
@@ -76,21 +81,48 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
return $this->_generateForm($url, $data);
|
||||
}
|
||||
|
||||
public function processTransaction($api_admin, $id, $data, $gateway_id): void
|
||||
public function processTransaction($api_admin, int $id, array $data, int $gateway_id): void
|
||||
{
|
||||
$post = (isset($data['post']) && is_array($data['post'])) ? $data['post'] : [];
|
||||
if ($post === []) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal payment data is missing.', [], 7021);
|
||||
}
|
||||
|
||||
if (!Environment::isTesting() && !$this->_isIpnValid($data)) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('IPN is invalid');
|
||||
}
|
||||
|
||||
$ipn = $data['post'];
|
||||
|
||||
$tx = $api_admin->invoice_transaction_get(['id' => $id]);
|
||||
|
||||
// Set the invoice ID if it's not set
|
||||
// The invoice binding arrives through a buyer-editable callback URL,
|
||||
// so authenticate it before anything below trusts it. Runs outside
|
||||
// the test-mode bypass above: the binding must hold in every environment.
|
||||
$verifiedInvoiceId = $this->verifyCallbackBinding($data, $gateway_id, $tx['invoice_id'] ?? null, $id);
|
||||
|
||||
$ipn = $post;
|
||||
|
||||
// PayPal's newer subscription flow uses recurring_payment* names for the
|
||||
// same concepts: normalize them once so every path below just works.
|
||||
if (!isset($ipn['subscr_id']) && isset($ipn['recurring_payment_id'])) {
|
||||
$ipn['subscr_id'] = $ipn['recurring_payment_id'];
|
||||
}
|
||||
if (!isset($ipn['mc_gross']) && isset($ipn['amount'])) {
|
||||
$ipn['mc_gross'] = $ipn['amount'];
|
||||
}
|
||||
if (!isset($ipn['amount3']) && isset($ipn['amount'])) {
|
||||
$ipn['amount3'] = $ipn['amount'];
|
||||
}
|
||||
if (!isset($ipn['mc_currency']) && isset($ipn['amount_currency'])) {
|
||||
$ipn['mc_currency'] = $ipn['amount_currency'];
|
||||
}
|
||||
|
||||
// Set the invoice ID if it's not set. The value was authenticated above.
|
||||
if (!$tx['invoice_id']) {
|
||||
$invoiceID = $data['get']['invoice_id'];
|
||||
$tx['invoice_id'] = $invoiceID;
|
||||
$api_admin->invoice_transaction_update(['id' => $id, 'invoice_id' => $invoiceID]);
|
||||
if ($verifiedInvoiceId === null) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal transaction is not associated with an invoice');
|
||||
}
|
||||
$tx['invoice_id'] = $verifiedInvoiceId;
|
||||
$api_admin->invoice_transaction_update(['id' => $id, 'invoice_id' => $verifiedInvoiceId]);
|
||||
}
|
||||
|
||||
if (!$tx['type'] && isset($ipn['txn_type'])) {
|
||||
@@ -117,10 +149,14 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
$client_id = $invoice['client']['id'];
|
||||
|
||||
$txnType = $ipn['txn_type'] ?? '';
|
||||
// Incomplete payments must stay received with their error instead of
|
||||
// being overwritten as cleanly processed by the tail update below.
|
||||
$awaitingCompletion = false;
|
||||
switch ($txnType) {
|
||||
case 'web_accept':
|
||||
case 'subscr_payment':
|
||||
if ($ipn['payment_status'] == 'Completed') {
|
||||
case 'recurring_payment':
|
||||
if (($ipn['payment_status'] ?? '') === 'Completed') {
|
||||
// Skip only if we've already processed a Completed payment for this transaction
|
||||
if (isset($tx['status'], $tx['txn_status']) && $tx['status'] === \Box\Mod\Invoice\Entity\Transaction::STATUS_PROCESSED && $tx['txn_status'] === 'Completed') {
|
||||
$d = [
|
||||
@@ -135,11 +171,25 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
return;
|
||||
}
|
||||
|
||||
// Claim transaction for processing
|
||||
// Prevents race conditions when multiple Completed IPNs arrive simultaneously
|
||||
if (!$api_admin->invoice_transaction_claim_for_processing(['id' => $id])) {
|
||||
return;
|
||||
if (!isset($ipn['mc_gross'], $ipn['txn_id'])) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal payment is missing transaction details');
|
||||
}
|
||||
$this->validateCurrency($ipn['mc_currency'] ?? null, $invoice['currency'] ?? null);
|
||||
$isSubscriptionPayment = in_array($txnType, ['subscr_payment', 'recurring_payment'], true);
|
||||
if ($isSubscriptionPayment && !isset($ipn['subscr_id'])) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription payment is missing the subscription ID');
|
||||
}
|
||||
if ($isSubscriptionPayment) {
|
||||
$paymentSubscription = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => (string) $ipn['subscr_id']]);
|
||||
if ($paymentSubscription instanceof \Box\Mod\Invoice\Entity\Subscription
|
||||
&& $paymentSubscription->getRelType() === 'invoice'
|
||||
&& $paymentSubscription->getRelId() !== null
|
||||
&& $paymentSubscription->getRelId() !== (int) $tx['invoice_id']) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription ' . $ipn['subscr_id'] . ' is not linked to invoice ' . $tx['invoice_id']);
|
||||
}
|
||||
}
|
||||
} elseif (($ipn['payment_status'] ?? '') === 'Refunded') {
|
||||
break;
|
||||
} else {
|
||||
$api_admin->invoice_transaction_update([
|
||||
'id' => $id,
|
||||
@@ -148,6 +198,8 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
'error' => sprintf('PayPal payment not completed: %s', (string) ($ipn['payment_status'] ?? 'unknown')),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$this->di['logger']->info('PayPal payment for transaction ' . $id . ' not completed: ' . ($ipn['payment_status'] ?? 'unknown'));
|
||||
$awaitingCompletion = true;
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -168,28 +220,36 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
$invoiceDbModel = null;
|
||||
if (!empty($tx['invoice_id'])) {
|
||||
$invoiceDbModel = $this->di['db']->load('Invoice', $tx['invoice_id']);
|
||||
$invoiceDbModel = $this->di['em']->getRepository(Invoice::class)->find($tx['invoice_id']);
|
||||
}
|
||||
|
||||
// For subscription renewals, generate a renewal invoice so
|
||||
// we validate against the correct amount. Skip this for the
|
||||
// initial payment (original invoice still unpaid) — that
|
||||
// payment should go to the original invoice.
|
||||
if ($ipn['txn_type'] === 'subscr_payment' && isset($ipn['subscr_id'])) {
|
||||
$originalAlreadyPaid = $invoiceDbModel instanceof \Model_Invoice
|
||||
&& $invoiceDbModel->status === \Model_Invoice::STATUS_PAID;
|
||||
// Echoed references describe the original invoice, so check
|
||||
// them before the renewal reassignment below changes it.
|
||||
if ($invoiceDbModel instanceof Invoice) {
|
||||
$this->verifyIpnEcho($ipn, $invoiceDbModel);
|
||||
}
|
||||
|
||||
if ($isSubscriptionPayment && isset($ipn['subscr_id'])) {
|
||||
$originalAlreadyPaid = $invoiceDbModel instanceof Invoice
|
||||
&& $invoiceDbModel->getStatus() === Invoice::STATUS_PAID;
|
||||
|
||||
if ($originalAlreadyPaid) {
|
||||
$renewalInvoice = $invoiceService->generateRenewalInvoiceForSubscriptionPayment($ipn['subscr_id'], $client_id);
|
||||
if ($renewalInvoice instanceof \Model_Invoice) {
|
||||
$api_admin->invoice_transaction_update(['id' => $id, 'invoice_id' => $renewalInvoice->id]);
|
||||
$tx['invoice_id'] = $renewalInvoice->id;
|
||||
$invoiceDbModel = $renewalInvoice;
|
||||
if (!$renewalInvoice instanceof Invoice || $renewalInvoice->getId() === null) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('Unable to generate a renewal invoice for subscription payment');
|
||||
}
|
||||
|
||||
$api_admin->invoice_transaction_update(['id' => $id, 'invoice_id' => $renewalInvoice->getId()]);
|
||||
$tx['invoice_id'] = $renewalInvoice->getId();
|
||||
$invoiceDbModel = $renewalInvoice;
|
||||
}
|
||||
}
|
||||
|
||||
if ($invoiceDbModel instanceof \Model_Invoice) {
|
||||
if ($invoiceDbModel instanceof Invoice) {
|
||||
$expected = $invoiceService->getTotalWithTax($invoiceDbModel);
|
||||
$invoiceService->validatePaymentAmount((float) $ipn['mc_gross'], $expected);
|
||||
}
|
||||
@@ -204,57 +264,150 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
|
||||
$api_admin->client_balance_add_funds($bd);
|
||||
|
||||
if (!empty($tx['invoice_id']) && $invoiceDbModel instanceof \Model_Invoice && !$invoiceService->isInvoiceTypeDeposit($invoiceDbModel)) {
|
||||
if (!$invoiceDbModel->approved) {
|
||||
$invoiceService->approveInvoice($invoiceDbModel, ['use_credits' => false]);
|
||||
if (!empty($tx['invoice_id']) && $invoiceDbModel instanceof Invoice && !$invoiceService->isInvoiceTypeDeposit($invoiceDbModel)) {
|
||||
if (!$invoiceDbModel->isIssued()) {
|
||||
$invoiceService->issueInvoice($invoiceDbModel, ['use_credits' => false]);
|
||||
}
|
||||
$api_admin->invoice_pay_with_credits(['id' => $tx['invoice_id']]);
|
||||
} elseif (!empty($tx['invoice_id']) && $invoiceDbModel instanceof \Model_Invoice && $invoiceService->isInvoiceTypeDeposit($invoiceDbModel)) {
|
||||
} elseif (!empty($tx['invoice_id']) && $invoiceDbModel instanceof Invoice && $invoiceService->isInvoiceTypeDeposit($invoiceDbModel)) {
|
||||
$invoiceService->markAsPaid($invoiceDbModel);
|
||||
} elseif (empty($tx['invoice_id'])) {
|
||||
$api_admin->invoice_batch_pay_with_credits(['client_id' => $client_id]);
|
||||
}
|
||||
$this->di['logger']->info('Applied PayPal transaction ' . $ipn['txn_id'] . ' to invoice ' . $tx['invoice_id']);
|
||||
|
||||
break;
|
||||
|
||||
case 'subscr_signup':
|
||||
$existingSubscription = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => $ipn['subscr_id']]);
|
||||
case 'recurring_payment_profile_created':
|
||||
$subscrId = (string) ($ipn['subscr_id'] ?? '');
|
||||
if ($subscrId === '') {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription signup is missing the subscription ID');
|
||||
}
|
||||
$this->validateCurrency($ipn['mc_currency'] ?? null, $invoice['currency'] ?? null);
|
||||
$subscrPeriod = str_replace(' ', '', (string) ($ipn['period3'] ?? ''));
|
||||
if ($subscrPeriod === '') {
|
||||
// Newer-flow IPNs carry no period: derive it from the linked
|
||||
// invoice, preserving null when the invoice has no recurring period.
|
||||
$subscriptionService = $this->di['mod_service']('Invoice', 'Subscription');
|
||||
$periodInvoice = $this->di['em']->getRepository(Invoice::class)->find($tx['invoice_id']);
|
||||
if ($periodInvoice instanceof Invoice) {
|
||||
$subscrPeriod = $subscriptionService->getSubscriptionPeriod($periodInvoice);
|
||||
}
|
||||
}
|
||||
|
||||
$existingSubscription = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => $subscrId]);
|
||||
|
||||
if (!$existingSubscription instanceof \Box\Mod\Invoice\Entity\Subscription) {
|
||||
$sd = [
|
||||
'client_id' => $client_id,
|
||||
'gateway_id' => $gateway_id,
|
||||
'currency' => $ipn['mc_currency'],
|
||||
'sid' => $ipn['subscr_id'],
|
||||
'currency' => (string) ($ipn['mc_currency'] ?? ''),
|
||||
'sid' => $subscrId,
|
||||
'status' => 'active',
|
||||
'period' => str_replace(' ', '', $ipn['period3']),
|
||||
'amount' => $ipn['amount3'],
|
||||
'period' => $subscrPeriod,
|
||||
'amount' => (string) ($ipn['amount3'] ?? ''),
|
||||
'rel_type' => 'invoice',
|
||||
'rel_id' => $invoice['id'],
|
||||
];
|
||||
$api_admin->invoice_subscription_create($sd);
|
||||
$this->di['logger']->info('Stored PayPal subscription ' . $subscrId . ' from ' . $txnType . ' IPN for transaction ' . $id);
|
||||
}
|
||||
|
||||
$t = [
|
||||
'id' => $id,
|
||||
's_id' => $ipn['subscr_id'],
|
||||
's_period' => str_replace(' ', '', $ipn['period3']),
|
||||
's_id' => $subscrId,
|
||||
's_period' => $subscrPeriod,
|
||||
];
|
||||
$api_admin->invoice_transaction_update($t);
|
||||
|
||||
break;
|
||||
|
||||
case 'subscr_modify':
|
||||
$modifySid = (string) ($ipn['subscr_id'] ?? '');
|
||||
if ($modifySid === '') {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription update is missing the subscription ID');
|
||||
}
|
||||
$modifiedSubscription = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => $modifySid]);
|
||||
if (!$modifiedSubscription instanceof \Box\Mod\Invoice\Entity\Subscription) {
|
||||
$this->di['logger']->warning('Ignoring PayPal subscr_modify IPN for unknown subscription ' . $modifySid . ' on transaction ' . $id);
|
||||
|
||||
break;
|
||||
}
|
||||
$modifyData = ['id' => $modifiedSubscription->getId()];
|
||||
if (isset($ipn['amount3'])) {
|
||||
$modifyData['amount'] = (string) $ipn['amount3'];
|
||||
}
|
||||
if (isset($ipn['period3'])) {
|
||||
$modifyData['period'] = str_replace(' ', '', (string) $ipn['period3']);
|
||||
}
|
||||
$api_admin->invoice_subscription_update($modifyData);
|
||||
$api_admin->invoice_transaction_update([
|
||||
'id' => $id,
|
||||
's_id' => $modifySid,
|
||||
's_period' => $modifyData['period'] ?? $modifiedSubscription->getPeriod(),
|
||||
]);
|
||||
$this->di['logger']->info('Updated subscription ' . $modifySid . ' from PayPal subscr_modify IPN for transaction ' . $id);
|
||||
|
||||
break;
|
||||
|
||||
case 'recurring_payment_suspended_due_to_max_failed_payment':
|
||||
case 'subscr_failed':
|
||||
case 'recurring_payment_profile_cancel':
|
||||
case 'recurring_payment_suspended':
|
||||
case 'recurring_payment_expired':
|
||||
case 'subscr_eot':
|
||||
case 'subscr_cancel':
|
||||
$s = $api_admin->invoice_subscription_get(['sid' => $ipn['subscr_id']]);
|
||||
$api_admin->invoice_subscription_update(['id' => $s['id'], 'status' => 'canceled']);
|
||||
$cancelSid = (string) ($ipn['subscr_id'] ?? '');
|
||||
if ($cancelSid === '') {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription update is missing the subscription ID');
|
||||
}
|
||||
|
||||
$storedCancellation = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => $cancelSid]);
|
||||
if (!$storedCancellation instanceof \Box\Mod\Invoice\Entity\Subscription) {
|
||||
// Nothing to cancel for an unknown subscription, so acknowledge
|
||||
// the notification instead of failing the IPN.
|
||||
$this->di['logger']->warning('Ignoring PayPal ' . $txnType . ' IPN for unknown subscription ' . $cancelSid . ' on transaction ' . $id);
|
||||
|
||||
break;
|
||||
}
|
||||
$api_admin->invoice_subscription_update(['id' => $storedCancellation->getId(), 'status' => 'canceled']);
|
||||
$this->di['logger']->info('Canceled subscription ' . $cancelSid . ' from PayPal ' . $txnType . ' IPN for transaction ' . $id);
|
||||
|
||||
break;
|
||||
|
||||
case 'recurring_payment_failed':
|
||||
case 'subscr_failed':
|
||||
$failedSid = (string) ($ipn['subscr_id'] ?? '');
|
||||
if ($failedSid === '') {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription update is missing the subscription ID');
|
||||
}
|
||||
$failedSubscription = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => $failedSid]);
|
||||
if (!$failedSubscription instanceof \Box\Mod\Invoice\Entity\Subscription) {
|
||||
$this->di['logger']->warning('Ignoring PayPal ' . $txnType . ' IPN for unknown subscription ' . $failedSid . ' on transaction ' . $id);
|
||||
|
||||
break;
|
||||
}
|
||||
// A failed charge does not end the subscription: PayPal reattempts
|
||||
// it and later payments still apply, so link the transaction and
|
||||
// leave the subscription status untouched.
|
||||
$api_admin->invoice_transaction_update(['id' => $id, 's_id' => $failedSid]);
|
||||
$this->di['logger']->warning('Recorded failed PayPal payment for subscription ' . $failedSid . ' on transaction ' . $id);
|
||||
|
||||
break;
|
||||
|
||||
case 'recurring_payment_skipped':
|
||||
$skippedSid = (string) ($ipn['subscr_id'] ?? '');
|
||||
if ($skippedSid === '') {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal subscription update is missing the subscription ID');
|
||||
}
|
||||
// A skipped cycle leaves the subscription itself in place, so
|
||||
// there is nothing to update — just leave a trace of the missed payment.
|
||||
$this->di['logger']->warning('Acknowledged skipped PayPal payment for subscription ' . $skippedSid . ' on transaction ' . $id);
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
error_log('Unknown paypal transaction ' . $id);
|
||||
$this->di['logger']->error('Unknown PayPal transaction ' . $id);
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -262,7 +415,7 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
if (
|
||||
isset($ipn['payment_status'], $ipn['txn_type'])
|
||||
&& $ipn['payment_status'] == 'Refunded'
|
||||
&& in_array($ipn['txn_type'], ['web_accept', 'subscr_payment'], true)
|
||||
&& in_array($ipn['txn_type'], ['web_accept', 'subscr_payment', 'recurring_payment'], true)
|
||||
) {
|
||||
$refd = [
|
||||
'id' => $invoice['id'],
|
||||
@@ -271,6 +424,10 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
$api_admin->invoice_refund($refd);
|
||||
}
|
||||
|
||||
if ($awaitingCompletion) {
|
||||
return;
|
||||
}
|
||||
|
||||
$d = [
|
||||
'id' => $id,
|
||||
'error' => '',
|
||||
@@ -281,6 +438,141 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
$api_admin->invoice_transaction_update($d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the invoice id from callback data, authenticating the binding.
|
||||
* Used by flows that read the invoice from the IPN before processing it.
|
||||
*
|
||||
* @param mixed $data raw callback input, which callers may not have validated yet
|
||||
*/
|
||||
public function getInvoiceId($data): ?int
|
||||
{
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
$invoiceId = $data['invoice_id'] ?? $data['get']['invoice_id'] ?? null;
|
||||
if (empty($invoiceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gatewayId = $this->config['gateway_id'] ?? null;
|
||||
if ($gatewayId === null || $gatewayId === '') {
|
||||
return (int) $invoiceId;
|
||||
}
|
||||
|
||||
return $this->verifyCallbackBinding($data, (int) $gatewayId, $invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the gateway/invoice binding carried by the callback URL.
|
||||
* Returns the verified invoice id, or null when no invoice was supplied.
|
||||
*
|
||||
* Unsigned callbacks predate signing and are honored only for a stored
|
||||
* subscription on the same invoice, or for a notification continuing a
|
||||
* previously recorded payment for the same invoice (delayed completions
|
||||
* and refunds for pre-upgrade payments), so existing activity keeps
|
||||
* working: a fresh payment has no earlier transaction row to match, and
|
||||
* a tampered invoice id never matches the stored row.
|
||||
*/
|
||||
private function verifyCallbackBinding(array $data, int $gatewayId, mixed $boundInvoiceId, ?int $currentTxId = null): ?int
|
||||
{
|
||||
$get = $data['get'] ?? [];
|
||||
$invoiceId = $boundInvoiceId ?: ($get['invoice_id'] ?? null);
|
||||
if (empty($invoiceId)) {
|
||||
return null;
|
||||
}
|
||||
$invoiceId = (int) $invoiceId;
|
||||
|
||||
if (\FOSSBilling\Tools::verifyCallbackSignature($gatewayId, $invoiceId, $get['sig'] ?? null)) {
|
||||
return $invoiceId;
|
||||
}
|
||||
|
||||
$post = $data['post'] ?? [];
|
||||
$subscrId = (string) ($post['subscr_id'] ?? $post['recurring_payment_id'] ?? '');
|
||||
if ($subscrId !== '') {
|
||||
$storedSubscription = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Subscription::class)->findOneBy(['sid' => $subscrId]);
|
||||
if ($storedSubscription instanceof \Box\Mod\Invoice\Entity\Subscription
|
||||
&& $storedSubscription->getRelType() === 'invoice'
|
||||
&& $storedSubscription->getRelId() !== null
|
||||
&& $storedSubscription->getRelId() === $invoiceId) {
|
||||
$this->di['logger']->info('Accepted unsigned PayPal callback for stored subscription ' . $subscrId . ' on invoice ' . $invoiceId);
|
||||
|
||||
return $invoiceId;
|
||||
}
|
||||
}
|
||||
|
||||
// A verified IPN names PayPal-grounded transaction ids, so an
|
||||
// earlier transaction row for the same gateway payment on the same
|
||||
// invoice proves this notification continues pre-upgrade activity
|
||||
// rather than starting a fresh (unsigned) payment.
|
||||
if ($currentTxId !== null) {
|
||||
foreach ([$post['txn_id'] ?? null, $post['parent_txn_id'] ?? null] as $candidateTxnId) {
|
||||
if (!is_string($candidateTxnId) || $candidateTxnId === '') {
|
||||
continue;
|
||||
}
|
||||
$earlier = $this->di['em']->getRepository(\Box\Mod\Invoice\Entity\Transaction::class)->findActiveByTxnIdAndGatewayId($candidateTxnId, $gatewayId, $currentTxId);
|
||||
$earlierInvoice = $earlier?->getInvoice();
|
||||
if ($earlierInvoice instanceof Invoice && (int) $earlierInvoice->getId() === $invoiceId) {
|
||||
$this->di['logger']->info('Accepted unsigned PayPal callback continuing transaction ' . $candidateTxnId . ' on invoice ' . $invoiceId);
|
||||
|
||||
return $invoiceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal callback signature is invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-check PayPal's echoed invoice references against the invoice
|
||||
* about to be credited (defense in depth behind the callback signature).
|
||||
* Only enforced when PayPal echoes the field; several notification
|
||||
* types omit these references, and that must never fail a payment.
|
||||
*/
|
||||
private function verifyIpnEcho(array $ipn, Invoice $invoice): void
|
||||
{
|
||||
$echoedNr = trim((string) ($ipn['item_number'] ?? ''));
|
||||
if ($echoedNr !== '') {
|
||||
$expectedNr = trim((string) $invoice->getNr());
|
||||
if ($expectedNr !== '' && $echoedNr !== $expectedNr) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal item_number does not match invoice ' . $invoice->getId());
|
||||
}
|
||||
}
|
||||
|
||||
$echoedInvoice = trim((string) ($ipn['invoice'] ?? ''));
|
||||
if ($echoedInvoice !== '' && (int) $echoedInvoice !== (int) $invoice->getId()) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal invoice reference does not match invoice ' . $invoice->getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a binding signature to a callback URL. Skipped when the adapter
|
||||
* was built without a gateway id (never the case in production).
|
||||
*/
|
||||
private function signNotifyUrl(string $url, string|int $invoiceId): string
|
||||
{
|
||||
$gatewayId = $this->config['gateway_id'] ?? null;
|
||||
if ($gatewayId === null || $gatewayId === '') {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$sig = \FOSSBilling\Tools::signCallbackParams((int) $gatewayId, (int) $invoiceId);
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
|
||||
return $url . $separator . 'sig=' . urlencode($sig);
|
||||
}
|
||||
|
||||
private function validateCurrency(mixed $received, mixed $expected): void
|
||||
{
|
||||
$received = trim((string) $received);
|
||||
$expected = trim((string) $expected);
|
||||
if ($received === '' || $expected === '') {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception('PayPal payment is missing currency details');
|
||||
}
|
||||
if (strcasecmp($received, $expected) !== 0) {
|
||||
throw new \FOSSBilling\Extension\Contract\Payment\Exception(sprintf('PayPal payment currency %s does not match invoice currency %s', $received, $expected));
|
||||
}
|
||||
}
|
||||
|
||||
private function serviceUrl(): string
|
||||
{
|
||||
if ($this->config['test_mode']) {
|
||||
@@ -290,12 +582,16 @@ class PayPalEmail extends \FOSSBilling\Extension\Contract\Payment\AdapterAbstrac
|
||||
return 'https://www.paypal.com/cgi-bin/webscr';
|
||||
}
|
||||
|
||||
private function _isIpnValid($data): bool
|
||||
private function _isIpnValid(array $data): bool
|
||||
{
|
||||
// use http_raw_post_data instead of post due to encoding
|
||||
parse_str((string) $data['http_raw_post_data'], $post);
|
||||
$req = 'cmd=_notify-validate';
|
||||
foreach ($post as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No stripslashes() here: that undid PHP's long-removed magic_quotes_gpc
|
||||
// escaping. Applied unconditionally it corrupts any value containing a
|
||||
// backslash, so the re-posted request no longer matches what PayPal sent.
|
||||
@@ -382,27 +678,27 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
public function isIpnDuplicate(array $ipn): bool
|
||||
{
|
||||
$sql = 'SELECT id
|
||||
FROM transaction
|
||||
// `transaction` is a reserved word: quote it portably, or the lookup is
|
||||
// a syntax error on SQLite and every IPN looks new.
|
||||
$table = $this->di['em']->getConnection()->quoteSingleIdentifier('transaction');
|
||||
$sql = "SELECT id
|
||||
FROM {$table}
|
||||
WHERE txn_id = :transaction_id
|
||||
AND txn_status = :transaction_status
|
||||
AND type = :transaction_type
|
||||
AND amount = :transaction_amount
|
||||
LIMIT 2';
|
||||
LIMIT 2";
|
||||
|
||||
$bindings = [
|
||||
':transaction_id' => $ipn['txn_id'],
|
||||
':transaction_status' => $ipn['payment_status'],
|
||||
':transaction_type' => $ipn['txn_type'],
|
||||
':transaction_amount' => $ipn['mc_gross'],
|
||||
'transaction_id' => $ipn['txn_id'] ?? null,
|
||||
'transaction_status' => $ipn['payment_status'] ?? null,
|
||||
'transaction_type' => $ipn['txn_type'] ?? null,
|
||||
'transaction_amount' => $ipn['mc_gross'] ?? null,
|
||||
];
|
||||
|
||||
$rows = $this->di['db']->getAll($sql, $bindings);
|
||||
if (\FOSSBilling\Tools::safeCount($rows) > 1) {
|
||||
return true;
|
||||
}
|
||||
$rows = $this->di['em']->getConnection()->fetchAllAssociative($sql, $bindings);
|
||||
|
||||
return false;
|
||||
return count($rows) > 1;
|
||||
}
|
||||
|
||||
public function getInvoiceTitle(array $invoice): string
|
||||
@@ -428,7 +724,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
$data['currency_code'] = $invoice['currency'];
|
||||
$data['return'] = $this->config['thankyou_url'];
|
||||
$data['cancel_return'] = $this->config['cancel_url'];
|
||||
$data['notify_url'] = $this->config['notify_url'];
|
||||
$data['notify_url'] = $this->signNotifyUrl($this->config['notify_url'], $invoice['id']);
|
||||
$data['business'] = $this->config['email'];
|
||||
|
||||
$data['cmd'] = '_xclick-subscriptions';
|
||||
@@ -478,7 +774,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
$data['rm'] = '2';
|
||||
$data['return'] = $this->config['thankyou_url'];
|
||||
$data['cancel_return'] = $this->config['cancel_url'];
|
||||
$data['notify_url'] = $this->config['notify_url'];
|
||||
$data['notify_url'] = $this->signNotifyUrl($this->config['notify_url'], $invoice['id']);
|
||||
$data['business'] = $this->config['email'];
|
||||
$data['cmd'] = '_xclick';
|
||||
$data['amount'] = $this->moneyFormat($invoice['subtotal'], $invoice['currency']);
|
||||
|
||||
@@ -4,5 +4,17 @@
|
||||
"name": "PayPal",
|
||||
"description": "Accepts PayPal payments addressed to a PayPal account email.",
|
||||
"version": "1.0.0",
|
||||
"api": 1
|
||||
"api": 1,
|
||||
"logo": {
|
||||
"file": "paypal.png",
|
||||
"width": "85px",
|
||||
"height": "25px"
|
||||
},
|
||||
"settings": [
|
||||
{
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"label": "PayPal email address for payments"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"require": {
|
||||
"stripe/stripe-php": "^21.0.0"
|
||||
"stripe/stripe-php": "^22.0.0"
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,70 @@
|
||||
"name": "Stripe",
|
||||
"description": "Accepts card payments and subscriptions through Stripe.",
|
||||
"version": "1.0.0",
|
||||
"api": 1
|
||||
"api": 1,
|
||||
"logo": {
|
||||
"file": "stripe.png",
|
||||
"width": "65px",
|
||||
"height": "30px"
|
||||
},
|
||||
"settings": [
|
||||
{
|
||||
"name": "pub_key",
|
||||
"type": "text",
|
||||
"label": "Live Publishable Key:",
|
||||
"required_when": {
|
||||
"enabled": true,
|
||||
"test_mode": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "api_key",
|
||||
"type": "password",
|
||||
"label": "Live Secret Key:",
|
||||
"required_when": {
|
||||
"enabled": true,
|
||||
"test_mode": false
|
||||
},
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "webhook_secret",
|
||||
"type": "password",
|
||||
"label": "Live Webhook signing secret:",
|
||||
"required_when": {
|
||||
"enabled": true,
|
||||
"test_mode": false
|
||||
},
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "test_pub_key",
|
||||
"type": "text",
|
||||
"label": "Test Publishable Key:",
|
||||
"required_when": {
|
||||
"enabled": true,
|
||||
"test_mode": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "test_api_key",
|
||||
"type": "password",
|
||||
"label": "Test Secret Key:",
|
||||
"required_when": {
|
||||
"enabled": true,
|
||||
"test_mode": true
|
||||
},
|
||||
"secret": true
|
||||
},
|
||||
{
|
||||
"name": "test_webhook_secret",
|
||||
"type": "password",
|
||||
"label": "Test Webhook signing secret:",
|
||||
"required_when": {
|
||||
"enabled": true,
|
||||
"test_mode": true
|
||||
},
|
||||
"secret": true
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -136,14 +136,21 @@ class CWP extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
$new = clone $account;
|
||||
$acc = $this->request('accountdetail', $data);
|
||||
|
||||
if ($acc['account_info']['state'] == 'suspended') {
|
||||
$info = $acc['account_info'] ?? null;
|
||||
if (!is_array($info) || !is_string($info['state'] ?? null) || !is_string($info['package_name'] ?? null)) {
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('CWP did not return valid account details. Check that the account exists and the API key has permission to view it.');
|
||||
}
|
||||
|
||||
if ($info['state'] == 'suspended') {
|
||||
$new->setSuspended(true);
|
||||
} else {
|
||||
$new->setSuspended(false);
|
||||
}
|
||||
|
||||
$new->setPackage($acc['account_info']['package_name']);
|
||||
$new->setReseller(\FOSSBilling\Tools::normalizeBoolean($acc['account_info']['reseller'] ?? false));
|
||||
$package = new \FOSSBilling\Extension\Contract\Server\Package();
|
||||
$package->setName($info['package_name']);
|
||||
$new->setPackage($package);
|
||||
$new->setReseller(\FOSSBilling\Tools::normalizeBoolean($info['reseller'] ?? false));
|
||||
|
||||
return $new;
|
||||
}
|
||||
@@ -403,7 +410,14 @@ class CWP extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
$request = $client->request('POST', $url, [
|
||||
'body' => $data,
|
||||
]);
|
||||
$response = $request->toArray();
|
||||
|
||||
try {
|
||||
$response = $request->toArray();
|
||||
} catch (\Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface $e) {
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('The CWP server returned HTTP status :status. Check server availability and API configuration.', [':status' => $e->getResponse()->getStatusCode()]);
|
||||
} catch (\Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface) {
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('The CWP server returned an invalid JSON response. Check the server address, port and API configuration.');
|
||||
}
|
||||
|
||||
// Get the status, result, and message from the response, with default values if they are not set
|
||||
$status = $response['status'] ?? 'Error';
|
||||
@@ -412,7 +426,7 @@ class CWP extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
|
||||
// If the status is not 'OK', log an error message and return false
|
||||
if ($status !== 'OK') {
|
||||
error_log('CWP Server manager error. Status: ' . $status . '. Message: ' . $msg);
|
||||
$this->getLog()->error('CWP Server manager error. Status: ' . $status . '. Message: ' . $msg);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
function createCwpManager(string $body, int $statusCode = 200): FOSSBilling\Extension\Manager\CWP\CWP
|
||||
{
|
||||
$client = new MockHttpClient(new MockResponse($body, ['http_code' => $statusCode]));
|
||||
|
||||
return new class($client) extends FOSSBilling\Extension\Manager\CWP\CWP {
|
||||
public function __construct(private readonly HttpClientInterface $httpClient)
|
||||
{
|
||||
parent::__construct(['ip' => '127.0.0.1', 'host' => 'cwp.example.com', 'accesshash' => 'test-key']);
|
||||
$this->setLog(new Psr\Log\NullLogger());
|
||||
}
|
||||
|
||||
public function getHttpClient(): HttpClientInterface
|
||||
{
|
||||
return $this->httpClient;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('CWP rejects unsuccessful or incomplete account details', function (array $response): void {
|
||||
$manager = createCwpManager(json_encode($response));
|
||||
$account = (new FOSSBilling\Extension\Contract\Server\Account())->setUsername('example')->setSuspended(true)->setPackage((new FOSSBilling\Extension\Contract\Server\Package())->setName('existing'));
|
||||
|
||||
set_error_handler(static function (int $severity, string $message): never {
|
||||
throw new ErrorException($message, 0, $severity);
|
||||
});
|
||||
|
||||
try {
|
||||
expect(fn (): FOSSBilling\Extension\Contract\Server\Account => $manager->synchronizeAccount($account))->toThrow(FOSSBilling\Extension\Contract\Server\Exception::class, 'CWP did not return valid account details');
|
||||
} finally {
|
||||
restore_error_handler();
|
||||
}
|
||||
|
||||
expect($account->getSuspended())->toBeTrue()
|
||||
->and($account->getPackage()->getName())->toBe('existing');
|
||||
})->with([
|
||||
'API error' => [['status' => 'Error', 'msg' => 'Account unavailable']],
|
||||
'null result' => [['status' => 'OK', 'result' => null]],
|
||||
'missing account' => [['status' => 'OK', 'result' => []]],
|
||||
'missing state' => [['status' => 'OK', 'result' => ['account_info' => ['package_name' => 'basic']]]],
|
||||
'missing package' => [['status' => 'OK', 'result' => ['account_info' => ['state' => 'active']]]],
|
||||
'invalid package' => [['status' => 'OK', 'result' => ['account_info' => ['state' => 'active', 'package_name' => []]]]],
|
||||
]);
|
||||
|
||||
test('CWP reports invalid JSON as a server error', function (string $body): void {
|
||||
$manager = createCwpManager($body);
|
||||
|
||||
expect(fn (): bool => $manager->testConnection())->toThrow(FOSSBilling\Extension\Contract\Server\Exception::class, 'The CWP server returned an invalid JSON response');
|
||||
})->with(['empty' => '', 'HTML' => '<html>Not found</html>']);
|
||||
|
||||
test('CWP reports HTTP errors separately from invalid JSON', function (int $statusCode): void {
|
||||
$manager = createCwpManager('<html>Request failed</html>', $statusCode);
|
||||
|
||||
expect(fn (): bool => $manager->testConnection())->toThrow(FOSSBilling\Extension\Contract\Server\Exception::class, 'The CWP server returned HTTP status ' . $statusCode);
|
||||
})->with([403, 503]);
|
||||
|
||||
test('CWP synchronizes valid account details', function (string $state, bool $suspended): void {
|
||||
$manager = createCwpManager(json_encode(['status' => 'OK', 'result' => ['account_info' => [
|
||||
'state' => $state, 'package_name' => 'basic', 'reseller' => '1',
|
||||
]]]));
|
||||
$account = (new FOSSBilling\Extension\Contract\Server\Account())->setUsername('example');
|
||||
|
||||
$updated = $manager->synchronizeAccount($account);
|
||||
|
||||
expect($updated)->not->toBe($account)
|
||||
->and($updated->getSuspended())->toBe($suspended)
|
||||
->and($updated->getPackage()->getName())->toBe('basic')
|
||||
->and($updated->getReseller())->toBeTrue();
|
||||
})->with([['active', false], ['suspended', true]]);
|
||||
@@ -511,84 +511,16 @@ class Directadmin extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
*/
|
||||
public function modifyAccount(\FOSSBilling\Extension\Contract\Server\Account $account): bool
|
||||
{
|
||||
// Get the package associated with the account
|
||||
$package = $account->getPackage();
|
||||
|
||||
// Prepare the fields for the request
|
||||
$fields = [
|
||||
'aftp' => $package->getHasAnonymousFtp() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will be able to have anonymous ftp accounts.
|
||||
'action' => 'customize',
|
||||
'bandwidth' => $package->getBandwidth(), // Bandwidth quota in MB
|
||||
'catchall' => $package->getHasCatchAll() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have the ability to enable and customize a catch-all email (*@domain.com).
|
||||
'cgi' => $package->getHasCgi() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have the ability to run cgi scripts in their cgi-bin.
|
||||
'cron' => $package->getHasCron() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have the ability to create cronjobs.
|
||||
'dnscontrol' => 'ON', // ON or OFF. If ON, the User will be able to modify his/her dns records.
|
||||
'domainptr' => $package->getMaxParkedDomains(), // Domain pointer quota
|
||||
'ftp' => $package->getMaxFtp(), // FTP account quota
|
||||
'mysql' => $package->getMaxSql(), // MySQL database quota
|
||||
'nemailf' => $package->getMaxEmailForwarders(), // Email forwarder quota
|
||||
'nemailml' => $package->getMaxEmailLists(), // Mailing list quota
|
||||
'nemailr' => $package->getMaxEmailAutoresponders(), // Autoresponder quota
|
||||
'nemails' => $package->getMaxPop(), // Email account quota
|
||||
'nsubdomains' => $package->getMaxSubdomains(), // Subdomain quota
|
||||
'ns1' => $account->getNs1(),
|
||||
'ns2' => $account->getNs2(),
|
||||
'php' => $package->getHasPhp() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have the ability to run php scripts.
|
||||
'quota' => $package->getQuota(), // Disk space quota in MB
|
||||
'spam' => $package->getHasSpamFilter() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have the ability to run scan email with SpamAssassin.
|
||||
'ssh' => $package->getHasShell() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have an ssh account.
|
||||
'ssl' => $package->getHasSsl() ? 'ON' : 'OFF', // ON or OFF. If ON, the User will have the ability to access their websites through secure https://.
|
||||
'sysinfo' => 'ON', // ON or OFF. If ON, the User will have access to a page that shows the system information.
|
||||
'user' => $account->getUsername(),
|
||||
'vdomains' => $package->getMaxDomains(), // Domain quota
|
||||
];
|
||||
$fields = $this->getCustomPackageFields($package);
|
||||
$fields['dnscontrol'] = $this->getCustomPackageFlag($package, 'dnscontrol', 'ON');
|
||||
$fields['sysinfo'] = $this->getCustomPackageFlag($package, 'sysinfo', 'ON');
|
||||
$fields['action'] = 'customize';
|
||||
$fields['ns1'] = $account->getNs1();
|
||||
$fields['ns2'] = $account->getNs2();
|
||||
$fields['user'] = $account->getUsername();
|
||||
|
||||
// Check if certain parameters are set to 'unlimited' and set the corresponding fields to 'ON'
|
||||
if ($package->getBandwidth() == 'unlimited') {
|
||||
$fields['ubandwidth'] = 'ON'; // ON or OFF. If ON, bandwidth is ignored and no limit is set
|
||||
}
|
||||
|
||||
if ($package->getQuota() == 'unlimited') {
|
||||
$fields['uquota'] = 'ON'; // ON or OFF. If ON, quota is ignored and no limit is set
|
||||
}
|
||||
|
||||
if ($package->getMaxDomains() == 'unlimited') {
|
||||
$fields['uvdomains'] = 'ON'; // ON or OFF. If ON, vdomains is ignored and no limit is set
|
||||
}
|
||||
|
||||
if ($package->getMaxSubdomains() == 'unlimited') {
|
||||
$fields['unsubdomains'] = 'ON'; // ON or OFF. If ON, nsubdomains is ignored and no limit is set
|
||||
}
|
||||
|
||||
if ($package->getMaxPop() == 'unlimited') {
|
||||
$fields['unemails'] = 'ON'; // ON or OFF Unlimited option for nemails
|
||||
}
|
||||
|
||||
if ($package->getMaxEmailForwarders() == 'unlimited') {
|
||||
$fields['unemailf'] = 'ON'; // ON or OFF Unlimited option for nemailf
|
||||
}
|
||||
|
||||
if ($package->getMaxEmailLists() == 'unlimited') {
|
||||
$fields['unemailml'] = 'ON'; // ON or OFF Unlimited option for nemailml
|
||||
}
|
||||
|
||||
if ($package->getMaxEmailAutoresponders() == 'unlimited') {
|
||||
$fields['unemailr'] = 'ON'; // ON or OFF Unlimited option for nemailr
|
||||
}
|
||||
|
||||
if ($package->getMaxSql() == 'unlimited') {
|
||||
$fields['umysql'] = 'ON'; // ON or OFF Unlimited option for mysql
|
||||
}
|
||||
|
||||
if ($package->getMaxParkedDomains() == 'unlimited') {
|
||||
$fields['udomainptr'] = 'ON'; // ON or OFF Unlimited option for domainptr
|
||||
}
|
||||
|
||||
if ($package->getMaxFtp() == 'unlimited') {
|
||||
$fields['uftp'] = 'ON'; // ON or OFF Unlimited option for ftp
|
||||
}
|
||||
|
||||
// Send the request to the server
|
||||
$this->request('API_MODIFY_USER', $fields);
|
||||
|
||||
return true;
|
||||
@@ -740,28 +672,28 @@ class Directadmin extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
private function getCustomPackageFields(\FOSSBilling\Extension\Contract\Server\Package $package): array
|
||||
{
|
||||
$fields = [
|
||||
'aftp' => $package->getCustomValue('aftp') ? 'ON' : 'OFF',
|
||||
'aftp' => $this->getCustomPackageFlag($package, 'aftp'),
|
||||
'bandwidth' => $package->getBandwidth(),
|
||||
'catchall' => $package->getCustomValue('catchall') ? 'ON' : 'OFF',
|
||||
'cgi' => $package->getCustomValue('cgi') ? 'ON' : 'OFF',
|
||||
'cron' => $package->getCustomValue('cron') ? 'ON' : 'OFF',
|
||||
'dnscontrol' => $package->getCustomValue('dnscontrol') ? 'ON' : 'OFF',
|
||||
'catchall' => $this->getCustomPackageFlag($package, 'catchall'),
|
||||
'cgi' => $this->getCustomPackageFlag($package, 'cgi'),
|
||||
'cron' => $this->getCustomPackageFlag($package, 'cron'),
|
||||
'dnscontrol' => $this->getCustomPackageFlag($package, 'dnscontrol', 'ON'),
|
||||
'domainptr' => $package->getMaxParkedDomains(),
|
||||
'ftp' => $package->getMaxFtp(),
|
||||
'login_keys' => $package->getCustomValue('login_keys') ? 'ON' : 'OFF',
|
||||
'login_keys' => $this->getCustomPackageFlag($package, 'login_keys'),
|
||||
'mysql' => $package->getMaxSql(),
|
||||
'nemailf' => $package->getMaxEmailForwarders(),
|
||||
'nemailml' => $package->getMaxEmailLists(),
|
||||
'nemailr' => $package->getMaxEmailAutoresponders(),
|
||||
'nemailf' => $package->getCustomValue('nemailf'),
|
||||
'nemailml' => $package->getCustomValue('nemailml'),
|
||||
'nemailr' => $package->getCustomValue('nemailr'),
|
||||
'nemails' => $package->getMaxPop(),
|
||||
'nsubdomains' => $package->getMaxSubdomains(),
|
||||
'php' => $package->getCustomValue('php') ? 'ON' : 'OFF',
|
||||
'php' => $this->getCustomPackageFlag($package, 'php'),
|
||||
'quota' => $package->getQuota(),
|
||||
'spam' => $package->getCustomValue('spam') ? 'ON' : 'OFF',
|
||||
'ssh' => $package->getCustomValue('ssh') ? 'ON' : 'OFF',
|
||||
'ssl' => $package->getCustomValue('ssl') ? 'ON' : 'OFF',
|
||||
'suspend_at_limit' => $package->getCustomValue('suspend_at_limit') ? 'ON' : 'OFF',
|
||||
'sysinfo' => $package->getCustomValue('sysinfo') ? 'ON' : 'OFF',
|
||||
'spam' => $this->getCustomPackageFlag($package, 'spam'),
|
||||
'ssh' => $this->getCustomPackageFlag($package, 'ssh'),
|
||||
'ssl' => $this->getCustomPackageFlag($package, 'ssl'),
|
||||
'suspend_at_limit' => $this->getCustomPackageFlag($package, 'suspend_at_limit'),
|
||||
'sysinfo' => $this->getCustomPackageFlag($package, 'sysinfo', 'ON'),
|
||||
'vdomains' => $package->getMaxDomains(),
|
||||
];
|
||||
|
||||
@@ -812,6 +744,16 @@ class Directadmin extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
return $fields;
|
||||
}
|
||||
|
||||
private function getCustomPackageFlag(\FOSSBilling\Extension\Contract\Server\Package $package, string $key, string $default = 'OFF'): string
|
||||
{
|
||||
$value = $package->getCustomValue($key);
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return \FOSSBilling\Tools::normalizeBoolean($value) ? 'ON' : 'OFF';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the information of an account from the DirectAdmin server.
|
||||
*
|
||||
|
||||
@@ -49,6 +49,91 @@ test('parseResponse decodes the legacy unterminated apostrophe entity', function
|
||||
expect($result['name'])->toBe("O'Brien");
|
||||
});
|
||||
|
||||
test('modifyAccount sends custom package values to DirectAdmin', function (): void {
|
||||
$requests = [];
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
|
||||
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
|
||||
|
||||
return new MockResponse('');
|
||||
});
|
||||
$manager = createDirectadminManager($httpClient);
|
||||
$package = (new FOSSBilling\Extension\Contract\Server\Package())
|
||||
->setBandwidth('1024')
|
||||
->setQuota('2048')
|
||||
->setMaxDomains('3')
|
||||
->setMaxSubdomains('4')
|
||||
->setMaxParkedDomains('5')
|
||||
->setMaxFtp('6')
|
||||
->setMaxSql('7')
|
||||
->setMaxPop('8')
|
||||
->setCustomValues([
|
||||
'aftp' => '1',
|
||||
'catchall' => 'false',
|
||||
'cgi' => 'yes',
|
||||
'cron' => 'true',
|
||||
'nemailf' => '5',
|
||||
'nemailml' => 'unlimited',
|
||||
'nemailr' => '7',
|
||||
'php' => 'on',
|
||||
'spam' => 'false',
|
||||
'ssh' => '1',
|
||||
'ssl' => 'yes',
|
||||
]);
|
||||
$account = (new FOSSBilling\Extension\Contract\Server\Account())
|
||||
->setUsername('example')
|
||||
->setNs1('ns1.example.com')
|
||||
->setNs2('ns2.example.com')
|
||||
->setPackage($package);
|
||||
|
||||
expect($manager->modifyAccount($account))->toBeTrue()
|
||||
->and($requests)->toHaveCount(1);
|
||||
|
||||
parse_str((string) parse_url($requests[0]['url'], PHP_URL_QUERY), $fields);
|
||||
|
||||
expect($fields)->toMatchArray([
|
||||
'action' => 'customize',
|
||||
'aftp' => 'ON',
|
||||
'catchall' => 'OFF',
|
||||
'cgi' => 'ON',
|
||||
'cron' => 'ON',
|
||||
'dnscontrol' => 'ON',
|
||||
'nemailf' => '5',
|
||||
'nemailml' => 'unlimited',
|
||||
'nemailr' => '7',
|
||||
'php' => 'ON',
|
||||
'spam' => 'OFF',
|
||||
'ssh' => 'ON',
|
||||
'ssl' => 'ON',
|
||||
'sysinfo' => 'ON',
|
||||
'unemailml' => 'ON',
|
||||
'user' => 'example',
|
||||
]);
|
||||
});
|
||||
|
||||
test('modifyAccount honors explicit false DNS and system information permissions', function (): void {
|
||||
$requests = [];
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
|
||||
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
|
||||
|
||||
return new MockResponse('');
|
||||
});
|
||||
$manager = createDirectadminManager($httpClient);
|
||||
$package = (new FOSSBilling\Extension\Contract\Server\Package())->setCustomValues([
|
||||
'dnscontrol' => 'false',
|
||||
'sysinfo' => '0',
|
||||
]);
|
||||
$account = (new FOSSBilling\Extension\Contract\Server\Account())
|
||||
->setUsername('example')
|
||||
->setPackage($package);
|
||||
|
||||
expect($manager->modifyAccount($account))->toBeTrue();
|
||||
|
||||
parse_str((string) parse_url($requests[0]['url'], PHP_URL_QUERY), $fields);
|
||||
|
||||
expect($fields['dnscontrol'])->toBe('OFF')
|
||||
->and($fields['sysinfo'])->toBe('OFF');
|
||||
});
|
||||
|
||||
test('suspendAccount sends the suspension reason to DirectAdmin', function (): void {
|
||||
$requests = [];
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
|
||||
|
||||
@@ -378,7 +378,7 @@ class Hestia extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
if (str_contains($result, 'Error')) {
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('Failed to connect to the :type: server. Please verify your credentials and configuration', [':type:' => 'HestiaCP']);
|
||||
} elseif (intval($result) !== 0) {
|
||||
error_log("HestiaCP returned error code $result for the " . $params['cmd'] . 'command');
|
||||
$this->getLog()->error("HestiaCP returned error code $result for the " . $params['cmd'] . ' command');
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace FOSSBilling\Extension\Manager\Plesk;
|
||||
|
||||
use PleskX\Api\Client;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Copyright 2022-2025 FOSSBilling
|
||||
@@ -72,7 +73,8 @@ class Plesk extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
$protocol = $this->_config['secure'] ? 'https' : 'http';
|
||||
$url = $protocol . '://' . $this->_config['host'] . ':' . $this->_config['port'];
|
||||
if ($account) {
|
||||
$sessionId = $this->_client->session()->create($account->getUsername(), $_SERVER['REMOTE_ADDR']);
|
||||
$clientIp = Request::createFromGlobals()->getClientIp() ?? '';
|
||||
$sessionId = $this->_client->session()->create($account->getUsername(), $clientIp);
|
||||
$url .= '/enterprise/rsession_init.php?PHPSESSID=' . $sessionId;
|
||||
}
|
||||
|
||||
@@ -296,12 +298,17 @@ class Plesk extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
{
|
||||
$this->getLog()->info('Updating domain for account ' . $account->getUsername());
|
||||
|
||||
// Filtering by 'owner-login' would match every webspace owned by this customer, renaming
|
||||
// all of them; 'name' (like deleteSubscription() already uses) scopes this to the one
|
||||
// subscription being changed. Capture it before setDomain() overwrites it below.
|
||||
$currentDomain = $account->getDomain();
|
||||
|
||||
$account->setDomain($newDomain);
|
||||
|
||||
$params = [
|
||||
'set' => [
|
||||
'filter' => [
|
||||
'owner-login' => $account->getUsername(),
|
||||
'name' => $currentDomain,
|
||||
],
|
||||
'values' => [
|
||||
'gen_setup' => [
|
||||
@@ -402,16 +409,27 @@ class Plesk extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
|
||||
/**
|
||||
* Creates an array of properties for a subscription.
|
||||
* The properties include the domain name, owner login, hosting type, IP address, FTP login, FTP password, PHP, SSL, CGI, limits, and permissions.
|
||||
* The properties include the domain name, owner login, hosting type, IP address, the hosting plan name, FTP login, FTP password, PHP, SSL, CGI, limits, and permissions.
|
||||
*
|
||||
* For the 'add' action, the properties are sent as the direct children of the <add> node, per the
|
||||
* webspace add operation's schema. For the 'set' action, Plesk requires the <set> node to contain only
|
||||
* a <filter> (identifying which subscription to update) and a <values> node wrapping the actual
|
||||
* settings; its gen_setup element also does not accept 'htype', which only applies at creation time,
|
||||
* and its schema has no plan-related node at all, so 'plan-name' is only ever sent on 'add'.
|
||||
*
|
||||
* @see https://docs.plesk.com/en-US/obsidian/api-rpc/reference/managing-subscriptions-webspaces/creating-a-subscription-webspace.33892/
|
||||
* @see https://docs.plesk.com/en-US/obsidian/api-rpc/reference/managing-subscriptions-webspaces/setting-subscription-parameters.33907/
|
||||
*
|
||||
* @param \FOSSBilling\Extension\Contract\Server\Account $account the account for which the properties should be created
|
||||
* @param string $action the action to be performed on the subscription
|
||||
* @param string $action the action to be performed on the subscription ('add' or 'set')
|
||||
*
|
||||
* @return array the array of subscription properties
|
||||
*/
|
||||
private function createSubscriptionProps(\FOSSBilling\Extension\Contract\Server\Account $account, string $action): array
|
||||
{
|
||||
$package = $account->getPackage();
|
||||
$maxMailLists = $package->getCustomValue('nemailml');
|
||||
$manageMailLists = is_numeric($maxMailLists) && (int) $maxMailLists > 0;
|
||||
|
||||
// check if bandwidth quota is set as an integer. If so, convert it to bytes
|
||||
$bandwidth = 0;
|
||||
@@ -425,134 +443,168 @@ class Plesk extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
$quota = intval($package->getQuota()) * 1024 * 1024;
|
||||
}
|
||||
|
||||
return [
|
||||
$action => [
|
||||
'gen_setup' => [
|
||||
'name' => $account->getDomain(),
|
||||
'owner-login' => $account->getUsername(),
|
||||
'htype' => 'vrt_hst',
|
||||
$genSetup = [
|
||||
'name' => $account->getDomain(),
|
||||
'owner-login' => $account->getUsername(),
|
||||
];
|
||||
|
||||
if ($action === 'add') {
|
||||
// 'htype' is only valid on subscription creation; the update schema (SetGenSetupType) doesn't have it.
|
||||
$genSetup['htype'] = 'vrt_hst';
|
||||
}
|
||||
|
||||
$genSetup['ip_address'] = $account->getIp();
|
||||
|
||||
$values = [
|
||||
'gen_setup' => $genSetup,
|
||||
'hosting' => [
|
||||
'vrt_hst' => [
|
||||
'property' => [
|
||||
[
|
||||
'name' => 'ftp_login',
|
||||
'value' => $account->getUsername(),
|
||||
],
|
||||
[
|
||||
'name' => 'ftp_password',
|
||||
'value' => $account->getPassword(),
|
||||
],
|
||||
[
|
||||
'name' => 'php',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'ssl',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'cgi',
|
||||
'value' => 'true',
|
||||
],
|
||||
],
|
||||
'ip_address' => $account->getIp(),
|
||||
],
|
||||
'hosting' => [
|
||||
'vrt_hst' => [
|
||||
'property' => [
|
||||
[
|
||||
'name' => 'ftp_login',
|
||||
'value' => $account->getUsername(),
|
||||
],
|
||||
[
|
||||
'name' => 'ftp_password',
|
||||
'value' => $account->getPassword(),
|
||||
],
|
||||
[
|
||||
'name' => 'php',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'ssl',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'cgi',
|
||||
'value' => 'true',
|
||||
],
|
||||
],
|
||||
'ip_address' => $account->getIp(),
|
||||
],
|
||||
'limits' => [
|
||||
'limit' => [
|
||||
[
|
||||
'name' => 'max_db',
|
||||
'value' => $package->getMaxSql() ?: 0,
|
||||
],
|
||||
],
|
||||
'limits' => [
|
||||
'limit' => [
|
||||
[
|
||||
'name' => 'max_db',
|
||||
'value' => $package->getMaxSql() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_maillists',
|
||||
'value' => $package->getMaxEmailLists() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_box',
|
||||
'value' => $package->getMaxPop() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_traffic',
|
||||
'value' => $bandwidth,
|
||||
],
|
||||
[
|
||||
'name' => 'disk_space',
|
||||
'value' => $quota,
|
||||
],
|
||||
[
|
||||
'name' => 'max_subdom',
|
||||
'value' => $package->getMaxSubdomains() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_subftp_users',
|
||||
'value' => $package->getMaxFtp() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_site',
|
||||
'value' => $package->getMaxDomains() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_maillists',
|
||||
'value' => $maxMailLists ?: 0,
|
||||
],
|
||||
],
|
||||
'permissions' => [
|
||||
'permission' => [
|
||||
[
|
||||
'name' => 'manage_subdomains',
|
||||
'value' => $package->getMaxSubdomains() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_dns',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_crontab',
|
||||
'value' => $package->getHasCron() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_anonftp',
|
||||
'value' => $package->getHasAnonymousFtp() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_sh_access',
|
||||
'value' => $package->getHasShell() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_maillists',
|
||||
'value' => $package->getMaxEmailLists() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'create_domains',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_phosting',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_quota',
|
||||
'value' => $account->getReseller() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_not_chroot_shell',
|
||||
'value' => $package->getHasShell() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_domain_aliases',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_subftp',
|
||||
'value' => $package->getMaxFtp() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_spamfilter',
|
||||
'value' => $package->getHasSpamFilter() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'max_box',
|
||||
'value' => $package->getMaxPop() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_traffic',
|
||||
'value' => $bandwidth,
|
||||
],
|
||||
[
|
||||
'name' => 'disk_space',
|
||||
'value' => $quota,
|
||||
],
|
||||
[
|
||||
'name' => 'max_subdom',
|
||||
'value' => $package->getMaxSubdomains() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_subftp_users',
|
||||
'value' => $package->getMaxFtp() ?: 0,
|
||||
],
|
||||
[
|
||||
'name' => 'max_site',
|
||||
'value' => $package->getMaxDomains() ?: 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
'permissions' => [
|
||||
'permission' => [
|
||||
[
|
||||
'name' => 'manage_subdomains',
|
||||
'value' => $package->getMaxSubdomains() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_dns',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_crontab',
|
||||
'value' => \FOSSBilling\Tools::normalizeBoolean($package->getCustomValue('cron')) ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_anonftp',
|
||||
'value' => \FOSSBilling\Tools::normalizeBoolean($package->getCustomValue('aftp')) ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_sh_access',
|
||||
'value' => \FOSSBilling\Tools::normalizeBoolean($package->getCustomValue('ssh')) ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_maillists',
|
||||
'value' => $manageMailLists ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'create_domains',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_phosting',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_quota',
|
||||
'value' => $account->getReseller() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_not_chroot_shell',
|
||||
'value' => \FOSSBilling\Tools::normalizeBoolean($package->getCustomValue('ssh')) ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_domain_aliases',
|
||||
'value' => 'true',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_subftp',
|
||||
'value' => $package->getMaxFtp() ? 'true' : 'false',
|
||||
],
|
||||
[
|
||||
'name' => 'manage_spamfilter',
|
||||
'value' => \FOSSBilling\Tools::normalizeBoolean($package->getCustomValue('spam')) ? 'true' : 'false',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if ($action === 'set') {
|
||||
$values['hosting']['vrt_hst']['property'] = array_values(array_filter(
|
||||
$values['hosting']['vrt_hst']['property'],
|
||||
fn (array $property): bool => $property['name'] !== 'ftp_password',
|
||||
));
|
||||
|
||||
// Filtering by 'owner-login' would match every webspace this customer owns, applying
|
||||
// $values to all of them; 'name' (like deleteSubscription() already uses) scopes the
|
||||
// update to this one subscription.
|
||||
return [
|
||||
'set' => [
|
||||
'filter' => [
|
||||
'name' => $account->getDomain(),
|
||||
],
|
||||
'values' => $values,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// 'plan-name' must come after 'permissions', per the add schema's element order -- Plesk's
|
||||
// XML-RPC API validates requests against an XSD sequence, and plesk/api-php-lib serializes
|
||||
// this array's key order verbatim into the XML it sends. It's add-only: 'set' has no
|
||||
// plan-related node, so changing an existing subscription's plan isn't supported here.
|
||||
$values['plan-name'] = $package->getName();
|
||||
|
||||
return [
|
||||
$action => $values,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use PleskX\Api\Client;
|
||||
use PleskX\Api\Operator\Session;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
function invokePleskCreateSubscriptionProps(FOSSBilling\Extension\Manager\Plesk\Plesk $manager, FOSSBilling\Extension\Contract\Server\Account $account, string $action): array
|
||||
{
|
||||
$reflection = new ReflectionClass($manager);
|
||||
$method = $reflection->getMethod('createSubscriptionProps');
|
||||
|
||||
return $method->invokeArgs($manager, [$account, $action]);
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->manager = new FOSSBilling\Extension\Manager\Plesk\Plesk([
|
||||
'host' => 'plesk.example.com',
|
||||
'username' => 'admin',
|
||||
'password' => 'secret',
|
||||
]);
|
||||
|
||||
$this->account = (new FOSSBilling\Extension\Contract\Server\Account())
|
||||
->setUsername('example')
|
||||
->setDomain('example.com')
|
||||
->setIp('192.0.2.10')
|
||||
->setPassword('secret')
|
||||
->setClient(new FOSSBilling\Extension\Contract\Server\Client())
|
||||
->setPackage((new FOSSBilling\Extension\Contract\Server\Package())->setName('Business Hosting'));
|
||||
});
|
||||
|
||||
test('createSubscriptionProps sends the settings directly under <add>, in schema order', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'add');
|
||||
|
||||
expect($props)->toHaveKey('add')
|
||||
->and(array_keys($props['add']))->toBe(['gen_setup', 'hosting', 'limits', 'permissions', 'plan-name'])
|
||||
->and($props['add'])->not->toHaveKey('filter')
|
||||
->and($props['add'])->not->toHaveKey('values');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps includes htype in gen_setup for the add action', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'add');
|
||||
|
||||
expect(array_keys($props['add']['gen_setup']))->toBe(['name', 'owner-login', 'htype', 'ip_address'])
|
||||
->and($props['add']['gen_setup']['htype'])->toBe('vrt_hst');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps wraps the set action in filter and values, as Plesk requires', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'set');
|
||||
|
||||
expect($props)->toHaveKey('set')
|
||||
->and(array_keys($props['set']))->toBe(['filter', 'values'])
|
||||
->and(array_keys($props['set']['values']))->toBe(['gen_setup', 'hosting', 'limits', 'permissions']);
|
||||
});
|
||||
|
||||
test('createSubscriptionProps filters the set action by domain name, not owner-login', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'set');
|
||||
|
||||
// 'owner-login' would match every webspace this customer owns, applying $values to all of
|
||||
// them; 'name' (the domain) scopes the update to this one subscription.
|
||||
expect($props['set']['filter'])->toBe(['name' => 'example.com']);
|
||||
});
|
||||
|
||||
test('createSubscriptionProps omits htype from gen_setup for the set action', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'set');
|
||||
|
||||
expect($props['set']['values']['gen_setup'])->not->toHaveKey('htype');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps only sends the FTP password when creating a subscription', function (): void {
|
||||
$addProps = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'add');
|
||||
$setProps = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'set');
|
||||
$addHostingProperties = array_column($addProps['add']['hosting']['vrt_hst']['property'], 'value', 'name');
|
||||
$setHostingProperties = array_column($setProps['set']['values']['hosting']['vrt_hst']['property'], 'value', 'name');
|
||||
|
||||
expect($addHostingProperties['ftp_password'])->toBe('secret')
|
||||
->and($setHostingProperties)->not->toHaveKey('ftp_password');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps uses custom package limits and permissions', function (): void {
|
||||
$this->account->getPackage()->setCustomValues([
|
||||
'aftp' => 'true',
|
||||
'cron' => '1',
|
||||
'nemailml' => '25',
|
||||
'spam' => 'yes',
|
||||
'ssh' => 'on',
|
||||
]);
|
||||
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'add');
|
||||
$limits = array_column($props['add']['limits']['limit'], 'value', 'name');
|
||||
$permissions = array_column($props['add']['permissions']['permission'], 'value', 'name');
|
||||
|
||||
expect($limits['max_maillists'])->toBe('25')
|
||||
->and($permissions['manage_crontab'])->toBe('true')
|
||||
->and($permissions['manage_anonftp'])->toBe('true')
|
||||
->and($permissions['manage_maillists'])->toBe('true')
|
||||
->and($permissions['manage_not_chroot_shell'])->toBe('true')
|
||||
->and($permissions['manage_spamfilter'])->toBe('true');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps disables mailing lists when the custom limit is zero', function (): void {
|
||||
$this->account->getPackage()->setCustomValue('nemailml', '0');
|
||||
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'add');
|
||||
$limits = array_column($props['add']['limits']['limit'], 'value', 'name');
|
||||
$permissions = array_column($props['add']['permissions']['permission'], 'value', 'name');
|
||||
|
||||
expect($limits['max_maillists'])->toBe(0)
|
||||
->and($permissions['manage_maillists'])->toBe('false');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps assigns the hosting plan name to the Plesk subscription on creation', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'add');
|
||||
|
||||
expect($props['add']['plan-name'])->toBe('Business Hosting');
|
||||
});
|
||||
|
||||
test('createSubscriptionProps does not send plan-name on updates, since webspace/set has no plan node', function (): void {
|
||||
$props = invokePleskCreateSubscriptionProps($this->manager, $this->account, 'set');
|
||||
|
||||
expect($props['set']['values'])->not->toHaveKey('plan-name');
|
||||
});
|
||||
|
||||
test('getLoginUrl passes the trusted client IP to Plesk session creation', function (): void {
|
||||
$originalServer = $_SERVER;
|
||||
$originalTrustedProxies = Request::getTrustedProxies();
|
||||
$originalTrustedHeaderSet = Request::getTrustedHeaderSet();
|
||||
|
||||
try {
|
||||
$_SERVER['REMOTE_ADDR'] = '10.0.0.2';
|
||||
$_SERVER['HTTP_X_FORWARDED_FOR'] = '8.8.8.8';
|
||||
Request::setTrustedProxies(['10.0.0.2'], Request::HEADER_X_FORWARDED_FOR);
|
||||
|
||||
$session = Mockery::mock(Session::class);
|
||||
$session->shouldReceive('create')
|
||||
->once()
|
||||
->with('example', '8.8.8.8')
|
||||
->andReturn('session-id');
|
||||
$client = Mockery::mock(Client::class);
|
||||
$client->shouldReceive('session')->once()->andReturn($session);
|
||||
|
||||
$clientProperty = new ReflectionProperty($this->manager, '_client');
|
||||
$clientProperty->setValue($this->manager, $client);
|
||||
|
||||
expect($this->manager->getLoginUrl($this->account))
|
||||
->toBe('http://plesk.example.com:8443/enterprise/rsession_init.php?PHPSESSID=session-id');
|
||||
} finally {
|
||||
$_SERVER = $originalServer;
|
||||
Request::setTrustedProxies($originalTrustedProxies, $originalTrustedHeaderSet);
|
||||
}
|
||||
});
|
||||
@@ -208,7 +208,7 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
*/
|
||||
public function synchronizeAccount(\FOSSBilling\Extension\Contract\Server\Account $account): \FOSSBilling\Extension\Contract\Server\Account
|
||||
{
|
||||
$this->getLog()->info(sprintf('Synchronizing account %s %s with server', $account->getDomain(), $account->getUsername()));
|
||||
$this->getLog()->info('Synchronizing account {domain} {username} with server', ['domain' => $account->getDomain(), 'username' => $account->getUsername()]);
|
||||
|
||||
$action = 'accountsummary';
|
||||
$varHash = [
|
||||
@@ -217,7 +217,7 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
|
||||
$result = $this->request($action, $varHash);
|
||||
if (!isset($result->acct[0])) {
|
||||
error_log('Could not synchronize account with cPanel server. Account does not exist.');
|
||||
$this->getLog()->error('Could not synchronize account with cPanel server. Account does not exist.');
|
||||
|
||||
return $account;
|
||||
}
|
||||
@@ -225,7 +225,7 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
$acc = $result->acct[0];
|
||||
|
||||
$new = clone $account;
|
||||
$new->setSuspended($acc->suspended);
|
||||
$new->setSuspended((bool) $acc->suspended);
|
||||
$new->setDomain($acc->domain);
|
||||
$new->setUsername($acc->user);
|
||||
$new->setIp($acc->ip);
|
||||
@@ -236,7 +236,8 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
/**
|
||||
* Creates a new account on the WHM server.
|
||||
* Sends a request to the WHM server to create a new account with the details provided in the \FOSSBilling\Extension\Contract\Server\Account object.
|
||||
* If the account is a reseller account, it also sets up the reseller and assigns the appropriate ACL list.
|
||||
* If the account is a reseller account, it also sets up the reseller and, if the hosting plan defines an
|
||||
* 'acl' custom value, assigns that ACL list to it.
|
||||
*
|
||||
* @param \FOSSBilling\Extension\Contract\Server\Account $account The account to be created. This object should contain all the necessary details for the new account.
|
||||
*
|
||||
@@ -276,7 +277,7 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
$json = $this->request($action, $varHash);
|
||||
$result = ($json->result[0]->status == 1);
|
||||
|
||||
// If the account is a reseller account and was successfully created, set up the reseller and assign the ACL list
|
||||
// If the account is a reseller account and was successfully created, set up the reseller and, if the hosting plan defines a custom 'acl' value, assign that ACL list to it
|
||||
if ($result && $account->getReseller()) {
|
||||
$params = [
|
||||
'user' => $account->getUsername(),
|
||||
@@ -284,11 +285,14 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
];
|
||||
$this->request('setupreseller', $params);
|
||||
|
||||
$params = [
|
||||
'reseller' => $account->getUsername(),
|
||||
'acllist' => $package->getAcllist(),
|
||||
];
|
||||
$this->request('setacls', $params);
|
||||
$acl = $package->getCustomValue('acl');
|
||||
if (!empty($acl)) {
|
||||
$params = [
|
||||
'reseller' => $account->getUsername(),
|
||||
'acllist' => $acl,
|
||||
];
|
||||
$this->request('setacls', $params);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the result of the account creation
|
||||
@@ -601,7 +605,7 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
: 'Basic ' . $username . ':' . $password;
|
||||
|
||||
// Log the request
|
||||
$this->getLog()->debug(sprintf('Requesting WHM server action "%s" with params "%s" ', $action, print_r($params, true)));
|
||||
$this->getLog()->debug('Requesting WHM server action "{action}" with params "{params}"', ['action' => $action, 'params' => print_r($params, true)]);
|
||||
|
||||
// Send the request and handle any errors
|
||||
try {
|
||||
@@ -631,28 +635,28 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
}
|
||||
|
||||
if (isset($json->cpanelresult->error)) {
|
||||
$this->getLog()->critical(sprintf('WHM server response error calling action %s: "%s"', $action, $json->cpanelresult->error));
|
||||
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->cpanelresult->error]);
|
||||
$placeholders = ['action' => $action, 'type' => 'cPanel'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
if (isset($json->data->result) && $json->data->result == '0') {
|
||||
$this->getLog()->critical(sprintf('WHM server response error calling action %s: "%s"', $action, $json->data->reason));
|
||||
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->data->reason]);
|
||||
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
if (isset($json->result) && is_array($json->result) && $json->result[0]->status == 0) {
|
||||
$this->getLog()->critical(sprintf('WHM server response error calling action %s: "%s"', $action, $json->result[0]->statusmsg));
|
||||
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->result[0]->statusmsg]);
|
||||
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
if (isset($json->status) && $json->status != '1') {
|
||||
$this->getLog()->critical(sprintf('WHM server response error calling action %s: "%s"', $action, $json->statusmsg));
|
||||
$this->getLog()->critical('WHM server response error calling action {action}: "{error}"', ['action' => $action, 'error' => $json->statusmsg]);
|
||||
$placeholders = [':action:' => $action, ':type:' => 'cPanel'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Server\Exception('Failed to :action: on the :type: server, check the error logs for further details', $placeholders);
|
||||
@@ -723,42 +727,4 @@ class Whm extends \FOSSBilling\Extension\Contract\Server\Manager
|
||||
{
|
||||
return $this->_config['username'] . '_' . $package->getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the package of an account on the WHM server.
|
||||
*
|
||||
* @param \FOSSBilling\Extension\Contract\Server\Account $a the account for which to modify the package
|
||||
* @param \FOSSBilling\Extension\Contract\Server\Package $p the new package
|
||||
*
|
||||
* @return true if the package was successfully modified
|
||||
*
|
||||
* @throws \FOSSBilling\Extension\Contract\Server\Exception if an error occurs during the request
|
||||
*/
|
||||
private function modifyAccountPackage(\FOSSBilling\Extension\Contract\Server\Account $a, \FOSSBilling\Extension\Contract\Server\Package $p): bool
|
||||
{
|
||||
// Log the modification
|
||||
$this->getLog()->info('Modifying account ' . $a->getUsername());
|
||||
|
||||
// Prepare the parameters for the API request
|
||||
$varHash = [
|
||||
'user' => $a->getUsername(),
|
||||
'domain' => $a->getDomain(),
|
||||
'HASCGI' => $p->getHasCgi(),
|
||||
'CPTHEME' => $p->getTheme(),
|
||||
'LANG' => $p->getLanguage(),
|
||||
'MAXPOP' => $p->getMaxPop(),
|
||||
'MAXFTP' => $p->getMaxFtp(),
|
||||
'MAXLST' => $p->getMaxEmailLists(),
|
||||
'MAXSUB' => $p->getMaxSubdomains(),
|
||||
'MAXPARK' => $p->getMaxParkedDomains(),
|
||||
'MAXADDON' => $p->getMaxAddons(),
|
||||
'MAXSQL' => $p->getMaxSql(),
|
||||
'shell' => $p->getHasShell(),
|
||||
];
|
||||
|
||||
// Send a request to the WHM server to modify the account's package
|
||||
$this->request('modifyacct', $varHash);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
function createWhmManager(HttpClientInterface $httpClient): FOSSBilling\Extension\Manager\Whm\Whm
|
||||
{
|
||||
return new class(['host' => 'whm.example.com', 'username' => 'admin', 'password' => 'secret'], $httpClient) extends FOSSBilling\Extension\Manager\Whm\Whm {
|
||||
public function __construct(array $options, private readonly HttpClientInterface $httpClient)
|
||||
{
|
||||
parent::__construct($options);
|
||||
}
|
||||
|
||||
public function getHttpClient(): HttpClientInterface
|
||||
{
|
||||
return $this->httpClient;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createWhmAccount(FOSSBilling\Extension\Contract\Server\Package $package, bool $reseller): FOSSBilling\Extension\Contract\Server\Account
|
||||
{
|
||||
return (new FOSSBilling\Extension\Contract\Server\Account())
|
||||
->setUsername('example')
|
||||
->setDomain('example.com')
|
||||
->setPassword('secret')
|
||||
->setClient((new FOSSBilling\Extension\Contract\Server\Client())->setEmail('client@example.com'))
|
||||
->setPackage($package)
|
||||
->setReseller($reseller);
|
||||
}
|
||||
|
||||
function actionOf(array $request): string
|
||||
{
|
||||
return basename((string) parse_url($request['url'], PHP_URL_PATH));
|
||||
}
|
||||
|
||||
function createWhmAccountCreationClient(array &$requests): MockHttpClient
|
||||
{
|
||||
return new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
|
||||
$requests[] = ['method' => $method, 'url' => $url, 'options' => $options];
|
||||
|
||||
if (str_contains($url, 'listpkgs')) {
|
||||
return new MockResponse(json_encode(['package' => []]));
|
||||
}
|
||||
|
||||
if (str_contains($url, 'createacct')) {
|
||||
return new MockResponse(json_encode(['result' => [['status' => 1]]]));
|
||||
}
|
||||
|
||||
return new MockResponse(json_encode(['status' => 1]));
|
||||
});
|
||||
}
|
||||
|
||||
test('createAccount sets up the reseller but does not touch the ACL list when the plan has no acl custom value', function (): void {
|
||||
$requests = [];
|
||||
$manager = createWhmManager(createWhmAccountCreationClient($requests));
|
||||
$account = createWhmAccount(new FOSSBilling\Extension\Contract\Server\Package(), true);
|
||||
|
||||
expect($manager->createAccount($account))->toBeTrue();
|
||||
|
||||
$actions = array_map(actionOf(...), $requests);
|
||||
|
||||
// Calling "setacls" with an empty acllist would strip the initial privileges "setupreseller"
|
||||
// just granted, so it must be skipped when the plan has no 'acl' custom value.
|
||||
expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct', 'setupreseller'])
|
||||
->and($actions)->not->toContain('setacls');
|
||||
});
|
||||
|
||||
test('createAccount assigns the plan\'s acl custom value to the reseller via setacls', function (): void {
|
||||
$requests = [];
|
||||
$manager = createWhmManager(createWhmAccountCreationClient($requests));
|
||||
$package = (new FOSSBilling\Extension\Contract\Server\Package())->setCustomValues(['acl' => 'reseller_basic']);
|
||||
$account = createWhmAccount($package, true);
|
||||
|
||||
expect($manager->createAccount($account))->toBeTrue();
|
||||
|
||||
$actions = array_map(actionOf(...), $requests);
|
||||
expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct', 'setupreseller', 'setacls']);
|
||||
|
||||
$setaclsRequest = $requests[array_search('setacls', $actions, true)];
|
||||
parse_str((string) $setaclsRequest['options']['body'], $fields);
|
||||
|
||||
expect($fields)->toMatchArray([
|
||||
'reseller' => 'example',
|
||||
'acllist' => 'reseller_basic',
|
||||
]);
|
||||
});
|
||||
|
||||
test('synchronizeAccount casts the suspended flag to bool', function (): void {
|
||||
$client = new MockHttpClient(fn (): MockResponse => new MockResponse(json_encode([
|
||||
'acct' => [
|
||||
['suspended' => 1, 'domain' => 'example.com', 'user' => 'example', 'ip' => '1.2.3.4'],
|
||||
],
|
||||
])));
|
||||
$manager = createWhmManager($client);
|
||||
$account = createWhmAccount(new FOSSBilling\Extension\Contract\Server\Package(), false);
|
||||
|
||||
$updated = $manager->synchronizeAccount($account);
|
||||
|
||||
expect($updated->getSuspended())->toBeTrue();
|
||||
});
|
||||
|
||||
test('createAccount never calls setupreseller or setacls for a non-reseller account', function (): void {
|
||||
$requests = [];
|
||||
$manager = createWhmManager(createWhmAccountCreationClient($requests));
|
||||
$package = (new FOSSBilling\Extension\Contract\Server\Package())->setCustomValues(['acl' => 'reseller_basic']);
|
||||
$account = createWhmAccount($package, false);
|
||||
|
||||
expect($manager->createAccount($account))->toBeTrue();
|
||||
|
||||
$actions = array_map(actionOf(...), $requests);
|
||||
expect($actions)->toBe(['listpkgs', 'addpkg', 'createacct']);
|
||||
});
|
||||
@@ -11,19 +11,15 @@ declare(strict_types=1);
|
||||
|
||||
namespace FOSSBilling\Extension\Registrar\Custom;
|
||||
|
||||
use Iodev\Whois\Factory;
|
||||
|
||||
class Custom extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
{
|
||||
public $config = [
|
||||
'use_whois' => false,
|
||||
'use_rdap' => false,
|
||||
];
|
||||
|
||||
public function __construct($options)
|
||||
{
|
||||
if (isset($options['use_whois'])) {
|
||||
$this->config['use_whois'] = (bool) $options['use_whois'];
|
||||
}
|
||||
$this->config['use_rdap'] = (bool) ($options['use_rdap'] ?? $options['use_whois'] ?? false);
|
||||
}
|
||||
|
||||
public static function getConfig(): array
|
||||
@@ -31,9 +27,9 @@ class Custom extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
return [
|
||||
'label' => 'Custom Registrar always responds with positive results. Useful if no other registrar is suitable.',
|
||||
'form' => [
|
||||
'use_whois' => ['radio', [
|
||||
'use_rdap' => ['radio', [
|
||||
'multiOptions' => ['1' => 'Yes', '0' => 'No'],
|
||||
'label' => 'Use WHOIS to Check for Domain Availability',
|
||||
'label' => 'Use RDAP Registry Lookups to Check for Domain Availability',
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -44,6 +40,14 @@ class Custom extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
{
|
||||
$this->getLog()->debug('Checking if domain can be transferred: ' . $domain->getName());
|
||||
|
||||
if (!$this->config['use_rdap']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->getRdap()->isDomainAvailable($domain->getName()) ?? false) {
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Domain :domain is not registered, so it cannot be transferred. You may be able to register it instead.', [':domain' => $domain->getName()]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -51,13 +55,11 @@ class Custom extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
{
|
||||
$this->getLog()->debug('Checking domain availability: ' . $domain->getName());
|
||||
|
||||
if ($this->config['use_whois']) {
|
||||
$whois = Factory::get()->createWhois();
|
||||
|
||||
return $whois->isDomainAvailable($domain->getName());
|
||||
if (!$this->config['use_rdap']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $this->getRdap()->isDomainAvailable($domain->getName()) ?? true;
|
||||
}
|
||||
|
||||
public function modifyNs(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): bool
|
||||
@@ -81,7 +83,7 @@ class Custom extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
|
||||
public function getDomainDetails(\FOSSBilling\Extension\Contract\Registrar\Domain $domain)
|
||||
{
|
||||
$this->getLog()->debug('Getting whois: ' . $domain->getName());
|
||||
$this->getLog()->debug('Getting domain details: ' . $domain->getName());
|
||||
|
||||
if (!$domain->getRegistrationTime()) {
|
||||
$domain->setRegistrationTime(time());
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"require": {
|
||||
"io-developer/php-whois": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
function createCustomAdapter(array $options, ?callable $respond = null): FOSSBilling\Extension\Registrar\Custom\Custom
|
||||
{
|
||||
$httpClient = new MockHttpClient(fn (string $method, string $url): MockResponse => $respond !== null
|
||||
? $respond($method, $url)
|
||||
: new MockResponse('{}'));
|
||||
|
||||
return new class($options, $httpClient) extends FOSSBilling\Extension\Registrar\Custom\Custom {
|
||||
public function __construct(array $options, private readonly MockHttpClient $httpClient)
|
||||
{
|
||||
parent::__construct($options);
|
||||
}
|
||||
|
||||
public function getHttpClient(): Symfony\Contracts\HttpClient\HttpClientInterface
|
||||
{
|
||||
return $this->httpClient;
|
||||
}
|
||||
|
||||
public function requestCount(): int
|
||||
{
|
||||
return $this->httpClient->getRequestsCount();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('the Custom adapter answers positively without registry lookups unless RDAP is enabled', function (): void {
|
||||
$adapter = createCustomAdapter([]);
|
||||
|
||||
expect($adapter->isDomainAvailable((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue()
|
||||
->and($adapter->requestCount())->toBe(0);
|
||||
});
|
||||
|
||||
test('a legacy use_whois opt-in enables RDAP lookups on the Custom adapter', function (): void {
|
||||
$adapter = createCustomAdapter(['use_whois' => '1'], fn (): MockResponse => new MockResponse('', ['http_code' => 404]));
|
||||
|
||||
expect($adapter->isDomainAvailable((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue();
|
||||
});
|
||||
|
||||
test('an explicit use_rdap setting takes precedence over the legacy use_whois opt-in', function (): void {
|
||||
$adapter = createCustomAdapter(['use_whois' => '1', 'use_rdap' => '0']);
|
||||
|
||||
expect($adapter->isDomainAvailable((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue()
|
||||
->and($adapter->requestCount())->toBe(0);
|
||||
});
|
||||
|
||||
test('the Custom adapter reports a registered domain as unavailable', function (): void {
|
||||
$bootstrap = json_encode(['version' => '1.0', 'services' => [[['com'], ['https://rdap.example.com/com/v1/']]]], JSON_THROW_ON_ERROR);
|
||||
$adapter = createCustomAdapter(['use_rdap' => '1'], fn (string $method, string $url): MockResponse => str_contains($url, '/domain/')
|
||||
? new MockResponse('{"objectClassName":"domain"}', ['http_code' => 200])
|
||||
: new MockResponse($bootstrap));
|
||||
|
||||
expect($adapter->isDomainAvailable((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the Custom adapter falls back to a positive answer when RDAP cannot determine availability', function (): void {
|
||||
$logger = new class extends Psr\Log\AbstractLogger {
|
||||
/** @var list<string> */
|
||||
public array $messages = [];
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->messages[] = (string) $message;
|
||||
}
|
||||
};
|
||||
$bootstrap = json_encode(['version' => '1.0', 'services' => [[['com'], ['https://rdap.example.com/com/v1/']]]], JSON_THROW_ON_ERROR);
|
||||
$adapter = createCustomAdapter(['use_rdap' => '1'], fn (string $method, string $url): MockResponse => str_contains($url, '/domain/')
|
||||
? new MockResponse('', ['http_code' => 500])
|
||||
: new MockResponse($bootstrap));
|
||||
$adapter->setLog($logger);
|
||||
|
||||
expect($adapter->isDomainAvailable((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue()
|
||||
->and(array_filter($logger->messages, fn (string $message): bool => str_contains($message, 'RDAP request against')))->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('the Custom adapter allows transfers without registry lookups unless RDAP is enabled', function (): void {
|
||||
$adapter = createCustomAdapter([]);
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue()
|
||||
->and($adapter->requestCount())->toBe(0);
|
||||
});
|
||||
|
||||
test('the Custom adapter allows transferring a registered domain', function (): void {
|
||||
$bootstrap = json_encode(['version' => '1.0', 'services' => [[['com'], ['https://rdap.example.com/com/v1/']]]], JSON_THROW_ON_ERROR);
|
||||
$adapter = createCustomAdapter(['use_rdap' => '1'], fn (string $method, string $url): MockResponse => str_contains($url, '/domain/')
|
||||
? new MockResponse('{"objectClassName":"domain"}', ['http_code' => 200])
|
||||
: new MockResponse($bootstrap));
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the Custom adapter refuses transferring an unregistered domain', function (): void {
|
||||
$bootstrap = json_encode(['version' => '1.0', 'services' => [[['com'], ['https://rdap.example.com/com/v1/']]]], JSON_THROW_ON_ERROR);
|
||||
$adapter = createCustomAdapter(['use_rdap' => '1'], fn (string $method, string $url): MockResponse => str_contains($url, '/domain/')
|
||||
? new MockResponse('', ['http_code' => 404])
|
||||
: new MockResponse($bootstrap));
|
||||
|
||||
$adapter->isDomaincanBeTransferred((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com'));
|
||||
})->throws(FOSSBilling\Extension\Contract\Registrar\Exception::class, 'not registered');
|
||||
|
||||
test('the Custom adapter falls back to allowing a transfer when RDAP cannot determine availability', function (): void {
|
||||
$bootstrap = json_encode(['version' => '1.0', 'services' => [[['com'], ['https://rdap.example.com/com/v1/']]]], JSON_THROW_ON_ERROR);
|
||||
$adapter = createCustomAdapter(['use_rdap' => '1'], fn (string $method, string $url): MockResponse => str_contains($url, '/domain/')
|
||||
? new MockResponse('', ['http_code' => 500])
|
||||
: new MockResponse($bootstrap));
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred((new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com')))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the Custom adapter configuration form offers an RDAP toggle', function (): void {
|
||||
$form = FOSSBilling\Extension\Registrar\Custom\Custom::getConfig();
|
||||
|
||||
expect(array_keys($form['form']))->toBe(['use_rdap'])
|
||||
->and($form['form']['use_rdap'][1]['label'])->toContain('RDAP');
|
||||
});
|
||||
@@ -11,8 +11,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace FOSSBilling\Extension\Registrar\Email;
|
||||
|
||||
use Iodev\Whois\Factory;
|
||||
|
||||
class Email extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
{
|
||||
protected $config;
|
||||
@@ -26,12 +24,7 @@ class Email extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('The ":domain_registrar" domain registrar is not fully configured. Please configure the :missing', [':domain_registrar' => 'Email', ':missing' => 'email'], 3001);
|
||||
}
|
||||
|
||||
if (isset($options['use_whois'])) {
|
||||
$this->config['use_whois'] = (bool) $options['use_whois'];
|
||||
} else {
|
||||
$this->config['use_whois'] = false;
|
||||
}
|
||||
|
||||
$this->config['use_rdap'] = (bool) ($options['use_rdap'] ?? $options['use_whois'] ?? false);
|
||||
$this->config['from'] = $this->config['email'];
|
||||
}
|
||||
|
||||
@@ -45,9 +38,9 @@ class Email extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
'description' => 'Email to Send Domain Change Notifications',
|
||||
],
|
||||
],
|
||||
'use_whois' => ['radio', [
|
||||
'use_rdap' => ['radio', [
|
||||
'multiOptions' => ['1' => 'Yes', '0' => 'No'],
|
||||
'label' => 'Use WHOIS to Check for Domain Availability',
|
||||
'label' => 'Use RDAP Registry Lookups to Check for Domain Availability',
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -58,18 +51,31 @@ class Email extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
{
|
||||
$this->getLog()->debug('Checking domain availability: ' . $domain->getName());
|
||||
|
||||
if ($this->config['use_whois']) {
|
||||
$whois = Factory::get()->createWhois();
|
||||
|
||||
return $whois->isDomainAvailable($domain->getName());
|
||||
if (!$this->config['use_rdap']) {
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception(':type: registrar is unable to :action:', [':type:' => 'Email', ':action:' => 'determine domain availability'], 3001);
|
||||
}
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception(':type: registrar is unable to :action:', [':type:' => 'Email', ':action:' => 'determine domain availability']);
|
||||
$result = $this->getRdap()->isDomainAvailable($domain->getName());
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Unable to determine the availability of :domain via RDAP', [':domain' => $domain->getName()]);
|
||||
}
|
||||
|
||||
public function isDomaincanBeTransferred(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): never
|
||||
public function isDomaincanBeTransferred(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): bool
|
||||
{
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception(':type: registrar is unable to :action:', [':type:' => 'Email', ':action:' => 'determine domain transferability']);
|
||||
$this->getLog()->debug('Checking if domain can be transferred: ' . $domain->getName());
|
||||
|
||||
if (!$this->config['use_rdap']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->getRdap()->isDomainAvailable($domain->getName()) ?? false) {
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Domain :domain is not registered, so it cannot be transferred. You may be able to register it instead.', [':domain' => $domain->getName()]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function modifyNs(\FOSSBilling\Extension\Contract\Registrar\Domain $domain)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"require": {
|
||||
"io-developer/php-whois": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
function createEmailAdapter(array $options, ?callable $respond = null): FOSSBilling\Extension\Registrar\Email\Email
|
||||
{
|
||||
$httpClient = new MockHttpClient(fn (string $method, string $url): MockResponse => $respond !== null
|
||||
? $respond($method, $url)
|
||||
: new MockResponse('{}'));
|
||||
|
||||
return new class(['email' => 'registrar@example.com', ...$options], $httpClient) extends FOSSBilling\Extension\Registrar\Email\Email {
|
||||
public function __construct(array $options, private readonly MockHttpClient $httpClient)
|
||||
{
|
||||
parent::__construct($options);
|
||||
}
|
||||
|
||||
public function getHttpClient(): Symfony\Contracts\HttpClient\HttpClientInterface
|
||||
{
|
||||
return $this->httpClient;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createEmailDomain(): FOSSBilling\Extension\Contract\Registrar\Domain
|
||||
{
|
||||
return (new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld('example')->setTld('.com');
|
||||
}
|
||||
|
||||
test('the Email adapter refuses availability checks when RDAP lookups are disabled', function (): void {
|
||||
$adapter = createEmailAdapter([]);
|
||||
|
||||
$adapter->isDomainAvailable(createEmailDomain());
|
||||
})->throws(FOSSBilling\Extension\Contract\Registrar\Exception::class);
|
||||
|
||||
function createEmailRdapResponder(callable $domainResponse): callable
|
||||
{
|
||||
$bootstrap = json_encode(['version' => '1.0', 'services' => [[['com'], ['https://rdap.example.com/com/v1/']]]], JSON_THROW_ON_ERROR);
|
||||
|
||||
return fn (string $method, string $url): MockResponse => str_contains($url, '/domain/')
|
||||
? $domainResponse()
|
||||
: new MockResponse($bootstrap);
|
||||
}
|
||||
|
||||
test('the Email adapter reports an unregistered domain as available via RDAP', function (): void {
|
||||
$adapter = createEmailAdapter(['use_rdap' => '1'], createEmailRdapResponder(fn (): MockResponse => new MockResponse('', ['http_code' => 404])));
|
||||
|
||||
expect($adapter->isDomainAvailable(createEmailDomain()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the Email adapter reports a registered domain as unavailable via RDAP', function (): void {
|
||||
$adapter = createEmailAdapter(['use_whois' => '1'], createEmailRdapResponder(fn (): MockResponse => new MockResponse('{"objectClassName":"domain"}', ['http_code' => 200])));
|
||||
|
||||
expect($adapter->isDomainAvailable(createEmailDomain()))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the Email adapter throws when RDAP cannot determine availability', function (): void {
|
||||
$adapter = createEmailAdapter(['use_rdap' => '1'], createEmailRdapResponder(fn (): MockResponse => new MockResponse('', ['http_code' => 429])));
|
||||
|
||||
$adapter->isDomainAvailable(createEmailDomain());
|
||||
})->throws(FOSSBilling\Extension\Contract\Registrar\Exception::class);
|
||||
|
||||
test('the Email adapter allows transfers when RDAP lookups are disabled', function (): void {
|
||||
$adapter = createEmailAdapter([]);
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred(createEmailDomain()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the Email adapter allows transferring a registered domain via RDAP', function (): void {
|
||||
$adapter = createEmailAdapter(['use_rdap' => '1'], createEmailRdapResponder(fn (): MockResponse => new MockResponse('{"objectClassName":"domain"}', ['http_code' => 200])));
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred(createEmailDomain()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the Email adapter refuses transferring an unregistered domain via RDAP', function (): void {
|
||||
$adapter = createEmailAdapter(['use_rdap' => '1'], createEmailRdapResponder(fn (): MockResponse => new MockResponse('', ['http_code' => 404])));
|
||||
|
||||
$adapter->isDomaincanBeTransferred(createEmailDomain());
|
||||
})->throws(FOSSBilling\Extension\Contract\Registrar\Exception::class, 'not registered');
|
||||
|
||||
test('the Email adapter falls back to allowing a transfer when RDAP cannot determine availability', function (): void {
|
||||
$adapter = createEmailAdapter(['use_rdap' => '1'], createEmailRdapResponder(fn (): MockResponse => new MockResponse('', ['http_code' => 429])));
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred(createEmailDomain()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the Email adapter configuration form offers an RDAP toggle', function (): void {
|
||||
$form = FOSSBilling\Extension\Registrar\Email\Email::getConfig();
|
||||
|
||||
expect(array_keys($form['form']))->toBe(['email', 'use_rdap'])
|
||||
->and($form['form']['use_rdap'][1]['label'])->toContain('RDAP');
|
||||
});
|
||||
@@ -35,15 +35,16 @@ class Internetbs extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstra
|
||||
return [
|
||||
'label' => 'Manages domains on Internetbs via API',
|
||||
'form' => [
|
||||
'apikey' => ['text', [
|
||||
'apikey' => ['password', [
|
||||
'label' => 'Internetbs API Key',
|
||||
'description' => 'Internetbs API Key',
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
'password' => ['password', [
|
||||
'label' => 'Internetbs API Password',
|
||||
'description' => 'Internetbs API Password',
|
||||
'renderPassword' => true,
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -372,7 +373,7 @@ class Internetbs extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstra
|
||||
}
|
||||
|
||||
if ($this->isTestEnv()) {
|
||||
error_log(print_r($result, true));
|
||||
$this->getLog()->debug('Internet.bs response received.', ['status' => $result['status'] ?? null]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace FOSSBilling\Extension\Registrar\Namecheap;
|
||||
|
||||
use GeoIp2\Model\Domain;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
|
||||
class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
@@ -70,6 +69,7 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
'label' => 'API Key',
|
||||
'description' => 'You can get this at Namecheap control panel.',
|
||||
'required' => true,
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
'username' => [
|
||||
@@ -103,11 +103,8 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Premium domains cannot be registered.');
|
||||
}
|
||||
|
||||
if (isset($result->CommandResponse->DomainCheckResult['Available']) && $result->CommandResponse->DomainCheckResult['Available'] == 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return isset($result->CommandResponse->DomainCheckResult['Available'])
|
||||
&& $result->CommandResponse->DomainCheckResult['Available'] == 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,14 +151,14 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
$result = simplexml_load_string($data);
|
||||
|
||||
if (isset($result['status']) && strtolower((string) $result['status']) == 'error') {
|
||||
error_log('Namecheap error: ' . PHP_EOL . $result['error']);
|
||||
$this->getLog()->error('Namecheap error: ' . PHP_EOL . $result['error']);
|
||||
$placeholders = [':action:' => $params['Command'], ':type:' => 'Namecheap'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
if (isset($result['status']) && strtolower((string) $result['status']) == 'failed') {
|
||||
error_log('Namecheap error: ' . PHP_EOL . $result['actionstatusdesc']);
|
||||
$this->getLog()->error('Namecheap error: ' . PHP_EOL . $result['actionstatusdesc']);
|
||||
$placeholders = [':action:' => $params['Command'], ':type:' => 'Namecheap'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
@@ -248,15 +245,15 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
foreach (['Registrant', 'Admin', 'Tech', 'AuxBilling'] as $contactType) {
|
||||
$c = $domain->getContactRegistrar();
|
||||
|
||||
if ($contactType == 'Admin' && $domain->getContactAdmin()) {
|
||||
if ($contactType == 'Admin' && $domain->getContactAdmin() instanceof \FOSSBilling\Extension\Contract\Registrar\Domain\Contact) {
|
||||
$c = $domain->getContactAdmin();
|
||||
}
|
||||
|
||||
if ($contactType == 'Tech' && $domain->getContactTech()) {
|
||||
if ($contactType == 'Tech' && $domain->getContactTech() instanceof \FOSSBilling\Extension\Contract\Registrar\Domain\Contact) {
|
||||
$c = $domain->getContactTech();
|
||||
}
|
||||
|
||||
if ($contactType == 'AuxBilling' && $domain->getContactBilling()) {
|
||||
if ($contactType == 'AuxBilling' && $domain->getContactBilling() instanceof \FOSSBilling\Extension\Contract\Registrar\Domain\Contact) {
|
||||
$c = $domain->getContactBilling();
|
||||
}
|
||||
|
||||
@@ -291,11 +288,9 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
];
|
||||
|
||||
$result = $this->_makeRequest($params);
|
||||
if (isset($result->CommandResponse->DomainTransferCreateResult['Transfer']) && $result->CommandResponse->DomainTransferCreateResult['Transfer'] == 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return isset($result->CommandResponse->DomainTransferCreateResult['Transfer'])
|
||||
&& $result->CommandResponse->DomainTransferCreateResult['Transfer'] == 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,8 +308,12 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
|
||||
$result = $this->_makeRequest($params);
|
||||
|
||||
$domain->setRegistrationTime((string) $result->CommandResponse->DomainGetInfoResult->DomainDetails->CreatedDate);
|
||||
$domain->setExpirationTime((string) $result->CommandResponse->DomainGetInfoResult->DomainDetails->ExpiredDate);
|
||||
// Namecheap returns these as "MM/DD/YYYY" strings, not Unix timestamps.
|
||||
$registrationTime = strtotime((string) $result->CommandResponse->DomainGetInfoResult->DomainDetails->CreatedDate);
|
||||
$expirationTime = strtotime((string) $result->CommandResponse->DomainGetInfoResult->DomainDetails->ExpiredDate);
|
||||
|
||||
$domain->setRegistrationTime($registrationTime === false ? null : $registrationTime);
|
||||
$domain->setExpirationTime($expirationTime === false ? null : $expirationTime);
|
||||
$domain->setPrivacyEnabled((string) $result->CommandResponse->DomainGetInfoResult->Whoisguard['Enabled']);
|
||||
|
||||
$params = [
|
||||
@@ -337,6 +336,8 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
// set SLD and TLD
|
||||
$newDomain->setSld($domain->getSld());
|
||||
$newDomain->setTld($domain->getTld());
|
||||
$newDomain->setRegistrationTime($domain->getRegistrationTime());
|
||||
$newDomain->setExpirationTime($domain->getExpirationTime());
|
||||
|
||||
$registrarContact = new \FOSSBilling\Extension\Contract\Registrar\Domain\Contact();
|
||||
$adminContact = new \FOSSBilling\Extension\Contract\Registrar\Domain\Contact();
|
||||
@@ -388,8 +389,7 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
$result = $this->_makeRequest($params);
|
||||
|
||||
$NsArr = [];
|
||||
$xmlNsList = $result->CommandResponse->DomainDNSGetListResult;
|
||||
foreach ($xmlNsList->Nameserver as $Nameserver) {
|
||||
foreach ($result->CommandResponse->DomainDNSGetListResult->Nameserver ?? [] as $Nameserver) {
|
||||
$NsArr[] = $Nameserver;
|
||||
}
|
||||
|
||||
@@ -441,15 +441,15 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
foreach (['Registrant', 'Admin', 'Tech', 'AuxBilling'] as $contactType) {
|
||||
$c = $domain->getContactRegistrar();
|
||||
|
||||
if ($contactType == 'Admin' && $domain->getContactAdmin()) {
|
||||
if ($contactType == 'Admin' && $domain->getContactAdmin() instanceof \FOSSBilling\Extension\Contract\Registrar\Domain\Contact) {
|
||||
$c = $domain->getContactAdmin();
|
||||
}
|
||||
|
||||
if ($contactType == 'Tech' && $domain->getContactTech()) {
|
||||
if ($contactType == 'Tech' && $domain->getContactTech() instanceof \FOSSBilling\Extension\Contract\Registrar\Domain\Contact) {
|
||||
$c = $domain->getContactTech();
|
||||
}
|
||||
|
||||
if ($contactType == 'AuxBilling' && $domain->getContactBilling()) {
|
||||
if ($contactType == 'AuxBilling' && $domain->getContactBilling() instanceof \FOSSBilling\Extension\Contract\Registrar\Domain\Contact) {
|
||||
$c = $domain->getContactBilling();
|
||||
}
|
||||
|
||||
@@ -520,7 +520,7 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Implement this correctly.
|
||||
* Namecheap does not expose a domain deletion command through its API.
|
||||
*
|
||||
* @throws \FOSSBilling\Extension\Contract\Registrar\Exception
|
||||
*/
|
||||
@@ -530,11 +530,11 @@ class Namecheap extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstrac
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
* @return array{enabled: bool, id: string}
|
||||
*
|
||||
* @throws \FOSSBilling\Extension\Contract\Registrar\Exception
|
||||
*/
|
||||
private function getPrivacyInfo(\FOSSBilling\Extension\Contract\Registrar\Domain $domain)
|
||||
private function getPrivacyInfo(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): array
|
||||
{
|
||||
$params = [
|
||||
'DomainName' => $domain->getName(),
|
||||
|
||||
@@ -38,6 +38,7 @@ class Netearthone extends \FOSSBilling\Extension\Registrar\Resellerclub\Reseller
|
||||
'label' => 'NetEarthOne API Key',
|
||||
'description' => 'You can get this at NetEarthOne control panel, go to Settings -> API',
|
||||
'required' => false,
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -38,6 +38,7 @@ class Resellbiz extends \FOSSBilling\Extension\Registrar\Resellerclub\Resellercl
|
||||
'label' => 'Resell.biz API Key',
|
||||
'description' => 'You can get this at Resell.biz control panel, go to Settings -> API',
|
||||
'required' => false,
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace FOSSBilling\Extension\Registrar\Resellerclub;
|
||||
* HTTP API documentation http://cp.onlyfordemo.net/kb/answer/744
|
||||
*/
|
||||
|
||||
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
|
||||
|
||||
class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbstract
|
||||
@@ -70,6 +71,7 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
'label' => 'ResellerClub API Key',
|
||||
'description' => 'You can get this at ResellerClub control panel, go to Settings -> API',
|
||||
'required' => false,
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
@@ -78,23 +80,26 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
|
||||
public function isDomainAvailable(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): bool
|
||||
{
|
||||
$sld = strtolower((string) $domain->getSld());
|
||||
$tld = strtolower((string) $domain->getTld());
|
||||
$domainName = $sld . $tld;
|
||||
|
||||
$params = [
|
||||
'domain-name' => $domain->getSld(),
|
||||
'tlds' => [$domain->getTld(false)],
|
||||
'domain-name' => $sld,
|
||||
'tlds' => [ltrim($tld, '.')],
|
||||
'suggest-alternative' => false,
|
||||
];
|
||||
|
||||
$result = $this->_makeRequest('domains/available', $params);
|
||||
if (!isset($result[$domain->getName()])) {
|
||||
return true;
|
||||
// the response is keyed by domain name, but the casing of that key isn't guaranteed to match what we sent
|
||||
$result = array_change_key_case((array) $result, CASE_LOWER);
|
||||
if (!isset($result[$domainName]['status'])) {
|
||||
$placeholders = [':action:' => 'domains/available', ':type:' => 'ResellerClub'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
$check = $result[$domain->getName()];
|
||||
if ($check && $check['status'] == 'available') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return strtolower((string) $result[$domainName]['status']) === 'available';
|
||||
}
|
||||
|
||||
public function isDomaincanBeTransferred(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): bool
|
||||
@@ -131,33 +136,28 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
|
||||
public function modifyContact(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): bool
|
||||
{
|
||||
// ResellerClub deprecated contacts/modify in December 2016 due to ICANN's IRTP-C policy:
|
||||
// https://manage.resellerclub.com/kb/answer/791. Existing contacts can no longer be edited
|
||||
// in place; instead new contacts have to be created and then associated with the domain
|
||||
// order via domains/modify-contact. See https://github.com/FOSSBilling/FOSSBilling/issues/2365.
|
||||
// Reuse _getAllContacts() (as registerDomain() already does) rather than assigning a single
|
||||
// general contact to every role, since several TLDs (e.g. .uk, .de, .ru) require their own
|
||||
// contact type and don't allow a general contact for all four roles. Pass replaceExisting:
|
||||
// false so it only adds the new contact(s) - contacts/search isn't scoped to this domain's
|
||||
// order, so the default replace-existing behaviour could delete an unrelated active contact
|
||||
// belonging to the same customer.
|
||||
$customer = $this->_getCustomerDetails($domain);
|
||||
$cdetails = $this->_getDefaultContactDetails($domain, $customer['customerid']);
|
||||
$contact_id = $cdetails['Contact']['registrant'];
|
||||
[$reg_contact_id, $admin_contact_id, $tech_contact_id, $billing_contact_id] = $this->_getAllContacts($domain->getTld(), $customer['customerid'], $domain->getContactRegistrar(), replaceExisting: false);
|
||||
|
||||
$c = $domain->getContactRegistrar();
|
||||
|
||||
$required_params = [
|
||||
'contact-id' => $contact_id,
|
||||
'name' => $c->getName(),
|
||||
'company' => $c->getCompany(),
|
||||
'email' => $c->getEmail(),
|
||||
'address-line-1' => $c->getAddress1(),
|
||||
'city' => $c->getCity(),
|
||||
'zipcode' => $c->getZip(),
|
||||
'phone-cc' => $c->getTelCc(),
|
||||
'phone' => $c->getTel(),
|
||||
'country' => $c->getCountry(),
|
||||
$params = [
|
||||
'order-id' => $this->_getDomainOrderId($domain),
|
||||
'reg-contact-id' => $reg_contact_id,
|
||||
'admin-contact-id' => $admin_contact_id,
|
||||
'tech-contact-id' => $tech_contact_id,
|
||||
'billing-contact-id' => $billing_contact_id,
|
||||
];
|
||||
|
||||
$optional_params = [
|
||||
'address-line-2' => $c->getAddress2(),
|
||||
'address-line-3' => $c->getAddress3(),
|
||||
'state' => $c->getState(),
|
||||
];
|
||||
|
||||
$params = [...$optional_params, ...$required_params];
|
||||
$result = $this->_makeRequest('contacts/modify', $params, 'POST');
|
||||
$result = $this->_makeRequest('domains/modify-contact', $params, 'POST');
|
||||
|
||||
return $result['status'] == 'Success';
|
||||
}
|
||||
@@ -219,10 +219,10 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
];
|
||||
$data = $this->_makeRequest('domains/details', $params);
|
||||
|
||||
$d->setRegistrationTime($data['creationtime']);
|
||||
$d->setExpirationTime($data['endtime']);
|
||||
$d->setEpp($data['domsecret']);
|
||||
$d->setPrivacyEnabled($data['isprivacyprotected'] == 'true');
|
||||
$d->setRegistrationTime($data['creationtime'] ?? null);
|
||||
$d->setExpirationTime($data['endtime'] ?? null);
|
||||
$d->setEpp($data['domsecret'] ?? null);
|
||||
$d->setPrivacyEnabled(($data['isprivacyprotected'] ?? 'false') == 'true');
|
||||
|
||||
/* Contact details */
|
||||
$wc = $data['admincontact'];
|
||||
@@ -319,6 +319,14 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
$params['ns'] = ['dns1.directi.com', 'dns2.directi.com', 'dns3.directi.com', 'dns4.directi.com'];
|
||||
}
|
||||
|
||||
// ResellerClub is contractually required to display a notice - and get consent via this tnc
|
||||
// attribute - before sharing a European Registrant/Admin Organization contact's personal
|
||||
// information with the .fr registry. See https://manage.resellerclub.com/kb/answer/752
|
||||
if ($tld == '.fr') {
|
||||
$params['attr-name1'] = 'tnc';
|
||||
$params['attr-value1'] = 'Y';
|
||||
}
|
||||
|
||||
if ($tld == '.au' || $tld == '.net.au' || $tld == '.com.au') {
|
||||
$contact = $domain->getContactRegistrar();
|
||||
|
||||
@@ -342,6 +350,19 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
|
||||
public function renewDomain(\FOSSBilling\Extension\Contract\Registrar\Domain $domain): bool
|
||||
{
|
||||
// ResellerClub requires the domain's current expiry date (in epoch seconds) on every
|
||||
// renewal request, as a safeguard against renewing against stale data. FOSSBilling only
|
||||
// learns that date via a prior successful WHOIS sync, so a domain whose sync never
|
||||
// completed (e.g. it ran too soon after registration, before the registry had propagated
|
||||
// the record) reaches here with no expiration time cached locally. Sending the request
|
||||
// without exp-date fails with "Required parameter missing: exp-date", and since the
|
||||
// renewal never succeeds, the sync that would have fixed it for next time never runs
|
||||
// either - the domain is stuck failing every renewal attempt. Fetch it fresh instead of
|
||||
// giving up. See https://github.com/FOSSBilling/FOSSBilling/issues/4229.
|
||||
if (empty($domain->getExpirationTime())) {
|
||||
$this->getDomainDetails($domain);
|
||||
}
|
||||
|
||||
$params = [
|
||||
'order-id' => $this->_getDomainOrderId($domain),
|
||||
'years' => $domain->getRegistrationPeriod(),
|
||||
@@ -608,7 +629,7 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
]);
|
||||
} else {
|
||||
$result = $client->request('GET', $callUrl . '?' . $this->_formatParams($params));
|
||||
$this->getLog()->debug('API REQUEST: ' . $callUrl . '?' . $this->_formatParams($params));
|
||||
$this->getLog()->debug('API REQUEST: ' . $callUrl . '?' . $this->_formatParams($this->_redactAuthParams($params)));
|
||||
}
|
||||
$this->getLog()->info('API RESULT: ' . $result->getContent(false));
|
||||
} catch (HttpExceptionInterface $error) {
|
||||
@@ -619,30 +640,50 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
}
|
||||
|
||||
$content = $result->getContent(false);
|
||||
if (in_array($content, ['true', 'false'], true)) {
|
||||
return $content;
|
||||
$trimmedContent = trim($content);
|
||||
|
||||
// Some endpoints (e.g. domains/validate-transfer, domains/orderid) respond with a bare
|
||||
// scalar instead of a JSON object, which would make toArray() below throw "JSON content
|
||||
// was expected to decode to an array". Match booleans case-insensitively and trimmed,
|
||||
// since the registrar isn't consistent about formatting - see
|
||||
// https://github.com/FOSSBilling/FOSSBilling/issues/2939.
|
||||
if (in_array(strtolower($trimmedContent), ['true', 'false'], true)) {
|
||||
return strtolower($trimmedContent);
|
||||
}
|
||||
if (is_numeric($content)) {
|
||||
return $content;
|
||||
|
||||
$decoded = json_decode($trimmedContent, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_scalar($decoded)) {
|
||||
return $trimmedContent;
|
||||
}
|
||||
|
||||
try {
|
||||
$json = $result->toArray(false);
|
||||
} catch (DecodingExceptionInterface) {
|
||||
// A 2xx response with a non-JSON body (e.g. an HTML error/rate-limit/WAF page) - see #4220 for the same class of issue.
|
||||
// Symfony's exception message embeds the full request URL, which for a GET request includes
|
||||
// auth-userid/api-key in the query string - never log or surface that, log a fixed message instead.
|
||||
$this->getLog()->error("ResellerClub API response for {$url} could not be decoded as JSON.");
|
||||
$placeholders = [':action:' => $url, ':type:' => 'ResellerClub'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
}
|
||||
$json = $result->toArray(false);
|
||||
|
||||
if (isset($json['status']) && $json['status'] == 'ERROR') {
|
||||
error_log('ResellerClub error: ' . $json['message']);
|
||||
$this->getLog()->error('ResellerClub error: ' . $json['message']);
|
||||
$placeholders = [':action:' => $url, ':type:' => 'ResellerClub'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
if (isset($json['status']) && $json['status'] == 'error') {
|
||||
error_log('ResellerClub error: ' . $json['error']);
|
||||
$this->getLog()->error('ResellerClub error: ' . $json['error']);
|
||||
$placeholders = [':action:' => $url, ':type:' => 'ResellerClub'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
}
|
||||
|
||||
if (isset($json['status']) && $json['status'] == 'Failed') {
|
||||
error_log('ResellerClub error: ' . $json['actionstatusdesc']);
|
||||
$this->getLog()->error('ResellerClub error: ' . $json['actionstatusdesc']);
|
||||
$placeholders = [':action:' => $url, ':type:' => 'ResellerClub'];
|
||||
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Failed to :action: with the :type: registrar, check the error logs for further details', $placeholders);
|
||||
@@ -651,6 +692,23 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact auth-userid/api-key from params before they're logged. includeAuthorizationParams()
|
||||
* adds these to every request, so the debug-level "API REQUEST" log would otherwise leak
|
||||
* ResellerClub credentials on every single call - see the credential leak fixed in #4254 for
|
||||
* the same category of issue on the error path.
|
||||
*/
|
||||
private function _redactAuthParams(array $params): array
|
||||
{
|
||||
foreach (['auth-userid', 'api-key'] as $key) {
|
||||
if (isset($params[$key])) {
|
||||
$params[$key] = '***REDACTED***';
|
||||
}
|
||||
}
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert params to resellerClub format.
|
||||
*
|
||||
@@ -671,7 +729,7 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
return preg_replace('~%5B(\d+)%5D~', '', $params);
|
||||
}
|
||||
|
||||
private function _getAllContacts($tld, $customer_id, \FOSSBilling\Extension\Contract\Registrar\Domain\Contact $client): array
|
||||
private function _getAllContacts($tld, $customer_id, \FOSSBilling\Extension\Contract\Registrar\Domain\Contact $client, bool $replaceExisting = true): array
|
||||
{
|
||||
if ($tld[0] != '.') {
|
||||
$tld = '.' . $tld; // $tld must start with a dot(.)
|
||||
@@ -707,7 +765,7 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
}
|
||||
|
||||
// create general contact id
|
||||
$reg_contact_id = $this->_getContact($contact, $customer_id, $contact['type']);
|
||||
$reg_contact_id = $this->_getContact($contact, $customer_id, $contact['type'], $replaceExisting);
|
||||
|
||||
if ($tld == '.nl') {
|
||||
$contact['type'] = 'NlContact';
|
||||
@@ -723,6 +781,11 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
$contact['type'] = 'EuContact';
|
||||
}
|
||||
|
||||
// @see https://manage.resellerclub.com/kb/answer/790 for the FrContact type
|
||||
if ($tld == '.fr') {
|
||||
$contact['type'] = 'FrContact';
|
||||
}
|
||||
|
||||
if ($tld == '.cn') {
|
||||
$contact['type'] = 'CnContact';
|
||||
}
|
||||
@@ -839,7 +902,7 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
|
||||
$special_contact_id = null;
|
||||
if ($contact['type'] != 'Contact') {
|
||||
$special_contact_id = $this->_getContact($contact, $customer_id, $contact['type']);
|
||||
$special_contact_id = $this->_getContact($contact, $customer_id, $contact['type'], $replaceExisting);
|
||||
}
|
||||
|
||||
// by default special contact is also admin, tech and billing contact, but not always
|
||||
@@ -853,36 +916,46 @@ class Resellerclub extends \FOSSBilling\Extension\Contract\Registrar\AdapterAbst
|
||||
$tech_contact_id = -1;
|
||||
}
|
||||
|
||||
if (in_array($tld, ['.uk', '.co.uk', '.org.uk', '.nz', '.ru', '.com.ru', '.org.ru', '.net.ru', '.eu', '.ca', '.nl'])) {
|
||||
// @see https://manage.resellerclub.com/kb/answer/752 - .FR requires tech-contact-id to be -1,
|
||||
// but (unlike .uk/.ru/.eu above) it still expects a real admin-contact-id.
|
||||
if ($tld == '.fr') {
|
||||
$tech_contact_id = -1;
|
||||
}
|
||||
|
||||
if (in_array($tld, ['.uk', '.co.uk', '.org.uk', '.nz', '.ru', '.com.ru', '.org.ru', '.net.ru', '.eu', '.ca', '.nl', '.fr'])) {
|
||||
$billing_contact_id = -1;
|
||||
}
|
||||
|
||||
// general contact is special contact for these TLD'S
|
||||
if (in_array($tld, ['.de', '.nl', '.ru', '.es', '.uk', '.co.uk', '.org.uk', '.eu', '.com.ru', '.net.ru', '.org.ru', '.co'])) {
|
||||
if (in_array($tld, ['.de', '.nl', '.ru', '.es', '.uk', '.co.uk', '.org.uk', '.eu', '.com.ru', '.net.ru', '.org.ru', '.co', '.fr'])) {
|
||||
$reg_contact_id = $special_contact_id;
|
||||
}
|
||||
|
||||
return [$reg_contact_id, $admin_contact_id, $tech_contact_id, $billing_contact_id];
|
||||
}
|
||||
|
||||
private function _getContact($contact, $customer_id, $type = 'Contact')
|
||||
private function _getContact($contact, $customer_id, $type = 'Contact', bool $replaceExisting = true)
|
||||
{
|
||||
try {
|
||||
$params = [
|
||||
'customer-id' => $customer_id,
|
||||
'no-of-records' => 20,
|
||||
'page-no' => 1,
|
||||
'status' => 'Active',
|
||||
'type' => $type,
|
||||
];
|
||||
$result = $this->_makeRequest('contacts/search', $params, 'GET', 'json');
|
||||
if ($result['recsonpage'] < 1) {
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Contact not found');
|
||||
// contacts/search isn't scoped to a domain/order, so this only replaces an existing contact
|
||||
// when the caller has confirmed there's nothing else relying on it (e.g. during registration).
|
||||
if ($replaceExisting) {
|
||||
try {
|
||||
$params = [
|
||||
'customer-id' => $customer_id,
|
||||
'no-of-records' => 20,
|
||||
'page-no' => 1,
|
||||
'status' => 'Active',
|
||||
'type' => $type,
|
||||
];
|
||||
$result = $this->_makeRequest('contacts/search', $params, 'GET', 'json');
|
||||
if ($result['recsonpage'] < 1) {
|
||||
throw new \FOSSBilling\Extension\Contract\Registrar\Exception('Contact not found');
|
||||
}
|
||||
$existing_contact_id = $result['result'][0]['entity.entityid'];
|
||||
$this->_makeRequest('contacts/delete', ['contact-id' => $existing_contact_id], 'POST');
|
||||
} catch (\FOSSBilling\Extension\Contract\Registrar\Exception $e) {
|
||||
$this->getLog()->info($e->getMessage());
|
||||
}
|
||||
$existing_contact_id = $result['result'][0]['entity.entityid'];
|
||||
$this->_makeRequest('contacts/delete', ['contact-id' => $existing_contact_id], 'POST');
|
||||
} catch (\FOSSBilling\Extension\Contract\Registrar\Exception $e) {
|
||||
$this->getLog()->info($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->_makeRequest('contacts/add', $contact, 'POST');
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
function createResellerclubAdapter(HttpClientInterface $httpClient): FOSSBilling\Extension\Registrar\Resellerclub\Resellerclub
|
||||
{
|
||||
return new class(['userid' => '12345', 'api-key' => 'secret'], $httpClient) extends FOSSBilling\Extension\Registrar\Resellerclub\Resellerclub {
|
||||
public function __construct(array $options, private readonly HttpClientInterface $httpClient)
|
||||
{
|
||||
parent::__construct($options);
|
||||
}
|
||||
|
||||
public function getHttpClient(): HttpClientInterface
|
||||
{
|
||||
return $this->httpClient;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createResellerclubDomain(string $sld = 'example', string $tld = '.com'): FOSSBilling\Extension\Contract\Registrar\Domain
|
||||
{
|
||||
return (new FOSSBilling\Extension\Contract\Registrar\Domain())->setSld($sld)->setTld($tld);
|
||||
}
|
||||
|
||||
test('isDomaincanBeTransferred returns false for a bare "false" response instead of throwing', function (): void {
|
||||
// Regression test for #2939: the ResellerClub-style API responds to domains/validate-transfer.json
|
||||
// with a bare JSON boolean (no wrapping object). Symfony's toArray() throws "JSON content was
|
||||
// expected to decode to an array" for a non-array JSON body, so _makeRequest() must short-circuit
|
||||
// before reaching toArray() for this case.
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('false'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred(createResellerclubDomain()))->toBeFalse();
|
||||
});
|
||||
|
||||
test('isDomaincanBeTransferred returns true for a bare "true" response', function (): void {
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('true'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred(createResellerclubDomain()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('isDomaincanBeTransferred tolerates trailing whitespace and mixed casing in the raw response', function (): void {
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse("True\n"));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
expect($adapter->isDomaincanBeTransferred(createResellerclubDomain()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a bare numeric response (e.g. domains/orderid) is still returned as-is', function (): void {
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('98765'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
$reflection = new ReflectionMethod($adapter, '_getDomainOrderId');
|
||||
$result = $reflection->invoke($adapter, createResellerclubDomain());
|
||||
|
||||
expect($result)->toBe('98765');
|
||||
});
|
||||
|
||||
test('a bare "null" response is not silently treated as a scalar', function (): void {
|
||||
// Regression guard for the scalar short-circuit above: json_decode('null') also returns
|
||||
// null with no decode error, but null isn't a usable scalar result (e.g. getDomainDetails()
|
||||
// would go on to index it like an array), so it must fall through to toArray() instead,
|
||||
// which now surfaces as a clean Registrar exception rather than a leaked Symfony one.
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('null'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
expect(fn (): bool => $adapter->isDomaincanBeTransferred(createResellerclubDomain()))
|
||||
->toThrow(FOSSBilling\Extension\Contract\Registrar\Exception::class);
|
||||
});
|
||||
|
||||
test('a non-JSON response (e.g. an HTML error/rate-limit page) throws a \FOSSBilling\Extension\Contract\Registrar\Exception instead of leaking a JsonException', function (): void {
|
||||
// A 2xx response whose body isn't valid JSON at all (an HTML error page,
|
||||
// a WAF block page, a truncated response) made toArray() throw Symfony's
|
||||
// raw JsonException uncaught - only the 4xx/5xx and bare-scalar cases were
|
||||
// handled. See #4220 for the same class of issue fixed elsewhere (PSL download).
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('<html>Rate limit exceeded</html>'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
expect(fn (): bool => $adapter->isDomaincanBeTransferred(createResellerclubDomain()))
|
||||
->toThrow(FOSSBilling\Extension\Contract\Registrar\Exception::class);
|
||||
});
|
||||
|
||||
test('a non-JSON response never logs the request URL, since it carries auth-userid and api-key in the query string', function (): void {
|
||||
// CodeRabbit finding on #4254: Symfony's DecodingExceptionInterface message embeds the full
|
||||
// request URL, which for a GET request (as used here) includes ResellerClub credentials in the
|
||||
// query string - the error handler must log a fixed message instead of the raw exception message.
|
||||
// Scoped to error-level messages: the pre-existing debug-level "API REQUEST" log already includes
|
||||
// the full URL for every call, which is a separate, pre-existing issue outside this fix's scope.
|
||||
$logger = new class extends Psr\Log\AbstractLogger {
|
||||
/** @var list<string> */
|
||||
public array $errorMessages = [];
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
if ($level === Psr\Log\LogLevel::ERROR) {
|
||||
$this->errorMessages[] = (string) $message;
|
||||
}
|
||||
}
|
||||
};
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('<html>Rate limit exceeded</html>'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$adapter->setLog($logger);
|
||||
|
||||
expect(fn (): bool => $adapter->isDomaincanBeTransferred(createResellerclubDomain()))
|
||||
->toThrow(FOSSBilling\Extension\Contract\Registrar\Exception::class);
|
||||
|
||||
$logged = implode("\n", $logger->errorMessages);
|
||||
expect($logged)->not->toContain('secret') // the api-key configured in createResellerclubAdapter()
|
||||
->and($logged)->not->toContain('auth-userid')
|
||||
->and($logged)->not->toContain('api-key');
|
||||
});
|
||||
|
||||
test('an error-object response still throws a \FOSSBilling\Extension\Contract\Registrar\Exception', function (): void {
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse(json_encode([
|
||||
'status' => 'ERROR',
|
||||
'message' => 'Invalid API key',
|
||||
])));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
|
||||
expect(fn (): bool => $adapter->isDomaincanBeTransferred(createResellerclubDomain()))
|
||||
->toThrow(FOSSBilling\Extension\Contract\Registrar\Exception::class);
|
||||
});
|
||||
|
||||
test('a GET request never logs its auth-userid/api-key in the debug-level "API REQUEST" log', function (): void {
|
||||
// includeAuthorizationParams() appends auth-userid/api-key to every request, and GET requests
|
||||
// put them straight into the query string. _makeRequest() logs that query string at debug
|
||||
// level on every single call (unlike the narrower error-path leak fixed for #4254, which only
|
||||
// covered the JSON-decoding-failure log line), so the debug log must redact both params too.
|
||||
$logger = new class extends Psr\Log\AbstractLogger {
|
||||
/** @var list<string> */
|
||||
public array $debugMessages = [];
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
if ($level === Psr\Log\LogLevel::DEBUG) {
|
||||
$this->debugMessages[] = (string) $message;
|
||||
}
|
||||
}
|
||||
};
|
||||
$httpClient = new MockHttpClient(fn (): MockResponse => new MockResponse('true'));
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$adapter->setLog($logger);
|
||||
|
||||
$adapter->isDomaincanBeTransferred(createResellerclubDomain());
|
||||
|
||||
$logged = implode("\n", $logger->debugMessages);
|
||||
expect($logger->debugMessages)->not->toBeEmpty()
|
||||
->and($logged)->toContain('API REQUEST')
|
||||
->and($logged)->not->toContain('secret') // the api-key configured in createResellerclubAdapter()
|
||||
->and($logged)->not->toContain('auth-userid=12345')
|
||||
->and($logged)->toContain('api-key=');
|
||||
});
|
||||
|
||||
function createResellerclubTestContact(): FOSSBilling\Extension\Contract\Registrar\Domain\Contact
|
||||
{
|
||||
return (new FOSSBilling\Extension\Contract\Registrar\Domain\Contact())
|
||||
->setName('Jane Doe')
|
||||
->setEmail('jane@example.com')
|
||||
->setAddress1('1 Example St')
|
||||
->setCity('Example City')
|
||||
->setZip('12345')
|
||||
->setTelCc('1')
|
||||
->setTel('5551234567')
|
||||
->setCountry('US');
|
||||
}
|
||||
|
||||
test('modifyContact creates a new contact and re-associates it with the domain order instead of calling the deprecated contacts/modify endpoint', function (): void {
|
||||
// Regression test for #2365: ResellerClub deprecated contacts/modify in December 2016 due to
|
||||
// ICANN's IRTP-C policy - existing contacts can no longer be edited in place. modifyContact must
|
||||
// instead create a new contact via contacts/add and re-point the domain order at it via
|
||||
// domains/modify-contact. It must never call contacts/search or contacts/delete: that search
|
||||
// isn't scoped to this domain's order, so it could delete an unrelated active contact belonging
|
||||
// to the same customer.
|
||||
$requests = [];
|
||||
$responses = [
|
||||
json_encode(['customerid' => '555']), // customers/details
|
||||
'998877', // contacts/add -> new contact id
|
||||
'112233', // domains/orderid
|
||||
json_encode(['status' => 'Success']), // domains/modify-contact
|
||||
];
|
||||
$lastBody = null;
|
||||
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests, &$responses, &$lastBody): MockResponse {
|
||||
$requests[] = $method . ' ' . parse_url($url, PHP_URL_PATH);
|
||||
$lastBody = $options['body'] ?? null;
|
||||
|
||||
return new MockResponse(array_shift($responses));
|
||||
});
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain()->setContactRegistrar(createResellerclubTestContact());
|
||||
|
||||
expect($adapter->modifyContact($domain))->toBeTrue();
|
||||
expect($requests)->toBe([
|
||||
'GET /api/customers/details.json',
|
||||
'POST /api/contacts/add.json',
|
||||
'GET /api/domains/orderid.json',
|
||||
'POST /api/domains/modify-contact.json',
|
||||
]);
|
||||
|
||||
// a plain .com domain only needs one general contact, shared across all four roles
|
||||
parse_str((string) $lastBody, $body);
|
||||
expect($body['reg-contact-id'])->toBe('998877');
|
||||
expect($body['admin-contact-id'])->toBe('998877');
|
||||
expect($body['tech-contact-id'])->toBe('998877');
|
||||
expect($body['billing-contact-id'])->toBe('998877');
|
||||
});
|
||||
|
||||
test('modifyContact assigns per-role contact IDs for TLDs that require a dedicated contact type (.co.uk)', function (): void {
|
||||
// Regression test for the .uk/.co.uk/.org.uk handling that _getAllContacts() already applies for
|
||||
// registerDomain(): ResellerClub doesn't accept a general contact for these roles, so modifyContact
|
||||
// must go through _getAllContacts() rather than pointing every role at one general contact ID.
|
||||
$requests = [];
|
||||
$responses = [
|
||||
json_encode(['customerid' => '555']), // customers/details
|
||||
'111111', // contacts/add (general Contact) -> id
|
||||
'222222', // contacts/add (UkContact) -> id
|
||||
'333333', // domains/orderid
|
||||
json_encode(['status' => 'Success']), // domains/modify-contact
|
||||
];
|
||||
$lastBody = null;
|
||||
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests, &$responses, &$lastBody): MockResponse {
|
||||
$requests[] = $method . ' ' . parse_url($url, PHP_URL_PATH);
|
||||
$lastBody = $options['body'] ?? null;
|
||||
|
||||
return new MockResponse(array_shift($responses));
|
||||
});
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain(tld: '.co.uk')->setContactRegistrar(createResellerclubTestContact());
|
||||
|
||||
expect($adapter->modifyContact($domain))->toBeTrue();
|
||||
|
||||
parse_str((string) $lastBody, $body);
|
||||
expect($body['reg-contact-id'])->toBe('222222'); // the UkContact, not the general contact
|
||||
expect($body['admin-contact-id'])->toBe('-1');
|
||||
expect($body['tech-contact-id'])->toBe('-1');
|
||||
expect($body['billing-contact-id'])->toBe('-1');
|
||||
});
|
||||
|
||||
test('modifyContact assigns per-role contact IDs for .fr, which only forces tech and billing to -1', function (): void {
|
||||
// Regression test for #77: .fr needs its own FrContact type, but - unlike .uk/.ru/.eu - the
|
||||
// registry still expects a real admin-contact-id rather than -1. See
|
||||
// https://manage.resellerclub.com/kb/answer/752 and https://manage.resellerclub.com/kb/answer/790
|
||||
$requests = [];
|
||||
$bodies = [];
|
||||
$responses = [
|
||||
json_encode(['customerid' => '555']), // customers/details
|
||||
'111111', // contacts/add (general Contact) -> id
|
||||
'222222', // contacts/add (FrContact) -> id
|
||||
'333333', // domains/orderid
|
||||
json_encode(['status' => 'Success']), // domains/modify-contact
|
||||
];
|
||||
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests, &$responses, &$bodies): MockResponse {
|
||||
$requests[] = $method . ' ' . parse_url($url, PHP_URL_PATH);
|
||||
$bodies[] = $options['body'] ?? null;
|
||||
|
||||
return new MockResponse(array_shift($responses));
|
||||
});
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain(tld: '.fr')->setContactRegistrar(createResellerclubTestContact());
|
||||
|
||||
expect($adapter->modifyContact($domain))->toBeTrue();
|
||||
|
||||
// the second contacts/add call must actually be creating the FrContact, not just landing on the
|
||||
// right id by coincidence
|
||||
parse_str((string) $bodies[2], $frContactBody);
|
||||
expect($frContactBody['type'])->toBe('FrContact');
|
||||
|
||||
parse_str((string) end($bodies), $body);
|
||||
expect($body['reg-contact-id'])->toBe('222222'); // the FrContact, not the general contact
|
||||
expect($body['admin-contact-id'])->toBe('222222'); // real contact, NOT -1
|
||||
expect($body['tech-contact-id'])->toBe('-1');
|
||||
expect($body['billing-contact-id'])->toBe('-1');
|
||||
});
|
||||
|
||||
test('renewDomain fetches fresh domain details to fill in exp-date when it is not already cached', function (): void {
|
||||
// Regression test for #4229: FOSSBilling only learns a domain's expiration time via a prior
|
||||
// WHOIS sync. When that sync never completed (e.g. it ran too soon after registration), the
|
||||
// domain reaches renewDomain() with no expiration time, and ResellerClub rejects the renewal
|
||||
// outright with "Required parameter missing: exp-date" - which then also prevents the WHOIS
|
||||
// sync that would fix it for next time, since that only runs after a successful renewal. The
|
||||
// adapter must fetch the expiry itself instead of sending an incomplete request.
|
||||
$requests = [];
|
||||
$bodies = [];
|
||||
$responses = [
|
||||
'112233', // domains/orderid (via getDomainDetails)
|
||||
json_encode([ // domains/details
|
||||
'creationtime' => 1577836800,
|
||||
'endtime' => 1893456000,
|
||||
'domsecret' => 'epp-code',
|
||||
'isprivacyprotected' => 'false',
|
||||
'admincontact' => [
|
||||
'contactid' => '998877',
|
||||
'name' => 'Jane Doe',
|
||||
'emailaddr' => 'jane@example.com',
|
||||
'company' => '',
|
||||
'telno' => '5551234567',
|
||||
'telnocc' => '1',
|
||||
'address1' => '1 Example St',
|
||||
'city' => 'Example City',
|
||||
'country' => 'US',
|
||||
'state' => '',
|
||||
'zip' => '12345',
|
||||
],
|
||||
]),
|
||||
'112233', // domains/orderid (for the renew request itself)
|
||||
json_encode(['actionstatus' => 'Success']), // domains/renew
|
||||
];
|
||||
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests, &$responses, &$bodies): MockResponse {
|
||||
$requests[] = $method . ' ' . parse_url($url, PHP_URL_PATH);
|
||||
$bodies[] = $options['body'] ?? null;
|
||||
|
||||
return new MockResponse(array_shift($responses));
|
||||
});
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain()->setRegistrationPeriod(1);
|
||||
|
||||
expect($adapter->renewDomain($domain))->toBeTrue();
|
||||
expect($requests)->toBe([
|
||||
'GET /api/domains/orderid.json',
|
||||
'GET /api/domains/details.json',
|
||||
'GET /api/domains/orderid.json',
|
||||
'POST /api/domains/renew.json',
|
||||
]);
|
||||
|
||||
parse_str((string) end($bodies), $body);
|
||||
expect($body['exp-date'])->toBe('1893456000');
|
||||
expect($domain->getExpirationTime())->toBe(1893456000);
|
||||
});
|
||||
|
||||
test('renewDomain does not re-fetch domain details when an expiration time is already known', function (): void {
|
||||
$requests = [];
|
||||
$bodies = [];
|
||||
$responses = [
|
||||
'112233', // domains/orderid (for the renew request)
|
||||
json_encode(['actionstatus' => 'Success']), // domains/renew
|
||||
];
|
||||
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests, &$responses, &$bodies): MockResponse {
|
||||
$requests[] = $method . ' ' . parse_url($url, PHP_URL_PATH);
|
||||
$bodies[] = $options['body'] ?? null;
|
||||
|
||||
return new MockResponse(array_shift($responses));
|
||||
});
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain()->setRegistrationPeriod(1)->setExpirationTime(1893456000);
|
||||
|
||||
expect($adapter->renewDomain($domain))->toBeTrue();
|
||||
expect($requests)->toBe([
|
||||
'GET /api/domains/orderid.json',
|
||||
'POST /api/domains/renew.json',
|
||||
]);
|
||||
|
||||
parse_str((string) end($bodies), $body);
|
||||
expect($body['exp-date'])->toBe('1893456000');
|
||||
});
|
||||
|
||||
test('registerDomain sends the .fr registry consent attribute alongside the FrContact IDs', function (): void {
|
||||
// Regression test for #77: .fr requires accepting the registry's data-sharing terms via a tnc
|
||||
// attribute on domains/register, on top of the FrContact type/contact-id handling above. See
|
||||
// https://manage.resellerclub.com/kb/answer/752
|
||||
$requests = [];
|
||||
$bodies = [];
|
||||
$responses = [
|
||||
json_encode(['status' => 'ERROR', 'message' => 'Order not found']), // domains/orderid (via _hasCompletedOrder)
|
||||
json_encode(['customerid' => '555']), // customers/details
|
||||
json_encode(['recsonpage' => 0]), // contacts/search (general Contact) -> none found
|
||||
'111111', // contacts/add (general Contact) -> id
|
||||
json_encode(['recsonpage' => 0]), // contacts/search (FrContact) -> none found
|
||||
'222222', // contacts/add (FrContact) -> id
|
||||
json_encode(['status' => 'Success']), // domains/register
|
||||
];
|
||||
|
||||
$httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests, &$responses, &$bodies): MockResponse {
|
||||
$requests[] = $method . ' ' . parse_url($url, PHP_URL_PATH);
|
||||
$bodies[] = $options['body'] ?? null;
|
||||
|
||||
return new MockResponse(array_shift($responses));
|
||||
});
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain(tld: '.fr')
|
||||
->setContactRegistrar(createResellerclubTestContact())
|
||||
->setRegistrationPeriod(1)
|
||||
->setNs1('ns1.example.com')
|
||||
->setNs2('ns2.example.com');
|
||||
|
||||
expect($adapter->registerDomain($domain))->toBeTrue();
|
||||
expect($requests[6])->toBe('POST /api/domains/register.json');
|
||||
|
||||
// the second contacts/add call must actually be creating the FrContact, not just landing on the
|
||||
// right id by coincidence
|
||||
parse_str((string) $bodies[5], $frContactBody);
|
||||
expect($frContactBody['type'])->toBe('FrContact');
|
||||
|
||||
parse_str((string) end($bodies), $body);
|
||||
expect($body['reg-contact-id'])->toBe('222222');
|
||||
expect($body['admin-contact-id'])->toBe('222222');
|
||||
expect($body['tech-contact-id'])->toBe('-1');
|
||||
expect($body['billing-contact-id'])->toBe('-1');
|
||||
expect($body['attr-name1'])->toBe('tnc');
|
||||
expect($body['attr-value1'])->toBe('Y');
|
||||
});
|
||||
|
||||
test('getDomainDetails tolerates a details response missing optional keys', function (): void {
|
||||
$httpClient = new MockHttpClient([
|
||||
new MockResponse('12345'),
|
||||
new MockResponse(json_encode([
|
||||
'admincontact' => [
|
||||
'contactid' => '1', 'name' => 'Example', 'emailaddr' => 'admin@example.com',
|
||||
'company' => 'Example', 'telno' => '123', 'telnocc' => '1',
|
||||
'address1' => 'Street', 'city' => 'City', 'country' => 'US',
|
||||
'state' => 'State', 'zip' => '12345',
|
||||
],
|
||||
])),
|
||||
]);
|
||||
$adapter = createResellerclubAdapter($httpClient);
|
||||
$domain = createResellerclubDomain();
|
||||
|
||||
$adapter->getDomainDetails($domain);
|
||||
|
||||
expect($domain->getRegistrationTime())->toBeNull()
|
||||
->and($domain->getExpirationTime())->toBeNull()
|
||||
->and($domain->getEpp())->toBeNull()
|
||||
->and($domain->getPrivacyEnabled())->toBeFalse();
|
||||
});
|
||||
@@ -38,6 +38,7 @@ class Resellerid extends \FOSSBilling\Extension\Registrar\Resellerclub\Resellerc
|
||||
'label' => 'ResellerID API Key',
|
||||
'description' => 'You can get this at ResellerID control panel, go to Settings -> API',
|
||||
'required' => false,
|
||||
'secret' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
+2
-14
@@ -33,8 +33,8 @@ if ((bool) ($config['debug_and_monitoring']['debug'] ?? false)) {
|
||||
// PDO collector
|
||||
$pdoCollector = new DebugBar\DataCollector\PDO\PDOCollector();
|
||||
|
||||
// RedBean
|
||||
$pdoCollector->addConnection($di['pdo'], 'RedBeanPHP');
|
||||
// PDO
|
||||
$pdoCollector->addConnection($di['pdo'], 'PDO');
|
||||
|
||||
// Doctrine
|
||||
$connection = $di['em']->getConnection();
|
||||
@@ -59,18 +59,6 @@ $http_err_code = $request->query->get('_errcode');
|
||||
|
||||
$timeCollector?->startMeasure('session_start', 'Starting / restoring the session');
|
||||
|
||||
/*
|
||||
* Workaround: Session IDs get reset when using PGs like PayPal because of the `samesite=strict` cookie attribute, resulting in the client getting logged out.
|
||||
* The return and cancel URLs include a signed restore_token that contains the session ID. We validate and extract it here.
|
||||
*/
|
||||
if ($request->query->has('restore_token')) {
|
||||
$restoreToken = $request->query->get('restore_token');
|
||||
$restoredSessionId = is_string($restoreToken) ? FOSSBilling\Tools::validateSessionRestoreToken($restoreToken) : null;
|
||||
if ($restoredSessionId !== null) {
|
||||
session_id($restoredSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
$di['session']->getId();
|
||||
$timeCollector?->stopMeasure('session_start');
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ define('PATH_EXTENSIONS', Path::join(PATH_ROOT, 'extensions'));
|
||||
define('PATH_CACHE', Path::join(PATH_ROOT, 'data', 'cache'));
|
||||
|
||||
$failures = FOSSBilling\Extension\DependencyBootstrap::create()
|
||||
->installMissing(static fn (string $line) => print $line . PHP_EOL);
|
||||
->installMissing(static function (string $line): void {
|
||||
echo $line . PHP_EOL;
|
||||
});
|
||||
|
||||
if ($failures !== []) {
|
||||
fwrite(STDERR, PHP_EOL . count($failures) . ' extension(s) could not be prepared.' . PHP_EOL);
|
||||
|
||||
@@ -111,9 +111,20 @@
|
||||
<details id="step-3">
|
||||
<summary>Step 3 - Database Connection</summary>
|
||||
|
||||
<p>Enter the MySQL or MariaDB database details FOSSBilling should use.</p>
|
||||
<p>Choose the database FOSSBilling should use, and enter its connection details.</p>
|
||||
|
||||
<fieldset>
|
||||
{% set selectedDriver = request.database_driver|default(database_driver)|default('pdo_mysql') %}
|
||||
<label for="database_driver">Database Type <span class="red">*</span></label>
|
||||
<select id="database_driver" name="database_driver">
|
||||
<option value="pdo_mysql" {% if selectedDriver == 'pdo_mysql' %} selected {% endif %}>MySQL / MariaDB</option>
|
||||
<option value="pdo_pgsql" {% if selectedDriver == 'pdo_pgsql' %} selected {% endif %}>PostgreSQL</option>
|
||||
<option value="pdo_sqlite" {% if selectedDriver == 'pdo_sqlite' %} selected {% endif %}>SQLite</option>
|
||||
</select>
|
||||
<small id="database-upgrade-notice" hidden>PostgreSQL and SQLite are supported for new installs. Future updates keep the database structure current automatically, but one-time data migrations tied to a specific historical release are MySQL/MariaDB-only - MySQL/MariaDB is recommended if your installation may need one of those.</small>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="database-server-fields">
|
||||
<label for="database_hostname">Database Hostname <span class="red">*</span></label>
|
||||
<input type="text" id="database_hostname" name="database_hostname" value="{{ request.database_hostname|default(database_hostname)|default('localhost') }}" placeholder="localhost, mysql, db.domain.com, 12.34.56.78 ..." required>
|
||||
<small>Use the database service name in Docker environments, for example <strong>mariadb</strong>.</small>
|
||||
@@ -130,6 +141,12 @@
|
||||
<label for="database_password">Database Password <span class="red">*</span></label>
|
||||
<input type="password" id="database_password" name="database_password" value="{{ request.database_password|default(database_password) }}" placeholder="Database password" required>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="database-sqlite-fields" hidden>
|
||||
<label for="database_path">Database File Path <span class="red">*</span></label>
|
||||
<input type="text" id="database_path" name="database_path" value="{{ request.database_path|default(database_path) }}" placeholder="/path/to/data/database.sqlite">
|
||||
<small>Created automatically if it doesn't exist. The containing directory must be writable by the web server, and should live outside the public web root.</small>
|
||||
</fieldset>
|
||||
</details>
|
||||
|
||||
<details id="step-4">
|
||||
@@ -306,6 +323,39 @@
|
||||
const step5 = document.getElementById("step-5");
|
||||
const steps = [step1, step2, step3, step4, step5];
|
||||
|
||||
const databaseDriverSelect = document.getElementById('database_driver');
|
||||
const databaseServerFields = document.getElementById('database-server-fields');
|
||||
const databaseSqliteFields = document.getElementById('database-sqlite-fields');
|
||||
const databaseUpgradeNotice = document.getElementById('database-upgrade-notice');
|
||||
const databasePortInput = document.getElementById('database_port');
|
||||
const defaultPortByDriver = { pdo_mysql: '3306', pdo_pgsql: '5432' };
|
||||
|
||||
function setFieldsetActive(fieldset, active) {
|
||||
fieldset.hidden = !active;
|
||||
fieldset.querySelectorAll('input').forEach(function (input) {
|
||||
input.required = active;
|
||||
});
|
||||
}
|
||||
|
||||
function updateDatabaseFields() {
|
||||
const driver = databaseDriverSelect.value;
|
||||
const isSqlite = driver === 'pdo_sqlite';
|
||||
|
||||
setFieldsetActive(databaseServerFields, !isSqlite);
|
||||
setFieldsetActive(databaseSqliteFields, isSqlite);
|
||||
databaseUpgradeNotice.hidden = driver === 'pdo_mysql';
|
||||
|
||||
if (!isSqlite && defaultPortByDriver[driver] && (databasePortInput.value === '' || Object.values(defaultPortByDriver).includes(databasePortInput.value))) {
|
||||
databasePortInput.value = defaultPortByDriver[driver];
|
||||
}
|
||||
if (!isSqlite) {
|
||||
databasePortInput.placeholder = defaultPortByDriver[driver] || '';
|
||||
}
|
||||
}
|
||||
|
||||
databaseDriverSelect.addEventListener('change', updateDatabaseFields);
|
||||
updateDatabaseFields();
|
||||
|
||||
function isStepAllowed(step, reportValidity = false) {
|
||||
for (let i = 1; i < step; i++) {
|
||||
if(!checkRequiredInputs(i, false)){
|
||||
|
||||
+187
-76
@@ -44,7 +44,6 @@ require PATH_VENDOR . DIRECTORY_SEPARATOR . 'autoload.php';
|
||||
define('PATH_INSTALL_THEMES', Path::join(PATH_ROOT, 'install'));
|
||||
define('PATH_THEMES', Path::join(PATH_ROOT, 'themes'));
|
||||
define('PATH_LICENSE', Path::join(PATH_ROOT, 'LICENSE'));
|
||||
define('PATH_SQL', Path::join(PATH_ROOT, 'install', 'sql', 'structure.sql'));
|
||||
define('PATH_SQL_DATA', Path::join(PATH_ROOT, 'install', 'sql', 'content.sql'));
|
||||
define('PATH_INSTALL', Path::join(PATH_ROOT, 'install'));
|
||||
define('PATH_CONFIG', Path::join(PATH_ROOT, 'config.php'));
|
||||
@@ -55,8 +54,8 @@ define('PATH_MODS', Path::join(PATH_ROOT, 'modules'));
|
||||
define('PATH_DATA', Path::join(PATH_ROOT, 'data'));
|
||||
define('PATH_CACHE', Path::join(PATH_DATA, 'cache'));
|
||||
define('PATH_LOG', Path::join(PATH_DATA, 'log'));
|
||||
define('HURAGA_CONFIG', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json'));
|
||||
define('HURAGA_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'huraga', 'config', 'settings_data.json.example'));
|
||||
define('CLIENT_THEME_CONFIG', Path::join(PATH_THEMES, 'default', 'client', 'config', 'settings_data.json'));
|
||||
define('CLIENT_THEME_CONFIG_TEMPLATE', Path::join(PATH_THEMES, 'default', 'client', 'config', 'settings_data.json.example'));
|
||||
define('PATH_HTACCESS', Path::join(PATH_ROOT, '.htaccess'));
|
||||
define('PAGE_INSTALL', Path::join('./assets', 'install.html.twig'));
|
||||
define('PAGE_RESULT', Path::join('./assets', 'result.html.twig'));
|
||||
@@ -88,13 +87,14 @@ final class FOSSBilling_Installer
|
||||
{
|
||||
private readonly Session $session;
|
||||
private PDO $pdo;
|
||||
private Doctrine\DBAL\Connection $connection;
|
||||
private bool $isDebug = false;
|
||||
private readonly Filesystem $filesystem;
|
||||
|
||||
public function __construct(private readonly Request $request, private readonly array $preConfigProxyCandidate = [])
|
||||
{
|
||||
require_once Path::join(PATH_INSTALL, 'session.php');
|
||||
$this->session = new Session();
|
||||
$this->session = new Session($this->request);
|
||||
$this->filesystem = new Filesystem();
|
||||
$config = $this->getExistingConfig();
|
||||
if ($config !== null) {
|
||||
@@ -147,16 +147,27 @@ final class FOSSBilling_Installer
|
||||
|
||||
// Set up default currency before validation to preserve user selection if validation fails
|
||||
$this->session->set('currency_code', $this->request->request->get('currency_code'));
|
||||
|
||||
// Handle database information
|
||||
$this->session->set('database_hostname', $this->request->request->get('database_hostname'));
|
||||
$databasePort = FOSSBilling\Tools::normalizePort($this->request->request->get('database_port'));
|
||||
if ($databasePort === null) {
|
||||
throw new Exception('Database port is invalid.');
|
||||
$databaseDriver = (string) $this->request->request->get('database_driver', 'pdo_mysql');
|
||||
if (!in_array($databaseDriver, FOSSBilling\Doctrine\DriverManagerFactory::SUPPORTED_DRIVERS, true)) {
|
||||
throw new Exception('Unsupported database driver.');
|
||||
}
|
||||
$this->session->set('database_driver', $databaseDriver);
|
||||
|
||||
if ($databaseDriver === 'pdo_sqlite') {
|
||||
$this->session->set('database_path', $this->request->request->get('database_path'));
|
||||
} else {
|
||||
$this->session->set('database_hostname', $this->request->request->get('database_hostname'));
|
||||
$databasePort = FOSSBilling\Tools::normalizePort($this->request->request->get('database_port'));
|
||||
if ($databasePort === null) {
|
||||
throw new Exception('Database port is invalid.');
|
||||
}
|
||||
$this->session->set('database_port', $databasePort);
|
||||
$this->session->set('database_name', $this->request->request->get('database_name'));
|
||||
$this->session->set('database_username', $this->request->request->get('database_username'));
|
||||
$this->session->set('database_password', $this->request->request->get('database_password'));
|
||||
}
|
||||
$this->session->set('database_port', $databasePort);
|
||||
$this->session->set('database_name', $this->request->request->get('database_name'));
|
||||
$this->session->set('database_username', $this->request->request->get('database_username'));
|
||||
$this->session->set('database_password', $this->request->request->get('database_password'));
|
||||
$this->connectDatabase();
|
||||
|
||||
// Handle admin information
|
||||
@@ -175,8 +186,8 @@ final class FOSSBilling_Installer
|
||||
|
||||
// Attempt installation
|
||||
$this->install();
|
||||
$this->session->destroy();
|
||||
$this->generateEmailTemplates();
|
||||
session_destroy();
|
||||
|
||||
$result = $this->render(PAGE_RESULT, [
|
||||
'success' => true,
|
||||
@@ -216,10 +227,12 @@ final class FOSSBilling_Installer
|
||||
'fossbilling_ver' => FOSSBilling\Version::VERSION,
|
||||
'canInstall' => !$this->isSubfolder() && $compatibility['can_install'],
|
||||
'alreadyInstalled' => $this->isAlreadyInstalled(),
|
||||
'database_driver' => $this->session->get('database_driver') ?: 'pdo_mysql',
|
||||
'database_hostname' => $this->session->get('database_hostname'),
|
||||
'database_name' => $this->session->get('database_name'),
|
||||
'database_username' => $this->session->get('database_username'),
|
||||
'database_password' => $this->session->get('database_password'),
|
||||
'database_path' => $this->session->get('database_path') ?: Path::join(PATH_DATA, 'database.sqlite'),
|
||||
'admin_name' => $this->session->get('admin_name'),
|
||||
'admin_email' => $this->session->get('admin_email'),
|
||||
'admin_password' => $this->session->get('admin_password'),
|
||||
@@ -270,13 +283,31 @@ final class FOSSBilling_Installer
|
||||
* Attempt to open the database connection.
|
||||
*/
|
||||
private function connectDatabase(): void
|
||||
{
|
||||
match ((string) ($this->session->get('database_driver') ?: 'pdo_mysql')) {
|
||||
'pdo_pgsql' => $this->connectPostgres(),
|
||||
'pdo_sqlite' => $this->connectSqlite(),
|
||||
default => $this->connectMysql(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL/MariaDB connection. Opens the original raw PDO connection first - proven, unchanged
|
||||
* from before PostgreSQL/SQLite support existed - to apply session settings and create the
|
||||
* database itself (`DriverManagerFactory::getConnection()` below connects to a specific,
|
||||
* already-existing `dbname`; it doesn't create one). Then opens the portable
|
||||
* {@see FOSSBilling\Doctrine\DriverManagerFactory} connection the other two drivers use,
|
||||
* since {@see self::install()} now installs every driver, MySQL included, through the same
|
||||
* entity-metadata-driven path (see {@see self::installPortable()}).
|
||||
*/
|
||||
private function connectMysql(): void
|
||||
{
|
||||
$databaseName = $this->quoteMysqlIdentifier((string) $this->session->get('database_name'));
|
||||
|
||||
// Open the connection
|
||||
$databasePort = FOSSBilling\Tools::normalizePort($this->session->get('database_port'), 3306);
|
||||
|
||||
$this->pdo = new PDO('mysql:host=' . $this->session->get('database_hostname') . ';port=' . $databasePort,
|
||||
$this->pdo = new PDO('mysql:host=' . $this->session->get('database_hostname') . ';port=' . $databasePort . ';charset=utf8mb4',
|
||||
$this->session->get('database_username'),
|
||||
$this->session->get('database_password'),
|
||||
[
|
||||
@@ -286,23 +317,94 @@ final class FOSSBilling_Installer
|
||||
);
|
||||
|
||||
// Set required MySQL environment settings
|
||||
$this->pdo->exec('SET NAMES "utf8"');
|
||||
$this->pdo->exec('SET CHARACTER SET utf8');
|
||||
$this->pdo->exec('SET CHARACTER_SET_CONNECTION = utf8');
|
||||
$this->pdo->exec('SET character_set_results = utf8');
|
||||
$this->pdo->exec('SET character_set_server = utf8');
|
||||
$this->pdo->exec('SET SESSION interactive_timeout = 28800');
|
||||
$this->pdo->exec('SET SESSION wait_timeout = 28800');
|
||||
|
||||
// Attempt to create the database.
|
||||
try {
|
||||
$this->pdo->exec('CREATE DATABASE ' . $databaseName . ' CHARACTER SET utf8 COLLATE utf8_general_ci;');
|
||||
$this->pdo->exec('CREATE DATABASE ' . $databaseName . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
|
||||
} catch (PDOException) {
|
||||
// Silently fail if the database already exists.
|
||||
}
|
||||
|
||||
// Select the database as default for future queries
|
||||
$this->pdo->query('USE ' . $databaseName . ';');
|
||||
|
||||
$this->connection = FOSSBilling\Doctrine\DriverManagerFactory::getConnection([], [
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => (string) $this->session->get('database_hostname'),
|
||||
'port' => $databasePort,
|
||||
'name' => (string) $this->session->get('database_name'),
|
||||
'user' => (string) $this->session->get('database_username'),
|
||||
'password' => (string) $this->session->get('database_password'),
|
||||
]);
|
||||
$this->connection->fetchOne('SELECT 1');
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL connection. Unlike MySQL, PostgreSQL has no "CREATE DATABASE IF NOT EXISTS" and
|
||||
* can't create a database from within the connection that will use it - so this connects to
|
||||
* the always-present `postgres` maintenance database first to issue the CREATE DATABASE, then
|
||||
* opens the real, portable connection to the target database.
|
||||
*/
|
||||
private function connectPostgres(): void
|
||||
{
|
||||
$host = (string) $this->session->get('database_hostname');
|
||||
$port = FOSSBilling\Tools::normalizePort($this->session->get('database_port'), 5432);
|
||||
$user = (string) $this->session->get('database_username');
|
||||
$password = (string) $this->session->get('database_password');
|
||||
$databaseName = (string) $this->session->get('database_name');
|
||||
|
||||
$adminPdo = new PDO(
|
||||
"pgsql:host={$host};port={$port};dbname=postgres",
|
||||
$user,
|
||||
$password,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
|
||||
);
|
||||
|
||||
try {
|
||||
$adminPdo->exec('CREATE DATABASE ' . $this->quotePostgresIdentifier($databaseName));
|
||||
} catch (PDOException) {
|
||||
// Silently fail if the database already exists, matching connectMysql()'s behavior.
|
||||
}
|
||||
unset($adminPdo);
|
||||
|
||||
$this->connection = FOSSBilling\Doctrine\DriverManagerFactory::getConnection([], [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'name' => $databaseName,
|
||||
'user' => $user,
|
||||
'password' => $password,
|
||||
]);
|
||||
$this->connection->fetchOne('SELECT 1');
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite "connection" - there's no server to reach or database to pre-create; PDO creates the
|
||||
* file lazily on first real use, so only the containing directory needs to exist.
|
||||
*/
|
||||
private function connectSqlite(): void
|
||||
{
|
||||
$path = (string) $this->session->get('database_path');
|
||||
if ($path === '') {
|
||||
throw new Exception('The SQLite database file path is required.');
|
||||
}
|
||||
|
||||
$directory = Path::getDirectory($path);
|
||||
if ($directory !== '' && !$this->filesystem->exists($directory)) {
|
||||
try {
|
||||
$this->filesystem->mkdir($directory);
|
||||
} catch (IOException) {
|
||||
throw new Exception('Unable to create the SQLite database directory at ' . $directory . '. Check file and folder permissions.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection = FOSSBilling\Doctrine\DriverManagerFactory::getConnection([], [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => $path,
|
||||
]);
|
||||
$this->connection->fetchOne('SELECT 1');
|
||||
}
|
||||
|
||||
private function quoteMysqlIdentifier(string $identifier): string
|
||||
@@ -314,6 +416,15 @@ final class FOSSBilling_Installer
|
||||
return '`' . str_replace('`', '``', $identifier) . '`';
|
||||
}
|
||||
|
||||
private function quotePostgresIdentifier(string $identifier): string
|
||||
{
|
||||
if ($identifier === '' || str_contains($identifier, "\0")) {
|
||||
throw new InvalidArgumentException('The database name is invalid.');
|
||||
}
|
||||
|
||||
return '"' . str_replace('"', '""', $identifier) . '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate admin information meets all required parameters.
|
||||
*/
|
||||
@@ -381,61 +492,55 @@ final class FOSSBilling_Installer
|
||||
return is_array($config) ? $config : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema + seed data for a fresh install, on any supported platform. Schema is generated
|
||||
* from Doctrine entity metadata rather than a hand-maintained SQL dump (see
|
||||
* {@see FOSSBilling\Doctrine\SchemaInstaller}), and content.sql's seed rows are replayed
|
||||
* through a portable rewrite of that same file (see {@see FOSSBilling\Doctrine\InstallSeeder})
|
||||
* rather than a second, duplicated copy of the data.
|
||||
*
|
||||
* MySQL/MariaDB installs used to run a hand-maintained install/sql/structure.sql and
|
||||
* content.sql almost verbatim via raw PDO instead; the schema dump is gone (its content
|
||||
* at the cutover commit is preserved in git history as the reference the legacy
|
||||
* {@see FOSSBilling\UpdatePatcher} patches below were written against), and only
|
||||
* content.sql's seed rows survive, replayed portably as described above.
|
||||
*/
|
||||
private function installPortable(): void
|
||||
{
|
||||
$entityManager = FOSSBilling\Doctrine\EntityManagerFactory::create($this->connection);
|
||||
FOSSBilling\Doctrine\SchemaInstaller::createSchema($entityManager);
|
||||
|
||||
$contentSql = $this->filesystem->readFile(PATH_SQL_DATA);
|
||||
if (!$contentSql) {
|
||||
throw new Exception('Could not read content.sql file');
|
||||
}
|
||||
|
||||
$now = new DateTimeImmutable();
|
||||
FOSSBilling\Doctrine\InstallSeeder::seedContent($this->connection, $entityManager, $contentSql, $now);
|
||||
|
||||
$passwordObject = new FOSSBilling\PasswordManager();
|
||||
FOSSBilling\Doctrine\InstallSeeder::seedAdmin(
|
||||
$this->connection,
|
||||
(string) $this->session->get('admin_name'),
|
||||
(string) $this->session->get('admin_email'),
|
||||
$passwordObject->hashIt((string) $this->session->get('admin_password')),
|
||||
$this->session->get('admin_api_token'),
|
||||
$now,
|
||||
);
|
||||
FOSSBilling\Doctrine\InstallSeeder::setDefaultCurrency($this->connection, (string) $this->session->get('currency_code'));
|
||||
FOSSBilling\Doctrine\InstallSeeder::seedInstallNudge($this->connection, FOSSBilling\Version::VERSION, $now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installation processor.
|
||||
*/
|
||||
private function install(): bool
|
||||
{
|
||||
// Load database structure
|
||||
$sql = $this->filesystem->readFile(PATH_SQL);
|
||||
$sql_content = $this->filesystem->readFile(PATH_SQL_DATA);
|
||||
if (!$sql || !$sql_content) {
|
||||
throw new Exception('Could not read structure.sql file');
|
||||
}
|
||||
|
||||
// Read content, parse queries into an array, then loop and execute each query
|
||||
$sql .= $sql_content;
|
||||
$sql = preg_split('/\;[\r]*\n/ism', $sql);
|
||||
$sql = array_map(trim(...), $sql);
|
||||
foreach ($sql as $query) {
|
||||
if (!trim($query)) {
|
||||
continue;
|
||||
}
|
||||
$this->pdo->query($query);
|
||||
}
|
||||
|
||||
// Create default administrator
|
||||
$passwordObject = new FOSSBilling\PasswordManager();
|
||||
$stmt = $this->pdo->prepare('INSERT INTO admin (name, email, pass, created_at, updated_at, api_token) VALUES(:admin_name, :admin_email, :admin_password, NOW(), NOW(), :api_token);');
|
||||
$stmt->execute([
|
||||
'admin_name' => $this->session->get('admin_name'),
|
||||
'admin_email' => $this->session->get('admin_email'),
|
||||
'admin_password' => $passwordObject->hashIt($this->session->get('admin_password')),
|
||||
'api_token' => $this->session->get('admin_api_token'),
|
||||
]);
|
||||
|
||||
$stmt = $this->pdo->prepare('INSERT INTO admin_group_member (admin_id, admin_group_id, created_at) VALUES (:admin_id, 1, NOW())');
|
||||
$stmt->execute([
|
||||
'admin_id' => $this->pdo->lastInsertId(),
|
||||
]);
|
||||
|
||||
// Delete default currency from content file and use currency passed in the installer
|
||||
$stmt = $this->pdo->prepare("DELETE FROM currency WHERE code='USD'");
|
||||
$stmt->execute();
|
||||
$stmt = $this->pdo->prepare('INSERT INTO currency (id, code, is_default, conversion_rate, created_at, updated_at) VALUES(1, :currency_code, 1, 1.000000, NOW(), NOW());');
|
||||
$stmt->execute([
|
||||
'currency_code' => $this->session->get('currency_code'),
|
||||
]);
|
||||
|
||||
$stmt = $this->pdo->prepare('INSERT INTO setting (param, value, created_at, updated_at) VALUES (:param, :value, NOW(), NOW())');
|
||||
$stmt->execute([
|
||||
':param' => 'last_error_reporting_nudge',
|
||||
':value' => FOSSBilling\Version::VERSION,
|
||||
]);
|
||||
$this->installPortable();
|
||||
|
||||
// Copy config templates when applicable
|
||||
if (!$this->filesystem->exists(HURAGA_CONFIG) && $this->filesystem->exists(HURAGA_CONFIG_TEMPLATE)) {
|
||||
$this->filesystem->copy(HURAGA_CONFIG_TEMPLATE, HURAGA_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually.
|
||||
if (!$this->filesystem->exists(CLIENT_THEME_CONFIG) && $this->filesystem->exists(CLIENT_THEME_CONFIG_TEMPLATE)) {
|
||||
$this->filesystem->copy(CLIENT_THEME_CONFIG_TEMPLATE, CLIENT_THEME_CONFIG); // Copy the file instead of renaming it. This allows local dev instances to not need to restore the original file manually.
|
||||
}
|
||||
|
||||
// If .htaccess doesn't exist, fetch the latest from GitHub.
|
||||
@@ -557,14 +662,20 @@ final class FOSSBilling_Installer
|
||||
$data['info']['instance_id'] = Uuid::v4()->toString();
|
||||
$data['url'] = str_replace(['https://', 'http://'], '', $systemUrl);
|
||||
$data['path_data'] = PATH_DATA;
|
||||
$data['db'] = [
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $this->session->get('database_hostname'),
|
||||
'port' => FOSSBilling\Tools::normalizePort($this->session->get('database_port'), 3306),
|
||||
'name' => $this->session->get('database_name'),
|
||||
'user' => $this->session->get('database_username'),
|
||||
'password' => $this->session->get('database_password'),
|
||||
];
|
||||
$driver = (string) ($this->session->get('database_driver') ?: 'pdo_mysql');
|
||||
$data['db'] = $driver === 'pdo_sqlite'
|
||||
? [
|
||||
'driver' => 'pdo_sqlite',
|
||||
'path' => (string) $this->session->get('database_path'),
|
||||
]
|
||||
: [
|
||||
'driver' => $driver,
|
||||
'host' => $this->session->get('database_hostname'),
|
||||
'port' => FOSSBilling\Tools::normalizePort($this->session->get('database_port'), $driver === 'pdo_pgsql' ? 5432 : 3306),
|
||||
'name' => $this->session->get('database_name'),
|
||||
'user' => $this->session->get('database_username'),
|
||||
'password' => $this->session->get('database_password'),
|
||||
];
|
||||
$data['twig']['cache'] = PATH_CACHE;
|
||||
$data['disable_auto_cron'] = !FOSSBilling\Version::isPreviewVersion() && !Environment::isDevelopment();
|
||||
|
||||
|
||||
+31
-100
@@ -3,127 +3,58 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use FOSSBilling\Http\CookieNames;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\Session as SymfonySession;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
|
||||
|
||||
class Session
|
||||
{
|
||||
private $_handler;
|
||||
private readonly SymfonySession $session;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(Request $request)
|
||||
{
|
||||
$this->_handler = Box_SessionFile::getInstance();
|
||||
$storage = new NativeSessionStorage([
|
||||
'cache_limiter' => '',
|
||||
'cookie_lifetime' => 0,
|
||||
'cookie_secure' => $request->isSecure(),
|
||||
'cookie_httponly' => true,
|
||||
'cookie_samesite' => Cookie::SAMESITE_LAX,
|
||||
'name' => CookieNames::SESSION,
|
||||
'serialize_handler' => 'php',
|
||||
]);
|
||||
$this->session = new SymfonySession($storage);
|
||||
|
||||
if (!headers_sent()) {
|
||||
$this->session->start();
|
||||
}
|
||||
}
|
||||
|
||||
public function getId()
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->_handler->getId();
|
||||
return $this->session->getId();
|
||||
}
|
||||
|
||||
public function delete($key)
|
||||
public function delete(string $key): void
|
||||
{
|
||||
return $this->_handler->delete($key);
|
||||
$this->session->remove($key);
|
||||
}
|
||||
|
||||
public function get($key)
|
||||
public function get(string $key): mixed
|
||||
{
|
||||
return $this->_handler->get($key);
|
||||
return $this->session->get($key);
|
||||
}
|
||||
|
||||
public function set($key, $value): void
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$this->_handler->set($key, $value);
|
||||
$this->session->set($key, $value);
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
$this->_handler->destroy();
|
||||
}
|
||||
}
|
||||
|
||||
class Box_SessionFile
|
||||
{
|
||||
final public const bool SESSION_STARTED = true;
|
||||
final public const bool SESSION_NOT_STARTED = false;
|
||||
|
||||
protected $sessionState = self::SESSION_NOT_STARTED;
|
||||
|
||||
protected static $instance;
|
||||
|
||||
public static function getInstance()
|
||||
{
|
||||
if (!isset(self::$instance)) {
|
||||
self::$instance = new self();
|
||||
session_name(CookieNames::SESSION);
|
||||
if (!self::$instance->sessionExists() && !headers_sent()) {
|
||||
self::$instance->sessionState = session_start();
|
||||
}
|
||||
if ($this->session->isStarted()) {
|
||||
$this->session->invalidate();
|
||||
$this->session->save();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function getId(): string|false
|
||||
{
|
||||
return session_id();
|
||||
}
|
||||
|
||||
public function destroy(): void
|
||||
{
|
||||
if (self::$instance->sessionExists()) {
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
|
||||
public function delete($key): bool
|
||||
{
|
||||
if (isset($_SESSION[$key])) {
|
||||
unset($_SESSION[$key]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function sessionExists(): bool
|
||||
{
|
||||
if (!isset($_SESSION)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ini_get('session.use_cookies') == '1' && isset($_COOKIE[session_name()])) {
|
||||
return true;
|
||||
} elseif ($this->sessionState) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function get($key)
|
||||
{
|
||||
return $this->__get($key);
|
||||
}
|
||||
|
||||
public function set($key, $value)
|
||||
{
|
||||
return $this->__set($key, $value);
|
||||
}
|
||||
|
||||
public function __get($key)
|
||||
{
|
||||
return $_SESSION[$key] ?? null;
|
||||
}
|
||||
|
||||
public function __set($key, $value)
|
||||
{
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
|
||||
public function __isset($name)
|
||||
{
|
||||
return isset($_SESSION[$name]);
|
||||
}
|
||||
|
||||
public function __unset($name)
|
||||
{
|
||||
unset($_SESSION[$name]);
|
||||
}
|
||||
}
|
||||
+79
-77
@@ -161,6 +161,21 @@ UNLOCK TABLES;
|
||||
|
||||
|
||||
|
||||
# Dump of table support_kb_article_category
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `support_kb_article_category` WRITE;
|
||||
/*!40000 ALTER TABLE `support_kb_article_category` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `support_kb_article_category` (`id`, `title`, `description`, `slug`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,'Frequently asked questions','Section for common issues','faq',NOW(),NOW()),
|
||||
(2,'How to''s','Section dedicated for tutorials','how-to',NOW(),NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `support_kb_article_category` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table support_kb_article
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -177,21 +192,6 @@ VALUES
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table support_kb_article_category
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `support_kb_article_category` WRITE;
|
||||
/*!40000 ALTER TABLE `support_kb_article_category` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `support_kb_article_category` (`id`, `title`, `description`, `slug`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,'Frequently asked questions','Section for common issues','faq',NOW(),NOW()),
|
||||
(2,'How to''s','Section dedicated for tutorials','how-to',NOW(),NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `support_kb_article_category` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table pay_gateway
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -217,26 +217,12 @@ INSERT INTO `post` (`id`, `admin_id`, `title`, `description`, `content`, `slug`,
|
||||
VALUES
|
||||
(1,1,'FOSSBilling needs your donations','Donations are crucial for the success and sustainability of FOSSBilling and other open-source projects. We need your help.','Donations are crucial for the success and sustainability of FOSSBilling and other open-source projects. Without financial support from users and the broader community, it is difficult for these projects to continue to grow and evolve.\nDonations help to cover the costs of development and maintenance. Developing and maintaining software requires time and resources, and without financial support, it can be challenging for open-source projects to sustain their efforts.\n\nThe FOSSBilling project has expenses such as hosting, hardware, software, and other costs associated with developing and maintaining FOSSBilling and other open-source projects. Your donations also help cover some of the maintainers'' expenses.\nClick [here](https://fossbilling.org/donate) to donate to the FOSSBilling project.','fossbilling-needs-your-donations','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()),
|
||||
(2,1,'Check out great features of FOSSBilling','FOSSBilling supports automated billing, invoicing, product provisioning', '* Supports automated billing, invoicing, product provisioning\n* Automatically create accounts as soon as the payment is received, suspend when account becomes overdue, terminate when a specified amount of time passes.\n* FOSSBilling is perfectly created to sell shared and reseller hosting accounts, software licenses and downloadable products.\n* Integrated helpdesk, knowledgebase, news and announcements system.\n','great-features-of-fossbilling','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW()),
|
||||
(3,1,'FOSSBilling is customizable','You can create your own simple or advanced hooks on FOSSBilling events.','* You can create your own simple or advanced hooks on FOSSBilling events. For example, send notification via sms when new client signs up.\n* Create custom theme for your client interface\n','fossbilling-is-customizable','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW());
|
||||
(3,1,'FOSSBilling is customizable','You can extend FOSSBilling with typed event listeners.','* You can extend FOSSBilling with typed event listeners. For example, send an SMS notification when a new client signs up.\n* Create custom theme for your client interface\n','fossbilling-is-customizable','active',NULL,NULL,NULL,NULL,NULL,NOW(),NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `post` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table product
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `product` WRITE;
|
||||
/*!40000 ALTER TABLE `product` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `product` (`id`, `product_category_id`, `product_payment_id`, `form_id`, `title`, `slug`, `description`, `unit`, `active`, `status`, `hidden`, `is_addon`, `setup`, `addons`, `icon_url`, `allow_quantity_select`, `stock_control`, `quantity_in_stock`, `plugin`, `plugin_config`, `upgrades`, `priority`, `config`, `created_at`, `updated_at`, `type`)
|
||||
VALUES
|
||||
(1,1,NULL,NULL,'Domains registration and transfer','domain-checker',NULL,'product',1,'enabled',0,0,'after_payment',NULL,NULL,0,0,0,NULL,NULL,NULL,1,NULL,NOW(),NOW(),'domain');
|
||||
|
||||
/*!40000 ALTER TABLE `product` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table product_category
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -251,6 +237,20 @@ VALUES
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table product
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `product` WRITE;
|
||||
/*!40000 ALTER TABLE `product` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `product` (`id`, `product_category_id`, `product_payment_id`, `form_id`, `title`, `slug`, `description`, `unit`, `active`, `status`, `hidden`, `is_addon`, `setup`, `addons`, `icon_url`, `allow_quantity_select`, `stock_control`, `quantity_in_stock`, `plugin`, `plugin_config`, `upgrades`, `priority`, `config`, `created_at`, `updated_at`, `type`)
|
||||
VALUES
|
||||
(1,1,NULL,NULL,'Domains registration and transfer','domain-checker',NULL,'product',1,'enabled',0,0,'after_payment',NULL,NULL,0,0,0,NULL,NULL,NULL,1,NULL,NOW(),NOW(),'domain');
|
||||
|
||||
/*!40000 ALTER TABLE `product` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table product_payment
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE;
|
||||
|
||||
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,'last_patch','101',0,NULL,NULL,NOW(),NOW()),
|
||||
(1,'last_patch','130',0,NULL,NULL,NOW(),NOW()),
|
||||
(2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()),
|
||||
(3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()),
|
||||
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()),
|
||||
@@ -319,23 +319,25 @@ VALUES
|
||||
(13,'company_note','This is a placeholder for your About Us page. It will be available in /about-us.\nReplace this with information about your company, such as:\n- Your company history and mission\n- Services offered\n- Values and vision\n- Contact or support information\n- Any other details you want your clients to know about your business',0,NULL,NULL,NOW(),NOW()),
|
||||
(14,'invoice_series','FOSS',0,NULL,NULL,NOW(),NOW()),
|
||||
(15,'invoice_due_days','5',0,NULL,NULL,NOW(),NOW()),
|
||||
(16,'invoice_auto_approval','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(16,'invoice_auto_issue','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(17,'invoice_issue_days_before_expire','14',0,NULL,NULL,NOW(),NOW()),
|
||||
(18,'theme','huraga',0,NULL,NULL,NOW(),NOW()),
|
||||
(18,'theme','default/client',0,NULL,NULL,NOW(),NOW()),
|
||||
(19,'issue_invoice_days_before_expire','7',0,NULL,NULL,NOW(),NOW()),
|
||||
(20,'invoice_refund_logic','credit_note',0,NULL,NULL,NOW(),NOW()),
|
||||
(21,'invoice_cn_series','CN-',0,NULL,NULL,NOW(),NOW()),
|
||||
(22,'invoice_cn_starting_number','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(23,'invoice_starting_number','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(24,'nameserver_1',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(25,'nameserver_2',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(26,'nameserver_3',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(27,'nameserver_4',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(28,'funds_min_amount','10',0,NULL,NULL,NOW(),NOW()),
|
||||
(29,'funds_max_amount','200',0,NULL,NULL,NOW(),NOW()),
|
||||
(30,'company_favicon','public/branding/favicon.ico',0,NULL,NULL,NOW(),NOW()),
|
||||
(31,'hide_company_public',1,0,NULL,NULL,NOW(), NOW()),
|
||||
(32,'invoice_hash_lifetime_days','90',0,NULL,NULL,NOW(),NOW());
|
||||
(23,'invoice_dn_series','DN-',0,NULL,NULL,NOW(),NOW()),
|
||||
(24,'invoice_dn_starting_number','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(25,'invoice_starting_number','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(26,'nameserver_1',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(27,'nameserver_2',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(28,'nameserver_3',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(29,'nameserver_4',NULL,0,NULL,NULL,NOW(),NOW()),
|
||||
(30,'funds_min_amount','10',0,NULL,NULL,NOW(),NOW()),
|
||||
(31,'funds_max_amount','200',0,NULL,NULL,NOW(),NOW()),
|
||||
(32,'company_favicon','public/branding/favicon.ico',0,NULL,NULL,NOW(),NOW()),
|
||||
(33,'hide_company_public','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(34,'invoice_hash_lifetime_days','90',0,NULL,NULL,NOW(),NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `setting` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
@@ -360,6 +362,26 @@ VALUES
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table support_pr_category
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `support_pr_category` WRITE;
|
||||
/*!40000 ALTER TABLE `support_pr_category` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `support_pr_category` (`id`, `title`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1, 'Greetings', NOW(), NOW()),
|
||||
(2, 'General Support', NOW(), NOW()),
|
||||
(3, 'Billing & Payments', NOW(), NOW()),
|
||||
(4, 'Account Management', NOW(), NOW()),
|
||||
(5, 'Technical Issues', NOW(), NOW()),
|
||||
(6, 'Notices & Maintenance', NOW(), NOW()),
|
||||
(7, 'Domain & SSL', NOW(), NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `support_pr_category` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table support_pr
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -390,26 +412,6 @@ VALUES
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table support_pr_category
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `support_pr_category` WRITE;
|
||||
/*!40000 ALTER TABLE `support_pr_category` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `support_pr_category` (`id`, `title`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1, 'Greetings', NOW(), NOW()),
|
||||
(2, 'General Support', NOW(), NOW()),
|
||||
(3, 'Billing & Payments', NOW(), NOW()),
|
||||
(4, 'Account Management', NOW(), NOW()),
|
||||
(5, 'Technical Issues', NOW(), NOW()),
|
||||
(6, 'Notices & Maintenance', NOW(), NOW()),
|
||||
(7, 'Domain & SSL', NOW(), NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `support_pr_category` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table support_ticket
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -430,20 +432,6 @@ UNLOCK TABLES;
|
||||
|
||||
|
||||
|
||||
# Dump of table tld
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `tld` WRITE;
|
||||
/*!40000 ALTER TABLE `tld` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `tld` (`id`, `tld_registrar_id`, `tld`, `price_registration`, `price_renew`, `price_transfer`, `allow_register`, `allow_transfer`, `active`, `min_years`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,1,'.com',11.99,11.99,11.99,1,1,1,1,NOW(),NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `tld` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table tld_registrar
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -460,6 +448,20 @@ VALUES
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table tld
|
||||
# ------------------------------------------------------------
|
||||
|
||||
LOCK TABLES `tld` WRITE;
|
||||
/*!40000 ALTER TABLE `tld` DISABLE KEYS */;
|
||||
|
||||
INSERT INTO `tld` (`id`, `tld_registrar_id`, `tld`, `price_registration`, `price_renew`, `price_transfer`, `allow_register`, `allow_transfer`, `active`, `min_years`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,1,'.com',11.99,11.99,11.99,1,1,1,1,NOW(),NOW());
|
||||
|
||||
/*!40000 ALTER TABLE `tld` ENABLE KEYS */;
|
||||
UNLOCK TABLES;
|
||||
|
||||
|
||||
# Dump of table transaction
|
||||
# ------------------------------------------------------------
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+37
-7
@@ -44,7 +44,7 @@ class Box_App
|
||||
|
||||
public function __construct(array|object $options = [], ?StandardDebugBar $debugBar = null)
|
||||
{
|
||||
$this->options = new ArrayObject($options);
|
||||
$this->options = new ArrayObject((array) $options);
|
||||
|
||||
if (!$debugBar) {
|
||||
$this->debugBar = new StandardDebugBar();
|
||||
@@ -59,9 +59,10 @@ class Box_App
|
||||
$this->request = $di['request'];
|
||||
}
|
||||
|
||||
public function setUrl(string $url): void
|
||||
public function setUrl(?string $url): void
|
||||
{
|
||||
$this->url = $url;
|
||||
// Bot probes and legacy callers can pass null/empty. Fall back to '/'.
|
||||
$this->url = ($url === null || $url === '') ? '/' : $url;
|
||||
}
|
||||
|
||||
public function getDebugBar(): StandardDebugBar
|
||||
@@ -87,8 +88,7 @@ class Box_App
|
||||
[$mod] = explode('/', $requestUri);
|
||||
}
|
||||
}
|
||||
$mod = htmlspecialchars($mod);
|
||||
|
||||
// Kept raw: only used for routing and exception placeholders, never HTML.
|
||||
$this->mod = $mod;
|
||||
$this->uri = $requestUri;
|
||||
}
|
||||
@@ -104,7 +104,7 @@ class Box_App
|
||||
|
||||
public function show404(Exception $e): Response
|
||||
{
|
||||
$this->di['logger']->setChannel('routing')->info($e->getMessage());
|
||||
$this->di['logger']->withChannel('routing')->info($e->getMessage());
|
||||
|
||||
return $this->errorResponse($e, 404);
|
||||
}
|
||||
@@ -224,6 +224,36 @@ class Box_App
|
||||
return 'Rendering ' . $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twig's FilesystemCache throws a raw \RuntimeException when it can't create or
|
||||
* write to the configured template cache directory - typically a host file
|
||||
* permission issue outside our control. Convert it into a FOSSBilling\Exception
|
||||
* with error code 5002 (Cache category, report:false) so the visitor gets a
|
||||
* friendly error page instead of a fatal, and it isn't reported to Sentry as a
|
||||
* code bug. Any other \RuntimeException is rethrown unchanged.
|
||||
*/
|
||||
protected function convertCacheWriteFailure(RuntimeException $e): never
|
||||
{
|
||||
// Twig\Cache\FilesystemCache::write() throws one of these three messages for what is
|
||||
// always the same underlying problem: it couldn't create, or write into, the cache
|
||||
// directory.
|
||||
$cacheWriteFailurePrefixes = [
|
||||
'Unable to create the cache directory',
|
||||
'Unable to write in the cache directory',
|
||||
'Failed to write cache file',
|
||||
];
|
||||
|
||||
foreach ($cacheWriteFailurePrefixes as $prefix) {
|
||||
if (str_starts_with($e->getMessage(), $prefix)) {
|
||||
$this->di['logger']->withChannel('routing')->error($e->getMessage());
|
||||
|
||||
throw new FOSSBilling\Exception('The template cache directory could not be written to. Please check the file permissions and available disk space for the "data/cache" directory.', null, 5002);
|
||||
}
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
private function invokeSharedController(string $classname, string $methodName, array $params): mixed
|
||||
{
|
||||
/** @var TimeDataCollector $timeCollector */
|
||||
@@ -399,7 +429,7 @@ class Box_App
|
||||
protected function checkAdminPrefix(): bool
|
||||
{
|
||||
$requestPath = $this->getRequestPath();
|
||||
$realAdminUrl = rtrim(SYSTEM_URL, '/') . ADMIN_PREFIX;
|
||||
$realAdminUrl = rtrim(SYSTEM_URL, '/') . '/' . ltrim(ADMIN_PREFIX, '/');
|
||||
$realAdminPath = parse_url($realAdminUrl, PHP_URL_PATH);
|
||||
|
||||
if ($this->pathStartsWith($requestPath, $realAdminPath)) {
|
||||
|
||||
@@ -19,7 +19,7 @@ class Box_AppAdmin extends Box_App
|
||||
{
|
||||
$m = $this->di['mod']($this->mod);
|
||||
$controller = $m->getAdminController();
|
||||
if (!is_null($controller)) {
|
||||
if (!is_null($controller) && method_exists($controller, 'register')) {
|
||||
$controller->register($this);
|
||||
}
|
||||
}
|
||||
@@ -54,9 +54,13 @@ class Box_AppAdmin extends Box_App
|
||||
#[Override]
|
||||
public function render($fileName, $variableArray = []): string
|
||||
{
|
||||
$template = $this->getTwig()->load(Path::changeExtension($fileName, '.html.twig'));
|
||||
try {
|
||||
$template = $this->getTwig()->load(Path::changeExtension($fileName, '.html.twig'));
|
||||
|
||||
return $template->render($variableArray);
|
||||
return $template->render($variableArray);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->convertCacheWriteFailure($e);
|
||||
}
|
||||
}
|
||||
|
||||
#[Override]
|
||||
|
||||
@@ -76,18 +76,20 @@ class Box_AppClient extends Box_App
|
||||
} catch (FOSSBilling\InformationException $e) {
|
||||
// @phpstan-ignore if.alwaysFalse (DEBUG is a runtime constant that may be true during debugging)
|
||||
if (DEBUG) {
|
||||
error_log($e->getMessage());
|
||||
$this->di['logger']->withChannel('routing')->debug($e->getMessage());
|
||||
}
|
||||
} catch (Twig\Error\LoaderError|Twig\Error\RuntimeError|Twig\Error\SyntaxError $e) {
|
||||
// A real template bug, not a missing page. Surface as a 500 so the
|
||||
// next regression of this shape (issue #3818) cannot hide behind a
|
||||
// generic 404.
|
||||
$this->di['logger']->setChannel('routing')->error(sprintf(
|
||||
'Template rendering failed for "%s" (page "%s"): %s',
|
||||
$tpl,
|
||||
(string) $page,
|
||||
$e->getMessage(),
|
||||
), ['exception' => $e]);
|
||||
$this->di['logger']->withChannel('routing')->error(
|
||||
'Template rendering failed for "{template}" (page "{page}").',
|
||||
[
|
||||
'template' => $tpl,
|
||||
'page' => (string) $page,
|
||||
'exception_class' => $e::class,
|
||||
]
|
||||
);
|
||||
|
||||
$internal = new FOSSBilling\InformationException('The requested page could not be rendered.', [], 500);
|
||||
|
||||
@@ -95,7 +97,7 @@ class Box_AppClient extends Box_App
|
||||
}
|
||||
$e = new FOSSBilling\InformationException('Page :url not found', [':url' => $this->url], 404);
|
||||
|
||||
$this->di['logger']->setChannel('routing')->info($e->getMessage());
|
||||
$this->di['logger']->withChannel('routing')->info($e->getMessage());
|
||||
|
||||
return $this->errorResponse($e, 404);
|
||||
}
|
||||
@@ -108,13 +110,15 @@ class Box_AppClient extends Box_App
|
||||
{
|
||||
try {
|
||||
$template = $this->getTwig()->load(Path::changeExtension($fileName, $ext));
|
||||
|
||||
return $template->render($variableArray);
|
||||
} catch (Twig\Error\LoaderError $e) {
|
||||
$this->di['logger']->setChannel('routing')->info($e->getMessage());
|
||||
$this->di['logger']->withChannel('routing')->info($e->getMessage());
|
||||
|
||||
throw new FOSSBilling\InformationException('Page not found', null, 404);
|
||||
} catch (RuntimeException $e) {
|
||||
$this->convertCacheWriteFailure($e);
|
||||
}
|
||||
|
||||
return $template->render($variableArray);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,8 +24,8 @@ class Box_Authorization
|
||||
return false;
|
||||
}
|
||||
|
||||
$client = $this->di['db']->load('Client', $clientId);
|
||||
if (!$client || $client->status !== Model_Client::ACTIVE) {
|
||||
$client = $this->di['em']->getRepository(Box\Mod\Client\Entity\Client::class)->find($clientId);
|
||||
if (!$client instanceof Box\Mod\Client\Entity\Client || $client->getStatus() !== Box\Mod\Client\Entity\Client::ACTIVE) {
|
||||
$this->session->delete('client_id');
|
||||
|
||||
return false;
|
||||
@@ -41,8 +41,8 @@ class Box_Authorization
|
||||
return false;
|
||||
}
|
||||
|
||||
$adminModel = $this->di['db']->load('Admin', $admin['id']);
|
||||
if (!$adminModel || $adminModel->status !== Model_Admin::STATUS_ACTIVE || $adminModel->isCron()) {
|
||||
$adminModel = $this->di['em']->getRepository(Box\Mod\Staff\Entity\Admin::class)->find($admin['id']);
|
||||
if (!$adminModel instanceof Box\Mod\Staff\Entity\Admin || $adminModel->getStatus() !== Box\Mod\Staff\Entity\Admin::STATUS_ACTIVE || $adminModel->isCron()) {
|
||||
$this->session->delete('admin');
|
||||
|
||||
return false;
|
||||
@@ -59,11 +59,16 @@ class Box_Authorization
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->di['password']->verify($plainTextPassword, $user->pass)) {
|
||||
if ($this->di['password']->needsRehash($user->pass)) {
|
||||
$user->pass = $this->di['password']->hashIt($plainTextPassword);
|
||||
$user->updated_at = date('Y-m-d H:i:s');
|
||||
$this->di['db']->store($user);
|
||||
if (!$user instanceof Box\Mod\Client\Entity\Client && !$user instanceof Box\Mod\Staff\Entity\Admin) {
|
||||
throw new RuntimeException('Unknown user type');
|
||||
}
|
||||
|
||||
$pass = $user->getPass();
|
||||
if ($this->di['password']->verify($plainTextPassword, $pass)) {
|
||||
if ($this->di['password']->needsRehash($pass)) {
|
||||
$user->setPass($this->di['password']->hashIt($plainTextPassword));
|
||||
$this->di['em']->persist($user);
|
||||
$this->di['em']->flush();
|
||||
}
|
||||
|
||||
return $user;
|
||||
|
||||
Loaded 100 of 979 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user