Compare commits

...

2 Commits

Author SHA1 Message Date
jric11 b851712968 updates from chat
Security and workflow regressions / regression (push) Has been cancelled
2026-10-21 20:41:57 -04:00
jric11 acc8cb70f5 ChatGPT's update to harden software and run db updates. 2026-10-21 06:53:45 -04:00
178 changed files with 9745 additions and 411 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- run: sudo apt-get update && sudo apt-get install -y mariadb-client - run: sudo apt-get update && sudo apt-get install -y mariadb-client
- name: Install isolated test browser - name: Install isolated test browser
run: | run: |
npm install --prefix "$RUNNER_TEMP/hcr-browser" --ignore-scripts playwright@1.62.1 npm install --prefix "$RUNNER_TEMP/hcr-browser" --ignore-scripts playwright@1.62.1 jsqr@1.4.0
"$RUNNER_TEMP/hcr-browser/node_modules/.bin/playwright" install --with-deps chromium "$RUNNER_TEMP/hcr-browser/node_modules/.bin/playwright" install --with-deps chromium
echo "NODE_PATH=$RUNNER_TEMP/hcr-browser/node_modules" >> "$GITHUB_ENV" echo "NODE_PATH=$RUNNER_TEMP/hcr-browser/node_modules" >> "$GITHUB_ENV"
echo "HCR_TEST_CHROME=$(node -p "require('$RUNNER_TEMP/hcr-browser/node_modules/playwright').chromium.executablePath()")" >> "$GITHUB_ENV" echo "HCR_TEST_CHROME=$(node -p "require('$RUNNER_TEMP/hcr-browser/node_modules/playwright').chromium.executablePath()")" >> "$GITHUB_ENV"
+51
View File
@@ -0,0 +1,51 @@
# Club summary action dialogs
Club add/edit forms now open in Bootstrap modals. The summary shows club details,
tables, history and action buttons; it no longer displays editing fields inline.
Dialogs cover club editing/archiving, membership edits, existing-account
assignment, invitations, invitation revocation, new seasons, recording dues and
payments, dues corrections and payment reversals. The Invite members header
action opens its dialog directly. Existing-record dialogs identify the selected
member, dues or payment row. Separate club creation and ticket pages retain their
existing workflows.
`classes/ClubFormModals.php` transforms only the club forms the server has already
authorized for the viewer. It moves dialogs outside the tables, supplies unique
IDs, labelled headings, close and cancel controls, and scrollable phone-friendly
layouts. Members receive no administrator forms or action dialogs.
`js/club-modals.js` submits these forms through the existing protected club
controller. Validation and permission errors appear inside the dialog without
clearing entered values. Saving disables action buttons and prevents dismissal
until the response returns; existing financial request keys are retained.
Successful saves reload the club summary and display its success notice. Cancel,
close and Escape dismiss an idle dialog without submitting changes. Bootstrap
provides focus containment and returns focus to the opening action.
`classes/Roster.php` renders the dialog layout and returns a JSON success result
only for the modal's explicitly marked AJAX requests. Existing ordinary POST
flows retain their redirects. All existing CSRF, tenant access, recent MFA,
validation, audit and financial safeguards continue to run on the server.
Browser regressions exercise the real dialogs rather than hidden forms,
including cancellation, validation failures, membership changes, invitation
delivery feedback, seasons, dues, payments, corrections and reversals. Release
packaging checks require both new files. No new dependency or database migration
is required.
Validation passed 262 targeted checks: 99 project HTTP, 19 onboarding HTTP,
33 general browser, 19 tenant browser, 16 deployment tooling, 24 MFA/session HTTP,
41 security/capacity service and 11 paging/export HTTP checks. Source validation
passed for 66 PHP files, 58 JavaScript files and 39 XSL stylesheets. Browser tests
also verify club edits, cancellation of invitation revocation, phone dialog
width, first-field focus and focus restoration after Escape.
The three application files were deployed to development and their hashes
verified. A protected temporary runtime check confirmed server-side dialog
rendering, and the new script was fetched successfully; the probe was removed.
Prior files were saved under
`.tools/deployment-backups/club-modals-07cbd8d9f91242e19f5501609489e35a`.
No existing development database records were changed. Tests used disposable
local databases and generated accounts with external email disabled; the local
test database server was stopped after cleanup.
+74
View File
@@ -0,0 +1,74 @@
# Club summary and new-club signup
The club page follows the flow of `xsl/userSummary.xsl`: identity and status at
the top, a compact action row, then clearly headed tables and supporting details.
The reference stylesheet itself is unchanged.
## Club page
- The selected club name is the page heading, followed by its status and the
viewer's role. Ticket, membership, dues and invitation actions appear together.
- A Switch club action appears when more than one club is accessible. The
single-club redirect and responsive club cards introduced previously remain.
- Membership, invitations, seasons, dues, payments and correction history follow
in order, using the existing permissions and bounded paging.
- Members see their own membership, dues and financial details. Administrative
forms remain restricted to administrators of that particular club.
- The Create club form is removed from the club summary. Super Administrators
retain a separate Create club page reached through their navigation. Club
administrators cannot access that page or its creation action.
- Existing club editing and archiving remain available on the club summary.
- Responsive table containers now contain their hidden accessibility labels,
preventing those labels from creating document-wide scrolling on phones while
preserving screen-reader names for the membership controls.
## Signup
- Public signup now asks for a new club name. A successful submission creates
the account, independent club, administrator membership and mandatory audit
entry in one transaction. Any failure rolls back all four.
- The account retains the global User role. Administration belongs to the new
club membership; posted roles and existing club IDs cannot promote the account
or attach it to another club. Reusing a club name creates a separate tenant.
- Email verification is required before the new owner can access or administer
the club. The existing MFA gate then requires authenticator enrollment before
administrator access. Membership creation does not verify the email.
- An invited-member signup link remains available, without a new-club field.
It requires a current invitation to the submitted email from an authorized
inviter. It creates only an account; email verification and explicit invitation
acceptance are still required before membership is granted.
- Invitation emails link directly to this member signup option. Existing users
continue signing in and accepting invitations as before.
- Existing signup rate limits, CSRF checks, password validation and uniqueness
protections remain in effect. Password fields are never repopulated on error.
## Files and verification
Application changes are in `classes/Roster.php`, `classes/AuthService.php`,
`classes/SignUp.php`, `classes/ClubInvitations.php`, `classes/Base.php`,
`xsl/content.xsl` and `xsl/createAccount.xsl`. No new database schema or migration
is required; the existing tenant, verification and MFA migrations must be present.
New onboarding HTTP coverage checks transactional rollback, validation, duplicate
submission, isolation of identically named clubs, unverified access denial,
mandatory administrator MFA and invitation restrictions. Browser coverage checks
the summary header/actions, separate creation page, phone layout, new-club signup
and invited-member signup. Existing authentication and roster tests were updated
to exercise the corresponding signup and creation paths. These tests use only
disposable local databases and generated accounts; external email is disabled.
The existing MFA replay regression now reuses the exact code submitted during
confirmation, so crossing a 30-second boundary cannot make a fresh code look
like a failed replay test. Application MFA behavior was not changed in this work.
Completed regression coverage passed 772 checks and 12 concurrency scenarios,
including 19 new onboarding HTTP checks and 18 tenant browser checks. Source
validation passed for 65 PHP files, 57 JavaScript files and 39 XSL stylesheets.
The seven application files were deployed to ositsdevels and their hashes
verified. Live checks confirmed the new creation method, existing tenant schema,
new-club signup form and invited-member signup form. A protected temporary
runtime probe was removed after verification. Previous application files were
saved under `.tools/deployment-backups/club-summary-083cfeb77c414c7297fd269ebb7cefa4`.
No existing development database records were changed, no migration was run,
and no external test email was sent. Disposable test databases were removed and
the isolated local database server was stopped afterward.
+106
View File
@@ -0,0 +1,106 @@
# Maintenance and backup repairs — October 20, 2026
This follow-up addresses the three code findings in
[the production review](PRODUCTION-REVIEW-2026-10-20.md).
No schema migration is needed. Tests use disposable local databases, not the
development or production database. Deployment and production-host acceptance
work in that review still applies.
## Editor replacement
- Removed the CKEditor 24 bundle and its references from all four alert/popover
add/edit forms. Replaced it with locally hosted **SunEditor 3.3.3**.
- Current CKEditor requires a commercial license key or GPL-compatible usage;
neither was established for this project. The review allowed a maintained
replacement. SunEditor uses the MIT license, included verbatim in
`THIRD-PARTY-NOTICES.md`. No commercial key or runtime CDN is required.
- Kept headings, bold, italic, underline, links, lists, font family/size/color,
indentation, alignment, horizontal rules, tables, undo and redo. Upload and
remote media tools are not enabled.
- Added the shared `js/rich-text.js` adapter. Save buttons stay disabled until
initialization completes. Initialization failure or timeout produces a visible
message. Saving copies editor HTML into the original form field; an empty
editor remains an empty optional field.
- Added versioned JavaScript/CSS, archive integrity and individual asset hashes
to `browser-dependencies.json`. Source checks verify the installed asset hashes
and removal of the old bundle. The release builder includes the replacement,
its license and dependency metadata. `Base::VERSION` changed to `1.01` to refresh
cached form scripts.
- Browser coverage exercises actual add/save/reopen/edit flows for both forms,
including legacy table markup, fonts, lists, links and text formatting; formatted
paste; filtering scripts, event attributes and unsafe links; failed asset
loading; and the existing content security policy.
- SunEditor 3 targets modern browsers (Chrome/Edge 119+, Firefox 125+,
Safari 17.2+). Unusual legacy HTML may be normalized by its content model;
arbitrary HTML is not promised to round-trip byte for byte. Existing server
output protections remain necessary; client filtering is not a server trust
boundary.
Upstream sources:
[SunEditor release](https://github.com/JiHong88/suneditor/releases/tag/v3.3.3),
[installation and license](https://github.com/JiHong88/suneditor),
[CKEditor licensing](https://ckeditor.com/docs/ckeditor5/latest/getting-started/installation/self-hosted/quick-start.html).
## Server-side maintenance validation
- Added `classes/MaintenanceValidation.php`, shared by create and edit handlers
in `Alerts.php` and `Popovers.php`.
- Alert titles and popover names/titles are required and limited to 100 Unicode
characters. Optional popover placeholders allow 100 characters. Optional alert
messages and popover HTML allow 1,000 characters, matching existing columns.
- Rejects malformed text, null bytes, and control characters in single-line
fields. Required fields reject Unicode whitespace-only values. Limits count
characters rather than UTF-8 bytes.
- Alert dates and times must be present and use exact `YYYY-MM-DD` and `HH:MM`
formats. Rejects invalid calendar dates, impossible times, nonexistent local
daylight-saving times, years outside the database range, and expiration before
the start. Equal start/expiration remains allowed.
- Invalid requests return HTTP 422 with a specific error before any write.
Title-only alerts and popovers without optional body text remain supported.
- Alert/popover lookup, update and deletion IDs now use strict integer validation.
The popover uniqueness lookup still allows its create form's zero exclusion ID.
- Added direct HTTP tests that verify both rejection and unchanged table
fingerprints. Fixed the isolated test fixture connection to use UTF-8 so
Unicode assertions read the stored text correctly.
## Backup memory usage and failure cleanup
- Added `classes/BackupValidation.php`. Completion validation reads at most the
final **8 KiB** of the dump instead of loading the whole file into PHP memory.
- Requires the completion comment at the end, rejects missing/truncated dumps,
and accepts Windows or Unix line endings. Existing process exit-status,
streaming checksum, atomic completion and protected output handling remain.
- Moved temporary output/credential creation into the cleanup-protected block
and handles credential-file creation failure safely.
- Added a 64 MiB file test under a **16 MiB PHP memory limit**, including checksum
generation and measured peak-memory assertion. Additional tests cover missing,
tiny and incomplete files and a completion comment before unfinished data.
- Deployment tests exercise a real scratch-database dump and a failed dump
process, checking nonzero failure status and removal of temporary credentials,
partial output and success metadata. Release tests verify the new files.
## Verification
The full regression entry point is `tests/run.py`; it now includes the new
maintenance HTTP and bounded-memory backup suites. It also runs source validation,
authentication, authorization, club/ledger, concurrency, browser and deployment
checks. The complete run passed **374 checks plus five concurrency scenarios**:
| Suite | Passed |
| --- | ---: |
| Authentication service/templates | 49 |
| Maintenance unit checks | 52 |
| Authentication HTTP | 27 |
| Project HTTP | 99 |
| New maintenance HTTP | 47 |
| Production security and ledger | 42 |
| Restricted database privileges | 5 |
| Browser flows | 28 |
| Backup validation | 9 |
| Deployment tools/release packaging | 16 |
Source validation also passed for 44 PHP files, 56 JavaScript files and 39 XSL
stylesheets, including jQuery and SunEditor integrity checks. The isolated
loopback database server was stopped after the disposable databases were removed.
No development database records, real passwords, production services or email
recipients were changed by this work.
+66
View File
@@ -0,0 +1,66 @@
# Administrator actions on member summaries
Follow-up: [dues corrections and payment reversals](MEMBER-CORRECTIONS-2026-10-21.md)
are also available directly from member summaries.
Administrators can now work directly from a member summary:
- Edit membership opens a prefilled dialog for membership status and join date.
Super Administrators can also change the club role. Club administrators cannot
edit protected administrator memberships or grant administrator roles.
- Record season dues opens a dialog tied to the selected member. Seasons are
drawn only from the selected club, with bounded paging for longer season lists.
Inactive memberships do not receive an add-dues action.
- Record payment appears beside each season with an outstanding balance. The
dialog is tied to that dues record and membership and supports amount, received
date, payment method and reference.
Dialogs identify the member, keep validation errors and entered values in place,
and use the existing cancellation and focus behavior. Successful saves return to
the same member summary, preserve its page selections, show a success notice and
refresh membership details and financial totals. All editing fields remain hidden
until an action is opened. Members retain their read-only summaries, and archived
clubs expose no write dialogs.
## Security and implementation
`MemberSummary.php` now handles an explicit allowlist of member, charge and
payment POST actions. It checks CSRF, club management permission, club status,
the selected membership and matching form identifiers before calling the existing
membership/ledger services. It returns a fixed local summary URL for successful
modal submissions; arbitrary return URLs are not accepted.
`Base.php` applies the existing recent-MFA requirement to membership edits on the
new endpoint. Existing server-side role restrictions, last-administrator
safeguards, audit requirements, duplicate-dues checks, payment idempotency and
overpayment checks remain active.
`LedgerService.php` accepts an optional membership constraint for payment writes.
The constraint is checked against the locked dues row inside the payment
transaction, before any write or idempotent success. A payment posted from one
member summary cannot target another member's dues, including within the same
club. Existing club-page callers retain their previous behavior.
`ClubFormModals.php` recognizes the member-summary form action and renders the
existing labelled Bootstrap dialogs. No new JavaScript dependency, database
schema change or migration is required.
Tests cover complete browser saves from all three dialogs, same-member redirects,
updated totals, privacy, CSRF, target tampering, audit rollback, archived clubs,
duplicate dues, foreign seasons, wrong-member payments, idempotency, overpayments,
administrator role restrictions and recent MFA. Test data is disposable and
external email is disabled.
Completed validation passed 195 targeted checks: 22 new action HTTP checks,
22 member-summary HTTP checks, 26 tenant browser checks, 33 general browser
checks, 24 MFA/session HTTP checks, 41 security/capacity service checks,
11 paging/export HTTP checks and 16 deployment tooling checks. Source validation
passed for 67 PHP files, 58 JavaScript files and 39 XSL stylesheets.
The four application files were deployed to development and their hashes
verified. A protected temporary probe confirmed the new summary forms, member
dialog rendering and membership-scoped payment API, then was removed. Prior
files were saved under
`.tools/deployment-backups/member-actions-7033d88c563c4b8ba6399f423cef1601`.
No existing development database records were changed. Disposable databases
were removed and the isolated local database server was stopped after testing.
+49
View File
@@ -0,0 +1,49 @@
# Member-summary dues corrections and payment reversals
Administrators can now open Correct dues beside a member's season dues and
Reverse incorrect payment beside an unreversed receipt on the member summary.
The correction dialog prefills the current effective dues total and requires a
reason. The reversal dialog identifies the member and receipt, requires a reason
and confirmation, and explains that reversal does not issue a refund.
Successful actions return to the same member summary and preserve paging.
Totals, balances, correction history and payment status refresh immediately.
Cancel does not change the ledger. Reversed receipts remain visible with their
reason and no longer offer a reversal button. Members and archived clubs retain
their existing read-only behavior.
`classes/MemberSummary.php` adds the two dialogs and the explicit `adjust` and
`reverse` POST actions. They use the existing CSRF and club-management checks and
the existing modal validation/error handling. The summary remains free of inline
editing fields.
`classes/LedgerService.php` adds optional membership constraints to corrections
and reversals. These are checked against the locked dues record inside each
transaction, before writes or idempotent success. Forged requests cannot correct
another member's dues or reverse another member's receipt, including within the
same club. Existing club-page callers remain compatible.
Corrections still cannot reduce dues below payments received. Both operations
append records and require a successful audit write in the same transaction.
Duplicate request keys remain idempotent; already-reversed payments reject a new
reversal request. No financial history is deleted and no external payment or
refund is initiated. No schema change, dependency or migration is required.
Regression coverage includes prefilled correction values, successful browser
correction/reversal, cancellation, updated totals, removal of reversal buttons,
wrong-member and foreign-club requests, member access restrictions, correction
limits, duplicate requests, audit rollback and preserved reasons. The history
paging test counts actual payment-table records separately from hidden dialog
context, which now also identifies the selected receipt.
Validation passed 186 targeted checks: 35 member-action HTTP, 22 member-summary
HTTP, 28 tenant browser, 33 general browser, 41 security/capacity service,
11 paging/export HTTP and 16 deployment tooling checks. Source validation passed
for 67 PHP files, 58 JavaScript files and 39 XSL stylesheets.
The two application files were deployed to development and hash verified. A
protected temporary runtime probe confirmed the membership-scoped correction
and reversal methods, then was removed. Prior files were saved under
`.tools/deployment-backups/member-corrections-7f0ba3ed47d24a3789f30bc116f8a8a8`.
No development database records were changed. Tests used disposable data with
external email disabled; the isolated database server was stopped after cleanup.
+14
View File
@@ -0,0 +1,14 @@
# Administrative note deletion — October 21, 2026
- Added a red Delete note action on each administrative note in the member summary. A confirmation modal shows the member and escaped note text, an irreversible-deletion warning, Confirm deletion, and Cancel.
- Deletion is limited on the server to administrators of the selected club and Super Administrators. It requires CSRF, matching club/membership context, and a matching note version. Archived clubs remain read-only.
- The service locks the club and selected private membership note, checks permissions again, and deletes only that note. A changed note requires reloading before deletion.
- Deletion and its mandatory audit event share one database transaction. Audit failure rolls back deletion. The journal retains the actor, timestamp, IP, club, membership and deleted note identifiers, without retaining private note text. Repeated deletion returns not found without another deletion event.
- Restricted the legacy global note deletion route to global User notes so it cannot bypass the private-note audit service.
- Successful deletion returns to the member summary’s administrative notes section. No schema migration is required.
Validation covers tenant and membership isolation, member restrictions, shared administrators, Super Administrator access, CSRF, wrong note types, stale confirmations, mandatory-audit rollback, duplicate requests, archived clubs, escaped previews, and browser Cancel/Confirm behavior. Regression suites cover existing notes, member actions, club browser flows, and deployment tooling.
Validation result: all 161 targeted HTTP, browser, and deployment checks passed, plus source validation of 68 PHP files, 58 JavaScript files and 39 XSL stylesheets. The deletion suite contains 17 checks; browser coverage includes Cancel and Confirm deletion.
Deployed four PHP files to development after saving previous copies under `.tools/deployment-backups/member-note-deletion-29e446be674741c5a06f6cfca23aa947`. Deployed hashes and a temporary protected server runtime probe passed; the probe was removed. No development database records were changed, and no migration was required. Disposable local databases were cleaned up and the local database server stopped.
+71
View File
@@ -0,0 +1,71 @@
# Private administrative notes on member summaries
Authorized administrators now see Administrative notes on the selected member
summary, with Add note and Edit note dialogs. Notes show their last update and
editor, preserve line breaks, and are displayed as escaped plain text. The server
accepts up to 2,000 Unicode characters. Successful saves return to the notes
section on the same member summary and reset notes paging so the updated note is
visible. Cancel does not submit changes.
## Visibility and data separation
- Only administrators of the selected club and Super System Administrators can
read, add or edit the notes. Administrators of a shared club can edit each
other's notes.
- Members receive no note text, section, metadata, identifiers, hashes or hidden
edit fields in their response, including on their own member summary.
- Being an administrator of one club does not grant administrative-note access
in another club where the person is only a member.
- Notes are attached to the specific club membership. A person belonging to
multiple clubs has separate administrative notes in each club.
- Existing global account notes remain separate and are not copied into club
summaries. Global user summaries do not include these private club notes.
- Archived club notes remain readable to authorized administrators, with writes
disabled. No new note deletion action is provided.
## Implementation and safeguards
`classes/ClubMemberNotes.php` uses the existing `notes` table with the reserved
type `ClubMember` and the membership ID as its reference. Every read/write checks
club administration and the exact club/membership relationship. Edits additionally
require the note ID to match that type and membership. Publicly posted note types
or global-user references are not trusted.
Writes recheck permissions inside a transaction, require CSRF and a mandatory
audit entry, and store the editor from the authenticated session. Audit entries
record IDs rather than private note text. Storage must use InnoDB; writes fail
closed otherwise. The development table was checked and already supports these
transactions, so no migration or grant change is required.
Edits include a hash of the previously loaded note. If another administrator has
changed it, the stale form receives a conflict and cannot silently overwrite the
new text. Audit failure rolls back both new notes and edits. Note history is
paged with the same scoped count and row query.
`MemberSummary.php` renders the administrator-only section and handles the two
explicit note actions. `ClubFormModals.php` adds their dialog labels and avoids
duplicating the full note text above its edit field. `js/club-modals.js` now forces
a refresh when the save return address differs only by a page anchor; previously
that navigation could leave stale content and an open dialog. `Base.php` bumps
the asset version to 1.05 so browsers receive the updated script.
Tests cover note creation/editing, shared administrators, ordinary members,
cross-club access, separation from account notes, direct forged requests, CSRF,
stale edits, audit rollback, Unicode limits, paging, archived clubs, modal cancel,
immediate refresh and absence of hidden private fields in member responses.
The full test runner includes the new HTTP suite and release checks require the
new service. Tests use disposable local data with external email disabled.
Completed validation passed 216 targeted checks: 27 private-note HTTP,
22 member-summary HTTP, 31 tenant browser, 33 general browser, 35 member-action
HTTP, 41 security/capacity service, 11 paging/export HTTP and 16 deployment
tooling checks. Source validation passed for 68 PHP files, 58 JavaScript files
and 39 XSL stylesheets.
Five application files were deployed to development and hash verified. Live
checks confirmed transactional note storage, the private service, note-dialog
rendering, asset version and updated script. Temporary protected probes were
removed. Prior files were saved under
`.tools/deployment-backups/member-notes-0869c36d56e74d51adea3d7350ef45d3`.
No development database records were changed by deployment, and the isolated
test database server was stopped after cleanup.
+59
View File
@@ -0,0 +1,59 @@
# Club member summaries
Follow-up: administrators now have member-specific action dialogs. See
[member summary actions](MEMBER-ACTIONS-2026-10-21.md). Members still have
read-only access; the original summary implementation is described below.
Roster names now link to a read-only summary for the selected club membership.
The page shows name, email, club, membership status and role, join date, total
dues, net payments and balance, followed by season dues, payment history and dues
correction history. Reversed payments remain visible with their reversal reason
but do not count toward payments received. Financial totals include every season
and remain unchanged when the user pages through individual history sections.
Each history section has bounded paging. Paging links retain the selected club
and membership. Back to club roster preserves the roster page from which the
member was opened. Members with no financial records receive explicit empty
states; no administrator edit dialogs are exposed on the summary.
Access is checked on the server for the exact club and membership pair:
- Super System Administrators can view memberships in every club.
- Club administrators can view members only in clubs they administer.
- Ordinary members can view only their own active club membership.
- A person who administers one club but is an ordinary member of another has
ordinary-member permissions in that other club.
- Membership IDs cannot be paired with another club, and members of multiple
clubs receive separate financial histories for each membership.
- Existing authentication, MFA and email-verification requirements remain in
effect. Opening the summary does not write to membership or ledger records.
Application files:
- `classes/MemberSummary.php`: new read-only summary page and complete totals.
- `classes/Roster.php`: clickable names with club, membership and roster page.
- `classes/RosterService.php`: authorized single-membership lookup and an optional
membership filter for dues queries.
- `classes/LedgerService.php`: optional authorized membership filter for payment
and correction histories; existing callers retain their previous behavior.
- `classes/Base.php`: explicit authenticated route and GET allowlist entry.
Tests cover corrected balances, reversed receipts, bounded and clamped paging,
safe text escaping, read-only requests, cross-club/member access, per-club roles,
MFA, clickable names, empty histories, phone layout and existing modal workflows.
The full test runner includes the new HTTP suite, and release packaging checks
require the new page class. No new dependency or database migration is required.
Completed validation passed 245 targeted checks: 22 member-summary HTTP,
99 project HTTP, 23 tenant browser, 33 general browser, 16 deployment tooling,
41 security/capacity service and 11 paging/export HTTP checks. Source validation
passed for 67 PHP files, 58 JavaScript files and 39 XSL stylesheets.
Five application files were deployed to development and their hashes verified.
A temporary protected runtime probe confirmed the page, membership lookup,
membership filters and existing tenant schema, then was removed. Previous files
were saved under
`.tools/deployment-backups/member-summary-700b9a0ae6b84b7e9a1cf1d77397ca64`.
No existing development database records were changed. Tests used disposable
local data with external email disabled; test databases were removed and the
isolated database server was stopped afterward.
+70
View File
@@ -0,0 +1,70 @@
# MFA lifecycle repairs — October 20, 2026
Repairs the two confirmed MFA findings in
[the readiness review](MULTI-TENANT-READINESS-2026-10-20.md).
## Authenticator replacement
Starting replacement requires the current password and an unused authenticator
or recovery code. The existing factor and all remaining recovery codes stay
active. A ten-minute replacement grant holds the new secret in the authorized
server-side session and binds it to the account version and existing factor.
The page offers a QR code, manual setup key and cancellation.
Confirmation requires the password again and a valid code from the new device.
One transaction locks the account and factor, checks authorization, replaces the
encrypted secret, generates ten fresh recovery codes, increments the session
version and writes the mandatory audit entry. Other sessions and old recovery
codes are revoked only when this transaction commits. The confirmation code is
recorded as consumed so it cannot immediately be replayed.
Cancellation, expiration, incorrect credentials and audit failure leave the
existing factor intact. Password resets preserve MFA and invalidate the pending
session. Competing confirmations cannot both succeed. Signing in clears any
previous pending replacement from that session.
## Password and code verification
MFA verification checks the current password, active account, session identity
and account version under the account lock before consuming an authenticator
time step or recovery code. Every mutation follows the account-then-factor lock
order, with consumption and mandatory audit committed together. Existing attempt
limits continue to apply. The verification API requires the password explicitly.
## Security notifications
Email notices use the account address read under the account lock. Events cover
initial enrollment, replacement requested, replacement completed and recovery
code use. Recovery-authorized replacement sends one combined request/recovery
notice. Notices identify the account and UTC time and explain what to do if the
activity was unauthorized. They contain no passwords, setup secrets, provisioning
links or recovery codes.
Delivery uses the existing SMTP configuration after the security transaction
commits. Failed or rolled-back actions send no success notice. A delivery failure
does not undo the security change: it produces a visible warning and a log entry
containing the account ID and event type. Delivery is best effort, without a new
queue or automatic retry; production SMTP/inbox delivery and failure monitoring
still need deployment acceptance.
## Validation and deployment
New regressions cover 24 HTTP lifecycle assertions, 24 notification assertions
and two concurrent database races. Existing MFA capacity and QR scanning tests
were updated for explicit replacement confirmation and password-required
verification. The release packaging test requires the notification class.
All tests use disposable local databases and generated accounts; notification
tests capture messages or deliberately disable SMTP. No real account password,
MFA factor or recovery codes are changed, and no test email is sent externally.
No database migration, encryption-key change or administrator re-enrollment is
required. Deploy `classes/MfaSecurityNotifications.php` before the updated
`MfaService.php`, `AuthService.php` and `Roster.php`.
Completed validation: the full suite passed 743 checks and 12 concurrency
scenarios. Source validation passed for 65 PHP files, 57 JavaScript files and
39 XSL stylesheets. The four application files were deployed to ositsdevels and
their hashes verified. A temporary protected runtime check confirmed the
password-required verification API, replacement methods, notification class and
working server-side encryption key; the probe was removed immediately afterward.
No development database records were changed.
+168
View File
@@ -0,0 +1,168 @@
# Club tenancy and account access
Implemented for the requested model. On October 20, 2026, the development
database was backed up and migrated, and `james.richie@onesourceits.com` was
promoted to **Super System Administrator**. Its existing password was preserved;
old sessions were revoked. See the
[development migration report](TENANT-MIGRATION-2026-10-20.md) for backup,
restoration and live verification results.
## Access model
| Capability | Super System Administrator | Club Administrator | User/member |
| --- | --- | --- | --- |
| View clubs | All | Assigned clubs | Active joined clubs |
| View member records | All | All members of managed clubs | Own membership only |
| Invite members | Any club | Managed clubs | No |
| Appoint club administrators | Yes | No | No |
| Create a new club | Yes | No | No |
| Edit/archive/reactivate club | Any club | Managed clubs | No |
| Seasons, dues, payments, corrections/reversals | Any club | Managed clubs | Read own dues/history only |
| Export roster/financial data | Any club | Managed clubs | No |
| Tickets | All clubs | All tickets in managed clubs | Submit/read/reply to own tickets |
| Global accounts, credentials, notes, settings, journal | Yes | No | No |
An administrator assigned to two clubs can manage both. An administrator in one
club who is an ordinary member of another gets only member access in that other
club. Administrators sharing a club see the same membership and invitation data,
regardless of who issued the invitation. An account can belong to several clubs;
deactivating membership in one club leaves its global account and other club
memberships intact.
Club administration is stored on `club_memberships.membership_role`, not inferred
from the old global `Administrator` or `System Administrator` label. Those old
labels do not confer cross-club access. The exact global
`Super System Administrator` role grants platform access. Only a current Super
System Administrator can assign global privileges or appoint club administrators.
The last active Super System Administrator and the last active administrator
membership in a club cannot be disabled/demoted through their respective controls.
Members get club name/seasons and their own membership, dues, payment and
correction history. Other members' contact information and private tickets are
not shown to them. Site alerts, dropdowns, popovers and account notes remain
platform-wide features, editable only by the Super System Administrator; they
are not private club content editors.
## Invitations
Club administrators use **Invite to this club** within their club workspace,
instead of the global Users directory or a username/email lookup of existing
accounts. This prevents searching another tenant's accounts or changing shared
login credentials. Your Super System Administrator retains global user controls
and can directly assign an existing verified account when provisioning a club.
An invitation records the club, submitted email, inviter, role and seven-day
expiration. It does not create an account, reset a password, or grant immediate
access. The recipient signs up or signs in using that email, verifies mailbox
ownership using the existing verification/reset process, opens **Invitations**,
and explicitly accepts. A verified, signed-in matching email is required; an
invitation ID alone is not a bearer credential. Consequently an invitation URL
does not need to contain a secret token.
Invitations cannot be accepted by another account, after expiry/revocation, or
after the inviter loses authority. Acceptance and revocation serialize on the
club record. Repeated acceptance does not duplicate membership. Only the Super
System Administrator can issue administrator invitations. Deactivating an
existing membership revokes its outstanding invitations for that club. Sending
again requires a one-minute wait; per-actor/club limits also apply.
Delivery uses the configured trusted HTTPS `ApplicationUrl`; the email explains
signup, verification and acceptance. Failed delivery leaves a visible warning
and a pending invitation that the recipient can find after signing in. An
administrator can retry sending to the same email after a minute. Development
email delivery is not claimed to work by these tests.
## Tickets
Members submit a subject and message from **Tickets** or **My tickets** inside
their club. Only the author, assigned club administrators and the Super System
Administrator can see that ticket or its messages. Members may reply to open
tickets; administrators can reply, close and reopen them. Archived clubs keep
their history but do not accept new tickets/replies. Subject/message limits are
200/8,000 Unicode characters. Text is escaped on display, requests require CSRF
protection, and submission/reply rates are limited.
Tickets and replies commit with mandatory audit records. Successful submissions
redirect to a read page, so refreshing does not resubmit the form. Ticket lists use 25-item pages with subject search; count queries and every page
retain the same club/author restrictions. Older authorized tickets are discoverable
through page navigation or search. This feature supports text discussions,
not attachments or email notifications.
## Migration and rollout
Use a protected maintenance window for the coordinated code/schema change:
1. Back up the target database and retain the matching application release.
2. Ensure migrations 001–003 are already applied and the designated existing
login `james.richie@onesourceits.com` is active. This migration does not create
an owner account or guess a replacement if that email is missing.
3. With migration credentials, set `HCR_SUPER_ADMIN_EMAIL` to
`james.richie@onesourceits.com` and run `php bin/migrate-tenants.php`.
Remove that environment variable afterward; it is a one-time explicit
bootstrap selection, not a runtime setting.
4. Deploy this version with all new files and the updated runtime grants in
`deploy/runtime-grants.sql.example`. Run `bin/check-health.php`.
5. Sign in again as the designated owner. Promotion revokes existing sessions and
outstanding reset/verification tokens. Club memberships are **not** promoted
automatically: open each club, assign its intended existing verified accounts
if needed, and set their **Club role** to **Administrator**. Alternatively,
invite an administrator and have them accept.
6. Verify each club administrator/member with separate sessions, then reopen
service. Test production inbox delivery separately.
The migration adds `membership_role` and three InnoDB tables:
`club_invitations`, `club_tickets`, `club_ticket_messages`. Existing memberships
default to `member`; clubs without an explicit administrator remain visible and
manageable by the Super System Administrator. No historical financial records or
memberships are removed. Club display names no longer need to be globally unique;
club IDs remain the tenant boundary. Rerunning the migration is safe and does not
re-promote an already promoted account or repeatedly revoke sessions.
Do not deploy the new application by itself against the old schema: health
checks will reject missing tenancy tables. If rollback is required after using
the new roles, restore the matching backed-up database and release together;
older code does not understand the new Super System Administrator role.
## Implementation and verification
- `TenantAccess.php` centralizes current database-backed platform/club permissions.
`Base.php` restricts global routes and passes the navigation capability to XSL.
- `RosterService.php`, `LedgerService.php` and `RosterReports.php` enforce the
selected club's access for reads, writes and downloads. Membership changes and
permission checks participate in club transactions. Account services enforce
Super System Administrator access and protect the final platform administrator.
- `ClubInvitations.php`, `ClubTickets.php` and the new Roster screens implement
the invitation and ticket flows. `xsl/content.xsl` shows scoped navigation.
- `migrations/004_tenants.sql`, `bin/migrate-tenants.php`, runtime grants, release
packaging, role options and application cache version (`1.02`) were updated.
- Added separate migration, service, HTTP and browser tenancy suites; expanded
concurrency coverage for invitation acceptance and the last club administrator.
Test fixtures use generated scratch databases, capture invitation mail or
force SMTP to a closed loopback port, and promote only a test account.
Verification passed: **497 checks plus seven concurrency scenarios**, including
the complete prior regression suite and the final owner-assignment browser check.
| Suite | Passed checks |
| --- | ---: |
| Existing authentication, maintenance, security, ledger, browser, backup and release suites | 374 |
| Tenant migration | 8 |
| Tenant services | 32 |
| Tenant HTTP access and isolation | 75 |
| Tenant browser workflows, including owner assignment | 8 |
Source checks passed for 49 PHP files, 56 application JavaScript files and 39 XSL
stylesheets. Concurrency scenarios cover defaults, payment limits, financial
retries, the final platform administrator, invitation acceptance and the final
club administrator. The disposable local test databases were removed and their
loopback server stopped. No development records, real recipients or live account
privileges were changed.
That regression run preceded the separately authorized development migration.
The subsequent migration and 61 development acceptance checks are documented in
the linked migration report; temporary verification data was removed afterward.
See [the tenant repair follow-up](TENANT-REPAIRS-2026-10-20.md) for the invitation
reactivation policy, bounded transaction retries and Unicode-aware ticket forms.
Mandatory administrator MFA and capacity controls require the [security/capacity migration and deployment steps](SECURITY-CAPACITY-2026-10-20.md). Apply them before running this release.
+118
View File
@@ -0,0 +1,118 @@
# Multi-tenant production readiness review — October 20, 2026
Follow-up: the two MFA defects below have been repaired. See
[MFA lifecycle repairs](MFA-REPAIRS-2026-10-20.md) for the implementation and
regression coverage. The findings below preserve the original review evidence.
Reviewed the current authentication/MFA lifecycle, tenant permissions and routes,
invitations, roster/ledger paging and exports, shared budgets, deployment tooling
and dependency state. No application code, server configuration or development
database records were changed by this review. Reproductions used disposable
local databases and generated accounts, with external email disabled.
## Confirmed defects, recommended repair order
### 1. P1 — MFA replacement temporarily permits password-only factor takeover
Location: `classes/MfaService.php:182`–`200`, particularly the deletion at
lines 188–191; enrollment checks at lines 107–141.
After verifying the current password and an existing authenticator/recovery code,
replacement immediately deletes the current factor and all recovery codes. The
replacement authenticator has not yet been configured or proved. The account
therefore returns to the initial-enrollment path for any new password login.
Reproduced through the real HTTP handlers with two independent cookie jars:
1. A temporary Super System Administrator enrolls MFA normally.
2. Its legitimate session starts authenticator replacement with valid credentials.
3. Before replacement is confirmed, a separate session supplies only the account
password. It has neither the old authenticator nor an unused recovery code.
4. That session receives a new setup secret, enrolls its own authenticator and
successfully opens the Super System Administrator account-management page.
This requires knowledge of the password and overlap with an authorized
replacement window. It does not bypass a stable enrolled factor outside that
window. Its impact is nevertheless platform-wide for the Super account.
Fix: retain the existing factor while a replacement is pending. Bind replacement
authorization and the new secret to the authorized session and account version,
then atomically swap the factor, regenerate recovery codes and revoke other
sessions only after confirmation of the new authenticator. New password-only
sessions must continue to face the existing MFA challenge. Abandonment, expiry,
concurrent replacements and password resets must not reopen initial enrollment.
### 2. P2 — Wrong password consumes a valid recovery code or authenticator step
Location: `classes/MfaService.php:177`–`181`; consumption in `verify()` at
lines 80–105.
The page computes `$passwordOk`, but invokes the mutating factor verifier before
testing that boolean. The verifier commits recovery-code deletion/counter
advancement and its audit before the page rejects the incorrect password.
Reproduced: an account has ten recovery codes; submitting one with an incorrect
password returns HTTP 403 and leaves nine codes. Submitting that same recovery
code with the correct password also returns HTTP 403. The valid recovery code
has been lost despite the failed authentication.
Fix: validate both credentials before committing factor consumption. Preserve
rate limiting for incorrect passwords as well as incorrect codes, and keep
consumption/auditing atomic. Verify rollback and concurrent use explicitly.
## Security recommendation before public launch
MFA enrollment, recovery use and replacement write audit records, but do not
notify the account owner through an independent channel. Add security notices
for factor changes/recovery, with visible or monitored delivery failures and
without putting secrets or recovery codes in email. This is a recommendation,
not another reproduced authorization bypass. It follows
[OWASP's factor-change notification guidance](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html).
## Production acceptance and operations still required
The previous development fixes are present: administrator MFA/QR setup, absolute
and idle timeouts, bounded lists/exports, and shared club budgets. The recent
server follow-up confirmed Apache key access and private-path denial on
ositsdevels. Development HTTP and its approved database connection are not new
production defects.
Before public onboarding:
- Preserve all required new/untracked classes, migrations, tests, assets and
dependency changes in a reviewed, versioned release; run CI on that exact
release. A current workspace build does not prove a clean checkout is complete.
- Run acceptance on the actual production HTTPS host: proxy trust, cookies and
headers, private paths, session/log permissions, and injected production secrets.
The development key-file override is deliberately unavailable in Production;
the production MFA key and a protected recovery copy must be provisioned. If
migrating existing enrolled accounts, preserve their key or perform an explicit
factor migration rather than silently changing it.
- Exercise complete tenant, MFA, invitation, reset and financial workflows with
the actual restricted production database account. Verify remote production
database TLS and separate migration/backup credentials.
- Verify real invitation, verification and reset inbox delivery, trusted links,
sender authentication and monitored delivery failures.
- Verify scheduled backups, cleanup and health checks, alert routing, log
rotation and storage monitoring. Test off-host restore of both SQL and MFA
key material. Document and exercise recovery of a single club without rolling
back unrelated clubs.
- Establish expected club sizes/concurrency and measure sustained workloads.
Bounded rows and request budgets improve resource control but do not establish
production throughput or response-time targets.
These are requirements to verify on the selected production infrastructure,
not claims that externally configured jobs or controls are absent.
## Evidence and limits
- Both MFA findings reproduced against actual service/HTTP behavior using
independent sessions and disposable data.
- No additional cross-club authorization bypass was demonstrated in this review;
this does not replace a full penetration test or independent security review.
- `composer audit --locked` reported no security vulnerability advisories.
- Did not rerun the entire regression suite, load-test production, send real
email, or modify real accounts. Existing happy-path replacement tests do not
cover the competing password-only session reproduced here.
- Private reproduction helper: `.tools/review-mfa-replacement.py`.
- Removed the disposable databases and stopped their loopback server after review.
+152
View File
@@ -0,0 +1,152 @@
# Multi-tenant production review — October 20, 2026
Follow-up: the four confirmed defects below are addressed in
[tenant invitation and ticket repairs](TENANT-REPAIRS-2026-10-20.md). The findings
describe the reviewed version. Administrator MFA, session lifetime and capacity
recommendations are implemented in [the security/capacity follow-up](SECURITY-CAPACITY-2026-10-20.md).
Production acceptance recommendations remain applicable.
The development tenant migration and owner promotion are complete. The earlier
61 development acceptance checks and the broader regression results remain
useful evidence, but they did not cover the edge cases reproduced below.
This review changed no application code or development database records.
Reproductions used generated disposable databases on a separate loopback server.
## Confirmed defects, in recommended repair order
### 1. P1 — Member invitation restores inactive administrator privileges
Location: `classes/ClubInvitations.php:49`; related guard in
`classes/RosterService.php:62`.
The invitation acceptance upsert always activates the membership and preserves
an existing `administrator` role. This conflicts with the rule that only the
Super System Administrator can reactivate/change administrator memberships.
Reproduced:
1. The Super System Administrator deactivates an administrator membership while
retaining its historical administrator role. A second administrator remains.
2. That other club administrator sends a normal **member** invitation to the
inactive administrator's email. No administrator invitation or Super approval
is involved.
3. The recipient accepts. The resulting membership is `active/administrator`,
and `TenantAccess::canManage()` returns true.
This restores management rights within that club; the reproduction did not
grant platform-wide privileges or access to an unrelated club. It is still a
confirmed bypass of the chosen administrator-assignment policy.
Fix: make reactivation/role assignment honor the newly authorized invitation
role. An inactive historical administrator must not regain administrative rights
through a member invitation. Require fresh Super approval for an administrator
grant, and test inactive, demoted, existing active and shared-account cases.
### 2. P2 — Invitation acceptance and membership edits use opposite lock order
Locations: `classes/ClubInvitations.php:42`–`43` and
`classes/RosterService.php:58`–`60`.
Acceptance locks the club and then the user; membership editing locks the user
and then the club. Controlled concurrent execution of the actual service methods
reproduced a MariaDB deadlock (error **1213**): acceptance failed with a
`PDOException` while the membership update succeeded. The services roll back the
failed transaction, but do not retry it; the user receives an application error.
Fix: establish a consistent lock order across invitation, membership and account
flows. Add bounded retry handling for retryable transaction failures, with the
authorization checks rerun inside the retried transaction. Extend concurrency
coverage to acceptance versus editing/revocation, rather than only two accepts.
### 3. P2 — Ticket character limits conflict with the global byte limit
Locations: `classes/Base.php:250`, `classes/ClubTickets.php:4` and `:18`/`:27`.
Tickets advertise/validate an 8,000 Unicode-character message, but the dispatcher
rejects any individual value exceeding 8,192 bytes before ticket validation runs.
A valid 3,000-character message consisting of `界` occupies 9,000 UTF-8 bytes and
was rejected with **HTTP 400 / Invalid request** through the real HTTP handler.
Fix: align field-specific character and byte limits and the HTTP form limits.
Retain request-shape and maximum-size protections; report field-specific errors
instead of rejecting supported text before the field validator can inspect it.
Cover both creation and replies with multibyte boundary tests.
### 4. P2 — Tickets beyond the newest 100 cannot be found through the UI
Locations: `classes/ClubTickets.php:8` and `classes/Roster.php::tickets()`.
The service applies a hard `LIMIT 100`; the page offers no older-page navigation
or search. With 101 owned tickets, the list returned 100 and excluded the oldest.
The older ticket remains in storage and an authorized direct link still works,
but members/administrators cannot discover it through the ticket list.
Fix: add stable server-side paging/search, keeping the club/author access filter
in every page and count query. Include tests for the second page, private-ticket
visibility and cross-club paging attempts.
## Administrator security before public launch
Authentication remains password-only (`classes/AuthService.php:37`), and sensitive
global account/role changes do not require recent authentication. The session
validator has an idle timeout, but no independently enforced absolute lifetime.
The Super System Administrator can access all tenants, so compromise of that
account has platform-wide impact.
I recommend MFA for the Super System Administrator and club administrators,
recent authentication for privilege/email/security changes, a bounded absolute
session lifetime, and a tested recovery process. This is a security-hardening
recommendation, not a newly demonstrated login bypass. OWASP recommends MFA for
[administrative/high-privilege users](https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html#quick-recommendations).
## Shared-service capacity and recovery
Roster members, dues, financial histories, invitation lists and several exports
still load whole collections with `fetchAll()`. Ticket messages are also unpaged.
Current invitation/ticket limits are chiefly per actor, not a total tenant budget.
No realistic large-club/load result was established by this review.
Before general onboarding, establish expected club sizes and concurrent use,
measure expensive pages/exports, add bounded paging/streaming where needed, and
set tenant-level resource limits so one busy club cannot consume shared capacity.
This follows the resource-isolation concerns in
[OWASP's multi-tenant guidance](https://cheatsheetseries.owasp.org/cheatsheets/Multi_Tenant_Security_Cheat_Sheet.html).
The full development backup was restored successfully during migration. A
production recovery plan should additionally explain how to recover one club's
records without rolling back unrelated clubs, and test that procedure.
## Production acceptance work still required
These are deployment checks, not assertions that external hosting configuration
is absent or that the approved development connection needs changing.
- Preserve a reviewed release containing the currently untracked tenant classes,
migration, tests and assets; run CI on that exact release.
- On the actual production host, verify HTTPS/proxy trust, secure session storage,
headers/cookies, secret injection and private-directory denial. The local PHP
router is not evidence that Apache/IIS/Nginx deployment rules are active.
- Run complete tenant/invitation/ticket/financial flows using the actual restricted
runtime database account, with separate migration and backup credentials and
verified TLS for a remote production database.
- Verify real invitation, signup-verification and reset inbox delivery, trusted
links, sender-domain authentication and delivery-failure monitoring. Recent
development acceptance deliberately sent no external messages.
- Install/verify backup, retention, cleanup and health schedules, off-host
protected recovery copies, alert routing and log rotation. Exercise failure
alerts for SMTP, mandatory audit writes, backup jobs and storage exhaustion.
- Explicitly assign the intended club administrators before onboarding members.
Development currently has one existing club and no real memberships; the
security tests correctly used owned temporary assignments.
## Review evidence and limits
- Confirmed the privilege restoration, opposite-lock-order deadlock, Unicode HTTP
rejection and 101-ticket list truncation in disposable local databases.
- Current `composer audit --locked` returned no advisories or abandoned packages.
This does not cover all vendored browser assets or establish that hosting is safe.
- Did not rerun the full regression suite, load-test production, send real email,
or modify development records. The new edge cases need permanent regression
coverage with their fixes.
- Removed the disposable databases and stopped their separate loopback server
after the review.
+122
View File
@@ -0,0 +1,122 @@
# Production readiness review — October 20, 2026
Follow-up: the three code findings below are addressed in
[maintenance and backup repairs](MAINTENANCE-REPAIRS.md). The findings describe
the state at review time; production-host acceptance work remains applicable.
The production-safeguards migration is applied to development. Reset invalidation,
verified-email access, financial corrections/reversals, and transactional audit
records are already implemented. Their earlier findings are not being reopened.
There are three remaining code fixes and production-host acceptance work. This
review changed no application code or development records. It used a disposable
database for focused validation checks, then removed it and stopped its server.
It did not rerun the complete regression suite or contact the future production
hostname. The current Composer audit reported no advisories or abandoned packages.
## Code fixes, in recommended order
### 1. Update the separate CKEditor dependency
`js/ckeditor.js:5` contains the version constant `24.0.0`. This editor is loaded by
the alert and popover forms. It was not replaced by the jQuery update and is not
included in browser-dependencies.json or its integrity checks.
The upstream advisory GHSA-3rh3-wfr4-76mj affects CKEditor packages through 26.0.0,
including engine, font, and list packages present in this build. It describes a
low-severity regular-expression denial of service that can freeze a browser tab.
This review did not demonstrate an application exploit or establish that every
newer advisory applies to this build.
Fix: rebuild/replace the editor with an appropriately licensed, patched version
or use a simpler maintained editor. Record all bundled browser libraries and
their provenance, extend advisory/integrity checks, and test paste, save, edit,
formatting, and CSP behavior. Passing editor-initialization tests alone does not
establish security coverage.
Primary source:
https://github.com/ckeditor/ckeditor5/security/advisories/GHSA-3rh3-wfr4-76mj
### 2. Finish server validation for older maintenance forms
`classes/Alerts.php:70–80` accepts title/message fields directly and converts
dates with strtotime without validating the ordering. The edit path at lines
163–174 repeats this pattern. Browser JavaScript checks some of these values,
but a direct authenticated request bypasses those checks.
Confirmed in a disposable database: posting a blank alert title and an expiration
before its start returned HTTP 200 and persisted both invalid values.
`classes/Popovers.php:52–56` and its edit path also lack equivalent server-side
required-field validation. Posting a blank popover name/title returned HTTP 200
and stored the blank name.
Fix: centralize required fields, length limits, strict date/time parsing and date
ordering for create/edit operations. Return field-specific validation errors and
test direct HTTP submissions. These routes require an administrator; this is a
data-integrity gap, not a demonstrated unauthenticated write vulnerability.
### 3. Make backup completion checking bounded in memory
`bin/backup-database.php:23` uses file_get_contents on the entire SQL dump merely
to locate the completion marker. Memory use grows with the database backup size.
Under a configured memory limit, a valid large dump can therefore cause the
backup job to fail during validation. Current tests exercise a small database.
Fix: inspect a bounded tail of the dump or stream it, retaining the existing
exit-code and checksum checks. Test large-file behavior and failed/interrupted
jobs, including temporary credential/partial-file cleanup and scheduler alerts.
The resource concern was identified from source; a large-file failure was not
reproduced during this review.
## Production acceptance work still required
These are checks on the selected production environment, not evidence of a public
leak or an instruction to change the approved development connection.
1. **Complete and preserve the release.** Essential new classes, migrations,
browser assets, deployment files, and the CI workflow are still untracked in
the working tree. Include them in the reviewed source-control release; a
GitHub checkout cannot run files that have not been committed. Run CI on the
actual repository and retain a versioned release and recovery plan.
2. **Validate the actual hosting configuration.** Configure production secrets,
HTTPS/proxy trust, secure cookies/headers, protected sessions/logs, and private
directory denial. Run the production check script against the real HTTPS
deployment when it exists. Apache/IIS/Nginx examples have not been exercised
on the selected production host.
3. **Verify production database transport and privileges.** Use verified TLS for
a remote production database and separate runtime/migration/backup roles.
Run complete workflows using the actual restricted runtime account, rather
than validating only connectivity or individual denied operations. The
development non-TLS approval does not establish this production configuration.
4. **Test real email delivery.** The most recent development acceptance run
captured links locally. Send actual verification, invitation, and reset emails
from production, confirm inbox delivery and links, and configure sender-domain
authentication and delivery-failure monitoring. Generated URLs and a prior
SMTP acceptance result do not prove the final inbox experience.
5. **Install operational jobs and alerts.** Backup/cleanup/health entry points
exist, but the repository does not prove their production schedules or alert
routing. Configure protected encrypted off-host backups, retention, restore
drills, log rotation, and monitoring for failed jobs, audit writes, SMTP,
database health, and disk space. Verify any existing external setup rather
than assuming it is absent.
6. **Test realistic volumes.** Roster, dues, history, and export code loads whole
collections. Test the expected club sizes and concurrent use. Introduce paging,
streaming, or background exports where measured limits require them; small
workflow tests do not establish large-club performance.
## Recommendations and product scope
- MFA and recent-password confirmation for sensitive administrator actions remain
recommended protections. They are not implemented, and this review does not
represent them as newly discovered authorization bypasses.
- Global administrators still manage all clubs. Delegated club administrators
are needed only if clubs will administer themselves.
- Active members still see fellow club members' email addresses. Confirm that
directory policy before onboarding real members.
- Online payment collection/refunds are outside the current manual-ledger scope;
their absence is not a defect in recording received payments and reversals.
Recommended next step: address the three code items, then complete one documented
production acceptance run. Additional features should be driven by the intended
operating model rather than treated as automatic launch blockers.
+34
View File
@@ -1,5 +1,26 @@
# Hunting Club Roster # Hunting Club Roster
See [private member notes](MEMBER-NOTES-2026-10-21.md) for club-specific
administrative notes and their add/edit dialogs.
See [member corrections and reversals](MEMBER-CORRECTIONS-2026-10-21.md) for the
financial correction dialogs on member summaries.
See [member summary actions](MEMBER-ACTIONS-2026-10-21.md) for administrator
membership edits, dues and payment dialogs directly on a member summary.
See [member summaries](MEMBER-SUMMARY-2026-10-20.md) for clickable roster names,
membership details and member-specific financial history.
See [club action modals](CLUB-MODALS-2026-10-20.md) for the cleaner summary page
and add/edit dialogs.
See [club summary and signup changes](CLUB-UI-AND-SIGNUP-2026-10-20.md) for the
club page layout, separate club creation page and new administrator signup flow.
See [MFA lifecycle repairs](MFA-REPAIRS-2026-10-20.md) for secure authenticator
replacement, password-before-code verification, security notices and tests.
See [authentication setup and changes](AUTHENTICATION.md) before deploying the See [authentication setup and changes](AUTHENTICATION.md) before deploying the
updated login, signup, and password reset flows. The authentication migration updated login, signup, and password reset flows. The authentication migration
must be applied to the existing database before using this version. must be applied to the existing database before using this version.
@@ -9,3 +30,16 @@ fixes, new roster migration, feature scope, and verification results.
See [production safeguard changes](PRODUCTION-FIXES.md) for the newest migration, See [production safeguard changes](PRODUCTION-FIXES.md) for the newest migration,
verified-email requirement, ledger corrections, and deployment instructions. verified-email requirement, ledger corrections, and deployment instructions.
See [maintenance and backup repairs](MAINTENANCE-REPAIRS.md) for the editor
replacement, server validation, bounded-memory backups, and regression coverage.
See [club tenancy and account access](MULTI-TENANCY.md) before deploying the
tenant migration, Super System Administrator role, club invitations and tickets.
See [tenant invitation and ticket repairs](TENANT-REPAIRS-2026-10-20.md) for the
invitation-role fix, transaction retries, Unicode limits, ticket paging and tests.
See [administrator security and capacity](SECURITY-CAPACITY-2026-10-20.md) for mandatory MFA, session expiration, bounded collections, shared club budgets, and the required new migration/key configuration.
See [administrative note deletion](MEMBER-NOTE-DELETION-2026-10-21.md) for confirmation dialogs, club permissions, and mandatory deletion auditing.
+152
View File
@@ -0,0 +1,152 @@
# Administrator security and shared capacity — October 20, 2026
Implements the first four remaining multi-tenant review recommendations. All
database verification uses disposable local databases. This work does not migrate
the development database or enroll any real account's authenticator.
## 1. Administrator MFA and recent authentication
- Added maintained `spomky-labs/otphp` 11.5.0 and its locked dependencies.
- Every authenticated request passes an MFA gate before application dispatch.
Super System Administrators and active club administrators must enroll and
complete MFA; an ordinary global User role does not bypass a club administrator
membership. Enrolled ordinary members must also complete their chosen MFA.
- Existing password login establishes the first factor only. Direct URLs and
AJAX requests cannot use administrator functionality before the gate completes.
- Setup requires the current password and a working authenticator code. The
setup page offers a locally generated QR code and a manual key for authenticator
apps using SHA-1, six digits and 30-second periods. The QR uses the same pending
secret, the application issuer and account email. A white quiet zone preserves
scan contrast on dark pages. Pending setup expires after ten minutes. No
third-party QR service receives the secret, and QR images are not saved to disk.
- Secrets use AES-256-GCM with a random nonce and account ID as authenticated
data. `HCR_MFA_KEY` is a separate, required 32-byte encryption key, encoded as
base64. Database records do not contain plaintext secrets.
- Verifies the current or adjacent time step, rejecting counters already used.
Verification locks the factor row. Ten random 128-bit recovery codes are
displayed once; only SHA-256 hashes are stored. Consumption and mandatory audit
commit together. Failed audit writes roll back consumption.
- Limits authentication attempts per account and IP. Successful MFA rotates the
session ID and CSRF token. Password login clears earlier MFA verification;
password resets preserve the factor and its replay counter.
- Account security is linked from the home page. Lost authenticator replacement
requires the password and an unused recovery/current authenticator code. It
retains the existing factor while the authorized session configures a new one.
Confirmation atomically replaces the factor, refreshes recovery codes and
revokes other sessions. See [MFA repairs](MFA-REPAIRS-2026-10-20.md).
- Global account/security changes and membership role/status changes require
password plus MFA confirmation within five minutes. The original write is
never replayed automatically after authentication; the user submits it again.
- MFA enrollment/replacement invalidates other account sessions. Session versions
are captured under lock, avoiding adoption of a concurrent password-reset
version. Recovery does not use email-only factor removal.
## 2. Absolute session lifetime
- `AuthService` records an authentication start timestamp at password login and
enforces an eight-hour maximum lifetime independently of the existing
30-minute idle timeout.
- Activity, MFA and recent-authentication confirmation do not extend that start
time. Legacy sessions without a start timestamp must sign in again.
## 3. Bounded collections and exports
- Added shared count/paging support using the same authorized SQL for both.
- Club navigation, roster members, seasons, dues, payment histories, corrections,
administrator invitation lists, pending invitations and ticket messages now
have independent 25-record pages with first/previous/next/last controls.
- Page numbers are validated and clamped. Links preserve the selected club and
other page/filter context. Personal member visibility applies to counts as
well as rows. Older records remain accessible through the pager.
- Dues-entry member/season selectors use their corresponding displayed pages;
navigate those pages to choose older seasons or additional members.
- Added a read-only, authorized report-catalog route for page navigation. Its
club list counts only clubs the administrator can manage.
- Roster CSV/Excel/PDF and financial CSV exports reject oversized collections
before loading them, with a default 1,000-row limit. Queries retain a second
hard bound so concurrent growth cannot create unbounded allocations.
`HCR_EXPORT_MAX_ROWS` accepts 1–5,000. Oversized exports return a clear error,
never a silently truncated file; a large-export workflow is not added here.
## 4. Shared club resource budgets
Each club has independent hourly counters for:
| Resource | Default per hour | Configuration |
|---|---:|---|
| Invitation email attempts | 100 | `HCR_CLUB_EMAIL_HOURLY` |
| New tickets | 100 | `HCR_CLUB_TICKET_HOURLY` |
| Ticket replies | 1,000 | `HCR_CLUB_REPLY_HOURLY` |
| Exports, across all formats | 20 | `HCR_CLUB_EXPORT_HOURLY` |
- Configured budgets accept 1–100,000; invalid configuration fails closed.
- Existing individual rate limits remain. Changing users or formats does not
reset the club counter. Another club has a separate budget.
- Atomic database increments return each connection's own assigned slot, so
simultaneous requests cannot exceed the configured budget.
- Permission checks precede consumption. Budgets count authorized attempts,
including failed delivery or later validation; they are deliberately not
refunded by transaction retries or failed writes.
- Counters occupy one row per club/resource and reset on the next attempt after
an hour, so request history does not grow without a bound.
## Deployment steps
1. Preserve a backup, then run `bin/migrate-security-capacity.php` with migration
credentials after the existing migrations. It adds `user_mfa`, `mfa_recovery`
and `club_resource_usage`; rerunning it preserves existing data.
2. Inject a unique random `HCR_MFA_KEY` through the application's secret store.
Generate 32 cryptographically random bytes and base64-encode them. Never use
the disposable test key. Keep a protected recovery copy separate from SQL
backups; changing or losing the key prevents decrypting existing factors.
Development can instead set `MfaKeyFile` in its private local settings to a
protected file outside the application directory. An explicit environment key
takes precedence. Production does not allow this development fallback.
3. Apply the new table grants in `deploy/runtime-grants.sql.example` to the
restricted runtime account. The health check now verifies these tables and,
in Production, the MFA key format.
4. Deploy the complete locked dependencies and updated source together. The
release builder includes the migration and new classes. Application version
is now 1.04.
5. Administrators sign in and enroll their own authenticator, saving their
recovery codes offline. Keep server clocks synchronized. If both the device
and all recovery codes are lost, recovery requires a trusted operator to
verify identity and perform an audited database recovery; there is no public
email-only bypass.
Production host acceptance, sender-domain/inbox checks, off-host recovery and
realistic sustained load testing remain separate deployment work. These changes
do not establish production hosting readiness by themselves.
## Verification
Regression verification covers **675 checks and ten concurrency scenarios**:
- Existing 584 authentication, maintenance, tenant isolation, financial,
browser, backup and deployment checks, plus nine existing concurrency races.
- 24 HTTP MFA/session checks, 41 service checks, 11 large-list/export HTTP checks
and 15 shared-budget HTTP checks.
- Twelve simultaneous requests from two administrators compete for five export
slots: exactly five succeed and seven receive the limit response.
- MFA checks include valid code verification, counter replay rejection,
single-use recovery, audit-failure rollback, account-bound encryption,
password-reset factor preservation, lost-device replacement, recent
authentication, promotion-triggered enrollment and both session timeouts.
- Source validation covers 60 PHP files, 57 application JavaScript files and
39 XSL stylesheets, plus bundled browser dependency integrity. Composer
installation reported no security advisories for the locked dependencies.
The existing regression suites and all new suites passed across the full run
and targeted follow-up runs. Test databases are disposable and no real email
is sent. The isolated local database server was stopped after verification.
The later [development migration and acceptance run](SECURITY-MIGRATION-2026-10-20.md)
verified the persistent key-file configuration with eight additional regression
checks and 68 acceptance checks against the backed-up development database.
The subsequent QR setup change passed ten browser checks using an independent
decoder, 24 existing MFA/security HTTP checks and 16 deployment checks. Browser
coverage includes scanning, enrollment using the scanned secret, phone-width
layout, stable pending setup details, replacement and absence of external QR
requests. QR rendering was also verified on ositsdevels without changing a real
account. No new application dependency or database migration was required.
+102
View File
@@ -0,0 +1,102 @@
# Development security/capacity migration — October 20, 2026
Completed against the approved development target,
`192.168.1.190/huntingclubroster`, using its previously approved non-TLS connection.
## Backup and migration
- Backup: `.tools/backups/20261020-190128/huntingclubroster.sql`.
- Size: 53,033 bytes; 23 existing tables.
- SHA-256: `e7301cc5429f57bf93e9f1627b6c77afc9d8678ea0757a56a445e597417e0570`.
- Verified the completion marker and checksum, restored the backup to an
isolated loopback database, and matched all 23 table row counts.
- Applied `bin/migrate-security-capacity.php` and reran it to confirm repeatability.
The database now has 26 tables, including `user_mfa`, `mfa_recovery` and
`club_resource_usage`.
- Compared every original table's row fingerprints before and after migration;
existing values were preserved.
## Persistent development MFA key
- Generated a cryptographically random 32-byte key outside the project, in
`C:\Users\JamesRichie\AppData\Local\HuntingClubRoster\secrets\development-mfa.key`.
- Configured its path in ignored `xml/settings.local.xml`. Development servers
can read this persistent key without inheriting a temporary shell variable.
No key value is stored in the repository or this report.
- The key has protected Windows permissions allowing only the current Windows
account and SYSTEM. A protected recovery copy was saved under the same
external secrets folder's `recovery` subdirectory. This is a local recovery
copy, not an off-host backup.
- Added a development-only key-file loader. It rejects missing, oversized,
malformed and in-project key files. Production continues to require its
explicitly injected environment key.
- Eight key-configuration regression checks passed and are included in the test
runner.
## Development acceptance
All **68 development acceptance checks passed**, using owned temporary clubs,
accounts and records:
- Super System Administrator and club administrator authenticator enrollment;
administrator URLs and AJAX denied before MFA; valid authenticator acceptance;
encrypted secrets; recovery-code consumption and rejection of reused codes.
- Password login still requires the enrolled factor. Ordinary members keep their
normal login flow. Sensitive changes require recent password/MFA confirmation.
- Independent eight-hour absolute and 30-minute idle session expiration, tested
by aging only private test sessions rather than changing server time or waiting.
- Last-page access for members, seasons, dues, payments, corrections and
invitations; paged ticket conversations; invalid page handling; personal
counts and cross-club access denial.
- Shared ticket, reply, invitation email and export budgets across different
users, independent budgets for another club, and hourly-window reset.
- Oversized CSV, Excel, PDF and financial exports rejected without truncation.
The verification servers temporarily used lower limits to exercise boundaries;
normal development defaults were not changed. SMTP was redirected to an unused
loopback port, so no real email was sent. The MFA key was loaded from the actual
protected development key file, not a disposable test key.
All temporary accounts, factors, recovery codes, clubs, ledger rows, tickets,
audits, counters and private sessions were removed. Row fingerprints for all
26 tables then matched their pre-test state. Real account passwords, roles,
email verification and MFA enrollment were unchanged.
Detailed private evidence is retained beside the backup in
`security-migration-result.json`, `security-acceptance-result.json` and
`security-cleanup-result.json`.
The final development health check passed. Source validation passed for 61 PHP
files, 57 application JavaScript files and 39 XSL stylesheets, including browser
dependency integrity. The local backup-restoration copy was removed and its
isolated database server stopped.
The existing Super System Administrator will be prompted to enroll an
authenticator at the next sign-in; its owner must save the recovery codes.
No real account was enrolled on the owner's behalf.
## Actual development web-server follow-up
The browser application runs on `ositsdevels` under Rocky Linux Apache/PHP-FPM,
not on the Windows workstation used for the original acceptance server. Its
copied Windows key path caused login to fail before displaying MFA setup.
The owner ran the prepared administrator installer on that server. It installed
the unchanged key at `/etc/httpd/huntingclubroster/development-mfa.key`, restricted
its permissions to root and the Apache group, restored its SELinux context, and
configured the application's FastCGI requests with `HCR_MFA_KEY_FILE`. This
development-only override prevents workstation settings from replacing the
server's key path. Nine key-configuration regression checks passed.
The installer also enabled the app's existing private-directory rules, validated
Apache configuration and reloaded Apache. Its staged key was outside the web
root in the server home directory and was removed after installation.
Verified through the actual Apache/PHP-FPM server after installation:
- Configured MFA key is readable and can encrypt a secret.
- Authenticator library can generate and verify a code entirely in memory.
- Login page returns HTTP 200.
- `.tools/`, local settings, classes and vendor endpoints return HTTP 403.
- The staged key is removed. All temporary token-protected diagnostics were
immediately removed, and no real account was enrolled or changed.
+87
View File
@@ -0,0 +1,87 @@
# Development tenant migration — October 20, 2026
Completed against the authorized development target
`192.168.1.190/huntingclubroster`, using the previously approved unencrypted
development connection. No production service was changed.
## Backup and restoration
- Backup: `.tools/backups/20261020-173710/huntingclubroster.sql`.
- Full dump with routines, events, triggers, 20 existing tables and a completion
footer; size **48,133 bytes**. Temporary connection credentials were removed.
- SHA-256:
`4107de994758039a107ee449120463d3f2b21d8a7da705355fa940d6bf834ed8`.
- Restored on the separate loopback test database server before migration;
all 20 table row counts matched the backup manifest. The local restoration
copy was subsequently removed and that server stopped.
- Backup validation uses the bounded-tail validator. Backup files remain in the
ignored, web-denied `.tools` directory; the verification confirmed that the
private control file was inaccessible over HTTP.
## Migration and owner promotion
- Applied `bin/migrate-tenants.php` and `migrations/004_tenants.sql`.
- Added `membership_role` and the invitations, tickets and ticket-message tables;
replaced the global unique club-name index with a normal index.
- Explicitly promoted **james.richie@onesourceits.com** to
**Super System Administrator**.
- Preserved its existing password and email-verification state. Incremented the
authentication version to revoke old sessions, invalidated the designated
account's outstanding reset/verification tokens, and added one promotion audit
entry. The one-time promotion environment variable was removed afterward.
- Compared all original tables and original-column values, allowing only those
intended owner changes and the new audit entry. Original clubs, other accounts,
memberships and financial data were preserved.
- No existing club-administrator assignments were guessed. The existing club
had no memberships before the migration and still has none; it is available
to the Super System Administrator for explicit setup.
## Verification on the migrated development database
**61 checks passed** using real HTTP handlers on a temporary loopback web server
connected to the development database. Separate cookie/session clients signed
in with seven randomly generated temporary accounts across two owned test clubs.
No real account password was requested or changed, and no external email was sent.
Verified:
- Club administrators see all members/invitations of their shared club and cannot
view or modify another tenant through changed URLs, IDs or direct submissions.
- Global user searches, account edits/deletion, password-reset requests, notes and
the journal are denied to club administrators, including attempts against the
real owner account.
- Roster and financial exports are restricted to managed clubs, including CSV,
Excel, PDF and financial-history endpoints. Downloads contain only that club's
member data.
- An administrator in one club who is an ordinary member of another gets only
ordinary member access in the second club.
- Members see their own membership and dues, cannot see other members' contact
details, and cannot record payments or access an unassigned club.
- A foreign dues ID cannot be paired with an authorized club. Recording a valid
payment updates only the intended member's balance.
- Administrator invitations cannot be forged; matching verified accounts can
accept member invitations, and other accounts cannot steal them by ID.
- Members can submit and read their own private tickets; other members and other
tenants cannot read them. Assigned administrators can respond and close tickets.
Ticket text is escaped when displayed.
- Revoking a club administrator assignment removes management privileges on the
next request in the existing session.
- Private verification credentials cannot be downloaded through the web server.
Afterward, removed only the owned temporary clubs, accounts, memberships, dues,
payment, invitation, ticket/messages, tagged audit entries and exact test rate
limit records. Fingerprints across **all 23 tables** matched the post-migration,
pre-test baseline before cleanup committed. The owner promotion and its audit
record remain intact.
Final health check passed. The development database contains its original
**2 users, 1 club and 0 memberships**. The promoted owner can see the existing
club. Temporary sessions and web server were removed/stopped.
Protected evidence is retained beside the backup:
`manifest.json`, `pre-tenant-snapshot.json`, `tenant-migration-result.json`,
`tenant-acceptance-result.json`, `tenant-cleanup-result.json`, and
`tenant-final-state.json`. The HTTP log is retained there as well.
Sign in again using the owner's existing credentials, then assign the intended
club administrators explicitly as described in [MULTI-TENANCY.md](MULTI-TENANCY.md).
+122
View File
@@ -0,0 +1,122 @@
# Tenant invitation and ticket repairs — October 20, 2026
Addresses the four confirmed defects in
[the multi-tenant production review](MULTI-TENANT-REVIEW-2026-10-20.md), in its
recommended order. No schema migration or development database change is needed.
All verification uses generated disposable local databases; no real email is sent.
## 1. Administrator invitation bypass
`ClubInvitations::accept()` now distinguishes existing **active** administrator
memberships from inactive historical roles. A member invitation can reactivate an
inactive membership only as a member; it cannot restore the historical
administrator role. A fresh administrator invitation still requires a current
Super System Administrator grant. Already active administrators keep their role
when accepting a member invitation, preventing accidental loss of the last club
administrator.
`RosterService::saveMember()` revokes outstanding invitations when the membership
role changes as well as when membership is made inactive. An earlier administrator
invitation therefore cannot undo a later explicit demotion. These changes are
scoped to the selected membership and do not change a shared account's other clubs.
Regression cases cover rejoining as a member, Super-approved restoration,
preserving active administrators, invitation invalidation on promotion/demotion
or deactivation, and unaffected access to another club.
## 2. Concurrent transactions
Invitation acceptance now locks the recipient user before the club, matching
membership updates and account operations that lock user records first.
New `classes/DatabaseTransaction.php` provides up to **three complete transaction
attempts** for database deadlocks, serialization failures and lock-wait timeouts.
Every failed attempt rolls back before a short randomized backoff and retry.
Other database errors and business/permission errors are propagated immediately.
Exhausted retries propagate the failure after rollback; retries are bounded.
Applied the helper to:
- Invitation creation, acceptance and revocation.
- Club creation/updates, membership updates and season creation.
- Ledger charges, payments, corrections and reversals.
- Global account edits, activation/deactivation and deletion.
- Ticket creation and replies.
Permission checks inside the transaction rerun on retry. Administrator invitation
issuance also rechecks Super privilege inside each attempt. Email delivery and
rate-limit accounting remain outside the retry closure, so retrying database work
does not repeat those external effects. Mandatory audits commit with their writes.
Tests inject retryable failures after database writes, verify exactly one
committed write, cover exhausted attempts and nonretryable permission failures,
and revoke authority between attempts to confirm the retry cannot commit.
Controlled concurrent real service calls cover acceptance versus activation and
acceptance versus deactivation. Existing financial and final-administrator races
remain part of the full suite.
## 3. Unicode ticket validation
- Ticket subjects and bodies use server-owned limits of **200** and **8,000**
Unicode characters, with specific required/invalid/over-limit errors.
- The dispatcher permits larger message transport only for POST ticket creation
and reply actions. That field retains a hard **65,536-byte** ceiling; all other
fields retain the existing 8,192-byte ceiling. The field validator then enforces
the actual 8,000-character limit before storage.
- Query and form values are checked separately. A valid POST value cannot hide an
array-shaped query value with the same name.
- Added `js/tickets.js`, which validates Unicode code-point length rather than
JavaScript UTF-16 units. HTML maxlength permits the required two UTF-16 units
per character; client validation and server validation enforce the character
limit. The same handling applies to subjects, replies and ticket search.
- HTTP tests preserve 3,000 CJK characters and 8,000 four-byte emoji without
truncation, reject 8,001-character messages/replies without partial writes,
and verify malformed/extreme requests still fail. Browser tests cover native
controls, meaningful validation messages and full Unicode submission.
## 4. Ticket pages and search
- Replaced the newest-100-only list with **25-ticket pages**, newest serial first,
and first/previous/next/last navigation.
- Added subject search with a 100-character limit. Search treats SQL wildcard
characters `%`, `_` and `!` literally and uses bound parameters.
- Both the count and row query apply the club and author/administrator scope.
Members see only their personal results/counts; club administrators see only
their managed club's tickets. Direct ticket access retains its own permission
check.
- Validates page numbers, clamps requests beyond the last page, escapes subjects
and search text, and provides clear no-match and page-count displays.
- Opening a ticket preserves list context. Reply redirects retain page/search
state; a newly created ticket returns to an unfiltered first page. Successful
submissions still redirect to GET, avoiding resubmission on refresh.
- Tests use 102 personal tickets, a private ticket from another member and a ticket
in another club. They verify complete nonduplicated traversal, old-ticket search,
literal wildcard handling, escaped HTML, private counts, malformed/tampered
requests, old-ticket replies and the browser navigation controls.
## Other changed files
`Base::VERSION` is now `1.03` for browser cache refresh. Updated `tests/run.py`
to include all new repair suites, test fixtures/workers to create owned data and
control concurrency, and release-package tests to require the new transaction
helper and browser script. Updated the tenancy documentation and linked the
review to this follow-up. New classes/scripts are automatically included by the
existing release builder.
## Verification results
The complete regression suite passed: **584 checks and nine concurrency
scenarios**, including the existing authentication, tenant isolation, financial,
backup and deployment checks. New coverage includes nine invitation checks,
eight transaction retry checks, 18 Unicode HTTP checks, 44 ticket paging HTTP
checks and eight ticket browser checks. The nine concurrency scenarios include
two new invitation/membership races.
Source validation passed for 53 PHP files, 57 application JavaScript files and
39 XSL files, along with bundled editor and jQuery integrity checks. Tests used
disposable databases on the isolated local server; no development database
migration or development data changes were needed.
The remaining MFA,
capacity and production-host acceptance work in the review is outside these four
repairs; these changes do not assert that production hosting has been validated.
+29
View File
@@ -0,0 +1,29 @@
# SunEditor 3.3.3
Source: https://github.com/JiHong88/suneditor/releases/tag/v3.3.3
MIT License
Copyright (c) 2017-2025 Yi JiHong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## OTPHP and MFA dependencies
Authenticator codes use spomky-labs/otphp 11.5.0 (MIT). Locked supporting packages include paragonie/constant_time_encoding (MIT), psr/clock (MIT), and symfony/deprecation-contracts (MIT). Their license texts are included in the corresponding vendor directories and the release archive.
+5 -4
View File
@@ -8,21 +8,22 @@ $base=new Base();$settings=$base->getSettings();$db=$base->connect();
$engines=$db->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN); $engines=$db->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_TYPE='BASE TABLE'")->fetchAll(PDO::FETCH_COLUMN);
if(array_filter($engines,static fn($v)=>$v!=='InnoDB'))throw new RuntimeException('Nontransactional tables require a maintenance-window locked backup.'); if(array_filter($engines,static fn($v)=>$v!=='InnoDB'))throw new RuntimeException('Nontransactional tables require a maintenance-window locked backup.');
$file=$directory.DIRECTORY_SEPARATOR.'hcr-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)).'.sql'; $file=$directory.DIRECTORY_SEPARATOR.'hcr-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)).'.sql';
$partial=$file.'.partial';$handle=fopen($partial,'x');if(!$handle)throw new RuntimeException('Cannot create protected backup output.');fclose($handle);chmod($partial,0600); $partial=$file.'.partial';$config=null;
$config=tempnam($directory,'hcr-connection-');chmod($config,0600);
$quote=static fn($value)=>'"'.str_replace(["\\",'"',"\r","\n"],["\\\\",'\\"','\\r','\\n'],(string)$value).'"'; $quote=static fn($value)=>'"'.str_replace(["\\",'"',"\r","\n"],["\\\\",'\\"','\\r','\\n'],(string)$value).'"';
$options="[client]\nhost=".$quote($settings->DatabaseServer)."\nport=".(int)($settings->DatabasePort??3306)."\nuser=".$quote($settings->DatabaseUser)."\npassword=".$quote($settings->DatabasePassword)."\n"; $options="[client]\nhost=".$quote($settings->DatabaseServer)."\nport=".(int)($settings->DatabasePort??3306)."\nuser=".$quote($settings->DatabaseUser)."\npassword=".$quote($settings->DatabasePassword)."\n";
$ca=getenv('HCR_DB_TLS_CA');if($ca)$options.='ssl-ca='.$quote($ca)."\nssl-verify-server-cert\n"; $ca=getenv('HCR_DB_TLS_CA');if($ca)$options.='ssl-ca='.$quote($ca)."\nssl-verify-server-cert\n";
try{ try{
$handle=fopen($partial,'x');if(!$handle)throw new RuntimeException('Cannot create protected backup output.');fclose($handle);chmod($partial,0600);
$config=tempnam($directory,'hcr-connection-');if($config===false)throw new RuntimeException('Cannot create protected connection configuration.');chmod($config,0600);
file_put_contents($config,$options); file_put_contents($config,$options);
$command=[getenv('HCR_DUMP_BINARY')?:'mariadb-dump','--defaults-extra-file='.$config,'--single-transaction','--quick','--routines','--events','--triggers','--hex-blob','--databases',(string)$settings->DatabaseName,'--result-file='.$partial]; $command=[getenv('HCR_DUMP_BINARY')?:'mariadb-dump','--defaults-extra-file='.$config,'--single-transaction','--quick','--routines','--events','--triggers','--hex-blob','--databases',(string)$settings->DatabaseName,'--result-file='.$partial];
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes); $process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
if(!is_resource($process))throw new RuntimeException('Backup process could not start.'); if(!is_resource($process))throw new RuntimeException('Backup process could not start.');
fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]); fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);
if(proc_close($process)!==0){error_log('Backup process failed: '.$error);throw new RuntimeException('Backup failed; inspect the protected backup error log.');} if(proc_close($process)!==0){error_log('Backup process failed: '.$error);throw new RuntimeException('Backup failed; inspect the protected backup error log.');}
if(!is_file($partial)||filesize($partial)<100||!str_contains(file_get_contents($partial),'Dump completed on'))throw new RuntimeException('Backup completion could not be confirmed.'); if(!BackupValidation::complete($partial))throw new RuntimeException('Backup completion could not be confirmed.');
if(!rename($partial,$file))throw new RuntimeException('Cannot finalize completed backup.'); if(!rename($partial,$file))throw new RuntimeException('Cannot finalize completed backup.');
chmod($file,0600); chmod($file,0600);
$metadata=['created_utc'=>gmdate(DATE_ATOM),'bytes'=>filesize($file),'sha256'=>hash_file('sha256',$file)];file_put_contents($file.'.json',json_encode($metadata,JSON_PRETTY_PRINT));chmod($file.'.json',0600); $metadata=['created_utc'=>gmdate(DATE_ATOM),'bytes'=>filesize($file),'sha256'=>hash_file('sha256',$file)];file_put_contents($file.'.json',json_encode($metadata,JSON_PRETTY_PRINT));chmod($file.'.json',0600);
echo json_encode(['backup'=>$file]+$metadata),"\n"; echo json_encode(['backup'=>$file]+$metadata),"\n";
}finally{if(is_file($config))unlink($config);if(is_file($partial))unlink($partial);} }finally{if(is_string($config)&&is_file($config))unlink($config);if(is_file($partial))unlink($partial);}
+2 -2
View File
@@ -11,9 +11,9 @@ for directory in ['classes','css','images','js','html','migrations','reports','v
for file in (ROOT/directory).rglob('*'): for file in (ROOT/directory).rglob('*'):
if file.is_file() and not file.is_symlink() and file.suffix.lower() not in ['.sql','.php','.js','.css','.xsl'] and directory not in ['vendor','images','css','html']:continue if file.is_file() and not file.is_symlink() and file.suffix.lower() not in ['.sql','.php','.js','.css','.xsl'] and directory not in ['vendor','images','css','html']:continue
if file.is_file() and not file.is_symlink() and not any(p.startswith('.') for p in file.relative_to(ROOT).parts) and '_orig.' not in file.name:files.append(file) if file.is_file() and not file.is_symlink() and not any(p.startswith('.') for p in file.relative_to(ROOT).parts) and '_orig.' not in file.name:files.append(file)
for name in ['main.php','.htaccess','web.config','composer.json','composer.lock','xml/settings.xml','xml/constants.xml']: for name in ['main.php','.htaccess','web.config','composer.json','composer.lock','THIRD-PARTY-NOTICES.md','browser-dependencies.json','xml/settings.xml','xml/constants.xml']:
files.append(ROOT/name) files.append(ROOT/name)
for name in ['migrate-auth.php','migrate-roster.php','migrate-production.php','cleanup-auth.php','check-production.py','backup-database.php','check-health.php']: for name in ['migrate-auth.php','migrate-roster.php','migrate-production.php','migrate-tenants.php','migrate-security-capacity.php','cleanup-auth.php','check-production.py','backup-database.php','check-health.php']:
file=ROOT/'bin'/name file=ROOT/'bin'/name
if file.exists():files.append(file) if file.exists():files.append(file)
if not (ROOT/'vendor/autoload.php').is_file():raise SystemExit('Install locked Composer dependencies before building a release.') if not (ROOT/'vendor/autoload.php').is_file():raise SystemExit('Install locked Composer dependencies before building a release.')
+2
View File
@@ -4,6 +4,8 @@ require __DIR__.'/../classes/Base.php';
try{ try{
$db=(new Base())->connect();$db->query('SELECT 1')->fetchColumn(); $db=(new Base())->connect();$db->query('SELECT 1')->fetchColumn();
if(!(new RosterService())->ready())throw new RuntimeException('Required schema is missing.'); if(!(new RosterService())->ready())throw new RuntimeException('Required schema is missing.');
foreach(['user_mfa','mfa_recovery','club_resource_usage'] as $table)$db->query('SELECT 1 FROM '.$table.' LIMIT 0');
if((string)(new Base())->getSettings()->Environment==='Production'){ $key=base64_decode(getenv('HCR_MFA_KEY')?:'',true);if($key===false||strlen($key)!==32)throw new RuntimeException('MFA key is missing.');}
foreach(['user_email_verified_at'] as $column){$columns=$db->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN);if(!in_array($column,$columns,true))throw new RuntimeException('Account schema is missing.');} foreach(['user_email_verified_at'] as $column){$columns=$db->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN);if(!in_array($column,$columns,true))throw new RuntimeException('Account schema is missing.');}
echo "Database and required application schema are available.\n"; echo "Database and required application schema are available.\n";
}catch(Throwable $e){fwrite(STDERR,"Health check failed. Review the protected server logs.\n");error_log((string)$e);exit(1);} }catch(Throwable $e){fwrite(STDERR,"Health check failed. Review the protected server logs.\n");error_log((string)$e);exit(1);}
+6
View File
@@ -0,0 +1,6 @@
<?php
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require_once __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/005_security_capacity.sql')) as $sql)if(trim($sql))$db->exec($sql);
echo "Security and capacity schema ready. Configure HCR_MFA_KEY before administrator sign-in.\n";
+27
View File
@@ -0,0 +1,27 @@
<?php
/** Explicit account selection; never promotes every legacy administrator. */
if(PHP_SAPI!=='cli'){http_response_code(404);exit;}
require_once __DIR__.'/../classes/Base.php';
$db=(new Base())->connect();
$columns=$db->query('SHOW COLUMNS FROM club_memberships')->fetchAll(PDO::FETCH_COLUMN);
if(!in_array('membership_role',$columns,true))$db->exec("ALTER TABLE club_memberships ADD COLUMN membership_role ENUM('member','administrator') NOT NULL DEFAULT 'member'");
// Club display names are tenant-local, not a global account lookup key.
$index=$db->query("SHOW INDEX FROM clubs WHERE Key_name='club_name'")->fetch();
if($index && !(int)$index['Non_unique'])$db->exec('ALTER TABLE clubs DROP INDEX club_name, ADD INDEX club_name (club_name)');
foreach(explode(';',file_get_contents(__DIR__.'/../migrations/004_tenants.sql')) as $sql)if(trim($sql))$db->exec($sql);
$email=getenv('HCR_SUPER_ADMIN_EMAIL')?:'';
if($email!==''){
if(!filter_var($email,FILTER_VALIDATE_EMAIL))throw new RuntimeException('Use a valid existing Super System Administrator email.');
$db->beginTransaction();
try{
$stmt=$db->prepare("SELECT user_serial,user_role FROM users WHERE user_email=? AND user_status='active' FOR UPDATE");$stmt->execute([$email]);$user=$stmt->fetch();
if(!$user)throw new RuntimeException('The selected active account does not exist. No account was promoted.');
if($user['user_role']!==TenantAccess::SUPER){
$stmt=$db->prepare('UPDATE users SET user_role=?,user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([TenantAccess::SUPER,$user['user_serial']]);
foreach(['password_resets','email_verifications'] as $table){$stmt=$db->prepare('DELETE FROM '.$table.' WHERE user_serial=?');$stmt->execute([$user['user_serial']]);}
Audit::required($db,(int)$user['user_serial'],'designated Super System Administrator by CLI migration');
}
$db->commit();
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
}
echo "Tenant schema is ready. Existing clubs require explicit administrator assignments; no memberships were guessed.\n";
+19 -2
View File
@@ -6,6 +6,23 @@
"sha256_base64": "/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=", "sha256_base64": "/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=",
"upgrade_note": "Patched jQuery 3.x retained for compatibility with the existing Bootstrap plugins; browser regressions required for future upgrades." "upgrade_note": "Patched jQuery 3.x retained for compatibility with the existing Bootstrap plugins; browser regressions required for future upgrades."
}, },
"bootstrap": {"file": "js/bootstrap.bundle.min.js", "version": "5.3.8"}, "bootstrap": {
"bootstrap-datepicker": {"file": "js/bootstrap-datepicker.min.js", "version": "1.9.0"} "file": "js/bootstrap.bundle.min.js",
"version": "5.3.8"
},
"bootstrap-datepicker": {
"file": "js/bootstrap-datepicker.min.js",
"version": "1.9.0"
},
"suneditor": {
"version": "3.3.3",
"license": "MIT",
"source": "https://registry.npmjs.org/suneditor/-/suneditor-3.3.3.tgz",
"integrity": "sha512-p/e8ymutQOVDVfGnVo3kVAltkshQdfIDWoR/akMRGg4s1OUuz3eYQGMwd4HbER/oojXLV6YbRrRSQC6hHKSFvA==",
"files": {
"js/suneditor-3.3.3.min.js": "79320e3c797d27fbaabb023dbc170964b2f91047c1abc35722297d65285a9ec5",
"css/suneditor-3.3.3.min.css": "0cffdc05252996ee97609317850f7ae3c84ef8cefab0f11ee477b0a6595116e8",
"THIRD-PARTY-NOTICES.md": "00c332152816d2e8ddff450bb56d35f688f0d6314fca420adea9e3daae7d42df"
}
}
} }
+2 -1
View File
@@ -3,12 +3,13 @@
class AdminAccounts extends Base { class AdminAccounts extends Base {
public function create(array $input): array { public function create(array $input): array {
(new TenantAccess())->requireSuper();
$first = trim($input['user_first_name'] ?? ''); $first = trim($input['user_first_name'] ?? '');
$last = trim($input['user_last_name'] ?? ''); $last = trim($input['user_last_name'] ?? '');
$email = trim($input['user_email'] ?? ''); $email = trim($input['user_email'] ?? '');
$name = trim($input['user_name'] ?? ''); $name = trim($input['user_name'] ?? '');
$role = $input['user_role'] ?? ''; $role = $input['user_role'] ?? '';
if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !in_array($role, ['User', 'Guest', 'Administrator'], true)) if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !in_array($role, ['User', 'Guest', 'Administrator', TenantAccess::SUPER], true))
throw new AppException('Enter a valid username, first/last name, email and role.'); throw new AppException('Enter a valid username, first/last name, email and role.');
$db = $this->connect(); $db = $this->connect();
$stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (?,?) OR user_email IN (?,?)'); $stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (?,?) OR user_email IN (?,?)');
+8 -21
View File
@@ -69,15 +69,8 @@ class Alerts extends Base {
case "add": case "add":
$alert_title = filter_input(INPUT_POST, "alert_title") ?: ""; $data=MaintenanceValidation::alert($_POST);
$alert_starts_date = filter_input(INPUT_POST, "alert_starts_date") ?: ""; $alert_title=$data['title'];$alert_starts=$data['starts'];$alert_expires=$data['expires'];$alert_message=$data['message'];
$alert_starts_time = filter_input(INPUT_POST, "alert_starts_time") ?: "";
$alert_expires_date = filter_input(INPUT_POST, "alert_expires_date") ?: "";
$alert_expires_time = filter_input(INPUT_POST, "alert_expires_time") ?: "";
$alert_message = filter_input(INPUT_POST, "alert_message") ?: "";
$alert_starts = date("Y-m-d H:i", strtotime("{$alert_starts_date} {$alert_starts_time}"));
$alert_expires = date("Y-m-d H:i", strtotime("{$alert_expires_date} {$alert_expires_time}"));
$SQL = "insert into alerts $SQL = "insert into alerts
@@ -120,7 +113,7 @@ class Alerts extends Base {
public function deleteAlert() { public function deleteAlert() {
$alert_serial = filter_input(INPUT_POST, "alert_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0; $alert_serial = MaintenanceService::id($_POST['alert_serial']??null);
$alert = $this->getAlert($alert_serial); $alert = $this->getAlert($alert_serial);
@@ -146,7 +139,7 @@ class Alerts extends Base {
case "prompt": case "prompt":
$alert_serial = filter_input(INPUT_POST, "alert_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0; $alert_serial = MaintenanceService::id($_POST['alert_serial']??null);
$alert = $this->getAlert($alert_serial); $alert = $this->getAlert($alert_serial);
@@ -162,16 +155,9 @@ class Alerts extends Base {
case "update": case "update":
$alert_serial = filter_input(INPUT_POST, "alert_serial") ?: 0; $alert_serial = MaintenanceService::id($_POST['alert_serial']??null);
$alert_title = filter_input(INPUT_POST, "alert_title") ?: ""; $data=MaintenanceValidation::alert($_POST);
$alert_starts_date = filter_input(INPUT_POST, "alert_starts_date") ?: ""; $alert_title=$data['title'];$alert_starts=$data['starts'];$alert_expires=$data['expires'];$alert_message=$data['message'];
$alert_starts_time = filter_input(INPUT_POST, "alert_starts_time") ?: "";
$alert_expires_date = filter_input(INPUT_POST, "alert_expires_date") ?: "";
$alert_expires_time = filter_input(INPUT_POST, "alert_expires_time") ?: "";
$alert_message = filter_input(INPUT_POST, "alert_message") ?: "";
$alert_starts = date("Y-m-d H:i", strtotime("{$alert_starts_date} {$alert_starts_time}"));
$alert_expires = date("Y-m-d H:i", strtotime("{$alert_expires_date} {$alert_expires_time}"));
$alert = $this->getAlert($alert_serial); $alert = $this->getAlert($alert_serial);
@@ -211,6 +197,7 @@ class Alerts extends Base {
// ===================== // =====================
public function getAlert($alert_serial = 0) { public function getAlert($alert_serial = 0) {
$alert_serial=MaintenanceService::id($alert_serial);
$SQL = "select *, $SQL = "select *,
date_format(alert_started, '%Y') as alert_started_year, date_format(alert_started, '%Y') as alert_started_year,
+22 -2
View File
@@ -4,6 +4,7 @@
class AuthService extends Base { class AuthService extends Base {
public const IDLE_TIMEOUT = 1800; public const IDLE_TIMEOUT = 1800;
public const ABSOLUTE_TIMEOUT = 28800;
public function findUser(string $identifier): ?array { public function findUser(string $identifier): ?array {
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1'); $stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1');
@@ -50,6 +51,8 @@ class AuthService extends Base {
$stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]); $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]);
} }
session_regenerate_id(true); session_regenerate_id(true);
unset($_SESSION['mfa_user'],$_SESSION['recent_auth'],$_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);
$_SESSION['authenticated_at']=time();
$_SESSION[self::USER_AUTHENTICATED] = true; $_SESSION[self::USER_AUTHENTICATED] = true;
$_SESSION[self::USER_SERIAL] = (int) $user['user_serial']; $_SESSION[self::USER_SERIAL] = (int) $user['user_serial'];
$_SESSION[self::USER_NAME] = $user['user_name']; $_SESSION[self::USER_NAME] = $user['user_name'];
@@ -68,7 +71,7 @@ class AuthService extends Base {
public function currentUser(): ?array { public function currentUser(): ?array {
if (empty($_SESSION[self::USER_AUTHENTICATED])) if (empty($_SESSION[self::USER_AUTHENTICATED]))
return null; return null;
if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT) if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT || time() - ($_SESSION['authenticated_at'] ?? 0) >= self::ABSOLUTE_TIMEOUT)
return null; return null;
$stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id'); $stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id');
$stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]); $stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]);
@@ -104,8 +107,25 @@ class AuthService extends Base {
if ($stmt->fetch()) if ($stmt->fetch())
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
$stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')"); $stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')");
$kind=$input['registration_kind']??'account';
if(!in_array($kind,['account','new_club','invited'],true))throw new InvalidArgumentException('Choose a valid signup option.');
$clubName=trim($input['club_name']??'');
if($kind==='new_club' && ($clubName===''||strlen($clubName)>128))throw new InvalidArgumentException('Enter your new club name (up to 128 bytes).');
try { try {
$stmt->execute([':name' => $name, ':first' => $first, ':last' => $last, ':email' => $email, ':hash' => password_hash($input['user_password'], PASSWORD_DEFAULT)]); DatabaseTransaction::run($db,function(PDO $db)use($stmt,$name,$first,$last,$email,$input,$kind,$clubName){
if($kind==='invited'){
$invites=$db->prepare("SELECT i.club_serial,i.invited_by,i.membership_role FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial WHERE i.invite_email=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active'");$invites->execute([$email]);$valid=false;$access=new TenantAccess();
foreach($invites->fetchAll() as $invite)if($access->canManage((int)$invite['club_serial'],$db,(int)$invite['invited_by'])&&($invite['membership_role']==='member'||$access->isSuper($db,(int)$invite['invited_by']))){$valid=true;break;}
if(!$valid)throw new InvalidArgumentException('Ask your club administrator for a current invitation to this email address.');
}
$stmt->execute([':name'=>$name,':first'=>$first,':last'=>$last,':email'=>$email,':hash'=>password_hash($input['user_password'],PASSWORD_DEFAULT)]);
$user=(int)$db->lastInsertId();
if($kind==='new_club'){
$club=$db->prepare("INSERT INTO clubs(club_name,club_status) VALUES(?,'active')");$club->execute([$clubName]);$clubId=(int)$db->lastInsertId();
$membership=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_role,membership_status,joined_on) VALUES(?,?,'administrator','active',?)");$membership->execute([$clubId,$user,date('Y-m-d')]);
Audit::required($db,$user,'registered new club '.json_encode(['club'=>$clubId,'name'=>$clubName]));
}
});
} catch (PDOException $e) { } catch (PDOException $e) {
if ($e->getCode() === '23000') if ($e->getCode() === '23000')
throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.');
+14
View File
@@ -0,0 +1,14 @@
<?php
final class BackupValidation {
/** mariadb-dump writes the completion comment at the end; never load the dump. */
public static function complete(string $path): bool {
clearstatcache(true,$path);
if(!is_file($path)||filesize($path)<100)return false;
$stream=fopen($path,'rb');if(!$stream)return false;
try{
if(fseek($stream,max(0,filesize($path)-8192),SEEK_SET)!==0)return false;
$tail=fread($stream,8192);
return $tail!==false&&preg_match('/(?:\A|\r?\n)-- Dump completed on [^\r\n]+(?:\r?\n)*\z/',$tail)===1;
}finally{fclose($stream);}
}
}
+26 -8
View File
@@ -15,7 +15,7 @@ class Base {
const APPLICATION_NAME = "My Hunting Club Roster"; const APPLICATION_NAME = "My Hunting Club Roster";
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER"; const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
const VERSION = "1.00"; const VERSION = "1.05";
const TIMEZONE = "America/New_York"; const TIMEZONE = "America/New_York";
// ================= // =================
// Session Variables // Session Variables
@@ -176,7 +176,7 @@ class Base {
public function displayContent($content = "") { public function displayContent($content = "") {
$html = $this->applyXSL("", $this->getXSL("content")); $html = $this->applyXSL("", $this->getXSL("content"), ["IS_SUPER"=>(new TenantAccess())->isSuper()?"1":"0","CAN_MANAGE_CLUBS" => (new TenantAccess())->managesAny()?"1":"0"]);
$html = str_replace("$$$-CONTENT-$$$", $content, $html); $html = str_replace("$$$-CONTENT-$$$", $content, $html);
echo $html; echo $html;
@@ -245,13 +245,20 @@ class Base {
exit('Method not allowed.'); exit('Method not allowed.');
} }
// Reject arrays and oversized values before passing input to string operations. // Ticket bodies need room for 8,000 four-byte Unicode characters. Keep
foreach (array_merge($_GET, $_POST) as $value) { // a hard transport ceiling; field validators enforce the character limit.
if (!is_string($value) || strlen($value) > 8192) { $candidate=$_POST['action']??$_GET['Xaction']??$_GET['action']??'';
$ticketSubmission=is_string($candidate)&&strtolower(trim($candidate))==='roster.tickets'
&& in_array($_POST['step']??null,['openTicket','reply'],true);
foreach ([$_GET,$_POST] as $source=>$values) {
foreach($values as $field=>$value){
$byteLimit=$source===1&&$ticketSubmission&&$field==='message'?65536:8192;
if (!is_string($value) || strlen($value) > $byteLimit) {
http_response_code(400); http_response_code(400);
exit('Invalid request.'); exit('Invalid request.');
} }
} }
}
$action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? '')); $action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
if($action==='verifyemail'){(new EmailVerification())->page();return;} if($action==='verifyemail'){(new EmailVerification())->page();return;}
if ($action === 'signout') { if ($action === 'signout') {
@@ -296,6 +303,12 @@ class Base {
if ($method === 'POST') { if ($method === 'POST') {
$this->requireCsrf(); $this->requireCsrf();
} }
$mfa=new MfaService();
if($mfa->gate($user,$action))return;
$sensitive=in_array($action,['users.changepassword','users.adduser','users.edituser','users.deleteuser','users.updateactivestatus','users.useractive','users.resetpassword'],true)
|| ($action==='roster.membersummary' && ($_POST['step']??'')==='member')
|| ($action==='roster.workspace' && (($_POST['step']??'')==='member' || (($_POST['step']??'')==='invite' && ($_POST['membership_role']??'')==='administrator')));
if($method==='POST' && $sensitive && (new TenantAccess())->managesAny())$mfa->requireRecent();
if ((bool) $user['user_change_password']) { if ((bool) $user['user_change_password']) {
(new Users())->changePassword(); (new Users())->changePassword();
return; return;
@@ -312,7 +325,12 @@ class Base {
'getage' => [Base::class, 'getAge', false], 'getage' => [Base::class, 'getAge', false],
'users.changepassword' => [Users::class, 'changePassword', false], 'users.changepassword' => [Users::class, 'changePassword', false],
'roster.workspace' => [Roster::class,'workspace',false], 'roster.workspace' => [Roster::class,'workspace',false],
'roster.membersummary' => [MemberSummary::class,'show',false],
'roster.createclub' => [Roster::class,'createClub','super'],
'roster.help' => [Roster::class,'help',false], 'roster.help' => [Roster::class,'help',false],
'roster.invitations' => [Roster::class,'invitations',false],
'roster.tickets' => [Roster::class,'tickets',false],
'rosterreports.catalog' => [RosterReports::class,'catalog',true],
'rosterreports.ledger' => [RosterReports::class,'ledger',true], 'rosterreports.ledger' => [RosterReports::class,'ledger',true],
'rosterreports.csv' => [RosterReports::class,'csv',true], 'rosterreports.csv' => [RosterReports::class,'csv',true],
'rosterreports.excel' => [RosterReports::class,'excel',true], 'rosterreports.excel' => [RosterReports::class,'excel',true],
@@ -334,7 +352,7 @@ class Base {
]; ];
foreach ($adminRoutes as $class => $methods) { foreach ($adminRoutes as $class => $methods) {
foreach ($methods as $entry) { foreach ($methods as $entry) {
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, true]; $routes[strtolower($class . '.' . $entry)] = [$class, $entry, $class==='Reports'?true:'super'];
} }
} }
if (!isset($routes[$action])) { if (!isset($routes[$action])) {
@@ -342,11 +360,11 @@ class Base {
exit('Page not found.'); exit('Page not found.');
} }
[$class, $entry, $adminOnly] = $routes[$action]; [$class, $entry, $adminOnly] = $routes[$action];
if ($adminOnly && !in_array($user['user_role'], ['Administrator', 'System Administrator'], true)) { if (($adminOnly==='super' && !(new TenantAccess())->isSuper()) || ($adminOnly===true && !(new TenantAccess())->managesAny())) {
http_response_code(403); http_response_code(403);
exit('You do not have permission to perform this action.'); exit('You do not have permission to perform this action.');
} }
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.help'], true)) { if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.membersummary','roster.createclub','roster.help','roster.invitations','roster.tickets','rosterreports.catalog'], true)) {
http_response_code(405); http_response_code(405);
exit('Use POST for this action.'); exit('Use POST for this action.');
} }
+39
View File
@@ -0,0 +1,39 @@
<?php
/** Same authorized query feeds the count and bounded rows. */
final class BoundedList {
public static array $pages=[];
public static bool $exporting=false;
public static function page(string $scope): int {
$raw=$_GET[$scope.'_page']??'1';$page=filter_var($raw,FILTER_VALIDATE_INT,['options'=>['min_range'=>1]]);
if($page===false)throw new AppException('Choose a valid page.');return $page;
}
public static function rows(PDO $db,string $sql,array $args,?int $page,string $scope): array {
if($page!==null){
$stmt=$db->prepare('SELECT COUNT(*) FROM ('.$sql.') scoped_rows');$stmt->execute($args);$count=(int)$stmt->fetchColumn();
$paging=Pagination::calculate($count,25,$page);self::$pages[$scope]=$paging+['count'=>$count,'pages'=>max(1,(int)ceil($count/25))];
$sql.=' LIMIT '.$paging['size'].' OFFSET '.$paging['offset'];
}
if($page===null&&self::$exporting)$sql.=' LIMIT '.(self::exportLimit()+1);
$stmt=$db->prepare($sql);$stmt->execute($args);$rows=$stmt->fetchAll();
if(self::$exporting&&count($rows)>self::exportLimit())throw new AppException('This export exceeds the configured row limit.',422);
return $rows;
}
public static function navigation(string $scope,?string $action=null): string {
$p=self::$pages[$scope]??null;if(!$p)return '';
$h=static fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES,'UTF-8');$html='<nav aria-label="'.$h($scope).' pages"><p>'.$p['count'].' records. Page '.$p['page'].' of '.$p['pages'].'.</p>';
foreach(['First'=>1,'Previous'=>$p['page']-1,'Next'=>$p['page']+1,'Last'=>$p['pages']] as $label=>$page){
if($page<1||$page>$p['pages']||$page===$p['page'])continue;
$args=$_GET;unset($args['action']);$args['Xaction']=$action??$args['Xaction']??'Roster.workspace';$args[$scope.'_page']=$page;
$html.='<a class="btn btn-outline-primary me-2" href="'.$h('main.php?'.http_build_query($args)).'">'.$label.'</a>';
}
return $html.'</nav>';
}
public static function exportLimit(): int {
$value=getenv('HCR_EXPORT_MAX_ROWS');$limit=$value===false?1000:filter_var($value,FILTER_VALIDATE_INT,['options'=>['min_range'=>1,'max_range'=>5000]]);
if($limit===false)throw new RuntimeException('Invalid export row limit.');return $limit;
}
public static function exportGuard(PDO $db,string $sql,array $args): void {
$stmt=$db->prepare($sql);$stmt->execute($args);
if((int)$stmt->fetchColumn()>self::exportLimit())throw new AppException('This export exceeds the configured row limit. Contact support for a large export.',422);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/** Move already-authorized club forms out of summary tables into labelled dialogs. */
final class ClubFormModals {
public static function render(string $html): string {
$titles=['club'=>'Edit or archive club','member'=>'Add member','invite'=>'Invite members',
'season'=>'Add season','charge'=>'Record season dues','payment'=>'Record payment',
'adjust'=>'Correct dues','reverse'=>'Reverse incorrect payment','revokeInvitation'=>'Revoke invitation',
'note_add'=>'Add note','note_edit'=>'Edit note','note_delete'=>'Delete note'];
$doc=new DOMDocument('1.0','UTF-8');$previous=libxml_use_internal_errors(true);
try{$loaded=$doc->loadHTML('<?xml encoding="UTF-8">'.$html,LIBXML_NONET);}
finally{libxml_clear_errors();libxml_use_internal_errors($previous);}
if(!$loaded)throw new RuntimeException('Cannot render club dialogs.');
$main=$doc->getElementsByTagName('main')->item(0);
if(!$main)throw new LogicException('Club summary container is missing.');
$dialogs=[];$index=0;
foreach(iterator_to_array($main->getElementsByTagName('form')) as $form){
$values=[];foreach($form->getElementsByTagName('input') as $input)$values[$input->getAttribute('name')]=$input->getAttribute('value');
$step=$values['step']??'';
if(!in_array($values['action']??'',['Roster.workspace','Roster.memberSummary'],true)||!isset($titles[$step]))continue;
$title=$step==='member'&&!empty($values['membership_serial'])?'Edit membership':$titles[$step];
$id='club-action-'.(++$index);$parent=$form->parentNode;
$trigger=$doc->createElement('button');$trigger->appendChild($doc->createTextNode($title));
foreach(['type'=>'button','class'=>'btn '.($step==='note_delete'?'btn-outline-danger':'btn-outline-primary').' me-2 mb-2','data-bs-toggle'=>'modal','data-bs-target'=>'#'.$id,'data-club-step'=>$step] as $key=>$value)$trigger->setAttribute($key,$value);
$dialog=$doc->createElement('div');
foreach(['class'=>'modal fade club-action-modal','id'=>$id,'tabindex'=>'-1','aria-labelledby'=>$id.'-title','aria-hidden'=>'true'] as $key=>$value)$dialog->setAttribute($key,$value);
$layout=$dialog->appendChild($doc->createElement('div'));$layout->setAttribute('class','modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable');
$content=$layout->appendChild($doc->createElement('div'));$content->setAttribute('class','modal-content');
$header=$content->appendChild($doc->createElement('div'));$header->setAttribute('class','modal-header');
$heading=$header->appendChild($doc->createElement('h2'));$heading->setAttribute('class','modal-title fs-5');$heading->setAttribute('id',$id.'-title');$heading->appendChild($doc->createTextNode($title));
$close=$header->appendChild($doc->createElement('button'));$close->setAttribute('type','button');$close->setAttribute('class','btn-close');$close->setAttribute('data-bs-dismiss','modal');$close->setAttribute('aria-label','Close');
$body=$content->appendChild($doc->createElement('div'));$body->setAttribute('class','modal-body');
// Keep the selected row visible in the dialog so financial/member actions are unambiguous.
$row=$parent;while($row&&$row!==$main&&!in_array($row->nodeName,['tr','li'],true))$row=$row->parentNode;
if($row&&$row!==$main&&!in_array($step,['note_edit','note_delete'],true)){
$text=$row->textContent;
if($row->nodeName==='tr')$text=implode(' · ',array_map(static fn($cell)=>trim($cell->textContent),array_slice(iterator_to_array($row->getElementsByTagName('td')),0,-1)));
$context=$body->appendChild($doc->createElement('p'));$context->setAttribute('class','text-secondary');$context->appendChild($doc->createTextNode(trim(preg_replace('/\s+/u',' ',$text))));
}
if($parent->nodeName==='details'){
$parent->parentNode->replaceChild($trigger,$parent);
foreach(iterator_to_array($parent->childNodes) as $child)if($child->nodeName!=='summary')$body->appendChild($child);
}else{
$previous=$form->previousSibling;
if($previous&&$previous->nodeName==='h4')$previous->parentNode->removeChild($previous);
$following=$form->nextSibling;
$parent->replaceChild($trigger,$form);$body->appendChild($form);
if($following&&$following->nodeName==='p')$body->appendChild($following);
}
$error=$doc->createElement('p');$error->setAttribute('class','alert alert-danger club-modal-error');$error->setAttribute('role','alert');$error->setAttribute('tabindex','-1');$error->setAttribute('hidden','hidden');$body->insertBefore($error,$body->firstChild);
$footer=$content->appendChild($doc->createElement('div'));$footer->setAttribute('class','modal-footer');
$cancel=$footer->appendChild($doc->createElement('button'));$cancel->setAttribute('type','button');$cancel->setAttribute('class','btn btn-outline-secondary');$cancel->setAttribute('data-bs-dismiss','modal');$cancel->appendChild($doc->createTextNode('Cancel'));
$dialogs[]=$dialog;
if($step==='invite')foreach(iterator_to_array($main->getElementsByTagName('a')) as $link)if($link->getAttribute('href')==='#club-invitations'){$link->parentNode->replaceChild($trigger,$link);break;}
}
foreach($dialogs as $dialog)$main->appendChild($dialog);
$script=$main->appendChild($doc->createElement('script'));$script->setAttribute('src','js/club-modals.js?version='.Base::VERSION);
return $doc->saveHTML($main);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
/** Invitations grant only club membership, never global account privileges. */
class ClubInvitations extends RosterService {
public function pending(?int $page=null): array {
return BoundedList::rows($this->connect(),"SELECT i.invitation_serial,c.club_name,i.membership_role,i.expires_at FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial JOIN users u ON LOWER(u.user_email)=LOWER(i.invite_email) WHERE u.user_serial=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active' ORDER BY i.invitation_serial",[$_SESSION[self::USER_SERIAL]??0],$page,'pending');
}
public function forClub(int $club,?int $page=null): array {
(new TenantAccess())->requireManager($club);$this->club($club);
return BoundedList::rows($this->connect(),'SELECT invitation_serial,invite_email,membership_role,invitation_status,expires_at FROM club_invitations WHERE club_serial=? ORDER BY invitation_serial DESC',[$club],$page,'invitations');
}
public function invite(int $club,string $email,string $role='member'): array {
$email=trim($email);$access=new TenantAccess();$access->requireManager($club);
if(!filter_var($email,FILTER_VALIDATE_EMAIL)||strlen($email)>100||!in_array($role,['member','administrator'],true))throw new AppException('Enter a valid invitation email and club role.');
if($role==='administrator')$access->requireSuper();
if(!(new AuthService())->allowAttempt('club-invite',($_SESSION[self::USER_SERIAL]??0).':'.$club,20,3600))throw new AppException('Too many invitations. Try again later.',429);
(new ClubResources())->consume($club,'email');
$db=$this->connect();
$selected=DatabaseTransaction::run($db,function(PDO $db)use($club,$email,$role,$access){
$selected=$this->club($club,$db,true);
if($role==='administrator')$access->requireSuper($db);
$stmt=$db->prepare('SELECT * FROM club_invitations WHERE club_serial=? AND invite_email=? FOR UPDATE');$stmt->execute([$club,$email]);$existing=$stmt->fetch();
if($existing && strtotime($existing['sent_at'].' UTC')>time()-60)throw new AppException('Wait a minute before sending this invitation again.',429);
if($existing && $existing['membership_role']==='administrator')$access->requireSuper($db);
$stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,membership_role,invited_by,expires_at,sent_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 7 DAY),UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE membership_role=VALUES(membership_role),invited_by=VALUES(invited_by),invitation_status='pending',expires_at=VALUES(expires_at),sent_at=VALUES(sent_at),accepted_at=NULL");
$stmt->execute([$club,$email,$role,$_SESSION[self::USER_SERIAL]]);
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'invited club member '.json_encode(['club'=>$club,'email'=>$email,'role'=>$role]));return $selected;
});
$sent=false;try{$sent=$this->deliver($email,$selected['club_name']);}catch(Throwable $e){error_log('Club invitation delivery failed: '.$e->getMessage());}
return ['ok'=>true,'message'=>'Invitation saved. The recipient must sign in with this email, verify it, and accept the invitation.','warning'=>$sent?'':'The invitation email could not be sent. The recipient can still sign in and open Invitations; you can retry sending after a minute.'];
}
protected function deliver(string $email,string $club): bool {
$url=rtrim((string)$this->getSettings()->ApplicationUrl,'/');
if(!filter_var($url,FILTER_VALIDATE_URL)||parse_url($url,PHP_URL_SCHEME)!=='https'||parse_url($url,PHP_URL_QUERY)!==null||parse_url($url,PHP_URL_FRAGMENT)!==null||parse_url($url,PHP_URL_USER)!==null||parse_url($url,PHP_URL_PASS)!==null)throw new RuntimeException('Configure a trusted HTTPS ApplicationUrl.');
$h=static fn($v)=>htmlspecialchars($v,ENT_QUOTES,'UTF-8');
$body='<p>You have been invited to '.$h($club).'.</p><p><a href="'.$h($url.'/main.php?Xaction=Roster.invitations').'">Sign in and open Invitations</a>. If you do not have an account, use <a href="'.$h($url.'/main.php?Xaction=signup&membership=invited').'">invited member signup</a> with this email and verify it. Accepting the invitation grants access to this club.</p><p>This invitation expires in seven days.</p>';
return (new Email())->sendEmail($email,self::APPLICATION_NAME.' - Club invitation',$body);
}
public function accept(int $id): void {
$db=$this->connect();
DatabaseTransaction::run($db,function(PDO $db)use($id){
// Match membership/account edits: lock the recipient before the club.
$stmt=$db->prepare('SELECT club_serial FROM club_invitations WHERE invitation_serial=?');$stmt->execute([$id]);$club=(int)$stmt->fetchColumn();
$stmt=$db->prepare('SELECT user_serial,user_email,user_email_verified_at,user_status FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);$user=$stmt->fetch();
$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('Invitation is unavailable.',409);
$stmt=$db->prepare("SELECT * FROM club_invitations WHERE invitation_serial=? AND invitation_status='pending' AND expires_at>UTC_TIMESTAMP() FOR UPDATE");$stmt->execute([$id]);$invite=$stmt->fetch();
if(!$invite||!$user||strcasecmp($invite['invite_email'],$user['user_email'])!==0)throw new AppException('Invitation is unavailable.',404);
if(!$user['user_email_verified_at']||$user['user_status']!=='active')throw new AppException('Verify your account email before accepting an invitation.',403);
$access=new TenantAccess();
if(!$access->canManage($club,$db,(int)$invite['invited_by']) || ($invite['membership_role']==='administrator'&&!$access->isSuper($db,(int)$invite['invited_by'])))throw new AppException('Ask a current club administrator to send a new invitation.',409);
$stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on,membership_role) VALUES(?,?,'active',?,?) ON DUPLICATE KEY UPDATE membership_role=IF(membership_status='active' AND membership_role='administrator','administrator',VALUES(membership_role)),membership_status='active'");$stmt->execute([$club,$user['user_serial'],date('Y-m-d'),$invite['membership_role']]);
$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='accepted',accepted_at=UTC_TIMESTAMP() WHERE invitation_serial=?");$stmt->execute([$id]);
Audit::required($db,(int)$user['user_serial'],'accepted club invitation '.json_encode(['club'=>$club,'invitation'=>$id,'role'=>$invite['membership_role']]));
});
}
public function revoke(int $club,int $id): void {
$db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$id){
$this->club($club,$db,true);
$stmt=$db->prepare('SELECT membership_role FROM club_invitations WHERE invitation_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$id,$club]);$role=$stmt->fetchColumn();if(!$role)throw new AppException('Invitation not found.',404);
if($role==='administrator')(new TenantAccess())->requireSuper($db);
$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='revoked' WHERE invitation_serial=? AND club_serial=? AND invitation_status='pending'");$stmt->execute([$id,$club]);
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'revoked club invitation '.json_encode(['club'=>$club,'invitation'=>$id]));
});
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
/** Private notes reference a club membership, never a global user account. */
class ClubMemberNotes extends RosterService {
public const TYPE='ClubMember';
public const LIMIT=2000;
public function listing(int $club,int $membership,int $page): array {
(new TenantAccess())->requireManager($club);$this->member($club,$membership);
return BoundedList::rows($this->connect(),'SELECT note_serial,note_text,note_origin,note_timestamp FROM notes WHERE note_type=? AND note_reference=? ORDER BY note_timestamp DESC,note_serial DESC',[self::TYPE,$membership],$page,'notes');
}
public function save(int $club,int $membership,int $id,string $text,string $version=''): int {
(new TenantAccess())->requireManager($club);
$this->requireTransactionalNotes();
$text=trim($text);
if(!mb_check_encoding($text,'UTF-8')||$text===''||mb_strlen($text,'UTF-8')>self::LIMIT)throw new AppException('Enter a note of up to 2,000 characters.');
return DatabaseTransaction::run($this->connect(),function(PDO $db)use($club,$membership,$id,$text,$version){
$this->club($club,$db,true);(new TenantAccess())->requireManager($club,$db);$this->member($club,$membership);
if($id){
$stmt=$db->prepare('SELECT note_text FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,self::TYPE,$membership]);$old=$stmt->fetchColumn();
if($old===false)throw new AppException('Note not found in this membership.',404);
if(!hash_equals(hash('sha256',$old),$version))throw new AppException('This note changed since you opened it. Reload the summary before editing again.',409);
$stmt=$db->prepare('UPDATE notes SET note_text=?,note_origin=? WHERE note_serial=? AND note_type=? AND note_reference=?');$stmt->execute([$text,$_SESSION[self::USER_NAME]??'',$id,self::TYPE,$membership]);
}else{
$stmt=$db->prepare('INSERT INTO notes(note_type,note_reference,note_text,note_origin) VALUES(?,?,?,?)');$stmt->execute([self::TYPE,$membership,$text,$_SESSION[self::USER_NAME]??'']);$id=(int)$db->lastInsertId();
}
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'saved club member note '.json_encode(['club'=>$club,'membership'=>$membership,'note'=>$id]));
return $id;
});
}
public function delete(int $club,int $membership,int $id,string $version): void {
(new TenantAccess())->requireManager($club);$this->requireTransactionalNotes();
DatabaseTransaction::run($this->connect(),function(PDO $db)use($club,$membership,$id,$version){
$this->club($club,$db,true);(new TenantAccess())->requireManager($club,$db);$this->member($club,$membership);
$stmt=$db->prepare('SELECT note_text FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,self::TYPE,$membership]);$old=$stmt->fetchColumn();
if($old===false)throw new AppException('Note not found in this membership.',404);
if(!hash_equals(hash('sha256',$old),$version))throw new AppException('This note changed since you opened it. Reload the summary before deleting it.',409);
$stmt=$db->prepare('DELETE FROM notes WHERE note_serial=? AND note_type=? AND note_reference=?');$stmt->execute([$id,self::TYPE,$membership]);
Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'deleted club member note '.json_encode(['club'=>$club,'membership'=>$membership,'note'=>$id]));
});
}
private function requireTransactionalNotes(): void {
$engine=$this->connect()->query("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notes'")->fetchColumn();
if(strtoupper((string)$engine)!=='INNODB')throw new AppException('Administrative notes require transactional note storage. Contact support.',503);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
/** Atomic shared club budgets, independent of the member who submits work. */
class ClubResources extends Base {
public function consume(int $club,string $resource): void {
$defaults=['email'=>100,'ticket'=>100,'reply'=>1000,'export'=>20];
if(!isset($defaults[$resource]))throw new LogicException('Unknown club resource.');
(new RosterService())->club($club);
$raw=getenv('HCR_CLUB_'.strtoupper($resource).'_HOURLY');
$limit=$raw===false?$defaults[$resource]:filter_var($raw,FILTER_VALIDATE_INT,['options'=>['min_range'=>1,'max_range'=>100000]]);
if($limit===false)throw new RuntimeException('Invalid club resource limit.');
$db=$this->connect();
$stmt=$db->prepare("INSERT INTO club_resource_usage(club_serial,resource_name,window_started,used_count) VALUES(?,?,UTC_TIMESTAMP(),LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE used_count=LAST_INSERT_ID(IF(window_started<=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 HOUR),1,LEAST(used_count+1,100001))),window_started=IF(window_started<=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 1 HOUR),UTC_TIMESTAMP(),window_started)");$stmt->execute([$club,$resource]);
$stmt=$db->query('SELECT LAST_INSERT_ID()');
if((int)$stmt->fetchColumn()>$limit)throw new AppException('This club has reached its hourly '.$resource.' limit. Try again later.',429);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
/** Members see their own tickets; assigned club administrators see that club's tickets. */
class ClubTickets extends RosterService {
public const SUBJECT_LIMIT=200;
public const MESSAGE_LIMIT=8000;
private function text(string $value,int $limit,string $label): string {
if(!mb_check_encoding($value,'UTF-8')||str_contains($value,"\0"))throw new AppException($label.' must contain valid text.');
$value=trim($value);
if(preg_match('/\A[\s\p{Z}]*\z/u',$value))throw new AppException($label.' is required.');
if(mb_strlen($value,'UTF-8')>$limit)throw new AppException($label.' must be '.$limit.' characters or fewer.');
return $value;
}
public function normalizeSearch(string $search): string {
$search=trim($search);return $search===''?'':$this->text($search,100,'Ticket search');
}
public function page(int $club,int $page=1,string $search=''): array {
$this->club($club);if($page<1)throw new AppException('Choose a valid ticket page.');
$search=$this->normalizeSearch($search);$manage=$this->manages($club);$args=$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];
$scope=' WHERE club_serial=?'.($manage?'':' AND user_serial=?');
if($search!==''){$scope.=" AND subject LIKE ? ESCAPE '!'";$args[]='%'.str_replace(['!','%','_'],['!!','!%','!_'],$search).'%';}
$db=$this->connect();$stmt=$db->prepare('SELECT COUNT(*) FROM club_tickets'.$scope);$stmt->execute($args);$count=(int)$stmt->fetchColumn();
$paging=Pagination::calculate($count,25,$page);$pages=max(1,(int)ceil($count/$paging['size']));
$stmt=$db->prepare('SELECT ticket_serial,subject,ticket_status,user_serial,created_at FROM club_tickets'.$scope.' ORDER BY ticket_serial DESC LIMIT '.$paging['size'].' OFFSET '.$paging['offset']);$stmt->execute($args);
return $paging+['rows'=>$stmt->fetchAll(),'count'=>$count,'pages'=>$pages,'search'=>$search];
}
public function listing(int $club,int $page=1,string $search=''): array {return $this->page($club,$page,$search)['rows'];}
public function ticket(int $club,int $id,?PDO $db=null): array {
$this->club($club,$db);$stmt=($db??$this->connect())->prepare('SELECT * FROM club_tickets WHERE club_serial=? AND ticket_serial=?');$stmt->execute([$club,$id]);$row=$stmt->fetch();
if(!$row||(!$this->manages($club,$db)&&(int)$row['user_serial']!==(int)($_SESSION[self::USER_SERIAL]??0)))throw new AppException('Ticket not found.',404);return $row;
}
public function messages(int $club,int $id,?int $page=null): array {
$this->ticket($club,$id);return BoundedList::rows($this->connect(),'SELECT m.message_text,m.created_at,u.user_first_name,u.user_last_name FROM club_ticket_messages m JOIN users u ON u.user_serial=m.author_serial WHERE ticket_serial=? ORDER BY message_serial',[$id],$page,'messages');
}
public function createTicket(int $club,string $subject,string $message): int {
$subject=$this->text($subject,self::SUBJECT_LIMIT,'Subject');$message=$this->text($message,self::MESSAGE_LIMIT,'Message');
if(!(new AuthService())->allowAttempt('club-ticket',(string)($_SESSION[self::USER_SERIAL]??0),20,3600))throw new AppException('Too many tickets. Try again later.',429);
(new ClubResources())->consume($club,'ticket');
$db=$this->connect();return DatabaseTransaction::run($db,function(PDO $db)use($club,$subject,$message){
$selected=$this->club($club,$db);$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('This club is archived.',409);
$this->club($club,$db);$stmt=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$stmt->execute([$club,$_SESSION[self::USER_SERIAL],$subject]);$id=(int)$db->lastInsertId();
$stmt=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');$stmt->execute([$id,$_SESSION[self::USER_SERIAL],$message]);Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'opened club ticket '.json_encode(['club'=>$club,'ticket'=>$id]));return $id;
});
}
public function reply(int $club,int $id,string $message,string $status=''): void {
$message=$this->text($message,self::MESSAGE_LIMIT,'Message');if(!in_array($status,['','open','closed'],true))throw new AppException('Invalid ticket status.');
if(!(new AuthService())->allowAttempt('ticket-reply',(string)($_SESSION[self::USER_SERIAL]??0),60,3600))throw new AppException('Too many replies. Try again later.',429);
$this->ticket($club,$id);(new ClubResources())->consume($club,'reply');
$db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$id,$message,$status){
$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('This club is archived.',409);
$row=$this->ticket($club,$id,$db);if($status!=='')(new TenantAccess())->requireManager($club,$db);
if($row['ticket_status']==='closed'&&$status!=='open')throw new AppException('A club administrator must reopen the ticket before replying.',409);
$stmt=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');$stmt->execute([$id,$_SESSION[self::USER_SERIAL],$message]);
$stmt=$db->prepare('UPDATE club_tickets SET ticket_status=?,updated_at=CURRENT_TIMESTAMP WHERE ticket_serial=?');$stmt->execute([$status?:$row['ticket_status'],$id]);Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'replied to club ticket '.json_encode(['club'=>$club,'ticket'=>$id,'status'=>$status?:$row['ticket_status']]));
});
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/** Retry only complete, rollback-safe database work; never put email delivery here. */
final class DatabaseTransaction {
public static function run(PDO $db,callable $work): mixed {
if($db->inTransaction())throw new LogicException('Retryable work must own its transaction.');
for($attempt=1;$attempt<=3;$attempt++){
$db->beginTransaction();
try{
$result=$work($db);$db->commit();return $result;
}catch(Throwable $error){
if($db->inTransaction())$db->rollBack();
$retryable=$error instanceof PDOException && (in_array((int)($error->errorInfo[1]??0),[1205,1213],true)||$error->getCode()==='40001');
if(!$retryable||$attempt===3)throw $error;
usleep(random_int(10000,30000)*$attempt);
}
}
throw new LogicException('Transaction attempts exhausted.');
}
}
+19 -14
View File
@@ -5,8 +5,8 @@ class LedgerService extends RosterService {
private function key(string $key): void {if(!preg_match('/\A[a-f0-9]{64}\z/',$key))throw new AppException('Invalid request key.');} private function key(string $key): void {if(!preg_match('/\A[a-f0-9]{64}\z/',$key))throw new AppException('Invalid request key.');}
private function reason(string $reason): string {$reason=trim($reason);if(!$reason||strlen($reason)>500)throw new AppException('Enter a correction reason, up to 500 bytes.');return $reason;} private function reason(string $reason): string {$reason=trim($reason);if(!$reason||strlen($reason)>500)throw new AppException('Enter a correction reason, up to 500 bytes.');return $reason;}
private function transaction(callable $work): void { private function transaction(callable $work): void {
$this->requireAdministrator();$db=$this->connect();$db->beginTransaction(); $this->requireAdministrator();$db=$this->connect();
try{$work($db);$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;} DatabaseTransaction::run($db,$work);
} }
private function lockedDue(PDO $db,int $club,int $due): array { private function lockedDue(PDO $db,int $club,int $due): array {
$this->club($club,$db,true); $this->club($club,$db,true);
@@ -25,11 +25,12 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'created dues '.json_encode(['due'=>(int)$db->lastInsertId(),'club'=>$club,'membership'=>$membership,'season'=>$season,'amount_cents'=>$cents])); Audit::required($db,$this->actor(),'created dues '.json_encode(['due'=>(int)$db->lastInsertId(),'club'=>$club,'membership'=>$membership,'season'=>$season,'amount_cents'=>$cents]));
}); });
} }
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void { public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference='',?int $membership=null): void {
$cents=self::cents($amount);self::date($paid);$this->key($key);$reference=trim($reference); $cents=self::cents($amount);self::date($paid);$this->key($key);$reference=trim($reference);
if($cents<=0||$paid>date('Y-m-d')||!in_array($method,['unspecified','cash','check','transfer','other'],true)||strlen($reference)>100)throw new AppException('Enter a positive payment, a valid date/method, and a reference up to 100 bytes.'); if($cents<=0||$paid>date('Y-m-d')||!in_array($method,['unspecified','cash','check','transfer','other'],true)||strlen($reference)>100)throw new AppException('Enter a positive payment, a valid date/method, and a reference up to 100 bytes.');
$this->transaction(function(PDO $db)use($club,$due,$cents,$paid,$key,$method,$reference){ $this->transaction(function(PDO $db)use($club,$due,$cents,$paid,$key,$method,$reference,$membership){
$row=$this->lockedDue($db,$club,$due); $row=$this->lockedDue($db,$club,$due);
if($membership!==null&&(int)$row['membership_serial']!==$membership)throw new AppException('Dues record does not belong to this membership.',404);
$stmt=$db->prepare('SELECT * FROM club_payments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch(); $stmt=$db->prepare('SELECT * FROM club_payments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['amount_cents']!==$cents||$existing['paid_on']!==$paid||$existing['payment_method']!==$method||$existing['payment_reference']!==$reference)throw new AppException('This request key was already used for different payment details.',409);return;} if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['amount_cents']!==$cents||$existing['paid_on']!==$paid||$existing['payment_method']!==$method||$existing['payment_reference']!==$reference)throw new AppException('This request key was already used for different payment details.',409);return;}
if($cents>$row['effective']-$row['paid'])throw new AppException('Payment exceeds the outstanding balance.',409); if($cents>$row['effective']-$row['paid'])throw new AppException('Payment exceeds the outstanding balance.',409);
@@ -37,10 +38,12 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference])); Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference]));
}); });
} }
public function adjust(int $club,int $due,string $total,string $reason,string $key): void { public function adjust(int $club,int $due,string $total,string $reason,string $key,?int $membership=null): void {
$cents=self::cents($total);$reason=$this->reason($reason);$this->key($key); $cents=self::cents($total);$reason=$this->reason($reason);$this->key($key);
$this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key){ $this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key,$membership){
$row=$this->lockedDue($db,$club,$due);$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch(); $row=$this->lockedDue($db,$club,$due);
if($membership!==null&&(int)$row['membership_serial']!==$membership)throw new AppException('Dues record does not belong to this membership.',404);
$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;} if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;}
if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409); if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409);
if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409); if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409);
@@ -48,21 +51,23 @@ class LedgerService extends RosterService {
Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason])); Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason]));
}); });
} }
public function reverse(int $club,int $payment,string $reason,string $key): void { public function reverse(int $club,int $payment,string $reason,string $key,?int $membership=null): void {
$reason=$this->reason($reason);$this->key($key); $reason=$this->reason($reason);$this->key($key);
$this->transaction(function(PDO $db)use($club,$payment,$reason,$key){ $this->transaction(function(PDO $db)use($club,$payment,$reason,$key,$membership){
$this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404); $this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404);
$this->lockedDue($db,$club,(int)$row['due_serial']); $dueRow=$this->lockedDue($db,$club,(int)$row['due_serial']);
if($membership!==null&&(int)$dueRow['membership_serial']!==$membership)throw new AppException('Payment does not belong to this membership.',404);
$stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch(); $stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch();
if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);} if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);}
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]); $stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]);
Audit::required($db,$this->actor(),'reversed payment '.json_encode(['reversal'=>(int)$db->lastInsertId(),'club'=>$club,'payment'=>$payment,'amount_cents'=>(int)$row['amount_cents'],'reason'=>$reason])); Audit::required($db,$this->actor(),'reversed payment '.json_encode(['reversal'=>(int)$db->lastInsertId(),'club'=>$club,'payment'=>$payment,'amount_cents'=>(int)$row['amount_cents'],'reason'=>$reason]));
}); });
} }
public function history(int $club): array { public function history(int $club,?int $page=null,?int $adjustmentPage=null,?int $membership=null): array {
$this->club($club);$result=[];$db=$this->connect(); $this->club($club);$result=[];$db=$this->connect();
$scope=$this->administrator()?'':' AND m.user_serial=?';$args=$this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0]; $scope=$this->manages($club)?'':' AND m.user_serial=?';$args=$this->manages($club)?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];
$stmt=$db->prepare('SELECT p.*,m.user_serial,r.reversal_serial,r.reason AS reversal_reason,r.created_at AS reversed_at,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor,ra.user_name AS reversal_actor FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial LEFT JOIN club_payment_reversals r ON r.payment_serial=p.payment_serial LEFT JOIN users a ON a.user_serial=p.recorded_by LEFT JOIN users ra ON ra.user_serial=r.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY p.paid_on DESC,p.payment_serial DESC');$stmt->execute($args);$result['payments']=$stmt->fetchAll(); if($membership!==null){$this->member($club,$membership);$scope.=' AND m.membership_serial=?';$args[]=$membership;}
$stmt=$db->prepare('SELECT x.*,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor FROM club_due_adjustments x JOIN club_dues d ON d.due_serial=x.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN users a ON a.user_serial=x.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY x.adjustment_serial DESC');$stmt->execute($args);$result['adjustments']=$stmt->fetchAll();return $result; $result['payments']=BoundedList::rows($db,'SELECT p.*,m.user_serial,r.reversal_serial,r.reason AS reversal_reason,r.created_at AS reversed_at,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor,ra.user_name AS reversal_actor FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial LEFT JOIN club_payment_reversals r ON r.payment_serial=p.payment_serial LEFT JOIN users a ON a.user_serial=p.recorded_by LEFT JOIN users ra ON ra.user_serial=r.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY p.paid_on DESC,p.payment_serial DESC',$args,$page,'payments');
$result['adjustments']=BoundedList::rows($db,'SELECT x.*,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor FROM club_due_adjustments x JOIN club_dues d ON d.due_serial=x.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN users a ON a.user_serial=x.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY x.adjustment_serial DESC',$args,$adjustmentPage,'adjustments');return $result;
} }
} }
+25 -29
View File
@@ -120,19 +120,20 @@ class MaintenanceService extends Base {
} }
private function lockAdministrators(PDO $db): array { private function lockAdministrators(PDO $db): array {
return $db->query("SELECT user_serial FROM users WHERE user_status='active' AND user_role IN ('Administrator','System Administrator') ORDER BY user_serial FOR UPDATE")->fetchAll(PDO::FETCH_COLUMN); return $db->query("SELECT user_serial FROM users WHERE user_status='active' AND user_role='Super System Administrator' ORDER BY user_serial FOR UPDATE")->fetchAll(PDO::FETCH_COLUMN);
} }
private function guardAdministrator(array $admins, int $id, string $role, string $status, bool $deleting = false): void { private function guardAdministrator(array $admins, int $id, string $role, string $status, bool $deleting = false): void {
if ($deleting && $id === (int) ($_SESSION[self::USER_SERIAL] ?? 0)) if ($deleting && $id === (int) ($_SESSION[self::USER_SERIAL] ?? 0))
throw new AppException('You cannot delete your own account.', 409); throw new AppException('You cannot delete your own account.', 409);
if (in_array($id, array_map('intval', $admins), true) && count($admins) <= 1 && ($deleting || $status !== 'active' || !in_array($role, ['Administrator', 'System Administrator'], true))) { if (in_array($id, array_map('intval', $admins), true) && count($admins) <= 1 && ($deleting || $status !== 'active' || $role!==TenantAccess::SUPER)) {
throw new AppException('The last active administrator cannot be removed, disabled or demoted.', 409); throw new AppException('The last active Super System Administrator cannot be removed, disabled or demoted.', 409);
} }
} }
public function userUsed(int $id, ?PDO $db = null): bool { public function userUsed(int $id, ?PDO $db = null): bool {
$db ??= $this->connect(); $db ??= $this->connect();
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare("SELECT COUNT(*) FROM notes WHERE note_type='User' AND note_reference=?"); $stmt = $db->prepare("SELECT COUNT(*) FROM notes WHERE note_type='User' AND note_reference=?");
$stmt->execute([$id]); $stmt->execute([$id]);
if ((int) $stmt->fetchColumn() > 0) if ((int) $stmt->fetchColumn() > 0)
@@ -149,20 +150,25 @@ class MaintenanceService extends Base {
if ((int) $stmt->fetchColumn() > 0) if ((int) $stmt->fetchColumn() > 0)
return true; return true;
} }
foreach(['club_invitations'=>'invited_by','club_tickets'=>'user_serial','club_ticket_messages'=>'author_serial'] as $table=>$column){
$stmt=$db->prepare("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?");$stmt->execute([$table]);
if($stmt->fetchColumn()){$stmt=$db->prepare('SELECT 1 FROM '.$table.' WHERE '.$column.'=? LIMIT 1');$stmt->execute([$id]);if($stmt->fetchColumn())return true;}
}
return false; return false;
} }
public function deleteUser(int $id): void { public function deleteUser(int $id): void {
(new TenantAccess())->requireSuper();
$db = $this->connect(); $db = $this->connect();
$db->beginTransaction(); DatabaseTransaction::run($db,function(PDO $db)use($id){
try {
$admins = $this->lockAdministrators($db); $admins = $this->lockAdministrators($db);
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare('SELECT user_role,user_status FROM users WHERE user_serial=? FOR UPDATE'); $stmt = $db->prepare('SELECT user_role,user_status FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]); $stmt->execute([$id]);
$user = $stmt->fetch(); $user = $stmt->fetch();
if (!$user) if (!$user)
throw new AppException('User not found.', 404); throw new AppException('User not found.', 404);
if ($user['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator') if ($user['user_role'] === TenantAccess::SUPER && !(new TenantAccess())->isSuper($db))
throw new AppException('Only a system administrator can modify that account.', 403); throw new AppException('Only a system administrator can modify that account.', 403);
$this->guardAdministrator($admins, $id, $user['user_role'], $user['user_status'], true); $this->guardAdministrator($admins, $id, $user['user_role'], $user['user_status'], true);
if ($this->userUsed($id, $db)) if ($this->userUsed($id, $db))
@@ -172,35 +178,32 @@ class MaintenanceService extends Base {
$stmt = $db->prepare('DELETE FROM users WHERE user_serial=?'); $stmt = $db->prepare('DELETE FROM users WHERE user_serial=?');
$stmt->execute([$id]); $stmt->execute([$id]);
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'deleted unused user ' . $id); Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'deleted unused user ' . $id);
$db->commit(); });
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
} }
public function updateUser(int $id, array $input): void { public function updateUser(int $id, array $input): void {
(new TenantAccess())->requireSuper();
$name = trim($input['user_name'] ?? ''); $name = trim($input['user_name'] ?? '');
$email = trim($input['user_email'] ?? ''); $email = trim($input['user_email'] ?? '');
$first = trim($input['user_first_name'] ?? ''); $first = trim($input['user_first_name'] ?? '');
$last = trim($input['user_last_name'] ?? ''); $last = trim($input['user_last_name'] ?? '');
$role = $input['user_role'] ?? ''; $role = $input['user_role'] ?? '';
$status = self::boolean($input['user_active'] ?? false) ? 'active' : 'inactive'; $status = self::boolean($input['user_active'] ?? false) ? 'active' : 'inactive';
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !in_array($role, ['User', 'Guest', 'Administrator', 'System Administrator'], true)) if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !in_array($role, ['User', 'Guest', 'Administrator', TenantAccess::SUPER], true))
throw new AppException('Enter a valid username, first/last name, email and role.'); throw new AppException('Enter a valid username, first/last name, email and role.');
// Only an existing system administrator may assign that role. // Only an existing system administrator may assign that role.
if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator') if ($role === TenantAccess::SUPER && !(new TenantAccess())->isSuper())
throw new AppException('Only a system administrator can assign that role.', 403); throw new AppException('Only a system administrator can assign that role.', 403);
$db = $this->connect(); $db = $this->connect();
$db->beginTransaction(); DatabaseTransaction::run($db,function(PDO $db)use($id,$name,$email,$first,$last,$role,$status){
try {
$admins = $this->lockAdministrators($db); $admins = $this->lockAdministrators($db);
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE'); $stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]); $stmt->execute([$id]);
$old = $stmt->fetch(); $old = $stmt->fetch();
if (!$old) if (!$old)
throw new AppException('User not found.', 404); throw new AppException('User not found.', 404);
if ($old['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator') if ($old['user_role'] === TenantAccess::SUPER && !(new TenantAccess())->isSuper($db))
throw new AppException('Only a system administrator can modify that account.', 403); throw new AppException('Only a system administrator can modify that account.', 403);
$this->guardAdministrator($admins, $id, $role, $status); $this->guardAdministrator($admins, $id, $role, $status);
$revoke = $old['user_role'] !== $role || $old['user_status'] !== $status || $old['user_email'] !== $email || $old['user_name'] !== $name; $revoke = $old['user_role'] !== $role || $old['user_status'] !== $status || $old['user_email'] !== $email || $old['user_name'] !== $name;
@@ -218,24 +221,21 @@ class MaintenanceService extends Base {
} }
$fields = ['user_name', 'user_email', 'user_first_name', 'user_last_name', 'user_role', 'user_status']; $fields = ['user_name', 'user_email', 'user_first_name', 'user_last_name', 'user_role', 'user_status'];
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'updated user ' . json_encode(['user' => $id, 'before' => array_intersect_key($old, array_flip($fields)), 'after' => ['user_name' => $name, 'user_email' => $email, 'user_first_name' => $first, 'user_last_name' => $last, 'user_role' => $role, 'user_status' => $status]])); Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'updated user ' . json_encode(['user' => $id, 'before' => array_intersect_key($old, array_flip($fields)), 'after' => ['user_name' => $name, 'user_email' => $email, 'user_first_name' => $first, 'user_last_name' => $last, 'user_role' => $role, 'user_status' => $status]]));
$db->commit(); });
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
} }
public function setUserActive(int $id, bool $active): void { public function setUserActive(int $id, bool $active): void {
(new TenantAccess())->requireSuper();
$db = $this->connect(); $db = $this->connect();
$db->beginTransaction(); DatabaseTransaction::run($db,function(PDO $db)use($id,$active){
try {
$admins = $this->lockAdministrators($db); $admins = $this->lockAdministrators($db);
(new TenantAccess())->requireSuper($db);
$stmt = $db->prepare('SELECT user_role FROM users WHERE user_serial=? FOR UPDATE'); $stmt = $db->prepare('SELECT user_role FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$id]); $stmt->execute([$id]);
$role = $stmt->fetchColumn(); $role = $stmt->fetchColumn();
if (!$role) if (!$role)
throw new AppException('User not found.', 404); throw new AppException('User not found.', 404);
if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator') if ($role === TenantAccess::SUPER && !(new TenantAccess())->isSuper($db))
throw new AppException('Only a system administrator can modify that account.', 403); throw new AppException('Only a system administrator can modify that account.', 403);
$this->guardAdministrator($admins, $id, $role, $active ? 'active' : 'inactive'); $this->guardAdministrator($admins, $id, $role, $active ? 'active' : 'inactive');
$stmt = $db->prepare('UPDATE users SET user_status=?,user_auth_version=user_auth_version+1 WHERE user_serial=?'); $stmt = $db->prepare('UPDATE users SET user_status=?,user_auth_version=user_auth_version+1 WHERE user_serial=?');
@@ -245,11 +245,7 @@ class MaintenanceService extends Base {
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?'); $stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
$stmt->execute([$id]); $stmt->execute([$id]);
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'changed user status ' . json_encode(['user' => $id, 'active' => $active])); Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'changed user status ' . json_encode(['user' => $id, 'active' => $active]));
$db->commit(); });
} catch (Throwable $e) {
if ($db->inTransaction())
$db->rollBack();throw $e;
}
} }
public function deleteStatus(int $id): void { public function deleteStatus(int $id): void {
+45
View File
@@ -0,0 +1,45 @@
<?php
/** The same rules apply to create and edit, regardless of browser validation. */
final class MaintenanceValidation {
private static function text(array $input, string $field, string $label, int $limit, bool $required = false, bool $singleLine = false): string {
$value = $input[$field] ?? '';
if (!is_string($value) || !mb_check_encoding($value, 'UTF-8') || str_contains($value, "\0"))
throw new AppException($label . ' must contain valid text.');
$value = trim($value);
if ($required && ($value === '' || preg_match('/\A[\s\p{Z}]*\z/u', $value)))
throw new AppException($label . ' is required.');
if (mb_strlen($value, 'UTF-8') > $limit)
throw new AppException($label . ' must be ' . $limit . ' characters or fewer.');
if ($singleLine && preg_match('/[\x00-\x1f\x7f]/u', $value))
throw new AppException($label . ' must be a single line.');
return $value;
}
private static function timestamp(array $input, string $prefix, string $label): string {
$date = self::text($input, $prefix . '_date', $label . ' date', 10, true, true);
$time = self::text($input, $prefix . '_time', $label . ' time', 5, true, true);
$value = $date . ' ' . $time;
if (!preg_match('/\A[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}\z/', $value))
throw new AppException($label . ' must use a valid YYYY-MM-DD date and HH:MM time.');
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $value, new DateTimeZone(Base::TIMEZONE));
$errors = DateTimeImmutable::getLastErrors();
if (!$parsed || ($errors && ($errors['warning_count'] || $errors['error_count'])) || $parsed->format('Y-m-d H:i') !== $value || $date < '1000-01-01')
throw new AppException($label . ' date/time is invalid.');
return $value;
}
public static function alert(array $input): array {
$title = self::text($input, 'alert_title', 'Alert title', 100, true, true);
$starts = self::timestamp($input, 'alert_starts', 'Start');
$expires = self::timestamp($input, 'alert_expires', 'Expiration');
if ($expires < $starts)
throw new AppException('Expiration cannot be before the start.');
return ['title' => $title, 'starts' => $starts, 'expires' => $expires, 'message' => self::text($input, 'alert_message', 'Alert message', 1000)];
}
public static function popover(array $input): array {
return ['name' => self::text($input, 'popover_name', 'Popover name', 100, true, true), 'title' => self::text($input, 'popover_title', 'Popover title', 100, true, true), 'placeholder' => self::text($input, 'popover_placeholder', 'Popover placeholder', 100, false, true), 'text' => self::text($input, 'popover_text', 'Popover text', 1000)];
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
/** A member summary always belongs to one authorized club membership. */
class MemberSummary extends Base {
private string $memberLabel='';
private static function h(mixed $value): string {return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
private static function money(int $cents): string {return '$'.number_format($cents/100,2);}
private function form(string $step,int $club,int $membership): string {
$url='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons'),'notes_page'=>BoundedList::page('notes')]);
return '<form method="post" action="'.self::h($url).'" class="row g-2 mb-3"><p class="text-secondary">Member: '.self::h($this->memberLabel).'</p><input type="hidden" name="action" value="Roster.memberSummary"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="step" value="'.$step.'"><input type="hidden" name="club_serial" value="'.$club.'"><input type="hidden" name="membership_serial" value="'.$membership.'">';
}
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
$id='member_'.$name.'_'.bin2hex(random_bytes(3));
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" name="'.$name.'" type="'.$type.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
}
private function noteForm(int $club,int $membership,?array $note=null): string {
$id='club_note_'.($note?(int)$note['note_serial']:'new');
$form=$this->form($note?'note_edit':'note_add',$club,$membership);
if($note)$form.='<input type="hidden" name="note_serial" value="'.(int)$note['note_serial'].'"><input type="hidden" name="note_version" value="'.hash('sha256',$note['note_text']).'">';
return $form.'<label class="form-label" for="'.$id.'">Note</label><textarea class="form-control" id="'.$id.'" name="note_text" rows="6" maxlength="2000" required>'.self::h($note['note_text']??'').'</textarea><p>Up to 2,000 characters. Only administrators of this club and Super Administrators can see this note.</p><button class="btn btn-primary">Save note</button></form>';
}
private function deleteNoteForm(int $club,int $membership,array $note): string {
return $this->form('note_delete',$club,$membership).'<input type="hidden" name="note_serial" value="'.(int)$note['note_serial'].'"><input type="hidden" name="note_version" value="'.hash('sha256',$note['note_text']).'"><p>Delete this administrative note? This cannot be undone. A record of the deletion will remain in the audit trail.</p><blockquote style="white-space:pre-wrap;overflow-wrap:anywhere">'.self::h($note['note_text']).'</blockquote><button class="btn btn-danger">Confirm deletion</button></form>';
}
public function show(): void {
$club=MaintenanceService::id($_GET['club_serial']??$_POST['club_serial']??null);
$membership=MaintenanceService::id($_GET['membership_serial']??$_POST['membership_serial']??null);
$service=new RosterService();$member=$service->member($club,$membership);$selected=$service->club($club);
$this->memberLabel=trim($member['user_first_name'].' '.$member['user_last_name']).' ('.$member['user_email'].')';
$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$active=$selected['club_status']==='active';
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();(new TenantAccess())->requireManager($club);
if(!$active)throw new AppException('Reactivate the club before changing records.',409);
if((int)($_POST['membership_serial']??0)!==$membership||(int)($_POST['club_serial']??0)!==$club)throw new AppException('The selected membership changed. Reload its summary.',409);
switch($_POST['step']??''){
case 'member':$service->saveMember($club,$membership,'',$_POST['membership_status']??'',$_POST['joined_on']??'',$_POST['membership_role']??'member');break;
case 'charge':$service->charge($club,$membership,MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
case 'payment':(new LedgerService())->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'',$membership);break;
case 'adjust':(new LedgerService())->adjust($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['corrected_amount']??'',$_POST['reason']??'',$_POST['request_key']??'',$membership);break;
case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'',$membership);break;
case 'note_add':(new ClubMemberNotes())->save($club,$membership,0,$_POST['note_text']??'');break;
case 'note_edit':(new ClubMemberNotes())->save($club,$membership,MaintenanceService::id($_POST['note_serial']??null),$_POST['note_text']??'',$_POST['note_version']??'');break;
case 'note_delete':(new ClubMemberNotes())->delete($club,$membership,MaintenanceService::id($_POST['note_serial']??null),$_POST['note_version']??'');break;
default:throw new AppException('Unknown member action.');
}
$_SESSION['roster_notice']='Changes saved.';
$redirect='main.php?'.http_build_query(['Xaction'=>'Roster.memberSummary','club_serial'=>$club,'membership_serial'=>$membership,'members_page'=>BoundedList::page('members'),'dues_page'=>BoundedList::page('dues'),'payments_page'=>BoundedList::page('payments'),'adjustments_page'=>BoundedList::page('adjustments'),'seasons_page'=>BoundedList::page('seasons'),'notes_page'=>(in_array($_POST['step']??'',['note_add','note_edit','note_delete'],true)?1:BoundedList::page('notes'))]);
if(in_array($_POST['step']??'',['note_add','note_edit','note_delete'],true))$redirect.='#admin-notes';
if(($_SESSION[self::AJAX]??false)&&($_SERVER['HTTP_X_HCR_MODAL']??'')==='1'){header('Content-Type: application/json');echo json_encode(['ok'=>true,'redirect'=>$redirect]);return;}
header('Location: '.$redirect,true,303);return;
}
$dues=$service->dues($club,BoundedList::page('dues'),$membership);
$history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'),$membership);
// Totals include every season, independently of the visible history pages.
$stmt=$this->connect()->prepare('SELECT COALESCE(SUM(d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0)),0) AS dues_cents,COALESCE(SUM(COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0)),0) AS paid_cents FROM club_dues d WHERE d.club_serial=? AND d.membership_serial=?');
$stmt->execute([$club,$membership]);$totals=$stmt->fetch();
$back='main.php?'.http_build_query(['Xaction'=>'Roster.workspace','club_serial'=>$club,'members_page'=>BoundedList::page('members')]).'#roster';
$name=trim($member['user_first_name'].' '.$member['user_last_name']);
$html='<main class="container-fluid py-3 memberSummary"><header class="mb-4"><h1>'.self::h($name).'</h1><p>'.self::h($member['user_email']).'</p><p>Club: '.self::h($selected['club_name']).' · '.self::h(ucfirst($selected['club_status'])).'</p><p>Membership: '.self::h(ucfirst($member['membership_status'])).' · '.self::h(ucfirst($member['membership_role'])).'<br>Joined: '.self::h($member['joined_on']).'</p><a class="btn btn-outline-primary" href="'.self::h($back).'">Back to club roster</a></header>';
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
if($manage&&$active){
$html.='<nav class="d-flex flex-wrap gap-2 mb-3" aria-label="Member actions">';
if($super||$member['membership_role']!=='administrator'){
$html.=$this->form('member',$club,$membership).self::field('joined_on','Joined on','date',$member['joined_on']).'<label class="form-label">Membership status<select class="form-select" name="membership_status"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select></label>';
if($super)$html.='<label class="form-label">Club role<select class="form-select" name="membership_role"><option value="member"'.($member['membership_role']==='member'?' selected':'').'>Member</option><option value="administrator"'.($member['membership_role']==='administrator'?' selected':'').'>Administrator</option></select></label>';
else $html.='<input type="hidden" name="membership_role" value="member">';
$html.='<button class="btn btn-primary">Save membership</button></form>';
}
if($member['membership_status']==='active'){
$seasons=$service->seasons($club,BoundedList::page('seasons'));
if($seasons){$html.=$this->form('charge',$club,$membership).'<p>Add dues for '.self::h($name).'.</p><label class="form-label" for="member_dues_season">Season</label><select class="form-select" name="season_serial" id="member_dues_season">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';$html.='</select>'.self::field('amount','Dues (USD)').'<button class="btn btn-primary">Save dues</button>'.BoundedList::navigation('seasons','Roster.memberSummary').'</form>';}
else $html.='<p>Add a season from the club page before recording dues.</p>';
}
$html.='</nav>';
}
if($manage){
$notes=(new ClubMemberNotes())->listing($club,$membership,BoundedList::page('notes'));
$html.='<section class="mb-4 member-admin-notes" id="admin-notes"><h2 class="h4">Administrative notes</h2><p>Visible only to this club’s administrators and Super Administrators.</p>';
if($active)$html.=$this->noteForm($club,$membership);
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Note</th><th>Updated</th><th>By</th>'.($active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($notes as $note){
$html.='<tr><td style="white-space:pre-wrap;overflow-wrap:anywhere">'.self::h($note['note_text']).'</td><td>'.self::h($note['note_timestamp']).'</td><td>'.self::h($note['note_origin']).'</td>';
if($active)$html.='<td>'.$this->noteForm($club,$membership,$note).$this->deleteNoteForm($club,$membership,$note).'</td>';
$html.='</tr>';
}
if(!$notes)$html.='<tr><td colspan="'.($active?4:3).'">No administrative notes yet.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('notes','Roster.memberSummary').'</section>';
}
$html.='<div class="row g-3 mb-4">';
foreach(['Total dues'=>(int)$totals['dues_cents'],'Payments received'=>(int)$totals['paid_cents'],'Balance due'=>(int)$totals['dues_cents']-(int)$totals['paid_cents']] as $label=>$amount)
$html.='<div class="col-12 col-md-4"><section class="card h-100"><div class="card-body"><h2 class="h6">'.$label.'</h2><p class="h4 mb-0">'.self::money($amount).'</p></div></section></div>';
$html.='</div><p>Amounts are in USD. Totals include all seasons and exclude reversed payments.</p><h2 class="h4 mt-4">Season dues</h2><div class="table-responsive"><table class="table"><thead><tr><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage&&$active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($dues as $due){
$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];
$html.='<tr><td>'.self::h($due['season_name']).'</td><td>'.self::money((int)$due['amount_cents']).'</td><td>'.self::money((int)$due['paid_cents']).'</td><td>'.self::money($balance).'</td>';
if($manage&&$active){
$html.='<td>';
if($balance>0)$html.=$this->form('payment',$club,$membership).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-primary">Record payment</button></form>';
$html.=$this->form('adjust',$club,$membership).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)','text',number_format((int)$due['amount_cents']/100,2,'.','')).self::field('reason','Reason','text','',500).'<p>This records an adjustment and preserves the original dues and payment history.</p><button class="btn btn-primary">Save correction</button></form>';
$html.='</td>';
}
$html.='</tr>';
}
if(!$dues)$html.='<tr><td colspan="'.($manage&&$active?5:4).'">No dues recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('dues','Roster.memberSummary').'<h2 class="h4 mt-4">Payment history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Status</th>'.($manage&&$active?'<th>Actions</th>':'').'</tr></thead><tbody>';
foreach($history['payments'] as $payment){
$status=$payment['reversal_serial']?'Reversed: '.$payment['reversal_reason'].' ('.$payment['reversed_at'].' UTC)':'Received';
$html.='<tr><td>#'.(int)$payment['payment_serial'].' · Dues #'.(int)$payment['due_serial'].'</td><td>'.self::h($payment['paid_on']).'</td><td>'.self::money((int)$payment['amount_cents']).'</td><td>'.self::h($payment['payment_method'].' / '.$payment['payment_reference']).'</td><td>'.self::h($status).'</td>';
if($manage&&$active){
$html.='<td>';
if(!$payment['reversal_serial'])$html.=$this->form('reverse',$club,$membership).'<input type="hidden" name="payment_serial" value="'.(int)$payment['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<p>This preserves the original payment and restores the amount owed. It does not issue a refund.</p><button class="btn btn-outline-danger">Confirm reversal</button></form>';
$html.='</td>';
}
$html.='</tr>';
}
if(!$history['payments'])$html.='<tr><td colspan="'.($manage&&$active?6:5).'">No payments recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('payments','Roster.memberSummary').'<h2 class="h4 mt-4">Dues correction history</h2><div class="table-responsive"><table class="table"><thead><tr><th>Date (UTC)</th><th>Adjustment</th><th>Corrected total</th><th>Reason</th></tr></thead><tbody>';
foreach($history['adjustments'] as $adjustment)$html.='<tr><td>'.self::h($adjustment['created_at']).'</td><td>'.self::money((int)$adjustment['amount_cents']).'</td><td>'.self::money((int)$adjustment['corrected_amount_cents']).'</td><td>'.self::h($adjustment['reason']).'</td></tr>';
if(!$history['adjustments'])$html.='<tr><td colspan="4">No dues corrections recorded for this membership.</td></tr>';
$html.='</tbody></table></div>'.BoundedList::navigation('adjustments','Roster.memberSummary').'</main>';
$this->displayContent($manage&&$active?ClubFormModals::render($html):$html);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
final class MfaClock implements Psr\Clock\ClockInterface {
public function now(): DateTimeImmutable {
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
/** Security notices contain event details, never authenticator or recovery secrets. */
class MfaSecurityNotifications extends Base {
public function send(array $account,string $event): bool {
$events=[
'enrolled'=>'An authenticator was added to your account.',
'replacement_started'=>'An authenticator replacement was requested. Your existing authenticator remains active until replacement is confirmed.',
'replacement_started_recovery'=>'A single-use recovery code was used to request authenticator replacement. Your existing authenticator remains active until replacement is confirmed.',
'replaced'=>'Your authenticator was replaced. Old recovery codes and other sign-in sessions have been revoked.',
'recovery_used'=>'A single-use recovery code was used to authenticate your account.'
];
if(!isset($events[$event]))throw new LogicException('Unknown MFA notification event.');
$h=static fn($text)=>htmlspecialchars((string)$text,ENT_QUOTES,'UTF-8');
$body='<p>'.$h($events[$event]).'</p><p>Account: '.$h($account['user_email']).'<br>Time (UTC): '.$h(gmdate('Y-m-d H:i:s')).'</p><p>If you did not authorize this activity, contact your site administrator immediately and secure your account.</p>';
$ok=false;
try{$ok=$this->deliver((string)$account['user_email'],self::APPLICATION_NAME.' - Account security notice',$body);}
catch(Throwable $error){/* Keep secrets and provider diagnostics out of this event log. */}
if(!$ok)error_log('MFA security notification failed user='.(int)$account['user_serial'].' event='.$event);
return $ok;
}
protected function deliver(string $to,string $subject,string $body): bool {return (new Email())->sendEmail($to,$subject,$body);}
public static function notice(): string {
if(empty($_SESSION['mfa_notification_warning']))return '';
unset($_SESSION['mfa_notification_warning']);
return '<p class="alert alert-warning" role="alert">Your security action succeeded, but its notification email could not be sent. Please contact support.</p>';
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
use OTPHP\TOTP;
/** Authentication factors belong to the account, never to a selected tenant. */
class MfaService extends Base {
public const RECENT_SECONDS = 300;
private function key(): string {
$encoded = getenv('HCR_MFA_KEY');
// Local development servers do not necessarily inherit shell variables.
// Keep their persistent secret outside the application and its releases.
if ($encoded === false) {
$settings = $this->getSettings();
$file = getenv('HCR_MFA_KEY_FILE');
if ($file === false)
$file = (string) ($settings->MfaKeyFile ?? '');
if ((string) $settings->Environment === 'Development' && $file !== '') {
$resolved = realpath($file);
$root = realpath(__DIR__ . '/..');
$normalize = static fn($path) => strtolower(str_replace('\\', '/', $path));
if ($resolved === false || !is_file($resolved) || !is_readable($resolved) || filesize($resolved) > 128 || str_starts_with($normalize($resolved), $normalize($root) . '/'))
throw new RuntimeException('Development MFA key must be a readable file outside the application directory.');
$encoded = trim(file_get_contents($resolved));
}
}
$key = base64_decode($encoded === false ? '' : $encoded, true);
if ($key === false || strlen($key) !== 32)
throw new RuntimeException('Configure HCR_MFA_KEY with a base64-encoded random 32-byte key.');
return $key;
}
public function sealSecret(string $secret, int $user): string {
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt($secret, 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, $iv, $tag, (string) $user);
if ($cipher === false)
throw new RuntimeException('Cannot protect authenticator secret.');
return base64_encode($iv . $tag . $cipher);
}
private function decrypt(string $cipher, int $user): string {
$raw = base64_decode($cipher, true);
if ($raw === false || strlen($raw) < 29)
throw new RuntimeException('Invalid authenticator secret.');
$secret = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16), (string) $user);
if ($secret === false)
throw new RuntimeException('Cannot unlock authenticator secret.');return $secret;
}
public function enrolled(int $user): bool {
$stmt = $this->connect()->prepare('SELECT 1 FROM user_mfa WHERE user_serial=?');
$stmt->execute([$user]);
return (bool) $stmt->fetchColumn();
}
public function required(int $user): bool {
return (new TenantAccess())->managesAny() || $this->enrolled($user);
}
public function verified(int $user): bool {
return ($_SESSION['mfa_user'] ?? 0) === $user;
}
private function limited(int $user): void {
$auth = new AuthService();
if (!$auth->allowAttempt('mfa-account', (string) $user, 10, 300) || !$auth->allowAttempt('mfa-ip', $_SERVER['REMOTE_ADDR'] ?? '', 40, 300))
throw new AppException('Too many authentication attempts. Try again later.', 429);
}
private static function counter(string $secret, string $code, int $last): ?int {
if (!preg_match('/\A[0-9]{6}\z/', $code))
return null;
$otp = TOTP::createFromSecret($secret, new MfaClock());
$now = intdiv(time(), 30);
foreach ([$now, $now - 1, $now + 1] as $counter)
if ($counter > $last && hash_equals($otp->at($counter * 30), $code))
return $counter;
return null;
}
/** All factor mutations use the account-then-factor lock order. */
private function account(PDO $db,int $user,string $password): ?array {
$stmt=$db->prepare('SELECT user_serial,user_email,user_password_hash,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
$stmt->execute([$user]);$account=$stmt->fetch();
if(!$account||$account['user_status']!=='active'||($_SESSION[self::USER_SERIAL]??0)!==$user
||($_SESSION['auth_version']??-1)!==(int)$account['user_auth_version']
||!password_verify($password,$account['user_password_hash']))return null;
return $account;
}
private function factor(PDO $db,int $user): ?array {
$stmt=$db->prepare('SELECT * FROM user_mfa WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);return $stmt->fetch()?:null;
}
private function consume(PDO $db,int $user,array $factor,string $code): ?string {
$code=trim($code);$counter=self::counter($this->decrypt($factor['secret_cipher'],$user),$code,(int)$factor['last_counter']);
if($counter!==null){
$stmt=$db->prepare('UPDATE user_mfa SET last_counter=? WHERE user_serial=?');$stmt->execute([$counter,$user]);return 'totp';
}
$code=strtolower($code);if(!preg_match('/\A[a-f0-9]{32}\z/',$code))return null;
$stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=? AND code_hash=?');$stmt->execute([$user,hash('sha256',$code)]);
return $stmt->rowCount()===1?'recovery':null;
}
protected function notify(array $account,string $event): void {
if(!(new MfaSecurityNotifications())->send($account,$event))$_SESSION['mfa_notification_warning']=true;
}
public function verify(int $user,string $code,string $password): bool {
$this->limited($user);
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){
$account=$this->account($db,$user,$password);if(!$account)return null;
$factor=$this->factor($db,$user);if(!$factor)return null;
$kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null;
Audit::required($db,$user,$kind==='recovery'?'used MFA recovery code':'verified authenticator');
return ['account'=>$account,'kind'=>$kind];
});
if(!$result)return false;
if($result['kind']==='recovery')$this->notify($result['account'],'recovery_used');
return true;
}
private function recoveryCodes(PDO $db,int $user): array {
$stmt=$db->prepare('DELETE FROM mfa_recovery WHERE user_serial=?');$stmt->execute([$user]);
$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(?,?)');$codes=[];
for($i=0;$i<10;$i++){$code=bin2hex(random_bytes(16));$codes[]=$code;$stmt->execute([$user,hash('sha256',$code)]);}
return $codes;
}
public function enroll(int $user,string $secret,string $code,string $password): array {
$this->limited($user);$pending=$_SESSION['mfa_pending']??[];
if(($pending['user']??0)!==$user||($pending['expires']??0)<time()||!hash_equals($pending['secret']??'',$secret))throw new AppException('Authenticator setup expired. Reload the page.',409);
$counter=self::counter($secret,trim($code),-1);if($counter===null)throw new AppException('Enter the current authenticator code.');
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$secret,$counter,$password){
$account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403);
if($this->factor($db,$user))throw new AppException('Authenticator is already enrolled.',409);
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher,last_counter) VALUES(?,?,?)');$stmt->execute([$user,$this->sealSecret($secret,$user),$counter]);
$codes=$this->recoveryCodes($db,$user);
$stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]);
Audit::required($db,$user,'enrolled authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1];
});
unset($_SESSION['mfa_pending'],$_SESSION['mfa_replacement']);$_SESSION['auth_version']=$result['version'];
$this->notify($result['account'],'enrolled');return $result['codes'];
}
/** Prove the old factor, but keep it and the remaining recovery codes active. */
public function beginReplacement(int $user,string $code,string $password): bool {
$this->limited($user);
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$code,$password){
$account=$this->account($db,$user,$password);if(!$account)return null;
$factor=$this->factor($db,$user);if(!$factor)return null;
$kind=$this->consume($db,$user,$factor,$code);if($kind===null)return null;
if($kind==='recovery')Audit::required($db,$user,'used MFA recovery code');
Audit::required($db,$user,'requested authenticator replacement');
return ['account'=>$account,'kind'=>$kind,'factor'=>hash('sha256',$factor['secret_cipher'])];
});
if(!$result)return false;
$_SESSION['mfa_replacement']=['user'=>$user,'version'=>(int)$result['account']['user_auth_version'],'factor'=>$result['factor'],
'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600];
$this->mark($user);
$this->notify($result['account'],$result['kind']==='recovery'?'replacement_started_recovery':'replacement_started');return true;
}
private function replacement(int $user): ?array {
$pending=$_SESSION['mfa_replacement']??null;
if(!$pending)return null;
if(($pending['user']??0)!==$user||($pending['version']??-1)!==($_SESSION['auth_version']??-2)||($pending['expires']??0)<time()){
unset($_SESSION['mfa_replacement']);return null;
}
return $pending;
}
public function completeReplacement(int $user,string $code,string $password): array {
$this->limited($user);$pending=$this->replacement($user);
if(!$pending)throw new AppException('Replacement setup expired. Your existing authenticator is still active.',409);
$counter=self::counter($pending['secret'],trim($code),-1);if($counter===null)throw new AppException('Enter the code from your new authenticator.');
$result=DatabaseTransaction::run($this->connect(),function(PDO $db)use($user,$password,$pending,$counter){
$account=$this->account($db,$user,$password);if(!$account)throw new AppException('Current password is incorrect or your session has expired.',403);
$factor=$this->factor($db,$user);
if(!$factor||!hash_equals($pending['factor'],hash('sha256',$factor['secret_cipher'])))throw new AppException('Authenticator changed. Start replacement again.',409);
$stmt=$db->prepare('UPDATE user_mfa SET secret_cipher=?,last_counter=? WHERE user_serial=?');$stmt->execute([$this->sealSecret($pending['secret'],$user),$counter,$user]);
$codes=$this->recoveryCodes($db,$user);
$stmt=$db->prepare('UPDATE users SET user_auth_version=user_auth_version+1 WHERE user_serial=?');$stmt->execute([$user]);
Audit::required($db,$user,'replaced authenticator');return ['codes'=>$codes,'account'=>$account,'version'=>(int)$account['user_auth_version']+1];
});
unset($_SESSION['mfa_replacement'],$_SESSION['mfa_pending']);$_SESSION['auth_version']=$result['version'];$this->mark($user);
$this->notify($result['account'],'replaced');return $result['codes'];
}
private function mark(int $user): void {
session_regenerate_id(true);$_SESSION['mfa_user']=$user;$_SESSION['recent_auth']=time();$_SESSION['csrf_token']=bin2hex(random_bytes(32));
}
public function gate(array $user,string $action): bool {
$id=(int)$user['user_serial'];
if($action!=='security'&&(!$this->required($id)||$this->verified($id)))return false;
if(!empty($_SESSION[self::AJAX])&&$action!=='security')throw new AppException('Complete administrator authentication from the home page.',403);
$this->page($user);return true;
}
public function page(array $user): void {
$id=(int)$user['user_serial'];$error='';$codes=[];$enrolled=$this->enrolled($id);
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();
try{
$action=$_POST['factor_action']??'';
if($action==='cancelReplacement'){
unset($_SESSION['mfa_replacement']);header('Location: main.php?Xaction=security',true,303);return;
}elseif($action==='confirmReplacement'){
$codes=$this->completeReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??'');
}elseif(!$enrolled){
$codes=$this->enroll($id,$_SESSION['mfa_pending']['secret']??'',$_POST['mfa_code']??'',$_POST['current_password']??'');$enrolled=true;$this->mark($id);
}elseif($action==='replace'){
if(!$this->beginReplacement($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403);
header('Location: main.php?Xaction=security',true,303);return;
}else{
if(!$this->verify($id,$_POST['mfa_code']??'',$_POST['current_password']??''))throw new AppException('Password or authentication code is incorrect.',403);
$this->mark($id);header('Location: main.php',true,303);return;
}
}catch(AppException $errorObject){http_response_code($errorObject->status);$error=$errorObject->getMessage();}
}
$pending=$this->replacement($id);$h=static fn($value)=>htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');
$html='<main class="container py-3"><h1>Account security</h1><p role="alert">'.$h($error).'</p>'.MfaSecurityNotifications::notice();
if($codes){
$html.='<h2>Save your recovery codes</h2><p>These single-use codes are shown only now. Store them securely away from your authenticator. You will need one if your device is lost.</p><pre>'.implode("\n",$codes).'</pre><a href="main.php">I have saved my recovery codes</a>';
}else{
if(!$enrolled||$pending){
if(!$pending){
$this->key();$initial=$_SESSION['mfa_pending']??[];
if(($initial['user']??0)!==$id||($initial['expires']??0)<time())$_SESSION['mfa_pending']=['user'=>$id,'secret'=>TOTP::generate(new MfaClock())->getSecret(),'expires'=>time()+600];
}
$secret=$pending['secret']??$_SESSION['mfa_pending']['secret'];$qr=MfaSetupQr::dataUri($secret,$user['user_email']);
if($pending)$html.='<h2>Confirm your new authenticator</h2><p>Your existing authenticator remains active until you confirm this replacement. This setup expires after ten minutes.</p>';
$html.='<p>Scan this QR code with your authenticator app, then enter its six-digit code below.</p><img class="mfa-setup-qr" src="'.$qr.'" alt="Scan to set up your authenticator" width="320" height="320" style="max-width:100%;height:auto"><p>If you cannot scan it, use this setup key:</p><pre>'.$h($secret).'</pre><p>Account: '.$h($user['user_email']).'. Six digits, 30-second period, SHA-1.</p>';
}else $html.='<p>Enter your password and an authenticator code or a single-use recovery code.</p>';
$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="security"><input type="hidden" name="csrf_token" value="'.$h($this->csrfToken()).'">';
if($pending)$html.='<input type="hidden" name="factor_action" value="confirmReplacement">';
$html.='<label>Current password<input class="form-control" type="password" name="current_password" autocomplete="current-password" required></label><label>Authentication code<input class="form-control" name="mfa_code" autocomplete="one-time-code" maxlength="32" required></label><button class="btn btn-primary">'.($pending?'Confirm replacement':'Verify').'</button>';
if($enrolled&&!$pending)$html.='<button class="btn btn-outline-warning" name="factor_action" value="replace">Replace lost authenticator</button><p>Replacement requires your password and a current authenticator or unused recovery code. Your old factor remains active until the new one is confirmed.</p>';
$html.='</form>';
if($pending)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="security"><input type="hidden" name="csrf_token" value="'.$h($this->csrfToken()).'"><button class="btn btn-outline-secondary" name="factor_action" value="cancelReplacement">Cancel replacement</button></form>';
}
$this->displayContent($html.'</main>');
}
public function requireRecent(): void {
if(($_SESSION['recent_auth']??0)<time()-self::RECENT_SECONDS)throw new AppException('Open Account security from the home page, confirm your password and authenticator code, then submit the change again.',403);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/** Render the pending authenticator secret without external services or files. */
final class MfaSetupQr {
public static function dataUri(string $secret,string $email): string {
$otp=OTPHP\TOTP::createFromSecret($secret,new MfaClock())
->withIssuer(Base::APPLICATION_NAME)->withLabel($email);
$barcode=new TCPDF2DBarcode($otp->getProvisioningUri(),'QRCODE,M');
$matrix=$barcode->getBarcodeArray();
$size=(int)$matrix['num_cols'];$height=(int)$matrix['num_rows'];
if($size<1||$height!==$size)throw new RuntimeException('Cannot generate authenticator QR code.');
// A four-module white quiet zone keeps the image scannable on dark pages.
$edge=$size+8;$path='';
for($y=0;$y<$size;$y++)for($x=0;$x<$size;$x++){
if($matrix['bcode'][$y][$x])$path.='M'.($x+4).' '.($y+4).'h1v1h-1z';
}
$svg='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '.$edge.' '.$edge.'" width="'.$edge.'" height="'.$edge.'" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="'.$path.'" fill="black"/></svg>';
return 'data:image/svg+xml;base64,'.base64_encode($svg);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ class Notes extends Base {
$this->displayContent($this->applyXSL($note,$this->getXSL('editNote'))); $this->displayContent($this->applyXSL($note,$this->getXSL('editNote')));
} }
public function deleteNote(){ public function deleteNote(){
$stmt=$this->connect()->prepare('DELETE FROM notes WHERE note_serial=?');$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404); $stmt=$this->connect()->prepare("DELETE FROM notes WHERE note_serial=? AND note_type='User'");$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404);
} }
public function write(int $id,string $type,int $reference,string $text): int { public function write(int $id,string $type,int $reference,string $text): int {
$text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).'); $text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).');
+10 -12
View File
@@ -51,10 +51,8 @@ class Popovers extends Base {
case "add": case "add":
$popover_name = filter_input(INPUT_POST, "popover_name") ?: ""; $data=MaintenanceValidation::popover($_POST);
$popover_title = filter_input(INPUT_POST, "popover_title") ?: ""; $popover_name=$data['name'];$popover_title=$data['title'];$popover_placeholder=$data['placeholder'];$popover_text=$data['text'];
$popover_placeholder = filter_input(INPUT_POST, "popover_placeholder") ?: "";
$popover_text = filter_input(INPUT_POST, "popover_text") ?: "";
$SQL = "insert into popovers $SQL = "insert into popovers
@@ -97,7 +95,7 @@ class Popovers extends Base {
public function deletePopover() { public function deletePopover() {
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0; $popover_serial = MaintenanceService::id($_POST['popover_serial']??null);
$popover = $this->getPopover($popover_serial); $popover = $this->getPopover($popover_serial);
@@ -123,7 +121,7 @@ class Popovers extends Base {
case "prompt": case "prompt":
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0; $popover_serial = MaintenanceService::id($_POST['popover_serial']??null);
$popover = $this->getPopover($popover_serial); $popover = $this->getPopover($popover_serial);
@@ -141,11 +139,9 @@ class Popovers extends Base {
case "update": case "update":
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0; $popover_serial = MaintenanceService::id($_POST['popover_serial']??null);
$popover_name = filter_input(INPUT_POST, "popover_name") ?: ""; $data=MaintenanceValidation::popover($_POST);
$popover_title = filter_input(INPUT_POST, "popover_title") ?: ""; $popover_name=$data['name'];$popover_title=$data['title'];$popover_placeholder=$data['placeholder'];$popover_text=$data['text'];
$popover_placeholder = filter_input(INPUT_POST, "popover_placeholder") ?: "";
$popover_text = filter_input(INPUT_POST, "popover_text") ?: "";
$popover = $this->getPopover($popover_serial); $popover = $this->getPopover($popover_serial);
@@ -187,6 +183,7 @@ class Popovers extends Base {
// ======================= // =======================
public function getPopover($popover_serial = 0) { public function getPopover($popover_serial = 0) {
$popover_serial=MaintenanceService::id($popover_serial);
$SQL = "select view_popovers.* $SQL = "select view_popovers.*
from view_popovers from view_popovers
@@ -299,7 +296,8 @@ class Popovers extends Base {
public function popoverExists() { public function popoverExists() {
$popover_serial = filter_input(INPUT_POST, "popover_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0; $popover_serial = filter_var($_POST['popover_serial']??0,FILTER_VALIDATE_INT,['options'=>['min_range'=>0]]);
if($popover_serial===false)throw new AppException('Invalid popover ID.');
$popover_name = filter_input(INPUT_POST, "popover_name") ?: ""; $popover_name = filter_input(INPUT_POST, "popover_name") ?: "";
$popover_name = strtolower($popover_name); $popover_name = strtolower($popover_name);
+108 -37
View File
@@ -9,33 +9,48 @@ class Roster extends Base {
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" type="'.$type.'" name="'.$name.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>'; return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" type="'.$type.'" name="'.$name.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
} }
public function help(): void { public function help(): void {
$this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>Site administrators create clubs, assign existing accounts, add seasons, and record dues and received payments. Contact your club administrator for membership or account assistance.</p></main>'); $this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>Your Super System Administrator creates clubs and assigns club administrators. Club administrators invite members, add seasons, record dues and payments, and answer tickets only in their clubs. Members can view club information and their own dues, and submit tickets. Accept invitations through Invitations after verifying your email.</p></main>');
} }
public function home(): void { public function home(): void {
$service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>'; $service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p><a href="main.php?Xaction=security">Account security and authenticator recovery</a></p><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>';
if(!$service->ready()){ if(!$service->ready()){
$html.=$service->administrator()?'<p>Club setup is ready to install. Run the database migrations described in PRODUCTION-FIXES.md.</p>':'<p>Your club workspace is being prepared. Please contact the site administrator.</p>'; $html.=$service->administrator()?'<p>Club setup is ready to install. Run the database migrations described in PRODUCTION-FIXES.md.</p>':'<p>Your club workspace is being prepared. Please contact the site administrator.</p>';
}else{ }else{
$clubs=$service->clubs(); $clubs=$service->clubs(BoundedList::page('clubs'));
if((BoundedList::$pages['clubs']['count']??0)===1 && $clubs){
header('Location: main.php?Xaction=Roster.workspace&club_serial='.(int)$clubs[0]['club_serial'],true,303);
return;
}
if(!$clubs)$html.='<p>You are not assigned to an active club yet. A site administrator can create clubs and assign memberships.</p>'; if(!$clubs)$html.='<p>You are not assigned to an active club yet. A site administrator can create clubs and assign memberships.</p>';
foreach($clubs as $club)$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary" href="main.php?Xaction=Roster.workspace&amp;club_serial='.(int)$club['club_serial'].'">Open club</a></div></section>'; $html.='<div class="row">';
foreach($clubs as $club)$html.='<div class="col-12 col-md-6 col-xl-4 mb-3"><section class="card h-100"><div class="card-body d-flex flex-column"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary mt-auto align-self-start" href="main.php?Xaction=Roster.workspace&amp;club_serial='.(int)$club['club_serial'].'">Open club</a></div></section></div>';
$html.='</div>'.BoundedList::navigation('clubs','displayHome');
$html.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>'; $html.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>';
} }
$this->displayContent($html.'</div>'); $this->displayContent($html.MfaSecurityNotifications::notice().'</div>');
} }
private function financialHistory(int $club,bool $active,bool $admin): string { private function financialHistory(int $club,bool $active,bool $admin): string {
$history=(new LedgerService())->history($club);$html='<h3 class="h4 mt-4">Payment history</h3>'; $history=(new LedgerService())->history($club,BoundedList::page('payments'),BoundedList::page('adjustments'));$html='<h2 class="h4 mt-4">Payment history</h2>';
if($admin)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.ledger"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="club_serial" value="'.$club.'"><button class="btn btn-outline-primary">Export financial history (CSV)</button></form>'; if($admin)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.ledger"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="club_serial" value="'.$club.'"><button class="btn btn-outline-primary">Export financial history (CSV)</button></form>';
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>'; $html.='<div class="table-responsive position-relative"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>';
foreach($history['payments'] as $p){ foreach($history['payments'] as $p){
$html.='<tr><td>#'.(int)$p['payment_serial'].'</td><td>'.self::h($p['member_name']).'</td><td>'.self::h($p['paid_on']).'</td><td>$'.number_format($p['amount_cents']/100,2).'</td><td>'.self::h($p['payment_method'].' '.$p['payment_reference']).'</td><td>'.self::h($p['actor']??'Historical record').'</td><td>'; $html.='<tr><td>#'.(int)$p['payment_serial'].'</td><td>'.self::h($p['member_name']).'</td><td>'.self::h($p['paid_on']).'</td><td>$'.number_format($p['amount_cents']/100,2).'</td><td>'.self::h($p['payment_method'].' '.$p['payment_reference']).'</td><td>'.self::h($p['actor']??'Historical record').'</td><td>';
if($p['reversal_serial'])$html.='Reversed: '.self::h($p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']); if($p['reversal_serial'])$html.='Reversed: '.self::h($p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']);
elseif($admin&&$active)$html.='<details><summary>Reverse incorrect payment</summary><p>This preserves the original entry and restores the amount owed. It does not send a refund.</p>'.$this->form('reverse',$club).'<input type="hidden" name="payment_serial" value="'.(int)$p['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<button class="btn btn-outline-danger">Confirm reversal</button></form></details>'; elseif($admin&&$active)$html.='<details><summary>Reverse incorrect payment</summary><p>This preserves the original entry and restores the amount owed. It does not send a refund.</p>'.$this->form('reverse',$club).'<input type="hidden" name="payment_serial" value="'.(int)$p['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<button class="btn btn-outline-danger">Confirm reversal</button></form></details>';
else $html.='Received';$html.='</td></tr>'; else $html.='Received';$html.='</td></tr>';
} }
$html.='</tbody></table></div><h3 class="h4">Dues correction history</h3><ul>'; $html.='</tbody></table></div>'.BoundedList::navigation('payments').'<h2 class="h4 mt-4">Dues correction history</h2><ul>';
foreach($history['adjustments'] as $a)$html.='<li>'.self::h($a['member_name']).': $'.number_format($a['amount_cents']/100,2).' adjustment; corrected total $'.number_format($a['corrected_amount_cents']/100,2).' — '.self::h($a['reason'].' ('.$a['actor'].', '.$a['created_at'].')').'</li>'; foreach($history['adjustments'] as $a)$html.='<li>'.self::h($a['member_name']).': $'.number_format($a['amount_cents']/100,2).' adjustment; corrected total $'.number_format($a['corrected_amount_cents']/100,2).' — '.self::h($a['reason'].' ('.$a['actor'].', '.$a['created_at'].')').'</li>';
return $html.'</ul>'; return $html.'</ul>'.BoundedList::navigation('adjustments');
}
public function createClub(): void {
(new TenantAccess())->requireSuper();$service=new RosterService();
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST'){
$this->requireCsrf();$club=$service->saveClub(0,$_POST['club_name']??'');
$_SESSION['roster_notice']='Club created.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);return;
}
$form=str_replace('value="Roster.workspace"','value="Roster.createClub"',$this->form('club'));
$this->displayContent('<main class="container-fluid py-3"><h1>Create club</h1><p>Create a separate club, then assign its administrator from the club roster.</p>'.$form.self::field('club_name','Club name').'<div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form><a class="btn btn-outline-primary" href="main.php">Back to clubs</a></main>');
} }
public function workspace(): void { public function workspace(): void {
$service=new RosterService();if(!$service->ready()){$this->home();return;} $service=new RosterService();if(!$service->ready()){$this->home();return;}
@@ -44,7 +59,9 @@ class Roster extends Base {
$service->requireAdministrator();$this->requireCsrf(); $service->requireAdministrator();$this->requireCsrf();
switch($_POST['step']){ switch($_POST['step']){
case 'club':$club=$service->saveClub($club,$_POST['club_name']??'',$_POST['club_status']??'active');break; case 'club':$club=$service->saveClub($club,$_POST['club_name']??'',$_POST['club_status']??'active');break;
case 'member':$service->saveMember($club,empty($_POST['membership_serial'])?0:MaintenanceService::id($_POST['membership_serial']),$_POST['identifier']??'',$_POST['membership_status']??'active',$_POST['joined_on']??'');break; case 'member':$service->saveMember($club,empty($_POST['membership_serial'])?0:MaintenanceService::id($_POST['membership_serial']),$_POST['identifier']??'',$_POST['membership_status']??'active',$_POST['joined_on']??'',$_POST['membership_role']??'member');break;
case 'invite':$result=(new ClubInvitations())->invite($club,$_POST['invite_email']??'',$_POST['membership_role']??'member');$_SESSION['club_notice']=$result['message'].' '.$result['warning'];break;
case 'revokeInvitation':(new ClubInvitations())->revoke($club,MaintenanceService::id($_POST['invitation_serial']??null));$_SESSION['club_notice']='Invitation revoked.';break;
case 'season':$service->addSeason($club,$_POST['season_name']??'',$_POST['starts_on']??'',$_POST['ends_on']??'');break; case 'season':$service->addSeason($club,$_POST['season_name']??'',$_POST['starts_on']??'',$_POST['ends_on']??'');break;
case 'charge':$service->charge($club,MaintenanceService::id($_POST['membership_serial']??null),MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break; case 'charge':$service->charge($club,MaintenanceService::id($_POST['membership_serial']??null),MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
case 'payment':$service->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'');break; case 'payment':$service->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'');break;
@@ -52,45 +69,99 @@ class Roster extends Base {
case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'');break; case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'');break;
default:throw new AppException('Unknown club action.'); default:throw new AppException('Unknown club action.');
} }
$_SESSION['roster_notice']='Changes saved.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);exit; $_SESSION['roster_notice']='Changes saved.';
$redirect='main.php?Xaction=Roster.workspace&club_serial='.$club;
if(($_SESSION[self::AJAX]??false)&&($_SERVER['HTTP_X_HCR_MODAL']??'')==='1'){
header('Content-Type: application/json');echo json_encode(['ok'=>true,'redirect'=>$redirect]);return;
} }
$html='<div class="container-fluid py-3"><h1>Clubs and memberships</h1>'; header('Location: '.$redirect,true,303);exit;
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);} }
$clubs=$service->clubs();$html.='<nav aria-label="Clubs" class="mb-3">'; $clubs=$service->clubs(BoundedList::page('clubs'));
foreach($clubs as $row)$html.='<a class="btn btn-outline-primary me-2 mb-2" href="main.php?Xaction=Roster.workspace&amp;club_serial='.(int)$row['club_serial'].'">'.self::h($row['club_name']).'</a>';
$html.='</nav>';
if($service->administrator())$html.='<section><h2 class="h4">Create club</h2>'.$this->form('club').self::field('club_name','Club name').' <div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form></section>';
if(!$club && $clubs)$club=(int)$clubs[0]['club_serial']; if(!$club && $clubs)$club=(int)$clubs[0]['club_serial'];
if(!$club){$this->displayContent($html.'<p>No clubs are available.</p></div>');return;} if(!$club){$this->displayContent('<main class="container-fluid py-3"><h1>Clubs and memberships</h1><p>No clubs are available. Accept an invitation to join your club.</p><a class="btn btn-primary" href="main.php?Xaction=Roster.invitations">View invitations</a></main>');return;}
$selected=$service->club($club);$members=$service->members($club);$seasons=$service->seasons($club);$dues=$service->dues($club); $selected=$service->club($club);$manage=$service->manages($club);$super=(new TenantAccess())->isSuper();$members=$service->members($club,BoundedList::page('members'));$seasons=$service->seasons($club,BoundedList::page('seasons'));$dues=$service->dues($club,BoundedList::page('dues'));
$html.='<hr><h2>'.self::h($selected['club_name']).'</h2>'; $html='<main class="container-fluid py-3 clubSummary"><header class="mb-4"><h1>'.self::h($selected['club_name']).'</h1><p>Club status: '.self::h(ucfirst($selected['club_status'])).' · '.($super?'Super System Administrator':($manage?'Club administrator':'Member')).'</p><p>'.($manage?'Manage your club roster, invitations, seasons and dues.':'View your membership, season dues and payment history.').'</p><nav class="d-flex flex-wrap gap-2" aria-label="Club actions"><a class="btn btn-primary" href="main.php?Xaction=Roster.tickets&amp;club_serial='.$club.'">'.($manage?'Club tickets':'My tickets').'</a><a class="btn btn-outline-primary" href="#roster">'.($manage?'View roster':'My membership').'</a><a class="btn btn-outline-primary" href="#dues">'.($manage?'Season dues':'My dues').'</a>';
if($service->administrator()){ if($manage && $selected['club_status']==='active')$html.='<a class="btn btn-outline-primary" href="#club-invitations">Invite members</a>';
$html.='<details class="mb-3"><summary>Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>'; if((BoundedList::$pages['clubs']['count']??0)>1){
$html.='<a class="btn btn-outline-primary" href="main.php?Xaction=displayHome">Switch club</a>';
} }
$html.='<h3 class="h4">Roster</h3><div class="table-responsive"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($service->administrator()?'<th>Update membership</th>':'').'</tr></thead><tbody>'; $html.='</nav></header>'.MfaSecurityNotifications::notice();
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
if(isset($_SESSION['club_notice'])){$html.='<p role="status">'.self::h($_SESSION['club_notice']).'</p>';unset($_SESSION['club_notice']);}
if($manage){
$html.='<details class="mb-3"><summary class="btn btn-outline-primary">Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>';
}
$html.='<h2 class="h4 mt-4" id="roster">'.($manage?'Roster':'My membership').'</h2><div class="table-responsive position-relative"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($manage?'<th>Update membership</th>':'').'</tr></thead><tbody>';
foreach($members as $member){ foreach($members as $member){
$html.='<tr><td>'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status']).'</td><td>'.self::h($member['joined_on']).'</td>'; $html.='<tr><td><a href="main.php?Xaction=Roster.memberSummary&amp;club_serial='.$club.'&amp;membership_serial='.(int)$member['membership_serial'].'&amp;members_page='.(int)BoundedList::$pages['members']['page'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</a></td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status'].' / '.$member['membership_role']).'</td><td>'.self::h($member['joined_on']).'</td>';
if($service->administrator())$html.='<td>'.$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'"><label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form></td>'; if($manage)$html.='<td>'.(($super||$member['membership_role']!=='administrator')?$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'">'.($super?'<label>Club role<select class="form-select" name="membership_role"><option value="member"'.($member['membership_role']==='member'?' selected':'').'>Member</option><option value="administrator"'.($member['membership_role']==='administrator'?' selected':'').'>Administrator</option></select></label>':'<input type="hidden" name="membership_role" value="member">').'<label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form>':'Club administrator (managed by Super System Administrator)').'</td>';
$html.='</tr>'; $html.='</tr>';
} }
if(!$members)$html.='<tr><td colspan="5">No members assigned.</td></tr>'; if(!$members)$html.='<tr><td colspan="5">No members assigned.</td></tr>';
$html.='</tbody></table></div>'; $html.='</tbody></table></div>'.BoundedList::navigation('members');
if($service->administrator() && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Create or invite accounts through Users before assigning them to a club.</p>'; if($super && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Global account management is available through Users. To appoint an administrator, assign the account, then change its club role above.</p>';
$html.='<h3 class="h4">Seasons</h3><ul>'; if($manage && $selected['club_status']==='active'){
foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>'; $html.='<h2 class="h4 mt-4" id="club-invitations">Invitations</h2>'.$this->form('invite',$club).self::field('invite_email','Invitation email','email','',100);
$html.='</ul>'; if($super)$html.='<label>Club role<select class="form-select" name="membership_role"><option value="member">Member</option><option value="administrator">Administrator</option></select></label>';
if($service->administrator() && $selected['club_status']==='active')$html.=$this->form('season',$club).self::field('season_name','Season name','text','',100).self::field('starts_on','Starts on','date').self::field('ends_on','Ends on','date').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>'; $html.='<button class="btn btn-primary">Send club invitation</button></form><p>The recipient signs up or signs in using this email, verifies it, and accepts under Invitations. This does not change their login or any other club membership.</p><ul>';
$html.='<h3 class="h4" id="dues">'.($service->administrator()?'Season dues':'My season dues').'</h3><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($service->administrator()?'<th>Record received payment</th>':'').'</tr></thead><tbody>'; foreach((new ClubInvitations())->forClub($club,BoundedList::page('invitations')) as $invite){$html.='<li>'.self::h($invite['invite_email'].' — '.$invite['membership_role'].' — '.$invite['invitation_status'].' (expires '.$invite['expires_at'].' UTC)');if($invite['invitation_status']==='pending'&&($super||$invite['membership_role']==='member'))$html.=$this->form('revokeInvitation',$club).'<input type="hidden" name="invitation_serial" value="'.(int)$invite['invitation_serial'].'"><button class="btn btn-outline-danger">Revoke invitation</button></form>';$html.='</li>';}$html.='</ul>'.BoundedList::navigation('invitations');
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
if($service->administrator()){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
} }
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>'; $html.='<h2 class="h4 mt-4">Seasons</h2><ul>';
if($service->administrator() && $selected['club_status']==='active' && $members && $seasons){ foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>';
$html.='</ul>'.BoundedList::navigation('seasons');
if($manage && $selected['club_status']==='active')$html.=$this->form('season',$club).self::field('season_name','Season name','text','',100).self::field('starts_on','Starts on','date').self::field('ends_on','Ends on','date').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>';
$html.='<h2 class="h4 mt-4" id="dues">'.($manage?'Season dues':'My season dues').'</h2><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive position-relative"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($manage?'<th>Record received payment</th>':'').'</tr></thead><tbody>';
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
if($manage){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
}
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>'.BoundedList::navigation('dues');
if($manage && $selected['club_status']==='active' && $members && $seasons){
$html.='<h4 class="h5">Record season dues</h4>'.$this->form('charge',$club).'<div class="col-md"><label class="form-label">Member<select class="form-select" name="membership_serial">';foreach($members as $member)if($member['membership_status']==='active')$html.='<option value="'.(int)$member['membership_serial'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</option>'; $html.='<h4 class="h5">Record season dues</h4>'.$this->form('charge',$club).'<div class="col-md"><label class="form-label">Member<select class="form-select" name="membership_serial">';foreach($members as $member)if($member['membership_status']==='active')$html.='<option value="'.(int)$member['membership_serial'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</option>';
$html.='</select></label></div><div class="col-md"><label class="form-label">Season<select class="form-select" name="season_serial">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>'; $html.='</select></label></div><div class="col-md"><label class="form-label">Season<select class="form-select" name="season_serial">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';
$html.='</select></label></div>'.self::field('amount','Dues (USD)','text').'<div class="col-auto align-self-end"><button class="btn btn-primary">Save dues</button></div></form>'; $html.='</select></label></div>'.self::field('amount','Dues (USD)','text').'<div class="col-auto align-self-end"><button class="btn btn-primary">Save dues</button></div></form>';
} }
$html.=$this->financialHistory($club,$selected['club_status']==='active',$service->administrator()); $html.=$this->financialHistory($club,$selected['club_status']==='active',$manage);
$this->displayContent(ClubFormModals::render($html.'</main>'));
}
public function invitations(): void {
if(!(new RosterService())->ready()){$this->home();return;}
$service=new ClubInvitations();
if(($_POST['step']??'')==='accept'){$service->accept(MaintenanceService::id($_POST['invitation_serial']??null));header('Location: main.php?Xaction=Roster.workspace',true,303);exit;}
$html='<div class="container py-3"><h1>Club invitations</h1><p>Sign in using the invited email and verify it before accepting. Club invitations never change your account password or other club memberships.</p>';
$pending=$service->pending(BoundedList::page('pending'));
foreach($pending as $invite)$html.='<section><h2 class="h4">'.self::h($invite['club_name']).'</h2><p>Club role: '.self::h($invite['membership_role']).'; expires '.self::h($invite['expires_at']).' UTC.</p>'.str_replace('Roster.workspace','Roster.invitations',$this->form('accept')).'<input type="hidden" name="invitation_serial" value="'.(int)$invite['invitation_serial'].'"><button class="btn btn-primary">Accept invitation</button></form></section>';
if(!$pending)$html.='<p>No pending invitations for your current email.</p>';
$html.=BoundedList::navigation('pending');
$this->displayContent($html.'</div>');
}
public function tickets(): void {
$service=new ClubTickets();if(!$service->ready()){$this->home();return;}
$clubs=$service->clubs(BoundedList::page('clubs'));$raw=$_POST['club_serial']??$_GET['club_serial']??'';$club=$raw===''?($clubs?(int)$clubs[0]['club_serial']:0):MaintenanceService::id($raw);
$html='<div class="container py-3"><h1>Club tickets</h1><nav>';
foreach($clubs as $option)$html.='<a class="btn btn-outline-primary me-2" href="main.php?Xaction=Roster.tickets&amp;club_serial='.(int)$option['club_serial'].'">'.self::h($option['club_name']).'</a>';$html.='</nav>'.BoundedList::navigation('clubs');
if(!$club){$this->displayContent($html.'<p>Accept a club invitation to submit a ticket.</p></div>');return;}
$selected=$service->club($club);$manage=$service->manages($club);$raw=$_POST['ticket_serial']??$_GET['ticket_serial']??'';$ticket=$raw===''?0:MaintenanceService::id($raw);
$page=filter_var($_POST['ticket_page']??$_GET['ticket_page']??'1',FILTER_VALIDATE_INT,['options'=>['min_range'=>1]]);
if($page===false)throw new AppException('Choose a valid ticket page.');
$search=$service->normalizeSearch($_POST['ticket_search']??$_GET['ticket_search']??'');
if(($_POST['step']??'')==='openTicket')$ticket=$service->createTicket($club,$_POST['subject']??'',$_POST['message']??'');
elseif(($_POST['step']??'')==='reply')$service->reply($club,$ticket,$_POST['message']??'',$_POST['ticket_status']??'');
if(($_POST['step']??'')!==''){if(!in_array($_POST['step'],['openTicket','reply'],true))throw new AppException('Unknown ticket action.');header('Location: main.php?Xaction=Roster.tickets&club_serial='.$club.'&ticket_serial='.$ticket.($_POST['step']==='reply'?'&ticket_page='.$page.'&ticket_search='.rawurlencode($search):''),true,303);exit;}
$listing=$service->page($club,$page,$search);$page=$listing['page'];
$ticketUrl=static fn(int $target)=>'main.php?'.http_build_query(['Xaction'=>'Roster.tickets','club_serial'=>$club,'ticket_page'=>$target,'ticket_search'=>$search],'','&',PHP_QUERY_RFC3986);
$html.='<h2>'.self::h($selected['club_name']).'</h2><p>'.($manage?'Tickets from this club.':'Only your own tickets are shown.').'</p>';
$html.='<form method="get" action="main.php" class="row g-2 mb-3"><input type="hidden" name="Xaction" value="Roster.tickets"><input type="hidden" name="club_serial" value="'.$club.'"><label for="ticket_search">Search ticket subjects</label><input class="form-control" id="ticket_search" name="ticket_search" value="'.self::h($search).'" maxlength="200" data-character-limit="100"><button class="btn btn-outline-primary">Search tickets</button><a href="'.self::h('main.php?Xaction=Roster.tickets&club_serial='.$club).'">Clear search</a></form>';
$html.='<p role="status" class="ticket-page-status">Tickets '.$listing['start'].'-'.$listing['end'].' of '.$listing['count'].'. Page '.$page.' of '.$listing['pages'].'.</p><ul class="ticket-list">';
foreach($listing['rows'] as $row)$html.='<li><a href="'.self::h($ticketUrl($page).'&ticket_serial='.(int)$row['ticket_serial']).'">#'.(int)$row['ticket_serial'].' '.self::h($row['subject']).'</a> — '.self::h($row['ticket_status']).'</li>';
if(!$listing['rows'])$html.='<li>No matching tickets.</li>';$html.='</ul><nav aria-label="Ticket pages" class="d-flex gap-3 mb-3">';
if($page>1)$html.='<a href="'.self::h($ticketUrl(1)).'">First page</a><a href="'.self::h($ticketUrl($page-1)).'">Previous page</a>';
if($page<$listing['pages'])$html.='<a href="'.self::h($ticketUrl($page+1)).'">Next page</a><a href="'.self::h($ticketUrl($listing['pages'])).'">Last page</a>';$html.='</nav>';
if($ticket){$row=$service->ticket($club,$ticket);$html.='<h3>'.self::h($row['subject']).'</h3><p>Status: '.self::h($row['ticket_status']).'</p>';foreach($service->messages($club,$ticket,BoundedList::page('messages')) as $message)$html.='<article class="card p-3 mb-2"><p>'.self::h($message['user_first_name'].' '.$message['user_last_name'].' — '.$message['created_at']).'</p><p class="text-break">'.nl2br(self::h($message['message_text'])).'</p></article>';
$html.=BoundedList::navigation('messages');
if($selected['club_status']==='active'&&($manage||$row['ticket_status']==='open')){$html.=str_replace('Roster.workspace','Roster.tickets',$this->form('reply',$club)).'<input type="hidden" name="ticket_page" value="'.$page.'"><input type="hidden" name="ticket_search" value="'.self::h($search).'"><input type="hidden" name="ticket_serial" value="'.$ticket.'"><label>Reply<textarea class="form-control" name="message" maxlength="'.(ClubTickets::MESSAGE_LIMIT*2).'" data-character-limit="'.ClubTickets::MESSAGE_LIMIT.'" required></textarea></label>';if($manage)$html.='<label for="ticket_status">Status</label><select id="ticket_status" class="form-select" name="ticket_status"><option value="open">Open</option><option value="closed">Closed</option></select>';$html.='<button class="btn btn-primary">Send reply</button></form>';}
}
if($selected['club_status']==='active')$html.='<h3 class="h4">Submit a ticket</h3>'.str_replace('Roster.workspace','Roster.tickets',$this->form('openTicket',$club)).str_replace('name="subject"','name="subject" data-character-limit="'.ClubTickets::SUBJECT_LIMIT.'"',self::field('subject','Subject','text','',ClubTickets::SUBJECT_LIMIT*2)).'<label>Message<textarea class="form-control" name="message" maxlength="'.(ClubTickets::MESSAGE_LIMIT*2).'" data-character-limit="'.ClubTickets::MESSAGE_LIMIT.'" required></textarea></label><button class="btn btn-primary">Submit ticket</button></form>';
$html.='<script src="js/tickets.js?version='.self::VERSION.'"></script>';
$this->displayContent($html.'</div>'); $this->displayContent($html.'</div>');
} }
} }
+14 -5
View File
@@ -3,9 +3,18 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Cell\DataType;
class RosterReports extends Base { class RosterReports extends Base {
private function exportBudget(int $club,bool $financial=false): void {
(new TenantAccess())->requireManager($club);
(new ClubResources())->consume($club,'export');
$db=$this->connect();
if($financial){
BoundedList::exportGuard($db,'SELECT (SELECT COUNT(*) FROM club_dues WHERE club_serial=?)+(SELECT COUNT(*) FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE d.club_serial=?)+(SELECT COUNT(*) FROM club_due_adjustments a JOIN club_dues d ON d.due_serial=a.due_serial WHERE d.club_serial=?)',[$club,$club,$club]);
}else BoundedList::exportGuard($db,'SELECT COUNT(*) FROM club_memberships WHERE club_serial=?',[$club]);
BoundedList::$exporting=true;
}
public function ledger(): void { public function ledger(): void {
$service=new RosterService();$service->requireAdministrator();$club=MaintenanceService::id($_POST['club_serial']??null);$name=$service->club($club)['club_name']; $service=new RosterService();$service->requireAdministrator();$club=MaintenanceService::id($_POST['club_serial']??null);(new TenantAccess())->requireManager($club);$name=$service->club($club)['club_name'];
$history=(new LedgerService())->history($club);header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-financial-history.csv"');$file=fopen('php://output','w'); $this->exportBudget($club,true);$history=(new LedgerService())->history($club);header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-financial-history.csv"');$file=fopen('php://output','w');
$write=static function(array $row)use($file){fputcsv($file,array_map(static fn($v)=>self::csvText((string)$v),$row),',','"','');}; $write=static function(array $row)use($file){fputcsv($file,array_map(static fn($v)=>self::csvText((string)$v),$row),',','"','');};
$write(['Record type','Club','Record ID','Dues ID','Member','Amount cents (USD)','Date','Method','Reference','Recorded by','Status / reason']); $write(['Record type','Club','Record ID','Dues ID','Member','Amount cents (USD)','Date','Method','Reference','Recorded by','Status / reason']);
foreach($service->dues($club) as $due)$write(['dues_total',$name,$due['due_serial'],$due['due_serial'],$due['user_first_name'].' '.$due['user_last_name'],$due['amount_cents'],'','','',$due['recorded_by']??'Historical','Paid cents '.$due['paid_cents'].'; balance cents '.($due['amount_cents']-$due['paid_cents'])]); foreach($service->dues($club) as $due)$write(['dues_total',$name,$due['due_serial'],$due['due_serial'],$due['user_first_name'].' '.$due['user_last_name'],$due['amount_cents'],'','','',$due['recorded_by']??'Historical','Paid cents '.$due['paid_cents'].'; balance cents '.($due['amount_cents']-$due['paid_cents'])]);
@@ -14,7 +23,7 @@ class RosterReports extends Base {
fclose($file);exit; fclose($file);exit;
} }
private function rows(): array { private function rows(): array {
$service=new RosterService();$service->requireAdministrator();$id=MaintenanceService::id($_POST['club_serial']??null);$club=$service->club($id); $service=new RosterService();$service->requireAdministrator();$id=MaintenanceService::id($_POST['club_serial']??null);(new TenantAccess())->requireManager($id);$club=$service->club($id);$this->exportBudget($id);
$rows=[['Club','Username','First name','Last name','Email','Membership','Joined on']]; $rows=[['Club','Username','First name','Last name','Email','Membership','Joined on']];
foreach($service->members($id) as $member)$rows[]=[$club['club_name'],$member['user_name'],$member['user_first_name'],$member['user_last_name'],$member['user_email'],$member['membership_status'],$member['joined_on']]; foreach($service->members($id) as $member)$rows[]=[$club['club_name'],$member['user_name'],$member['user_first_name'],$member['user_last_name'],$member['user_email'],$member['membership_status'],$member['joined_on']];
return $rows; return $rows;
@@ -37,7 +46,7 @@ class RosterReports extends Base {
public function catalog(): void { public function catalog(): void {
$service=new RosterService();$service->requireAdministrator();$html='<div class="container-fluid py-3"><h1>Roster reports</h1>'; $service=new RosterService();$service->requireAdministrator();$html='<div class="container-fluid py-3"><h1>Roster reports</h1>';
if(!$service->ready()){$this->displayContent($html.'<p>Install the roster migration before exporting reports.</p></div>');return;} if(!$service->ready()){$this->displayContent($html.'<p>Install the roster migration before exporting reports.</p></div>');return;}
foreach($service->clubs() as $club){$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.htmlspecialchars($club['club_name'],ENT_QUOTES,'UTF-8').'</h2><div class="d-flex gap-2">';foreach(['csv'=>'CSV','excel'=>'Excel','pdf'=>'PDF'] as $method=>$label)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.'.$method.'"><input type="hidden" name="club_serial" value="'.(int)$club['club_serial'].'"><input type="hidden" name="csrf_token" value="'.$this->csrfToken().'"><button class="btn btn-primary">Download '.$label.'</button></form>';$html.='</div></div></section>';} foreach($service->clubs(BoundedList::page('clubs'),true) as $club){if(!$service->manages((int)$club['club_serial']))continue;$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.htmlspecialchars($club['club_name'],ENT_QUOTES,'UTF-8').'</h2><div class="d-flex gap-2">';foreach(['csv'=>'CSV','excel'=>'Excel','pdf'=>'PDF'] as $method=>$label)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.'.$method.'"><input type="hidden" name="club_serial" value="'.(int)$club['club_serial'].'"><input type="hidden" name="csrf_token" value="'.$this->csrfToken().'"><button class="btn btn-primary">Download '.$label.'</button></form>';$html.='</div></div></section>';}
$this->displayContent($html.'</div>'); $this->displayContent($html.BoundedList::navigation('clubs','RosterReports.catalog').'</div>');
} }
} }
+49 -31
View File
@@ -1,31 +1,43 @@
<?php <?php
class RosterService extends Base { class RosterService extends Base {
public function administrator(): bool {return in_array($_SESSION[self::USER_ROLE]??'', ['Administrator','System Administrator'],true);} public function administrator(): bool {return (new TenantAccess())->managesAny();}
public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only site administrators may manage clubs and dues.',403);} public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only assigned club administrators may manage clubs and dues.',403);}
public function ready(): bool {return (int)$this->connect()->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','email_verifications')")->fetchColumn()===8;} public function manages(int $club,?PDO $db=null): bool {return (new TenantAccess())->canManage($club,$db);}
public function clubs(): array { public function ready(): bool {return (int)$this->connect()->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','email_verifications','club_invitations','club_tickets','club_ticket_messages')")->fetchColumn()===11 && (new TenantAccess())->available();}
public function clubs(?int $page=null,bool $managedOnly=false): array {
$db=$this->connect(); $db=$this->connect();
if($this->administrator())return $db->query('SELECT * FROM clubs ORDER BY club_name,club_serial')->fetchAll(); if((new TenantAccess())->isSuper($db))return BoundedList::rows($db,'SELECT * FROM clubs ORDER BY club_name,club_serial',[],$page,'clubs');
$stmt=$db->prepare("SELECT c.* FROM clubs c JOIN club_memberships m ON m.club_serial=c.club_serial JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_status='active' AND c.club_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL ORDER BY c.club_name,c.club_serial"); return BoundedList::rows($db,"SELECT c.* FROM clubs c JOIN club_memberships m ON m.club_serial=c.club_serial JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_status='active' AND (c.club_status='active' OR m.membership_role='administrator') AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL".($managedOnly?" AND m.membership_role='administrator'":'')." ORDER BY c.club_name,c.club_serial",[$_SESSION[self::USER_SERIAL]??0],$page,'clubs');
$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
} }
public function club(int $id,?PDO $db=null,bool $writing=false): array { public function club(int $id,?PDO $db=null,bool $writing=false): array {
$db??=$this->connect();$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=?'.($writing?' FOR UPDATE':''));$stmt->execute([$id]);$club=$stmt->fetch(); $db??=$this->connect();$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=?'.($writing?' FOR UPDATE':''));$stmt->execute([$id]);$club=$stmt->fetch();
if(!$club)throw new AppException('Club not found.',404); if(!$club)throw new AppException('Club not found.',404);
if(!$this->administrator()){ if(!$this->manages($id,$db)){
$stmt=$db->prepare("SELECT m.membership_serial FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL");$stmt->execute([$id,$_SESSION[self::USER_SERIAL]??0]); if($writing)throw new AppException('You cannot manage this club.',403);
$stmt=$db->prepare("SELECT m.membership_serial FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL LOCK IN SHARE MODE");$stmt->execute([$id,$_SESSION[self::USER_SERIAL]??0]);
if($club['club_status']!=='active'||!$stmt->fetch())throw new AppException('You do not have access to this club.',403); if($club['club_status']!=='active'||!$stmt->fetch())throw new AppException('You do not have access to this club.',403);
} }
if($writing && $club['club_status']!=='active')throw new AppException('Reactivate the club before adding or changing records.',409); if($writing && $club['club_status']!=='active')throw new AppException('Reactivate the club before adding or changing records.',409);
return $club; return $club;
} }
public function members(int $club): array { public function members(int $club,?int $page=null): array {
$this->club($club);$stmt=$this->connect()->prepare("SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=?".($this->administrator()?'':" AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial');$stmt->execute([$club]);return $stmt->fetchAll(); $this->club($club);$manage=$this->manages($club);
return BoundedList::rows($this->connect(),"SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=?".($manage?'':" AND m.user_serial=? AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial',$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0],$page,'members');
} }
public function seasons(int $club): array {$this->club($club);$stmt=$this->connect()->prepare('SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC');$stmt->execute([$club]);return $stmt->fetchAll();} public function seasons(int $club,?int $page=null): array {
public function dues(int $club): array { $this->club($club);return BoundedList::rows($this->connect(),'SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC',[$club],$page,'seasons');
$this->club($club);$stmt=$this->connect()->prepare('SELECT d.*,d.amount_cents AS original_amount_cents,d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0) AS amount_cents,s.season_name,u.user_first_name,u.user_last_name,COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0) AS paid_cents FROM club_dues d JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN club_seasons s ON s.season_serial=d.season_serial WHERE d.club_serial=?'.($this->administrator()?'':' AND m.user_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial'); }
$stmt->execute($this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll(); public function member(int $club,int $membership): array {
$this->club($club);$manage=$this->manages($club);
$stmt=$this->connect()->prepare('SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.membership_serial=?'.($manage?'':" AND m.user_serial=? AND m.membership_status='active'"));
$stmt->execute($manage?[$club,$membership]:[$club,$membership,$_SESSION[self::USER_SERIAL]??0]);
$member=$stmt->fetch();if(!$member)throw new AppException('Membership not found in this club.',404);return $member;
}
public function dues(int $club,?int $page=null,?int $membership=null): array {
$this->club($club);$manage=$this->manages($club);
if($membership!==null)$this->member($club,$membership);
$args=$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];if($membership!==null)$args[]=$membership;
return BoundedList::rows($this->connect(),'SELECT d.due_serial,d.club_serial,d.membership_serial,d.season_serial,d.created_at,d.recorded_by,d.amount_cents AS original_amount_cents,d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0) AS amount_cents,s.season_name,u.user_first_name,u.user_last_name,COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0) AS paid_cents FROM club_dues d JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN club_seasons s ON s.season_serial=d.season_serial WHERE d.club_serial=?'.($manage?'':' AND m.user_serial=?').($membership===null?'':' AND d.membership_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial',$args,$page,'dues');
} }
private function text(string $text,int $limit): string {$text=trim($text);if(!$text||strlen($text)>$limit)throw new AppException('A name is required and must fit the displayed length limit.');return $text;} private function text(string $text,int $limit): string {$text=trim($text);if(!$text||strlen($text)>$limit)throw new AppException('A name is required and must fit the displayed length limit.');return $text;}
public static function date(string $value): string {$date=DateTime::createFromFormat('!Y-m-d',$value);if(!$date||$date->format('Y-m-d')!==$value)throw new AppException('Enter a valid date in YYYY-MM-DD format.');return $value;} public static function date(string $value): string {$date=DateTime::createFromFormat('!Y-m-d',$value);if(!$date||$date->format('Y-m-d')!==$value)throw new AppException('Enter a valid date in YYYY-MM-DD format.');return $value;}
@@ -34,36 +46,42 @@ class RosterService extends Base {
[$whole,$fraction]=array_pad(explode('.',$value,2),2,'');return (int)$whole*100+(int)str_pad($fraction,2,'0'); [$whole,$fraction]=array_pad(explode('.',$value,2),2,'');return (int)$whole*100+(int)str_pad($fraction,2,'0');
} }
public function saveClub(int $id,string $name,string $status='active'): int { public function saveClub(int $id,string $name,string $status='active'): int {
$this->requireAdministrator();$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.'); if(!$id)(new TenantAccess())->requireSuper();else (new TenantAccess())->requireManager($id);$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.');
$db=$this->connect();$db->beginTransaction(); $db=$this->connect();
try { $id=DatabaseTransaction::run($db,function(PDO $db)use($id,$name,$status){
if(!$id)(new TenantAccess())->requireSuper($db);
$before=null;if($id){$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$id]);$before=$stmt->fetch();if(!$before)throw new AppException('Club not found.',404);} $before=null;if($id){$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$id]);$before=$stmt->fetch();if(!$before)throw new AppException('Club not found.',404);}
if($id)(new TenantAccess())->requireManager($id,$db);
if($id){$stmt=$db->prepare('UPDATE clubs SET club_name=?,club_status=? WHERE club_serial=?');$stmt->execute([$name,$status,$id]);} if($id){$stmt=$db->prepare('UPDATE clubs SET club_name=?,club_status=? WHERE club_serial=?');$stmt->execute([$name,$status,$id]);}
else{$stmt=$db->prepare('INSERT INTO clubs(club_name,club_status) VALUES(?,?)');$stmt->execute([$name,$status]);$id=(int)$db->lastInsertId();} else{$stmt=$db->prepare('INSERT INTO clubs(club_name,club_status) VALUES(?,?)');$stmt->execute([$name,$status]);$id=(int)$db->lastInsertId();}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));$db->commit(); Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));return $id;
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;} });
return $id; return $id;
} }
public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined): void { public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined,string $memberRole='member'): void {
$this->requireAdministrator();$joined=self::date($joined);if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.'); $this->requireAdministrator();(new TenantAccess())->requireManager($club);$joined=self::date($joined);if(!in_array($memberRole,['member','administrator'],true))throw new AppException('Invalid membership role.');if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.');
$db=$this->connect();$db->beginTransaction(); $db=$this->connect();
try{ DatabaseTransaction::run($db,function(PDO $db)use($club,$membership,$identifier,$status,$joined,$memberRole){
// Lock user before membership writes, matching account deletion's lock order. // Lock user before membership writes, matching account deletion's lock order.
if($membership){$stmt=$db->prepare('SELECT user_serial FROM club_memberships WHERE membership_serial=? AND club_serial=?');$stmt->execute([$membership,$club]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('Membership not found in this club.',404);} if($membership){$stmt=$db->prepare('SELECT user_serial FROM club_memberships WHERE membership_serial=? AND club_serial=?');$stmt->execute([$membership,$club]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('Membership not found in this club.',404);}
else{$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_name=? OR user_email=?');$stmt->execute([trim($identifier),trim($identifier)]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('No account matches that username or email. Create the account first.',404);} else{(new TenantAccess())->requireSuper($db);$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_name=? OR user_email=?');$stmt->execute([trim($identifier),trim($identifier)]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('No account matches that username or email. Create the account first.',404);}
$stmt=$db->prepare('SELECT user_serial,user_status,user_email_verified_at FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);if(!($account=$stmt->fetch()))throw new AppException('Account not found.',404); $stmt=$db->prepare('SELECT user_serial,user_status,user_email_verified_at FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);if(!($account=$stmt->fetch()))throw new AppException('Account not found.',404);
if($status==='active' && ($account['user_status']!=='active'||!$account['user_email_verified_at']))throw new AppException('The account must be active and its email verified before membership can be activated.',409); if($status==='active' && ($account['user_status']!=='active'||!$account['user_email_verified_at']))throw new AppException('The account must be active and its email verified before membership can be activated.',409);
$this->club($club,$db,true); $this->club($club,$db,true);
$before=null;if($membership){$stmt=$db->prepare('SELECT * FROM club_memberships WHERE membership_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$membership,$club]);$before=$stmt->fetch();if(!$before)throw new AppException('Membership not found.',404);} $before=null;if($membership){$stmt=$db->prepare('SELECT * FROM club_memberships WHERE membership_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$membership,$club]);$before=$stmt->fetch();if(!$before)throw new AppException('Membership not found.',404);}
if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$membership,$club]);} if(!(new TenantAccess())->isSuper($db) && (($before['membership_role']??'member')==='administrator'||$memberRole!=='member'))throw new AppException('Only the Super System Administrator can assign or change club administrators.',403);
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on) VALUES(?,?,?,?)');$stmt->execute([$club,$user,$status,$joined]);} if(($before['membership_role']??'')==='administrator' && $before['membership_status']==='active' && ($memberRole!=='administrator'||$status!=='active')){
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved membership '.json_encode(['membership'=>$membership?:((int)$db->lastInsertId()),'club'=>$club,'user'=>(int)$user,'before'=>$before,'status'=>$status,'joined_on'=>$joined])); $stmt=$db->prepare("SELECT membership_serial FROM club_memberships WHERE club_serial=? AND membership_role='administrator' AND membership_status='active' ORDER BY membership_serial FOR UPDATE");$stmt->execute([$club]);if(count($stmt->fetchAll())<=1)throw new AppException('Assign another club administrator before removing the last one.',409);
$db->commit(); }
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;} if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=?,membership_role=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$memberRole,$membership,$club]);}
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on,membership_role) VALUES(?,?,?,?,?)');$stmt->execute([$club,$user,$status,$joined,$memberRole]);$membership=(int)$db->lastInsertId();}
if($status==='inactive'||($before&&$before['membership_role']!==$memberRole)){$stmt=$db->prepare("UPDATE club_invitations SET invitation_status='revoked' WHERE club_serial=? AND invite_email=(SELECT user_email FROM users WHERE user_serial=?) AND invitation_status='pending'");$stmt->execute([$club,$user]);}
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved membership '.json_encode(['membership'=>$membership?:((int)$db->lastInsertId()),'club'=>$club,'user'=>(int)$user,'before'=>$before,'status'=>$status,'membership_role'=>$memberRole,'joined_on'=>$joined]));
});
} }
public function addSeason(int $club,string $name,string $start,string $end): void { public function addSeason(int $club,string $name,string $start,string $end): void {
$this->requireAdministrator();$name=$this->text($name,100);self::date($start);self::date($end);if($end<$start)throw new AppException('Season end cannot precede its start.'); $this->requireAdministrator();$name=$this->text($name,100);self::date($start);self::date($end);if($end<$start)throw new AppException('Season end cannot precede its start.');
$db=$this->connect();$db->beginTransaction();try{$this->club($club,$db,true);$stmt=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');$stmt->execute([$club,$name,$start,$end]);Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'created season '.json_encode(['season'=>(int)$db->lastInsertId(),'club'=>$club,'name'=>$name,'start'=>$start,'end'=>$end]));$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;} $db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$name,$start,$end){$this->club($club,$db,true);$stmt=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');$stmt->execute([$club,$name,$start,$end]);Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'created season '.json_encode(['season'=>(int)$db->lastInsertId(),'club'=>$club,'name'=>$name,'start'=>$start,'end'=>$end]));});
} }
public function charge(int $club,int $membership,int $season,string $amount): void {(new LedgerService())->charge($club,$membership,$season,$amount);} public function charge(int $club,int $membership,int $season,string $amount): void {(new LedgerService())->charge($club,$membership,$season,$amount);}
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {(new LedgerService())->payment($club,$due,$amount,$paid,$key,$method,$reference);} public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {(new LedgerService())->payment($club,$due,$amount,$paid,$key,$method,$reference);}
+7 -4
View File
@@ -3,7 +3,8 @@
class SignUp extends Base { class SignUp extends Base {
public function createAccount() { public function createAccount() {
$params = ['BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount']; $kind=$_POST['registration_kind']??(($_GET['membership']??'')==='invited'?'invited':'new_club');
$params = ['REGISTRATION_KIND'=>$kind,'BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount'];
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$this->requireCsrf(); $this->requireCsrf();
try { try {
@@ -11,20 +12,22 @@ class SignUp extends Base {
if (!$auth->allowAttempt('signup-ip', $_SERVER['REMOTE_ADDR'] ?? '', 5, 3600)) { if (!$auth->allowAttempt('signup-ip', $_SERVER['REMOTE_ADDR'] ?? '', 5, 3600)) {
throw new InvalidArgumentException('Too many account requests. Please try again later.'); throw new InvalidArgumentException('Too many account requests. Please try again later.');
} }
$auth->register($_POST); if(!in_array($kind,['new_club','invited'],true))throw new InvalidArgumentException('Choose a valid signup option.');
$input=$_POST;$input['registration_kind']=$kind;$auth->register($input);
try{(new EmailVerification())->request($_POST['user_email']);} try{(new EmailVerification())->request($_POST['user_email']);}
catch(Throwable $e){error_log('Signup verification delivery failed: '.$e->getMessage());} catch(Throwable $e){error_log('Signup verification delivery failed: '.$e->getMessage());}
$_SESSION['signup_success'] = true; $_SESSION['signup_success'] = $kind;
header('Location: main.php?Xaction=signup', true, 303); header('Location: main.php?Xaction=signup', true, 303);
exit; exit;
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$params['SIGNUP_ERROR'] = $e->getMessage(); $params['SIGNUP_ERROR'] = $e->getMessage();
foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name'] as $field) { foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name', 'club_name'] as $field) {
$params[strtoupper($field)] = $_POST[$field] ?? ''; $params[strtoupper($field)] = $_POST[$field] ?? '';
} }
} }
} }
if (!empty($_SESSION['signup_success'])) { if (!empty($_SESSION['signup_success'])) {
$params['REGISTRATION_KIND'] = $_SESSION['signup_success'];
$params['SIGNUP_SUCCESSFUL'] = 'TRUE'; $params['SIGNUP_SUCCESSFUL'] = 'TRUE';
unset($_SESSION['signup_success']); unset($_SESSION['signup_success']);
} }
+47
View File
@@ -0,0 +1,47 @@
<?php
/** Club permissions are looked up on each request, never trusted from form fields. */
class TenantAccess extends Base {
public const SUPER = 'Super System Administrator';
public function isSuper(?PDO $db = null, ?int $user = null): bool {
$stmt = ($db ?? $this->connect())->prepare("SELECT 1 FROM users WHERE user_serial=? AND user_role=? AND user_status='active' LOCK IN SHARE MODE");
$stmt->execute([$user ?? ($_SESSION[self::USER_SERIAL] ?? 0), self::SUPER]);
return (bool) $stmt->fetchColumn();
}
public function requireSuper(?PDO $db = null): void {
if (!$this->isSuper($db))
throw new AppException('Only a Super System Administrator may manage global accounts and settings.', 403);
}
public function canManage(int $club, ?PDO $db = null, ?int $user = null): bool {
$db ??= $this->connect();
$user ??= (int) ($_SESSION[self::USER_SERIAL] ?? 0);
if ($this->isSuper($db, $user))
return true;
$stmt = $db->prepare("SELECT 1 FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_role='administrator' AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL LOCK IN SHARE MODE");
$stmt->execute([$club, $user]);
return (bool) $stmt->fetchColumn();
}
public function available(): bool {
return (bool) $this->connect()->query("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='club_memberships' AND COLUMN_NAME='membership_role'")->fetchColumn();
}
public function managesAny(): bool {
if ($this->isSuper())
return true;
if (!$this->available())
return false;
$stmt = $this->connect()->prepare("SELECT 1 FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_role='administrator' AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL LIMIT 1");
$stmt->execute([$_SESSION[self::USER_SERIAL] ?? 0]);
return (bool) $stmt->fetchColumn();
}
public function requireManager(int $club, ?PDO $db = null): void {
if (!$this->canManage($club, $db))
throw new AppException('You cannot manage this club.', 403);
}
}
+2 -1
View File
@@ -13,7 +13,8 @@
"ext-xsl": "*", "ext-xsl": "*",
"tecnickcom/tcpdf": "^6.10", "tecnickcom/tcpdf": "^6.10",
"phpmailer/phpmailer": "^7.0", "phpmailer/phpmailer": "^7.0",
"phpoffice/phpspreadsheet": "^5.2" "phpoffice/phpspreadsheet": "^5.2",
"spomky-labs/otphp": "^11.4"
}, },
"scripts": { "scripts": {
"test:auth": "php tests/auth_integration.php" "test:auth": "php tests/auth_integration.php"
Generated
+259 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0389de306dca31ea5873c1d9ed6a4c5d", "content-hash": "a46cfa2681b0fe4f29817c408c11ccc1",
"packages": [ "packages": [
{ {
"name": "composer/pcre", "name": "composer/pcre",
@@ -267,6 +267,75 @@
}, },
"time": "2022-12-02T22:17:43+00:00" "time": "2022-12-02T22:17:43+00:00"
}, },
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{ {
"name": "phpmailer/phpmailer", "name": "phpmailer/phpmailer",
"version": "v7.0.0", "version": "v7.0.0",
@@ -460,6 +529,54 @@
}, },
"time": "2026-09-17T03:54:50+00:00" "time": "2026-09-17T03:54:50+00:00"
}, },
{
"name": "psr/clock",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/clock.git",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
"shasum": ""
},
"require": {
"php": "^7.0 || ^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Psr\\Clock\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for reading the clock.",
"homepage": "https://github.com/php-fig/clock",
"keywords": [
"clock",
"now",
"psr",
"psr-20",
"time"
],
"support": {
"issues": "https://github.com/php-fig/clock/issues",
"source": "https://github.com/php-fig/clock/tree/1.0.0"
},
"time": "2022-11-25T14:36:26+00:00"
},
{ {
"name": "psr/simple-cache", "name": "psr/simple-cache",
"version": "3.0.0", "version": "3.0.0",
@@ -511,6 +628,147 @@
}, },
"time": "2021-10-29T13:26:27+00:00" "time": "2021-10-29T13:26:27+00:00"
}, },
{
"name": "spomky-labs/otphp",
"version": "11.5.0",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/otphp.git",
"reference": "877683d6352b80cdc7020fd43a725629c2524435"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/877683d6352b80cdc7020fd43a725629c2524435",
"reference": "877683d6352b80cdc7020fd43a725629c2524435",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^2.0 || ^3.0",
"php": ">=8.1",
"psr/clock": "^1.0",
"symfony/deprecation-contracts": "^3.2"
},
"require-dev": {
"symfony/error-handler": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"OTPHP\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/Spomky-Labs/otphp/contributors"
}
],
"description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator",
"homepage": "https://github.com/Spomky-Labs/otphp",
"keywords": [
"FreeOTP",
"RFC 4226",
"RFC 6238",
"google authenticator",
"hotp",
"otp",
"totp"
],
"support": {
"issues": "https://github.com/Spomky-Labs/otphp/issues",
"source": "https://github.com/Spomky-Labs/otphp/tree/11.5.0"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-06-06T23:41:24+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
},
{ {
"name": "tecnickcom/tcpdf", "name": "tecnickcom/tcpdf",
"version": "6.10.0", "version": "6.10.0",
+28
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -22,3 +22,11 @@ GRANT INSERT ON huntingclubroster.club_due_adjustments TO 'hcr_runtime'@'app-hos
GRANT INSERT ON huntingclubroster.club_payment_reversals TO 'hcr_runtime'@'app-host.example'; GRANT INSERT ON huntingclubroster.club_payment_reversals TO 'hcr_runtime'@'app-host.example';
-- No schema-changing privileges, journal UPDATE/DELETE, or financial UPDATE/DELETE. -- No schema-changing privileges, journal UPDATE/DELETE, or financial UPDATE/DELETE.
-- Use separate credentials for migrations and backup/restore jobs. -- Use separate credentials for migrations and backup/restore jobs.
GRANT INSERT, UPDATE ON huntingclubroster.club_invitations TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE ON huntingclubroster.club_tickets TO 'hcr_runtime'@'app-host.example';
GRANT INSERT ON huntingclubroster.club_ticket_messages TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE, DELETE ON huntingclubroster.user_mfa TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, DELETE ON huntingclubroster.mfa_recovery TO 'hcr_runtime'@'app-host.example';
GRANT INSERT, UPDATE ON huntingclubroster.club_resource_usage TO 'hcr_runtime'@'app-host.example';
+3 -43
View File
@@ -5,53 +5,13 @@
$(document).ready(function () { $(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}"), $CKEditor; var $application = $("#APPLICATION_MNEMONIC").val(), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
// ---------------------- // ----------------------
// Initialize the Editor. // Initialize the Editor.
// ---------------------- // ----------------------
ClassicEditor HcrRichText.create(document.querySelector('#alert_message'));
.create(document.querySelector('#alert_message'), {
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'underline',
'link',
'bulletedList',
'numberedList',
'|',
'fontColor',
'fontSize',
'fontFamily',
'|',
'indent',
'outdent',
'alignment',
'|',
'horizontalLine',
'insertTable',
'|',
'undo',
'redo',
'|'
]},
link: {
addTargetToExternalLinks: true,
defaultProtocol: "http://"
}
})
.then(editor => {
$CKEditor = editor;
})
.catch(error => {
console.error('CK-Editor error...');
console.error(error);
});
// ------------ // ------------
@@ -210,7 +170,7 @@ $(document).ready(function () {
e.preventDefault(); e.preventDefault();
$CKEditor.updateSourceElement(); if (!HcrRichText.sync(document.querySelector('#alert_message'))) return;
$.post("./", $(this).serialize()) $.post("./", $(this).serialize())
+3 -44
View File
@@ -6,54 +6,13 @@
$(document).ready(function () { $(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(), var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}"), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
$CKEditor;
// ---------------------- // ----------------------
// Initialize the Editor. // Initialize the Editor.
// ---------------------- // ----------------------
ClassicEditor HcrRichText.create(document.querySelector('#popover_text'));
.create(document.querySelector('#popover_text'), {
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'underline',
'link',
'bulletedList',
'numberedList',
'|',
'fontColor',
'fontSize',
'fontFamily',
'|',
'indent',
'outdent',
'alignment',
'|',
'horizontalLine',
'insertTable',
'|',
'undo',
'redo',
'|'
]},
link: {
addTargetToExternalLinks: true,
defaultProtocol: "http://"
}
})
.then(editor => {
$CKEditor = editor;
})
.catch(error => {
console.error('CK-Editor error...');
console.error(error);
});
// ----------- // -----------
// Save Button // Save Button
@@ -114,7 +73,7 @@ $(document).ready(function () {
e.preventDefault(); e.preventDefault();
$CKEditor.updateSourceElement(); if (!HcrRichText.sync(document.querySelector('#popover_text'))) return;
$.post("./", $(this).serialize()) $.post("./", $(this).serialize())
-6
View File
File diff suppressed because one or more lines are too long
+49
View File
@@ -0,0 +1,49 @@
document.querySelectorAll('.club-action-modal').forEach(modal => {
const form = modal.querySelector('form');
if (!form) return;
let saving = false;
modal.addEventListener('shown.bs.modal', () => {
form.querySelector('input:not([type="hidden"]),select,textarea')?.focus();
});
modal.addEventListener('hide.bs.modal', event => {
if (saving) event.preventDefault();
});
form.addEventListener('submit', async event => {
event.preventDefault();
if (saving || !form.reportValidity()) return;
const error = modal.querySelector('.club-modal-error');
error.hidden = true;
const data = new FormData(form);
const buttons = Array.from(modal.querySelectorAll('button'));
const disabled = buttons.map(button => button.disabled);
saving = true;
buttons.forEach(button => button.disabled = true);
try {
// The existing hidden field named "action" shadows form.action.
const response = await fetch(form.getAttribute('action') || 'main.php', {
method: 'POST', credentials: 'same-origin', body: data,
headers: {'X-Requested-With': 'XMLHttpRequest', 'X-HCR-Modal': '1'}
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Your change could not be saved.');
const target = new URL(result.redirect, window.location.href);
if (target.origin !== window.location.origin) throw new Error('The return address could not be verified. Reload this page.');
if (target.pathname === window.location.pathname && target.search === window.location.search) {
// A fragment-only navigation would leave the saved dialog and stale content open.
window.history.replaceState(null, '', target.href);
window.location.reload();
} else {
window.location.assign(target.href);
}
} catch (failure) {
error.textContent = failure instanceof SyntaxError || failure instanceof TypeError
? 'Your change could not be confirmed. Reload the club page before retrying.'
: failure.message;
error.hidden = false;
error.focus();
} finally {
saving = false;
buttons.forEach((button, index) => button.disabled = disabled[index]);
}
});
});
+3 -43
View File
@@ -5,53 +5,13 @@
$(document).ready(function () { $(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}"), $CKEditor; var $application = $("#APPLICATION_MNEMONIC").val(), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
// ---------------------- // ----------------------
// Initialize the Editor. // Initialize the Editor.
// ---------------------- // ----------------------
ClassicEditor HcrRichText.create(document.querySelector('#alert_message'));
.create(document.querySelector('#alert_message'), {
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'underline',
'link',
'bulletedList',
'numberedList',
'|',
'fontColor',
'fontSize',
'fontFamily',
'|',
'indent',
'outdent',
'alignment',
'|',
'horizontalLine',
'insertTable',
'|',
'undo',
'redo',
'|'
]},
link: {
addTargetToExternalLinks: true,
defaultProtocol: "http://"
}
})
.then(editor => {
$CKEditor = editor;
})
.catch(error => {
console.error('CK-Editor error...');
console.error(error);
});
// ------------ // ------------
@@ -210,7 +170,7 @@ $(document).ready(function () {
e.preventDefault(); e.preventDefault();
$CKEditor.updateSourceElement(); if (!HcrRichText.sync(document.querySelector('#alert_message'))) return;
$.post("./", $(this).serialize()) $.post("./", $(this).serialize())
+3 -44
View File
@@ -6,54 +6,13 @@
$(document).ready(function () { $(document).ready(function () {
var $application = $("#APPLICATION_MNEMONIC").val(), var $application = $("#APPLICATION_MNEMONIC").val(),
$sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}"), $sessionStorage = JSON.parse(sessionStorage.getItem($application) || "{}");
$CKEditor;
// ---------------------- // ----------------------
// Initialize the Editor. // Initialize the Editor.
// ---------------------- // ----------------------
ClassicEditor HcrRichText.create(document.querySelector('#popover_text'));
.create(document.querySelector('#popover_text'), {
toolbar: {
items: [
'heading',
'|',
'bold',
'italic',
'underline',
'link',
'bulletedList',
'numberedList',
'|',
'fontColor',
'fontSize',
'fontFamily',
'|',
'indent',
'outdent',
'alignment',
'|',
'horizontalLine',
'insertTable',
'|',
'undo',
'redo',
'|'
]},
link: {
addTargetToExternalLinks: true,
defaultProtocol: "http://"
}
})
.then(editor => {
$CKEditor = editor;
})
.catch(error => {
console.error('CK-Editor error...');
console.error(error);
});
// ----------- // -----------
// Save Button // Save Button
@@ -144,7 +103,7 @@ $(document).ready(function () {
e.preventDefault(); e.preventDefault();
$CKEditor.updateSourceElement(); if (!HcrRichText.sync(document.querySelector('#popover_text'))) return;
$.post("./", $(this).serialize()) $.post("./", $(this).serialize())
+47
View File
@@ -0,0 +1,47 @@
/* Shared adapter for the locally hosted SunEditor build. */
window.HcrRichText = (function () {
'use strict';
const editors = new WeakMap();
function create(element) {
const buttons = element.closest('.card-body').querySelectorAll('.btnSave');
buttons.forEach(button => button.disabled = true);
const failure = function () {
const message = document.createElement('p');
message.setAttribute('role', 'alert');
message.textContent = 'The text editor could not load. Reload this page before saving.';
element.parentNode.appendChild(message);
};
let timer;
try {
timer = setTimeout(failure, 10000);
const editor = SUNEDITOR.create(element, {
height: '240px',
plugins: ['blockStyle', 'link', 'list_bulleted', 'list_numbered', 'font', 'fontSize', 'fontColor', 'align', 'hr', 'table'].map(name => SUNEDITOR.plugins[name]),
buttonList: [
['undo', 'redo'], ['blockStyle', 'bold', 'italic', 'underline'],
['link', 'list_bulleted', 'list_numbered'],
['font', 'fontSize', 'fontColor'], ['indent', 'outdent', 'align'],
['hr', 'table']
]
});
editor.$.eventManager.events.onload = function () {
clearTimeout(timer);
editors.set(element, editor);
element.hcrEditor = editor;
buttons.forEach(button => button.disabled = false);
};
} catch (error) {
console.error('Text editor initialization failed.', error);
clearTimeout(timer);
failure();
}
}
function sync(element) {
const editor = editors.get(element);
if (!editor)
return false;
element.value = editor.isEmpty() ? '' : editor.$.html.get();
return true;
}
return {create: create, sync: sync};
}());
+1
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
/* HTML maxlength counts UTF-16 units; the server counts Unicode code points. */
(function () {
'use strict';
function initialize() {
document.querySelectorAll('[data-character-limit]').forEach(function (field) {
const limit = Number(field.dataset.characterLimit);
function validate() {
const tooLong = Array.from(field.value.replace(/^[ \t\n\r\0\v]+|[ \t\n\r\0\v]+$/g, '')).length > limit;
field.setCustomValidity(tooLong ? 'Please enter ' + limit + ' characters or fewer.' : '');
}
field.addEventListener('input', validate);
validate();
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize);
else initialize();
}());
+20 -11
View File
@@ -1,28 +1,37 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
ini_set('display_errors','0'); ini_set('display_errors', '0');
ini_set('log_errors','1'); ini_set('log_errors', '1');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer'); header('Referrer-Policy: no-referrer');
header('X-Frame-Options: DENY'); header('X-Frame-Options: DENY');
header('Cache-Control: no-store'); header('Cache-Control: no-store');
set_exception_handler(function (Throwable $e): void { set_exception_handler(function (Throwable $e): void {
$status=$e instanceof AppException?$e->status:($e instanceof InvalidArgumentException?422:500); $status = $e instanceof AppException ? $e->status : ($e instanceof InvalidArgumentException ? 422 : 500);
$message=$status<500?$e->getMessage():'The application could not complete this request. Please try again later.'; $message = $status < 500 ? $e->getMessage() : 'The application could not complete this request. Please try again later.';
if($e instanceof PDOException && $e->getCode()==='23000'){$status=409;$message='This record already exists or is still referenced by another record.';} if ($e instanceof PDOException && $e->getCode() === '23000') {
if($status>=500)error_log((string)$e); $status = 409;
$message = 'This record already exists or is still referenced by another record.';
}
if ($status >= 500)
error_log((string) $e);
http_response_code($status); http_response_code($status);
if(strtolower($_SERVER['HTTP_X_REQUESTED_WITH']??'')==='xmlhttprequest'){ if (strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest') {
header('Content-Type: application/json');echo json_encode(['ok'=>false,'error'=>$message]); header('Content-Type: application/json');
}else{header('Content-Type: text/html; charset=UTF-8');echo '<!doctype html><html lang="en"><title>Request could not be completed</title><main><h1>Request could not be completed</h1><p>'.htmlspecialchars($message,ENT_QUOTES,'UTF-8').'</p><p><a href="main.php">Return to the application</a></p></main></html>';} echo json_encode(['ok' => false, 'error' => $message]);
} else {
header('Content-Type: text/html; charset=UTF-8');
echo '<!doctype html><html lang="en"><title>Request could not be completed</title><main><h1>Request could not be completed</h1><p>' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</p><p><a href="main.php">Return to the application</a></p></main></html>';
}
}); });
require_once __DIR__ . '/classes/Base.php'; require_once __DIR__ . '/classes/Base.php';
$application = new Base(); $application = new Base();
$https = Transport::https($_SERVER,getenv('HCR_TRUSTED_PROXIES')?:''); $https = Transport::https($_SERVER, getenv('HCR_TRUSTED_PROXIES') ?: '');
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"); header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
if($https&&(string)$application->getSettings()->Environment==='Production')header('Strict-Transport-Security: max-age=31536000'); if ($https && (string) $application->getSettings()->Environment === 'Production')
header('Strict-Transport-Security: max-age=31536000');
if ((string) $application->getSettings()->Environment === 'Production' && !$https) { if ((string) $application->getSettings()->Environment === 'Production' && !$https) {
http_response_code(400); http_response_code(400);
exit('HTTPS is required. Open the secure application URL.'); exit('HTTPS is required. Open the secure application URL.');
+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE IF NOT EXISTS club_invitations (
invitation_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
club_serial BIGINT UNSIGNED NOT NULL,
invite_email VARCHAR(100) NOT NULL,
membership_role ENUM('member','administrator') NOT NULL DEFAULT 'member',
invited_by BIGINT UNSIGNED NOT NULL,
invitation_status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending',
expires_at DATETIME NOT NULL,
sent_at DATETIME NOT NULL,
accepted_at DATETIME NULL,
UNIQUE KEY (club_serial,invite_email),
FOREIGN KEY (club_serial) REFERENCES clubs(club_serial) ON DELETE RESTRICT,
FOREIGN KEY (invited_by) REFERENCES users(user_serial) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS club_tickets (
ticket_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
club_serial BIGINT UNSIGNED NOT NULL,
user_serial BIGINT UNSIGNED NOT NULL,
subject VARCHAR(200) NOT NULL,
ticket_status ENUM('open','closed') NOT NULL DEFAULT 'open',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (club_serial,user_serial,ticket_serial),
FOREIGN KEY (club_serial) REFERENCES clubs(club_serial) ON DELETE RESTRICT,
FOREIGN KEY (user_serial) REFERENCES users(user_serial) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS club_ticket_messages (
message_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
ticket_serial BIGINT UNSIGNED NOT NULL,
author_serial BIGINT UNSIGNED NOT NULL,
message_text TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_serial) REFERENCES club_tickets(ticket_serial) ON DELETE RESTRICT,
FOREIGN KEY (author_serial) REFERENCES users(user_serial) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS user_mfa (
user_serial BIGINT UNSIGNED NOT NULL PRIMARY KEY,
secret_cipher TEXT NOT NULL,
last_counter BIGINT NOT NULL DEFAULT -1,
FOREIGN KEY (user_serial) REFERENCES users(user_serial) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS mfa_recovery (
user_serial BIGINT UNSIGNED NOT NULL,
code_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
PRIMARY KEY(user_serial,code_hash),
FOREIGN KEY (user_serial) REFERENCES users(user_serial) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS club_resource_usage (
club_serial BIGINT UNSIGNED NOT NULL,
resource_name VARCHAR(32) NOT NULL,
window_started DATETIME NOT NULL,
used_count INT UNSIGNED NOT NULL,
PRIMARY KEY(club_serial,resource_name),
FOREIGN KEY (club_serial) REFERENCES clubs(club_serial) ON DELETE RESTRICT
) ENGINE=InnoDB;
+22 -1
View File
@@ -20,7 +20,8 @@ class Application:
if not os.environ.get('HCR_TEST_DSN'): if not os.environ.get('HCR_TEST_DSN'):
raise RuntimeError('An isolated HCR_TEST_DSN is required.') raise RuntimeError('An isolated HCR_TEST_DSN is required.')
self.env=os.environ.copy() self.env=os.environ.copy()
self.env.update(HCR_APPLICATION_URL='',HCR_ENVIRONMENT='Development') self.env['HCR_MFA_KEY']='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
self.env.update(HCR_SUPER_ADMIN_EMAIL='',HCR_APPLICATION_URL='',HCR_ENVIRONMENT='Development',HCR_SMTP_HOST='127.0.0.1',HCR_SMTP_PORT='1',HCR_SMTP_USER='test@example.test',HCR_SMTP_PASSWORD='test-only',HCR_SMTP_FROM='test@example.test')
self.database=self.fixture('create')['database'] self.database=self.fixture('create')['database']
self.env['HCR_DB_NAME']=self.database self.env['HCR_DB_NAME']=self.database
with socket.socket() as available: with socket.socket() as available:
@@ -52,7 +53,27 @@ class Application:
_,body,_=self.request() _,body,_=self.request()
status,body,_=self.request(data={'action':'SignIn.login','csrf_token':self.csrf(body),'user_email':email,'user_password':password}) status,body,_=self.request(data={'action':'SignIn.login','csrf_token':self.csrf(body),'user_email':email,'user_password':password})
if status!=200 or b'name="csrf-token"' not in body:raise AssertionError('Login failed: '+body[:500].decode(errors='replace')) if status!=200 or b'name="csrf-token"' not in body:raise AssertionError('Login failed: '+body[:500].decode(errors='replace'))
if b'<h1>Account security</h1>' in body:
if b'setup key' in body:
secret=re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode()
code=self.totp(secret)
status,body,_=self.request(data={'action':'security','csrf_token':self.csrf(body),'current_password':password,'mfa_code':code})
if b'Save your recovery codes' not in body:raise AssertionError(body[:1000])
recovery=re.findall(rb'\b[a-f0-9]{32}\b',body)
self.recovery=[c.decode() for c in recovery]
status,body,_=self.request()
else:
code=self.fixture('mfaCode',email)['code']
status,body,_=self.request(data={'action':'security','csrf_token':self.csrf(body),'current_password':password,'mfa_code':code})
if status!=200:raise AssertionError('MFA failed: '+str(status))
self.token=self.csrf(body) self.token=self.csrf(body)
@staticmethod
def totp(secret, at=None):
import base64,hashlib,hmac,struct,time
secret=base64.b32decode(secret+'='*((8-len(secret)%8)%8))
digest=hmac.new(secret,struct.pack('>Q',int((time.time() if at is None else at)//30)),hashlib.sha1).digest()
offset=digest[-1]&15
return str((int.from_bytes(digest[offset:offset+4],'big')&0x7fffffff)%1000000).zfill(6)
def post(self,action,**values): def post(self,action,**values):
return self.request(data={'action':action,'csrf_token':self.token,**values},ajax=True) return self.request(data={'action':action,'csrf_token':self.token,**values},ajax=True)
def logout(self):self.post('signout') def logout(self):self.post('signout')
+2 -2
View File
@@ -80,7 +80,7 @@ try:
check(request(data={'action[]': 'bad'})[0] == 400, 'Array-shaped request fields are rejected') check(request(data={'action[]': 'bad'})[0] == 400, 'Array-shaped request fields are rejected')
status, html, _ = request('/?Xaction=signup') status, html, _ = request('/?Xaction=signup')
check(status == 200 and 'name="user_first_name"' in html, 'Signup is reachable before login') check(status == 200 and 'name="user_first_name"' in html, 'Signup is reachable before login')
fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator'} fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator','club_name':'HTTP signup club'}
status, html, _ = request(data=fields) status, html, _ = request(data=fields)
check(status == 200 and 'Your account has been created' in html, 'Signup form creates an account and redirects') check(status == 200 and 'Your account has been created' in html, 'Signup form creates an account and redirects')
status, html, _ = request() status, html, _ = request()
@@ -89,7 +89,7 @@ try:
status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP long password!'}) status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP long password!'})
check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell') check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell')
token = csrf(html) token = csrf(html)
check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator'})[0] == 403, 'Regular users cannot edit account privileges') check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator','club_name':'HTTP signup club'})[0] == 403, 'Regular users cannot edit account privileges')
check(request(data={'action': 'RosterReports.csv', 'club_serial': '1', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions') check(request(data={'action': 'RosterReports.csv', 'club_serial': '1', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched') check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched')
check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token') check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token')
+5
View File
@@ -0,0 +1,5 @@
import json,os,subprocess
from pathlib import Path
root=Path(__file__).resolve().parents[1]
php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
subprocess.run(php+['-d','memory_limit=16M','tests/backup_validation.php'],cwd=root,check=True)
+19
View File
@@ -0,0 +1,19 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
$checks=0;function checkBackup(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo 'PASS: '.$message."\n";}
$path=tempnam(sys_get_temp_dir(),'hcr-backup-test-');
try{
$padding=str_repeat('-',150)."\n";
file_put_contents($path,$padding."-- Dump completed on 2026-10-20 12:00:00\n");checkBackup(BackupValidation::complete($path),'Complete SQL footer is recognized');
file_put_contents($path,$padding."-- Dump completed on 2026-10-20 12:00:00\nINSERT INTO unfinished");checkBackup(!BackupValidation::complete($path),'A completion comment before a truncated ending is rejected');
file_put_contents($path,$padding.'Incomplete SQL');checkBackup(!BackupValidation::complete($path),'Missing completion footer is rejected');
file_put_contents($path,"-- Dump completed on fake\n");checkBackup(!BackupValidation::complete($path),'Suspiciously short dump is rejected');
file_put_contents($path,$padding."-- Dump completed on 2026-10-20 12:00:00\r\n");checkBackup(BackupValidation::complete($path),'Windows line endings are supported');
$handle=fopen($path,'w+b');fwrite($handle,"-- Large backup validation fixture\n");ftruncate($handle,64*1024*1024);fseek($handle,0,SEEK_END);fwrite($handle,"\n-- Dump completed on 2026-10-20 12:00:00\n");fclose($handle);
checkBackup(BackupValidation::complete($path),'A 64 MiB dump validates under a 16 MiB PHP memory limit');
checkBackup(strlen(hash_file('sha256',$path))===64,'Checksum generation also works with bounded memory');
checkBackup(memory_get_peak_usage(true)<16*1024*1024,'Peak PHP memory stays below the configured limit');
}finally{unlink($path);}
checkBackup(!BackupValidation::complete($path),'Missing backup file is rejected');
echo "$checks backup validation checks passed.\n";
+52 -10
View File
@@ -8,10 +8,11 @@ const fs=require('node:fs/promises');
const errors=[],checks=[];page.on('pageerror',error=>errors.push(error.message)); const errors=[],checks=[];page.on('pageerror',error=>errors.push(error.message));
await page.exposeFunction('recordCspViolation',message=>errors.push(message));await page.addInitScript(()=>document.addEventListener('securitypolicyviolation',event=>window.recordCspViolation('CSP: '+event.violatedDirective+' '+event.blockedURI))); await page.exposeFunction('recordCspViolation',message=>errors.push(message));await page.addInitScript(()=>document.addEventListener('securitypolicyviolation',event=>window.recordCspViolation('CSP: '+event.violatedDirective+' '+event.blockedURI)));
const pass=message=>{checks.push(message);console.log('PASS: '+message);}; const pass=message=>{checks.push(message);console.log('PASS: '+message);};
const openClubModal=async step=>{await page.locator('[data-club-step="'+step+'"]').first().click();await page.locator('.club-action-modal.show').waitFor();};
const submit=async data=>{await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(data=>formSubmit(data),data)]);await page.waitForFunction(()=>typeof formSubmit==='function');await page.waitForLoadState('networkidle');}; const submit=async data=>{await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.evaluate(data=>formSubmit(data),data)]);await page.waitForFunction(()=>typeof formSubmit==='function');await page.waitForLoadState('networkidle');};
try{ try{
await page.goto(process.env.HCR_TEST_URL+'/main.php'); await page.goto(process.env.HCR_TEST_URL+'/main.php');
await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.locator('#login button[type="submit"]').click()]);await page.waitForFunction(()=>typeof formSubmit==='function'); await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await Promise.all([page.waitForNavigation({waitUntil:'load'}),page.locator('#login button[type="submit"]').click()]);await require('./browser_mfa')(page,'Admin test password 123!');await page.waitForFunction(()=>typeof formSubmit==='function');
pass('Browser login loads the application shell without script errors'); pass('Browser login loads the application shell without script errors');
const safe=await page.evaluate(()=>{ const safe=await page.evaluate(()=>{
const original=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){}; const original=HTMLFormElement.prototype.submit;HTMLFormElement.prototype.submit=function(){};
@@ -32,18 +33,59 @@ const fs=require('node:fs/promises');
await page.route('**/*',failUsage);await page.locator('.btnDelete').click();await page.getByText('Deliberate usage-check failure',{exact:true}).waitFor();assert(!await page.locator('#confirm-dialog').isVisible());pass('A failed deletion check shows an error and does not open confirmation');await page.unroute('**/*',failUsage); await page.route('**/*',failUsage);await page.locator('.btnDelete').click();await page.getByText('Deliberate usage-check failure',{exact:true}).waitFor();assert(!await page.locator('#confirm-dialog').isVisible());pass('A failed deletion check shows an error and does not open confirmation');await page.unroute('**/*',failUsage);
await page.locator('.btnDelete').click();await page.locator('#confirm-dialog').waitFor({state:'visible'});await page.locator('#confirm-dialog .btnConfirmDialogConfirm').click();await page.locator('.setupUsers').waitFor();assert(await page.locator('tr.user-row').filter({hasText:'browser_tester'}).count()===0);pass('Deleting an unused account succeeds through the browser'); await page.locator('.btnDelete').click();await page.locator('#confirm-dialog').waitFor({state:'visible'});await page.locator('#confirm-dialog .btnConfirmDialogConfirm').click();await page.locator('.setupUsers').waitFor();assert(await page.locator('tr.user-row').filter({hasText:'browser_tester'}).count()===0);pass('Deleting an unused account succeeds through the browser');
await submit({action:'Dropdowns.setupDropdowntypes'});await page.locator('.setupDropdowntypes').waitFor();await page.locator('.btnAdd').click();await page.locator('#addDropdowntype').waitFor();await page.locator('#dropdowntype_name').fill('browser_type');await page.locator('#dropdowntype_title').fill('Browser type');await page.locator('.btnSave').click();await page.locator('.setupDropdowntypes').waitFor();pass('Dropdown type form works with restricted usage metadata'); await submit({action:'Dropdowns.setupDropdowntypes'});await page.locator('.setupDropdowntypes').waitFor();await page.locator('.btnAdd').click();await page.locator('#addDropdowntype').waitFor();await page.locator('#dropdowntype_name').fill('browser_type');await page.locator('#dropdowntype_title').fill('Browser type');await page.locator('.btnSave').click();await page.locator('.setupDropdowntypes').waitFor();pass('Dropdown type form works with restricted usage metadata');
await submit({action:'Alerts.addAlert'});await page.locator('.ck-editor').waitFor();pass('Alert editor initializes with updated jQuery and the security policy'); const richHtml='<p><strong>Legacy bold</strong> <em>italic</em> <u>underline</u> <a href="https://example.test/">link</a> <span style="color: rgb(255, 0, 0); font-family: Arial; font-size: 18px;">font</span></p><ul><li>Bullet</li></ul><figure class="table"><table><tbody><tr><td>Cell</td></tr></tbody></table></figure>';
await submit({action:'Popovers.addPopover'});await page.locator('.ck-editor').waitFor();pass('Popover editor initializes with updated jQuery and the security policy'); for (const kind of ['Alert','Popover']) {
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=Roster.workspace'); const isAlert=kind==='Alert',field=isAlert?'alert_message':'popover_text',prefix=isAlert?'alert':'popover',controller=kind+'s';
await page.evaluate(({kind,controller})=>{const key=document.querySelector('#APPLICATION_MNEMONIC').value,state=JSON.parse(sessionStorage.getItem(key)||'{}');state['add'+kind]=state['edit'+kind]=controller+'.setup'+kind+'s';sessionStorage.setItem(key,JSON.stringify(state));},{kind,controller});
await submit({action:controller+'.add'+kind});
await page.waitForFunction(field=>!!document.getElementById(field).hcrEditor,field);
assert(await page.locator('.btnSave').isEnabled());pass(kind+' editor initializes locally under the security policy');
await page.evaluate(({field,richHtml,kind,controller})=>{
document.getElementById(field).hcrEditor.$.html.set(richHtml);
const key=document.querySelector('#APPLICATION_MNEMONIC').value,state=JSON.parse(sessionStorage.getItem(key)||'{}');state['add'+kind]=controller+'.setup'+kind+'s';sessionStorage.setItem(key,JSON.stringify(state));
},{field,richHtml,kind,controller});
await page.locator('#'+prefix+'_title').fill('Browser rich '+kind);
if(isAlert){
await page.evaluate(()=>{$('#alert_starts_date').datepicker('update','2026-01-01');$('#alert_expires_date').datepicker('update','2027-12-31');$('#alert_starts_time').inputmask('setvalue','00:00');$('#alert_expires_time').inputmask('setvalue','23:59');});
}else await page.locator('#popover_name').fill('browser_rich_popover');
await page.locator('.btnSave').click();await page.locator('.setup'+kind+'s').waitFor();
const richRow=page.locator('tr.'+prefix+'-row').filter({hasText:isAlert?'Browser rich Alert':'browser_rich_popover'});
const serial=await richRow.getAttribute('data-'+prefix+'-serial');await richRow.click();
await page.waitForFunction(field=>!!document.getElementById(field)?.hcrEditor,field);
const content=await page.evaluate(field=>document.getElementById(field).hcrEditor.$.html.get(),field);
assert.match(content,/<strong>Legacy bold<\/strong>/);assert.match(content,/<em>italic<\/em>/);assert.match(content,/<u>underline<\/u>/);assert.match(content,/<table/);assert.match(content,/>Cell</);assert.match(content,/<ul/);assert.match(content,/https:\/\/example.test\//);assert.match(content,/font-family/);assert.match(content,/font-size/);assert.match(content,/color:/);
pass(kind+' add/save/reopen preserves legacy formatting, lists, links, fonts and tables');
await page.locator('.sun-editor [contenteditable=true]').first().press('Control+End');
await page.locator('.sun-editor [contenteditable=true]').first().press('Enter');
await page.locator('.sun-editor [contenteditable=true]').first().pressSequentially('Browser edit');
await page.locator('.btnSave').click();await page.locator('.setup'+kind+'s').waitFor();
await submit({action:controller+'.edit'+kind,[prefix+'_serial']:serial});
await page.waitForFunction(field=>!!document.getElementById(field)?.hcrEditor,field);
assert((await page.locator('.sun-editor [contenteditable=true]').first().innerText()).includes('Browser edit'));
pass(kind+' edits made in the visible editor are persisted');
}
await submit({action:'Popovers.addPopover'});await page.waitForFunction(()=>!!document.querySelector('#popover_text').hcrEditor);
const cleaned=await page.evaluate(()=>{const editor=document.querySelector('#popover_text').hcrEditor;editor.$.html.set('<p onclick="window.bad=1">Safe<script>window.bad=1</script><a href="javascript:alert(1)">link</a></p>');return editor.$.html.get();});
assert(!/<script|onclick=|javascript:/i.test(cleaned));pass('Editor content filtering removes scripts, event attributes and unsafe links');
await page.locator('.sun-editor [contenteditable=true]').first().focus();
await page.evaluate(()=>{const transfer=new DataTransfer();transfer.setData('text/html','<p><strong>Pasted formatting</strong></p>');transfer.setData('text/plain','Pasted formatting');document.querySelector('.sun-editor [contenteditable=true]').dispatchEvent(new ClipboardEvent('paste',{clipboardData:transfer,bubbles:true,cancelable:true}));});
await page.waitForFunction(()=>document.querySelector('#popover_text').hcrEditor.$.html.get().includes('<strong>Pasted formatting</strong>'));pass('Pasting formatted HTML retains bold text');
await page.route('**/js/suneditor-3.3.3.min.js',route=>route.abort());await submit({action:'Alerts.addAlert'});
await page.getByText('The text editor could not load. Reload this page before saving.',{exact:true}).waitFor();assert(await page.locator('.btnSave').isDisabled());pass('Editor load failure is visible and saving stays disabled');await page.unroute('**/js/suneditor-3.3.3.min.js');
await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=Roster.createClub');
let form=page.locator('form').filter({has:page.getByRole('button',{name:'Create club',exact:true})});await form.getByLabel('Club name',{exact:true}).fill('Browser Club');await form.getByRole('button',{name:'Create club',exact:true}).click();await page.getByRole('heading',{name:'Browser Club',exact:true}).waitFor();pass('Club creation works in the browser'); let form=page.locator('form').filter({has:page.getByRole('button',{name:'Create club',exact:true})});await form.getByLabel('Club name',{exact:true}).fill('Browser Club');await form.getByRole('button',{name:'Create club',exact:true}).click();await page.getByRole('heading',{name:'Browser Club',exact:true}).waitFor();pass('Club creation works in the browser');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Add member',exact:true})});await form.getByLabel('Username or email').fill('testadmin');await form.getByRole('button',{name:'Add member',exact:true}).click();await page.getByRole('cell',{name:'Test Administrator',exact:true}).waitFor();pass('Membership assignment works in the browser'); assert.equal(await page.locator('.clubSummary input:visible,.clubSummary select:visible').count(),0);pass('Club summary hides all editing fields until an action is opened');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Add season',exact:true})});await form.getByLabel('Season name').fill('Browser Season');await form.getByLabel('Starts on').fill('2026-01-01');await form.getByLabel('Ends on').fill('2026-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.getByText('Browser Season (2026-01-01 to 2026-12-31)',{exact:true}).waitFor();pass('Season creation works in the browser'); await openClubModal('club');await page.locator('.club-action-modal.show').getByLabel('Club name',{exact:true}).fill('Unsaved name');await page.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await page.locator('.club-action-modal.show').waitFor({state:'hidden'});assert.equal(await page.locator('.clubSummary h1').textContent(),'Browser Club');pass('Canceling a club edit leaves the club unchanged');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save dues',exact:true})});await form.getByLabel('Dues (USD)').fill('10.00');await form.getByRole('button',{name:'Save dues',exact:true}).click();await page.getByRole('button',{name:'Record payment',exact:true}).waitFor();pass('Dues entry works in the browser'); await openClubModal('club');await page.locator('.club-action-modal.show').getByLabel('Club name',{exact:true}).fill('Browser Club');await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Save club',exact:true}).click()]);assert.equal(await page.locator('.clubSummary h1').textContent(),'Browser Club');pass('Club edits save through their modal and return to the summary');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Record payment',exact:true})});await form.getByLabel('Payment (USD)').fill('5.00');await form.getByRole('button',{name:'Record payment',exact:true}).click();await page.getByRole('cell',{name:'$5.00',exact:true}).first().waitFor();pass('Recording a payment updates the displayed balance'); await openClubModal('member');form=page.locator('form').filter({has:page.getByRole('button',{name:'Add member',exact:true})});await form.getByLabel('Username or email').fill('testadmin');await form.getByRole('button',{name:'Add member',exact:true}).click();await page.getByRole('cell',{name:'Test Administrator',exact:true}).waitFor();pass('Membership assignment works in the browser');
await page.getByText('Correct dues',{exact:true}).click(); await openClubModal('season');form=page.locator('form').filter({has:page.getByRole('button',{name:'Add season',exact:true})});await form.getByLabel('Season name').fill('Browser Season');await form.getByLabel('Starts on').fill('2026-01-01');await form.getByLabel('Ends on').fill('2025-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.locator('.club-action-modal.show .club-modal-error:not([hidden])').waitFor();assert.equal(await form.getByLabel('Season name').inputValue(),'Browser Season');pass('Server validation stays inside the modal and preserves entered values');await form.getByLabel('Ends on').fill('2026-12-31');await form.getByRole('button',{name:'Add season',exact:true}).click();await page.getByText('Browser Season (2026-01-01 to 2026-12-31)',{exact:true}).waitFor();pass('Season creation works in the browser');
await openClubModal('charge');form=page.locator('form').filter({has:page.getByRole('button',{name:'Save dues',exact:true})});await form.getByLabel('Dues (USD)').fill('10.00');await form.getByRole('button',{name:'Save dues',exact:true}).click();await page.getByRole('button',{name:'Record payment',exact:true}).waitFor();pass('Dues entry works in the browser');
await openClubModal('payment');form=page.locator('form').filter({has:page.getByRole('button',{name:'Record payment',exact:true})});await form.getByLabel('Payment (USD)').fill('5.00');await form.getByRole('button',{name:'Record payment',exact:true}).click();await page.getByRole('cell',{name:'$5.00',exact:true}).first().waitFor();pass('Recording a payment updates the displayed balance');
await openClubModal('adjust');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Save correction',exact:true})});await form.getByLabel('Corrected total (USD)').fill('8.00');await form.getByLabel('Reason',{exact:true}).fill('Correct season rate');await form.getByRole('button',{name:'Save correction',exact:true}).click();await page.getByRole('cell',{name:'$8.00',exact:true}).waitFor();pass('Dues correction updates the balance and preserves history in the browser'); form=page.locator('form').filter({has:page.getByRole('button',{name:'Save correction',exact:true})});await form.getByLabel('Corrected total (USD)').fill('8.00');await form.getByLabel('Reason',{exact:true}).fill('Correct season rate');await form.getByRole('button',{name:'Save correction',exact:true}).click();await page.getByRole('cell',{name:'$8.00',exact:true}).waitFor();pass('Dues correction updates the balance and preserves history in the browser');
await page.getByText('Reverse incorrect payment',{exact:true}).click(); await openClubModal('reverse');
form=page.locator('form').filter({has:page.getByRole('button',{name:'Confirm reversal',exact:true})});await form.getByLabel('Reversal reason').fill('Wrong receipt');await form.getByRole('button',{name:'Confirm reversal',exact:true}).click();await page.getByText('Reversed: Wrong receipt',{exact:false}).waitFor();pass('Payment reversal is recorded visibly without deleting the original payment'); form=page.locator('form').filter({has:page.getByRole('button',{name:'Confirm reversal',exact:true})});await form.getByLabel('Reversal reason').fill('Wrong receipt');await form.getByRole('button',{name:'Confirm reversal',exact:true}).click();await page.getByText('Reversed: Wrong receipt',{exact:false}).waitFor();pass('Payment reversal is recorded visibly without deleting the original payment');
await openClubModal('invite');await page.locator('.club-action-modal.show').getByLabel('Invitation email',{exact:true}).fill('revocation@example.test');await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Send club invitation',exact:true}).click()]);const invitation=page.locator('li').filter({hasText:'revocation@example.test'});await invitation.locator('[data-club-step=revokeInvitation]').click();await page.locator('.club-action-modal.show').getByRole('button',{name:'Cancel',exact:true}).click();await page.locator('.club-action-modal.show').waitFor({state:'hidden'});assert((await invitation.textContent()).includes('pending'));await invitation.locator('[data-club-step=revokeInvitation]').click();await Promise.all([page.waitForNavigation(),page.locator('.club-action-modal.show').getByRole('button',{name:'Revoke invitation',exact:true}).click()]);assert((await page.locator('li').filter({hasText:'revocation@example.test'}).textContent()).includes('revoked'));pass('Invitation revocation requires modal confirmation and Cancel preserves the invitation');
const ledgerDownload=page.waitForEvent('download');await page.getByRole('button',{name:'Export financial history (CSV)',exact:true}).click();assert.equal((await ledgerDownload).suggestedFilename(),'club-financial-history.csv');pass('Financial history can be downloaded from the browser'); const ledgerDownload=page.waitForEvent('download');await page.getByRole('button',{name:'Export financial history (CSV)',exact:true}).click();assert.equal((await ledgerDownload).suggestedFilename(),'club-financial-history.csv');pass('Financial history can be downloaded from the browser');
await page.screenshot({path:path.join(__dirname,'.local','roster.png'),fullPage:true}); await page.screenshot({path:path.join(__dirname,'.local','roster.png'),fullPage:true});
await submit({action:'Reports.selectReport'});await page.getByRole('heading',{name:'Roster reports'}).waitFor();const downloadEvent=page.waitForEvent('download');await page.getByRole('button',{name:'Download CSV'}).click();const download=await downloadEvent;assert.equal(download.suggestedFilename(),'club-roster.csv');pass('Report catalog downloads the roster CSV in the browser'); await submit({action:'Reports.selectReport'});await page.getByRole('heading',{name:'Roster reports'}).waitFor();const downloadEvent=page.waitForEvent('download');await page.getByRole('button',{name:'Download CSV'}).click();const download=await downloadEvent;assert.equal(download.suggestedFilename(),'club-roster.csv');pass('Report catalog downloads the roster CSV in the browser');
+17
View File
@@ -0,0 +1,17 @@
const crypto=require('node:crypto');
function totp(secret){
const alphabet='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';let bits='';for(const c of secret)bits+=alphabet.indexOf(c).toString(2).padStart(5,'0');
const bytes=[];for(let i=0;i+8<=bits.length;i+=8)bytes.push(parseInt(bits.slice(i,i+8),2));
const counter=Buffer.alloc(8);counter.writeBigUInt64BE(BigInt(Math.floor(Date.now()/30000)));
const digest=crypto.createHmac('sha1',Buffer.from(bytes)).update(counter).digest(),offset=digest[19]&15;
return String((digest.readUInt32BE(offset)&0x7fffffff)%1000000).padStart(6,'0');
}
module.exports=async function completeMfa(page,password){
if(!await page.getByRole('heading',{name:'Account security',exact:true}).count())return;
const secret=(await page.locator('pre').textContent()).trim();
await page.locator('[name=current_password]').fill(password);await page.locator('[name=mfa_code]').fill(totp(secret));
await Promise.all([page.waitForNavigation(),page.getByRole('button',{name:'Verify',exact:true}).click()]);
await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();
await Promise.all([page.waitForNavigation(),page.getByRole('link',{name:'I have saved my recovery codes'}).click()]);
};
module.exports.totp=totp;
+1 -1
View File
@@ -4,7 +4,7 @@ from application_harness import Application,ROOT
app=Application() app=Application()
try: try:
env=os.environ.copy();env['HCR_TEST_URL']=app.base env=app.env.copy();env['HCR_TEST_URL']=app.base
node=env.get('NODE_TEST_COMMAND','node') node=env.get('NODE_TEST_COMMAND','node')
result=subprocess.run([node,'tests/browser.js'],cwd=ROOT,env=env) result=subprocess.run([node,'tests/browser.js'],cwd=ROOT,env=env)
if result.returncode:raise SystemExit(result.returncode) if result.returncode:raise SystemExit(result.returncode)
+83
View File
@@ -0,0 +1,83 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
ob_start();session_start();
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable test database required.');
$db=(new Base())->connect();$ids=json_decode(getenv('HCR_TENANT_IDS'),true,512,JSON_THROW_ON_ERROR);$checks=0;
function capacityCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
function capacityDenied(callable $work,int $status,string $message):void{try{$work();}catch(AppException $e){capacityCheck($e->status===$status,$message);return;}throw new RuntimeException($message.' was allowed');}
function capacityActor(int $id):void{$_SESSION=[Base::USER_SERIAL=>$id];}
$roster=new RosterService();$tickets=new ClubTickets();$resources=new ClubResources();
capacityActor(1);
$hash=password_hash('Capacity test password!',PASSWORD_DEFAULT);
$user=$db->prepare("INSERT INTO users(user_name,user_email,user_first_name,user_last_name,user_password_hash,user_email_verified_at,user_role) VALUES(?,?,?, ?,?,UTC_TIMESTAMP(),'User')");
$membership=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,joined_on) VALUES(?,?,'2026-01-01')");
$season=$db->prepare("INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,'2026-01-01','2026-12-31')");
$due=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,1)');
$payment=$db->prepare("INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,recorded_by) VALUES(?,1,'2026-01-01',?,1)");
$adjustment=$db->prepare("INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,recorded_by,request_key) VALUES(?,1,101,'test',1,?)");
$invite=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,invited_by,expires_at,sent_at) VALUES(?,?,1,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 DAY),UTC_TIMESTAMP())");
for($i=1;$i<=76;$i++){
$user->execute(['capacity'.$i,'capacity'.$i.'@example.test','Capacity','Member '.sprintf('%03d',$i),$hash]);$uid=(int)$db->lastInsertId();$membership->execute([$ids['clubA'],$uid]);$mid=(int)$db->lastInsertId();
$season->execute([$ids['clubA'],'Capacity season '.sprintf('%03d',$i)]);$seasonid=(int)$db->lastInsertId();
$due->execute([$ids['clubA'],$mid,$seasonid,100]);$did=(int)$db->lastInsertId();$payment->execute([$did,bin2hex(random_bytes(32))]);$adjustment->execute([$did,bin2hex(random_bytes(32))]);
$invite->execute([$ids['clubA'],'pending'.$i.'@example.test']);
}
$ticket=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$ticket->execute([$ids['clubA'],$ids['membera'],'Long conversation']);$tid=(int)$db->lastInsertId();
$message=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');for($i=1;$i<=76;$i++)$message->execute([$tid,$ids['membera'],'Message '.$i]);
capacityActor($ids['admina2']);
foreach(['members','seasons','dues'] as $kind){
$rows=$roster->$kind($ids['clubA'],1);capacityCheck(count($rows)===25,"$kind page is bounded at 25 rows");
$last=$roster->$kind($ids['clubA'],9999);capacityCheck(count($last)>0&&count($last)<=25&&BoundedList::$pages[$kind]['page']===BoundedList::$pages[$kind]['pages'],"$kind clamps high page to reachable final records");
}
$history=(new LedgerService())->history($ids['clubA'],1,1);capacityCheck(count($history['payments'])===25&&count($history['adjustments'])===25,'Payment and correction histories have independent bounded pages');
capacityCheck(count((new ClubInvitations())->forClub($ids['clubA'],1))===25,'Administrator invitation list is bounded');
capacityCheck(count($tickets->messages($ids['clubA'],$tid,1))===25&&count($tickets->messages($ids['clubA'],$tid,4))===1,'Long conversation exposes all replies across bounded pages');
capacityDenied(fn()=>$roster->members($ids['clubB'],1),403,'Paging cannot access another tenant');
capacityActor($ids['membera']);capacityCheck(count($roster->members($ids['clubA'],1))===1&&BoundedList::$pages['members']['count']===1,'Member roster rows and total remain personal');
capacityCheck(count($roster->dues($ids['clubA'],1))===0&&BoundedList::$pages['dues']['count']===0,'Member dues total excludes other members');
capacityActor($ids['adminb']);capacityDenied(fn()=>$tickets->messages($ids['clubA'],$tid,1),403,'Conversation paging cannot expose foreign replies');
capacityActor(1);putenv('HCR_EXPORT_MAX_ROWS=30');
capacityDenied(fn()=>BoundedList::exportGuard($db,'SELECT COUNT(*) FROM club_memberships WHERE club_serial=?',[$ids['clubA']]),422,'Oversized export is rejected before loading its rows');
BoundedList::$exporting=true;capacityDenied(fn()=>$roster->members($ids['clubA']),422,'Export query itself remains bounded if preflight count is bypassed');BoundedList::$exporting=false;
$_GET=['Xaction'=>'Roster.workspace','club_serial'=>(string)$ids['clubA'],'dues_page'=>'2'];$roster->members($ids['clubA'],2);$nav=BoundedList::navigation('members');capacityCheck(str_contains($nav,'members_page=3')&&str_contains($nav,'dues_page=2'),'Page navigation preserves independent list context');
$_GET['members_page']='-1';capacityDenied(fn()=>BoundedList::page('members'),422,'Invalid list page is rejected');$_GET=[];
foreach(['email','ticket','reply','export'] as $resource){
putenv('HCR_CLUB_'.strtoupper($resource).'_HOURLY=2');capacityActor($ids['admina']);$resources->consume($ids['clubA'],$resource);capacityActor($ids['admina2']);$resources->consume($ids['clubA'],$resource);
capacityDenied(fn()=>$resources->consume($ids['clubA'],$resource),429,"$resource budget is shared across administrators");
capacityActor($ids['adminb']);$resources->consume($ids['clubB'],$resource);capacityCheck(true,"$resource budget is independent for another tenant");
$stmt=$db->prepare('UPDATE club_resource_usage SET window_started=DATE_SUB(UTC_TIMESTAMP(),INTERVAL 2 HOUR) WHERE club_serial=? AND resource_name=?');$stmt->execute([$ids['clubA'],$resource]);
capacityActor($ids['admina2']);$resources->consume($ids['clubA'],$resource);capacityCheck(true,"$resource budget resets after its window");
}
capacityActor($ids['memberb']);capacityDenied(fn()=>$resources->consume($ids['clubA'],'email'),403,'Unauthorized resource request is denied before budget consumption');
putenv('HCR_CLUB_TICKET_HOURLY=invalid');capacityActor($ids['membera']);try{$resources->consume($ids['clubA'],'ticket');throw new LogicException('Invalid limit accepted');}catch(RuntimeException $e){capacityCheck(str_contains($e->getMessage(),'Invalid club'),'Invalid budget configuration fails closed');}
putenv('HCR_CLUB_TICKET_HOURLY');putenv('HCR_EXPORT_MAX_ROWS');
// Idempotent migration must preserve both factors and counters.
$before=$db->query('SELECT * FROM club_resource_usage ORDER BY club_serial,resource_name')->fetchAll();require __DIR__.'/../bin/migrate-security-capacity.php';capacityCheck($before===$db->query('SELECT * FROM club_resource_usage ORDER BY club_serial,resource_name')->fetchAll(),'Security-capacity migration is repeatable without resetting budgets');
// Exercise the real factor service, including rollback and password reset.
capacityActor(1);$_SESSION['auth_version']=(int)$db->query('SELECT user_auth_version FROM users WHERE user_serial=1')->fetchColumn();
$mfa=new MfaService();$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher) VALUES(1,?)');$stmt->execute([$mfa->sealSecret($secret,1)]);
$code=OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now();
capacityCheck($mfa->verify(1,$code,'Admin test password 123!'),'A fresh authenticator code verifies through the maintained library');
capacityCheck(!$mfa->verify(1,$code,'Admin test password 123!'),'The same authenticator time step cannot be reused');
$recovery=bin2hex(random_bytes(16));$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(1,?)');$stmt->execute([hash('sha256',$recovery)]);
$db->exec("CREATE TRIGGER reject_mfa_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
try{$mfa->verify(1,$recovery,'Admin test password 123!');throw new LogicException('Audit failure accepted');}catch(PDOException $e){capacityCheck((int)$db->query('SELECT COUNT(*) FROM mfa_recovery WHERE user_serial=1')->fetchColumn()===1,'Failed mandatory audit rolls back recovery-code consumption');}
$db->exec('DROP TRIGGER reject_mfa_audit');capacityCheck($mfa->verify(1,$recovery,'Admin test password 123!'),'Recovery code still works after a rolled-back attempt');
capacityCheck(!$mfa->verify(1,$recovery,'Admin test password 123!'),'Recovery code is single use after commit');
$cipher=$db->query('SELECT secret_cipher FROM user_mfa WHERE user_serial=1')->fetchColumn();
$stmt=$db->prepare('INSERT INTO user_mfa(user_serial,secret_cipher) VALUES(?,?)');$stmt->execute([$ids['memberb'],$cipher]);
capacityActor($ids['memberb']);$stmt=$db->prepare('SELECT user_auth_version FROM users WHERE user_serial=?');$stmt->execute([$ids['memberb']]);$_SESSION['auth_version']=(int)$stmt->fetchColumn();
try{$mfa->verify($ids['memberb'],$code,'Tenant test password 123!');throw new LogicException('Copied ciphertext accepted');}catch(RuntimeException $e){capacityCheck(str_contains($e->getMessage(),'unlock'),'Encrypted secret is bound to its owning account');}
class CapacityReset extends AuthService {
public string $token='';
public function connect():PDO {return $GLOBALS['db'];}
public function getSettings(){return new SimpleXMLElement('<Settings><ApplicationUrl>https://example.test</ApplicationUrl></Settings>');}
protected function deliverResetEmail(array $user,string $url):bool{parse_str(parse_url($url,PHP_URL_QUERY),$query);$this->token=$query['token'];return true;}
}
$auth=new CapacityReset();$before=$db->query('SELECT * FROM user_mfa WHERE user_serial=1')->fetch();$auth->requestReset('admin@example.test');
capacityCheck($auth->resetPassword($auth->token,'Reset preserves MFA 123!','Reset preserves MFA 123!'),'Password reset works for an MFA-enrolled account');
capacityCheck($before===$db->query('SELECT * FROM user_mfa WHERE user_serial=1')->fetch(),'Password reset preserves the authenticator and replay counter');
$_SESSION['mfa_user']=1;capacityCheck($auth->login('admin@example.test','Reset preserves MFA 123!')&&!$mfa->verified(1),'A password login clears any earlier MFA verification');
echo "$checks capacity service checks passed.\n";ob_end_flush();
+55
View File
@@ -0,0 +1,55 @@
"""New-club signup must never grant administration of an existing tenant."""
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def signup(name='newowner',email='newowner@example.test',club='New owner club',kind='new_club',**extra):
app.fixture('clearTestAttempts')
_,body,_=app.request('/main.php?Xaction=signup')
return app.request(data={'action':'SignUp.createAccount','csrf_token':app.csrf(body),'user_first_name':'New','user_last_name':'Owner','user_email':email,'user_name':name,'user_password':'New owner password 123!','user_password_confirm':'New owner password 123!','registration_kind':kind,'club_name':club,**extra})
try:
ids=app.fixture('tenantSeed');before=app.fixture('inspect')
status,body,_=app.request('/main.php?Xaction=signup')
check(status==200 and b'name="club_name"' in body and b'value="new_club"' in body,'Default signup asks for a new club')
status,body,_=signup(club='')
check(b'Enter your new club name' in body and app.fixture('inspect')==before,'Missing club name creates no account or club')
status,body,_=signup(club='X'*129)
check(b'Enter your new club name' in body and app.fixture('inspect')==before,'Server rejects overlong club names without partial records')
status,body,_=signup(kind='account')
check(b'Choose a valid signup option' in body and app.fixture('inspect')==before,'Public signup cannot use the internal account-only mode')
app.fixture('mfaTestAuditFailure','on')
status,body,_=signup()
app.fixture('mfaTestAuditFailure','off')
check(status==500 and app.fixture('inspect')==before,'Audit failure rolls back account, club and administrator membership')
status,body,_=signup(club='Tenant Alpha',user_role='Super System Administrator',club_serial=ids['clubA'])
state=app.fixture('inspect');owner=next(u for u in state['users'] if u['user_name']=='newowner');memberships=[m for m in state['club_memberships'] if m['user_serial']==owner['user_serial']];club=memberships[0]['club_serial']
check(status==200 and b'Your account has been created' in body,'New-club signup succeeds')
check(owner['user_role']=='User' and owner['user_email_verified_at'] is None,'Signup keeps the global role unprivileged and email unverified')
check(len(memberships)==1 and memberships[0]['membership_role']=='administrator' and club!=ids['clubA'],'Matching an existing club name creates an independent tenant with its own administrator')
before=state;signup()
check(app.fixture('inspect')==before,'Duplicate account submission creates no orphan club')
app.login('newowner@example.test','New owner password 123!')
check(app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(club))[0]==403,'Unverified club owner cannot open the club')
app.fixture('verifyEmail','newowner');app.logout()
# Real login helper enrolls MFA only when the application requires it.
app.login('newowner@example.test','New owner password 123!')
check(bool(app.fixture('mfaState','newowner@example.test')['factor']),'Verified new administrator must enroll an authenticator')
status,body,_=app.request()
check(status==200 and b'class="container-fluid py-3 clubSummary"' in body and b'<h1>Tenant Alpha</h1>' in body,'Verified owner lands directly in the summary-style club page')
check(b'>Create club</button>' not in body and b'Xaction=Roster.createClub' not in body,'Club administrator sees no global club-creation controls')
check(app.request('/main.php?Xaction=Roster.workspace&club_serial='+str(ids['clubA']))[0]==403,'New administrator cannot open the pre-existing club with the same name')
check(app.request('/main.php?Xaction=Roster.createClub')[0]==403,'Dedicated creation page rejects club administrators')
app.logout();before=app.fixture('inspect');status,body,_=signup('notinvited','notinvited@example.test',kind='invited')
check(b'Ask your club administrator' in body and app.fixture('inspect')==before,'Member signup requires a current invitation to the submitted email')
app.fixture('seedTenantInvitation',str(ids['clubA']),'1','newmember@example.test');before=app.fixture('inspect')
status,body,_=signup('newmember','newmember@example.test',kind='invited')
after=app.fixture('inspect');member=next(u for u in after['users'] if u['user_name']=='newmember')
check(status==200 and b'Your account has been created' in body and len(after['clubs'])==len(before['clubs']),'Invited signup creates an account without creating another club')
check(not any(m['user_serial']==member['user_serial'] for m in after['club_memberships']),'Invitation signup does not skip email verification or invitation acceptance')
app.login();status,body,_=app.request('/main.php?Xaction=Roster.createClub')
check(status==200 and b'>Create club</button>' in body,'Super Administrator retains a separate Create club page')
print(checks,'club onboarding HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+17 -5
View File
@@ -50,15 +50,27 @@ try:
assert all(row['ok'] for row in result),result assert all(row['ok'] for row in result),result
assert len(app.fixture('inspect')['club_due_adjustments'])==1 assert len(app.fixture('inspect')['club_due_adjustments'])==1
print('PASS: Concurrent dues correction retries create only one adjustment.') print('PASS: Concurrent dues correction retries create only one adjustment.')
response=app.post('Users.addUser',step='add',user_name='second_admin',user_first_name='Second',user_last_name='Administrator',user_email='second@example.test',user_role='Administrator') response=app.post('Users.addUser',step='add',user_name='second_admin',user_first_name='Second',user_last_name='Administrator',user_email='second@example.test',user_role='Super System Administrator')
assert response[0]==200,response[1] assert response[0]==200,response[1]
ids=[row['user_serial'] for row in app.fixture('inspect')['users'] if row['user_role']=='Administrator'] ids=[row['user_serial'] for row in app.fixture('inspect')['users'] if row['user_role']=='Super System Administrator']
result=race('disable',ids) result=race('disable',ids)
assert sorted(item['ok'] for item in result)==[False,True],result assert sorted(item['ok'] for item in result)==[False,True],result
assert next(item for item in result if not item['ok'])['status']==409,result assert next(item for item in result if not item['ok'])['status'] in [403,409],result
assert sum(row['user_role']=='Administrator' and row['user_status']=='active' for row in app.fixture('inspect')['users'])==1 assert sum(row['user_role']=='Super System Administrator' and row['user_status']=='active' for row in app.fixture('inspect')['users'])==1
print('PASS: Concurrent administrator changes cannot disable the final administrator.') print('PASS: Concurrent administrator changes cannot disable the final administrator.')
print('5 concurrency scenarios passed.') tenant=app.fixture('tenantSeed')
invitation=app.fixture('seedTenantInvitation',str(tenant['clubA']),str(tenant['admina']),'invitee@example.test')['invitation']
result=race('accept',[invitation,invitation],[tenant['invitee']])
assert sorted(item['ok'] for item in result)==[False,True],result
assert sum(row['user_serial']==tenant['invitee'] and row['club_serial']==tenant['clubA'] for row in app.fixture('inspect')['club_memberships'])==1
print('PASS: Concurrent invitation acceptance grants exactly one membership.')
# Restore the worker actor after the previous last-super race, for this isolated fixture only.
app.fixture('restoreTestSuper')
result=race('membership',[tenant['clubA_admina'],tenant['clubA_admina2']],[tenant['clubA']])
assert sorted(item['ok'] for item in result)==[False,True],result
assert sum(row['club_serial']==tenant['clubA'] and row['membership_role']=='administrator' and row['membership_status']=='active' for row in app.fixture('inspect')['club_memberships'])==1
print('PASS: Concurrent membership changes preserve the last club administrator.')
print('7 concurrency scenarios passed.')
finally: finally:
for process in workers: for process in workers:
if process.poll() is None:process.terminate();process.wait(timeout=10) if process.poll() is None:process.terminate();process.wait(timeout=10)
+6
View File
@@ -19,6 +19,7 @@ try:
names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json')) names=archive.namelist();manifest=json.loads(archive.read('release-manifest.json'))
check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests') check(not any(name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml') for name in names),'Release excludes local credentials, backups, Git metadata, and tests')
check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery') check(all(name in names for name in ['classes/LedgerService.php','classes/EmailVerification.php','bin/migrate-production.php','migrations/003_production.sql','js/jquery-3.7.1.min.js']),'Release includes new classes, migration, and updated jQuery')
check(all(name in names for name in ['classes/ClubMemberNotes.php','classes/MemberSummary.php','classes/ClubFormModals.php','js/club-modals.js','classes/MfaSecurityNotifications.php','classes/MfaSetupQr.php','classes/MfaService.php','classes/MfaClock.php','classes/BoundedList.php','classes/ClubResources.php','bin/migrate-security-capacity.php','migrations/005_security_capacity.sql','classes/DatabaseTransaction.php','js/tickets.js','classes/TenantAccess.php','classes/ClubInvitations.php','classes/ClubTickets.php','bin/migrate-tenants.php','migrations/004_tenants.sql','classes/MaintenanceValidation.php','classes/BackupValidation.php','js/rich-text.js','js/suneditor-3.3.3.min.js','css/suneditor-3.3.3.min.css','THIRD-PARTY-NOTICES.md','browser-dependencies.json']) and 'js/ckeditor.js' not in names,'Release includes replacement editor, license, integrity metadata, and new validators')
check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file') check(all(hashlib.sha256(archive.read(name)).hexdigest()==digest for name,digest in manifest.items()),'Release manifest verifies every packaged file')
binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump') binary=os.environ.get('HCR_DUMP_BINARY') or shutil.which('mariadb-dump')
if not binary: if not binary:
@@ -33,6 +34,11 @@ try:
check(not list(Path(temporary).glob('hcr-connection-*')),'Backup credentials file is removed after completion') check(not list(Path(temporary).glob('hcr-connection-*')),'Backup credentials file is removed after completion')
result=subprocess.run(PHP+['bin/backup-database.php',str(ROOT/'.tools')],cwd=ROOT,env=env,capture_output=True) result=subprocess.run(PHP+['bin/backup-database.php',str(ROOT/'.tools')],cwd=ROOT,env=env,capture_output=True)
check(result.returncode!=0,'Backup tool rejects storage within the application tree') check(result.returncode!=0,'Backup tool rejects storage within the application tree')
failed_directory=Path(temporary)/'failed-job';failed_directory.mkdir()
env['HCR_DUMP_BINARY']=PHP[0] # A real executable that rejects dump arguments.
result=subprocess.run(PHP+['bin/backup-database.php',str(failed_directory)],cwd=ROOT,env=env,capture_output=True)
check(result.returncode!=0,'Dump process failure produces a failing job exit status')
check(not list(failed_directory.iterdir()),'Failed backup leaves no credentials, partial dump, or success metadata')
for path in ['/.tools/backups/probe.sql','/migrations/003_production.sql','/bin/check-health.php','/xml/settings.xml','/release-manifest.json']: for path in ['/.tools/backups/probe.sql','/migrations/003_production.sql','/bin/check-health.php','/xml/settings.xml','/release-manifest.json']:
check(app.request(path)[0] in [403,404],'Development router denies '+path) check(app.request(path)[0] in [403,404],'Development router denies '+path)
finally:app.close() finally:app.close()
+86 -1
View File
@@ -4,6 +4,7 @@ require_once __DIR__ . '/../classes/Base.php';
$dsn = getenv('HCR_TEST_DSN'); $dsn = getenv('HCR_TEST_DSN');
if (!$dsn) throw new RuntimeException('Set HCR_TEST_DSN to an isolated test server.'); if (!$dsn) throw new RuntimeException('Set HCR_TEST_DSN to an isolated test server.');
$db = new PDO($dsn, getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $db = new PDO($dsn, getenv('HCR_TEST_USER') ?: 'root', getenv('HCR_TEST_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$db->exec('SET NAMES utf8mb4');
$action = $argv[1] ?? ''; $action = $argv[1] ?? '';
$name = $argv[2] ?? ('hcr_http_test_' . bin2hex(random_bytes(6))); $name = $argv[2] ?? ('hcr_http_test_' . bin2hex(random_bytes(6)));
if (!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/', $name)) throw new RuntimeException('Only generated test databases may be used.'); if (!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/', $name)) throw new RuntimeException('Only generated test databases may be used.');
@@ -22,11 +23,60 @@ if ($action === 'create') {
putenv('HCR_DB_NAME=' . $name); putenv('HCR_DB_NAME=' . $name);
require __DIR__ . '/../bin/migrate-auth.php'; require __DIR__ . '/../bin/migrate-auth.php';
require __DIR__.'/../bin/migrate-production.php'; require __DIR__.'/../bin/migrate-production.php';
putenv('HCR_SUPER_ADMIN_EMAIL=admin@example.test');
require __DIR__.'/../bin/migrate-tenants.php';
putenv('HCR_SUPER_ADMIN_EMAIL');
require __DIR__.'/../bin/migrate-security-capacity.php';
// Idempotent migration is verified separately by the HTTP test runner. // Idempotent migration is verified separately by the HTTP test runner.
echo json_encode(['database' => $name]); echo json_encode(['database' => $name]);
exit; exit;
} }
$db->exec("USE `{$name}`"); $db->exec("USE `{$name}`");
if($action==='memberSummarySeed'){
$a=(int)$argv[3];$b=(int)$argv[4];$own=(int)$argv[5];$neighbor=(int)$argv[6];$foreign=(int)$argv[7];$first=0;
$season=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');
$due=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,1)');
for($i=1;$i<=31;$i++){$season->execute([$a,'Member season '.sprintf('%02d',$i),'2026-01-01','2026-12-31']);$due->execute([$a,$own,(int)$db->lastInsertId(),10000]);if(!$first)$first=(int)$db->lastInsertId();}
$pay=$db->prepare("INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,payment_method,payment_reference,recorded_by) VALUES(?,100,'2026-01-01',?,'cash',?,1)");
for($i=1;$i<=32;$i++)$pay->execute([$first,bin2hex(random_bytes(32)),'OWN-RECEIPT-'.sprintf('%02d',$i)]);
$reversed=(int)$db->lastInsertId();
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,request_key,recorded_by) VALUES(?,?,?,1)');$stmt->execute([$reversed,'Own reversed receipt',bin2hex(random_bytes(32))]);
$stmt=$db->prepare('INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,request_key,recorded_by) VALUES(?,5000,15000,?,?,1)');$stmt->execute([$first,'Own correction <script>unsafe</script>',bin2hex(random_bytes(32))]);
foreach([[$a,$neighbor,'NEIGHBOR-PRIVATE'],[$b,$foreign,'FOREIGN-PRIVATE']] as [$club,$membership,$marker]){$season->execute([$club,'Additional season '.$membership,'2026-01-01','2026-12-31']);$due->execute([$club,$membership,(int)$db->lastInsertId(),99999]);$pay->execute([(int)$db->lastInsertId(),bin2hex(random_bytes(32)),$marker]);}
$season->execute([$a,'Available new season','2026-01-01','2026-12-31']);
echo '{}';exit;
}
if($action==='sessionAge'){
$id=$argv[3]??'';if(!preg_match('/\A[a-zA-Z0-9,-]{16,128}\z/',$id))throw new RuntimeException('Invalid test session.');
session_name('HUNTINGCLUBROSTER');session_id($id);session_start();
if(!isset($_SESSION[Base::USER_SERIAL]))throw new RuntimeException('Missing test session.');
foreach(['authenticated_at','last_activity','recent_auth'] as $index=>$field){$age=$argv[4+$index]??'';if($age!=='')$_SESSION[$field]=time()-(int)$age;}
session_write_close();echo '{}';exit;
}
if($action==='clearTestAttempts'){$db->exec('DELETE FROM auth_attempts');echo '{}';exit;}
if($action==='mfaTestSession'){
$id=$argv[3]??'';if(!preg_match('/\A[a-zA-Z0-9,-]{16,128}\z/',$id))throw new RuntimeException('Invalid test session.');
session_name('HUNTINGCLUBROSTER');session_id($id);session_start();
$user=(int)($_SESSION[Base::USER_SERIAL]??0);$stmt=$db->prepare('SELECT 1 FROM users WHERE user_serial=?');$stmt->execute([$user]);if(!$stmt->fetchColumn())throw new RuntimeException('Owned test account required.');
if(($argv[4]??'')==='expire'&&isset($_SESSION['mfa_replacement']))$_SESSION['mfa_replacement']['expires']=time()-1;
$state=$_SESSION;session_write_close();echo json_encode($state);exit;
}
if($action==='mfaTestAuditFailure'){
if(($argv[3]??'')==='on')$db->exec("CREATE TRIGGER reject_mfa_test_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
else $db->exec('DROP TRIGGER IF EXISTS reject_mfa_test_audit');echo '{}';exit;
}
if($action==='mfaResetToken'){
$token=bin2hex(random_bytes(32));$stmt=$db->prepare('INSERT INTO password_resets(token_hash,user_serial,expires_at) VALUES(?,1,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 HOUR))');$stmt->execute([hash('sha256',$token)]);echo json_encode(['token'=>$token]);exit;
}
if($action==='mfaState'){
$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_email=?');$stmt->execute([$argv[3]]);$user=(int)$stmt->fetchColumn();
$stmt=$db->prepare('SELECT * FROM user_mfa WHERE user_serial=?');$stmt->execute([$user]);$factor=$stmt->fetch();
$stmt=$db->prepare('SELECT code_hash FROM mfa_recovery WHERE user_serial=?');$stmt->execute([$user]);echo json_encode(['factor'=>$factor,'recovery'=>$stmt->fetchAll(PDO::FETCH_COLUMN)]);exit;
}
if($action==='mfaCode'){
$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_email=?');$stmt->execute([$argv[3]]);$user=(int)$stmt->fetchColumn();
$code=bin2hex(random_bytes(16));$stmt=$db->prepare('INSERT INTO mfa_recovery(user_serial,code_hash) VALUES(?,?)');$stmt->execute([$user,hash('sha256',$code)]);echo json_encode(['code'=>$code]);exit;
}
if($action==='fingerprints'){ if($action==='fingerprints'){
$result=[];foreach($db->query('SHOW FULL TABLES WHERE Table_type="BASE TABLE"')->fetchAll(PDO::FETCH_COLUMN) as $table){ $result=[];foreach($db->query('SHOW FULL TABLES WHERE Table_type="BASE TABLE"')->fetchAll(PDO::FETCH_COLUMN) as $table){
$rows=array_map(static fn($row)=>json_encode($row,JSON_THROW_ON_ERROR),$db->query('SELECT * FROM `'.str_replace('`','``',$table).'`')->fetchAll(PDO::FETCH_ASSOC));sort($rows,SORT_STRING);$result[$table]=hash('sha256',implode("\n",$rows)); $rows=array_map(static fn($row)=>json_encode($row,JSON_THROW_ON_ERROR),$db->query('SELECT * FROM `'.str_replace('`','``',$table).'`')->fetchAll(PDO::FETCH_ASSOC));sort($rows,SORT_STRING);$result[$table]=hash('sha256',implode("\n",$rows));
@@ -36,11 +86,46 @@ if($action==='verifyEmail'){
$stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_name=?');$stmt->execute([$argv[3]??'']);echo json_encode(['verified'=>true]);exit; $stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_name=?');$stmt->execute([$argv[3]??'']);echo json_encode(['verified'=>true]);exit;
} }
if($action==='inspect'){ if($action==='inspect'){
$tables=['users','dropdowntypes','dropdowns','statuses','notes','journal','clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals'];$result=[]; $tables=['users','dropdowntypes','dropdowns','statuses','notes','journal','clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','club_invitations','club_tickets','club_ticket_messages'];$result=[];
foreach($tables as $table)$result[$table]=$db->query('SELECT * FROM '.$table)->fetchAll(PDO::FETCH_ASSOC); foreach($tables as $table)$result[$table]=$db->query('SELECT * FROM '.$table)->fetchAll(PDO::FETCH_ASSOC);
foreach($result['users'] as &$user)unset($user['user_password_hash']);unset($user); foreach($result['users'] as &$user)unset($user['user_password_hash']);unset($user);
echo json_encode($result);exit; echo json_encode($result);exit;
} }
if($action==='legacyTenantSchema'){$db->exec('ALTER TABLE club_memberships DROP COLUMN membership_role');$db->exec('ALTER TABLE clubs DROP INDEX club_name, ADD UNIQUE KEY club_name (club_name)');echo '{}';exit;}
if($action==='restoreTestSuper'){$db->exec("UPDATE users SET user_status='active' WHERE user_serial=1");echo '{}';exit;}
if($action==='seedTenantInvitation'){
$stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invited_by,invite_email,expires_at,sent_at) VALUES(?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 DAY),UTC_TIMESTAMP())");$stmt->execute([(int)$argv[3],(int)$argv[4],$argv[5]]);echo json_encode(['invitation'=>(int)$db->lastInsertId()]);exit;
}
if($action==='demoteTenantAdmin'){
$stmt=$db->prepare("UPDATE club_memberships SET membership_role='member' WHERE membership_serial=?");$stmt->execute([(int)($argv[3]??0)]);echo '{}';exit;
}
if($action==='repairConcurrencySeed'){
$club=(int)$argv[3];$user=(int)$argv[4];$inviter=(int)$argv[5];
$stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on) VALUES(?,?,'inactive','2026-01-01')");$stmt->execute([$club,$user]);$membership=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,invited_by,expires_at,sent_at) SELECT ?,user_email,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 1 DAY),UTC_TIMESTAMP() FROM users WHERE user_serial=?");$stmt->execute([$club,$inviter,$user]);echo json_encode(['membership'=>$membership,'invitation'=>(int)$db->lastInsertId()]);exit;
}
if($action==='ticketPagingSeed'){
$a=(int)$argv[3];$b=(int)$argv[4];$owner=(int)$argv[5];$neighbor=(int)$argv[6];$foreign=(int)$argv[7];$oldest=0;
$stmt=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');
for($n=1;$n<=101;$n++){$stmt->execute([$a,$owner,'Archive ticket '.sprintf('%03d',$n)]);if($n===1)$oldest=(int)$db->lastInsertId();}
$stmt->execute([$a,$owner,'Literal %_! <b>needle</b>']);$literal=(int)$db->lastInsertId();
$stmt->execute([$a,$neighbor,'Private neighbor ticket']);$private=(int)$db->lastInsertId();
$stmt->execute([$b,$foreign,'Archive foreign club ticket']);$foreignTicket=(int)$db->lastInsertId();
echo json_encode(['oldest'=>$oldest,'literal'=>$literal,'private'=>$private,'foreign'=>$foreignTicket]);exit;
}
if($action==='tenantSeed'){
$ids=[];$hash=password_hash('Tenant test password 123!',PASSWORD_DEFAULT);
$insert=$db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role,user_email_verified_at) VALUES(?,?,?,?,?,?,UTC_TIMESTAMP())");
foreach(['admina','admina2','adminb','membera','memberb','shared','invitee','unverified'] as $label){$insert->execute(['tenant'.$label,'Tenant',ucfirst($label),$label.'@example.test',$hash,in_array($label,['admina','adminb'],true)?'Administrator':'User']);$ids[$label]=(int)$db->lastInsertId();}
$db->exec("UPDATE users SET user_email_verified_at=NULL WHERE user_name='tenantunverified'");
$db->exec("INSERT INTO clubs(club_name) VALUES('Tenant Alpha')");$ids['clubA']=(int)$db->lastInsertId();$db->exec("INSERT INTO clubs(club_name) VALUES('Tenant Beta')");$ids['clubB']=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_role,joined_on) VALUES(?,?,?,'2026-01-01')");
foreach([['clubA','admina','administrator'],['clubA','admina2','administrator'],['clubB','adminb','administrator'],['clubA','membera','member'],['clubB','memberb','member'],['clubA','shared','member'],['clubB','shared','member'],['clubB','admina','member']] as [$club,$who,$role]){$stmt->execute([$ids[$club],$ids[$who],$role]);$ids[$club.'_'.$who]=(int)$db->lastInsertId();}
echo json_encode($ids);exit;
}
if($action==='maintenance'){
$out=[];foreach(['alerts','popovers'] as $table)$out[$table]=$db->query('SELECT * FROM '.$table.' ORDER BY 1')->fetchAll(PDO::FETCH_ASSOC);echo json_encode($out);exit;
}
if($action==='unsafeRule'){ if($action==='unsafeRule'){
$db->exec("INSERT INTO dropdowntypes(dropdowntype_name,dropdowntype_title,dropdowntype_table,dropdowntype_column) VALUES('Unsafe legacy','Unsafe legacy','*function','getSettings')");$type=(int)$db->lastInsertId(); $db->exec("INSERT INTO dropdowntypes(dropdowntype_name,dropdowntype_title,dropdowntype_table,dropdowntype_column) VALUES('Unsafe legacy','Unsafe legacy','*function','getSettings')");$type=(int)$db->lastInsertId();
$stmt=$db->prepare("INSERT INTO dropdowns(dropdown_dropdowntype,dropdown_value) VALUES(?,'Legacy')");$stmt->execute([$type]);echo json_encode(['dropdown'=>(int)$db->lastInsertId()]);exit; $stmt=$db->prepare("INSERT INTO dropdowns(dropdown_dropdowntype,dropdown_value) VALUES(?,'Legacy')");$stmt->execute([$type]);echo json_encode(['dropdown'=>(int)$db->lastInsertId()]);exit;
+36
View File
@@ -0,0 +1,36 @@
from application_harness import Application
app=Application();checks=[]
def check(value,message):
if not value:raise AssertionError(message)
checks.append(message);print('PASS: '+message)
def valid(action,**fields):
result=app.post(action,**fields);check(result[0]==200,'Valid '+action+' succeeds: '+str(result[0]));return result
def invalid(action,fields,message):
before=app.fixture('fingerprints');response=app.post(action,**fields)
check(response[0]==422,message+' returns validation status')
check(app.fixture('fingerprints')==before,message+' leaves stored data unchanged')
try:
app.login()
alert=dict(step='add',alert_title='Valid alert',alert_starts_date='2026-10-20',alert_starts_time='12:00',alert_expires_date='2026-10-21',alert_expires_time='12:00',alert_message='<p>Valid text</p>')
for change,label in [({'alert_title':' '},'Blank alert title'),({'alert_title':'x'*101},'Oversized alert title'),({'alert_message':'x'*1001},'Oversized alert HTML'),({'alert_starts_date':'2026-02-30'},'Invalid calendar date'),({'alert_starts_time':'25:00'},'Invalid hour'),({'alert_starts_date':'10/20/2026'},'Ambiguous date format'),({'alert_expires_date':'2026-10-19'},'Reversed alert interval'),({'alert_title':'Line\nBreak'},'Multiline title'),({'alert_starts_date':'2026-03-08','alert_starts_time':'02:30'},'Nonexistent daylight-saving time')]:
invalid('Alerts.addAlert',{**alert,**change},label)
valid('Alerts.addAlert',**alert);ident=app.fixture('maintenance')['alerts'][0]['alert_serial']
invalid('Alerts.editAlert',{**alert,'step':'update','alert_serial':ident,'alert_title':''},'Invalid alert edit')
invalid('Alerts.editAlert',{**alert,'step':'update','alert_serial':'1 OR 1=1'},'Malformed alert edit ID')
valid('Alerts.editAlert',**{**alert,'step':'update','alert_serial':ident,'alert_title':'Updated alert','alert_message':'<table><tbody><tr><td>Preserved</td></tr></tbody></table>'})
stored=app.fixture('maintenance')['alerts'][0];check(stored['alert_title']=='Updated alert' and '<table>' in stored['alert_message'],'Valid edit preserves rich HTML without truncation')
valid('Alerts.addAlert',**{**alert,'alert_title':'界'*100,'alert_starts_date':'2028-02-29','alert_expires_date':'2028-03-01'})
stored_title=app.fixture('maintenance')['alerts'][-1]['alert_title']
check(stored_title=='界'*100,'Unicode limit counts characters rather than bytes')
popover=dict(step='add',popover_name='Valid popover',popover_title='Title',popover_placeholder='Optional hint',popover_text='<p>Text</p>')
for change,label in [({'popover_name':''},'Blank popover name'),({'popover_title':' '},'Blank popover title'),({'popover_name':'x'*101},'Oversized popover name'),({'popover_title':'x'*101},'Oversized popover title'),({'popover_placeholder':'x'*101},'Oversized placeholder'),({'popover_text':'x'*1001},'Oversized popover HTML')]:
invalid('Popovers.addPopover',{**popover,**change},label)
valid('Popovers.addPopover',**popover);ident=app.fixture('maintenance')['popovers'][0]['popover_serial']
invalid('Popovers.editPopover',{**popover,'step':'update','popover_serial':ident,'popover_title':''},'Invalid popover edit')
invalid('Popovers.editPopover',{**popover,'step':'update','popover_serial':'1 OR 1=1'},'Malformed popover edit ID')
valid('Popovers.editPopover',**{**popover,'step':'update','popover_serial':ident,'popover_title':'Updated title','popover_text':'<p><strong>Updated</strong></p>'})
check(app.fixture('maintenance')['popovers'][0]['popover_text']=='<p><strong>Updated</strong></p>','Popover editing retains formatting')
response=app.post('Popovers.popoverExists',popover_serial=0,popover_name='Valid popover');check(response[0]==200 and response[1]==b'true','Create-form uniqueness check still accepts its zero exclusion ID')
finally:app.close()
print(str(len(checks))+' maintenance HTTP checks passed.')
+4 -2
View File
@@ -3,11 +3,13 @@ if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php'; require __DIR__.'/../classes/Base.php';
$settings=(new Base())->getSettings();$name=$argv[2]??''; $settings=(new Base())->getSettings();$name=$argv[2]??'';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name)||(string)$settings->DatabaseName!==$name||!in_array((string)$settings->DatabaseServer,['127.0.0.1','localhost','::1'],true))throw new RuntimeException('Concurrency workers require an isolated local test database.'); if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name)||(string)$settings->DatabaseName!==$name||!in_array((string)$settings->DatabaseServer,['127.0.0.1','localhost','::1'],true))throw new RuntimeException('Concurrency workers require an isolated local test database.');
$_SESSION=[Base::USER_AUTHENTICATED=>true,Base::USER_ROLE=>'Administrator',Base::USER_SERIAL=>1,Base::USER_NAME=>'Test worker']; $_SESSION=[Base::USER_AUTHENTICATED=>true,Base::USER_ROLE=>TenantAccess::SUPER,Base::USER_SERIAL=>1,Base::USER_NAME=>'Test worker'];
echo "READY\n";flush();fgets(STDIN); echo "READY\n";flush();fgets(STDIN);
try{ try{
$service=new MaintenanceService(); $service=new MaintenanceService();
if(($argv[1]??'')==='disable')$service->setUserActive((int)$argv[3],false); if(($argv[1]??'')==='accept'){$_SESSION[Base::USER_SERIAL]=(int)$argv[4];(new ClubInvitations())->accept((int)$argv[3]);}
elseif(($argv[1]??'')==='membership')(new RosterService())->saveMember((int)$argv[4],(int)$argv[3],'','inactive','2026-01-01');
elseif(($argv[1]??'')==='disable')$service->setUserActive((int)$argv[3],false);
elseif(($argv[1]??'')==='default')$service->setDropdownDefault((int)$argv[3],(int)$argv[4],true); elseif(($argv[1]??'')==='default')$service->setDropdownDefault((int)$argv[3],(int)$argv[4],true);
elseif(($argv[1]??'')==='payment')(new LedgerService())->payment((int)$argv[4],(int)$argv[3],$argv[5],'2026-01-01',bin2hex(random_bytes(32))); elseif(($argv[1]??'')==='payment')(new LedgerService())->payment((int)$argv[4],(int)$argv[3],$argv[5],'2026-01-01',bin2hex(random_bytes(32)));
elseif(($argv[1]??'')==='reverse')(new LedgerService())->reverse((int)$argv[4],(int)$argv[3],'Concurrent reversal',$argv[5]); elseif(($argv[1]??'')==='reverse')(new LedgerService())->reverse((int)$argv[4],(int)$argv[3],'Concurrent reversal',$argv[5]);
+84
View File
@@ -0,0 +1,84 @@
import json,secrets,urllib.request,urllib.parse,urllib.error
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def url(club,member):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)
def save(step,club,member,**extra):
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
request=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
try:
with app.client.open(request) as response:return response.status,json.loads(response.read())
except urllib.error.HTTPError as error:
body=error.read()
try:result=json.loads(body)
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
return error.code,result
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];neighbor=ids['clubA_shared'];foreign=ids['clubB_shared']
app.login()
for club,name in [(a,'New season'),(a,'Other season'),(b,'Foreign season')]:app.post('Roster.workspace',step='season',club_serial=club,season_name=name,starts_on='2026-01-01',ends_on='2026-12-31')
state=app.fixture('inspect');season=next(s['season_serial'] for s in state['club_seasons'] if s['season_name']=='New season');foreignseason=next(s['season_serial'] for s in state['club_seasons'] if s['season_name']=='Foreign season')
app.post('Roster.workspace',step='charge',club_serial=a,membership_serial=neighbor,season_serial=season,amount='100.00')
app.post('Roster.workspace',step='charge',club_serial=b,membership_serial=foreign,season_serial=foreignseason,amount='100.00')
state=app.fixture('inspect');neighbor_due=state['club_dues'][0]['due_serial'];foreign_due=state['club_dues'][1]['due_serial']
app.logout();app.login('admina@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member))
check(b'data-club-step="member"' in body and b'data-club-step="charge"' in body,'Club administrator gets member edit and dues dialogs')
check(save('charge',a,member,csrf_token='invalid',season_serial=season,amount='100.00')[0]==403,'Member summary writes require a valid CSRF token')
check(save('charge',a,member,membership_serial=neighbor,season_serial=season,amount='100.00')[0]==409,'Posted membership cannot differ from the selected summary')
status,result=save('charge',a,member,season_serial=season,amount='100.00')
check(status==200 and result['ok'] and 'Roster.memberSummary' in result['redirect'] and 'membership_serial='+str(member) in result['redirect'],'Dues save returns a same-member summary redirect')
state=app.fixture('inspect');due=next(d['due_serial'] for d in state['club_dues'] if d['membership_serial']==member)
check(save('charge',a,member,season_serial=season,amount='100.00')[0]==409,'Duplicate season dues remain rejected')
check(save('charge',a,member,season_serial=foreignseason,amount='10.00')[0]==409,'Dues cannot use another club season')
check(save('payment',a,member,due_serial=neighbor_due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==404,'Payment cannot target a different member in the same club')
check(save('payment',a,member,due_serial=foreign_due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==404,'Payment cannot target another club dues')
key=secrets.token_hex(32)
check(save('payment',a,member,due_serial=due,amount='25.00',paid_on='2026-01-01',request_key=key,payment_method='cash',payment_reference='Member summary receipt')[0]==200,'Payment is recorded for the selected membership')
check(save('payment',a,member,due_serial=due,amount='25.00',paid_on='2026-01-01',request_key=key,payment_method='cash',payment_reference='Member summary receipt')[0]==200 and len(app.fixture('inspect')['club_payments'])==1,'Payment retry remains idempotent')
app.fixture('mfaTestAuditFailure','on')
status,_=save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))
app.fixture('mfaTestAuditFailure','off')
check(status==500 and len(app.fixture('inspect')['club_payments'])==1,'Failed mandatory audit rolls back a summary payment completely')
check(save('payment',a,member,due_serial=due,amount='76.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==409,'Overpayments remain rejected')
status,result=save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-02-01')
check(status==200 and next(m for m in app.fixture('inspect')['club_memberships'] if m['membership_serial']==member)['joined_on']=='2026-02-01','Membership information saves from the summary')
check(save('member',a,member,membership_status='active',membership_role='administrator',joined_on='2026-02-01')[0]==403,'Club administrator cannot promote members to administrator')
check(save('member',a,ids['clubA_admina2'],membership_status='inactive',membership_role='member',joined_on='2026-02-01')[0]==403,'Club administrator cannot edit protected administrator memberships')
sid=next(c.value for h in app.client.handlers if hasattr(h,'cookiejar') for c in h.cookiejar if c.name=='HUNTINGCLUBROSTER');app.fixture('sessionAge',sid,'','','301')
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Membership edits require recent MFA on the new endpoint')
payment=app.fixture('inspect')['club_payments'][0]['payment_serial']
check(save('adjust',a,member,due_serial=neighbor_due,corrected_amount='80.00',reason='Wrong member',request_key=secrets.token_hex(32))[0]==404,'Correction rejects another member dues in the same club')
check(save('adjust',a,member,due_serial=foreign_due,corrected_amount='80.00',reason='Wrong club',request_key=secrets.token_hex(32))[0]==404,'Correction rejects another club dues')
check(save('adjust',a,member,due_serial=due,corrected_amount='24.00',reason='Below paid',request_key=secrets.token_hex(32))[0]==409,'Correction cannot reduce dues below received payments')
adjustment_key=secrets.token_hex(32)
status,result=save('adjust',a,member,due_serial=due,corrected_amount='80.00',reason='Corrected season rate',request_key=adjustment_key)
check(status==200 and 'Roster.memberSummary' in result['redirect'],'Correction saves and returns to the selected member summary')
check(save('adjust',a,member,due_serial=due,corrected_amount='80.00',reason='Corrected season rate',request_key=adjustment_key)[0]==200 and len(app.fixture('inspect')['club_due_adjustments'])==1,'Correction retries create only one adjustment')
check(save('reverse',a,neighbor,payment_serial=payment,reason='Wrong member',request_key=secrets.token_hex(32))[0]==404,'Reversal rejects another member payment in the same club')
app.fixture('mfaTestAuditFailure','on')
failed,_=save('reverse',a,member,payment_serial=payment,reason='Audit failure',request_key=secrets.token_hex(32))
app.fixture('mfaTestAuditFailure','off')
check(failed==500 and len(app.fixture('inspect')['club_payment_reversals'])==0,'Reversal rolls back completely when mandatory audit fails')
reversal_key=secrets.token_hex(32)
check(save('reverse',a,member,payment_serial=payment,reason='Wrong receipt',request_key=reversal_key)[0]==200,'Payment reverses from the member summary')
check(save('reverse',a,member,payment_serial=payment,reason='Wrong receipt',request_key=reversal_key)[0]==200 and len(app.fixture('inspect')['club_payment_reversals'])==1,'Reversal retries create only one reversal')
check(save('reverse',a,member,payment_serial=payment,reason='Repeat',request_key=secrets.token_hex(32))[0]==409,'Already reversed payment cannot be reversed again')
_,summary,_=app.request(url(a,member));check(b'$80.00' in summary and b'$0.00' in summary and b'Wrong receipt' in summary and b'Corrected season rate' in summary,'Summary reflects corrected dues, reversed payment and preserved reasons')
app.logout();app.login('membera@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Ordinary member receives no action dialogs')
check(save('payment',a,member,due_serial=due,amount='1.00',paid_on='2026-01-01',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a summary payment request')
check(save('charge',a,member,season_serial=season,amount='1.00')[0]==403,'Ordinary member cannot forge a dues request')
check(save('adjust',a,member,due_serial=due,corrected_amount='50.00',reason='Forged',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a dues correction')
check(save('reverse',a,member,payment_serial=payment,reason='Forged',request_key=secrets.token_hex(32))[0]==403,'Ordinary member cannot forge a payment reversal')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(save('member',a,member,membership_status='active',membership_role='member',joined_on='2026-03-01')[0]==403,'Other club administrator cannot modify the membership')
app.logout();app.login();app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
_,body,_=app.request(url(a,member));check(b'club-action-modal' not in body,'Archived club summary exposes no write dialogs')
check(save('charge',a,member,season_serial=season,amount='10.00')[0]==409,'Archived club rejects forged member summary writes')
print(checks,'member action HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+64
View File
@@ -0,0 +1,64 @@
import hashlib,json,urllib.request,urllib.parse,urllib.error
from application_harness import Application
from lxml import html
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def url(club,member,extra=''):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)+extra
def save(step,club,member,**extra):
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
req=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
try:
with app.client.open(req) as r:return r.status,json.loads(r.read())
except urllib.error.HTTPError as e:
body=e.read()
try:result=json.loads(body)
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
return e.code,result
def notes(member):return [n for n in app.fixture('inspect')['notes'] if n['note_type']=='ClubMember' and n['note_reference']==member]
def version(text):return hashlib.sha256(text.encode()).hexdigest()
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];shared=ids['clubA_shared']
app.login();app.post('Notes.addNote',step='add',note_type='User',note_reference=ids['shared'],note_text='GLOBAL NOTE')
global_note=next(n for n in app.fixture('inspect')['notes'] if n['note_type']=='User')
app.logout();app.login('admina@example.test','Tenant test password 123!')
text='Private deletion test <script>neverRun()</script>'
save('note_add',a,member,note_text=text);note=notes(member)[0];nid=note['note_serial']
def delete(club=a,membership=member,**extra):return save('note_delete',club,membership,**{'note_serial':nid,'note_version':version(text),**extra})
_,page,_=app.request(url(a,member));doc=html.fromstring(page)
forms=doc.xpath('//form[input[@name="step" and @value="note_delete"]]')
check(len(forms)==1 and forms[0].xpath('.//button[text()="Confirm deletion"]') and b'&lt;script&gt;' in page,'Deletion has a separate confirmation form with escaped note preview')
check(delete(csrf_token='invalid')[0]==403,'Deletion requires valid CSRF')
check(delete(membership=shared)[0]==404,'Deletion is scoped to the selected membership')
check(delete(note_serial=global_note['note_serial'])[0]==404,'Club deletion cannot remove a global user note')
check(delete(note_version='')[0]==409,'Deletion requires the displayed note version')
save('note_edit',a,member,note_serial=nid,note_text='Changed by another administrator',note_version=version(text))
check(delete()[0]==409 and len(notes(member))==1,'Stale confirmation cannot delete an edited note')
text='Changed by another administrator'
app.fixture('mfaTestAuditFailure','on');before=app.fixture('inspect')['notes']
status,_=delete();app.fixture('mfaTestAuditFailure','off')
check(status==500 and before==app.fixture('inspect')['notes'],'Audit failure rolls back deletion')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(delete()[0]==403,'Another club administrator cannot delete the note')
app.logout();app.login('membera@example.test','Tenant test password 123!')
check(delete()[0]==403,'Ordinary member cannot forge a deletion')
check(app.post('Notes.deleteNote',note_serial=nid)[0]==403,'Member cannot use the legacy global deletion route')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
status,result=delete()
check(status==200 and not notes(member) and '#admin-notes' in result['redirect'],'Shared club administrator can delete another administrator note and return to notes')
events=[j for j in app.fixture('inspect')['journal'] if 'deleted club member note ' in j['journal_entry']]
check(len(events)==1 and str(events[0]['journal_origin'])==str(ids['admina2']) and all(('"'+key+'":'+str(value)) in events[0]['journal_entry'] for key,value in [('club',a),('membership',member),('note',nid)]) and events[0]['journal_timestamp'],'Audit retains actor, timestamp, club, membership and note identifiers')
check(text not in events[0]['journal_entry'],'Audit does not expose private note text')
check(delete()[0]==404 and len([j for j in app.fixture('inspect')['journal'] if 'deleted club member note ' in j['journal_entry']])==1,'Repeated deletion returns not found without duplicate audit')
save('note_add',a,member,note_text=text);nid=notes(member)[0]['note_serial']
app.logout();app.login()
check(app.post('Notes.deleteNote',note_serial=nid)[0]==404 and len(notes(member))==1,'Even Super Administrator cannot bypass club deletion audit through the global route')
check(delete()[0]==200 and not notes(member),'Super Administrator can delete club administrative notes')
save('note_add',a,member,note_text=text);nid=notes(member)[0]['note_serial']
app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
check(delete()[0]==409 and len(notes(member))==1,'Archived club deletion is rejected')
print(checks,'member note deletion HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+76
View File
@@ -0,0 +1,76 @@
import hashlib,json,urllib.request,urllib.parse,urllib.error
from application_harness import Application
from lxml import html
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def url(club,member,extra=''):return '/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(member)+extra
def save(step,club,member,**extra):
data={'action':'Roster.memberSummary','csrf_token':app.token,'step':step,'club_serial':club,'membership_serial':member,**extra}
req=urllib.request.Request(app.base+url(club,member),data=urllib.parse.urlencode(data).encode(),headers={'X-Requested-With':'XMLHttpRequest','X-HCR-Modal':'1'})
try:
with app.client.open(req) as r:return r.status,json.loads(r.read())
except urllib.error.HTTPError as e:
body=e.read()
try:result=json.loads(body)
except json.JSONDecodeError:result={'error':body.decode(errors='replace')}
return e.code,result
def notes(member):return [n for n in app.fixture('inspect')['notes'] if n['note_type']=='ClubMember' and n['note_reference']==member]
def version(text):return hashlib.sha256(text.encode()).hexdigest()
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB'];member=ids['clubA_membera'];sharedA=ids['clubA_shared'];sharedB=ids['clubB_shared']
app.login();app.post('Notes.addNote',step='add',note_type='User',note_reference=ids['shared'],note_text='GLOBAL ACCOUNT NOTE')
global_note=next(n for n in app.fixture('inspect')['notes'] if n['note_type']=='User')
app.logout();app.login('admina@example.test','Tenant test password 123!')
text='PRIVATE MEMBER NOTE <script>alert(1)</script>\nSecond line'
status,result=save('note_add',a,member,note_text=text,note_type='User',note_reference=ids['shared'])
note=notes(member)[0]
check(status==200 and '#admin-notes' in result['redirect'],'Club administrator adds a note and returns to the member notes section')
check(note['note_text']==text and note['note_type']=='ClubMember' and note['note_reference']==member,'Note uses the selected membership and fixed private type despite forged fields')
_,body,_=app.request(url(a,member));check(b'&lt;script&gt;alert(1)&lt;/script&gt;' in body and b'<script>alert(1)</script>' not in body,'Private notes render as escaped plain text')
_,other,_=app.request(url(a,sharedA));check(b'PRIVATE MEMBER NOTE' not in other,'Notes do not leak into another member summary')
check(save('note_add',a,member,note_text='')[0]==422,'Empty administrative notes are rejected')
check(save('note_add',a,member,note_text='x'*2001)[0]==422,'Oversized administrative notes are rejected')
check(save('note_add',a,member,note_text='x',csrf_token='invalid')[0]==403,'Note writes require CSRF')
check(save('note_edit',a,sharedA,note_serial=note['note_serial'],note_text='Wrong member',note_version=version(text))[0]==404,'Note IDs cannot be applied to a different membership')
check(save('note_edit',a,member,note_serial=global_note['note_serial'],note_text='Wrong type',note_version=version('GLOBAL ACCOUNT NOTE'))[0]==404,'Global account notes cannot be edited through the club notes API')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'PRIVATE MEMBER NOTE' in body,'Another administrator of the shared club can read its notes')
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='EDITED PRIVATE NOTE',note_version=version(text))[0]==200,'Another club administrator can edit its note')
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='Stale overwrite',note_version=version(text))[0]==409 and notes(member)[0]['note_text']=='EDITED PRIVATE NOTE','Stale edits cannot overwrite a newer note')
before=app.fixture('inspect')['notes'];app.fixture('mfaTestAuditFailure','on')
failed_add,_=save('note_add',a,member,note_text='Must roll back')
failed_edit,_=save('note_edit',a,member,note_serial=note['note_serial'],note_text='Must also roll back',note_version=version('EDITED PRIVATE NOTE'))
app.fixture('mfaTestAuditFailure','off')
check(failed_add==500 and failed_edit==500 and app.fixture('inspect')['notes']==before,'Mandatory audit failures roll back both note additions and edits')
save('note_add',a,sharedA,note_text='ALPHA CLUB PRIVATE')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(app.request(url(a,member))[0]==403,'Other club administrator cannot open the protected member summary')
check(save('note_edit',a,member,note_serial=note['note_serial'],note_text='Foreign overwrite',note_version=version('EDITED PRIVATE NOTE'))[0]==403,'Other club administrator cannot edit the note')
save('note_add',b,sharedB,note_text='BETA CLUB PRIVATE')
app.logout();app.login('admina@example.test','Tenant test password 123!')
check(app.request(url(b,sharedB))[0]==404,'An administrator with only member access in another club cannot read that club member notes')
app.logout();app.login('membera@example.test','Tenant test password 123!')
_,body,_=app.request(url(a,member));check(b'EDITED PRIVATE NOTE' not in body and b'Administrative notes' not in body and b'note_serial' not in body,'Member receives no private note content, section, identifiers or edit forms')
check(save('note_add',a,member,note_text='Forged')[0]==403 and save('note_edit',a,member,note_serial=note['note_serial'],note_text='Forged',note_version=version('EDITED PRIVATE NOTE'))[0]==403,'Member cannot forge add or edit note requests')
check(app.post('Notes.editNote',note_serial=note['note_serial'])[0]==403,'Legacy global note endpoints cannot bypass club-note permissions')
app.logout();app.login('shared@example.test','Tenant test password 123!')
_,alpha,_=app.request(url(a,sharedA));_,beta,_=app.request(url(b,sharedB))
check(b'ALPHA CLUB PRIVATE' not in alpha and b'BETA CLUB PRIVATE' not in beta and b'Administrative notes' not in alpha and b'Administrative notes' not in beta,'A member in multiple clubs cannot see administrative notes in either club')
app.logout();app.login()
_,alpha,_=app.request(url(a,sharedA));_,beta,_=app.request(url(b,sharedB))
check(b'ALPHA CLUB PRIVATE' in alpha and b'BETA CLUB PRIVATE' not in alpha and b'BETA CLUB PRIVATE' in beta and b'ALPHA CLUB PRIVATE' not in beta,'Super Administrator can read separate club-specific notes for the same person')
check(b'GLOBAL ACCOUNT NOTE' not in alpha and b'GLOBAL ACCOUNT NOTE' not in beta,'Global account notes remain separate from club administrative notes')
_,account,_=app.post('Users.userSummary',user_serial=ids['shared']);check(b'GLOBAL ACCOUNT NOTE' in account and b'ALPHA CLUB PRIVATE' not in account and b'BETA CLUB PRIVATE' not in account,'Global user summary does not mix in club membership notes')
check(save('note_add',a,member,note_text='😀'*2000)[0]==200,'Server accepts the full Unicode note limit within its transport budget')
for index in range(26):save('note_add',a,member,note_text='Paged admin note '+str(index))
_,body,_=app.request(url(a,member,'&notes_page=9999'));rows=html.fromstring(body).xpath('//section[contains(@class,"member-admin-notes")]//tbody/tr')
check(len(rows)==len(notes(member))-25 and b'Page 2 of 2' in body,'Administrative notes use bounded, clamped membership-specific paging')
app.post('Roster.workspace',step='club',club_serial=a,club_name='Tenant Alpha',club_status='archived')
_,body,_=app.request(url(a,member));check(b'Administrative notes' in body and b'data-club-step="note_' not in body,'Archived club notes remain readable to administrators without write dialogs')
check(save('note_add',a,member,note_text='Archived write')[0]==409,'Archived club note writes are rejected')
print(checks,'member note HTTP checks passed.')
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+49
View File
@@ -0,0 +1,49 @@
import re
from lxml import html
from application_harness import Application
app=Application();checks=0
def check(ok,message):
global checks
assert ok,message
checks+=1;print('PASS:',message)
def summary(club,membership,extra=''):
return app.request('/main.php?Xaction=Roster.memberSummary&club_serial='+str(club)+'&membership_serial='+str(membership)+extra)
try:
ids=app.fixture('tenantSeed');app.fixture('memberSummarySeed',str(ids['clubA']),str(ids['clubB']),str(ids['clubA_membera']),str(ids['clubA_shared']),str(ids['clubB_shared']))
_,login,_=app.request()
app.request(data={'action':'SignIn.login','csrf_token':app.csrf(login),'user_email':'admin@example.test','user_password':'Admin test password 123!'})
status,body,_=summary(ids['clubA'],ids['clubA_membera'])
check(status==200 and b'<h1>Account security</h1>' in body and b'<h1>Tenant Membera</h1>' not in body,'Member summary cannot bypass mandatory administrator MFA')
check(app.request('/main.php?Xaction=Roster.memberSummary&club_serial='+str(ids['clubA'])+'&membership_serial='+str(ids['clubA_membera']),ajax=True)[0]==403,'AJAX member summary also requires completed administrator MFA')
app.login();before=app.fixture('fingerprints')
status,body,_=summary(ids['clubA'],ids['clubA_membera'])
check(status==200 and b'<h1>Tenant Membera</h1>' in body and b'membera@example.test' in body,'Super Administrator opens a club member summary')
check(b'$3,150.00' in body and b'$31.00' in body and b'$3,119.00' in body,'Totals include all dues corrections and exclude reversed payments across every page')
check(b'Own reversed receipt' in body and b'OWN-RECEIPT-32' in body,'Payment history preserves reversed receipts and their reasons')
check(b'&lt;script&gt;unsafe&lt;/script&gt;' in body and b'<script>unsafe</script>' not in body,'Correction reasons are escaped safely')
check(b'NEIGHBOR-PRIVATE' not in body and b'FOREIGN-PRIVATE' not in body,'Selected member summary excludes neighboring members and other clubs')
check(html.fromstring(body).xpath('//h2[normalize-space(.)="Payment history"]/following-sibling::div[1]//table')[0].text_content().count('OWN-RECEIPT-')==25 and b'31 records.' in body and b'32 records.' in body,'Member dues and payment histories are bounded and counted independently')
links=re.findall(rb'href="([^"]+)"',body)
check(any(b'payments_page=2' in link and b'membership_serial='+str(ids['clubA_membera']).encode() in link and b'Roster.memberSummary' in link for link in links),'History pager links retain both club and membership scope')
status,last,_=summary(ids['clubA'],ids['clubA_membera'],'&payments_page=9999&dues_page=9999')
check(status==200 and html.fromstring(last).xpath('//h2[normalize-space(.)="Payment history"]/following-sibling::div[1]//table')[0].text_content().count('OWN-RECEIPT-')==7 and b'Page 2 of 2' in last,'Final history pages are clamped and show only remaining records')
check(b'$3,150.00' in last and b'$31.00' in last and b'$3,119.00' in last,'Summary totals remain identical on later history pages')
check(summary(ids['clubB'],ids['clubA_membera'])[0]==404,'Membership IDs cannot be paired with another club')
check(summary(ids['clubA'],'invalid')[0]==422,'Invalid membership identifiers are rejected')
check(summary(ids['clubA'],ids['clubA_membera'],'&payments_page=0')[0]==422,'Invalid history pages are rejected')
check(app.fixture('fingerprints')==before,'Opening summaries and changing pages perform no database writes beyond authentication bookkeeping')
app.logout();app.login('admina@example.test','Tenant test password 123!')
check(summary(ids['clubA'],ids['clubA_membera'])[0]==200,'Club administrator can view a member in the administered club')
check(summary(ids['clubB'],ids['clubB_shared'])[0]==404,'Administrator cannot view another member where they are only an ordinary member')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(summary(ids['clubA'],ids['clubA_membera'])[0]==403,'Other club administrator is denied access to the club summary')
app.logout();app.login('membera@example.test','Tenant test password 123!')
status,body,_=summary(ids['clubA'],ids['clubA_membera'])
check(status==200 and b'$3,119.00' in body and b'club-action-modal' not in body,'Member can read their own finances without administrator actions')
check(summary(ids['clubA'],ids['clubA_shared'])[0]==404,'Member cannot read another member summary in the same club')
check(summary(ids['clubB'],ids['clubB_shared'])[0]==403,'Member cannot read another club summary')
app.logout();app.login('shared@example.test','Tenant test password 123!')
_,a,_=summary(ids['clubA'],ids['clubA_shared']);_,b,_=summary(ids['clubB'],ids['clubB_shared'])
check(b'NEIGHBOR-PRIVATE' in a and b'FOREIGN-PRIVATE' not in a and b'FOREIGN-PRIVATE' in b and b'NEIGHBOR-PRIVATE' not in b,'A member of two clubs sees separate membership histories')
print(checks,'member summary HTTP checks passed.')
finally:app.close()
+34
View File
@@ -0,0 +1,34 @@
import json,subprocess
from application_harness import Application,ROOT,PHP
app=Application();workers=[]
def race(operation,seed):
group=[]
for state in seed['states']:
p=subprocess.Popen(PHP+['tests/mfa_worker.php',operation],cwd=ROOT,env=app.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
workers.append(p);group.append(p)
p.stdin.write(json.dumps({'state':state,'recovery':seed['recovery']})+'\n');p.stdin.flush()
for p in group:assert p.stdout.readline().strip()=='READY'
for p in group:p.stdin.write('go\n');p.stdin.flush()
result=[]
for p in group:
out,err=p.communicate(timeout=30)
assert p.returncode==0,err
result.append(json.loads(out))
return result
try:
seed=json.loads(subprocess.check_output(PHP+['tests/mfa_worker.php','seed'],cwd=ROOT,env=app.env,text=True))
before=app.fixture('mfaState','admin@example.test')
result=race('verify',seed)
after=app.fixture('mfaState','admin@example.test')
assert sorted(r['ok'] for r in result)==[False,True] and len(after['recovery'])==len(before['recovery'])-1,result
print('PASS: Concurrent recovery attempts consume a single-use code exactly once.')
result=race('replace',seed)
after=app.fixture('mfaState','admin@example.test')
assert sorted(r['ok'] for r in result)==[False,True],result
assert len(after['recovery'])==10 and after['factor']['secret_cipher']!=before['factor']['secret_cipher']
print('PASS: Competing authorized replacements commit exactly one complete factor swap.')
print('2 MFA concurrency checks passed.')
finally:
for p in workers:
if p.poll() is None:p.kill();p.wait()
app.close()
+33
View File
@@ -0,0 +1,33 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
class FileKeyMfa extends MfaService {
public string $environment='Development';
public string $file='';
public function getSettings(){
$settings=new SimpleXMLElement('<Settings/>');$settings->Environment=$this->environment;$settings->MfaKeyFile=$this->file;return $settings;
}
}
$old=getenv('HCR_MFA_KEY');$oldFile=getenv('HCR_MFA_KEY_FILE');putenv('HCR_MFA_KEY');putenv('HCR_MFA_KEY_FILE');$checks=0;$file=tempnam(sys_get_temp_dir(),'hcr-mfa-key-');$inside=__DIR__.'/.local/key-path-test-'.bin2hex(random_bytes(6));
function keyCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
function keyDenied(callable $work,string $message):void{try{$work();}catch(RuntimeException $e){keyCheck(true,$message);return;}throw new LogicException($message.' was allowed');}
try{
$service=new FileKeyMfa();$service->file=$file;file_put_contents($file,base64_encode(random_bytes(32)));
keyCheck($service->sealSecret('test',1)!=='','Development loads a persistent key outside the application');
$service->file=$file.'.missing';putenv('HCR_MFA_KEY_FILE='.$file);
keyCheck($service->sealSecret('test',1)!=='','Server key-file configuration overrides workstation settings');
$service->environment='Production';keyDenied(fn()=>$service->sealSecret('test',1),'Production cannot use the development key-file fallback');
putenv('HCR_MFA_KEY_FILE');$service->file=$file;
putenv('HCR_MFA_KEY='.base64_encode(random_bytes(32)));keyCheck($service->sealSecret('test',1)!=='','Explicit production environment key remains supported');
putenv('HCR_MFA_KEY=invalid');$service->environment='Development';keyDenied(fn()=>$service->sealSecret('test',1),'Invalid explicit key is not silently replaced by a file');putenv('HCR_MFA_KEY');
if(!is_dir(dirname($inside)))mkdir(dirname($inside),0700,true);file_put_contents($inside,base64_encode(random_bytes(32)));$service->file=$inside;
keyDenied(fn()=>$service->sealSecret('test',1),'Development key inside the application tree is rejected');
$service->file=$file;file_put_contents($file,str_repeat('A',129));keyDenied(fn()=>$service->sealSecret('test',1),'Oversized key file is rejected');
file_put_contents($file,'invalid');keyDenied(fn()=>$service->sealSecret('test',1),'Malformed file key is rejected');
$service->file=$file.'.missing';keyDenied(fn()=>$service->sealSecret('test',1),'Missing development key file is rejected');
echo "$checks MFA key configuration checks passed.\n";
}finally{
if(is_file($file))unlink($file);if(is_file($inside))unlink($inside);
if($old===false)putenv('HCR_MFA_KEY');else putenv('HCR_MFA_KEY='.$old);
if($oldFile===false)putenv('HCR_MFA_KEY_FILE');else putenv('HCR_MFA_KEY_FILE='.$oldFile);
}
+86
View File
@@ -0,0 +1,86 @@
"""Competing password-only sessions must never inherit replacement permission."""
import http.cookiejar,re,time,urllib.error,urllib.parse,urllib.request
from application_harness import Application
app=Application();checks=0;password='Admin test password 123!'
def check(ok,message):
global checks
if not ok:raise AssertionError(message)
checks+=1;print('PASS:',message)
class Client:
def __init__(self):
self.jar=http.cookiejar.CookieJar();self.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar));self.body=b''
self.login()
def request(self,path='/',data=None,ajax=False):
req=urllib.request.Request(app.base+path,data=urllib.parse.urlencode(data).encode() if data is not None else None,headers={'X-Requested-With':'XMLHttpRequest'} if ajax else {})
try:
with self.client.open(req,timeout=30) as r:status,body=r.status,r.read()
except urllib.error.HTTPError as e:status,body=e.code,e.read()
if b'csrf' in body:self.body=body
return status,body
def login(self,pwd=password):
_,body=self.request();return self.request(data={'action':'SignIn.login','csrf_token':app.csrf(body),'user_email':'admin@example.test','user_password':pwd})
def security(self,code='',pwd=password,**extra):
return self.request(data={'action':'security','csrf_token':app.csrf(self.body),'current_password':pwd,'mfa_code':code,**extra})
def post(self,action,**extra):return self.request(data={'action':action,'csrf_token':app.csrf(self.body),**extra},ajax=True)
def state(self,operation='snapshot'):
sid=next(c.value for c in self.jar if c.name=='HUNTINGCLUBROSTER');return app.fixture('mfaTestSession',sid,operation)
def factor():return app.fixture('mfaState','admin@example.test')
def secret(body):return re.search(rb'<pre>([A-Z2-7]+)</pre>',body).group(1).decode()
try:
victim=Client();original_secret=secret(victim.body)
status,body=victim.security(app.totp(original_secret));codes=[v.decode() for v in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(codes)==10,'Initial enrollment succeeds')
check(b'notification email could not be sent' in body,'Failed security email is visibly reported without undoing enrollment')
victim.request('/main.php?Xaction=security');before=factor()
status,_=victim.security(codes[0],pwd='incorrect')
check(status==403 and factor()==before,'Wrong password preserves the recovery code and factor counter')
status,_=victim.security(codes[0]);check(status==200 and len(factor()['recovery'])==9,'Recovery code remains usable with the correct password')
victim.request('/main.php?Xaction=security');before=factor();next_code=app.totp(original_secret,time.time()+30)
status,_=victim.security(next_code,pwd='incorrect');check(status==403 and factor()==before,'Wrong password cannot consume an authenticator time step')
status,_=victim.security(next_code);check(status==200,'Authenticator time step still verifies with the correct password')
victim.request('/main.php?Xaction=security');before=factor();version=victim.state()['auth_version']
status,body=victim.security(codes[1],factor_action='replace');replacement_secret=secret(body)
check(status==200 and factor()['factor']==before['factor'] and len(factor()['recovery'])==8,'Starting replacement keeps the old factor and unused recovery codes')
check(victim.state()['auth_version']==version,'Starting replacement does not revoke other sessions prematurely')
attacker=Client();check(b'setup key' not in attacker.body and b'Authentication code' in attacker.body,'A separate password-only session still faces the existing MFA challenge')
status,_=attacker.security(app.totp(replacement_secret),factor_action='confirmReplacement')
check(status==409 and factor()['factor']==before['factor'],'Password-only session cannot confirm another session\'s pending replacement')
check(attacker.post('Users.setupUsers')[0]==403,'Password-only session cannot access Super Administrator pages')
victim.security(factor_action='cancelReplacement')
check(factor()['factor']==before['factor'] and len(factor()['recovery'])==8,'Canceling replacement preserves the existing factor and remaining codes')
victim.security(codes[2],factor_action='replace');expired_secret=secret(victim.body);victim.state('expire');before=factor()
status,_=victim.security(app.totp(expired_secret),factor_action='confirmReplacement')
check(status==409 and factor()==before,'Expired replacement cannot change either factor or recovery codes')
app.fixture('clearTestAttempts')
victim.security(codes[3],factor_action='replace');replacement_secret=secret(victim.body);before=factor()
status,_=victim.security(app.totp(replacement_secret),pwd='incorrect',factor_action='confirmReplacement')
check(status==403 and factor()==before,'Replacement confirmation with wrong password is non-mutating')
app.fixture('clearTestAttempts')
status,_=victim.security('not-a-code',factor_action='confirmReplacement')
check(status==422 and factor()==before,'Invalid new authenticator code leaves existing protection intact')
app.fixture('mfaTestAuditFailure','on')
status,_=victim.security(app.totp(replacement_secret),factor_action='confirmReplacement')
app.fixture('mfaTestAuditFailure','off')
check(status==500 and factor()==before,'Failed mandatory audit rolls back the entire factor swap')
victim.request('/main.php?Xaction=security')
confirmed_code=app.totp(replacement_secret)
status,body=victim.security(confirmed_code,factor_action='confirmReplacement');newcodes=[v.decode() for v in re.findall(rb'\b[a-f0-9]{32}\b',body)]
check(status==200 and len(newcodes)==10 and factor()['factor']['secret_cipher']!=before['factor']['secret_cipher'],'Successful confirmation swaps the factor and creates ten fresh recovery codes')
check(not set(before['recovery'])&set(factor()['recovery']),'Confirmation revokes every old recovery code')
check(attacker.post('Users.setupUsers')[0]==401,'Successful replacement revokes the competing session')
victim.request('/main.php?Xaction=security');before=factor();status,_=victim.security(confirmed_code)
check(status==403 and factor()==before,'New authenticator confirmation code cannot be replayed')
status,_=victim.security(newcodes[0]);check(status==200,'New recovery codes authenticate successfully')
app.fixture('clearTestAttempts');victim.request('/main.php?Xaction=security');victim.security(newcodes[1],factor_action='replace');before=factor()
token=app.fixture('mfaResetToken')['token'];newpwd='Changed with MFA still active 123!'
_,reset=victim.request('/main.php?Xaction=resetpassword&token='+token)
status,_=victim.request(data={'action':'resetpassword','step':'reset','csrf_token':app.csrf(reset),'token':token,'user_password':newpwd,'user_password_confirm':newpwd})
check(status==200 and factor()==before,'Password reset preserves the active factor during pending replacement')
check(victim.post('security',factor_action='confirmReplacement',current_password=newpwd,mfa_code='000000')[0]==401,'Password reset invalidates the old replacement session')
fresh=Client();fresh.login(newpwd)
check(b'Authentication code' in fresh.body and b'setup key' not in fresh.body,'Password reset never reopens password-only MFA enrollment')
print(checks,'MFA lifecycle HTTP checks passed.')
except Exception:
app.log.seek(0);print(app.log.read().decode(errors='replace')[-4500:]);raise
finally:
app.fixture('mfaTestAuditFailure','off');app.close()
+45
View File
@@ -0,0 +1,45 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
ob_start();session_start();$db=(new Base())->connect();$checks=0;
function notificationCheck(bool $ok,string $message):void{global $checks;if(!$ok)throw new RuntimeException($message);$checks++;echo "PASS: $message\n";}
class CapturedMfaNotice extends MfaSecurityNotifications {
public array $messages=[];public bool $works=true;public bool $throws=false;
protected function deliver(string $to,string $subject,string $body):bool {
if($this->throws)throw new RuntimeException('Test delivery failure');
$this->messages[]=compact('to','subject','body');return $this->works;
}
}
$notice=new CapturedMfaNotice();$account=['user_serial'=>1,'user_email'=>'admin+security@example.test'];
foreach(['enrolled','replacement_started','replacement_started_recovery','replaced','recovery_used'] as $event){
notificationCheck($notice->send($account,$event),'Security notice supports event '.$event);
$message=end($notice->messages);notificationCheck($message['to']===$account['user_email']&&str_contains($message['body'],'Time (UTC)')&&str_contains($message['body'],'did not authorize'),'Notice uses stored address, time and unauthorized-activity instructions');
}
notificationCheck(!str_contains(implode('',array_column($notice->messages,'body')),'otpauth://')&&!str_contains(implode('',array_column($notice->messages,'body')),'secret='),'Security notices contain no provisioning links or setup secrets');
$notice->send(['user_serial'=>1,'user_email'=>'<img src=x onerror=alert(1)>'],'enrolled');notificationCheck(!str_contains(end($notice->messages)['body'],'<img'),'Account text is escaped in security email');
$notice->works=false;notificationCheck(!$notice->send($account,'enrolled'),'Delivery failure is reported to the caller');$notice->throws=true;notificationCheck(!$notice->send($account,'replaced'),'Provider exceptions cannot undo committed security actions');
try{$notice->send($account,'untrusted-event');throw new RuntimeException('Unknown event accepted');}catch(LogicException $e){notificationCheck(true,'Only reviewed security event types can be sent');}
$_SESSION['mfa_notification_warning']=true;notificationCheck(str_contains(MfaSecurityNotifications::notice(),'notification email could not be sent')&&MfaSecurityNotifications::notice()==='','Notification failure warning is visible and consumed once');
class CapturedMfaEvents extends MfaService {
public array $events=[];
protected function notify(array $account,string $event):void {
if($this->connect()->inTransaction())throw new RuntimeException('Notification attempted before commit');
$this->events[]=$event;
}
}
$auth=new AuthService();$auth->login('admin@example.test','Admin test password 123!');$mfa=new CapturedMfaEvents();$user=1;
$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();$_SESSION['mfa_pending']=['user'=>1,'secret'=>$secret,'expires'=>time()+600];
$codes=$mfa->enroll($user,$secret,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');
notificationCheck($mfa->events===['enrolled'],'Enrollment notification runs once after transaction commit');
$before=$mfa->events;notificationCheck(!$mfa->verify(1,$codes[0],'wrong')&&$before===$mfa->events,'Failed password creates no recovery notification');
$db->exec("CREATE TRIGGER reject_notice_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
try{$mfa->verify(1,$codes[0],'Admin test password 123!');throw new RuntimeException('Audit failure accepted');}catch(PDOException $e){notificationCheck($mfa->events===$before,'Rolled-back recovery does not send a false security notice');}$db->exec('DROP TRIGGER reject_notice_audit');
notificationCheck($mfa->verify(1,$codes[0],'Admin test password 123!')&&$mfa->events===['enrolled','recovery_used'],'Committed recovery sends exactly one notice');
$before=$mfa->events;notificationCheck(!$mfa->verify(1,$codes[0],'Admin test password 123!')&&$mfa->events===$before,'Replayed recovery sends no duplicate notice');
$mfa->beginReplacement(1,$codes[1],'Admin test password 123!');notificationCheck(end($mfa->events)==='replacement_started_recovery','Recovery-authorized replacement sends one combined security notice');
$secret=$_SESSION['mfa_replacement']['secret'];$before=$mfa->events;
$db->exec("CREATE TRIGGER reject_notice_audit BEFORE INSERT ON journal FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Test audit failure'");
try{$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');throw new RuntimeException('Audit failure accepted');}catch(PDOException $e){notificationCheck($mfa->events===$before,'Rolled-back replacement sends no completion notice');}$db->exec('DROP TRIGGER reject_notice_audit');
$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),'Admin test password 123!');notificationCheck(count($mfa->events)===4&&end($mfa->events)==='replaced','Successful replacement sends one completion notice after commit');
echo "$checks MFA notification checks passed.\n";ob_end_flush();
+5
View File
@@ -0,0 +1,5 @@
import subprocess
from application_harness import Application,PHP,ROOT
app=Application()
try:subprocess.run(PHP+['tests/mfa_notifications.php'],cwd=ROOT,env=app.env,check=True)
finally:app.close()
+38
View File
@@ -0,0 +1,38 @@
const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const jsQR=require('jsqr');
const {totp}=require('./browser_mfa');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[],requests=[];let checks=0;
page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>requests.push(r.url()));
const pass=message=>{checks++;console.log('PASS:',message);};
const click=async button=>{await Promise.all([page.waitForNavigation(),button.click()]);};
async function scan(){
const image=page.locator('.mfa-setup-qr');await image.waitFor({state:'visible'});
const pixels=await image.evaluate(async img=>{
await img.decode();const canvas=document.createElement('canvas');canvas.width=canvas.height=320;
const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,320,320);
return Array.from(ctx.getImageData(0,0,320,320).data);
});
const decoded=jsQR(new Uint8ClampedArray(pixels),320,320,{inversionAttempts:'dontInvert'});
assert(decoded,'QR image did not decode');return new URL(decoded.data);
}
try{
await page.goto(process.env.HCR_TEST_URL+'/main.php');await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await click(page.locator('#login button[type=submit]'));
const image=page.locator('.mfa-setup-qr');assert.match(await image.getAttribute('src'),/^data:image\/svg\+xml;base64,/);pass('Setup renders a locally generated QR image under the security policy');
const uri=await scan(),secret=uri.searchParams.get('secret');assert.equal(uri.protocol,'otpauth:');assert.equal(uri.host,'totp');assert.equal(uri.searchParams.get('issuer'),'My Hunting Club Roster');assert(decodeURIComponent(uri.pathname).includes('admin@example.test'));pass('Independent decoder reads the correct authenticator type, issuer and account');
assert.equal(secret,(await page.locator('pre').textContent()).trim());assert.equal(uri.searchParams.get('period')||'30','30');assert.equal(uri.searchParams.get('digits')||'6','6');assert.equal((uri.searchParams.get('algorithm')||'SHA1').toUpperCase(),'SHA1');pass('QR and manual key configure the same secret and verification settings');
await page.reload();assert((await scan()).href===uri.href,'Refreshing setup changed its provisioning details');pass('Refreshing pending setup preserves its QR details and manual key');
const box=await image.boundingBox();assert(box.width<=390&&box.height===box.width);pass('QR image fits a phone-width browser without distortion');
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(totp(secret));await click(page.getByRole('button',{name:'Verify',exact:true}));
await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();const codes=(await page.locator('pre').textContent()).trim().split(/\s+/);assert.equal(codes.length,10);pass('A code generated from the scanned QR completes MFA enrollment');
assert.equal(await page.locator('.mfa-setup-qr').count(),0);await click(page.getByRole('link',{name:'I have saved my recovery codes'}));await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=security');assert.equal(await page.locator('.mfa-setup-qr').count(),0);pass('Enrolled accounts and recovery-code pages do not display the setup QR');
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(codes[0]);await click(page.getByRole('button',{name:'Replace lost authenticator',exact:true}));
const replacement=await scan();assert.notEqual(replacement.searchParams.get('secret'),secret);assert.equal(replacement.searchParams.get('secret'),(await page.locator('pre').textContent()).trim());pass('Authenticator replacement gets a fresh QR matching the new manual key');
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(totp(replacement.searchParams.get('secret')));await click(page.getByRole('button',{name:'Confirm replacement',exact:true}));await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();pass('Scanned replacement code confirms the atomic factor swap');
assert(requests.every(url=>url.startsWith(process.env.HCR_TEST_URL)||url.startsWith('data:')));assert(!requests.some(url=>url.includes(secret)));pass('Setup makes no external QR requests and puts no secret in request URLs');
assert.deepEqual(errors,[]);pass('QR enrollment and replacement produce no browser script errors');
console.log(checks,'MFA QR browser checks passed.');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exit(1);});
+8
View File
@@ -0,0 +1,8 @@
import os,subprocess
from application_harness import Application,ROOT
app=Application()
try:
env=app.env.copy();env['HCR_TEST_URL']=app.base
env['NODE_PATH']=str(ROOT/'.tools/qr-test/node_modules')+os.pathsep+env.get('NODE_PATH','')
subprocess.run([env.get('NODE_TEST_COMMAND','node'),'tests/mfa_qr_browser.js'],cwd=ROOT,env=env,check=True)
finally:app.close()
+27
View File
@@ -0,0 +1,27 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
ob_start();session_start();
class SilentRaceMfa extends MfaService {protected function notify(array $account,string $event):void {}}
$mfa=new SilentRaceMfa();$password='Admin test password 123!';
if(($argv[1]??'')==='seed'){
(new AuthService())->login('admin@example.test',$password);
$secret=OTPHP\TOTP::generate(new MfaClock())->getSecret();
$_SESSION['mfa_pending']=['user'=>1,'secret'=>$secret,'expires'=>time()+600];
$codes=$mfa->enroll(1,$secret,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),$password);
$base=$_SESSION;$states=[];
foreach([$codes[0],$codes[1]] as $code){$_SESSION=$base;$mfa->beginReplacement(1,$code,$password);$states[]=$_SESSION;}
ob_clean();echo json_encode(['states'=>$states,'recovery'=>$codes[2]]);ob_end_flush();exit;
}
$payload=json_decode(trim(fgets(STDIN)),true,512,JSON_THROW_ON_ERROR);$_SESSION=$payload['state'];
ob_clean();fwrite(STDOUT,"READY\n");fflush(STDOUT);fgets(STDIN);
try{
if($argv[1]==='replace'){
$secret=$_SESSION['mfa_replacement']['secret'];
$codes=$mfa->completeReplacement(1,OTPHP\TOTP::createFromSecret($secret,new MfaClock())->now(),$password);
$ok=count($codes)===10;
}else $ok=$mfa->verify(1,$payload['recovery'],$password);
$result=['ok'=>$ok];
}catch(AppException $error){$result=['ok'=>false,'status'=>$error->status];}
ob_clean();echo json_encode($result);ob_end_flush();
+3 -3
View File
@@ -2,7 +2,7 @@
if(PHP_SAPI!=='cli')exit; if(PHP_SAPI!=='cli')exit;
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('A disposable test database is required.'); if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('A disposable test database is required.');
require __DIR__.'/../classes/Base.php';ob_start();session_start(); require __DIR__.'/../classes/Base.php';ob_start();session_start();
$db=(new Base())->connect();$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1; $db=(new Base())->connect();$_SESSION[Base::USER_ROLE]=TenantAccess::SUPER;$_SESSION[Base::USER_SERIAL]=1;
$checks=0; $checks=0;
function verify(bool $value,string $message): void {global $checks;if(!$value)throw new RuntimeException('FAILED: '.$message);$checks++;echo 'PASS: '.$message."\n";} function verify(bool $value,string $message): void {global $checks;if(!$value)throw new RuntimeException('FAILED: '.$message);$checks++;echo 'PASS: '.$message."\n";}
function rejects(callable $call,string $message): void {try{$call();}catch(AppException $e){verify(true,$message);return;}throw new RuntimeException('FAILED: '.$message);} function rejects(callable $call,string $message): void {try{$call();}catch(AppException $e){verify(true,$message);return;}throw new RuntimeException('FAILED: '.$message);}
@@ -52,9 +52,9 @@ $db->exec('DELETE FROM auth_attempts');$verification->mailWorks=false;try{$verif
verify((int)$db->query('SELECT COUNT(*) FROM email_verifications')->fetchColumn()===0,'Undelivered verification token is removed'); verify((int)$db->query('SELECT COUNT(*) FROM email_verifications')->fetchColumn()===0,'Undelivered verification token is removed');
$db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');verify($auth->resetPassword($auth->token(),$password,$password),'Password setup/reset can also prove mailbox ownership'); $db->exec('DELETE FROM auth_attempts');$auth->requestReset('securitymember');verify($auth->resetPassword($auth->token(),$password,$password),'Password setup/reset can also prove mailbox ownership');
verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Mailbox ownership is verified after successful password reset'); verify((bool)$auth->findUser('securitymember')['user_email_verified_at'],'Mailbox ownership is verified after successful password reset');
$edit['user_email']='stillunverified@example.test';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');ob_start();$auth->login('securitymember',$password);$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1;$_SESSION['auth_version']=(int)$auth->findUser('securitymember')['user_auth_version'];$auth->changePassword($id,$password,$password,$password);ob_end_clean(); $edit['user_email']='stillunverified@example.test';$service->updateUser($id,$edit);$db->exec('DELETE FROM auth_attempts');ob_start();$auth->login('securitymember',$password);$_SESSION[Base::USER_ROLE]=TenantAccess::SUPER;$_SESSION[Base::USER_SERIAL]=1;$_SESSION['auth_version']=(int)$auth->findUser('securitymember')['user_auth_version'];$auth->changePassword($id,$password,$password,$password);ob_end_clean();
verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing a known password does not falsely verify mailbox ownership'); verify(!$auth->findUser('securitymember')['user_email_verified_at'],'Changing a known password does not falsely verify mailbox ownership');
$_SESSION[Base::USER_ROLE]='Administrator';$_SESSION[Base::USER_SERIAL]=1; $_SESSION[Base::USER_ROLE]=TenantAccess::SUPER;$_SESSION[Base::USER_SERIAL]=1;
$roster=new RosterService();$club=$roster->saveClub(0,'Security Club'); $roster=new RosterService();$club=$roster->saveClub(0,'Security Club');
rejects(fn()=>$roster->saveMember($club,0,'securitymember','active','2026-01-01'),'Unverified email cannot receive active club membership'); rejects(fn()=>$roster->saveMember($club,0,'securitymember','active','2026-01-01'),'Unverified email cannot receive active club membership');
$db->exec('DELETE FROM auth_attempts');$verification->mailWorks=true;$verification->request('securitymember');$verification->confirm($verification->token(),$password); $db->exec('DELETE FROM auth_attempts');$verification->mailWorks=true;$verification->request('securitymember');$verification->confirm($verification->token(),$password);
+3 -2
View File
@@ -97,8 +97,9 @@ try:
# Set a known member password in this disposable fixture via the real reset flow. # Set a known member password in this disposable fixture via the real reset flow.
# Fixture token generation is intentionally scoped to httpuser, so create that # Fixture token generation is intentionally scoped to httpuser, so create that
# account through signup and assign it separately for isolation tests. # account through signup and assign it separately for isolation tests.
app.logout();_,signup,_=app.request('/?Xaction=signup') app.fixture('seedTenantInvitation',str(club_a),'1','http@example.test')
expect(app.request(data={'action':'SignUp.createAccount','csrf_token':app.csrf(signup),'user_first_name':'Regular','user_last_name':'Member','user_email':'http@example.test','user_name':'httpuser','user_password':'HTTP long password!','user_password_confirm':'HTTP long password!'}),200,'Regular member account can sign up') app.logout();_,signup,_=app.request('/?Xaction=signup&membership=invited')
expect(app.request(data={'action':'SignUp.createAccount','registration_kind':'invited','csrf_token':app.csrf(signup),'user_first_name':'Regular','user_last_name':'Member','user_email':'http@example.test','user_name':'httpuser','user_password':'HTTP long password!','user_password_confirm':'HTTP long password!'}),200,'Regular member account can sign up')
app.login();csrf=app.token app.login();csrf=app.token
app.fixture('verifyEmail','httpuser') app.fixture('verifyEmail','httpuser')
app.post('Roster.workspace',step='member',club_serial=club_a,identifier='httpuser',membership_status='active',joined_on='2026-01-01') app.post('Roster.workspace',step='member',club_serial=club_a,identifier='httpuser',membership_status='active',joined_on='2026-01-01')
+20
View File
@@ -0,0 +1,20 @@
import os,subprocess,time
from application_harness import Application,PHP,ROOT
app=Application()
workers=[]
try:
ids=app.fixture('tenantSeed');env=app.env.copy();env['HCR_CLUB_EXPORT_HOURLY']='5';start=str(time.time()+2)
for n in range(12):
workers.append(subprocess.Popen(PHP+['tests/resource_worker.php',str(ids['admina' if n%2 else 'admina2']),str(ids['clubA']),start],cwd=ROOT,env=env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True))
outputs=[]
for worker in workers:
out,err=worker.communicate(timeout=20)
if worker.returncode:raise RuntimeError(err+out)
outputs.append(out)
assert outputs.count('allowed')==5 and outputs.count('limited')==7,outputs
print('PASS: Twelve simultaneous requests from two administrators share exactly five export slots')
print('1 resource concurrency scenario passed.')
finally:
for worker in workers:
if worker.poll() is None:worker.kill();worker.wait()
app.close()
+32
View File
@@ -0,0 +1,32 @@
import os
from application_harness import Application
for resource in ['EMAIL','TICKET','REPLY','EXPORT']:os.environ['HCR_CLUB_'+resource+'_HOURLY']='2'
app=Application();checks=0
def check(ok,message):
global checks
if not ok:raise AssertionError(message)
checks+=1;print('PASS:',message)
def tickets():return app.fixture('inspect')['club_tickets']
try:
ids=app.fixture('tenantSeed');a=ids['clubA'];b=ids['clubB']
app.login('membera@example.test','Tenant test password 123!')
check(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='First',message='Body')[0]==200,'First member can submit within the shared ticket budget');tid=tickets()[0]['ticket_serial']
app.logout();app.login('shared@example.test','Tenant test password 123!')
check(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='Second',message='Body')[0]==200,'Another member shares the same ticket budget')
check(app.post('Roster.tickets',step='openTicket',club_serial=a,subject='Limited',message='Body')[0]==429 and len(tickets())==2,'Club ticket limit rejects further work without partial records')
app.logout();app.login('admina2@example.test','Tenant test password 123!')
for n in range(2):check(app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=tid,message='Reply '+str(n))[0]==200,'Club reply budget permits an authorized reply')
check(app.post('Roster.tickets',step='reply',club_serial=a,ticket_serial=tid,message='Limited')[0]==429,'Club reply limit is enforced through HTTP')
check(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='one@example.test')[0]==200,'Invitation email uses the shared club budget')
check(app.post('RosterReports.csv',club_serial=a)[0]==200,'Roster export uses the shared export budget')
app.logout();app.login('admina@example.test','Tenant test password 123!')
check(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='two@example.test')[0]==200,'Another administrator shares the email budget')
check(app.post('Roster.workspace',step='invite',club_serial=a,invite_email='three@example.test')[0]==429,'Club email limit rejects additional invitations')
check(app.post('RosterReports.ledger',club_serial=a)[0]==200,'Different export formats share one club budget')
check(app.post('RosterReports.excel',club_serial=a)[0]==429,'Export budget applies across administrators and formats')
check(app.post('RosterReports.csv',club_serial=b)[0]==403,'An ordinary member cannot consume a foreign export budget')
app.logout();app.login('adminb@example.test','Tenant test password 123!')
check(app.post('RosterReports.csv',club_serial=b)[0]==200,'Other club exports remain available after the first club reaches its limit')
check(app.post('Roster.workspace',step='invite',club_serial=b,invite_email='beta@example.test')[0]==200,'Other club email remains available after the first club reaches its limit')
print(checks,'resource HTTP checks passed.')
finally:app.close()
+9
View File
@@ -0,0 +1,9 @@
<?php
if(PHP_SAPI!=='cli')exit;
require __DIR__.'/../classes/Base.php';
if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',getenv('HCR_DB_NAME')?:''))throw new RuntimeException('Disposable database required.');
$_SESSION=[Base::USER_SERIAL=>(int)$argv[1]];
$start=(float)$argv[3];if($start>microtime(true)+5)throw new RuntimeException('Invalid start time.');
while(microtime(true)<$start)usleep(1000);
try{(new ClubResources())->consume((int)$argv[2],'export');echo 'allowed';}
catch(AppException $e){if($e->status!==429)throw $e;echo 'limited';}

Some files were not shown because too many files have changed in this diff Show More