diff --git a/.github/workflows/regression.yml b/.github/workflows/regression.yml
new file mode 100644
index 0000000..3bb00ef
--- /dev/null
+++ b/.github/workflows/regression.yml
@@ -0,0 +1,53 @@
+name: Security and workflow regressions
+on: [push, pull_request, workflow_dispatch]
+permissions:
+ contents: read
+jobs:
+ regression:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ services:
+ mariadb:
+ image: mariadb:11.4
+ env:
+ MARIADB_ROOT_PASSWORD: disposable-ci-password
+ ports: ['3306:3306']
+ options: >-
+ --health-cmd="healthcheck.sh --connect --innodb_initialized"
+ --health-interval=5s --health-timeout=5s --health-retries=20
+ env:
+ HCR_ENVIRONMENT: Development
+ HCR_TEST_DSN: mysql:host=127.0.0.1;port=3306
+ HCR_TEST_USER: root
+ HCR_TEST_PASSWORD: disposable-ci-password
+ HCR_DB_HOST: 127.0.0.1
+ HCR_DB_PORT: 3306
+ HCR_DB_USER: root
+ HCR_DB_PASSWORD: disposable-ci-password
+ HCR_APPLICATION_URL: ''
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: pdo_mysql, dom, simplexml, xsl, mbstring, openssl, zip, gd, fileinfo, curl
+ tools: composer
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - run: composer install --no-interaction --prefer-dist
+ - run: composer check-platform-reqs
+ - run: composer audit --locked
+ - run: python -m pip install lxml==6.0.2
+ - run: sudo apt-get update && sudo apt-get install -y mariadb-client
+ - name: Install isolated test browser
+ run: |
+ npm install --prefix "$RUNNER_TEMP/hcr-browser" --ignore-scripts playwright@1.62.1
+ "$RUNNER_TEMP/hcr-browser/node_modules/.bin/playwright" install --with-deps chromium
+ 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"
+ - run: python tests/run.py
+ - run: python bin/build-release.py "$RUNNER_TEMP/hcr-release.zip"
diff --git a/.gitignore b/.gitignore
index 89c1925..be00460 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
/.env
/.tools/
/tests/.local/
+/tests/__pycache__/
diff --git a/.htaccess b/.htaccess
index 9b0c667..7b78488 100644
--- a/.htaccess
+++ b/.htaccess
@@ -1,10 +1,8 @@
Options -Indexes
-
- RewriteEngine On
- RewriteRule ^(?:classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports)(?:/|$) - [F,L,NC]
- RewriteRule (^|/)\. - [F,L]
-
-
+RewriteEngine On
+RewriteRule ^(?:classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports|deploy)(?:/|$) - [F,L,NC]
+RewriteRule (^|/)\. - [F,L]
+
Require all denied
DirectoryIndex main.php
diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md
index 9ae6a9d..46680b9 100644
--- a/AUTHENTICATION.md
+++ b/AUTHENTICATION.md
@@ -4,6 +4,11 @@ Login, signup, password changes, and password reset now use `classes/AuthService
The existing PHP/XSL presentation structure is retained. Signup creates an active
`User` account; it does not grant administrator access or club membership.
+The October 19 update adds mailbox verification and reset-link invalidation on
+account changes. Apply the newest schema before using that code; see
+[production safeguard changes](PRODUCTION-FIXES.md) for migration and rollout
+instructions. Ordinary club access now requires verified email ownership.
+
## Before using this version against your database
1. Back up the database and use a maintenance window for the schema update.
diff --git a/PRODUCTION-FIXES.md b/PRODUCTION-FIXES.md
new file mode 100644
index 0000000..c9e9891
--- /dev/null
+++ b/PRODUCTION-FIXES.md
@@ -0,0 +1,253 @@
+# Production safeguard changes — October 19, 2026
+
+This implements the production review's security defects, financial history and
+corrections, protected audit writes, mailbox verification, and deployment tooling.
+It does not deploy to a public host or silently change the development database.
+
+## Apply the new schema before running this code against an existing database
+
+Back up the target database, verify that the backup restores, and use a maintenance
+window. With separate migration credentials and the target environment configured:
+
+```text
+php bin/migrate-auth.php
+php bin/migrate-roster.php
+php bin/migrate-production.php
+```
+
+All three migrations are now applied to development. The production-safeguards
+migration was applied there on October 20, 2026, after a fresh verified backup;
+see the rollout record below. It has not been applied to a production host here. It adds
+mailbox verification, financial correction/reversal tables, and payment metadata;
+it enlarges journal entries, ensures transactional audit storage, and refreshes
+the user view. It can be rerun. Existing financial records retain their amounts;
+their previously unknown actors remain null and appear as historical records.
+Schema changes can lock tables; they are CLI operations, never web requests.
+
+Existing addresses are not automatically marked verified. Ordinary users must
+verify their email before viewing club data or receiving/reactivating membership.
+They can use the new verification flow or complete a password setup/reset link.
+Existing administrators retain access to administer this transition. User records,
+memberships, and financial history are preserved.
+
+## Account security and browser dependency
+
+- Password-reset issuance now locks the account and compares the originally
+ resolved email, status, and account version. Account edits and status changes
+ delete outstanding reset tokens under the same account lock. This handles both
+ interleavings: an edit before token creation, and an edit during email delivery.
+- Username/email/role/status changes invalidate reset links and verification
+ links. Changing an email also clears its verified status. Disable/reactivate
+ cannot revive a reset link. Existing session revocation remains intact.
+- Replaced jQuery 3.2.0 with official jQuery 3.7.1 on current and legacy templates.
+ Removed the old asset. Its downloaded SHA-256 matches the official integrity
+ value recorded in browser-dependencies.json. Existing jQuery 3.x plugin behavior
+ is retained; a major-version migration is not necessary for this fix.
+- Browser tests cover account creation, deletion safeguards, the alert and popover
+ editors, rosters, financial corrections, downloads, and verification requests.
+- Fixed the garbled username validation message and normalized newly added view
+ text to UTF-8.
+
+## Financial history, corrections, and audit
+
+- New LedgerService coordinates dues, payments, corrections, and reversals.
+ Charges and payments record their actor; payments also store method/reference.
+- Itemized payment history shows date, amount, member, method/reference, actor,
+ and reversal details. Members see only their own financial history.
+- Correcting dues appends an adjustment with the previous/effective amounts,
+ actor, reason, and request key. It preserves the original charge. A correction
+ cannot reduce dues below received payments; reverse an erroneous payment first.
+- Reversing a payment appends one reversal with an actor and reason. It restores
+ the amount owed and retains the original payment. This records a bookkeeping
+ correction; it does not send a refund or move money.
+- Repeated payment, correction, and reversal requests are idempotent. Changed
+ payloads with a reused request key are rejected. Concurrent writes use account,
+ club, and dues locks as appropriate; overpayment is still rejected.
+- Financial CSV includes effective dues, paid/balance cents, individual payments,
+ corrections, reversal reasons, and actors. User text is protected against
+ spreadsheet formula injection. Monetary calculations use integer cents.
+- Financial, club, membership, season, and administrative account changes write
+ audit entries in the same transaction. An audit failure rolls back the change.
+ Entries include entity IDs and relevant before/after values, amounts, or reasons.
+ Passwords and tokens are never audit payloads.
+- Removed the journal-clearing route, button, and handler. The legacy PHP helper
+ also refuses clearing. Accounts with recorded journal history are retained;
+ disable them instead of deleting their identity.
+- Runtime grant examples allow journal inserts but deny journal update/deletion
+ and financial update/deletion. Migrations and backup/restore use separate roles.
+ Authentication audit errors continue to go to server logs without preventing
+ login; configure alerts on those errors.
+
+## Mailbox verification
+
+- Signup requests an email verification link and explains how to resend it.
+ Public requests return neutral messages for unknown, verified, rate-limited,
+ or undeliverable accounts.
+- Links contain 256-bit random values, stored only as hashes, expire after 24
+ hours, and are bound to the account email/version. Failed delivery removes the
+ unsent token. Expired records are handled by the cleanup CLI.
+- Opening a link does not consume it. Confirmation requires POST, CSRF protection,
+ a valid token, and the account password. This prevents a mailbox owner from
+ accidentally activating an account whose password was chosen by someone else.
+- Successful password setup/reset also proves ownership of its current mailbox.
+ Changing a known password without a delivered token does not verify an email.
+- Active membership assignment requires an active, verified account. Regular
+ members also need verified email to read club data. Admin summaries show the
+ verification state and retain the password setup-link action.
+
+## Production configuration and release tooling
+
+- Remote production database connections require HCR_DB_TLS_CA. Certificate
+ validation is enabled and the negotiated TLS cipher is checked. Localhost/
+ loopback database connections remain available; explicitly protect any local
+ tunnel. Production TLS must still be tested against the actual server.
+- Set HCR_ENVIRONMENT=Production explicitly. That prevents automatic loading of
+ settings.local.xml; environment variables or an explicitly selected external
+ HCR_SETTINGS_FILE supply production secrets. Never package development secrets.
+- HTTPS is recognized directly or from X-Forwarded-Proto only when REMOTE_ADDR is
+ in HCR_TRUSTED_PROXIES, a comma-separated list of exact IP addresses. The proxy
+ must overwrite forwarded headers and direct access to its backend must be
+ restricted. Untrusted and ambiguous forwarding headers are rejected.
+- Application responses hide PHP errors and send CSP, frame/referrer/content-type
+ protections and Permissions-Policy. Production HTTPS sends HSTS. Inline scripts
+ and JavaScript links were replaced so script-src does not need unsafe-inline.
+ Inline styles remain permitted for Bootstrap/editor compatibility.
+- Apache protections now require mod_rewrite rather than silently skipping the
+ private-directory rules. Apache needs AllowOverride support. IIS additionally
+ blocks sensitive file extensions. Nginx and PHP configuration examples are in
+ deploy/. These examples must be adapted and tested on the chosen server.
+- `python bin/build-release.py /protected/releases/hcr.zip` creates a clean zip and
+ SHA-256 manifest from the workspace, including new files. It excludes local
+ settings, backups, tests, Git metadata, and IDE files. Use locked Composer
+ dependencies and review the archive before deployment; it is not published.
+- `python bin/check-production.py https://app.myhuntingclubroster.com` performs
+ read-only HTTPS, header, cookie, and private-path checks after deployment.
+ It sends no email and changes no accounts. The actual production host was not
+ contacted in this change.
+- `php bin/check-health.php` exits nonzero on database/schema failures. Run it
+ from the server monitor with production settings and alert on failures.
+
+## Operational setup on the hosting server
+
+The project now supplies the entry points; the server administrator must install
+their schedule, permissions, storage, and alerts on the actual production host.
+No hosting credentials or host details were supplied for that work.
+
+1. Provision migration, runtime, and backup accounts separately. Adapt
+ deploy/runtime-grants.sql.example. Keep secrets outside the document root and
+ restrict file access to the service/job accounts. Test the runtime role.
+2. Schedule `php bin/cleanup-auth.php` daily. It removes expired reset/verification
+ links and stale rate-limit buckets without deleting audit history.
+3. Schedule `php bin/backup-database.php /protected/backups` using backup-specific
+ credentials. Set HCR_DUMP_BINARY to the installed mariadb-dump executable if it
+ is not on PATH. The directory must exist outside the application tree. The job
+ writes a complete transactional SQL dump and checksum metadata and removes its
+ temporary credentials file. All tables must be InnoDB. It exits on failure.
+4. Restrict the backup directory before running the job, especially with Windows
+ ACLs. Use encrypted storage and protected off-host copies, define retention and
+ recovery objectives, and regularly restore-test backups. The script does not
+ implement encryption, retention, or off-host storage by itself.
+5. Alert on failed backups/cleanup/health jobs, PHP errors, audit failures, SMTP
+ failures, disk pressure, and database failures. Configure log rotation and
+ protected audit retention/archival; there is no web audit-deletion facility.
+6. Run Composer platform checks and the isolated regression runner before release.
+ The GitHub workflow supplies a disposable MariaDB and browser; it runs when
+ pushed to a repository with Actions enabled. This change does not push code or
+ claim that GitHub CI has run.
+7. Deploy the complete release during the migration window. Run host checks, then
+ send a real invitation/reset to a controlled mailbox and verify inbox delivery
+ and link completion. Local tests deliberately use simulated delivery.
+8. Keep the prior release and the verified pre-migration backup. Recovery after
+ new financial writes needs reconciliation; do not blindly restore an old dump
+ and discard subsequently received entries.
+
+## Verification and remaining scope
+
+The final local run passed **308 checks plus 5 concurrency scenarios**:
+
+| Suite | Passed |
+| --- | ---: |
+| Authentication service/templates | 49 |
+| Maintenance validation/pagination | 52 |
+| Authentication HTTP | 27 |
+| Project HTTP, ledger, exports, and access boundaries | 99 |
+| Reset invalidation, verification, corrections, audit rollback, transport | 42 |
+| Restricted runtime database permissions | 5 |
+| Browser workflows, editors, corrections, downloads, and CSP | 21 |
+| Migration, release, backup, health, and private-path tooling | 13 |
+| Concurrent defaults, payments, reversal retries, correction retries, final admin | 5 scenarios |
+
+Source checks passed for 41 PHP files, 55 JavaScript files, 39 compiled XSL
+stylesheets, and the jQuery integrity hash. IIS configuration is well-formed XML.
+Composer's final locked dependency audit reported no advisories or abandoned
+packages. The GitHub workflow and actual Apache/IIS/Nginx production hosts have
+not been executed or contacted here.
+
+The verified development backup was also restored locally. After bringing the
+copy to the existing roster schema, the new migration was run twice. Every
+original column value across all 17 existing tables was unchanged. The isolated
+restored copy was removed. That October 19 implementation run did not migrate
+development; the subsequently authorized rollout is recorded below.
+
+Tests use generated disposable databases; they do not send real mail, move money,
+or modify existing development accounts. Temporary databases and the restricted
+test account were removed, and the local test server was stopped. Production TLS
+certificate negotiation and real inbox delivery require the actual hosting setup.
+
+Global site administrators continue to manage all clubs. Active club members
+continue to see their club's active contact directory. These are existing product
+policies, not newly introduced club-admin roles or contact-consent settings.
+Delegated club administration, MFA enrollment/recovery, online payments/refunds,
+and large-scale pagination/background exports remain separate enhancements. The
+production-specific TLS, web-server configuration, monitoring schedules, off-host
+backup policy, and real mail delivery remain deployment acceptance work.
+
+## Development rollout — October 20, 2026
+
+At the user's request, `bin/migrate-production.php` was applied to
+`192.168.1.190/huntingclubroster` at 6:32 a.m. Eastern. The previously approved
+unencrypted development connection was used. The target was checked explicitly;
+no production host was changed.
+
+Before migration, a complete 43,100-byte backup was saved at
+`.tools/backups/20261020-063143/huntingclubroster.sql`. Its SHA-256 is
+`526113c2575927d33680cb696a398f59352554a5892c23c1a8ff414fe6f1e3ca`.
+The backup was restored locally and its checksum and all 17 table counts matched.
+Every existing column value in the original 17 development tables was checked
+before and after migration and was preserved. The database now has 20 base tables.
+
+**48 acceptance checks passed against development**, using temporary accounts and
+an owned temporary club through a local application server. They verified:
+
+- Verification link generation, trusted URL, POST confirmation, correct-password
+ enforcement, single use, stored verification state, and membership gating.
+- Password reset, old-password rejection, new-password login, consumed-token
+ rejection, session revocation, and invalidation after an email change.
+- Clearing verified state on email change and restoring club access only after a
+ fresh bound verification link is confirmed.
+- Dues corrections preserving original charges, safe retries, correct balances,
+ and rejection of corrections below received payments or excess payments.
+- Reversals preserving original payments, safe retries, duplicate-reversal
+ rejection, restored balances, and settlement with a replacement payment.
+- Ordinary-member restrictions, detailed financial CSV, and audit events.
+
+Email issuance used the real application services and development database, with
+only the delivery boundary replaced by local capture. No real emails were sent;
+SMTP/inbox delivery was not retested. The public URL was checked in generated
+links without requiring development to be served at that public hostname. All
+payments were manual test ledger entries; no money moved.
+
+The first helper attempts encountered a Windows temporary-session-path parsing
+issue. The helper was corrected and those temporary records were removed. During
+the first complete run, an independent administrator login changed existing login
+timestamps, a journal entry, and rate counters. The differences were proved to
+match that login and were preserved; no original data was restored over user
+activity. A final complete run finished with a clean baseline comparison across
+all 20 tables. Existing accounts were not used as test accounts or reset by the
+verification scripts.
+
+All temporary accounts, memberships, seasons, charges, payments, adjustments,
+reversals, tokens, test audit/rate-limit entries, and temporary sessions were
+removed. Auto-increment sequences may have advanced. A final CLI health check
+passed. The local restore copy was removed and its database server stopped.
+Private migration results and the check-by-check report remain beside the backup.
diff --git a/PRODUCTION-REVIEW.md b/PRODUCTION-REVIEW.md
new file mode 100644
index 0000000..30986f7
--- /dev/null
+++ b/PRODUCTION-REVIEW.md
@@ -0,0 +1,171 @@
+# Production readiness review — October 18, 2026
+
+Follow-up implementation: see [PRODUCTION-FIXES.md](PRODUCTION-FIXES.md) for the
+October 19 code changes, new migration, tests, and remaining host acceptance work.
+The findings below describe the reviewed version before that implementation.
+
+Recommendation: hold production launch until the security defects and financial
+recordkeeping gaps below are resolved and the production deployment checks pass.
+The recent successful workflow tests remain useful, but do not cover these gaps.
+
+This review inspected the current source, schema, server rules, configuration,
+dependencies, and deployment documentation. Composer's locked dependency audit
+returned no published advisories or abandoned packages. One security defect was
+reproduced using a disposable local database; that database was removed and its
+server stopped. No development database records or application code were changed
+during this review. The earlier 231-check suite and 54 development checks were
+not rerun as part of this review.
+
+## 1. P1 — Revoke reset links when account security details change
+
+Evidence: `classes/MaintenanceService.php:110–137` revokes sessions on account
+changes but leaves password reset tokens intact. `classes/AuthService.php:150–181`
+accepts a token based on its user ID and expiry without binding it to the account
+version or email that existed when it was issued.
+
+Confirmed reproduction: issue a valid token for `old@example.test`, change the
+account email to `new@example.test` through MaintenanceService, then submit the
+old token. The reset succeeds. Someone holding a link delivered to the old mailbox
+can therefore regain access while the token remains valid, up to 30 minutes.
+
+Fix: invalidate existing tokens transactionally on email and other security changes;
+bind issued tokens to the relevant account version so concurrent issuance cannot
+leave an old token valid. Cover email changes, disable/reactivate, role changes,
+and concurrent reset issuance with regression tests.
+
+## 2. P1 — Replace the vulnerable browser dependency
+
+Evidence: `xsl/content.xsl:41`, `xsl/login.xsl:36`, and
+`xsl/createAccount.xsl:37` load `js/jquery-3.2.0.min.js`.
+
+This version predates fixes for published cross-site scripting vulnerabilities in
+HTML manipulation. The upstream advisories describe vulnerable operations and
+affected versions; this review does not claim a demonstrated application exploit.
+Composer audit does not cover bundled JavaScript files.
+
+Fix: select a patched, supported jQuery version, review the bundled plugins for
+compatibility, and rerun browser tests. Track browser dependencies in a manifest
+and include them in dependency checks.
+
+Primary sources:
+- https://github.com/jquery/jquery/security/advisories/GHSA-gxr4-xjj5-5px2
+- https://github.com/jquery/jquery/security/advisories/GHSA-jpcq-cgw6-v4j6
+- https://jquery.com/upgrade-guide/3.5/
+
+## 3. P1 — Complete financial history and corrections before real dues use
+
+Evidence: `classes/Roster.php:66–71` displays charges, summed payments, and balances,
+but no itemized payment history. `classes/RosterService.php:62–79` supports inserting
+charges and payments, without a correction, void, or reversal workflow.
+`migrations/002_roster.sql:30–49` has no recorded-by actor, correction reason, or
+payment reference fields.
+
+A payment or charge entered incorrectly currently requires direct database work.
+The user also cannot inspect each payment's date and details in the application.
+
+Fix: add itemized payment history and financial export/reconciliation; record who
+entered each item and a reference/method where appropriate; implement permissioned,
+audited adjustments or reversals that preserve the original entry. Test balance
+recalculation, repeated requests, and concurrent corrections. This concerns the
+manual ledger already in scope; online payment processing is a separate feature.
+
+## 4. P1/P2 — Preserve and complete the audit trail
+
+Evidence: `classes/Journal.php:38–44` lets an administrator truncate the complete
+journal. `classes/RosterService.php:44–61` does not audit membership or season
+changes. Financial messages at lines 67 and 77 omit amounts and the new financial
+record's ID. `classes/Audit.php:3–7` catches write failures; financial writes commit
+before their audit messages are attempted. Administrative account changes also
+lack corresponding detailed audit entries.
+
+Consequences: changing club access may leave no journal event, and the journal
+can be erased. A financial save can succeed while its audit record is lost.
+
+Fix: remove unrestricted journal clearing, define retention and protected archival,
+record actor/entity/before-and-after details for privileged changes, and write
+financial audit events in the same transaction or a transactional outbox. Keep
+authentication available if its logging backend fails, with failure monitoring.
+
+## 5. P2 — Verify email ownership before relying on it for club access
+
+Evidence: `classes/AuthService.php:86–113` immediately activates a self-registered
+account without verifying its mailbox. `classes/RosterService.php:44–55` assigns
+membership to an existing username or email with no verified-email check.
+
+An account can be registered using someone else's email. Signup alone does not
+grant membership, but an administrator assigning that expected email could select
+an account controlled by the wrong person.
+
+Fix: implement mailbox verification or an invitation acceptance flow, and require
+verified identity before assignment by email. Email changes should require fresh
+verification and invalidate outstanding reset links. Until then, an explicit
+independent identity-check procedure is needed for assignments.
+
+## 6. Release gate — Verify production isolation, transport, and secrets
+
+These are deployment checks, not claims that a public leak currently exists.
+
+- `classes/Base.php:145–153` has no database TLS certificate/verification options.
+ The approved non-TLS development connection does not establish a production
+ transport design. If production uses a remote database, configure verified TLS
+ or an explicitly secured private transport; use a restricted runtime database
+ account and a separate migration account.
+- `classes/Base.php:432–440` automatically prefers `xml/settings.local.xml` before
+ environment overrides. Build a clean production artifact that excludes local
+ secrets, `.tools` backups and control files, `.git`, tests, and IDE files.
+ Current workspace changes include untracked application classes and migrations;
+ ensure the release captures every required file.
+- Apache private-directory rules depend on rewrite configuration. IIS and the
+ development router have different rules; no Nginx configuration is provided.
+ Verify private paths and backup filenames return a denial on the actual host.
+ Prefer placing private code, secrets, and backups outside the document root.
+- `main.php:20–30` detects HTTPS only through `$_SERVER['HTTPS']`. If TLS terminates
+ at a proxy, configure a trusted proxy/server arrangement; otherwise HTTPS
+ visitors can be rejected or cookies can be misconfigured. Do not trust arbitrary
+ client-supplied forwarding headers.
+- Confirm production environment mode, secure cookies, PHP error-display settings,
+ HTTPS enforcement, and secret provisioning. Add HSTS at the HTTPS server and a
+ tested Content Security Policy; the latter needs work on inline scripts and
+ existing JavaScript links.
+- Perform a production-host invitation/reset test using the configured application
+ URL and sender. Earlier SMTP acceptance and local tests do not establish inbox
+ delivery or the final hosting setup. This review did not test production DNS.
+
+## 7. Release gate — Make backup, monitoring, and deployment repeatable
+
+The verified manual backups prove a backup can be restored; the repository does
+not demonstrate an automated production backup/restore policy, alerting setup, or
+release pipeline. These may exist outside the project and should be verified.
+
+Before launch:
+- Schedule protected backups outside the application document root, define
+ retention and recovery objectives, and periodically restore-test them.
+- Monitor application failures, audit failures, SMTP delivery, database health,
+ disk capacity, and failed backup jobs.
+- Schedule `bin/cleanup-auth.php` as documented; ensure scheduled jobs use the
+ correct environment and credentials.
+- Create a reproducible release with platform-requirement checks, dependency
+ audits, isolated regression tests, migration verification, and a recovery plan.
+- Test representative roster sizes. Club rosters/dues and exports currently load
+ complete result sets; large clubs need paging, query/performance testing, and
+ export limits or background generation as appropriate.
+
+## Product decisions and lower-priority improvements
+
+- All club management currently belongs to global site administrators, who can
+ access every club. This matches the implemented scope. If each club will manage
+ itself, scoped club-administrator roles and cross-club authorization tests are
+ needed before that operating model launches.
+- Active members can see their club's other active members' email addresses
+ (`RosterService.php:22–24`). Confirm that this is the intended directory/privacy
+ policy; consider restricted visibility or opt-in contacts.
+- Add MFA and recent-password confirmation for sensitive administrator actions.
+ These are additional protections, not a claim that current session checks fail.
+- Add season editing with history, clearer payment error recovery, keyboard/mobile
+ usability checks, and repair the garbled username validation text in
+ `AuthService.php:95`. These should follow the security and ledger work.
+
+Recommended order: reset-token invalidation; browser dependency update; durable
+financial history/corrections and audit; verified identity; production deployment
+and operational verification. Resolve the club-administration and directory-privacy
+decisions before enabling real club users.
diff --git a/PROJECT-REPAIRS.md b/PROJECT-REPAIRS.md
new file mode 100644
index 0000000..482eaab
--- /dev/null
+++ b/PROJECT-REPAIRS.md
@@ -0,0 +1,194 @@
+# Project repairs and multi-club setup
+
+Implemented and tested October 18, 2026. Existing authentication work is described
+in [AUTHENTICATION.md](AUTHENTICATION.md).
+
+## Database rollout
+
+The roster migration was applied to the development database on October 18, 2026,
+after the implementation tests and a fresh, verified backup. The development
+verification is recorded below. Existing accounts and passwords were preserved.
+
+After backing up the target database, run from the project directory with the
+application's database configuration and PHP runtime:
+
+```text
+php bin/migrate-roster.php
+```
+
+The migration adds five InnoDB tables: clubs, memberships, seasons, dues, and
+payments. It preserves existing records, can be rerun, and uses foreign keys to
+prevent orphan records and cross-club dues assignments. It does not rebuild the
+database. The authentication migration remains a prerequisite for this version;
+it was already applied to development during the earlier authentication work.
+
+The existing backup was restored locally, its SHA-256 and all ten original table
+counts were checked, then both migrations were rehearsed. Running the roster
+migration twice preserved all ten original counts. The restored copy was removed.
+On other installations, until the roster migration is applied, the home page
+explains that setup is needed.
+
+Use the PHP version and Composer requirements in AUTHENTICATION.md. Report tests
+used the curl, zip, gd, fileinfo, mbstring, openssl, PDO MySQL, and XSL extensions.
+Run Composer's platform requirements check with the web server's PHP configuration.
+
+## Security and deletion safeguards
+
+- Dropdown usage metadata no longer selects arbitrary PHP callbacks or SQL
+ identifiers. Only the supported no-reference rule is accepted; unsupported
+ legacy rules fail closed rather than exposing settings or allowing deletion.
+- IDs and booleans are validated, and dropdown writes use bound parameters.
+ A dropdown must belong to the requested type and be active to become default.
+ Switching defaults is transactional and serialized by type. Deactivation clears
+ the default. Default dropdowns and types containing dropdowns cannot be deleted.
+- Account deletion, disablement, and role changes protect the last active
+ administrator, including simultaneous requests. Administrators cannot delete
+ themselves; ordinary administrators cannot modify System Administrator accounts.
+ Accounts with notes or club memberships must be retained. Reset tokens are
+ removed transactionally when an otherwise unused account is deleted.
+- Status saves validate fields and enforce at most one default; inactive defaults
+ and deletion of the current default are rejected. Removed dependencies on
+ nonexistent requests and workorders tables from usage checks.
+- Notes validate type, text, target, and existing note on the server. Target user
+ locks coordinate note creation with account deletion. Queries are bound and
+ displayed note text is escaped.
+- Routing allows only supported actions, with administrative access enforced on
+ maintenance and exports. Removed arbitrary helper dispatch and obsolete report
+ dispatch. Failures return meaningful HTTP statuses and escaped messages;
+ AJAX no longer treats an empty successful write as invalid JSON.
+
+## Accounts, lists, navigation, and browser behavior
+
+- Administrative account creation validates first and last names, username,
+ email, and role. It stores an unknown random hashed password and sends a setup
+ link. A mail failure reports that the account exists and offers a resend path;
+ retrying cannot silently duplicate the account. No temporary password is exposed.
+- Repaired account summaries, notes, and login history without the missing
+ Subscriptions dependency. Corrected username lookup and invitation resend.
+- Restored audit entries for successful login, reset requests, password resets,
+ and password changes; club saves, dues, and payments also write journal entries.
+ Audit write errors are logged without breaking authentication.
+- Pagination now uses correct last-page offsets, stable ordering, bounded page
+ sizes, empty-list handling, and independent state per list. Fixed incorrect
+ per-page fields, added status page-size refresh, escaped search patterns, and
+ added last-name account search and display.
+- Replaced dead permit, zoning, subscription, support-ticket, and payment links
+ with Clubs and dues, Help, and a working report catalog. Retired three legacy
+ permit report implementations and obsolete metrics dependencies.
+- Converted synchronous browser requests to asynchronous requests and fixed their
+ callers. Save/confirmation controls wait for requests, errors appear visibly,
+ and failed deletion checks never open a confirmation dialog. Default-checkbox
+ failures restore the prior selection.
+- Hidden form inputs are built as DOM text values; toast and error content use
+ text rendering. Cached form restoration excludes hidden routing fields, IDs,
+ CSRF tokens, and passwords. Fixed ZIP lookup routing, logout request handling,
+ tooltip initialization, and jQuery callback compatibility.
+
+## Multi-club roster features
+
+- Site administrators create, rename, archive, and reactivate multiple clubs.
+ They assign existing accounts to separate club memberships, maintain membership
+ status and joined dates, and add dated seasons.
+- Ordinary members see active clubs to which they have active membership, their
+ club's active contact roster, and only their own dues. Changing a URL or form ID
+ cannot grant access to another club. Membership changes take effect in existing
+ sessions. Signup alone grants no club membership.
+- Administrators record season dues and received payments in a **manual USD
+ ledger**. Amounts use integer cents, reject fractional cents and negative values,
+ and payments cannot exceed the remaining balance. Repeated payment submissions
+ use an idempotency key to avoid duplicate entries. Dates and club relationships
+ are checked on the server.
+- Archiving retains membership and financial history and prevents further writes
+ until reactivation. History is not exposed through destructive delete controls.
+- Administrators can export a club roster as CSV, Excel, or PDF. Spreadsheet
+ formula injection is neutralized, Excel cells are explicit text, and PDF fields
+ are escaped.
+
+This is a multi-club foundation managed by global site administrators. It does not
+provide delegated club administrators, online payment processing, refunds, ledger
+correction workflows, or automatic billing. These are separate features.
+
+## Verification
+
+All final suites passed against generated disposable local databases:
+
+| Suite | Result |
+| --- | --- |
+| Authentication service and templates | 49 checks |
+| Authentication HTTP routes | 27 checks |
+| Maintenance validation and pagination units | 52 checks |
+| Project HTTP, access boundaries, ledger, exports, and paging | 88 checks |
+| Headless Chrome workflows | 15 checks |
+| Concurrent default and administrator updates | 2 scenarios |
+
+Total: **231 checks plus 2 concurrency scenarios**. Browser tests verified account
+creation, visible invitation failure, account summary, blocked and successful
+deletion, restricted dropdown metadata, club/membership/season creation, dues,
+payments, and CSV download without unexpected JavaScript errors. Mail failure was
+deliberately simulated; this round did not retest real delivery.
+
+Source checks also passed for 24 application PHP classes, four CLI scripts, four
+PHP test scripts, main.php, 54 JavaScript files, and 39 compiled XSL stylesheets.
+All disposable databases were cleaned up and the isolated database server stopped.
+
+Tests require an explicitly configured isolated MySQL/MariaDB server through
+`HCR_TEST_DSN`, `HCR_TEST_USER`, and `HCR_TEST_PASSWORD`. HTTP tests additionally
+require matching `HCR_DB_HOST`, `HCR_DB_PORT`, `HCR_DB_USER`, and `HCR_DB_PASSWORD`.
+`PHP_TEST_COMMAND` accepts a JSON argument array for the test PHP runtime.
+`NODE_TEST_COMMAND` selects Node and `NODE_PATH` locates Playwright for browser
+tests. Do not point these tests at the application database.
+
+```text
+php tests/auth_integration.php
+php tests/maintenance_unit.php
+python tests/auth_http.py
+python tests/project_http.py
+python tests/concurrency.py
+python tests/browser_runner.py
+```
+
+These tests cover application behavior in isolation; deployment still needs the
+new migration on any other target and validation with its actual PHP, HTTPS, and
+mail configuration.
+
+## Development database rollout and verification
+
+At the user's request, the roster migration was applied to
+`192.168.1.190/huntingclubroster` on October 18, 2026 at 8:23 p.m. Eastern.
+The previously approved unencrypted database connection was used.
+
+Before migration, a fresh complete SQL backup was saved at
+`.tools/backups/20261018-202150/huntingclubroster.sql` (37,317 bytes). Its SHA-256 is
+`f4458cce64404063d9760c2fe06f9779cf8904c0dc27d301541b24486382157a`.
+The backup was restored on an isolated local server; the checksum and row counts
+for all 12 original tables matched. All existing rows were fingerprinted before
+and after migration and were unchanged. Five new roster tables are ready.
+
+Then **54 checks passed against the development database**, through a local HTTP
+application server using three temporary accounts, two temporary clubs, separate
+memberships, three dues records, and received payments. The checks covered:
+
+- Creating multiple clubs, seasons, and memberships, including one account in two
+ clubs and rejection of duplicate memberships and dues.
+- Membership and season boundaries, member financial privacy, administrative
+ permissions, CSRF enforcement, and protection against account deletion.
+- Exact cents, partial payments, correct balances, idempotent payment retries,
+ changed-key-payload rejection, overpayment rejection, and full settlement.
+- Immediate access revocation for inactive membership or archived clubs, blocked
+ writes while archived, and successful reactivation with preserved ledger data.
+- Valid CSV, Excel, and PDF downloads.
+
+An initial verification-helper session setup error was corrected before the
+successful run; its temporary records were also removed. This required no change
+to application code.
+
+All temporary accounts, club records, dues, payments, test audit entries, rate
+limit records, and sessions were removed afterward. Every row across all 17
+tables matched the pre-test fingerprint. Auto-increment sequences may have
+advanced during testing. No existing passwords were changed and no emails or
+online payment transactions were sent. These are manually recorded payments.
+
+Private backup metadata, migration results, and the check-by-check verification
+report are retained alongside the backup in `.tools/backups/20261018-202150/`.
+That directory is ignored by Git and blocked by the development router. The local
+restore copy was removed and the isolated local database server stopped.
diff --git a/README.md b/README.md
index fa3e5cf..8a0ec18 100644
--- a/README.md
+++ b/README.md
@@ -3,3 +3,9 @@
See [authentication setup and changes](AUTHENTICATION.md) before deploying the
updated login, signup, and password reset flows. The authentication migration
must be applied to the existing database before using this version.
+
+See [project repairs and multi-club setup](PROJECT-REPAIRS.md) for the security
+fixes, new roster migration, feature scope, and verification results.
+
+See [production safeguard changes](PRODUCTION-FIXES.md) for the newest migration,
+verified-email requirement, ledger corrections, and deployment instructions.
diff --git a/bin/backup-database.php b/bin/backup-database.php
new file mode 100644
index 0000000..ab39689
--- /dev/null
+++ b/bin/backup-database.php
@@ -0,0 +1,28 @@
+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);
+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';
+$partial=$file.'.partial';$handle=fopen($partial,'x');if(!$handle)throw new RuntimeException('Cannot create protected backup output.');fclose($handle);chmod($partial,0600);
+$config=tempnam($directory,'hcr-connection-');chmod($config,0600);
+$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";
+$ca=getenv('HCR_DB_TLS_CA');if($ca)$options.='ssl-ca='.$quote($ca)."\nssl-verify-server-cert\n";
+try{
+ 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];
+ $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.');
+ 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(!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(!rename($partial,$file))throw new RuntimeException('Cannot finalize completed backup.');
+ 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);
+ echo json_encode(['backup'=>$file]+$metadata),"\n";
+}finally{if(is_file($config))unlink($config);if(is_file($partial))unlink($partial);}
diff --git a/bin/build-release.py b/bin/build-release.py
new file mode 100644
index 0000000..a14a8f3
--- /dev/null
+++ b/bin/build-release.py
@@ -0,0 +1,27 @@
+"""Build a clean deployment zip from current workspace files, including new files."""
+import argparse,hashlib,json,zipfile
+from pathlib import Path
+
+ROOT=Path(__file__).resolve().parents[1]
+parser=argparse.ArgumentParser();parser.add_argument('output',type=Path);args=parser.parse_args()
+output=args.output.resolve()
+if output.exists():raise SystemExit('Output already exists; choose a new release filename.')
+files=[]
+for directory in ['classes','css','images','js','html','migrations','reports','vendor','views','xsl']:
+ 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 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']:
+ 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']:
+ file=ROOT/'bin'/name
+ if file.exists():files.append(file)
+if not (ROOT/'vendor/autoload.php').is_file():raise SystemExit('Install locked Composer dependencies before building a release.')
+manifest={file.relative_to(ROOT).as_posix():hashlib.sha256(file.read_bytes()).hexdigest() for file in sorted(set(files))}
+for name in manifest:
+ if name.startswith(('.tools/','.git/','tests/','nbproject/')) or name.endswith('settings.local.xml'):raise SystemExit('Private file would enter the release.')
+output.parent.mkdir(parents=True,exist_ok=True)
+with zipfile.ZipFile(output,'x',zipfile.ZIP_DEFLATED) as archive:
+ for name in manifest:archive.writestr(name,(ROOT/name).read_bytes())
+ archive.writestr('release-manifest.json',json.dumps(manifest,indent=2))
+print(json.dumps({'release':str(output),'files':len(manifest),'sha256':hashlib.sha256(output.read_bytes()).hexdigest()}))
diff --git a/bin/check-health.php b/bin/check-health.php
new file mode 100644
index 0000000..ae0e0e0
--- /dev/null
+++ b/bin/check-health.php
@@ -0,0 +1,9 @@
+connect();$db->query('SELECT 1')->fetchColumn();
+ if(!(new RosterService())->ready())throw new RuntimeException('Required 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";
+}catch(Throwable $e){fwrite(STDERR,"Health check failed. Review the protected server logs.\n");error_log((string)$e);exit(1);}
diff --git a/bin/check-production.py b/bin/check-production.py
new file mode 100644
index 0000000..1fda972
--- /dev/null
+++ b/bin/check-production.py
@@ -0,0 +1,22 @@
+"""Read-only checks of a deployed HTTPS host; never sends account email or writes data."""
+import argparse,http.cookiejar,ssl,urllib.error,urllib.parse,urllib.request
+parser=argparse.ArgumentParser();parser.add_argument('url');args=parser.parse_args()
+url=args.url.rstrip('/');parts=urllib.parse.urlsplit(url)
+if parts.scheme!='https' or parts.query or parts.fragment or parts.username:raise SystemExit('Use the trusted HTTPS application base URL.')
+client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),urllib.request.HTTPSHandler(context=ssl.create_default_context()))
+def get(path):
+ try:
+ with client.open(url+'/'+path,timeout=25) as response:return response.status,response.headers,response.read(),response.geturl()
+ except urllib.error.HTTPError as error:return error.code,error.headers,error.read(),error.geturl()
+failures=[]
+status,headers,body,final=get('main.php')
+if status!=200 or urllib.parse.urlsplit(final).scheme!='https':failures.append('HTTPS login page unavailable.')
+for name in ['Strict-Transport-Security','Content-Security-Policy','X-Content-Type-Options','Referrer-Policy','X-Frame-Options']:
+ if not headers.get(name):failures.append('Missing '+name)
+cookie=';'.join(headers.get_all('Set-Cookie') or [])
+if not all(flag in cookie.lower() for flag in ['secure','httponly','samesite=lax']):failures.append('Session cookie flags are missing.')
+for path in ['xml/settings.local.xml','xml/settings.xml','.tools/backups/probe.sql','.git/config','vendor/autoload.php','bin/migrate-production.php','classes/Base.php','tests/http_fixture.php','composer.lock','release-manifest.json']:
+ if get(path)[0] not in [403,404]:failures.append('Private path was not denied: '+path)
+for failure in failures:print('FAIL: '+failure)
+if failures:raise SystemExit(1)
+print('HTTPS, security headers, cookies, and private-path checks passed. Verify inbox delivery and operational jobs separately.')
diff --git a/bin/cleanup-auth.php b/bin/cleanup-auth.php
index 77585c0..52745ad 100644
--- a/bin/cleanup-auth.php
+++ b/bin/cleanup-auth.php
@@ -3,5 +3,6 @@ if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
require_once __DIR__ . '/../classes/Base.php';
$db = (new Base())->connect();
$db->exec('DELETE FROM password_resets WHERE expires_at < UTC_TIMESTAMP()');
+$db->exec('DELETE FROM email_verifications WHERE expires_at < UTC_TIMESTAMP()');
$db->exec('DELETE FROM auth_attempts WHERE window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)');
echo "Expired authentication records removed.\n";
diff --git a/bin/migrate-production.php b/bin/migrate-production.php
new file mode 100644
index 0000000..c547a2b
--- /dev/null
+++ b/bin/migrate-production.php
@@ -0,0 +1,22 @@
+connect();
+foreach([
+ 'users'=>['user_email_verified_at'=>'DATETIME NULL'],
+ 'club_dues'=>['recorded_by'=>'BIGINT UNSIGNED NULL'],
+ 'club_payments'=>['recorded_by'=>'BIGINT UNSIGNED NULL','payment_method'=>"VARCHAR(20) NOT NULL DEFAULT 'unspecified'",'payment_reference'=>"VARCHAR(100) NOT NULL DEFAULT ''"],
+] as $table=>$columns){
+ $existing=$db->query('SHOW COLUMNS FROM `'.$table.'`')->fetchAll(PDO::FETCH_COLUMN);
+ foreach($columns as $column=>$definition)if(!in_array($column,$existing,true))$db->exec('ALTER TABLE `'.$table.'` ADD COLUMN `'.$column.'` '.$definition);
+}
+// Mandatory audit writes must participate in financial transactions.
+$db->exec('ALTER TABLE journal ENGINE=InnoDB');
+$db->exec('ALTER TABLE journal MODIFY journal_entry TEXT NULL');
+foreach(['club_dues','club_payments'] as $table){
+ $stmt=$db->prepare("SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME='recorded_by' AND REFERENCED_TABLE_NAME='users'");$stmt->execute([$table]);
+ if(!(int)$stmt->fetchColumn())$db->exec('ALTER TABLE `'.$table.'` ADD FOREIGN KEY (recorded_by) REFERENCES users(user_serial) ON DELETE RESTRICT');
+}
+foreach(explode(';',file_get_contents(__DIR__.'/../migrations/003_production.sql')) as $sql)if(trim($sql))$db->exec($sql);
+require __DIR__.'/../views/view_users.php';$db->exec(str_replace('create view','CREATE OR REPLACE VIEW',$SQL));
+echo "Production safeguards schema is ready. Existing emails remain unverified until ownership is proven.\n";
diff --git a/bin/migrate-roster.php b/bin/migrate-roster.php
new file mode 100644
index 0000000..10d6a3c
--- /dev/null
+++ b/bin/migrate-roster.php
@@ -0,0 +1,6 @@
+connect();
+foreach(explode(';',file_get_contents(__DIR__.'/../migrations/002_roster.sql')) as $sql){if(trim($sql))$db->exec($sql);}
+echo "Club roster schema is ready. Existing accounts and administrative records were preserved.\n";
diff --git a/browser-dependencies.json b/browser-dependencies.json
new file mode 100644
index 0000000..c7b2a3e
--- /dev/null
+++ b/browser-dependencies.json
@@ -0,0 +1,11 @@
+{
+ "jquery": {
+ "version": "3.7.1",
+ "file": "js/jquery-3.7.1.min.js",
+ "source": "https://code.jquery.com/jquery-3.7.1.min.js",
+ "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."
+ },
+ "bootstrap": {"file": "js/bootstrap.bundle.min.js", "version": "5.3.8"},
+ "bootstrap-datepicker": {"file": "js/bootstrap-datepicker.min.js", "version": "1.9.0"}
+}
diff --git a/classes/AdminAccounts.php b/classes/AdminAccounts.php
new file mode 100644
index 0000000..ae3c6b5
--- /dev/null
+++ b/classes/AdminAccounts.php
@@ -0,0 +1,39 @@
+ 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))
+ throw new AppException('Enter a valid username, first/last name, email and role.');
+ $db = $this->connect();
+ $stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (?,?) OR user_email IN (?,?)');
+ $stmt->execute([$name, $email, $email, $name]);
+ if ($stmt->fetch())
+ throw new AppException('An account already uses those details. Use the existing account or resend its invitation.', 409);
+ $db->beginTransaction();
+ try {
+ $stmt = $db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role,user_status,user_change_password,user_creator) VALUES(?,?,?,?,?,?,'active',1,?)");
+ $stmt->execute([$name, $first, $last, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $role, $_SESSION[self::USER_NAME] ?? '']);
+ $id = (int) $db->lastInsertId();
+ Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'created account ' . json_encode(['user' => $id, 'name' => $name, 'role' => $role]));
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ $warning = '';
+ try {
+ if (!(new AuthService())->requestReset($email))
+ $warning = 'Account created, but the invitation is rate limited. Open the user summary to resend it later.';
+ } catch (Throwable $e) {
+ error_log('Invitation delivery failed: ' . $e->getMessage());
+ $warning = 'Account created, but its invitation could not be sent. Open the user summary to resend it after checking email settings.';
+ }
+ return ['ok' => true, 'user_serial' => $id, 'message' => 'Account created.', 'warning' => $warning];
+ }
+}
diff --git a/classes/Alerts.php b/classes/Alerts.php
index 9d14542..895adec 100644
--- a/classes/Alerts.php
+++ b/classes/Alerts.php
@@ -28,7 +28,7 @@ class Alerts extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
- $this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
+ $this->pagination_page = 1;
}
// ============
@@ -267,7 +267,7 @@ class Alerts extends Base {
// Find Criteria
$find_alert = filter_input(INPUT_POST, "find_alert") ?: "";
- $find_words = $this->sanitizeString($find_alert);
+ $find_words = preg_quote(trim($find_alert));
// Show Per Page
@@ -295,28 +295,17 @@ class Alerts extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
- switch ($pagination) {
-
- case "first" : $this->pagination_page = 1;
- break;
- case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
- break;
- case "next" : $this->pagination_page = ($this->pagination_page + 1);
- break;
- case "last" : $this->pagination_page = ($count / $this->pagination_size);
- break;
- }
-
- $offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
- $start = ($offset + 1);
- $end = min($count, (($start + $this->pagination_size) - 1));
+ $paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
+ $this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
+ $offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
$SQL = "select view_alerts.*
from view_alerts
where alert_title regexp :alert_title
- limit {$this->pagination_size}
+ order by alert_serial
+ limit {$this->pagination_size}
offset {$offset}";
$statement = $connection->prepare($SQL);
@@ -334,7 +323,6 @@ class Alerts extends Base {
$alerts->addAttribute("end", $end);
$alerts->addAttribute("page", $this->pagination_page);
- $_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $alerts;
}
diff --git a/classes/AppException.php b/classes/AppException.php
new file mode 100644
index 0000000..af2edbe
--- /dev/null
+++ b/classes/AppException.php
@@ -0,0 +1,4 @@
+getMessage());
+ }
+ }
+
+ public static function required(PDO $db, int $user, string $event): void {
+ if (!$db->inTransaction())
+ throw new LogicException('Required audit writes need a transaction.');
+ self::insert($db, $user, $event);
+ }
+
+ public static function insert(PDO $db, int $user, string $event): void {
+ $stmt = $db->prepare('INSERT INTO journal(journal_origin,journal_ip,journal_entry) VALUES(?,?,?)');
+ $stmt->execute([(string) $user, $_SERVER['REMOTE_ADDR'] ?? '', $user . ' ' . $event]);
+ }
+}
diff --git a/classes/AuthService.php b/classes/AuthService.php
index 32e0057..5838c6d 100644
--- a/classes/AuthService.php
+++ b/classes/AuthService.php
@@ -61,6 +61,7 @@ class AuthService extends Base {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id');
$stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]);
+ Audit::record($this->connect(),(int)$user['user_serial'],'logged in ('.$user['user_role'].')');
return true;
}
@@ -92,7 +93,7 @@ class AuthService extends Base {
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100)
throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).');
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name))
- throw new InvalidArgumentException('Use 3–64 letters, numbers, dots, underscores or hyphens for your username.');
+ throw new InvalidArgumentException('Use 3 to 64 letters, numbers, dots, underscores or hyphens for your username.');
$error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? '');
if ($error)
throw new InvalidArgumentException($error);
@@ -113,14 +114,14 @@ class AuthService extends Base {
}
/** Never modify the password when somebody merely requests an email. */
- public function requestReset(string $identifier): void {
+ public function requestReset(string $identifier): bool {
$ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600);
$accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600);
if (!$ipAllowed || !$accountAllowed)
- return;
+ return false;
$user = $this->findUser($identifier);
if (!$user || $user['user_status'] !== 'active')
- return;
+ return false;
$baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https'
|| parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null
@@ -129,15 +130,27 @@ class AuthService extends Base {
}
$token = bin2hex(random_bytes(32));
$db = $this->connect();
- $stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
$hash = hash('sha256', $token);
- $stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]);
+ $db->beginTransaction();
+ try {
+ // Share the user-row lock with account edits and password changes. A
+ // request resolved before such a change must not mint a token afterward.
+ $stmt=$db->prepare('SELECT user_email,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
+ $stmt->execute([$user['user_serial']]);$current=$stmt->fetch();
+ if(!$current || $current['user_status']!=='active' || $current['user_email']!==$user['user_email'] || (int)$current['user_auth_version']!==(int)$user['user_auth_version']){
+ $db->rollBack();return false;
+ }
+ $stmt=$db->prepare('INSERT INTO password_resets (token_hash,user_serial,expires_at) VALUES (?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 30 MINUTE))');
+ $stmt->execute([$hash,$user['user_serial']]);$db->commit();
+ }catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
$url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token);
if (!$this->deliverResetEmail($user, $url)) {
$stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash');
$stmt->execute([':hash' => $hash]);
throw new RuntimeException('Password reset email delivery failed.');
}
+ Audit::record($db,(int)$user['user_serial'],'requested password reset');
+ return true;
}
protected function deliverResetEmail(array $user, string $url): bool {
@@ -174,7 +187,9 @@ class AuthService extends Base {
return false;
}
$this->storePassword($db, (int) $id, $password);
+ $stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');$stmt->execute([$id]);
$db->commit();
+ Audit::record($db,(int)$id,'reset password');
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
@@ -199,6 +214,7 @@ class AuthService extends Base {
}
$this->storePassword($db, $id, $password);
$db->commit();
+ Audit::record($db,$id,'changed password');
return true;
} catch (Throwable $e) {
if ($db->inTransaction())
diff --git a/classes/Base.php b/classes/Base.php
index 3f68928..de16df4 100644
--- a/classes/Base.php
+++ b/classes/Base.php
@@ -13,7 +13,7 @@ class Base {
// Constants
// =========
- const APPLICATION_NAME = "Hunting Club Roster";
+ const APPLICATION_NAME = "My Hunting Club Roster";
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
const VERSION = "1.00";
const TIMEZONE = "America/New_York";
@@ -136,11 +136,16 @@ class Base {
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, (int) ($settings->DatabasePort ?? 3306), $database);
- return new PDO($dsn, $username, $password,
- [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ $options=[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
- PDO::ATTR_PERSISTENT => false,]);
+ PDO::ATTR_PERSISTENT => false,];
+ $ca=getenv('HCR_DB_TLS_CA')?:'';
+ if((string)$settings->Environment==='Production'&&!in_array(strtolower($host),['localhost','127.0.0.1','::1'],true)&&!$ca)throw new RuntimeException('Remote production database connections require HCR_DB_TLS_CA and verified TLS.');
+ if($ca){if(!is_readable($ca))throw new RuntimeException('The database TLS CA file is not readable.');$options[PDO::MYSQL_ATTR_SSL_CA]=$ca;$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT]=true;}
+ $db=new PDO($dsn,$username,$password,$options);
+ if($ca){$cipher=$db->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")->fetch();if(!$cipher||empty($cipher['Value']))throw new RuntimeException('The database connection did not negotiate TLS.');}
+ return $db;
} catch (PDOException $e) {
error_log("Database Connection Failed: " . $e->getMessage());
@@ -216,9 +221,7 @@ class Base {
// =====================
public function displayHome() {
-
- $content = $this->applyXSL("", $this->getXSL("home"));
- $this->displayContent($content);
+ (new Roster())->home();
}
// =======================
@@ -250,6 +253,7 @@ class Base {
}
}
$action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
+ if($action==='verifyemail'){(new EmailVerification())->page();return;}
if ($action === 'signout') {
if ($method !== 'POST') {
http_response_code(405);
@@ -275,9 +279,9 @@ class Base {
if (in_array($action, ['signin', 'signin.login', 'users.login'], true)) {
(new SignIn())->login();
}
-
+
$user = (new AuthService())->currentUser();
-
+
if (!$user) {
$ajax = $_SESSION[self::AJAX];
if (!empty($_SESSION[self::USER_AUTHENTICATED])) {
@@ -296,6 +300,7 @@ class Base {
(new Users())->changePassword();
return;
}
+
// Only named, reviewed entry points are routable. Helpers are never endpoints.
$routes = [
'' => [Base::class, 'displayHome', false],
@@ -306,24 +311,31 @@ class Base {
'validatedate' => [Base::class, 'validateDate', false],
'getage' => [Base::class, 'getAge', false],
'users.changepassword' => [Users::class, 'changePassword', false],
- 'print' => [Base::class, 'print', true],
- 'print_all_permitsbybuilder.print' => ['Print_All_PermitsByBuilder', 'print', true],
- 'export_allpermitsbybuildercsv.export' => ['Export_AllPermitsByBuilderCSV', 'export', true],
- 'export_allpermitsbybuilderexcel.export' => ['Export_AllPermitsByBuilderExcel', 'export', true],
+ 'roster.workspace' => [Roster::class,'workspace',false],
+ 'roster.help' => [Roster::class,'help',false],
+ 'rosterreports.ledger' => [RosterReports::class,'ledger',true],
+ 'rosterreports.csv' => [RosterReports::class,'csv',true],
+ 'rosterreports.excel' => [RosterReports::class,'excel',true],
+ 'rosterreports.pdf' => [RosterReports::class,'pdf',true],
+
+
+
+
];
$adminRoutes = [
- 'Users' => ['setupUsers', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
+ 'Users' => ['setupUsers', 'userSummary', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
'Alerts' => ['setupAlerts', 'addAlert', 'editAlert', 'deleteAlert'],
'Dropdowns' => ['setupDropdowns', 'setupDropdowntypes', 'addDropdown', 'editDropdown', 'deleteDropdown', 'addDropdowntype', 'editDropdowntype', 'deleteDropdowntype', 'dropdownExists', 'dropdowntypeExists', 'dropdowntypetitleExists', 'dropdownActive', 'dropdowntypeActive', 'updateDropdownActive', 'updateDropdownDefault'],
'Popovers' => ['setupPopovers', 'addPopover', 'editPopover', 'deletePopover', 'popoverExists'],
- 'Reports' => ['setupReports', 'addReport', 'editReport', 'deleteReport', 'selectReport', 'reportExists'],
+ 'Reports' => ['setupReports', 'selectReport'],
'Statuses' => ['setupStatuses', 'addStatus', 'editStatus', 'deleteStatus', 'statusExists', 'statusActive'],
- 'Journal' => ['viewJournal', 'clearJournal'],
- 'Notes' => ['addNote', 'editNote', 'deleteNote', 'addNoteQuick'],
+ 'Journal' => ['viewJournal'],
+ 'Notes' => ['addNote', 'editNote', 'deleteNote'],
];
foreach ($adminRoutes as $class => $methods) {
- foreach ($methods as $entry)
+ foreach ($methods as $entry) {
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, true];
+ }
}
if (!isset($routes[$action])) {
http_response_code(404);
@@ -334,11 +346,15 @@ class Base {
http_response_code(403);
exit('You do not have permission to perform this action.');
}
- if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword'], true)) {
+ if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.help'], true)) {
http_response_code(405);
exit('Use POST for this action.');
}
- (new $class())->$entry();
+ ob_start();
+ try{(new $class())->$entry();$body=ob_get_clean();}
+ catch(Throwable $e){ob_end_clean();throw $e;}
+ if($_SESSION[self::AJAX]??false){header('Content-Type: application/json');echo trim($body)===''?json_encode(['ok'=>true]):$body;}
+ else echo $body;
}
public function csrfToken(): string {
@@ -420,7 +436,7 @@ class Base {
public function getSettings() {
- $path = getenv('HCR_SETTINGS_FILE') ?: (is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
+ $path = getenv('HCR_SETTINGS_FILE') ?: (getenv('HCR_ENVIRONMENT')!=='Production' && is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
$settings = simplexml_load_file($path, SimpleXMLElement::class, LIBXML_NONET);
if (!$settings)
throw new RuntimeException('Unable to load application settings.');
@@ -673,12 +689,8 @@ class Base {
// ============
public function logError($error_message = "") {
-
- (new Journal())->journalEntry($error_message);
-
- $this->displayHome();
-
- exit();
+ error_log($error_message);
+ throw new AppException('The requested record is invalid or unavailable.',404);
}
// ==========================
@@ -725,20 +737,6 @@ class Base {
// Print a Report
// ==============
- public function print() {
-
- $_SESSION[self::REPORTS_GET] = $_GET;
- $_SESSION[self::REPORTS_POST] = $_POST;
-
- $report = filter_input(INPUT_POST, "report") ?: "";
-
- if ($report !== 'Print_All_PermitsByBuilder') {
- http_response_code(404);
- exit('Report not found.');
- }
- (new $report())->print();
- }
-
// =================
// Sanitize a string
// =================
diff --git a/classes/Dropdowns.php b/classes/Dropdowns.php
index f46dbda..eeaa053 100644
--- a/classes/Dropdowns.php
+++ b/classes/Dropdowns.php
@@ -28,7 +28,7 @@ class Dropdowns extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
- $this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
+ $this->pagination_page = 1;
}
// ============
@@ -63,6 +63,7 @@ class Dropdowns extends Base {
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
+ $dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
$SQL = "insert into dropdowns
@@ -117,6 +118,8 @@ class Dropdowns extends Base {
$dropdowntype_title = filter_input(INPUT_POST, "dropdowntype_title") ?: "";
$dropdowntype_table = filter_input(INPUT_POST, "dropdowntype_table") ?: "";
$dropdowntype_column = filter_input(INPUT_POST, "dropdowntype_column") ?: "";
+ MaintenanceService::usageRule($dropdowntype_table, $dropdowntype_column);
+ if(!trim($dropdowntype_name)||!trim($dropdowntype_title)||strlen($dropdowntype_name)>100||strlen($dropdowntype_title)>100)throw new AppException('Enter a dropdown type name and title up to 100 bytes.');
$SQL = "insert into dropdowntypes
@@ -158,19 +161,7 @@ class Dropdowns extends Base {
// ===============
public function deleteDropdown() {
-
- $dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $dropdown = $this->getDropdown($dropdown_serial);
-
- if ($dropdown->count() == 0) {
- $this->logError("Invalid Dropdown ({$dropdown_serial}). " . __METHOD__);
- }
-
- $SQL = "delete from dropdowns
- where dropdown_serial = {$dropdown_serial}";
-
- $this->executeSQL($SQL);
+ (new MaintenanceService())->deleteDropdown(MaintenanceService::id($_POST['dropdown_serial']??null));
}
// ===================
@@ -178,19 +169,7 @@ class Dropdowns extends Base {
// ===================
public function deleteDropdowntype() {
-
- $dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $dropdowntype = $this->getDropdowntype($dropdowntype_serial);
-
- if ($dropdowntype->count() == 0) {
- $this->logError("Invalid Dropdowntype ({$dropdowntype_serial}). " . __METHOD__);
- }
-
- $SQL = "delete from dropdowntypes
- where dropdowntype_serial = {$dropdowntype_serial}";
-
- $this->executeSQL($SQL);
+ (new MaintenanceService())->deleteType(MaintenanceService::id($_POST['dropdowntype_serial']??null));
}
// ==============================
@@ -202,6 +181,7 @@ class Dropdowns extends Base {
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
+ $dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
$dropdown_value = strtolower($dropdown_value);
@@ -291,48 +271,8 @@ class Dropdowns extends Base {
// =================================
public function dropdownActive() {
-
- $dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $dropdown = $this->getDropdown($dropdown_serial);
-
- if ($dropdown->count() == 0) {
- $this->logError("Invalid Dropdown ({$dropdown_serial}). " . __METHOD__);
- }
-
- $table = (string) $dropdown->record->dropdowntype_table;
- $column = (string) $dropdown->record->dropdowntype_column;
-
- $active = false;
-
- switch ($table) {
-
- case "*none": // No table, never Active.
-
- break;
-
- case "*function": // Requires it's own function to determine if Active.
-
- if (method_exists(__CLASS__, $column)) {
- $active = $this->$column($dropdown_serial);
- }
-
- break;
-
- default: // All others, test the Column in the Table.
-
- $SQL = "select {$column}
- from {$table}
- where {$column} = {$dropdown_serial}";
-
- $results = $this->getTable("results", $SQL);
-
- $active = ($results->count() > 0) ? true : false;
-
- break;
- }
-
- echo json_encode($active);
+ $id=MaintenanceService::id($_POST['dropdown_serial']??null);
+ echo json_encode((new MaintenanceService())->dropdownUsed($id));
}
// =====================================
@@ -386,6 +326,7 @@ class Dropdowns extends Base {
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
+ $dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
$dropdown = $this->getDropdown($dropdown_serial);
@@ -451,6 +392,8 @@ class Dropdowns extends Base {
$dropdowntype_title = filter_input(INPUT_POST, "dropdowntype_title") ?: "";
$dropdowntype_table = filter_input(INPUT_POST, "dropdowntype_table") ?: "";
$dropdowntype_column = filter_input(INPUT_POST, "dropdowntype_column") ?: "";
+ MaintenanceService::usageRule($dropdowntype_table, $dropdowntype_column);
+ if(!trim($dropdowntype_name)||!trim($dropdowntype_title)||strlen($dropdowntype_name)>100||strlen($dropdowntype_title)>100)throw new AppException('Enter a dropdown type name and title up to 100 bytes.');
$dropdowntype = $this->getDropdowntype($dropdowntype_serial);
@@ -559,11 +502,11 @@ class Dropdowns extends Base {
// Find Criteria
$find_dropdowntype = filter_input(INPUT_POST, "find_dropdowntype") ?: "";
- $find_words = $this->sanitizeString($find_dropdowntype);
+ $find_words = preg_quote(trim($find_dropdowntype));
// Show Per Page
- $this->pagination_size = filter_input(INPUT_POST, "users_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
+ $this->pagination_size = filter_input(INPUT_POST, "dropdowntypes_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
// Record Count
@@ -593,21 +536,9 @@ class Dropdowns extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
- switch ($pagination) {
-
- case "first" : $this->pagination_page = 1;
- break;
- case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
- break;
- case "next" : $this->pagination_page = ($this->pagination_page + 1);
- break;
- case "last" : $this->pagination_page = ($count / $this->pagination_size);
- break;
- }
-
- $offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
- $start = ($offset + 1);
- $end = min($count, (($start + $this->pagination_size) - 1));
+ $paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
+ $this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
+ $offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -617,6 +548,7 @@ class Dropdowns extends Base {
or dropdowntype_title regexp :dropdowntype_title
or dropdowntype_table regexp :dropdowntype_table
or dropdowntype_column regexp :dropdowntype_column)
+ order by dropdowntype_name,dropdowntype_serial
limit {$this->pagination_size}
offset {$offset}";
@@ -638,7 +570,6 @@ class Dropdowns extends Base {
$dropdowntypes->addAttribute("end", $end);
$dropdowntypes->addAttribute("page", $this->pagination_page);
- $_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $dropdowntypes;
}
@@ -682,24 +613,7 @@ class Dropdowns extends Base {
// ======================
public function updateDropdownActive() {
-
- $dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $dropdown_checked = filter_input(INPUT_POST, "dropdown_checked", FILTER_VALIDATE_BOOLEAN) ?: false;
-
- $SQL = "update dropdowns
- set dropdown_active = :dropdown_active,
- dropdown_changer = :dropdown_changer,
- dropdown_changed = :dropdown_changed
- where dropdown_serial = :dropdown_serial";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":dropdown_serial", $dropdown_serial);
- $statement->bindValue(":dropdown_active", $dropdown_checked, PDO::PARAM_BOOL);
- $statement->bindValue(":dropdown_changer", $this->current_user_name);
- $statement->bindValue(":dropdown_changed", $this->getTimestamp());
-
- $statement->execute();
+ (new MaintenanceService())->setDropdownActive(MaintenanceService::id($_POST['dropdown_serial']??null),MaintenanceService::boolean($_POST['dropdown_checked']??null));
}
// =======================
@@ -707,26 +621,7 @@ class Dropdowns extends Base {
// =======================
public function updateDropdownDefault() {
-
- $dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $dropdown_dropdowntype = filter_input(INPUT_POST, "dropdown_dropdowntype", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $dropdown_checked = filter_input(INPUT_POST, "dropdown_checked") ?: "false";
-
- // Unset the Default for the Dropdown Type.
-
- $SQL = "update dropdowns
- set dropdown_default = false
- where dropdown_dropdowntype = {$dropdown_dropdowntype}";
-
- $this->executeSQL($SQL);
-
- // Set the Default for the clicked Dropdown.
-
- $SQL = "update dropdowns
- set dropdown_default = {$dropdown_checked}
- where dropdown_serial = {$dropdown_serial}";
-
- $this->executeSQL($SQL);
+ (new MaintenanceService())->setDropdownDefault(MaintenanceService::id($_POST['dropdown_serial']??null),MaintenanceService::id($_POST['dropdown_dropdowntype']??null),MaintenanceService::boolean($_POST['dropdown_checked']??null));
}
}
diff --git a/classes/EmailVerification.php b/classes/EmailVerification.php
new file mode 100644
index 0000000..376960e
--- /dev/null
+++ b/classes/EmailVerification.php
@@ -0,0 +1,114 @@
+allowAttempt('verify-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600) || !$auth->allowAttempt('verify-account', $identifier, 3, 3600))
+ return false;
+ $user = $auth->findUser($identifier);
+ if (!$user || $user['user_status'] !== 'active' || $user['user_email_verified_at'])
+ return false;
+ $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 application URL.');
+ $token = bin2hex(random_bytes(32));
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
+ $stmt->execute([$user['user_serial']]);
+ $current = $stmt->fetch();
+ if (!$current || $current['user_status'] !== 'active' || $current['user_email_verified_at'] || $current['user_email'] !== $user['user_email'] || (int) $current['user_auth_version'] !== (int) $user['user_auth_version']) {
+ $db->rollBack();
+ return false;
+ }
+ $stmt = $db->prepare('INSERT INTO email_verifications(token_hash,user_serial,email,auth_version,expires_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 24 HOUR))');
+ $stmt->execute([hash('sha256', $token), $user['user_serial'], $user['user_email'], $user['user_auth_version']]);
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ if (!$this->deliver($user, $url . '/main.php?Xaction=verifyemail&token=' . $token)) {
+ $stmt = $db->prepare('DELETE FROM email_verifications WHERE token_hash=?');
+ $stmt->execute([hash('sha256', $token)]);
+ throw new RuntimeException('Verification email could not be delivered.');
+ }
+ return true;
+ }
+
+ protected function deliver(array $user, string $url): bool {
+ $h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
+ return (new Email())->sendEmail($user['user_email'], self::APPLICATION_NAME . ' - Verify your email', '
Verify the email address for account ' . $h($user['user_name']) . '.
The link expires in 24 hours. You must also enter the account password. If you did not create this account, ignore this message or use password reset to secure it.
');
+ }
+
+ public function confirm(string $token, string $password): bool {
+ if (!preg_match('/\A[a-f0-9]{64}\z/', $token))
+ return false;
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $stmt = $db->prepare('SELECT user_serial FROM email_verifications WHERE token_hash=?');
+ $stmt->execute([hash('sha256', $token)]);
+ $id = $stmt->fetchColumn();
+ if (!$id) {
+ $db->rollBack();
+ return false;
+ }
+ $stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ $user = $stmt->fetch();
+ $stmt = $db->prepare('SELECT * FROM email_verifications WHERE token_hash=? AND expires_at>UTC_TIMESTAMP() FOR UPDATE');
+ $stmt->execute([hash('sha256', $token)]);
+ $row = $stmt->fetch();
+ if (!$row || !$user || $user['user_status'] !== 'active' || $row['email'] !== $user['user_email'] || (int) $row['auth_version'] !== (int) $user['user_auth_version'] || !password_verify($password, $user['user_password_hash'])) {
+ $db->rollBack();
+ return false;
+ }
+ $stmt = $db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');
+ $stmt->execute([$id]);
+ $stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
+ $stmt->execute([$id]);
+ Audit::required($db, (int) $id, 'verified email ownership');
+ $db->commit();
+ return true;
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+
+ public function page(): void {
+ $token = $_POST['token'] ?? $_GET['token'] ?? '';
+ $message = '';
+ $complete = false;
+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+ $this->requireCsrf();
+ if (($_POST['step'] ?? '') === 'confirm') {
+ $allowed = (new AuthService())->allowAttempt('verify-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900);
+ $complete = $allowed && $this->confirm($token, $_POST['user_password'] ?? '');
+ $message = $complete ? 'Email verified. You can now be assigned to a club.' : 'Unable to verify. Check the password or request a fresh link. You can also reset your password to prove mailbox ownership.';
+ } else {
+ try {
+ $this->request(trim($_POST['user_email'] ?? ''));
+ } catch (Throwable $e) {
+ error_log('Email verification failed: ' . $e->getMessage());
+ }
+ $message = 'If an active account needs verification, a link will be sent. You can also reset your password to verify its email.';
+ }
+ }
+ $h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
+ echo 'Verify email
';
+ exit;
+ }
+}
diff --git a/classes/Journal.php b/classes/Journal.php
index 62ccc18..7b29b4f 100644
--- a/classes/Journal.php
+++ b/classes/Journal.php
@@ -27,7 +27,7 @@ class Journal extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
- $this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
+ $this->pagination_page = 1;
}
@@ -37,12 +37,8 @@ class Journal extends Base {
public function clearJournal() {
- $SQL = "truncate journal";
-
- $this->executeSQL($SQL);
-
- $this->journalEntry("Journal was Cleared.");
-
+ throw new AppException('Audit history cannot be cleared through the application.',403);
+
}
// =======================
@@ -70,7 +66,7 @@ class Journal extends Base {
// Find Criteria
$find_journal = filter_input(INPUT_POST, "find_journal") ?: "";
- $find_words = $this->sanitizeString($find_journal);
+ $find_words = preg_quote(trim($find_journal));
// Show Per Page
@@ -103,19 +99,10 @@ class Journal extends Base {
// Pagination
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
-
- switch ($pagination) {
-
- case "first" : $this->pagination_page = 1; break;
- case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1); break;
- case "next" : $this->pagination_page = ($this->pagination_page + 1); break;
- case "last" : $this->pagination_page = ($count / $this->pagination_size); break;
-
- }
-
- $offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
- $start = ($offset + 1);
- $end = min($count, (($start + $this->pagination_size) - 1));
+
+ $paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
+ $this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
+ $offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -125,6 +112,7 @@ class Journal extends Base {
or journal_origin regexp :journal_origin
or journal_ip regexp :journal_ip
or journal_entry regexp :journal_entry
+ order by journal_timestamp DESC,journal_serial DESC
limit {$this->pagination_size}
offset {$offset}";
@@ -145,8 +133,7 @@ class Journal extends Base {
$journal->addAttribute("start", $start);
$journal->addAttribute("end", $end);
$journal->addAttribute("page", $this->pagination_page);
-
- $_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
+
return $journal;
diff --git a/classes/LedgerService.php b/classes/LedgerService.php
new file mode 100644
index 0000000..78d7444
--- /dev/null
+++ b/classes/LedgerService.php
@@ -0,0 +1,68 @@
+500)throw new AppException('Enter a correction reason, up to 500 bytes.');return $reason;}
+ private function transaction(callable $work): void {
+ $this->requireAdministrator();$db=$this->connect();$db->beginTransaction();
+ try{$work($db);$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
+ }
+ private function lockedDue(PDO $db,int $club,int $due): array {
+ $this->club($club,$db,true);
+ $stmt=$db->prepare('SELECT * FROM club_dues WHERE due_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$due,$club]);$row=$stmt->fetch();
+ if(!$row)throw new AppException('Dues record not found in this club.',404);
+ $stmt=$db->prepare('SELECT COALESCE(SUM(amount_cents),0) FROM club_due_adjustments WHERE due_serial=?');$stmt->execute([$due]);$row['effective']=(int)$row['amount_cents']+(int)$stmt->fetchColumn();
+ $stmt=$db->prepare('SELECT COALESCE(SUM(p.amount_cents),0) FROM club_payments p WHERE p.due_serial=? AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)');$stmt->execute([$due]);$row['paid']=(int)$stmt->fetchColumn();return $row;
+ }
+ public function charge(int $club,int $membership,int $season,string $amount): void {
+ $cents=self::cents($amount);
+ $this->transaction(function(PDO $db)use($club,$membership,$season,$cents){
+ $this->club($club,$db,true);
+ $stmt=$db->prepare("SELECT membership_serial FROM club_memberships WHERE membership_serial=? AND club_serial=? AND membership_status='active' FOR UPDATE");$stmt->execute([$membership,$club]);if(!$stmt->fetch())throw new AppException('Select an active member of this club.',409);
+ $stmt=$db->prepare('SELECT season_serial FROM club_seasons WHERE season_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$season,$club]);if(!$stmt->fetch())throw new AppException('Select a season belonging to this club.',409);
+ $stmt=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,?)');$stmt->execute([$club,$membership,$season,$cents,$this->actor()]);
+ 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 {
+ $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.');
+ $this->transaction(function(PDO $db)use($club,$due,$cents,$paid,$key,$method,$reference){
+ $row=$this->lockedDue($db,$club,$due);
+ $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($cents>$row['effective']-$row['paid'])throw new AppException('Payment exceeds the outstanding balance.',409);
+ $stmt=$db->prepare('INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,recorded_by,payment_method,payment_reference) VALUES(?,?,?,?,?,?,?)');$stmt->execute([$due,$cents,$paid,$key,$this->actor(),$method,$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 {
+ $cents=self::cents($total);$reason=$this->reason($reason);$this->key($key);
+ $this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key){
+ $row=$this->lockedDue($db,$club,$due);$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($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);
+ $stmt=$db->prepare('INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,recorded_by,request_key) VALUES(?,?,?,?,?,?)');$stmt->execute([$due,$cents-$row['effective'],$cents,$reason,$this->actor(),$key]);
+ 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 {
+ $reason=$this->reason($reason);$this->key($key);
+ $this->transaction(function(PDO $db)use($club,$payment,$reason,$key){
+ $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']);
+ $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);}
+ $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]));
+ });
+ }
+ public function history(int $club): array {
+ $this->club($club);$result=[];$db=$this->connect();
+ $scope=$this->administrator()?'':' AND m.user_serial=?';$args=$this->administrator()?[$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();
+ $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;
+ }
+}
diff --git a/classes/MaintenanceService.php b/classes/MaintenanceService.php
new file mode 100644
index 0000000..1cd0171
--- /dev/null
+++ b/classes/MaintenanceService.php
@@ -0,0 +1,310 @@
+ ['min_range' => 1]]);
+ if ($id === false)
+ throw new AppException('A valid record ID is required.');
+ return $id;
+ }
+
+ public static function boolean(mixed $value): bool {
+ if (!is_scalar($value))
+ throw new AppException('A true or false value is required.');
+ $result = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+ if (!is_scalar($value) || $result === null || $value === '')
+ throw new AppException('A true or false value is required.');
+ return $result;
+ }
+
+ public static function usageRule(string $table, string $column): void {
+ // No current roster field uses dropdown IDs. Add explicit code-owned references
+ // here when such a feature is implemented; database values are never SQL/callbacks.
+ if ($table !== '*none' || $column !== '') {
+ throw new AppException('Only "No usage reference" is supported. Custom table names and callbacks are disabled.');
+ }
+ }
+
+ public function dropdownUsed(int $id, ?PDO $db = null): bool {
+ $stmt = ($db ?? $this->connect())->prepare('SELECT d.dropdown_default,t.dropdowntype_table, t.dropdowntype_column FROM dropdowns d JOIN dropdowntypes t ON t.dropdowntype_serial=d.dropdown_dropdowntype WHERE d.dropdown_serial=?');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ if (!$row)
+ throw new AppException('Dropdown not found.', 404);
+ self::usageRule((string) $row['dropdowntype_table'], (string) $row['dropdowntype_column']);
+ return (bool) $row['dropdown_default'];
+ }
+
+ public function deleteDropdown(int $id): void {
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $stmt = $db->prepare('SELECT dropdown_dropdowntype FROM dropdowns WHERE dropdown_serial=?');
+ $stmt->execute([$id]);
+ $type = $stmt->fetchColumn();
+ if (!$type)
+ throw new AppException('Dropdown not found.', 404);
+ $stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
+ $stmt->execute([$type]);
+ $stmt->fetch();
+ $stmt = $db->prepare('SELECT dropdown_default FROM dropdowns WHERE dropdown_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ if (!$row)
+ throw new AppException('Dropdown not found.', 404);
+ if ($row['dropdown_default'] || $this->dropdownUsed($id, $db))
+ throw new AppException('Choose another default before deleting this dropdown.', 409);
+ $stmt = $db->prepare('DELETE FROM dropdowns WHERE dropdown_serial=?');
+ $stmt->execute([$id]);
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+
+ public function deleteType(int $id): void {
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ if (!$stmt->fetch())
+ throw new AppException('Dropdown type not found.', 404);
+ $stmt = $db->prepare('SELECT dropdown_serial FROM dropdowns WHERE dropdown_dropdowntype=? LIMIT 1');
+ $stmt->execute([$id]);
+ if ($stmt->fetch())
+ throw new AppException('Remove the dropdown values before deleting their type.', 409);
+ $stmt = $db->prepare('DELETE FROM dropdowntypes WHERE dropdowntype_serial=?');
+ $stmt->execute([$id]);
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+
+ public function setDropdownDefault(int $id, int $type, bool $checked): void {
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
+ $stmt->execute([$type]);
+ if (!$stmt->fetch())
+ throw new AppException('Dropdown type not found.', 404);
+ $stmt = $db->prepare('SELECT dropdown_dropdowntype,dropdown_active FROM dropdowns WHERE dropdown_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ if (!$row || (int) $row['dropdown_dropdowntype'] !== $type)
+ throw new AppException('Dropdown does not belong to this type.', 409);
+ if ($checked && !$row['dropdown_active'])
+ throw new AppException('Activate the dropdown before selecting it as the default.', 409);
+ if ($checked) {
+ $stmt = $db->prepare('UPDATE dropdowns SET dropdown_default=0 WHERE dropdown_dropdowntype=?');
+ $stmt->execute([$type]);
+ }
+ $stmt = $db->prepare('UPDATE dropdowns SET dropdown_default=?, dropdown_changer=?,dropdown_changed=? WHERE dropdown_serial=?');
+ $stmt->execute([(int) $checked, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+
+ public function setDropdownActive(int $id, bool $checked): void {
+ $stmt = $this->connect()->prepare('UPDATE dropdowns SET dropdown_active=?,dropdown_default=IF(?=0,0,dropdown_default),dropdown_changer=?,dropdown_changed=? WHERE dropdown_serial=?');
+ $stmt->execute([(int) $checked, (int) $checked, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
+ }
+
+ 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);
+ }
+
+ private function guardAdministrator(array $admins, int $id, string $role, string $status, bool $deleting = false): void {
+ if ($deleting && $id === (int) ($_SESSION[self::USER_SERIAL] ?? 0))
+ 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))) {
+ throw new AppException('The last active administrator cannot be removed, disabled or demoted.', 409);
+ }
+ }
+
+ public function userUsed(int $id, ?PDO $db = null): bool {
+ $db ??= $this->connect();
+ $stmt = $db->prepare("SELECT COUNT(*) FROM notes WHERE note_type='User' AND note_reference=?");
+ $stmt->execute([$id]);
+ if ((int) $stmt->fetchColumn() > 0)
+ return true;
+ $stmt = $db->prepare('SELECT COUNT(*) FROM journal WHERE journal_origin=?');
+ $stmt->execute([(string) $id]);
+ if ((int) $stmt->fetchColumn() > 0)
+ return true;
+ // Roster tables are installed by the separate non-destructive roster migration.
+ $exists = $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='club_memberships'")->fetchColumn();
+ if ($exists) {
+ $stmt = $db->prepare('SELECT COUNT(*) FROM club_memberships WHERE user_serial=?');
+ $stmt->execute([$id]);
+ if ((int) $stmt->fetchColumn() > 0)
+ return true;
+ }
+ return false;
+ }
+
+ public function deleteUser(int $id): void {
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $admins = $this->lockAdministrators($db);
+ $stmt = $db->prepare('SELECT user_role,user_status FROM users WHERE user_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ $user = $stmt->fetch();
+ if (!$user)
+ throw new AppException('User not found.', 404);
+ if ($user['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
+ throw new AppException('Only a system administrator can modify that account.', 403);
+ $this->guardAdministrator($admins, $id, $user['user_role'], $user['user_status'], true);
+ if ($this->userUsed($id, $db))
+ throw new AppException('This user has recorded history. Disable the account to preserve its history.', 409);
+ $stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
+ $stmt->execute([$id]);
+ $stmt = $db->prepare('DELETE FROM users WHERE user_serial=?');
+ $stmt->execute([$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 {
+ $name = trim($input['user_name'] ?? '');
+ $email = trim($input['user_email'] ?? '');
+ $first = trim($input['user_first_name'] ?? '');
+ $last = trim($input['user_last_name'] ?? '');
+ $role = $input['user_role'] ?? '';
+ $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))
+ throw new AppException('Enter a valid username, first/last name, email and role.');
+ // Only an existing system administrator may assign that role.
+ if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
+ throw new AppException('Only a system administrator can assign that role.', 403);
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $admins = $this->lockAdministrators($db);
+ $stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ $old = $stmt->fetch();
+ if (!$old)
+ throw new AppException('User not found.', 404);
+ if ($old['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
+ throw new AppException('Only a system administrator can modify that account.', 403);
+ $this->guardAdministrator($admins, $id, $role, $status);
+ $revoke = $old['user_role'] !== $role || $old['user_status'] !== $status || $old['user_email'] !== $email || $old['user_name'] !== $name;
+ $stmt = $db->prepare('UPDATE users SET user_name=?,user_email=?,user_first_name=?,user_last_name=?,user_role=?,user_status=?,user_auth_version=user_auth_version+?,user_changer=?,user_changed=? WHERE user_serial=?');
+ $stmt->execute([$name, $email, $first, $last, $role, $status, (int) $revoke, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
+ if ($old['user_email'] !== $email) {
+ $stmt = $db->prepare('UPDATE users SET user_email_verified_at=NULL WHERE user_serial=?');
+ $stmt->execute([$id]);
+ }
+ if ($revoke) {
+ $stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
+ $stmt->execute([$id]);
+ $stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
+ $stmt->execute([$id]);
+ }
+ $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]]));
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+
+ public function setUserActive(int $id, bool $active): void {
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $admins = $this->lockAdministrators($db);
+ $stmt = $db->prepare('SELECT user_role FROM users WHERE user_serial=? FOR UPDATE');
+ $stmt->execute([$id]);
+ $role = $stmt->fetchColumn();
+ if (!$role)
+ throw new AppException('User not found.', 404);
+ if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
+ throw new AppException('Only a system administrator can modify that account.', 403);
+ $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->execute([$active ? 'active' : 'inactive', $id]);
+ $stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
+ $stmt->execute([$id]);
+ $stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
+ $stmt->execute([$id]);
+ 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 {
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $rows = $db->query('SELECT status_serial,status_default FROM statuses ORDER BY status_serial FOR UPDATE')->fetchAll(PDO::FETCH_KEY_PAIR);
+ if (!array_key_exists($id, $rows))
+ throw new AppException('Status not found.', 404);
+ if ($rows[$id])
+ throw new AppException('Choose another default before deleting this status.', 409);
+ // Current club membership statuses are explicit enums, not references to this list.
+ $stmt = $db->prepare('DELETE FROM statuses WHERE status_serial=?');
+ $stmt->execute([$id]);
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+
+ public function saveStatus(int $id, array $input): void {
+ $name = trim($input['status_name'] ?? '');
+ $classes = trim($input['status_classes'] ?? '');
+ $active = self::boolean($input['status_active'] ?? false);
+ $default = self::boolean($input['status_default'] ?? false);
+ if (!$name || strlen($name) > 100 || strlen($classes) > 100 || !preg_match('/\A[A-Za-z0-9_ -]*\z/', $classes))
+ throw new AppException('Enter a status name and valid CSS classes (up to 100 bytes).');
+ if ($default && !$active)
+ throw new AppException('The default status must be active.');
+ $db = $this->connect();
+ $db->beginTransaction();
+ try {
+ $db->query('SELECT status_serial FROM statuses ORDER BY status_serial FOR UPDATE')->fetchAll();
+ $stmt = $db->prepare('SELECT status_serial FROM statuses WHERE LOWER(status_name)=LOWER(?) AND status_serial<>?');
+ $stmt->execute([$name, $id]);
+ if ($stmt->fetch())
+ throw new AppException('A status with that name already exists.', 409);
+ if ($default)
+ $db->exec('UPDATE statuses SET status_default=0');
+ if ($id) {
+ $stmt = $db->prepare('SELECT status_serial FROM statuses WHERE status_serial=?');
+ $stmt->execute([$id]);
+ if (!$stmt->fetch())
+ throw new AppException('Status not found.', 404);
+ $stmt = $db->prepare('UPDATE statuses SET status_name=?,status_classes=?,status_active=?,status_default=?,status_changer=?,status_changed=? WHERE status_serial=?');
+ $stmt->execute([$name, $classes, (int) $active, (int) $default, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
+ } else {
+ $stmt = $db->prepare('INSERT INTO statuses(status_name,status_classes,status_active,status_default,status_creator,status_created) VALUES(?,?,?,?,?,?)');
+ $stmt->execute([$name, $classes, (int) $active, (int) $default, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp()]);
+ }
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction())
+ $db->rollBack();throw $e;
+ }
+ }
+}
diff --git a/classes/Metrics.php b/classes/Metrics.php
index 2b78b69..7dbad76 100644
--- a/classes/Metrics.php
+++ b/classes/Metrics.php
@@ -1,96 +1,5 @@
= (date(now()) - interval 6 day)
- group by day
- order by workorder_created desc";
-
- return $this->getTable("dailymetrics", $SQL);
-
- }
-
- // =====================================
- // Return a Monthly Call Metrics Object.
- // =====================================
-
- private function getMonthlyWorkOrderMetrics() {
-
- $SQL = "select monthname(workorder_created) as month,
- count(*) as count
- from view_workorders
- where date(workorder_created) >= (date(now()) - interval 6 month)
- group by month
- order by workorder_created desc";
-
- return $this->getTable("monthlymetrics", $SQL);
-
- }
-
- // =========================================
- // Return a Weekly Work Order Metrics Object
- // =========================================
-
- private function getWeeklyWorkOrderMetrics() {
-
- $SQL = "select week(workorder_created) as week,
- count(*) as count
- from view_workorders
- where date(workorder_created) >= (date(now()) - interval 6 week)
- group by week
- order by workorder_created desc";
-
- return $this->getTable("weeklymetrics", $SQL);
-
- }
-
- // =======================
- // View Work Order Metrics
- // =======================
-
- public function viewWorkOrderMetrics() {
-
- $dailymetrics = $this->getDailyWorkOrderMetrics();
- $weeklymetrics = $this->getWeeklyWorkOrderMetrics();
- $monthlymetrics = $this->getMonthlyWorkorderMetrics();
-
- $XSLParms["DAILYMETRICS"] = $dailymetrics->asXML();
- $XSLParms["WEEKLYMETRICS"] = $weeklymetrics->asXML();
- $XSLParms["MONTHLYMETRICS"] = $monthlymetrics->asXML();
-
- $content = $this->applyXSL("", $this->getXSL("viewWorkOrderMetrics"), $XSLParms);
-
- $this->displayContent($content);
-
- exit();
-
- }
-
+ public function viewRosterMetrics(){(new Roster())->home();}
}
-
-?>
diff --git a/classes/Notes.php b/classes/Notes.php
index 8eb4dd3..4f5b0f6 100644
--- a/classes/Notes.php
+++ b/classes/Notes.php
@@ -1,244 +1,35 @@
current_user_name = $_SESSION[self::USER_NAME] ?? "";
- $this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
+ public const VALID_TYPES=['User'];
+ public function addNote(){
+ if(($_POST['step']??'prompt')==='add'){$this->write(0,$_POST['note_type']??'',MaintenanceService::id($_POST['note_reference']??null),$_POST['note_text']??'');return;}
+ $type=$_POST['note_type']??'';$id=MaintenanceService::id($_POST['note_reference']??null);
+ if(!in_array($type,self::VALID_TYPES,true))throw new AppException('Unsupported note type.');
+ $stmt=$this->connect()->prepare('SELECT user_serial FROM users WHERE user_serial=?');$stmt->execute([$id]);if(!$stmt->fetch())throw new AppException('User not found.',404);
+ $this->displayContent($this->applyXSL('',$this->getXSL('addNote'),['NOTE_TYPE'=>$type,'NOTE_REFERENCE'=>$id]));
}
-
- // ========
- // Add Note
- // ========
-
- public function addNote() {
-
- $step = filter_input(INPUT_POST, "step") ?: "prompt";
-
- switch ($step) {
-
- case "prompt":
-
- $note_type = filter_input(INPUT_POST, "note_type") ?: "";
- $note_reference = filter_input(INPUT_POST, "note_reference", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- if (!in_array($note_type, self::VALID_TYPES) or empty($note_reference)) {
- $this->logError("Invalid Note Type ({$note_type} or Reference {$note_reference} " . __METHOD__);
- }
-
- $XSLParms["NOTE_TYPE"] = $note_type;
- $XSLParms["NOTE_REFERENCE"] = $note_reference;
-
- $content = $this->applyXSL("", $this->getXSL("addNote"), $XSLParms);
-
- $this->displayContent($content);
-
- break;
-
- case "add":
-
- $note_type = filter_input(INPUT_POST, "note_type") ?: "";
- $note_reference = filter_input(INPUT_POST, "note_reference", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $note_text = filter_input(INPUT_POST, "note_text") ?: "";
-
- $SQL = "insert into notes
-
- ( note_type,
- note_reference,
- note_text,
- note_origin )
-
- VALUES ( :note_type,
- :note_reference,
- :note_text,
- :note_origin )";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":note_type", $note_type);
- $statement->bindValue(":note_reference", $note_reference);
- $statement->bindValue(":note_text", $note_text);
- $statement->bindValue(":note_origin", $this->current_user_name);
-
- $statement->execute();
-
- break;
-
- default:
-
- $this->displayHome();
- }
+ public function editNote(){
+ $id=MaintenanceService::id($_POST['note_serial']??null);$note=$this->getNote($id);
+ if(!$note->count())throw new AppException('Note not found.',404);
+ if(($_POST['step']??'prompt')==='update'){$this->write($id,(string)$note->record->note_type,(int)$note->record->note_reference,$_POST['note_text']??'');return;}
+ $this->displayContent($this->applyXSL($note,$this->getXSL('editNote')));
}
-
- // ===========
- // Delete Note
- // ===========
-
- public function deleteNote() {
-
- $note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $note = $this->getNote($note_serial);
-
- if ($note->count() == 0) {
- $this->logError("Invalid Note ({$note_serial}). " . __METHOD__);
- }
-
- $SQL = "delete from notes
- where note_serial = {$note_serial}";
-
- $this->executeSQL($SQL);
+ 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);
}
-
- // =========
- // Edit Note
- // =========
-
- public function editNote() {
-
- $step = filter_input(INPUT_POST, "step") ?: "prompt";
-
- switch ($step) {
-
- case "prompt":
-
- $note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $note = $this->getNote($note_serial);
-
- if ($note->count() == 0) {
- $this->logError("Invalid Note ({$note_serial}). " . __METHOD__);
- }
-
- $XML = $this->mergeXML($note, "");
-
- $content = $this->applyXSL($XML, $this->getXSL("editNote"));
-
- $this->displayContent($content);
-
- break;
-
- case "update":
-
- $note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $note_text = filter_input(INPUT_POST, "note_text") ?: "";
-
- $SQL = "update notes
- set note_text = :note_text,
- note_origin = :note_origin
- where note_serial = :note_serial";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":note_serial", $note_serial);
- $statement->bindValue(":note_text", $note_text);
- $statement->bindValue(":note_origin", $this->current_user_name);
-
- $statement->execute();
-
- break;
-
- default:
-
- $this->displayHome();
- }
- }
-
- // ====================
- // Return a Note Object
- // ====================
-
- public function getNote($note_serial = 0) {
-
- $SQL = "select view_notes.*
- from view_notes
- where note_serial = {$note_serial}";
-
- return $this->getTable("notes", $SQL);
- }
-
- // =====================
- // Return a Notes Object
- // =====================
-
- public function getNotes($note_type = "", $note_reference = 0) {
-
- $SQL = "select view_notes.*
- from view_notes
- where note_type = '{$note_type}'
- and note_reference = {$note_reference}
- order by note_timestamp asc";
-
- return $this->getTable("notes", $SQL);
- }
-
- // =====================
- // Return a Notes Object
- // =====================
-
- public function getAllNotes($note_reference = 0) {
-
- $SQL = "select view_notes.*
- from view_notes
- where note_reference = {$note_reference}
- order by note_timestamp asc";
-
-// $this->debug($SQL);
-
- return $this->getTable("notes", $SQL);
- }
-
- // ==============
- // Add Note Quick
- // ==============
-
- public function addNoteQuick($note_type = "", $note_reference = 0, $note_text = "") {
-
- $SQL = "insert into notes
-
- ( note_type,
- note_reference,
- note_text,
- note_origin )
-
- VALUES ( :note_type,
- :note_reference,
- :note_text,
- :note_origin )";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":note_type", $note_type);
- $statement->bindValue(":note_reference", $note_reference);
- $statement->bindValue(":note_text", $note_text);
- $statement->bindValue(":note_origin", $this->current_user_name);
-
- $statement->execute();
+ 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).');
+ $db=$this->connect();$db->beginTransaction();
+ try{
+ $stmt=$db->prepare('SELECT user_serial FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$reference]);if(!$stmt->fetch())throw new AppException('The note must refer to an existing user.',409);
+ if($id){$stmt=$db->prepare('SELECT note_serial FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,$type,$reference]);if(!$stmt->fetch())throw new AppException('Note not found.',404);
+ $stmt=$db->prepare('UPDATE notes SET note_text=?,note_origin=? WHERE note_serial=?');$stmt->execute([$text,$_SESSION[self::USER_NAME]??'',$id]);
+ }else{$stmt=$db->prepare('INSERT INTO notes(note_type,note_reference,note_text,note_origin) VALUES(?,?,?,?)');$stmt->execute([$type,$reference,$text,$_SESSION[self::USER_NAME]??'']);$id=(int)$db->lastInsertId();}
+ $db->commit();return $id;
+ }catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
}
+ public function getNote($id=0){$stmt=$this->connect()->prepare('SELECT * FROM view_notes WHERE note_serial=?');$stmt->execute([(int)$id]);return $this->getTableXML('notes',$stmt);}
+ public function getNotes($type='',$reference=0){$stmt=$this->connect()->prepare('SELECT * FROM view_notes WHERE note_type=? AND note_reference=? ORDER BY note_timestamp DESC,note_serial DESC');$stmt->execute([$type,(int)$reference]);return $this->getTableXML('notes',$stmt);}
+ public function getAllNotes($reference=0){return $this->getNotes('User',$reference);}
+ public function addNoteQuick($type='',$reference=0,$text=''){return $this->write(0,$type,(int)$reference,$text);}
}
-
-?>
diff --git a/classes/Pagination.php b/classes/Pagination.php
new file mode 100644
index 0000000..14db1e9
--- /dev/null
+++ b/classes/Pagination.php
@@ -0,0 +1,14 @@
+1,'previous'=>$current-1,'next'=>$current+1,'last'=>$last,default=>$current};
+ $page=max(1,min($last,$page));$offset=($page-1)*$size;
+ return ['size'=>$size,'page'=>$page,'offset'=>$offset,'start'=>$count?$offset+1:0,'end'=>min($count,$offset+$size)];
+ }
+ public static function session(string $scope,int $count,int $size,string $action=''): array {
+ if(!is_array($_SESSION[Base::PAGINATION_PAGE]??null))$_SESSION[Base::PAGINATION_PAGE]=[];
+ $result=self::calculate($count,$size,(int)($_SESSION[Base::PAGINATION_PAGE][$scope]??1),$action);
+ $_SESSION[Base::PAGINATION_PAGE][$scope]=$result['page'];return $result;
+ }
+}
diff --git a/classes/Popovers.php b/classes/Popovers.php
index aa4091f..62b2845 100644
--- a/classes/Popovers.php
+++ b/classes/Popovers.php
@@ -28,7 +28,7 @@ class Popovers extends Base {
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
- $this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
+ $this->pagination_page = 1;
}
// ===========
@@ -229,7 +229,7 @@ class Popovers extends Base {
// Find Criteria
$find_popover = filter_input(INPUT_POST, "find_popover") ?: "";
- $find_words = $this->sanitizeString($find_popover);
+ $find_words = preg_quote(trim($find_popover));
// Show Per Page
@@ -259,21 +259,9 @@ class Popovers extends Base {
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
- switch ($pagination) {
-
- case "first" : $this->pagination_page = 1;
- break;
- case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
- break;
- case "next" : $this->pagination_page = ($this->pagination_page + 1);
- break;
- case "last" : $this->pagination_page = ($count / $this->pagination_size);
- break;
- }
-
- $offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
- $start = ($offset + 1);
- $end = min($count, (($start + $this->pagination_size) - 1));
+ $paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
+ $this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
+ $offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
// Data
@@ -281,6 +269,7 @@ class Popovers extends Base {
from view_popovers
where popover_name regexp :popover_name
or popover_title regexp :popover_title
+ order by popover_serial
limit {$this->pagination_size}
offset {$offset}";
@@ -300,7 +289,6 @@ class Popovers extends Base {
$popovers->addAttribute("end", $end);
$popovers->addAttribute("page", $this->pagination_page);
- $_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
return $popovers;
}
diff --git a/classes/Reports.php b/classes/Reports.php
index b399320..7fbf720 100644
--- a/classes/Reports.php
+++ b/classes/Reports.php
@@ -1,479 +1,5 @@
current_user_name = $_SESSION[self::USER_NAME] ?? "";
- $this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
-
- $this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
- }
-
- // ==========
- // Add Report
- // ==========
-
- public function addReport() {
-
- $step = filter_input(INPUT_POST, "step") ?: "prompt";
-
- switch ($step) {
-
- case "prompt":
-
- $constants = $this->getConstants();
-
- $XML = $this->mergeXML($constants, "");
-
- $content = $this->applyXSL($XML, $this->getXSL("addReport"));
-
- $this->displayContent($content);
-
- break;
-
- case "add":
-
- $report_name = filter_input(INPUT_POST, "report_name") ?: "";
- $report_class = filter_input(INPUT_POST, "report_class") ?: "";
- $report_type = filter_input(INPUT_POST, "report_type") ?: "Report";
- $report_description = filter_input(INPUT_POST, "report_description") ?: "";
- $report_target = filter_input(INPUT_POST, "report_target") ?: "";
-
- $report_class = trim(preg_replace("/\s+/", " ", $report_class));
-
- $SQL = "insert into reports
-
- ( report_class,
- report_type,
- report_name,
- report_description,
- report_target )
-
- VALUES ( :report_class,
- :report_type,
- :report_name,
- :report_description,
- :report_target)";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":report_class", $report_class);
- $statement->bindValue(":report_type", $report_type);
- $statement->bindValue(":report_name", $report_name);
- $statement->bindValue(":report_description", $report_description);
- $statement->bindValue(":report_target", $report_target);
-
- $statement->execute();
-
- break;
-
- default:
-
- $this->displayHome();
- }
- }
-
- // =============
- // Delete Report
- // =============
-
- public function deleteReport() {
-
- $report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $reports = $this->getReport($report_serial);
-
- if ($reports->count() == 0) {
- $this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
- }
-
- $SQL = "delete from reports
- where report_serial = {$report_serial}";
-
- $this->executeSQL($SQL);
- }
-
- // ===========
- // Edit Report
- // ===========
-
- public function editReport() {
-
- $step = filter_input(INPUT_POST, "step") ?: "prompt";
-
- switch ($step) {
-
- case "prompt":
-
- $report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-
- $reports = $this->getReport($report_serial);
-
- if ($reports->count() == 0) {
- $this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
- }
-
- $constants = $this->getConstants();
-
- $XML = $this->mergeXML($reports, "");
- $XML = $this->mergeXML($constants, $XML);
-
- $content = $this->applyXSL($XML, $this->getXSL("editReport"));
-
- $this->displayContent($content);
-
- break;
-
- case "update":
-
- $report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $report_name = filter_input(INPUT_POST, "report_name") ?: "";
- $report_class = filter_input(INPUT_POST, "report_class") ?: "";
- $report_type = filter_input(INPUT_POST, "report_type") ?: "Report";
- $report_description = filter_input(INPUT_POST, "report_description") ?: "";
- $report_target = filter_input(INPUT_POST, "report_target") ?: "";
-
- $report_class = trim(preg_replace("/\s+/", " ", $report_class));
-
- $reports = $this->getReport($report_serial);
-
- if ($reports->count() == 0) {
- $this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
- }
-
- $SQL = "update reports
- set report_class = :report_class,
- report_type = :report_type,
- report_name = :report_name,
- report_description = :report_description,
- report_target = :report_target
- where report_serial = :report_serial";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":report_serial", $report_serial);
- $statement->bindValue(":report_class", $report_class);
- $statement->bindValue(":report_type", $report_type);
- $statement->bindValue(":report_name", $report_name);
- $statement->bindValue(":report_description", $report_description);
- $statement->bindValue(":report_target", $report_target);
-
- $statement->execute();
-
- break;
-
- default:
-
- $this->displayHome();
- }
- }
-
- // ======================
- // Return a Report Object
- // ======================
-
- public function getReport($report_serial = 0) {
-
- $SQL = "select view_reports.*
- from view_reports
- where report_serial = {$report_serial}";
-
- return $this->getTable("reports", $SQL);
- }
-
- // ===========================================
- // Return the report_serial for a report_class
- // ===========================================
-
- public function getReportSerial($report_class = "") {
-
- $SQL = "select view_reports.report_serial
- from view_reports
- where report_class = '{$report_class}'";
-
- $reports = $this->getTable("reports", $SQL);
-
- return (int) ($reports->record->report_serial ?? 0);
- }
-
- // =======================
- // Return a Reports Object
- // =======================
-
- public function getReports() {
-
- $SQL = "select view_reports.*
- from view_reports";
-
- return $this->getTable("reports", $SQL);
- }
-
- // ============================
- // Return a Reports Page Object
- // ============================
-
- public function getReportsPage() {
-
- // Find Criteria
-
- $find_report = filter_input(INPUT_POST, "find_report") ?: "";
- $find_words = $this->sanitizeString($find_report);
-
-// $find_words = implode("|", explode(" ", $find_report));
- // Show Per Page
-
- $this->pagination_size = filter_input(INPUT_POST, "reports_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
-
- // Record Count
-
- $connection = $this->connect();
-
- $SQL = "select count(*) as count
- from view_reports
- where (report_class regexp :report_class
- or report_name regexp :report_name
- or report_type regexp :report_type
- or report_description regexp :report_description)";
-
- $statement = $connection->prepare($SQL);
-
- $statement->bindValue(":report_class", $find_words);
- $statement->bindValue(":report_name", $find_words);
- $statement->bindValue(":report_type", $find_words);
- $statement->bindValue(":report_description", $find_words);
-
- $statement->execute();
-
- $recordset = $statement->fetch(PDO::FETCH_ASSOC);
-
- $count = (count($recordset) > 0) ? (int) $recordset["count"] : (int) 0;
-
- // Pagination
-
- $pagination = filter_input(INPUT_POST, "pagination") ?: "";
-
- switch ($pagination) {
-
- case "first" : $this->pagination_page = 1;
- break;
- case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
- break;
- case "next" : $this->pagination_page = ($this->pagination_page + 1);
- break;
- case "last" : $this->pagination_page = ($count / $this->pagination_size);
- break;
- }
-
- $offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
- $start = ($offset + 1);
- $end = min($count, (($start + $this->pagination_size) - 1));
-
- // Data
-
- $SQL = "select view_reports.*
- from view_reports
- where (report_class regexp :report_class
- or report_name regexp :report_name
- or report_type regexp :report_type
- or report_description regexp :report_description)
- limit {$this->pagination_size}
- offset {$offset}";
-
- $statement = $connection->prepare($SQL);
-
- $statement->bindValue(":report_class", $find_words);
- $statement->bindValue(":report_name", $find_words);
- $statement->bindValue(":report_type", $find_words);
- $statement->bindValue(":report_description", $find_words);
-
- $statement->execute();
-
- $reports = $this->getTableXML("reports", $statement);
-
- // Insert Pagination Attributes
-
- $reports->addAttribute("count", $count);
- $reports->addAttribute("start", $start);
- $reports->addAttribute("end", $end);
- $reports->addAttribute("page", $this->pagination_page);
-
- $_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
-
- return $reports;
- }
-
- // ==================================
- // Return a Select Report Page Object
- // ==================================
-
- public function getSelectReportPage() {
-
- // Find Criteria
-
- $select_report = filter_input(INPUT_POST, "select_report") ?: "";
- $find_words = $this->sanitizeString($select_report);
-
- // Show Per Page
-
- $this->pagination_size = filter_input(INPUT_POST, "reports_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
-
- // Record Count
-
- $connection = $this->connect();
-
- $SQL = "select count(*) as count
- from view_reports
- where report_name regexp :report_name
- or report_description regexp :report_description";
-
- $statement = $connection->prepare($SQL);
-
- $statement->bindValue(":report_name", $find_words);
- $statement->bindValue(":report_description", $find_words);
-
- $statement->execute();
-
- $recordset = $statement->fetch(PDO::FETCH_ASSOC);
-
- $count = (count($recordset) > 0) ? (int) $recordset["count"] : (int) 0;
-
- // Pagination
-
- $pagination = filter_input(INPUT_POST, "pagination") ?: "";
-
- switch ($pagination) {
-
- case "first" : $this->pagination_page = 1;
- break;
- case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
- break;
- case "next" : $this->pagination_page = ($this->pagination_page + 1);
- break;
- case "last" : $this->pagination_page = ($count / $this->pagination_size);
- break;
- }
-
- $offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
- $start = ($offset + 1);
- $end = min($count, (($start + $this->pagination_size) - 1));
-
- // Data
-
- $SQL = "select view_reports.*
- from view_reports
- where report_name regexp :report_name
- or report_description regexp :report_description
- limit {$this->pagination_size}
- offset {$offset}";
-
- $statement = $connection->prepare($SQL);
-
- $statement->bindValue(":report_name", $find_words);
- $statement->bindValue(":report_description", $find_words);
-
- $statement->execute();
-
- $reports = $this->getTableXML("reports", $statement);
-
- // Insert Pagination Attributes
-
- $reports->addAttribute("count", $count);
- $reports->addAttribute("start", $start);
- $reports->addAttribute("end", $end);
- $reports->addAttribute("page", $this->pagination_page);
-
- $_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
-
- return $reports;
- }
-
- // ====================================
- // Determine if the Report Class Exists
- // ====================================
-
- public function reportExists() {
-
- $exists = false;
-
- $report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
- $report_class = filter_input(INPUT_POST, "report_class") ?: "";
-
- $report_class = trim(preg_replace("/\s+/", " ", $report_class));
-
- $SQL = "select report_serial
- from view_reports
- where report_serial != :report_serial
- and lower(report_class) = :report_class";
-
- $statement = $this->connect()->prepare($SQL);
-
- $statement->bindValue(":report_serial", $report_serial);
- $statement->bindValue(":report_class", $report_class);
-
- $statement->execute();
-
- $recordset = $statement->fetchAll(PDO::FETCH_ASSOC);
-
- $exists = (count($recordset) > 0) ? true : false;
-
- echo json_encode($exists);
- }
-
- // ================
- // Select a Report.
- // ================
-
- public function selectReport() {
-
- $reports = $this->getSelectReportPage();
- $popovers = (new Popovers())->getPopovers();
-
- $XML = $this->mergeXML($reports, "");
- $XML = $this->mergeXML($popovers, $XML);
-
- $content = $this->applyXSL($XML, $this->getXSL("selectReport"));
-
- $this->displayContent($content);
- }
-
- // =============
- // Setup Reports
- // =============
-
- public function setupReports() {
-
- $reports = $this->getReportsPage();
- $popovers = (new Popovers())->getPopovers();
-
- $XML = $this->mergeXML($reports, "");
- $XML = $this->mergeXML($popovers, $XML);
-
- $content = $this->applyXSL($XML, $this->getXSL("setupReports"));
-
- $this->displayContent($content);
- }
+ public function selectReport(){(new RosterReports())->catalog();}
+ public function setupReports(){(new RosterReports())->catalog();}
}
-
-?>
diff --git a/classes/Roster.php b/classes/Roster.php
new file mode 100644
index 0000000..7d28818
--- /dev/null
+++ b/classes/Roster.php
@@ -0,0 +1,97 @@
+';
+ }
+ private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
+ $id=$name.'_'.bin2hex(random_bytes(3));
+ return '';
+ }
+ public function help(): void {
+ $this->displayContent('
Using your club workspace
Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.
Site administrators create clubs, assign existing accounts, add seasons, and record dues and received payments. Contact your club administrator for membership or account assistance.
');
+ }
+ public function home(): void {
+ $service=new RosterService();$html='
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/'),200,'Valid user note is saved')
+ expect(app.post('Users.deleteUser',user_serial=member['user_serial']),409,'User with note history cannot be deleted')
+ body=expect(app.request(data={'action':'Users.userSummary','user_serial':member['user_serial'],'csrf_token':csrf}),200,'User summary no longer depends on subscriptions')
+ check(b'<script>text only</script>' in body,'Notes are escaped in the summary')
+ expect(app.post('Users.userActive',user_serial=member['user_serial']),200,'User usage check no longer queries requests')
+ expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='North Woods'),200,'First club can be created')
+ club_a=app.fixture('inspect')['clubs'][0]['club_serial']
+ expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='South Woods'),200,'Second club can be created')
+ club_b=app.fixture('inspect')['clubs'][-1]['club_serial']
+ for club in [club_a,club_b]:
+ expect(app.post('Roster.workspace',step='season',club_serial=club,season_name='2026 Season',starts_on='2026-01-01',ends_on='2026-12-31'),200,'Club season can be created')
+ expect(app.post('Roster.workspace',step='season',club_serial=club_a,season_name='Invalid',starts_on='2026-12-31',ends_on='2026-01-01'),422,'Reversed season dates are rejected')
+ expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),409,'Unverified accounts cannot receive active memberships')
+ app.fixture('verifyEmail','member_one')
+ expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),200,'Account can be assigned to a club')
+ expect(app.post('Roster.workspace',step='member',club_serial=club_a,identifier='member_one',membership_status='active',joined_on='2026-01-01'),409,'Duplicate club membership is rejected')
+ state=app.fixture('inspect');membership=state['club_memberships'][0]['membership_serial'];season_a=state['club_seasons'][0]['season_serial'];season_b=state['club_seasons'][1]['season_serial']
+ expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_b,amount='100.00'),409,'Cross-club season cannot be used for dues')
+ expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.001'),422,'Money rejects fractional cents')
+ expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.00'),200,'Season dues can be recorded')
+ expect(app.post('Roster.workspace',step='charge',club_serial=club_a,membership_serial=membership,season_serial=season_a,amount='100.00'),409,'Duplicate season dues are rejected')
+ due=app.fixture('inspect')['club_dues'][0]['due_serial'];key=secrets.token_hex(32)
+ payment=dict(step='payment',club_serial=club_a,due_serial=due,amount='3.14',paid_on='2026-01-02',request_key=key)
+ expect(app.post('Roster.workspace',**payment),200,'Received payment can be recorded')
+ expect(app.post('Roster.workspace',**payment),200,'Repeated payment submission is idempotent')
+ check(len(app.fixture('inspect')['club_payments'])==1,'Payment retry does not double-charge the ledger')
+ expect(app.post('Roster.workspace',**{**payment,'amount':'100.00','request_key':secrets.token_hex(32)}),409,'Payment above balance is rejected')
+ for action,signature in [('csv',b'Club,Username'),('excel',b'PK'),('pdf',b'%PDF')]:
+ body=expect(app.request(data={'action':'RosterReports.'+action,'club_serial':club_a,'csrf_token':csrf}),200,action+' report downloads successfully')
+ check(body.startswith(signature),action+' report has the correct file format')
+ if action=='excel':
+ with zipfile.ZipFile(io.BytesIO(body)) as archive:
+ root=ElementTree.fromstring(archive.read('xl/worksheets/sheet1.xml'))
+ check(not root.findall('.//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}f'),'Spreadsheet cells do not contain formulas')
+ body=expect(app.post('Users.editUser',step='update',user_serial=member['user_serial'],user_name='member_one',user_first_name='=HYPERLINK("unsafe")',user_last_name='Updated',user_email='member@example.test',user_role='User',user_active='true'),200,'Administrator can edit first and last names')
+ body=app.request(data={'action':'RosterReports.csv','club_serial':club_a,'csrf_token':csrf})[1]
+ check(b"'=HYPERLINK" in body,'CSV formula text is neutralized')
+ # 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
+ # account through signup and assign it separately for isolation tests.
+ app.logout();_,signup,_=app.request('/?Xaction=signup')
+ 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.login();csrf=app.token
+ 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.logout();app.login('http@example.test','HTTP long password!')
+ body=expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),200,'Member can view assigned club')
+ check(b'South Woods' not in body,'Other clubs are absent from member navigation')
+ check(b'$100.00' not in body,'Member cannot view another member dues')
+ expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_b)),403,'Member cannot access another club by changing its ID')
+ expect(app.post('Roster.workspace',step='club',club_serial=0,club_name='Unauthorized'),403,'Member cannot create clubs')
+ expect(app.post('RosterReports.csv',club_serial=club_a),403,'Member cannot download administrator exports')
+ expect(app.post('Users.deleteUser',user_serial=member['user_serial']),403,'Member cannot delete accounts')
+ # Keep that member session alive while a separate administrator session changes
+ # membership/club status; access must be rechecked on each request.
+ member_client=app.client
+ import urllib.request,http.cookiejar
+ app.client=urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
+ app.login();csrf=app.token;admin_client=app.client
+ http_membership=next(row['membership_serial'] for row in app.fixture('inspect')['club_memberships'] if row['user_serial']==next(user['user_serial'] for user in app.fixture('inspect')['users'] if user['user_name']=='httpuser'))
+ expect(app.post('Roster.workspace',step='member',club_serial=club_a,membership_serial=http_membership,membership_status='inactive',joined_on='2026-01-01'),200,'Membership can be made inactive while preserving history')
+ app.client=member_client
+ expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),403,'Inactive membership immediately loses club access in an existing session')
+ app.client=admin_client
+ app.post('Roster.workspace',step='member',club_serial=club_a,membership_serial=http_membership,membership_status='active',joined_on='2026-01-01')
+ expect(app.post('Roster.workspace',step='club',club_serial=club_a,club_name='North Woods',club_status='archived'),200,'Club can be archived without deleting its history')
+ app.client=member_client
+ expect(app.request('/?Xaction=Roster.workspace&club_serial='+str(club_a)),403,'Archived club is inaccessible to ordinary members')
+ app.client=admin_client
+ expect(app.post('Roster.workspace',step='club',club_serial=club_a,club_name='North Woods',club_status='active'),200,'Archived club can be reactivated')
+ check(len(app.fixture('inspect')['club_payments'])==1,'Archiving and reactivation preserve the payment ledger')
+ body=expect(app.request(data={'action':'Journal.viewJournal','csrf_token':csrf}),200,'Journal page loads after authentication events')
+ check(b'logged in' in body,'Authentication writes journal entries')
+ for action in ['Users.setupUsers','Dropdowns.setupDropdowntypes','Statuses.setupStatuses','Alerts.setupAlerts','Popovers.setupPopovers','Journal.viewJournal']:
+ expect(app.request(data={'action':action,'pagination':'last','csrf_token':csrf}),200,action+' handles last page without SQL errors')
+ body=expect(app.post('getZipcode',zip_code='12345'),200,'ZIP lookup uses a valid route and JSON response')
+ json.loads(body)
+ app.fixture('paging')
+ from lxml import html as parser
+ import re
+ response=app.request(data={'action':'Popovers.setupPopovers','pagination':'last','csrf_token':csrf})
+ body=expect(response,200,'Last-page query works with 41 real records')
+ text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
+ check('Showing 41 to 41 of 41' in text,'Last page starts at record 41 rather than record 22')
+ body=app.request(data={'action':'Users.setupUsers','csrf_token':csrf})[1]
+ text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
+ check('Showing 1 to 20 of 44' in text,'User pagination is independent of popover pagination')
+ body=app.request(data={'action':'Users.setupUsers','find_user':'Family41','csrf_token':csrf})[1]
+ check(b'Family41' in body and b'paging40@example.test' not in body,'Users can be searched by last name')
+ body=app.request(data={'action':'Popovers.setupPopovers','pagination':'first','popovers_per_page':'40','csrf_token':csrf})[1]
+ text=re.sub(r'\s+',' ',parser.fromstring(body).text_content())
+ check('Showing 1 to 40 of 41' in text,'Per-page selection is respected')
+ expect(app.post('Journal.clearJournal'),404,'Journal-clearing endpoint is no longer routable')
+ expect(app.post('Roster.workspace',step='adjust',club_serial=club_a,due_serial=due,corrected_amount='90.00',reason='HTTP correction',request_key=secrets.token_hex(32)),200,'Dues corrections work through the protected HTTP route')
+ expect(app.post('Roster.workspace',step='payment',club_serial=club_a,due_serial=due,amount='1.00',paid_on='2026-01-02',request_key=secrets.token_hex(32),payment_method='check',payment_reference='=SUM(1,1)'),200,'Payment references and methods are saved through HTTP')
+ new_payment=app.fixture('inspect')['club_payments'][-1]['payment_serial']
+ expect(app.post('Roster.workspace',step='reverse',club_serial=club_a,payment_serial=new_payment,reason='HTTP reversal',request_key=secrets.token_hex(32)),200,'Payment reversals work through the protected HTTP route')
+ body=expect(app.request(data={'action':'RosterReports.ledger','club_serial':club_a,'csrf_token':csrf}),200,'Financial history exports through HTTP')
+ check(b"'=SUM(1,1)" in body,'Financial CSV neutralizes formula text in payment references')
+ check(b'HTTP correction' in body and b'HTTP reversal' in body,'Financial CSV includes correction and reversal reasons')
+ body=expect(app.request('/?Xaction=verifyemail'),200,'Public email verification page renders')
+ expect(app.request(data={'action':'verifyemail','step':'confirm','token':'0'*64,'user_password':'wrong'}),403,'Email verification rejects a missing CSRF token')
+ headers=app.request()[2]
+ check("script-src 'self'" in headers['Content-Security-Policy'],'Application sends a CSP without inline-script permission')
+
+ print('\n'.join('PASS: '+message for message in checks))
+ print(f'{len(checks)} project HTTP checks passed.')
+finally:app.close()
diff --git a/tests/run.py b/tests/run.py
new file mode 100644
index 0000000..69c9541
--- /dev/null
+++ b/tests/run.py
@@ -0,0 +1,7 @@
+import json,os,subprocess,sys
+from pathlib import Path
+ROOT=Path(__file__).resolve().parents[1]
+php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'))
+if not os.environ.get('HCR_TEST_DSN'):raise SystemExit('Configure an isolated HCR_TEST_DSN before running integration tests.')
+for suite in ['tests/source_checks.py','tests/auth_integration.php','tests/maintenance_unit.php','tests/auth_http.py','tests/project_http.py','tests/production_runner.py','tests/concurrency.py','tests/browser_runner.py','tests/deployment_tools.py']:
+ subprocess.run((php if suite.endswith('.php') else [sys.executable])+[suite],cwd=ROOT,check=True)
diff --git a/tests/runtime_privileges.php b/tests/runtime_privileges.php
new file mode 100644
index 0000000..0181db8
--- /dev/null
+++ b/tests/runtime_privileges.php
@@ -0,0 +1,17 @@
+getSettings();$name=(string)$settings->DatabaseName;
+if(!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/',$name))throw new RuntimeException('Disposable database required.');
+$db=(new Base())->connect();$account='hcr_check_'.bin2hex(random_bytes(6));$password=bin2hex(random_bytes(24));$identity=$db->quote($account)."@'%'";
+$db->exec('CREATE USER '.$identity.' IDENTIFIED BY '.$db->quote($password));
+try{
+ $sql=file_get_contents(__DIR__.'/../deploy/runtime-grants.sql.example');$sql=preg_replace('/^--.*$/m','',$sql);$sql=str_replace(['huntingclubroster',"'hcr_runtime'@'app-host.example'"],[$name,$identity],$sql);
+ foreach(explode(';',$sql) as $statement)if(trim($statement))$db->exec($statement);
+ $limited=new PDO('mysql:host='.$settings->DatabaseServer.';port='.(int)$settings->DatabasePort.';dbname='.$name,$account,$password,[PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]);
+ $limited->beginTransaction();Audit::required($limited,1,'runtime privilege verification');$limited->commit();$checks=1;
+ foreach(['DELETE FROM journal','UPDATE club_payments SET amount_cents=amount_cents','DELETE FROM club_dues','ALTER TABLE clubs ADD COLUMN forbidden INT'] as $statement){
+ try{$limited->exec($statement);throw new RuntimeException('A forbidden runtime operation was allowed.');}catch(PDOException $e){if(!in_array((int)($e->errorInfo[1]??0),[1142,1143],true))throw $e;$checks++;}
+ }
+ echo "$checks runtime privilege checks passed: audit inserts work; history deletion, payment edits, and schema changes are denied.\n";
+}finally{$db->exec('DROP USER '.$identity);}
diff --git a/tests/source_checks.py b/tests/source_checks.py
new file mode 100644
index 0000000..8e66d56
--- /dev/null
+++ b/tests/source_checks.py
@@ -0,0 +1,20 @@
+import base64,hashlib,json,os,subprocess,sys
+from pathlib import Path
+from lxml import etree
+ROOT=Path(__file__).resolve().parents[1]
+php=json.loads(os.environ.get('PHP_TEST_COMMAND','["php"]'));node=os.environ.get('NODE_TEST_COMMAND','node')
+files=[ROOT/'main.php']+[f for directory in ['classes','bin','tests'] for f in (ROOT/directory).glob('*.php')]
+for file in files:
+ result=subprocess.run(php+['-l',str(file)],cwd=ROOT,capture_output=True,text=True)
+ if result.returncode:raise RuntimeError(result.stdout+result.stderr)
+scripts=list((ROOT/'js').glob('*.js'))
+for file in scripts:
+ result=subprocess.run([node,'--check',str(file)],capture_output=True,text=True)
+ if result.returncode:raise RuntimeError(result.stdout+result.stderr)
+styles=list((ROOT/'xsl').glob('*.xsl'))
+for file in styles:etree.XSLT(etree.parse(str(file)))
+dependency=json.loads((ROOT/'browser-dependencies.json').read_text())['jquery']
+assert base64.b64encode(hashlib.sha256((ROOT/dependency['file']).read_bytes()).digest()).decode()==dependency['sha256_base64']
+assert not (ROOT/'js/jquery-3.2.0.min.js').exists()
+assert all('jquery-3.2.0' not in file.read_text(encoding='utf-8-sig') for file in styles)
+print(f'Source checks passed: {len(files)} PHP files, {len(scripts)} JavaScript files, {len(styles)} XSL stylesheets, and jQuery integrity.')
diff --git a/web.config b/web.config
index dc42a3c..fe5c013 100644
--- a/web.config
+++ b/web.config
@@ -10,6 +10,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/xsl/addDropdowntype.xsl b/xsl/addDropdowntype.xsl
index 7ca9032..60bd0a6 100644
--- a/xsl/addDropdowntype.xsl
+++ b/xsl/addDropdowntype.xsl
@@ -1,67 +1,70 @@
-
+
-
+
- 1.0
- Add Dropdown Type
- addDropdowntype
+ 1.0
+ Add Dropdown Type
+ addDropdowntype
-
-
-
+
+
+
+
+
A simpler way to keep your hunting club connected, organized, and ready for what’s next.
+
A simpler way to keep your hunting club connected, organized, and ready for what's next.
-
Your account has been created. You can now sign in. Your club administrator manages membership and access.
+
Your account has been created. You can now sign in. Verify your email before club membership can be activated. Check your email, or request a verification link. Your club administrator manages membership and access.